From d4bc6d753169aeecbcf8afb51acb565395932b22 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 15 Jan 2026 09:31:27 +0100 Subject: [PATCH] feat(cloudflare): Add TLS/SSL, records and email security checks for zones (#9424) Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com> --- .github/labeler.yml | 15 +- docs/introduction.mdx | 1 + .../cloudflare/getting-started-cloudflare.mdx | 4 + prowler/CHANGELOG.md | 1 + .../cloudflare/cloudflare_provider.py | 10 +- .../cloudflare/services/dns/__init__.py | 0 .../cloudflare/services/dns/dns_client.py | 4 + .../cloudflare/services/dns/dns_service.py | 64 ++ .../__init__.py | 0 ...matic_https_rewrites_enabled.metadata.json | 36 + .../zone_automatic_https_rewrites_enabled.py | 45 ++ .../zone_dnssec_enabled.metadata.json | 1 + .../__init__.py | 0 ...ne_email_obfuscation_enabled.metadata.json | 36 + .../zone_email_obfuscation_enabled.py | 43 ++ .../zone_hsts_enabled.metadata.json | 1 + .../zone_https_redirect_enabled.metadata.json | 4 +- .../zone_min_tls_version_secure.metadata.json | 1 + .../zone/zone_record_caa_exists/__init__.py | 0 .../zone_record_caa_exists.metadata.json | 36 + .../zone_record_caa_exists.py | 82 +++ .../zone/zone_record_dkim_exists/__init__.py | 0 .../zone_record_dkim_exists.metadata.json | 36 + .../zone_record_dkim_exists.py | 116 ++++ .../zone/zone_record_dmarc_exists/__init__.py | 0 .../zone_record_dmarc_exists.metadata.json | 36 + .../zone_record_dmarc_exists.py | 88 +++ .../zone/zone_record_spf_exists/__init__.py | 0 .../zone_record_spf_exists.metadata.json | 36 + .../zone_record_spf_exists.py | 68 ++ .../__init__.py | 0 ...curity_under_attack_disabled.metadata.json | 37 + .../zone_security_under_attack_disabled.py | 47 ++ .../cloudflare/services/zone/zone_service.py | 27 +- .../zone_ssl_strict.metadata.json | 1 + .../zone/zone_tls_1_3_enabled/__init__.py | 0 .../zone_tls_1_3_enabled.metadata.json | 36 + .../zone_tls_1_3_enabled.py | 39 ++ .../zone_universal_ssl_enabled/__init__.py | 0 .../zone_universal_ssl_enabled.metadata.json | 36 + .../zone_universal_ssl_enabled.py | 42 ++ .../cloudflare/cloudflare_provider_test.py | 8 +- .../services/dns/dns_service_test.py | 119 ++++ ...e_automatic_https_rewrites_enabled_test.py | 143 ++++ .../zone_email_obfuscation_enabled_test.py | 139 ++++ .../zone_record_caa_exists_test.py | 323 +++++++++ .../zone_record_dkim_exists_test.py | 646 ++++++++++++++++++ .../zone_record_dmarc_exists_test.py | 417 +++++++++++ .../zone_record_spf_exists_test.py | 315 +++++++++ ...one_security_under_attack_disabled_test.py | 210 ++++++ .../zone_tls_1_3_enabled_test.py | 173 +++++ .../zone_universal_ssl_enabled_test.py | 111 +++ 52 files changed, 3613 insertions(+), 20 deletions(-) create mode 100644 prowler/providers/cloudflare/services/dns/__init__.py create mode 100644 prowler/providers/cloudflare/services/dns/dns_client.py create mode 100644 prowler/providers/cloudflare/services/dns/dns_service.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_caa_exists/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_spf_exists/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/__init__.py create mode 100644 prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.metadata.json create mode 100644 prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.py create mode 100644 tests/providers/cloudflare/services/dns/dns_service_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled_test.py create mode 100644 tests/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled_test.py diff --git a/.github/labeler.yml b/.github/labeler.yml index fa2c7981f9..7e87c1a6a3 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -46,12 +46,17 @@ provider/oci: - changed-files: - any-glob-to-any-file: "prowler/providers/oraclecloud/**" - any-glob-to-any-file: "tests/providers/oraclecloud/**" - + provider/alibabacloud: - changed-files: - any-glob-to-any-file: "prowler/providers/alibabacloud/**" - any-glob-to-any-file: "tests/providers/alibabacloud/**" +provider/cloudflare: + - changed-files: + - any-glob-to-any-file: "prowler/providers/cloudflare/**" + - any-glob-to-any-file: "tests/providers/cloudflare/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" @@ -67,15 +72,21 @@ mutelist: - any-glob-to-any-file: "prowler/providers/azure/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/gcp/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/kubernetes/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/m365/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/mongodbatlas/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/oraclecloud/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**" - any-glob-to-any-file: "tests/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/gcp/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/kubernetes/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/m365/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/mongodbatlas/lib/mutelist/**" - - any-glob-to-any-file: "tests/providers/oci/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/oraclecloud/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**" integration/s3: - changed-files: diff --git a/docs/introduction.mdx b/docs/introduction.mdx index 928a527a88..6d62c07e5b 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -32,6 +32,7 @@ The supported providers right now are: | [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI | | [Github](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI | | [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | +| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI | | [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | | [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | | [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx index e32258eda3..77a7a36f04 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -2,6 +2,10 @@ title: 'Getting Started with Cloudflare' --- +import { VersionBadge } from "/snippets/version-badge.mdx"; + + + Prowler for Cloudflare allows you to scan your Cloudflare zones for security misconfigurations, including SSL/TLS settings, DNSSEC, HSTS, and more. ## Prerequisites diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index dca740ab56..a8c306af67 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `Cloudflare` provider with critical security checks [(#9423)](https://github.com/prowler-cloud/prowler/pull/9423) - `compute_instance_single_network_interface` check for GCP provider [(#9702)](https://github.com/prowler-cloud/prowler/pull/9702) - `compute_image_not_publicly_shared` check for GCP provider [(#9718)](https://github.com/prowler-cloud/prowler/pull/9718) +- `TLS/SSL`, `records` and `email` checks for `zone` service [(#9424)](https://github.com/prowler-cloud/prowler/pull/9424) - `compute_snapshot_not_outdated` check for GCP provider [(#9774)](https://github.com/prowler-cloud/prowler/pull/9774) - CIS 1.12 compliance framework for Kubernetes [(#9778)](https://github.com/prowler-cloud/prowler/pull/9778) - CIS 6.0 for M365 provider [(#9779)](https://github.com/prowler-cloud/prowler/pull/9779) diff --git a/prowler/providers/cloudflare/cloudflare_provider.py b/prowler/providers/cloudflare/cloudflare_provider.py index 5529d6d02b..0dddcdb8d4 100644 --- a/prowler/providers/cloudflare/cloudflare_provider.py +++ b/prowler/providers/cloudflare/cloudflare_provider.py @@ -35,12 +35,12 @@ class CloudflareProvider(Provider): _audit_config: dict _fixer_config: dict _mutelist: CloudflareMutelist - _filter_zone: set[str] | None + _filter_zones: set[str] | None audit_metadata: Audit_Metadata def __init__( self, - filter_zone: Iterable[str] | None = None, + filter_zones: Iterable[str] | None = None, config_path: str = None, config_content: dict | None = None, fixer_config: dict = {}, @@ -72,7 +72,7 @@ class CloudflareProvider(Provider): self._mutelist = CloudflareMutelist(mutelist_path=mutelist_path) # Store zone filter for filtering resources across services - self._filter_zone = set(filter_zone) if filter_zone else None + self._filter_zones = set(filter_zones) if filter_zones else None Provider.set_global_provider(self) @@ -101,9 +101,9 @@ class CloudflareProvider(Provider): return self._mutelist @property - def filter_zone(self) -> set[str] | None: + def filter_zones(self) -> set[str] | None: """Zone filter from --region argument to filter resources.""" - return self._filter_zone + return self._filter_zones @property def accounts(self) -> list[CloudflareAccount]: diff --git a/prowler/providers/cloudflare/services/dns/__init__.py b/prowler/providers/cloudflare/services/dns/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/dns/dns_client.py b/prowler/providers/cloudflare/services/dns/dns_client.py new file mode 100644 index 0000000000..70062519db --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_client.py @@ -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()) diff --git a/prowler/providers/cloudflare/services/dns/dns_service.py b/prowler/providers/cloudflare/services/dns/dns_service.py new file mode 100644 index 0000000000..309ee31366 --- /dev/null +++ b/prowler/providers/cloudflare/services/dns/dns_service.py @@ -0,0 +1,64 @@ +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.cloudflare.lib.service.service import CloudflareService +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class DNS(CloudflareService): + """Retrieve Cloudflare DNS records for all zones.""" + + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.records: list["CloudflareDNSRecord"] = [] + self._list_dns_records() + + def _list_dns_records(self) -> None: + """List DNS records for all zones.""" + logger.info("DNS - Listing DNS records...") + try: + for zone in zone_client.zones.values(): + seen_record_ids: set[str] = set() + try: + for record in self.client.dns.records.list(zone_id=zone.id): + record_id = getattr(record, "id", None) + # Prevent infinite loop + if record_id in seen_record_ids: + break + seen_record_ids.add(record_id) + + self.records.append( + CloudflareDNSRecord( + id=record_id, + zone_id=zone.id, + zone_name=zone.name, + name=getattr(record, "name", None), + type=getattr(record, "type", None), + content=getattr(record, "content", ""), + ttl=getattr(record, "ttl", None), + proxied=getattr(record, "proxied", False), + ) + ) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False diff --git a/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.metadata.json new file mode 100644 index 0000000000..2625a2f6e1 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_automatic_https_rewrites_enabled", + "CheckTitle": "Automatic HTTPS Rewrites is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Automatic HTTPS Rewrites** configuration by checking if it is enabled to automatically rewrite insecure HTTP links to HTTPS, resolving **mixed content issues** and enhancing site security.", + "Risk": "Without **Automatic HTTPS Rewrites**, pages may contain mixed content where HTTP resources load over HTTPS pages.\n- **Confidentiality**: insecure resources can be intercepted and modified by attackers\n- **Integrity**: browsers block or warn about mixed content, indicating potential tampering\n- **User Experience**: security warnings degrade trust and some browsers block mixed content entirely", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/automatic-https-rewrites/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to Automatic HTTPS Rewrites\n4. Toggle the setting to On\n5. Verify that your site loads correctly without mixed content warnings", + "Terraform": "```hcl\n# Enable Automatic HTTPS Rewrites to fix mixed content\nresource \"cloudflare_zone_settings_override\" \"https_rewrites\" {\n zone_id = \"\"\n settings {\n automatic_https_rewrites = \"on\" # Critical: automatically rewrites HTTP URLs to HTTPS\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Automatic HTTPS Rewrites** as part of a comprehensive HTTPS strategy.\n- Automatically fixes mixed content by rewriting HTTP URLs to HTTPS\n- Combine with **Always Use HTTPS** and **HSTS** for defense in depth\n- Works best when all resources are available over HTTPS\n- Monitor for resources that cannot be rewritten and fix them at the source", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_automatic_https_rewrites_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This feature works best when combined with Always Use HTTPS to ensure the entire site is served over HTTPS. Some resources may not be rewritable if they don't support HTTPS." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.py b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.py new file mode 100644 index 0000000000..6dc491062f --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled.py @@ -0,0 +1,45 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_automatic_https_rewrites_enabled(Check): + """Ensure that Automatic HTTPS Rewrites is enabled for Cloudflare zones. + + Automatic HTTPS Rewrites automatically rewrites insecure HTTP links to HTTPS, + resolving mixed content issues and enhancing site security. This feature scans + HTML responses and rewrites HTTP URLs to HTTPS for resources that are known to + be available over a secure connection, preventing mixed content warnings. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Automatic HTTPS Rewrites enabled check. + + Iterates through all Cloudflare zones and verifies that Automatic HTTPS + Rewrites is enabled. This setting automatically fixes mixed content issues + by rewriting HTTP links to HTTPS where possible. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Automatic + HTTPS Rewrites is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + automatic_https_rewrites = ( + zone.settings.automatic_https_rewrites or "" + ).lower() + if automatic_https_rewrites == "on": + report.status = "PASS" + report.status_extended = ( + f"Automatic HTTPS Rewrites is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Automatic HTTPS Rewrites is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.metadata.json index ad37033033..a72f7a6fd7 100644 --- a/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_dnssec_enabled/zone_dnssec_enabled.metadata.json @@ -8,6 +8,7 @@ "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **DNSSEC** configuration by checking if it is enabled to **cryptographically sign DNS responses** and protect against DNS spoofing and cache poisoning attacks.", "Risk": "Without **DNSSEC**, DNS responses can be spoofed or modified by attackers.\n- **Confidentiality**: users can be redirected to malicious sites that harvest credentials\n- **Integrity**: DNS hijacking enables man-in-the-middle attacks and content modification\n- **Availability**: cache poisoning can cause denial of service by directing traffic to non-existent servers", "RelatedUrl": "", diff --git a/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.metadata.json new file mode 100644 index 0000000000..be8b51f627 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_email_obfuscation_enabled", + "CheckTitle": "Email Obfuscation is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Email Obfuscation** (Scrape Shield) configuration by checking if it is enabled to protect email addresses on the website from **automated harvesting** by bots and spammers.", + "Risk": "Without **Email Obfuscation**, email addresses displayed on your website can be harvested by bots.\n- **Confidentiality**: harvested emails become targets for spam and phishing campaigns\n- **Integrity**: employees may fall victim to targeted social engineering attacks\n- **Availability**: increased spam volume can overwhelm email systems", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/waf/tools/scrape-shield/email-address-obfuscation/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Scrape Shield (or Security > Settings in newer UI)\n3. Scroll to Email Address Obfuscation\n4. Toggle the setting to On\n5. Verify that email addresses still work correctly for human visitors", + "Terraform": "```hcl\n# Enable Email Obfuscation to protect against email harvesting\nresource \"cloudflare_zone_settings_override\" \"email_obfuscation\" {\n zone_id = \"\"\n settings {\n email_obfuscation = \"on\" # Critical: hides email addresses from automated scrapers\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Email Obfuscation** as part of anti-scraping protections.\n- Automatically encodes email addresses to prevent bot harvesting\n- Email addresses remain visible and clickable for human visitors\n- Works with mailto: links and plain text email addresses\n- Part of the Scrape Shield feature set for comprehensive protection", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_email_obfuscation_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Email Obfuscation automatically hides email addresses from bots while keeping them visible and clickable for human visitors. May not work with JavaScript-rendered email addresses." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.py b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.py new file mode 100644 index 0000000000..4009378c82 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_email_obfuscation_enabled(Check): + """Ensure that Email Obfuscation is enabled for Cloudflare zones. + + Email Obfuscation is part of Cloudflare's Scrape Shield suite that protects + email addresses displayed on websites from automated harvesting by bots and + spammers. It encrypts email addresses in the HTML source while keeping them + visible to human visitors, reducing spam and protecting user privacy. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Email Obfuscation enabled check. + + Iterates through all Cloudflare zones and verifies that Email Obfuscation + is enabled. This feature helps prevent email harvesting by obfuscating + email addresses in the page source. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Email + Obfuscation is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + email_obfuscation = (zone.settings.email_obfuscation or "").lower() + if email_obfuscation == "on": + report.status = "PASS" + report.status_extended = ( + f"Email Obfuscation is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Email Obfuscation is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.metadata.json index 87db1522be..16888fb30b 100644 --- a/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_hsts_enabled/zone_hsts_enabled.metadata.json @@ -8,6 +8,7 @@ "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **HTTP Strict Transport Security (HSTS)** by checking if it is enabled with a `max-age` of at least **6 months** (15768000 seconds) and **includes subdomains** to instruct browsers to always use HTTPS across the entire domain.", "Risk": "Without **HSTS**, browsers may initially connect over HTTP before redirecting to HTTPS.\n- **Confidentiality**: creates a window for SSL stripping attacks where attackers downgrade connections to unencrypted HTTP\n- **Integrity**: first request can be intercepted and modified before HTTPS redirect\n- **Session hijacking**: cookies and credentials may be captured during initial HTTP request", "RelatedUrl": "", diff --git a/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.metadata.json index aa78dc6fa7..c529a98d6a 100644 --- a/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_https_redirect_enabled/zone_https_redirect_enabled.metadata.json @@ -8,12 +8,12 @@ "ResourceIdTemplate": "", "Severity": "medium", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **Always Use HTTPS** setting by checking if it is enabled to automatically redirect all **HTTP requests to HTTPS**, enforcing encrypted transport for all visitors.", "Risk": "Without **automatic HTTPS redirects**, users may access resources over unencrypted HTTP.\n- **Confidentiality**: traffic can be intercepted and read by attackers on the network path\n- **Integrity**: HTTP responses can be modified in transit (content injection, malware insertion)\n- **Authentication**: session cookies and credentials may be transmitted in plaintext", "RelatedUrl": "", "AdditionalURLs": [ - "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/", - "https://developers.cloudflare.com/terraform/tutorial/configure-https-settings/" + "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/always-use-https/" ], "Remediation": { "Code": { diff --git a/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.metadata.json b/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.metadata.json index 93c9bf3d5d..af89341222 100644 --- a/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_min_tls_version_secure/zone_min_tls_version_secure.metadata.json @@ -8,6 +8,7 @@ "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **minimum TLS version** configuration by checking if the version is set to at least `TLS 1.2` to ensure connections use **secure, modern cryptographic protocols**.", "Risk": "Allowing **legacy TLS versions** (1.0, 1.1) exposes connections to known protocol vulnerabilities.\n- **Confidentiality**: BEAST, POODLE, and weak cipher suites can be exploited for traffic decryption\n- **Compliance**: TLS 1.0/1.1 are deprecated by PCI-DSS, NIST, and major browsers\n- **Integrity**: downgrade attacks can force weaker encryption that is susceptible to tampering", "RelatedUrl": "", diff --git a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json new file mode 100644 index 0000000000..07e9181831 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_caa_exists", + "CheckTitle": "CAA record exists with issue or issuewild tag", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **CAA (Certificate Authority Authorization)** DNS records by checking if they exist with **`issue` or `issuewild` tags** that specify which **certificate authorities** are permitted to issue SSL/TLS certificates for the domain.", + "Risk": "Without **CAA** records or without `issue`/`issuewild` tags, any certificate authority can issue certificates for your domain.\n- **Confidentiality**: unauthorized certificates enable man-in-the-middle attacks\n- **Integrity**: attackers can impersonate your domain with fraudulently obtained certificates\n- **Trust**: CA compromise or social engineering can result in unauthorized certificate issuance\n- **Missing tags**: CAA records without `issue` or `issuewild` tags (e.g., only `iodef`) do not restrict certificate issuance", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/caa-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Click Add record\n4. Select CAA as the record type\n5. Enter @ for the Name field\n6. Set flags to 0, tag to issue, and value to your authorized CA (e.g., letsencrypt.org)\n7. Click Save\n8. Optionally add issuewild records to control wildcard certificate issuance", + "Terraform": "```hcl\n# Create CAA record to restrict certificate issuance\nresource \"cloudflare_record\" \"caa_issue\" {\n zone_id = \"\"\n name = \"@\"\n type = \"CAA\"\n data {\n flags = \"0\"\n tag = \"issue\" # Restricts which CAs can issue regular certificates\n value = \"letsencrypt.org\" # Specify your authorized certificate authority\n }\n}\n\n# Create CAA record to restrict wildcard certificate issuance\nresource \"cloudflare_record\" \"caa_issuewild\" {\n zone_id = \"\"\n name = \"@\"\n type = \"CAA\"\n data {\n flags = \"0\"\n tag = \"issuewild\" # Restricts which CAs can issue wildcard certificates\n value = \"letsencrypt.org\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Add **CAA records** with `issue` or `issuewild` tags specifying authorized certificate authorities.\n- `issue` tag: authorizes CAs for regular (non-wildcard) certificates\n- `issuewild` tag: authorizes CAs for wildcard certificates specifically\n- Use `issue \";\"` to block all certificate issuance (for domains that should never have certificates)\n- Use `iodef` tag to receive reports of policy violations (optional, for monitoring)\n- All CAs are required to check CAA records before issuing certificates", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_caa_exists" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "CAA records help prevent unauthorized SSL/TLS certificate issuance. This check verifies both existence and that the record contains issue or issuewild tags. Records with only iodef tag will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.py new file mode 100644 index 0000000000..2f9275cb91 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.py @@ -0,0 +1,82 @@ +import re + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_caa_exists(Check): + """Ensure that CAA record exists with certificate issuance restrictions. + + CAA (Certificate Authority Authorization) is a DNS record type that allows + domain owners to specify which certificate authorities (CAs) are permitted + to issue SSL/TLS certificates for their domain. This check verifies that CAA + records exist with "issue" or "issuewild" tags that explicitly authorize + specific CAs, preventing unauthorized certificate issuance and reducing the + risk of man-in-the-middle attacks from fraudulent certificates. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the CAA record exists check. + + Iterates through all Cloudflare zones and verifies CAA configuration. + The check validates two conditions: + 1. CAA records exist for the zone + 2. At least one record has an "issue" or "issuewild" tag specifying authorized CAs + + Returns: + A list of CheckReportCloudflare objects with PASS status if CAA + records with issuance restrictions exist, or FAIL status if no CAA + records are found or they lack proper issue/issuewild tags. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # CAA records restrict which CAs can issue certificates + caa_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id and record.type == "CAA" + ] + + if not caa_records: + report.status = "FAIL" + report.status_extended = f"No CAA record found for zone {zone.name}." + else: + # Check if CAA records have issue or issuewild tags with CA specified + issue_records = [ + record + for record in caa_records + if self._has_issue_tag_with_ca(record.content) + ] + + records_str = ", ".join(record.name for record in caa_records) + + if issue_records: + report.status = "PASS" + report.status_extended = f"CAA record with certificate issuance restrictions exists for zone {zone.name}: {records_str}." + else: + report.status = "FAIL" + report.status_extended = f"CAA record exists for zone {zone.name} but does not specify authorized CAs with issue or issuewild tags: {records_str}." + + findings.append(report) + + return findings + + def _has_issue_tag_with_ca(self, content: str) -> bool: + """Check if CAA record has issue or issuewild tag with a CA specified. + + CAA content format: "flags tag value" e.g., "0 issue letsencrypt.org" + """ + # Strip quotes that may be present from Cloudflare API + content_lower = content.strip('"').lower() + # Match issue or issuewild tag followed by a value (CA name or ";" to block all) + # Format: "0 issue letsencrypt.org" or "0 issuewild ;" or "0 issue \"digicert.com\"" + issue_match = re.search(r"\bissue\b", content_lower) + issuewild_match = re.search(r"\bissuewild\b", content_lower) + return bool(issue_match or issuewild_match) diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json new file mode 100644 index 0000000000..a8811febbf --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_dkim_exists", + "CheckTitle": "DKIM record exists with valid public key", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **DKIM (DomainKeys Identified Mail)** records by checking if TXT records exist at `*._domainkey` subdomains containing a **cryptographically valid public key** in the `p=` parameter used to **verify email signatures**.", + "Risk": "Without **DKIM** or with a revoked/empty public key, email recipients cannot verify that messages were sent by authorized servers.\n- **Confidentiality**: attackers can forge emails appearing to come from your domain\n- **Integrity**: no cryptographic proof that email content hasn't been modified in transit\n- **Reputation**: DMARC policies relying on DKIM will fail, affecting email deliverability\n- **Revoked keys**: A DKIM record with `p=` empty (e.g., `p=;`) indicates a revoked key that cannot authenticate emails", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Generate DKIM keys using your email provider or mail server\n2. Log in to the Cloudflare dashboard and select your account and domain\n3. Go to DNS > Records\n4. Click Add record\n5. Select TXT as the record type\n6. Enter selector._domainkey for the Name field (e.g., google._domainkey)\n7. Enter the DKIM public key record provided by your email service (must include p= with actual key)\n8. Click Save", + "Terraform": "```hcl\n# Create DKIM record for email authentication\nresource \"cloudflare_record\" \"dkim\" {\n zone_id = \"\"\n name = \"google._domainkey\" # Selector varies by email provider\n type = \"TXT\"\n content = \"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4...\" # Must contain valid public key\n ttl = 3600\n}\n```" + }, + "Recommendation": { + "Text": "Configure **DKIM records** with valid public keys from your email provider.\n- Each email service requires its own DKIM selector (e.g., google._domainkey for Google Workspace)\n- The `p=` parameter must contain the actual public key, not be empty\n- An empty `p=` value indicates a revoked key and will fail this check\n- DKIM works alongside SPF and DMARC for comprehensive email authentication\n- Rotate DKIM keys periodically for enhanced security\n- Use 2048-bit keys for stronger cryptographic protection", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_dkim_exists" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "DKIM records are TXT records at *._domainkey subdomains starting with 'v=DKIM1'. This check uses cryptographic validation to verify that the p= parameter contains a real DER-encoded public key, not just non-empty Base64. Revoked keys, invalid Base64, or malformed keys will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.py new file mode 100644 index 0000000000..3ecf725d44 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.py @@ -0,0 +1,116 @@ +import base64 +import re + +from cryptography.hazmat.primitives.serialization import load_der_public_key + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_dkim_exists(Check): + """Ensure that DKIM record exists with valid public key for Cloudflare zones. + + DKIM (DomainKeys Identified Mail) is an email authentication method that allows + the receiver to verify that an email was sent by the domain owner and was not + modified in transit. This check verifies that DKIM records exist at *._domainkey + subdomains containing "v=DKIM1" with a cryptographically valid public key in the + p= parameter that can be used to verify email signatures. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the DKIM record exists check. + + Iterates through all Cloudflare zones and verifies DKIM configuration. + The check validates three conditions: + 1. A DKIM record exists (TXT record at *._domainkey with "v=DKIM1") + 2. The record contains a p= parameter with a public key + 3. The public key is cryptographically valid (valid Base64 and DER format) + + Returns: + A list of CheckReportCloudflare objects with PASS status if a DKIM + record with valid public key exists, or FAIL status if no DKIM record + is found or the public key is invalid/missing. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # DKIM records are TXT records at *._domainkey subdomain containing "v=DKIM1" + dkim_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id + and record.type == "TXT" + and record.name + and "_domainkey" in record.name + and "V=DKIM1" + in record.content.replace('" "', "").replace('"', "").upper() + ] + + if not dkim_records: + report.status = "FAIL" + report.status_extended = f"No DKIM record found for zone {zone.name}." + else: + # Check if DKIM records have a valid public key + valid_key_records = [ + record + for record in dkim_records + if self._has_valid_public_key(record.content) + ] + + records_str = ", ".join(record.name for record in dkim_records) + + if valid_key_records: + report.status = "PASS" + report.status_extended = f"DKIM record with valid public key exists for zone {zone.name}: {records_str}." + else: + report.status = "FAIL" + report.status_extended = f"DKIM record exists for zone {zone.name} but has invalid or missing public key: {records_str}." + + findings.append(report) + + return findings + + def _has_valid_public_key(self, content: str) -> bool: + """Check if DKIM record has a valid public key. + + Validates that: + 1. The p= parameter exists and is not empty + 2. The key is valid Base64 + 3. The key can be loaded as a valid DER-encoded public key + """ + # Cloudflare API may return TXT records with quotes, and long records + # may be split into multiple quoted strings like: "part1" "part2" + # First remove '" "' to join split parts, then remove remaining quotes + content = content.replace('" "', "").replace('"', "") + + # Extract the public key value from p= parameter + match = re.search(r"p\s*=\s*([^;\s]*)", content, re.IGNORECASE) + if not match: + return False + + key_value = match.group(1) + + # Empty key means revoked + if not key_value: + return False + + try: + # Add padding if necessary for Base64 decoding + padding = 4 - (len(key_value) % 4) + if padding != 4: + key_value += "=" * padding + + # Decode Base64 to get DER-encoded key + der_key = base64.b64decode(key_value, validate=True) + + # Try to load as a public key using cryptography library + load_der_public_key(der_key) + return True + except Exception: + return False diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json new file mode 100644 index 0000000000..2aa572da36 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_dmarc_exists", + "CheckTitle": "DMARC record exists with enforcement policy (p=reject or p=quarantine)", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **DMARC (Domain-based Message Authentication, Reporting, and Conformance)** records by checking if a TXT record exists at `_dmarc` subdomain with an **enforcement policy (`p=reject` or `p=quarantine`)** to actively block or quarantine spoofed emails.", + "Risk": "Without **DMARC** or with a monitoring-only policy (`p=none`), there is no active protection against email spoofing.\n- **Confidentiality**: attackers can spoof emails for phishing campaigns to steal credentials\n- **Integrity**: no visibility into email authentication failures or abuse attempts\n- **Reputation**: domain reputation damage from spoofed emails sent in your name\n- **Monitoring-only policy**: `p=none` only generates reports but does not block or quarantine spoofed emails, providing no real protection", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Click Add record\n4. Select TXT as the record type\n5. Enter _dmarc for the Name field\n6. Enter your DMARC policy with enforcement (e.g., v=DMARC1; p=reject; rua=mailto:dmarc@example.com)\n7. Click Save", + "Terraform": "```hcl\n# Create DMARC record with enforcement policy\nresource \"cloudflare_record\" \"dmarc\" {\n zone_id = \"\"\n name = \"_dmarc\"\n type = \"TXT\"\n content = \"v=DMARC1; p=reject; rua=mailto:dmarc@example.com\" # Use p=reject or p=quarantine for enforcement\n ttl = 3600\n}\n```" + }, + "Recommendation": { + "Text": "Implement **DMARC** with an enforcement policy (`p=reject` or `p=quarantine`).\n- `p=reject`: receiving servers should reject emails that fail authentication\n- `p=quarantine`: receiving servers should send suspicious emails to spam folder\n- `p=none`: only monitoring, provides no protection (will fail this check)\n- Ensure SPF and DKIM are properly configured before enabling enforcement\n- Configure `rua` to receive aggregate reports on authentication results\n- Use `pct` parameter to gradually roll out enforcement (e.g., `pct=10` for 10% of emails)", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_dmarc_exists" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "DMARC records are TXT records at _dmarc subdomain starting with 'v=DMARC1'. This check verifies both existence and enforcement policy (p=reject or p=quarantine). Monitoring-only policy (p=none) will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.py new file mode 100644 index 0000000000..bf894a2bf1 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.py @@ -0,0 +1,88 @@ +import re + +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_dmarc_exists(Check): + """Ensure that DMARC record exists with enforcement policy for Cloudflare zones. + + DMARC (Domain-based Message Authentication, Reporting, and Conformance) is an + email authentication protocol that builds on SPF and DKIM. It allows domain + owners to specify how receiving mail servers should handle emails that fail + authentication checks. This check verifies that a DMARC record exists at the + _dmarc subdomain with an enforcement policy (p=reject or p=quarantine) to + actively block or quarantine spoofed emails, not just monitor them (p=none). + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the DMARC record exists check. + + Iterates through all Cloudflare zones and verifies DMARC configuration. + The check validates two conditions: + 1. A DMARC record exists (TXT record at _dmarc subdomain with "v=DMARC1") + 2. The record uses an enforcement policy (p=reject or p=quarantine) + + Returns: + A list of CheckReportCloudflare objects with PASS status if a DMARC + record with enforcement policy exists, or FAIL status if no DMARC + record is found or it uses monitoring-only policy (p=none). + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # DMARC records are TXT records at _dmarc subdomain starting with "v=DMARC1" + dmarc_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id + and record.type == "TXT" + and record.name + and record.name.startswith("_dmarc") + and "V=DMARC1" in record.content.strip('"').upper() + ] + + if not dmarc_records: + report.status = "FAIL" + report.status_extended = f"No DMARC record found for zone {zone.name}." + else: + # Check if DMARC uses enforcement policy (p=reject or p=quarantine) vs monitoring (p=none) + enforcement_records = [ + record + for record in dmarc_records + if self._get_policy_value(record.content) + in ("reject", "quarantine") + ] + + records_str = ", ".join(record.name for record in dmarc_records) + + if enforcement_records: + # Get the actual policy value from the first enforcement record + policy = self._get_policy_value(enforcement_records[0].content) + report.status = "PASS" + report.status_extended = f"DMARC record with enforcement policy p={policy} exists for zone {zone.name}: {records_str}." + else: + # Get the actual policy value to show in the message + policy = self._get_policy_value(dmarc_records[0].content) or "none" + report.status = "FAIL" + report.status_extended = f"DMARC record exists for zone {zone.name} but uses monitoring-only policy p={policy}: {records_str}." + + findings.append(report) + + return findings + + def _get_policy_value(self, content: str) -> str | None: + """Extract the DMARC policy value (reject, quarantine, or none).""" + # Strip quotes that may be present from Cloudflare API + content_clean = content.strip('"') + # Match p= (with optional spaces around =) + match = re.search(r"p\s*=\s*(\w+)", content_clean, re.IGNORECASE) + if match: + return match.group(1).lower() + return None diff --git a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/__init__.py b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json new file mode 100644 index 0000000000..07e2d4ee5f --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_record_spf_exists", + "CheckTitle": "SPF record exists with strict policy (-all)", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **SPF (Sender Policy Framework)** records by checking if a TXT record exists that specifies which mail servers are **authorized to send email** on behalf of the domain, and verifies that the record uses a **strict policy (`-all`)** to reject unauthorized senders.", + "Risk": "Without **SPF** or with a permissive policy (`~all`, `?all`, `+all`), attackers can forge emails appearing to come from your domain.\n- **Confidentiality**: phishing attacks can harvest sensitive information from recipients who trust spoofed emails\n- **Integrity**: brand reputation damage from fraudulent emails sent in your name\n- **Availability**: email deliverability issues as receiving servers may reject or quarantine legitimate emails\n- **Permissive policies**: `~all` (softfail) only marks emails as suspicious but does not reject them, `?all` (neutral) provides no protection", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to DNS > Records\n3. Click Add record\n4. Select TXT as the record type\n5. Enter @ for the Name field\n6. Enter your SPF record with strict policy (e.g., v=spf1 include:_spf.google.com -all)\n7. Click Save", + "Terraform": "```hcl\n# Create SPF record for email authentication with strict policy\nresource \"cloudflare_record\" \"spf\" {\n zone_id = \"\"\n name = \"@\"\n type = \"TXT\"\n content = \"v=spf1 include:_spf.google.com -all\" # Use -all for strict enforcement\n ttl = 3600\n}\n```" + }, + "Recommendation": { + "Text": "Configure **SPF records** with strict policy (`-all`) listing authorized mail servers.\n- SPF records start with `v=spf1` and define authorized senders\n- Use `-all` (hardfail) to reject emails from unauthorized servers\n- Avoid `~all` (softfail) in production as it only marks suspicious emails but does not reject them\n- Use `include:` to authorize third-party mail services\n- Combine with **DKIM** and **DMARC** for comprehensive email authentication\n- Test SPF records using online validators before deployment", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_record_spf_exists" + } + }, + "Categories": [ + "email-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "SPF records start with 'v=spf1' and define authorized mail servers. This check verifies both existence and strict policy (-all). Permissive policies like ~all (softfail) or ?all (neutral) will fail this check." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.py b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.py new file mode 100644 index 0000000000..861a0c82ab --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.py @@ -0,0 +1,68 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.dns.dns_client import dns_client +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_record_spf_exists(Check): + """Ensure that SPF record exists with strict policy for Cloudflare zones. + + SPF (Sender Policy Framework) is an email authentication method that specifies + which mail servers are authorized to send email on behalf of the domain. This + check verifies that an SPF record exists as a TXT record starting with "v=spf1" + and uses the strict policy qualifier "-all" to instruct receiving servers to + reject emails from unauthorized sources. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the SPF record exists check. + + Iterates through all Cloudflare zones and verifies SPF configuration. + The check validates two conditions: + 1. An SPF record exists (TXT record starting with "v=spf1") + 2. The record uses strict policy "-all" (not ~all, ?all, or +all) + + Returns: + A list of CheckReportCloudflare objects with PASS status if an SPF + record with strict policy exists, or FAIL status if no SPF record + is found or it uses a permissive policy. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + + # SPF records are TXT records starting with "v=spf1" + spf_records = [ + record + for record in dns_client.records + if record.zone_id == zone.id + and record.type == "TXT" + and record.content.strip('"').startswith("v=spf1") + ] + + if not spf_records: + report.status = "FAIL" + report.status_extended = f"No SPF record found for zone {zone.name}." + else: + # Check if SPF uses strict policy (-all) vs permissive (~all, ?all, +all) + strict_records = [ + record + for record in spf_records + if record.content.strip('"').rstrip().endswith("-all") + ] + + records_str = ", ".join(record.name for record in spf_records) + + if strict_records: + report.status = "PASS" + report.status_extended = f"SPF record with strict policy -all exists for zone {zone.name}: {records_str}." + else: + report.status = "FAIL" + report.status_extended = f"SPF record exists for zone {zone.name} but does not use strict policy -all: {records_str}." + + findings.append(report) + + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.metadata.json new file mode 100644 index 0000000000..405b88180f --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_security_under_attack_disabled", + "CheckTitle": "Under Attack Mode is disabled during normal operations", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Under Attack Mode** configuration by checking if it is disabled during normal operations, as this mode performs additional security checks including an **interstitial JavaScript challenge page** that significantly impacts user experience.", + "Risk": "Keeping **Under Attack Mode** permanently enabled causes operational issues.\n- **Availability**: all visitors face a 5-second interstitial challenge page before accessing the site\n- **Accessibility**: visitors without JavaScript support cannot access the site at all\n- **User Experience**: legitimate users experience unnecessary delays and third-party analytics show degraded performance", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/fundamentals/reference/under-attack-mode/", + "https://developers.cloudflare.com/waf/tools/security-level/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to Security > Settings\n3. Under 'Under Attack Mode', toggle it OFF\n4. Consider setting Security Level to 'High' for continued protection without the interstitial", + "Terraform": "```hcl\n# Set security level to high instead of under_attack\nresource \"cloudflare_zone_settings_override\" \"security_settings\" {\n zone_id = \"\"\n settings {\n security_level = \"high\" # Use high instead of under_attack for normal operations\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disable **Under Attack Mode** when not actively under a DDoS attack.\n- Use it only as a **last resort** during active layer 7 DDoS attacks\n- For ongoing protection, use **Security Level** settings (Low, Medium, High)\n- Configure specific **WAF rules** for targeted protection without impacting all users", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_security_under_attack_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Under Attack Mode is designed as a temporary measure during active attacks. The interstitial challenge page validates visitors using JavaScript, blocking automated attacks while allowing legitimate users through after a brief delay." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.py b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.py new file mode 100644 index 0000000000..e0dbf9b455 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled.py @@ -0,0 +1,47 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_security_under_attack_disabled(Check): + """Ensure that Under Attack Mode is disabled during normal operations. + + Under Attack Mode is a DDoS mitigation feature that performs additional + security checks including an interstitial JavaScript challenge page for all + visitors. While effective during active attacks, it significantly impacts + user experience and should only be enabled temporarily during actual DDoS + incidents, not as a permanent security measure. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Under Attack Mode disabled check. + + Iterates through all Cloudflare zones and verifies that the security + level is not set to "under_attack". Having this mode permanently enabled + indicates either an ongoing attack or misconfiguration that degrades + user experience unnecessarily. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Under + Attack Mode is disabled, or FAIL status if it is currently enabled. + """ + findings = [] + + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + security_level = (zone.settings.security_level or "").lower() + + if security_level == "under_attack": + report.status = "FAIL" + report.status_extended = ( + f"Zone {zone.name} has Under Attack Mode enabled." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Zone {zone.name} does not have Under Attack Mode enabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_service.py b/prowler/providers/cloudflare/services/zone/zone_service.py index 8b274fcc92..ce93664b23 100644 --- a/prowler/providers/cloudflare/services/zone/zone_service.py +++ b/prowler/providers/cloudflare/services/zone/zone_service.py @@ -16,12 +16,13 @@ class Zone(CloudflareService): self._list_zones() self._get_zones_settings() self._get_zones_dnssec() + self._get_zones_universal_ssl() def _list_zones(self) -> None: """List all Cloudflare zones with their basic information.""" logger.info("Zone - Listing zones...") audited_accounts = self.provider.identity.audited_accounts - filter_zone = self.provider.filter_zone + filter_zones = self.provider.filter_zones seen_zone_ids: set[str] = set() try: @@ -43,9 +44,9 @@ class Zone(CloudflareService): # Apply zone filter if specified via --region if ( - filter_zone - and zone_id not in filter_zone - and zone_name not in filter_zone + filter_zones + and zone_id not in filter_zones + and zone_name not in filter_zones ): continue @@ -107,6 +108,20 @@ class Zone(CloudflareService): f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_zones_universal_ssl(self) -> None: + """Get Universal SSL settings for all zones.""" + logger.info("Zones - Getting Universal SSL settings...") + for zone in self.zones.values(): + try: + universal_ssl = self.client.ssl.universal.settings.get(zone_id=zone.id) + zone.settings.universal_ssl_enabled = getattr( + universal_ssl, "enabled", False + ) + except Exception as error: + logger.error( + f"{zone.id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_zone_setting(self, zone_id: str, setting_id: str): """Get a single zone setting by ID.""" try: @@ -127,7 +142,6 @@ class Zone(CloudflareService): "ssl", "tls_1_3", "automatic_https_rewrites", - "universal_ssl", "security_header", "waf", "security_level", @@ -148,7 +162,6 @@ class Zone(CloudflareService): ssl_encryption_mode=settings.get("ssl"), tls_1_3=settings.get("tls_1_3"), automatic_https_rewrites=settings.get("automatic_https_rewrites"), - universal_ssl=settings.get("universal_ssl"), strict_transport_security=self._get_strict_transport_security( settings.get("security_header") ), @@ -210,7 +223,7 @@ class CloudflareZoneSettings(BaseModel): ssl_encryption_mode: Optional[str] = None tls_1_3: Optional[str] = None automatic_https_rewrites: Optional[str] = None - universal_ssl: Optional[str] = None + universal_ssl_enabled: bool = False # HSTS settings strict_transport_security: StrictTransportSecurity = Field( default_factory=StrictTransportSecurity diff --git a/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json index 86cd5c4125..d236895198 100644 --- a/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json @@ -8,6 +8,7 @@ "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "Zone", + "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **SSL/TLS encryption mode** by checking if the mode is set to `Full (Strict)` to ensure **end-to-end encryption** with certificate validation.", "Risk": "Without **strict SSL mode**, traffic between Cloudflare and origin may use unvalidated or unencrypted connections.\n- **Confidentiality**: sensitive data can be intercepted in transit via man-in-the-middle attacks\n- **Integrity**: responses can be modified without detection between Cloudflare and origin\n- **Compliance**: may violate PCI-DSS, HIPAA, and other regulatory requirements for encrypted transport", "RelatedUrl": "", diff --git a/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.metadata.json new file mode 100644 index 0000000000..5dba1a8de8 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_tls_1_3_enabled", + "CheckTitle": "TLS 1.3 is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **TLS 1.3** configuration by checking if it is enabled to benefit from **improved security** through simplified cipher suites and **faster handshakes** with zero round-trip time resumption.", + "Risk": "Without **TLS 1.3**, connections use older TLS versions with less secure characteristics.\n- **Confidentiality**: legacy TLS versions have more complex cipher negotiations that may expose weaknesses\n- **Integrity**: older protocols lack protection against downgrade attacks that TLS 1.3 provides\n- **Performance**: slower handshakes impact user experience and increase latency", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/additional-options/tls-13/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to TLS 1.3\n4. Toggle the setting to On\n5. Verify that your clients support TLS 1.3 (all modern browsers do)", + "Terraform": "```hcl\n# Enable TLS 1.3 for improved security and performance\nresource \"cloudflare_zone_settings_override\" \"tls_settings\" {\n zone_id = \"\"\n settings {\n tls_1_3 = \"on\" # Critical: enables most secure TLS version with faster handshakes\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **TLS 1.3** to provide the most secure and efficient TLS connections.\n- All modern browsers support TLS 1.3 ensuring broad compatibility\n- TLS 1.3 removes obsolete cryptographic algorithms\n- Zero round-trip time (0-RTT) resumption improves performance\n- Combine with minimum TLS version set to 1.2 or higher", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_tls_1_3_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "TLS 1.3 is enabled by default on Cloudflare but should be verified. It provides security improvements and performance benefits through simplified handshakes." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.py b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.py new file mode 100644 index 0000000000..7f9671b408 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_tls_1_3_enabled(Check): + """Ensure that TLS 1.3 is enabled for Cloudflare zones. + + TLS 1.3 provides improved security through simplified cipher suites and + faster handshakes with zero round-trip time (0-RTT) resumption. It removes + outdated cryptographic algorithms, reduces handshake latency, and provides + better forward secrecy compared to previous TLS versions. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the TLS 1.3 enabled check. + + Iterates through all Cloudflare zones and verifies that TLS 1.3 is + enabled. The check accepts both "on" (standard TLS 1.3) and "zrt" + (TLS 1.3 with 0-RTT) as valid enabled states. + + Returns: + A list of CheckReportCloudflare objects with PASS status if TLS 1.3 + is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + tls_1_3 = (zone.settings.tls_1_3 or "").lower() + if tls_1_3 in ["on", "zrt"]: + report.status = "PASS" + report.status_extended = f"TLS 1.3 is enabled for zone {zone.name}." + else: + report.status = "FAIL" + report.status_extended = f"TLS 1.3 is not enabled for zone {zone.name}." + findings.append(report) + return findings diff --git a/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/__init__.py b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.metadata.json b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.metadata.json new file mode 100644 index 0000000000..fde6ef3322 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "cloudflare", + "CheckID": "zone_universal_ssl_enabled", + "CheckTitle": "Universal SSL is enabled", + "CheckType": [], + "ServiceName": "zone", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Zone", + "ResourceGroup": "network", + "Description": "**Cloudflare zones** are assessed for **Universal SSL** configuration by checking if it is enabled to provide **free SSL/TLS certificates** for the domain and its subdomains, enabling secure HTTPS connections.", + "Risk": "Without **Universal SSL**, visitors cannot establish HTTPS connections to your site.\n- **Confidentiality**: all traffic is unencrypted and vulnerable to interception and eavesdropping\n- **Integrity**: HTTP responses can be modified in transit by attackers (content injection, malware)\n- **Trust**: browsers display security warnings degrading user trust and experience", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://developers.cloudflare.com/ssl/edge-certificates/universal-ssl/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Cloudflare dashboard and select your account and domain\n2. Go to SSL/TLS > Edge Certificates\n3. Scroll to Universal SSL\n4. Ensure the status shows Active\n5. If disabled, click Enable Universal SSL\n6. Wait for certificate issuance (typically within 24 hours for new domains)", + "Terraform": "```hcl\n# Note: Universal SSL is enabled by default on Cloudflare\n# To explicitly manage SSL settings, use zone_settings_override\nresource \"cloudflare_zone_settings_override\" \"ssl_settings\" {\n zone_id = \"\"\n settings {\n ssl = \"full\" # Critical: enables SSL/TLS encryption\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Universal SSL** to provide HTTPS capability for your domain.\n- Universal SSL is the foundation for transport security\n- Required before implementing HSTS or other HTTPS-dependent features\n- Cloudflare automatically renews certificates before expiration\n- Consider upgrading to Advanced Certificate Manager for additional control", + "Url": "https://hub.prowler.com/checks/cloudflare/zone_universal_ssl_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Universal SSL is enabled by default for all Cloudflare zones. If it was previously disabled, re-enabling may take up to 24 hours for certificate issuance." +} diff --git a/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.py b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.py new file mode 100644 index 0000000000..30b0c80037 --- /dev/null +++ b/prowler/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.services.zone.zone_client import zone_client + + +class zone_universal_ssl_enabled(Check): + """Ensure that Universal SSL is enabled for Cloudflare zones. + + Universal SSL provides free SSL/TLS certificates for the domain and its + subdomains, enabling secure HTTPS connections without requiring manual + certificate management. This feature automatically provisions and renews + certificates, ensuring continuous protection for web traffic. + """ + + def execute(self) -> list[CheckReportCloudflare]: + """Execute the Universal SSL enabled check. + + Iterates through all Cloudflare zones and verifies that Universal SSL + is enabled. Universal SSL provides automatic certificate provisioning + and management for the zone and its subdomains. + + Returns: + A list of CheckReportCloudflare objects with PASS status if Universal + SSL is enabled, or FAIL status if it is disabled for the zone. + """ + findings = [] + for zone in zone_client.zones.values(): + report = CheckReportCloudflare( + metadata=self.metadata(), + resource=zone, + ) + if zone.settings.universal_ssl_enabled: + report.status = "PASS" + report.status_extended = ( + f"Universal SSL is enabled for zone {zone.name}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Universal SSL is not enabled for zone {zone.name}." + ) + findings.append(report) + return findings diff --git a/tests/providers/cloudflare/cloudflare_provider_test.py b/tests/providers/cloudflare/cloudflare_provider_test.py index e5591ba36f..57bdade5fb 100644 --- a/tests/providers/cloudflare/cloudflare_provider_test.py +++ b/tests/providers/cloudflare/cloudflare_provider_test.py @@ -169,7 +169,7 @@ class TestCloudflareProvider: with pytest.raises(CloudflareCredentialsError): CloudflareProvider() - def test_cloudflare_provider_with_filter_zone(self): + def test_cloudflare_provider_with_filter_zones(self): with ( patch( "prowler.providers.cloudflare.cloudflare_provider.CloudflareProvider.setup_session", @@ -196,10 +196,10 @@ class TestCloudflareProvider: ), ), ): - filter_zone = ["zone1", "zone2"] - provider = CloudflareProvider(filter_zone=filter_zone) + filter_zones = ["zone1", "zone2"] + provider = CloudflareProvider(filter_zones=filter_zones) - assert provider.filter_zone == set(filter_zone) + assert provider.filter_zones == set(filter_zones) def test_cloudflare_provider_properties(self): with ( diff --git a/tests/providers/cloudflare/services/dns/dns_service_test.py b/tests/providers/cloudflare/services/dns/dns_service_test.py new file mode 100644 index 0000000000..c4e30c5d56 --- /dev/null +++ b/tests/providers/cloudflare/services/dns/dns_service_test.py @@ -0,0 +1,119 @@ +from typing import Optional + +from pydantic import BaseModel + +from tests.providers.cloudflare.cloudflare_fixtures import ZONE_ID, ZONE_NAME + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class TestDNSService: + def test_cloudflare_dns_record_model(self): + record = CloudflareDNSRecord( + id="record-123", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="A", + content="192.0.2.1", + ttl=3600, + proxied=True, + ) + + assert record.id == "record-123" + assert record.zone_id == ZONE_ID + assert record.zone_name == ZONE_NAME + assert record.name == "www.example.com" + assert record.type == "A" + assert record.content == "192.0.2.1" + assert record.ttl == 3600 + assert record.proxied is True + + def test_cloudflare_dns_record_defaults(self): + record = CloudflareDNSRecord( + id="record-123", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + ) + + assert record.id == "record-123" + assert record.zone_id == ZONE_ID + assert record.zone_name == ZONE_NAME + assert record.name is None + assert record.type is None + assert record.content == "" + assert record.ttl is None + assert record.proxied is False + + def test_cloudflare_dns_record_txt(self): + record = CloudflareDNSRecord( + id="record-txt", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="v=spf1 include:_spf.google.com ~all", + ttl=1, + proxied=False, + ) + + assert record.type == "TXT" + assert "v=spf1" in record.content + assert record.proxied is False + + def test_cloudflare_dns_record_cname(self): + record = CloudflareDNSRecord( + id="record-cname", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name="www.example.com", + type="CNAME", + content="example.com", + ttl=3600, + proxied=True, + ) + + assert record.type == "CNAME" + assert record.content == "example.com" + assert record.proxied is True + + def test_cloudflare_dns_record_mx(self): + record = CloudflareDNSRecord( + id="record-mx", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="MX", + content="10 mail.example.com", + ttl=3600, + proxied=False, + ) + + assert record.type == "MX" + assert "mail.example.com" in record.content + + def test_cloudflare_dns_record_caa(self): + record = CloudflareDNSRecord( + id="record-caa", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issue "letsencrypt.org"', + ttl=3600, + proxied=False, + ) + + assert record.type == "CAA" + assert "letsencrypt.org" in record.content diff --git a/tests/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled_test.py new file mode 100644 index 0000000000..2cacb15796 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_automatic_https_rewrites_enabled/zone_automatic_https_rewrites_enabled_test.py @@ -0,0 +1,143 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_automatic_https_rewrites_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_automatic_https_rewrites_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + automatic_https_rewrites="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Automatic HTTPS Rewrites is enabled" in result[0].status_extended + + def test_zone_automatic_https_rewrites_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + automatic_https_rewrites="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Automatic HTTPS Rewrites is not enabled" in result[0].status_extended + ) + + def test_zone_automatic_https_rewrites_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + automatic_https_rewrites=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_automatic_https_rewrites_enabled.zone_automatic_https_rewrites_enabled import ( + zone_automatic_https_rewrites_enabled, + ) + + check = zone_automatic_https_rewrites_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "Automatic HTTPS Rewrites is not enabled" in result[0].status_extended + ) diff --git a/tests/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled_test.py new file mode 100644 index 0000000000..dfe3aec33a --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_email_obfuscation_enabled/zone_email_obfuscation_enabled_test.py @@ -0,0 +1,139 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_email_obfuscation_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_email_obfuscation_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + email_obfuscation="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "Email Obfuscation is enabled" in result[0].status_extended + + def test_zone_email_obfuscation_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + email_obfuscation="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Email Obfuscation is not enabled" in result[0].status_extended + + def test_zone_email_obfuscation_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + email_obfuscation=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_email_obfuscation_enabled.zone_email_obfuscation_enabled import ( + zone_email_obfuscation_enabled, + ) + + check = zone_email_obfuscation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Email Obfuscation is not enabled" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists_test.py new file mode 100644 index 0000000000..a2e7914d86 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists_test.py @@ -0,0 +1,323 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_zone_record_caa_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_caa_record_issue_tag(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issue "letsencrypt.org"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CAA record with certificate issuance restrictions exists for zone {ZONE_NAME}: {ZONE_NAME}." + ) + + def test_zone_with_multiple_caa_records(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issue "letsencrypt.org"', + ), + CloudflareDNSRecord( + id="record-2", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 issuewild ";"', + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CAA record with certificate issuance restrictions exists for zone {ZONE_NAME}: {ZONE_NAME}, {ZONE_NAME}." + ) + + def test_zone_with_caa_record_only_iodef(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="CAA", + content='0 iodef "mailto:security@example.com"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"CAA record exists for zone {ZONE_NAME} but does not specify authorized CAs with issue or issuewild tags: {ZONE_NAME}." + ) + + def test_zone_without_caa_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No CAA record found for zone {ZONE_NAME}." + ) + + def test_zone_with_caa_record_for_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="other.com", + type="CAA", + content='0 issue "letsencrypt.org"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_caa_exists.zone_record_caa_exists import ( + zone_record_caa_exists, + ) + + check = zone_record_caa_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No CAA record found for zone {ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists_test.py new file mode 100644 index 0000000000..6930551993 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists_test.py @@ -0,0 +1,646 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +# Valid DKIM public key for testing (real RSA 2048-bit key in DER SubjectPublicKeyInfo format) +# This is a complete valid RSA public key that can be loaded by cryptography library +VALID_DKIM_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp4czBy2GlDrezAtyoKtrqZYpTLMsuJz1HjV0wZ/yIpClhKp5f8xGlAJuxOjxWokz5SoyW/XpmUtIPkFYwj90jlvUVkFhh9Q81BlJ/0DmhNnmIOs9MnVzgnLiUfNv06NQeKg3d65reCWNjEyrb1fDP6U4ePKM/lunTQc5CbHEUnSnU43vXpUO8v1TYb6OGeAKhumfVSdXFBF905c43/sqkt2QeRMabIoWPkYlSI0KSV0qhNpcRtOdfntFSyPljwa7iNVLlV9AckdL4+abOiy8zuYW0GDF5/1Jgl/Xbdab2M70AXuFnYldq6EgkhvyyiGEm7/15H5STgKxp8idarb6XQIDAQAB" + + +class Test_zone_record_dkim_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_dkim_record_valid_key(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_multiple_dkim_records(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ), + CloudflareDNSRecord( + id="record-2", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"selector1._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ), + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}, selector1._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_revoked_key(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content="v=DKIM1; k=rsa; p=", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DKIM record exists for zone {ZONE_NAME} but has invalid or missing public key: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_invalid_key_not_real_public_key(self): + """Test that valid Base64 that is not a real public key fails.""" + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Valid Base64 but not a valid DER-encoded public key + content="v=DKIM1; k=rsa; p=SGVsbG9Xb3JsZFRoaXNJc05vdEFWYWxpZFB1YmxpY0tleUJ1dEl0SXNWYWxpZEJhc2U2NA==", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DKIM record exists for zone {ZONE_NAME} but has invalid or missing public key: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_invalid_base64(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Invalid Base64 - contains characters not valid in Base64 and is long enough + content="v=DKIM1; k=rsa; p=ThisIsNotValidBase64!!!@@@###$$$%%%^^^&&&***Because_It_Contains_Invalid_Characters_And_Is_Long_Enough_To_Pass_Length_Check", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DKIM record exists for zone {ZONE_NAME} but has invalid or missing public key: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_without_dkim_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DKIM record found for zone {ZONE_NAME}." + ) + + def test_zone_with_domainkey_but_not_dkim(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + content="some other txt record", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DKIM record found for zone {ZONE_NAME}." + ) + + def test_zone_with_dkim_record_lowercase(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"default._domainkey.{ZONE_NAME}", + type="TXT", + content=f"v=dkim1; k=rsa; p={VALID_DKIM_KEY}", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: default._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="google._domainkey.other.com", + type="TXT", + content=f"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DKIM record found for zone {ZONE_NAME}." + ) + + def test_zone_with_dkim_record_quoted_content(self): + """Test that DKIM records with quoted content from Cloudflare API work.""" + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Cloudflare API returns content wrapped in quotes + content=f'"v=DKIM1; k=rsa; p={VALID_DKIM_KEY}"', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}." + ) + + def test_zone_with_dkim_record_split_quoted_content(self): + """Test that long DKIM records split into multiple quoted strings work.""" + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + # Split the key to simulate how Cloudflare returns long TXT records + # The split happens in the middle of the p= value with '" "' between parts + key_part1 = VALID_DKIM_KEY[:200] + key_part2 = VALID_DKIM_KEY[200:] + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"google._domainkey.{ZONE_NAME}", + type="TXT", + # Cloudflare splits long TXT records with '" "' between parts + content=f'v=DKIM1; k=rsa; p={key_part1}" "{key_part2}', + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dkim_exists.zone_record_dkim_exists import ( + zone_record_dkim_exists, + ) + + check = zone_record_dkim_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DKIM record with valid public key exists for zone {ZONE_NAME}: google._domainkey.{ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists_test.py new file mode 100644 index 0000000000..d01fa36a9e --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists_test.py @@ -0,0 +1,417 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_zone_record_dmarc_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_dmarc_record_reject_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=DMARC1; p=reject; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record with enforcement policy p=reject exists for zone {ZONE_NAME}: _dmarc.{ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_quarantine_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record with enforcement policy p=quarantine exists for zone {ZONE_NAME}: _dmarc.{ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_none_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=DMARC1; p=none; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"DMARC record exists for zone {ZONE_NAME} but uses monitoring-only policy p=none: _dmarc.{ZONE_NAME}." + ) + + def test_zone_without_dmarc_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DMARC record found for zone {ZONE_NAME}." + ) + + def test_zone_with_txt_record_but_not_dmarc(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="some other txt record", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DMARC record found for zone {ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_lowercase(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=f"_dmarc.{ZONE_NAME}", + type="TXT", + content="v=dmarc1; p=reject; rua=mailto:dmarc@example.com", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"DMARC record with enforcement policy p=reject exists for zone {ZONE_NAME}: _dmarc.{ZONE_NAME}." + ) + + def test_zone_with_dmarc_record_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="_dmarc.other.com", + type="TXT", + content="v=DMARC1; p=reject", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_dmarc_exists.zone_record_dmarc_exists import ( + zone_record_dmarc_exists, + ) + + check = zone_record_dmarc_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No DMARC record found for zone {ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists_test.py b/tests/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists_test.py new file mode 100644 index 0000000000..8543f68954 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists_test.py @@ -0,0 +1,315 @@ +from typing import Optional +from unittest import mock + +from pydantic import BaseModel + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class CloudflareDNSRecord(BaseModel): + """Cloudflare DNS record representation for testing.""" + + id: str + zone_id: str + zone_name: str + name: Optional[str] = None + type: Optional[str] = None + content: str = "" + ttl: Optional[int] = None + proxied: bool = False + + +class Test_zone_record_spf_exists: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + dns_client = mock.MagicMock + dns_client.records = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 0 + + def test_zone_with_spf_record_strict_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="v=spf1 include:_spf.google.com -all", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SPF record with strict policy -all exists for zone {ZONE_NAME}: {ZONE_NAME}." + ) + + def test_zone_with_spf_record_permissive_policy(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="v=spf1 include:_spf.google.com ~all", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"SPF record exists for zone {ZONE_NAME} but does not use strict policy -all: {ZONE_NAME}." + ) + + def test_zone_without_spf_record(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="A", + content="192.0.2.1", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SPF record found for zone {ZONE_NAME}." + ) + + def test_zone_with_txt_record_but_not_spf(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id=ZONE_ID, + zone_name=ZONE_NAME, + name=ZONE_NAME, + type="TXT", + content="google-site-verification=abc123", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SPF record found for zone {ZONE_NAME}." + ) + + def test_zone_with_spf_record_different_zone(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings(), + ) + } + + dns_client = mock.MagicMock + dns_client.records = [ + CloudflareDNSRecord( + id="record-1", + zone_id="other-zone-id", + zone_name="other.com", + name="other.com", + type="TXT", + content="v=spf1 include:_spf.google.com -all", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.zone_client", + new=zone_client, + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists.dns_client", + new=dns_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_record_spf_exists.zone_record_spf_exists import ( + zone_record_spf_exists, + ) + + check = zone_record_spf_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SPF record found for zone {ZONE_NAME}." + ) diff --git a/tests/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled_test.py b/tests/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled_test.py new file mode 100644 index 0000000000..e9cccd4992 --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_security_under_attack_disabled/zone_security_under_attack_disabled_test.py @@ -0,0 +1,210 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_security_under_attack_disabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_under_attack_mode_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="under_attack", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Zone {ZONE_NAME} has Under Attack Mode enabled." + ) + + def test_zone_security_level_high(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="high", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Zone {ZONE_NAME} does not have Under Attack Mode enabled." + ) + + def test_zone_security_level_medium(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="medium", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_zone_security_level_low(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level="low", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_zone_security_level_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + security_level=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_security_under_attack_disabled.zone_security_under_attack_disabled import ( + zone_security_under_attack_disabled, + ) + + check = zone_security_under_attack_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled_test.py new file mode 100644 index 0000000000..70a02b34fd --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_tls_1_3_enabled/zone_tls_1_3_enabled_test.py @@ -0,0 +1,173 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_tls_1_3_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_tls_1_3_enabled_on(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3="on", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert "TLS 1.3 is enabled" in result[0].status_extended + + def test_zone_tls_1_3_enabled_zrt(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3="zrt", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TLS 1.3 is enabled" in result[0].status_extended + + def test_zone_tls_1_3_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3="off", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLS 1.3 is not enabled" in result[0].status_extended + + def test_zone_tls_1_3_none(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + tls_1_3=None, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_tls_1_3_enabled.zone_tls_1_3_enabled import ( + zone_tls_1_3_enabled, + ) + + check = zone_tls_1_3_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLS 1.3 is not enabled" in result[0].status_extended diff --git a/tests/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled_test.py new file mode 100644 index 0000000000..dfa804181c --- /dev/null +++ b/tests/providers/cloudflare/services/zone/zone_universal_ssl_enabled/zone_universal_ssl_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock + +from prowler.providers.cloudflare.services.zone.zone_service import ( + CloudflareZone, + CloudflareZoneSettings, +) +from tests.providers.cloudflare.cloudflare_fixtures import ( + ZONE_ID, + ZONE_NAME, + set_mocked_cloudflare_provider, +) + + +class Test_zone_universal_ssl_enabled: + def test_no_zones(self): + zone_client = mock.MagicMock + zone_client.zones = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled import ( + zone_universal_ssl_enabled, + ) + + check = zone_universal_ssl_enabled() + result = check.execute() + assert len(result) == 0 + + def test_zone_universal_ssl_enabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + universal_ssl_enabled=True, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled import ( + zone_universal_ssl_enabled, + ) + + check = zone_universal_ssl_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == ZONE_ID + assert result[0].resource_name == ZONE_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Universal SSL is enabled for zone {ZONE_NAME}." + ) + + def test_zone_universal_ssl_disabled(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + settings=CloudflareZoneSettings( + universal_ssl_enabled=False, + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_cloudflare_provider(), + ), + mock.patch( + "prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_universal_ssl_enabled.zone_universal_ssl_enabled import ( + zone_universal_ssl_enabled, + ) + + check = zone_universal_ssl_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Universal SSL is not enabled for zone {ZONE_NAME}." + )