fix(cloudflare): plan-aware WAF FAIL hints for zones (#9896)

This commit is contained in:
Hugo Pereira Brito
2026-05-14 11:27:47 +01:00
committed by GitHub
parent 78af0c24fe
commit 6befa78978
4 changed files with 136 additions and 1 deletions
+1
View File
@@ -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)
---
+35
View File
@@ -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 ""
@@ -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
@@ -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