From 6befa78978f83eb6857247b7c1d92aea88734a2c Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 14 May 2026 11:27:47 +0100 Subject: [PATCH] fix(cloudflare): plan-aware WAF FAIL hints for zones (#9896) --- prowler/CHANGELOG.md | 1 + prowler/providers/cloudflare/lib/plan.py | 35 +++++++++ .../zone/zone_waf_enabled/zone_waf_enabled.py | 25 +++++- .../zone_waf_enabled/zone_waf_enabled_test.py | 76 +++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/cloudflare/lib/plan.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index ae3f1d6a86..32b5e24260 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🐞 Fixed +- `zone_waf_enabled` check for Cloudflare provider now appends a plan-aware hint to the FAIL `status_extended`: a possible-false-positive note on paid plans (Pro, Business, Enterprise) where the legacy `waf` zone setting can read `off` even though WAF managed rulesets are deployed via the dashboard, and a "not available on the Cloudflare Free plan" note on Free zones [(#9896)](https://github.com/prowler-cloud/prowler/pull/9896) - Google Workspace Gmail checks sharing a single resource row, causing the service field to be overwritten by the last check executed [(#11169)](https://github.com/prowler-cloud/prowler/pull/11169) --- diff --git a/prowler/providers/cloudflare/lib/plan.py b/prowler/providers/cloudflare/lib/plan.py new file mode 100644 index 0000000000..e6fa20d77d --- /dev/null +++ b/prowler/providers/cloudflare/lib/plan.py @@ -0,0 +1,35 @@ +from typing import Optional + +# Cloudflare returns the plan name in ``zone.plan.name`` (e.g. "Free Website", +# "Pro Website", "Business Website", "Enterprise Website"). Free plans do not +# expose WAF managed rulesets at all, while paid plans expose them but the +# legacy ``waf`` zone setting can lag behind the actual deployment state. +PAID_PLAN_KEYWORDS = ("pro", "business", "enterprise") +FREE_PLAN_KEYWORDS = ("free",) + + +def _plan_matches(plan: Optional[str], keywords: tuple[str, ...]) -> bool: + if not isinstance(plan, str): + return False + plan_lower = plan.lower() + return any(keyword in plan_lower for keyword in keywords) + + +def is_paid_plan(plan: Optional[str]) -> bool: + """Return True when the Cloudflare zone plan is a paid tier.""" + return _plan_matches(plan, PAID_PLAN_KEYWORDS) + + +def is_free_plan(plan: Optional[str]) -> bool: + """Return True when the Cloudflare zone plan is the Free tier.""" + return _plan_matches(plan, FREE_PLAN_KEYWORDS) + + +def paid_plan_suffix(plan: Optional[str], message: str) -> str: + """Return an explanatory suffix only when the zone is on a paid plan.""" + return f" {message}" if is_paid_plan(plan) else "" + + +def free_plan_suffix(plan: Optional[str], message: str) -> str: + """Return an explanatory suffix only when the zone is on the Free plan.""" + return f" {message}" if is_free_plan(plan) else "" diff --git a/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py index 64265c3985..4d3a7eed71 100644 --- a/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py +++ b/prowler/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled.py @@ -1,6 +1,19 @@ from prowler.lib.check.models import Check, CheckReportCloudflare +from prowler.providers.cloudflare.lib.plan import ( + free_plan_suffix, + paid_plan_suffix, +) from prowler.providers.cloudflare.services.zone.zone_client import zone_client +PAID_PLAN_FALSE_POSITIVE_HINT = ( + "This may be a false positive if WAF managed rulesets are configured via " + "the Cloudflare dashboard; verify manually in Security > WAF." +) +FREE_PLAN_UNAVAILABLE_HINT = ( + "This may be expected because the Web Application Firewall is not " + "available on the Cloudflare Free plan." +) + class zone_waf_enabled(Check): """Ensure that WAF is enabled for Cloudflare zones. @@ -35,6 +48,16 @@ class zone_waf_enabled(Check): report.status_extended = f"WAF is enabled for zone {zone.name}." else: report.status = "FAIL" - report.status_extended = f"WAF is not enabled for zone {zone.name}." + # Two plan-specific hints can be appended to the FAIL message: + # - Paid plans: the legacy ``waf`` zone setting can read ``off`` + # while WAF managed rulesets are deployed via the dashboard, + # so the FAIL may be a false positive. + # - Free plans: WAF is not available at all, so the FAIL is + # expected and the suffix points that out. + report.status_extended = ( + f"WAF is not enabled for zone {zone.name}." + f"{paid_plan_suffix(zone.plan, PAID_PLAN_FALSE_POSITIVE_HINT)}" + f"{free_plan_suffix(zone.plan, FREE_PLAN_UNAVAILABLE_HINT)}" + ) findings.append(report) return findings diff --git a/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py b/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py index 0d7d91bc5f..6bd3aace97 100644 --- a/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py +++ b/tests/providers/cloudflare/services/zone/zone_waf_enabled/zone_waf_enabled_test.py @@ -136,3 +136,79 @@ class Test_zone_waf_enabled: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" + + def test_zone_waf_disabled_paid_plan_includes_hint(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + plan="Pro Website", + settings=CloudflareZoneSettings( + waf="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_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "WAF is not enabled" in result[0].status_extended + assert "false positive" in result[0].status_extended + assert "Cloudflare dashboard" in result[0].status_extended + + def test_zone_waf_disabled_free_plan_includes_unavailable_hint(self): + zone_client = mock.MagicMock + zone_client.zones = { + ZONE_ID: CloudflareZone( + id=ZONE_ID, + name=ZONE_NAME, + status="active", + paused=False, + plan="Free Website", + settings=CloudflareZoneSettings( + waf="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_waf_enabled.zone_waf_enabled.zone_client", + new=zone_client, + ), + ): + from prowler.providers.cloudflare.services.zone.zone_waf_enabled.zone_waf_enabled import ( + zone_waf_enabled, + ) + + check = zone_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "WAF is not enabled" in result[0].status_extended + assert "not available on the Cloudflare Free plan" in ( + result[0].status_extended + ) + assert "false positive" not in result[0].status_extended