diff --git a/CHANGELOG.md b/CHANGELOG.md index ad685417..543c5ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add all ten evidence-rich enterprise network and perimeter controls `AZ-NET-018` through `AZ-NET-027` for issue #253, preserving API failures and incomplete data as indeterminate. + All notable changes to OpenShield are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). diff --git a/api/services/network_layer_assurance.py b/api/services/network_layer_assurance.py index 4ad66459..b73c9a98 100644 --- a/api/services/network_layer_assurance.py +++ b/api/services/network_layer_assurance.py @@ -20,7 +20,7 @@ EXPECTED_DOMAIN_IDS = {f"NL-{number:02d}" for number in range(1, 21)} EXPECTED_SUBDOMAIN_IDS = {"ADDRESSING", "ROUTING", "TRANSIT", "PROTECTION", "OBSERVABILITY"} EXPECTED_CONTROL_IDS = {f"NL-C{number:02d}" for number in range(1, 21)} -EXPECTED_RULE_IDS = {f"AZ-NET-{number:03d}" for number in range(1, 18)} | {"AZ-DL-001", "AZ-DL-002"} +EXPECTED_RULE_IDS = {f"AZ-NET-{number:03d}" for number in range(1, 28)} | {"AZ-DL-001", "AZ-DL-002"} ALLOWED_RESPONSIBILITIES = {"Microsoft", "Customer", "Shared"} ALLOWED_APPLICABILITY = {"APPLICABLE", "NOT_APPLICABLE", "UNSUPPORTED"} ALLOWED_VERIFICATION = { @@ -66,7 +66,7 @@ def validate_catalog(catalog: dict[str, Any]) -> None: raise CatalogValidationError("controls must contain the complete NL-C01 through NL-C20 set") if set(rules) != EXPECTED_RULE_IDS: raise CatalogValidationError( - "rule_classifications must contain AZ-NET-001 through AZ-NET-017 and both MACsec rules" + "rule_classifications must contain AZ-NET-001 through AZ-NET-027 and both MACsec rules" ) for evidence_id, source in evidence.items(): diff --git a/compliance/assurance/network_layer.json b/compliance/assurance/network_layer.json index b3780696..4cafa3f7 100644 --- a/compliance/assurance/network_layer.json +++ b/compliance/assurance/network_layer.json @@ -245,7 +245,10 @@ "AZ-NET-006", "AZ-NET-010", "AZ-NET-013", - "AZ-NET-017" + "AZ-NET-017", + "AZ-NET-018", + "AZ-NET-019", + "AZ-NET-022" ] }, { @@ -429,7 +432,11 @@ "AZ-NET-013", "AZ-NET-014", "AZ-NET-016", - "AZ-NET-017" + "AZ-NET-017", + "AZ-NET-018", + "AZ-NET-019", + "AZ-NET-022", + "AZ-NET-023" ] }, { @@ -936,6 +943,89 @@ "NL-10", "NL-19" ] + }, + { + "id": "AZ-NET-018", + "name": "Private Endpoint target retaining public access", + "osi_classification": "Layer 3", + "classification_basis": "The control evaluates whether a PaaS resource retains a public IP network path alongside its private endpoint path.", + "layer_3_domain_ids": [ + "NL-09", + "NL-10", + "NL-19" + ] + }, + { + "id": "AZ-NET-019", + "name": "Private Endpoint connection not approved", + "osi_classification": "Layer 3", + "classification_basis": "Private Endpoint connection state determines whether the target has an operational private IP network path.", + "layer_3_domain_ids": [ + "NL-01", + "NL-09", + "NL-19" + ] + }, + { + "id": "AZ-NET-020", + "name": "Private Endpoint missing Private DNS zone association", + "osi_classification": "Layer 7", + "classification_basis": "The finding evaluates DNS naming configuration even though the resulting record identifies a private IP address.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-021", + "name": "Private Endpoint DNS configuration reports only public addresses", + "osi_classification": "Layer 7", + "classification_basis": "DNS is an application-layer naming protocol; this rule evaluates ARM custom DNS configuration and does not claim effective resolver-path evidence.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-022", + "name": "Critical PaaS resource publicly accessible without exception", + "osi_classification": "Layer 3", + "classification_basis": "The control evaluates whether a critical service retains an unapproved public IP network path.", + "layer_3_domain_ids": [ + "NL-10", + "NL-19" + ] + }, + { + "id": "AZ-NET-023", + "name": "Azure Firewall threat intelligence not enforcing deny", + "osi_classification": "Cross-layer", + "classification_basis": "Azure Firewall threat intelligence enforces the perimeter across IP addresses, domains, and URLs.", + "layer_3_domain_ids": [ + "NL-19" + ] + }, + { + "id": "AZ-NET-024", + "name": "Application Gateway WAF not in Prevention mode", + "osi_classification": "Layer 7", + "classification_basis": "Application Gateway WAF Prevention mode evaluates and blocks HTTP application requests.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-025", + "name": "Application Gateway WAF diagnostic logging incomplete", + "osi_classification": "Layer 7", + "classification_basis": "The control evaluates the diagnostic log categories supported by the Application Gateway SKU; v2 performance telemetry is metric-based.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-026", + "name": "WAF missing current managed rules or bot protection", + "osi_classification": "Layer 7", + "classification_basis": "Managed WAF rules inspect HTTP application requests and bot behavior.", + "layer_3_domain_ids": [] + }, + { + "id": "AZ-NET-027", + "name": "Public Application Gateway missing rate limiting", + "osi_classification": "Layer 7", + "classification_basis": "WAF RateLimitRule evaluates HTTP request volume and application-layer match conditions.", + "layer_3_domain_ids": [] } ], "evidence_sources": [ diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index be50f996..156c38ad 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -373,6 +373,56 @@ "control_name": "Direct Internet default route review (no direct CIS Azure Foundations 2.0.0 control)", "description": "Azure exposes and documents user-defined Internet next hops, but CIS Azure Foundations 2.0.0 does not assign this route check a direct recommendation number." }, + "AZ-NET-018": { + "control_id": "N/A-NET-018", + "control_name": "Private Endpoint public access baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private connectivity should replace unnecessary public PaaS exposure; CIS Azure Foundations 2.0.0 has no universal control covering every supported Private Link target." + }, + "AZ-NET-019": { + "control_id": "N/A-NET-019", + "control_name": "Private Endpoint connection approval baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint connections must be approved to provide the intended private path; no universal CIS Azure Foundations 2.0.0 recommendation covers this state." + }, + "AZ-NET-020": { + "control_id": "N/A-NET-020", + "control_name": "Private Endpoint DNS association baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoints require service-appropriate private DNS integration; CIS Azure Foundations 2.0.0 has no universal recommendation for this association." + }, + "AZ-NET-021": { + "control_id": "N/A-NET-021", + "control_name": "Private Endpoint custom DNS configuration baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint custom DNS configuration should associate service names with private addresses. This is ARM configuration evidence, not an effective-resolution probe; CIS Azure Foundations 2.0.0 has no universal recommendation for it." + }, + "AZ-NET-022": { + "control_id": "N/A-NET-022", + "control_name": "Critical PaaS public exposure baseline (no universal CIS Azure Foundations 2.0.0 control)", + "description": "Critical PaaS resources should use private access or an approved exception; CIS Azure Foundations 2.0.0 provides service-specific rather than universal coverage." + }, + "AZ-NET-023": { + "control_id": "N/A-NET-023", + "control_name": "Azure Firewall threat intelligence enforcement baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Deny mode blocks traffic involving known malicious addresses and domains; CIS Azure Foundations 2.0.0 has no direct recommendation for this mode." + }, + "AZ-NET-024": { + "control_id": "N/A-NET-024", + "control_name": "Application Gateway WAF Prevention mode baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Prevention mode blocks matching application attacks; CIS Azure Foundations 2.0.0 has no direct recommendation for the gateway mode." + }, + "AZ-NET-025": { + "control_id": "N/A-NET-025", + "control_name": "Application Gateway WAF diagnostic logging baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "SKU-supported Application Gateway diagnostic logs support perimeter monitoring. Performance logging is required on v1; v2 exposes performance telemetry through metrics. CIS Azure Foundations 2.0.0 has no direct universal recommendation for these categories." + }, + "AZ-NET-026": { + "control_id": "N/A-NET-026", + "control_name": "Current WAF managed rules and bot protection baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Current base and bot managed rule sets protect the application perimeter; CIS Azure Foundations 2.0.0 has no direct rule-set-version recommendation." + }, + "AZ-NET-027": { + "control_id": "N/A-NET-027", + "control_name": "Internet-facing application rate limiting baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Rate limiting protects public applications from abusive request volume; CIS Azure Foundations 2.0.0 has no direct Application Gateway rate-rule recommendation." + }, "AZ-SECOPS-001": { "control_id": "5.1.1", "control_name": "Ensure that a 'Diagnostic Setting' exists", @@ -422,6 +472,56 @@ "control_id": "2.1.20", "control_name": "Ensure That 'Notify about alerts with the following severity' is Set to 'High'", "description": "No enabled Azure Monitor action group with a notification receiver exists, and no Sentinel automation rule routes incidents onward. CIS 2.1.20 requires Defender security alerts to notify a monitored destination; this rule generalizes that requirement to the concrete Azure notification primitive (action groups) and the Sentinel-native incident routing mechanism (automation rules)." + }, + "AZ-NET-018": { + "control_id": "N/A-NET-018", + "control_name": "Private Endpoint public access baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private connectivity should replace unnecessary public PaaS exposure; CIS Azure Foundations 2.0.0 has no universal control covering every supported Private Link target." + }, + "AZ-NET-019": { + "control_id": "N/A-NET-019", + "control_name": "Private Endpoint connection approval baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint connections must be approved to provide the intended private path; no universal CIS Azure Foundations 2.0.0 recommendation covers this state." + }, + "AZ-NET-020": { + "control_id": "N/A-NET-020", + "control_name": "Private Endpoint DNS association baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoints require service-appropriate private DNS integration; CIS Azure Foundations 2.0.0 has no universal recommendation for this association." + }, + "AZ-NET-021": { + "control_id": "N/A-NET-021", + "control_name": "Private Endpoint FQDN resolution baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Private Endpoint names should resolve to private addresses; CIS Azure Foundations 2.0.0 has no universal recommendation for this resolution evidence." + }, + "AZ-NET-022": { + "control_id": "N/A-NET-022", + "control_name": "Critical PaaS public exposure baseline (no universal CIS Azure Foundations 2.0.0 control)", + "description": "Critical PaaS resources should use private access or an approved exception; CIS Azure Foundations 2.0.0 provides service-specific rather than universal coverage." + }, + "AZ-NET-023": { + "control_id": "N/A-NET-023", + "control_name": "Azure Firewall threat intelligence enforcement baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "AlertAndDeny blocks traffic involving known malicious addresses and domains; CIS Azure Foundations 2.0.0 has no direct recommendation for this mode." + }, + "AZ-NET-024": { + "control_id": "N/A-NET-024", + "control_name": "Application Gateway WAF Prevention mode baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Prevention mode blocks matching application attacks; CIS Azure Foundations 2.0.0 has no direct recommendation for the gateway mode." + }, + "AZ-NET-025": { + "control_id": "N/A-NET-025", + "control_name": "Application Gateway WAF diagnostic logging baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Access, performance, and firewall logs support perimeter monitoring; CIS Azure Foundations 2.0.0 has no direct universal recommendation for all categories." + }, + "AZ-NET-026": { + "control_id": "N/A-NET-026", + "control_name": "Current WAF managed rules and bot protection baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Current base and bot managed rule sets protect the application perimeter; CIS Azure Foundations 2.0.0 has no direct rule-set-version recommendation." + }, + "AZ-NET-027": { + "control_id": "N/A-NET-027", + "control_name": "Internet-facing application rate limiting baseline (no direct CIS Azure Foundations 2.0.0 control)", + "description": "Rate limiting protects public applications from abusive request volume; CIS Azure Foundations 2.0.0 has no direct Application Gateway rate-rule recommendation." } } } diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 16462fc4..2f2ee5c6 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -433,6 +433,56 @@ "control_name": "Network controls", "description": "Default routes must preserve the approved controlled egress boundary." }, + "AZ-NET-018": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "PaaS resources using Private Link should not retain unnecessary public network exposure." + }, + "AZ-NET-019": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint connections must be approved and operational." + }, + "AZ-NET-020": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoints require an associated service-appropriate Private DNS zone." + }, + "AZ-NET-021": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint ARM DNS configuration must associate service names with private addresses; effective resolver-path validation remains separate evidence." + }, + "AZ-NET-022": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Critical PaaS resources restrict public exposure unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Azure Firewall denies traffic identified by Microsoft threat intelligence." + }, + "AZ-NET-024": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Application Gateway WAF operates in Prevention mode at the application boundary." + }, + "AZ-NET-025": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Application Gateway WAF diagnostic categories supported by its SKU are exported to an approved monitoring destination." + }, + "AZ-NET-026": { + "control_id": "A.14.2.5", + "control_name": "Secure system engineering principles", + "description": "Current managed application and bot rules are maintained at the web perimeter." + }, + "AZ-NET-027": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Rate limiting protects internet-facing application entry points." + }, "AZ-SECOPS-001": { "control_id": "A.12.4.1", "control_name": "Event logging", @@ -482,6 +532,56 @@ "control_id": "A.16.1.2", "control_name": "Reporting information security events", "description": "No monitored destination exists for security alerts or Sentinel incidents. A.16.1.2 requires information security events to be reported through appropriate management channels as quickly as possible; an alert with no notified recipient cannot be reported or acted on." + }, + "AZ-NET-018": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "PaaS resources using Private Link should not retain unnecessary public network exposure." + }, + "AZ-NET-019": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint connections must be approved and operational." + }, + "AZ-NET-020": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoints require an associated service-appropriate Private DNS zone." + }, + "AZ-NET-021": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Private Endpoint FQDNs must resolve to private addresses through the controlled network path." + }, + "AZ-NET-022": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Critical PaaS resources restrict public exposure unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Azure Firewall denies traffic identified by Microsoft threat intelligence." + }, + "AZ-NET-024": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Application Gateway WAF operates in Prevention mode at the application boundary." + }, + "AZ-NET-025": { + "control_id": "A.12.4.1", + "control_name": "Event logging", + "description": "Application Gateway WAF diagnostic categories are exported to an approved monitoring destination." + }, + "AZ-NET-026": { + "control_id": "A.14.2.5", + "control_name": "Secure system engineering principles", + "description": "Current managed application and bot rules are maintained at the web perimeter." + }, + "AZ-NET-027": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "Rate limiting protects internet-facing application entry points." } } } diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 5dd6df70..8fa9992b 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -433,6 +433,56 @@ "control_name": "Network integrity is protected", "description": "An explicit default Internet UDR can bypass the approved inspected egress path." }, + "AZ-NET-018": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Disabling unnecessary public access ensures the Private Endpoint is the managed remote access path." + }, + "AZ-NET-019": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Approved Private Endpoint connections preserve the intended private network boundary." + }, + "AZ-NET-020": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private DNS zone association directs service names through the intended private endpoint path." + }, + "AZ-NET-021": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private Endpoint ARM DNS configuration associates service names with private addresses; effective resolver-path validation remains separate evidence." + }, + "AZ-NET-022": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Critical PaaS public access is disabled unless an explicit approved exception exists." + }, + "AZ-NET-023": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Azure Firewall threat intelligence alerts on and denies traffic involving known malicious infrastructure." + }, + "AZ-NET-024": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Application Gateway WAF Prevention mode actively blocks matching application attacks." + }, + "AZ-NET-025": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Application Gateway SKU-supported diagnostic logs provide perimeter monitoring evidence; v2 performance telemetry is supplied through metrics." + }, + "AZ-NET-026": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Current base and bot managed rules protect the web application perimeter." + }, + "AZ-NET-027": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Rate-limit rules protect internet-facing applications from abusive request volume." + }, "AZ-SECOPS-001": { "control_id": "PR.PT-1", "control_name": "Audit/log records are determined, documented, implemented, and reviewed in accordance with policy", @@ -482,6 +532,56 @@ "control_id": "RS.CO-2", "control_name": "Incidents are reported consistent with established criteria", "description": "No monitored destination exists for security alerts or Sentinel incidents. RS.CO-2 requires incidents to be reported consistent with established criteria; an alert with no notified recipient is never reported to anyone who can act on it." + }, + "AZ-NET-018": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Disabling unnecessary public access ensures the Private Endpoint is the managed remote access path." + }, + "AZ-NET-019": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Approved Private Endpoint connections preserve the intended private network boundary." + }, + "AZ-NET-020": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private DNS zone association directs service names through the intended private endpoint path." + }, + "AZ-NET-021": { + "control_id": "PR.AC-5", + "control_name": "Network integrity is protected", + "description": "Private address resolution provides evidence that service traffic follows the private network boundary." + }, + "AZ-NET-022": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "Critical PaaS public access is disabled unless an explicit approved exception exists." + }, + "AZ-NET-023": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Azure Firewall threat intelligence alerts on and denies traffic involving known malicious infrastructure." + }, + "AZ-NET-024": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Application Gateway WAF Prevention mode actively blocks matching application attacks." + }, + "AZ-NET-025": { + "control_id": "DE.CM-1", + "control_name": "The network is monitored", + "description": "Application Gateway access, performance, and firewall logs provide perimeter monitoring evidence." + }, + "AZ-NET-026": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Current base and bot managed rules protect the web application perimeter." + }, + "AZ-NET-027": { + "control_id": "PR.PT-4", + "control_name": "Communications and control networks are protected", + "description": "Rate-limit rules protect internet-facing applications from abusive request volume." } } } diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index ef259f71..4e312355 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -433,6 +433,56 @@ "control_name": "Logical Access Security Measures", "description": "User-defined default routes preserve approved inspected egress paths." }, + "AZ-NET-018": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Link targets restrict unnecessary public network access." + }, + "AZ-NET-019": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint connections are approved and operational before they are relied upon as an access boundary." + }, + "AZ-NET-020": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private DNS association preserves the approved private access path." + }, + "AZ-NET-021": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint ARM DNS configuration associates service names with private addresses; effective resolver-path validation remains separate evidence." + }, + "AZ-NET-022": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Critical PaaS resources restrict public access unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Azure Firewall denies traffic involving infrastructure identified by threat intelligence." + }, + "AZ-NET-024": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Application Gateway WAF Prevention mode blocks matching malicious requests." + }, + "AZ-NET-025": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "Application Gateway SKU-supported diagnostic logs support anomaly monitoring; v2 performance telemetry is supplied through metrics." + }, + "AZ-NET-026": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Current application and bot managed rules protect the logical access boundary." + }, + "AZ-NET-027": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Rate-limit rules protect public application access from abusive request volume." + }, "AZ-SECOPS-001": { "control_id": "CC7.2", "control_name": "System Monitoring", @@ -482,6 +532,56 @@ "control_id": "CC7.4", "control_name": "Incident Response", "description": "No monitored destination exists for security alerts or Sentinel incidents. CC7.4 requires the entity to respond to identified security incidents; an alert nobody is notified of cannot trigger the incident-response process CC7.4 requires." + }, + "AZ-NET-018": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Link targets restrict unnecessary public network access." + }, + "AZ-NET-019": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint connections are approved and operational before they are relied upon as an access boundary." + }, + "AZ-NET-020": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private DNS association preserves the approved private access path." + }, + "AZ-NET-021": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Private Endpoint service names resolve to private addresses within the approved access boundary." + }, + "AZ-NET-022": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Critical PaaS resources restrict public access unless an approved exception exists." + }, + "AZ-NET-023": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Azure Firewall denies traffic involving infrastructure identified by threat intelligence." + }, + "AZ-NET-024": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Application Gateway WAF Prevention mode blocks matching malicious requests." + }, + "AZ-NET-025": { + "control_id": "CC7.2", + "control_name": "System monitoring", + "description": "Application Gateway access, performance, and firewall logs support anomaly monitoring." + }, + "AZ-NET-026": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Current application and bot managed rules protect the logical access boundary." + }, + "AZ-NET-027": { + "control_id": "CC6.6", + "control_name": "Logical Access Security Measures", + "description": "Rate-limit rules protect public application access from abusive request volume." } } } diff --git a/docs/network-layer-assurance.md b/docs/network-layer-assurance.md index 1e009d36..5ef1ba5a 100644 --- a/docs/network-layer-assurance.md +++ b/docs/network-layer-assurance.md @@ -6,7 +6,7 @@ OpenShield's authenticated `GET /api/assurance/network-layer` endpoint returns a Customers control exposed address spaces, subnets, routes, peerings, gateways, public IP associations, and supported diagnostics. Microsoft owns the Azure fabric, underlying forwarding implementation, tenant isolation, physical packet handling, and system internals that subscriptions cannot inspect. Provider-owned domains are documented but never create findings or change the tenant score. -The catalog classifies every `AZ-NET-001` through `AZ-NET-015` rule by actual behavior. Port-specific NSG rules remain Layer 4, the public DNS rule remains Layer 7, and ExpressRoute Direct MACsec rules remain Layer 2. Cross-layer rules only reference Layer 3 domains when part of their behavior genuinely covers IP addressing, routing, or segmentation. +The catalog classifies every `AZ-NET-001` through `AZ-NET-027` rule by actual behavior. Port-specific NSG rules remain Layer 4, DNS and WAF rules remain Layer 7, and ExpressRoute Direct MACsec rules remain Layer 2. Cross-layer rules only reference Layer 3 domains when part of their behavior genuinely covers IP addressing, routing, or segmentation. ## Automation limits diff --git a/docs/private-link-controls.md b/docs/private-link-controls.md new file mode 100644 index 00000000..f0ffb4d8 --- /dev/null +++ b/docs/private-link-controls.md @@ -0,0 +1,21 @@ +# Private Link and private DNS controls + +`AZ-NET-018` through `AZ-NET-021` implement the first delivery tranche of issue #253. They use the Azure Resource Manager Private Endpoint inventory, connection state, Private DNS zone groups, custom DNS configuration, and service-specific public-access properties. + +The controls distinguish four outcomes: a finding is `FAIL`; successful evaluation without a finding is `PASS`; empty Private Endpoint inventory is `NOT_APPLICABLE`; and API failures, unsupported target types, absent state, or incomplete DNS evidence are `UNKNOWN` and never create a finding. The current scanner persists failures rather than pass records, so `UNKNOWN` and `NOT_APPLICABLE` are emitted to scanner logs while evidence for failures is included in finding metadata. + +Public-access evaluation is deliberately limited to target types with authoritative service-specific management APIs: Storage accounts, Key Vaults, and Azure SQL logical servers. Other target types are unknown until a service collector is added. AZ-NET-021 evaluates only the `customDnsConfigs` returned for the Private Endpoint. Those values describe Azure's expected DNS configuration; they do not prove effective resolution from a workload VNet or on-premises resolver, whose DNS context may differ from the scanner host. + +Required permissions are `Microsoft.Network/privateEndpoints/read`, `Microsoft.Network/privateEndpoints/privateDnsZoneGroups/read`, and read permission on the target PaaS resource. Remediation playbooks require an operator to validate connectivity before disabling access or modifying DNS. + +## Enterprise perimeter controls + +The remaining issue controls are implemented by `AZ-NET-022` through `AZ-NET-027`: + +- Critical PaaS public exposure covers Storage accounts, Key Vaults, Azure SQL logical servers, PostgreSQL servers, and App Service. Exceptions are exact, case-insensitive resource IDs in `OPENSHIELD_PUBLIC_PAAS_EXCEPTIONS`; partial matches are never accepted. +- Azure Firewall threat intelligence must be `Deny`. +- An enabled Application Gateway WAF must use Prevention mode and export every diagnostic log category supported by its SKU. WAF v1 requires access, performance, and firewall logs; WAF_v2 requires access and firewall logs, while performance telemetry is supplied through Azure Monitor metrics. +- Application Gateway WAF policies must include OWASP 3.2 or Microsoft Default Rule Set 2.1 or later plus Microsoft Bot Manager Rule Set 1.0 or later. +- Public Application Gateways with WAF enabled must have an enabled `RateLimitRule` in the associated WAF policy. + +Each PaaS service inventory is independent. A permission failure for one service is `UNKNOWN` for that service and does not suppress valid findings from another. Application Gateway or WAF policy inventory failures similarly remain `UNKNOWN`. Empty service inventories are `NOT_APPLICABLE`. Rate limiting is deliberately limited to public Application Gateways because the repository currently has no authoritative inventory collector for Front Door, API Management, or third-party edge controls; those services remain unknown rather than being inferred from incomplete inventory. diff --git a/docs/rules-reference.md b/docs/rules-reference.md index c9824b96..4519ded2 100644 --- a/docs/rules-reference.md +++ b/docs/rules-reference.md @@ -1,6 +1,6 @@ # Rules Reference -OpenShield currently ships 80 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. +OpenShield currently ships 90 Azure scan rules. This table is generated from the module-level constants in `scanner/rules/`. | Rule ID | Name | Severity | Category | CIS | NIST | ISO 27001 | |---|---|---|---|---|---|---| @@ -47,6 +47,16 @@ OpenShield currently ships 80 Azure scan rules. This table is generated from the | AZ-NET-013 | Azure Firewall Not Enabled on Virtual Network | HIGH | Network | 6.4 | PR.AC-5 | A.13.1.1 | | AZ-NET-014 | VNet Peering Configured Without Gateway Transit Restrictions | MEDIUM | Network | 6.6 | PR.AC-5 | A.13.1.1 | | AZ-NET-015 | Public DNS Zone Exposes Internal Infrastructure Details | MEDIUM | Network | 9.8 | PR.AC-5 | A.13.1.1 | +| AZ-NET-018 | Private Endpoint Target Retains Public Network Access | HIGH | Network | N/A-NET-018 | PR.AC-3 | A.13.1.1 | +| AZ-NET-019 | Private Endpoint Connection Is Not Approved | HIGH | Network | N/A-NET-019 | PR.AC-5 | A.13.1.1 | +| AZ-NET-020 | Private Endpoint Lacks Private DNS Zone Association | HIGH | Network | N/A-NET-020 | PR.AC-5 | A.13.1.1 | +| AZ-NET-021 | Private Endpoint DNS Configuration Reports Only Public Addresses | HIGH | Network | N/A-NET-021 | PR.AC-5 | A.13.1.1 | +| AZ-NET-022 | Critical PaaS Resource Is Publicly Accessible Without Approved Exception | HIGH | Network | N/A-NET-022 | PR.AC-3 | A.13.1.1 | +| AZ-NET-023 | Azure Firewall Threat Intelligence Is Not in Deny Mode | HIGH | Network | N/A-NET-023 | DE.CM-1 | A.13.1.1 | +| AZ-NET-024 | Application Gateway WAF Is Not in Prevention Mode | HIGH | Network | N/A-NET-024 | PR.PT-4 | A.13.1.1 | +| AZ-NET-025 | Application Gateway WAF Diagnostic Logging Is Not Enabled | MEDIUM | Network | N/A-NET-025 | DE.CM-1 | A.12.4.1 | +| AZ-NET-026 | WAF Lacks Current Managed Rules or Bot Protection | HIGH | Network | N/A-NET-026 | PR.PT-4 | A.14.2.5 | +| AZ-NET-027 | Internet-Facing Application Gateway Lacks Approved Rate Limiting | HIGH | Network | N/A-NET-027 | PR.PT-4 | A.13.1.1 | | AZ-PQC-001 | TLS Using Classical Key Exchange Algorithm | HIGH | PostQuantum | 9.9 | PR.DS-2 | A.10.1.1 | | AZ-PQC-002 | Key Vault Key Using Non-Quantum-Safe Algorithm | HIGH | PostQuantum | 8.1 | PR.DS-2 | A.10.1.1 | | AZ-PQC-003 | Key Vault Certificate Using Non-Quantum-Safe Signature Algorithm | MEDIUM | PostQuantum | 8.9 | PR.DS-2 | A.10.1.1 | diff --git a/playbooks/cli/fix_az_net_018.sh b/playbooks/cli/fix_az_net_018.sh new file mode 100755 index 00000000..9697a9db --- /dev/null +++ b/playbooks/cli/fix_az_net_018.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_ID:?Set RESOURCE_ID to the target PaaS resource ID}" +echo "Disable public network access only after validating the Private Endpoint path: $RESOURCE_ID" +echo "Use the service-specific Azure CLI command documented for the target resource type." diff --git a/playbooks/cli/fix_az_net_019.sh b/playbooks/cli/fix_az_net_019.sh new file mode 100755 index 00000000..15550f2d --- /dev/null +++ b/playbooks/cli/fix_az_net_019.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PRIVATE_ENDPOINT_ID:?Set PRIVATE_ENDPOINT_ID}" +echo "Review and approve the intended connection, or recreate a rejected/disconnected endpoint: $PRIVATE_ENDPOINT_ID" +echo "Approval is performed on the target PaaS resource and is intentionally not automated by this playbook." diff --git a/playbooks/cli/fix_az_net_020.sh b/playbooks/cli/fix_az_net_020.sh new file mode 100755 index 00000000..305beddc --- /dev/null +++ b/playbooks/cli/fix_az_net_020.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP}" +: "${PRIVATE_ENDPOINT_NAME:?Set PRIVATE_ENDPOINT_NAME}" +: "${PRIVATE_DNS_ZONE_ID:?Set PRIVATE_DNS_ZONE_ID}" +az network private-endpoint dns-zone-group create --resource-group "$RESOURCE_GROUP" --endpoint-name "$PRIVATE_ENDPOINT_NAME" --name default --private-dns-zone "$PRIVATE_DNS_ZONE_ID" --zone-name default diff --git a/playbooks/cli/fix_az_net_021.sh b/playbooks/cli/fix_az_net_021.sh new file mode 100755 index 00000000..eb6a1f40 --- /dev/null +++ b/playbooks/cli/fix_az_net_021.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP}" +: "${PRIVATE_DNS_ZONE:?Set PRIVATE_DNS_ZONE}" +echo "Inspect records and VNet links before changing production DNS:" +az network private-dns record-set list --resource-group "$RESOURCE_GROUP" --zone-name "$PRIVATE_DNS_ZONE" --output table +az network private-dns link vnet list --resource-group "$RESOURCE_GROUP" --zone-name "$PRIVATE_DNS_ZONE" --output table diff --git a/playbooks/cli/fix_az_net_022.sh b/playbooks/cli/fix_az_net_022.sh new file mode 100755 index 00000000..3d8622a3 --- /dev/null +++ b/playbooks/cli/fix_az_net_022.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_ID:?Set RESOURCE_ID to the public PaaS resource}" +echo "Validate a private access path before disabling public access on: $RESOURCE_ID" +echo "Use the resource type's Azure CLI command, or document the exact resource ID in OPENSHIELD_PUBLIC_PAAS_EXCEPTIONS." diff --git a/playbooks/cli/fix_az_net_023.sh b/playbooks/cli/fix_az_net_023.sh new file mode 100755 index 00000000..169ee2a3 --- /dev/null +++ b/playbooks/cli/fix_az_net_023.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP}" +: "${FIREWALL_NAME:?Set FIREWALL_NAME}" +az network firewall update --resource-group "$RESOURCE_GROUP" --name "$FIREWALL_NAME" --threat-intel-mode Deny diff --git a/playbooks/cli/fix_az_net_024.sh b/playbooks/cli/fix_az_net_024.sh new file mode 100755 index 00000000..e1cccd6c --- /dev/null +++ b/playbooks/cli/fix_az_net_024.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP}" +: "${WAF_POLICY_NAME:?Set WAF_POLICY_NAME}" +az network application-gateway waf-policy policy-setting update --resource-group "$RESOURCE_GROUP" --policy-name "$WAF_POLICY_NAME" --mode Prevention diff --git a/playbooks/cli/fix_az_net_025.sh b/playbooks/cli/fix_az_net_025.sh new file mode 100755 index 00000000..16ab167c --- /dev/null +++ b/playbooks/cli/fix_az_net_025.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${APPLICATION_GATEWAY_ID:?Set APPLICATION_GATEWAY_ID}" +echo "Create a diagnostic setting for $APPLICATION_GATEWAY_ID with access, performance, and firewall logs." +echo "Select an approved Log Analytics workspace, Event Hub, or Storage destination before applying the change." diff --git a/playbooks/cli/fix_az_net_026.sh b/playbooks/cli/fix_az_net_026.sh new file mode 100755 index 00000000..2159f9ff --- /dev/null +++ b/playbooks/cli/fix_az_net_026.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP}" +: "${WAF_POLICY_NAME:?Set WAF_POLICY_NAME}" +echo "Update $WAF_POLICY_NAME in $RESOURCE_GROUP with OWASP 3.2 or DRS 2.1+ and Bot Manager 1.0+." +echo "Managed-rule changes require application-specific exclusion testing and are intentionally review-gated." diff --git a/playbooks/cli/fix_az_net_027.sh b/playbooks/cli/fix_az_net_027.sh new file mode 100755 index 00000000..db6dbe3c --- /dev/null +++ b/playbooks/cli/fix_az_net_027.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${RESOURCE_GROUP:?Set RESOURCE_GROUP}" +: "${WAF_POLICY_NAME:?Set WAF_POLICY_NAME}" +echo "Add an enabled RateLimitRule to WAF policy $WAF_POLICY_NAME in $RESOURCE_GROUP." +echo "Choose threshold, duration, grouping, and match conditions from approved application capacity data." diff --git a/scanner/azure_client.py b/scanner/azure_client.py index ab2bacdd..1818889d 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -1,6 +1,7 @@ """Azure SDK wrapper providing typed accessors for all CSPM scan operations.""" import logging +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from azure.core.exceptions import HttpResponseError, ResourceNotFoundError @@ -58,6 +59,9 @@ def __init__(self, subscription_id: str, credential: Optional[Any] = None) -> No self._managed_identity_principals_cache: Any = _UNSET self._subscription_role_assignments_cache: Any = _UNSET self._container_registries_cache: Any = _UNSET + self._critical_paas_cache: Any = _UNSET + self._application_gateways_cache: Any = _UNSET + self._waf_policies_cache: Any = _UNSET self._disks_cache: Dict[str, Any] = {} self.devops_client = self._build_devops_client() @@ -305,6 +309,253 @@ def get_public_ip_addresses(self) -> List[Any]: logger.error("get_public_ip_addresses failed: %s", exc) return [] + def get_private_link_inventory(self) -> Optional[List[Dict[str, Any]]]: + """Collect Private Endpoint state without collapsing API failures. + + The network list operation is authoritative for applicability. A failed + list returns ``None`` (UNKNOWN); an empty list means NOT_APPLICABLE. + DNS-zone-group failures are recorded per endpoint as ``None`` so rules + do not confuse missing permissions with a missing association. + """ + collected_at = datetime.now(timezone.utc).isoformat() + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + endpoints = list(client.private_endpoints.list_by_subscription()) + except Exception as exc: + logger.error("get_private_link_inventory failed: %s", exc) + return None + + inventory: List[Dict[str, Any]] = [] + for endpoint in endpoints: + endpoint_id = getattr(endpoint, "id", "") or "" + parsed = self.parse_resource_id(endpoint_id) + resource_group = parsed.get("resource_group", "") + endpoint_name = getattr(endpoint, "name", "") or parsed.get("name", "") + connections = getattr(endpoint, "private_link_service_connections", None) or [] + manual_connections = getattr(endpoint, "manual_private_link_service_connections", None) or [] + connections = [*connections, *manual_connections] + + try: + zone_groups = list(client.private_dns_zone_groups.list(resource_group, endpoint_name)) + zone_ids = [ + getattr(config, "private_dns_zone_id", "") + for group in zone_groups + for config in (getattr(group, "private_dns_zone_configs", None) or []) + if getattr(config, "private_dns_zone_id", "") + ] + except Exception as exc: + logger.error("private DNS zone groups unavailable for %s: %s", endpoint_name, exc) + zone_ids = None + + dns_configs = [] + for config in getattr(endpoint, "custom_dns_configs", None) or []: + dns_configs.append( + { + "fqdn": getattr(config, "fqdn", "") or "", + "ip_addresses": list(getattr(config, "ip_addresses", None) or []), + } + ) + + if not connections: + inventory.append( + { + "endpoint_id": endpoint_id, + "endpoint_name": endpoint_name, + "resource_group": resource_group, + "location": getattr(endpoint, "location", "") or "", + "target_id": "", + "connection_status": None, + "dns_zone_ids": zone_ids, + "dns_configs": dns_configs, + "public_network_access": None, + "collected_at": collected_at, + } + ) + continue + + for connection in connections: + target_id = getattr(connection, "private_link_service_id", "") or "" + state = getattr(connection, "private_link_service_connection_state", None) + inventory.append( + { + "endpoint_id": endpoint_id, + "endpoint_name": endpoint_name, + "resource_group": resource_group, + "location": getattr(endpoint, "location", "") or "", + "target_id": target_id, + "connection_status": getattr(state, "status", None), + "dns_zone_ids": zone_ids, + "dns_configs": dns_configs, + "public_network_access": self._get_private_link_target_public_access(target_id), + "collected_at": collected_at, + } + ) + return inventory + + def _get_private_link_target_public_access(self, resource_id: str) -> Optional[bool]: + """Return public-access state for supported Private Link PaaS targets.""" + parts = [part for part in (resource_id or "").split("/") if part] + lowered = [part.lower() for part in parts] + try: + rg_index = lowered.index("resourcegroups") + provider_index = lowered.index("providers") + resource_group = parts[rg_index + 1] + provider = lowered[provider_index + 1] + resource_type = lowered[provider_index + 2] + name = parts[provider_index + 3] + except (ValueError, IndexError): + return None + + try: + if provider == "microsoft.storage" and resource_type == "storageaccounts": + resource = StorageManagementClient( + self.credential, self.subscription_id + ).storage_accounts.get_properties(resource_group, name) + elif provider == "microsoft.keyvault" and resource_type == "vaults": + resource = KeyVaultManagementClient(self.credential, self.subscription_id).vaults.get( + resource_group, name + ) + elif provider == "microsoft.sql" and resource_type == "servers": + resource = SqlManagementClient(self.credential, self.subscription_id).servers.get(resource_group, name) + else: + return None + value = getattr(resource, "public_network_access", None) + if value is None: + value = getattr(getattr(resource, "properties", None), "public_network_access", None) + normalized = enum_str(value).lower() + if normalized in {"enabled", "true", "1"}: + return True + if normalized in {"disabled", "false", "0"}: + return False + return None + except Exception as exc: + logger.error("public network access unavailable for %s: %s", resource_id, exc) + return None + + def get_critical_paas_inventory(self) -> Dict[str, Optional[List[Dict[str, Any]]]]: + """Collect public-access state for critical PaaS services independently. + + Each service returns a list on a successful API call (including an + empty list) or ``None`` on failure. Rules can therefore evaluate + successful services without treating a denied service as compliant. + """ + if self._critical_paas_cache is not _UNSET: + return self._critical_paas_cache + + collected_at = datetime.now(timezone.utc).isoformat() + collectors = { + "storage": self._collect_public_storage_accounts, + "key_vault": self._collect_public_key_vaults, + "sql": self._collect_public_sql_servers, + "postgresql": self._collect_public_postgresql_servers, + "app_service": self._collect_public_web_apps, + } + result: Dict[str, Optional[List[Dict[str, Any]]]] = {} + for service, collector in collectors.items(): + try: + result[service] = collector(collected_at) + except Exception as exc: + logger.error("critical PaaS inventory failed for %s: %s", service, exc) + result[service] = None + self._critical_paas_cache = result + return result + + @staticmethod + def _public_access_value(resource: Any) -> Optional[bool]: + value = getattr(resource, "public_network_access", None) + if value is None: + value = getattr(getattr(resource, "properties", None), "public_network_access", None) + normalized = enum_str(value).lower() + if normalized in {"enabled", "true", "1"}: + return True + if normalized in {"disabled", "false", "0"}: + return False + return None + + def _paas_record(self, resource: Any, service: str, collected_at: str) -> Dict[str, Any]: + resource_id = getattr(resource, "id", "") or "" + parsed = self.parse_resource_id(resource_id) + return { + "resource_id": resource_id, + "resource_name": getattr(resource, "name", "") or parsed.get("name", ""), + "resource_type": service, + "resource_group": parsed.get("resource_group", ""), + "location": getattr(resource, "location", "") or "", + "public_network_access": self._public_access_value(resource), + "collected_at": collected_at, + } + + def _collect_public_storage_accounts(self, collected_at: str) -> List[Dict[str, Any]]: + resources = StorageManagementClient(self.credential, self.subscription_id).storage_accounts.list() + return [self._paas_record(item, "Microsoft.Storage/storageAccounts", collected_at) for item in resources] + + def _collect_public_key_vaults(self, collected_at: str) -> List[Dict[str, Any]]: + resources = KeyVaultManagementClient(self.credential, self.subscription_id).vaults.list_by_subscription() + return [self._paas_record(item, "Microsoft.KeyVault/vaults", collected_at) for item in resources] + + def _collect_public_sql_servers(self, collected_at: str) -> List[Dict[str, Any]]: + resources = SqlManagementClient(self.credential, self.subscription_id).servers.list() + return [self._paas_record(item, "Microsoft.Sql/servers", collected_at) for item in resources] + + def _collect_public_postgresql_servers(self, collected_at: str) -> List[Dict[str, Any]]: + resources = PostgreSQLManagementClient(self.credential, self.subscription_id).servers.list() + return [self._paas_record(item, "Microsoft.DBforPostgreSQL/servers", collected_at) for item in resources] + + def _collect_public_web_apps(self, collected_at: str) -> List[Dict[str, Any]]: + from azure.mgmt.web import WebSiteManagementClient + + resources = WebSiteManagementClient(self.credential, self.subscription_id).web_apps.list() + return [self._paas_record(item, "Microsoft.Web/sites", collected_at) for item in resources] + + def get_application_gateways(self) -> Optional[List[Any]]: + """List Application Gateways, preserving API failure as UNKNOWN.""" + if self._application_gateways_cache is not _UNSET: + return self._application_gateways_cache + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + self._application_gateways_cache = list(client.application_gateways.list_all()) + except Exception as exc: + logger.error("get_application_gateways failed: %s", exc) + self._application_gateways_cache = None + return self._application_gateways_cache + + def get_waf_policies(self) -> Optional[List[Any]]: + """List regional Application Gateway WAF policies with failure state.""" + if self._waf_policies_cache is not _UNSET: + return self._waf_policies_cache + try: + client = NetworkManagementClient(self.credential, self.subscription_id) + self._waf_policies_cache = list(client.web_application_firewall_policies.list_all()) + except Exception as exc: + logger.error("get_waf_policies failed: %s", exc) + self._waf_policies_cache = None + return self._waf_policies_cache + + def get_waf_diagnostic_logging(self, resource_id: str, sku_name: str) -> Optional[bool]: + """Return whether the SKU-supported Application Gateway log categories are enabled.""" + normalized_sku = (sku_name or "").strip().lower() + if not normalized_sku: + logger.warning("Application Gateway SKU unavailable for %s", resource_id) + return None + required = {"ApplicationGatewayAccessLog", "ApplicationGatewayFirewallLog"} + # ApplicationGatewayPerformanceLog is exposed by v1 only. v2 publishes + # performance telemetry through Azure Monitor metrics instead. + if not normalized_sku.endswith("_v2"): + required.add("ApplicationGatewayPerformanceLog") + try: + client = MonitorManagementClient(self.credential, self.subscription_id) + settings = list(client.diagnostic_settings.list(resource_id)) + enabled = { + getattr(log, "category", "") + for setting in settings + for log in (getattr(setting, "logs", None) or []) + if getattr(log, "enabled", False) + } + return required.issubset(enabled) + except Exception as exc: + logger.error("get_waf_diagnostic_logging failed for %s: %s", resource_id, exc) + return None + def get_azure_firewalls(self, resource_group: str) -> List[Any]: """List all Azure Firewalls in a resource group.""" try: diff --git a/scanner/rules/_perimeter_common.py b/scanner/rules/_perimeter_common.py new file mode 100644 index 00000000..c480f5a5 --- /dev/null +++ b/scanner/rules/_perimeter_common.py @@ -0,0 +1,53 @@ +"""Shared helpers for enterprise perimeter findings.""" + +from typing import Any, Dict + +REQUIRED_NETWORK_PERMISSIONS = [ + "Microsoft.Network/applicationGateways/read", + "Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies/read", +] + + +def resource_group(resource_id: str) -> str: + parts = (resource_id or "").split("/") + for index, part in enumerate(parts): + if part.lower() == "resourcegroups" and index + 1 < len(parts): + return parts[index + 1] + return "" + + +def metadata( + *, + resource_id: str, + observed: Any, + expected: Any, + source: str, + timestamp: str, + permissions: list[str], +) -> Dict[str, Any]: + return { + "resource_group": resource_group(resource_id), + "evidence_source": source, + "collection_timestamp": timestamp, + "observed": observed, + "expected": expected, + "required_permissions": permissions, + "confidence": "HIGH", + } + + +def waf_enabled(gateway: Any) -> bool: + config = getattr(gateway, "web_application_firewall_configuration", None) + policy = getattr(gateway, "firewall_policy", None) + return bool(getattr(config, "enabled", False) or getattr(policy, "id", "")) + + +def public_gateway(gateway: Any) -> bool: + return any( + getattr(getattr(config, "public_ip_address", None), "id", "") + for config in (getattr(gateway, "frontend_ip_configurations", None) or []) + ) + + +def policy_by_id(policies: list[Any]) -> Dict[str, Any]: + return {(getattr(policy, "id", "") or "").lower(): policy for policy in policies if getattr(policy, "id", "")} diff --git a/scanner/rules/_private_link_common.py b/scanner/rules/_private_link_common.py new file mode 100644 index 00000000..7776498d --- /dev/null +++ b/scanner/rules/_private_link_common.py @@ -0,0 +1,33 @@ +"""Shared, evidence-rich helpers for Private Link rules.""" + +from ipaddress import ip_address +from typing import Any, Dict, Optional + +EVIDENCE_SOURCE = "Azure Resource Manager: Microsoft.Network/privateEndpoints" +REQUIRED_PERMISSIONS = [ + "Microsoft.Network/privateEndpoints/read", + "Microsoft.Network/privateEndpoints/privateDnsZoneGroups/read", + "target PaaS resource/read", +] + + +def private_address(value: str) -> Optional[bool]: + """Return whether an address is non-public; malformed values are unknown.""" + try: + return ip_address(value).is_private + except ValueError: + return None + + +def evidence(item: Dict[str, Any], observed: Any, expected: Any) -> Dict[str, Any]: + return { + "resource_group": item.get("resource_group", ""), + "location": item.get("location", ""), + "target_resource_id": item.get("target_id", ""), + "evidence_source": EVIDENCE_SOURCE, + "collection_timestamp": item.get("collected_at", ""), + "observed": observed, + "expected": expected, + "required_permissions": REQUIRED_PERMISSIONS, + "confidence": "HIGH", + } diff --git a/scanner/rules/az_net_018.py b/scanner/rules/az_net_018.py new file mode 100644 index 00000000..b80a8052 --- /dev/null +++ b/scanner/rules/az_net_018.py @@ -0,0 +1,46 @@ +"""AZ-NET-018: Private Endpoint target retains public network access.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._private_link_common import evidence + +RULE_ID = "AZ-NET-018" +RULE_NAME = "Private Endpoint Target Retains Public Network Access" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-018", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = "A supported PaaS target has a Private Endpoint but still permits public network access." +REMEDIATION = "Validate private connectivity, then disable public network access on the target PaaS resource." +PLAYBOOK = "playbooks/cli/fix_az_net_018.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + inventory = azure_client.get_private_link_inventory() + if inventory is None: + logger.warning("%s UNKNOWN: Private Endpoint inventory unavailable", RULE_ID) + return [] + findings = [] + for item in inventory: + if item.get("public_network_access") is not True: + continue + findings.append(_finding(item, {"public_network_access": "Enabled"}, {"public_network_access": "Disabled"})) + return findings + + +def _finding(item: Dict[str, Any], observed: Any, expected: Any) -> Dict[str, Any]: + return { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": item.get("endpoint_id", ""), + "resource_name": item.get("endpoint_name", ""), + "resource_type": "Microsoft.Network/privateEndpoints", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": evidence(item, observed, expected), + } diff --git a/scanner/rules/az_net_019.py b/scanner/rules/az_net_019.py new file mode 100644 index 00000000..81c7fec4 --- /dev/null +++ b/scanner/rules/az_net_019.py @@ -0,0 +1,52 @@ +"""AZ-NET-019: Private Endpoint connection is not approved.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._private_link_common import evidence + +RULE_ID = "AZ-NET-019" +RULE_NAME = "Private Endpoint Connection Is Not Approved" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-019", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = ( + "A Private Endpoint connection is pending, rejected, or disconnected and cannot provide the intended private path." +) +REMEDIATION = ( + "Approve the intended connection, or delete and recreate rejected or disconnected Private Endpoint connections." +) +PLAYBOOK = "playbooks/cli/fix_az_net_019.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + inventory = azure_client.get_private_link_inventory() + if inventory is None: + logger.warning("%s UNKNOWN: Private Endpoint inventory unavailable", RULE_ID) + return [] + findings = [] + for item in inventory: + status = item.get("connection_status") + if status is None: + logger.warning("%s UNKNOWN for %s: connection state missing", RULE_ID, item.get("endpoint_name")) + continue + if str(status).lower() not in {"pending", "rejected", "disconnected"}: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": item.get("endpoint_id", ""), + "resource_name": item.get("endpoint_name", ""), + "resource_type": "Microsoft.Network/privateEndpoints", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": evidence(item, {"connection_status": status}, {"connection_status": "Approved"}), + } + ) + return findings diff --git a/scanner/rules/az_net_020.py b/scanner/rules/az_net_020.py new file mode 100644 index 00000000..2b683ad8 --- /dev/null +++ b/scanner/rules/az_net_020.py @@ -0,0 +1,51 @@ +"""AZ-NET-020: Private Endpoint lacks a Private DNS zone group.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._private_link_common import evidence + +RULE_ID = "AZ-NET-020" +RULE_NAME = "Private Endpoint Lacks Private DNS Zone Association" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-020", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = "An approved Private Endpoint has no associated Private DNS zone group." +REMEDIATION = ( + "Associate the service's documented privatelink DNS zone with the Private Endpoint " + "and link it to the consuming VNet." +) +PLAYBOOK = "playbooks/cli/fix_az_net_020.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + inventory = azure_client.get_private_link_inventory() + if inventory is None: + logger.warning("%s UNKNOWN: Private Endpoint inventory unavailable", RULE_ID) + return [] + findings = [] + for item in inventory: + zones = item.get("dns_zone_ids") + if zones is None: + logger.warning("%s UNKNOWN for %s: DNS zone groups unavailable", RULE_ID, item.get("endpoint_name")) + continue + if str(item.get("connection_status", "")).lower() != "approved" or zones: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": item.get("endpoint_id", ""), + "resource_name": item.get("endpoint_name", ""), + "resource_type": "Microsoft.Network/privateEndpoints", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": evidence(item, {"private_dns_zone_ids": []}, {"private_dns_zone_association": "Present"}), + } + ) + return findings diff --git a/scanner/rules/az_net_021.py b/scanner/rules/az_net_021.py new file mode 100644 index 00000000..3d7aa88d --- /dev/null +++ b/scanner/rules/az_net_021.py @@ -0,0 +1,71 @@ +"""AZ-NET-021: Private Endpoint ARM DNS configuration reports only public addresses.""" + +import logging +from typing import Any, Dict, List + +from scanner.rules._private_link_common import evidence, private_address + +RULE_ID = "AZ-NET-021" +RULE_NAME = "Private Endpoint DNS Configuration Reports Only Public Addresses" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-021", "NIST": "PR.AC-5", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = ( + "Azure's Private Endpoint custom DNS configuration reports an FQDN with only public IP addresses. " + "This control validates ARM configuration evidence; it does not test effective DNS resolution from a VNet " + "workload or an on-premises resolver." +) +REMEDIATION = ( + "Correct the Private Endpoint DNS configuration so its FQDN is associated with the allocated private IP, " + "then separately validate effective resolution from every relevant VNet and on-premises resolver path." +) +PLAYBOOK = "playbooks/cli/fix_az_net_021.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + inventory = azure_client.get_private_link_inventory() + if inventory is None: + logger.warning("%s UNKNOWN: Private Endpoint inventory unavailable", RULE_ID) + return [] + findings = [] + for item in inventory: + configs = item.get("dns_configs") + if not configs: + logger.warning( + "%s UNKNOWN for %s: custom DNS configuration unavailable", RULE_ID, item.get("endpoint_name") + ) + continue + bad = [] + for config in configs: + addresses = [private_address(value) for value in config.get("ip_addresses", [])] + if ( + config.get("fqdn") + and addresses + and all(value is not None for value in addresses) + and not any(addresses) + ): + bad.append(config) + if not bad: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": item.get("endpoint_id", ""), + "resource_name": item.get("endpoint_name", ""), + "resource_type": "Microsoft.Network/privateEndpoints", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": evidence( + item, + {"non_private_custom_dns_configs": bad}, + {"custom_dns_config": "FQDN associated with a private IP address"}, + ), + } + ) + return findings diff --git a/scanner/rules/az_net_022.py b/scanner/rules/az_net_022.py new file mode 100644 index 00000000..3232efa1 --- /dev/null +++ b/scanner/rules/az_net_022.py @@ -0,0 +1,69 @@ +"""AZ-NET-022: Critical PaaS resource is publicly accessible.""" + +import logging +import os +from typing import Any, Dict, List + +from scanner.rules._perimeter_common import metadata + +RULE_ID = "AZ-NET-022" +RULE_NAME = "Critical PaaS Resource Is Publicly Accessible Without Approved Exception" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-022", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = ( + "A critical PaaS resource permits public network access and is not in the explicit approved-exception list." +) +REMEDIATION = ( + "Validate private connectivity, disable public network access, or add a time-bound " + "approved exception through deployment configuration." +) +PLAYBOOK = "playbooks/cli/fix_az_net_022.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + inventories = azure_client.get_critical_paas_inventory() + exceptions = { + value.strip().lower() + for value in os.getenv("OPENSHIELD_PUBLIC_PAAS_EXCEPTIONS", "").split(",") + if value.strip() + } + findings = [] + for service, resources in inventories.items(): + if resources is None: + logger.warning("%s UNKNOWN: %s inventory unavailable", RULE_ID, service) + continue + for item in resources: + state = item.get("public_network_access") + if state is None: + logger.warning("%s UNKNOWN for %s: public access state missing", RULE_ID, item.get("resource_name")) + continue + if state is not True or item.get("resource_id", "").lower() in exceptions: + continue + evidence = metadata( + resource_id=item.get("resource_id", ""), + observed={"public_network_access": "Enabled", "approved_exception": False}, + expected={"public_network_access": "Disabled", "or_approved_exception": True}, + source=f"Azure Resource Manager: {item.get('resource_type', service)}", + timestamp=item.get("collected_at", ""), + permissions=[f"{item.get('resource_type', service)}/read"], + ) + evidence["location"] = item.get("location", "") + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": item.get("resource_id", ""), + "resource_name": item.get("resource_name", ""), + "resource_type": item.get("resource_type", ""), + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": evidence, + } + ) + return findings diff --git a/scanner/rules/az_net_023.py b/scanner/rules/az_net_023.py new file mode 100644 index 00000000..f0a56f5c --- /dev/null +++ b/scanner/rules/az_net_023.py @@ -0,0 +1,71 @@ +"""AZ-NET-023: Azure Firewall threat intelligence is not in Deny mode.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +from scanner.rules._perimeter_common import metadata + +RULE_ID = "AZ-NET-023" +RULE_NAME = "Azure Firewall Threat Intelligence Is Not in Deny Mode" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-023", "NIST": "DE.CM-1", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = ( + "Azure Firewall threat intelligence is disabled or configured to alert without denying known malicious traffic." +) +REMEDIATION = "Set Azure Firewall threat intelligence mode to Deny after reviewing documented exception traffic." +PLAYBOOK = "playbooks/cli/fix_az_net_023.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + firewalls = azure_client.get_all_azure_firewalls() + if firewalls is None: + logger.warning("%s UNKNOWN: Azure Firewall inventory unavailable", RULE_ID) + return [] + timestamp = datetime.now(timezone.utc).isoformat() + findings = [] + for firewall in firewalls: + mode = getattr(firewall, "threat_intel_mode", None) + if mode is None: + logger.warning( + "%s UNKNOWN for %s: threat intelligence state missing", RULE_ID, getattr(firewall, "name", "") + ) + continue + normalized = str(getattr(mode, "value", mode)).lower() + if normalized == "deny": + continue + if normalized not in {"alert", "off"}: + logger.warning( + "%s UNKNOWN for %s: unrecognized threat intelligence mode %r", + RULE_ID, + getattr(firewall, "name", ""), + mode, + ) + continue + resource_id = getattr(firewall, "id", "") or "" + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": getattr(firewall, "name", ""), + "resource_type": "Microsoft.Network/azureFirewalls", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": metadata( + resource_id=resource_id, + observed={"threat_intel_mode": str(mode)}, + expected={"threat_intel_mode": "Deny"}, + source="Azure Resource Manager: Microsoft.Network/azureFirewalls", + timestamp=timestamp, + permissions=["Microsoft.Network/azureFirewalls/read"], + ), + } + ) + return findings diff --git a/scanner/rules/az_net_024.py b/scanner/rules/az_net_024.py new file mode 100644 index 00000000..ca2b0655 --- /dev/null +++ b/scanner/rules/az_net_024.py @@ -0,0 +1,69 @@ +"""AZ-NET-024: Application Gateway WAF is not in Prevention mode.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +from scanner.rules._perimeter_common import REQUIRED_NETWORK_PERMISSIONS, metadata, policy_by_id, waf_enabled + +RULE_ID = "AZ-NET-024" +RULE_NAME = "Application Gateway WAF Is Not in Prevention Mode" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-024", "NIST": "PR.PT-4", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = "An enabled Application Gateway WAF is operating in Detection rather than Prevention mode." +REMEDIATION = ( + "Tune exclusions in a non-production environment, then set the inline WAF " + "or associated WAF policy to Prevention mode." +) +PLAYBOOK = "playbooks/cli/fix_az_net_024.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + gateways = azure_client.get_application_gateways() + policies = azure_client.get_waf_policies() + if gateways is None or policies is None: + logger.warning("%s UNKNOWN: Application Gateway or WAF policy inventory unavailable", RULE_ID) + return [] + indexed = policy_by_id(policies) + timestamp = datetime.now(timezone.utc).isoformat() + findings = [] + for gateway in gateways: + if not waf_enabled(gateway): + continue + policy_id = getattr(getattr(gateway, "firewall_policy", None), "id", "") or "" + policy = indexed.get(policy_id.lower()) + mode = getattr(getattr(policy, "policy_settings", None), "mode", None) if policy else None + if mode is None: + mode = getattr(getattr(gateway, "web_application_firewall_configuration", None), "firewall_mode", None) + if mode is None: + logger.warning("%s UNKNOWN for %s: WAF mode missing", RULE_ID, getattr(gateway, "name", "")) + continue + if str(getattr(mode, "value", mode)).lower() == "prevention": + continue + resource_id = getattr(gateway, "id", "") or "" + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": getattr(gateway, "name", ""), + "resource_type": "Microsoft.Network/applicationGateways", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": metadata( + resource_id=resource_id, + observed={"waf_mode": str(mode)}, + expected={"waf_mode": "Prevention"}, + source="Azure Resource Manager: Application Gateway and WAF policy", + timestamp=timestamp, + permissions=REQUIRED_NETWORK_PERMISSIONS, + ), + } + ) + return findings diff --git a/scanner/rules/az_net_025.py b/scanner/rules/az_net_025.py new file mode 100644 index 00000000..a7eddd56 --- /dev/null +++ b/scanner/rules/az_net_025.py @@ -0,0 +1,68 @@ +"""AZ-NET-025: WAF diagnostic logging is incomplete.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +from scanner.rules._perimeter_common import metadata, waf_enabled + +RULE_ID = "AZ-NET-025" +RULE_NAME = "Application Gateway WAF Diagnostic Logging Is Not Enabled" +SEVERITY = "MEDIUM" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-025", "NIST": "DE.CM-1", "ISO27001": "A.12.4.1", "SOC2": "CC7.2"} +DESCRIPTION = ( + "An Application Gateway WAF lacks a diagnostic setting with every log category supported by its SKU enabled. " + "WAF v1 requires access, performance, and firewall logs; WAF_v2 requires access and firewall logs, with " + "performance telemetry provided through Azure Monitor metrics." +) +REMEDIATION = ( + "Create an Azure Monitor diagnostic setting that exports all log categories supported by the Application " + "Gateway SKU to an approved destination, and retain Azure Monitor metrics for v2 performance telemetry." +) +PLAYBOOK = "playbooks/cli/fix_az_net_025.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + gateways = azure_client.get_application_gateways() + if gateways is None: + logger.warning("%s UNKNOWN: Application Gateway inventory unavailable", RULE_ID) + return [] + timestamp = datetime.now(timezone.utc).isoformat() + findings = [] + for gateway in gateways: + if not waf_enabled(gateway): + continue + resource_id = getattr(gateway, "id", "") or "" + sku_name = getattr(getattr(gateway, "sku", None), "name", "") or "" + enabled = azure_client.get_waf_diagnostic_logging(resource_id, sku_name) + if enabled is None: + logger.warning("%s UNKNOWN for %s: diagnostic settings unavailable", RULE_ID, getattr(gateway, "name", "")) + continue + if enabled: + continue + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": getattr(gateway, "name", ""), + "resource_type": "Microsoft.Network/applicationGateways", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": metadata( + resource_id=resource_id, + observed={"required_waf_log_categories": "Incomplete", "sku": sku_name}, + expected={"sku_supported_waf_log_categories": "Enabled"}, + source="Azure Monitor diagnostic settings", + timestamp=timestamp, + permissions=["Microsoft.Insights/diagnosticSettings/read"], + ), + } + ) + return findings diff --git a/scanner/rules/az_net_026.py b/scanner/rules/az_net_026.py new file mode 100644 index 00000000..ceee2971 --- /dev/null +++ b/scanner/rules/az_net_026.py @@ -0,0 +1,103 @@ +"""AZ-NET-026: WAF lacks current managed rules or bot protection.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +from scanner.rules._perimeter_common import REQUIRED_NETWORK_PERMISSIONS, metadata, policy_by_id, waf_enabled + +RULE_ID = "AZ-NET-026" +RULE_NAME = "WAF Lacks Current Managed Rules or Bot Protection" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-026", "NIST": "PR.PT-4", "ISO27001": "A.14.2.5", "SOC2": "CC6.6"} +DESCRIPTION = ( + "An Application Gateway WAF lacks both an approved current base managed rule set and Microsoft bot protection." +) +REMEDIATION = ( + "Associate a WAF policy using OWASP 3.2 or Microsoft Default Rule Set 2.1 or later, " + "plus Microsoft Bot Manager Rule Set 1.0 or later." +) +PLAYBOOK = "playbooks/cli/fix_az_net_026.sh" +logger = logging.getLogger(__name__) + + +def _version_at_least(value: Any, minimum: tuple[int, int]) -> bool: + try: + parts = tuple(int(part) for part in str(value).split(".")[:2]) + return parts >= minimum + except (TypeError, ValueError): + return False + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + gateways = azure_client.get_application_gateways() + policies = azure_client.get_waf_policies() + if gateways is None or policies is None: + logger.warning("%s UNKNOWN: Application Gateway or WAF policy inventory unavailable", RULE_ID) + return [] + indexed = policy_by_id(policies) + timestamp = datetime.now(timezone.utc).isoformat() + findings = [] + for gateway in gateways: + if not waf_enabled(gateway): + continue + policy_id = getattr(getattr(gateway, "firewall_policy", None), "id", "") or "" + policy = indexed.get(policy_id.lower()) + if policy: + rule_sets = getattr(getattr(policy, "managed_rules", None), "managed_rule_sets", None) + else: + inline = getattr(gateway, "web_application_firewall_configuration", None) + rule_sets = [inline] if inline and getattr(inline, "rule_set_type", None) else None + if rule_sets is None: + logger.warning( + "%s UNKNOWN for %s: managed rule inventory unavailable", RULE_ID, getattr(gateway, "name", "") + ) + continue + current_base = any( + ( + str(getattr(item, "rule_set_type", "")).lower() == "owasp" + and _version_at_least(getattr(item, "rule_set_version", ""), (3, 2)) + ) + or ( + str(getattr(item, "rule_set_type", "")).lower() == "microsoft_defaultruleset" + and _version_at_least(getattr(item, "rule_set_version", ""), (2, 1)) + ) + for item in rule_sets + ) + bot = any( + str(getattr(item, "rule_set_type", "")).lower() == "microsoft_botmanagerruleset" + and _version_at_least(getattr(item, "rule_set_version", ""), (1, 0)) + for item in rule_sets + ) + if current_base and bot: + continue + resource_id = getattr(gateway, "id", "") or "" + observed_sets = [ + {"type": getattr(item, "rule_set_type", ""), "version": getattr(item, "rule_set_version", "")} + for item in rule_sets + ] + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": getattr(gateway, "name", ""), + "resource_type": "Microsoft.Network/applicationGateways", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": metadata( + resource_id=resource_id, + observed={"managed_rule_sets": observed_sets, "current_base": current_base, "bot_protection": bot}, + expected={"current_base": True, "bot_protection": True}, + source="Azure Resource Manager: WAF managed rules", + timestamp=timestamp, + permissions=REQUIRED_NETWORK_PERMISSIONS, + ), + } + ) + return findings diff --git a/scanner/rules/az_net_027.py b/scanner/rules/az_net_027.py new file mode 100644 index 00000000..bc5560e8 --- /dev/null +++ b/scanner/rules/az_net_027.py @@ -0,0 +1,82 @@ +"""AZ-NET-027: Internet-facing Application Gateway lacks WAF rate limiting.""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +from scanner.rules._perimeter_common import ( + REQUIRED_NETWORK_PERMISSIONS, + metadata, + policy_by_id, + public_gateway, + waf_enabled, +) + +RULE_ID = "AZ-NET-027" +RULE_NAME = "Internet-Facing Application Gateway Lacks Approved Rate Limiting" +SEVERITY = "HIGH" +CATEGORY = "Network" +FRAMEWORKS = {"CIS": "N/A-NET-027", "NIST": "PR.PT-4", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = "A public Application Gateway has no enabled RateLimitRule in its associated WAF policy." +REMEDIATION = ( + "Associate a WAF policy and add an enabled RateLimitRule with thresholds " + "and grouping appropriate to the application." +) +PLAYBOOK = "playbooks/cli/fix_az_net_027.sh" +logger = logging.getLogger(__name__) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + gateways = azure_client.get_application_gateways() + policies = azure_client.get_waf_policies() + if gateways is None or policies is None: + logger.warning("%s UNKNOWN: Application Gateway or WAF policy inventory unavailable", RULE_ID) + return [] + indexed = policy_by_id(policies) + timestamp = datetime.now(timezone.utc).isoformat() + findings = [] + for gateway in gateways: + if not public_gateway(gateway) or not waf_enabled(gateway): + continue + policy_id = getattr(getattr(gateway, "firewall_policy", None), "id", "") or "" + policy = indexed.get(policy_id.lower()) + rules = getattr(policy, "custom_rules", None) if policy else [] + rate_rules = [rule for rule in (rules or []) if str(getattr(rule, "rule_type", "")).lower() == "ratelimitrule"] + states = [getattr(rule, "state", None) for rule in rate_rules] + normalized_states = [str(getattr(state, "value", state)).lower() for state in states if state is not None] + if "enabled" in normalized_states: + continue + if rate_rules and ( + len(normalized_states) != len(states) or any(state != "disabled" for state in normalized_states) + ): + logger.warning( + "%s UNKNOWN for %s: rate-limit rule state missing or unrecognized", + RULE_ID, + getattr(gateway, "name", ""), + ) + continue + resource_id = getattr(gateway, "id", "") or "" + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": resource_id, + "resource_name": getattr(gateway, "name", ""), + "resource_type": "Microsoft.Network/applicationGateways", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": metadata( + resource_id=resource_id, + observed={"enabled_rate_limit_rules": 0}, + expected={"enabled_rate_limit_rules": ">=1"}, + source="Azure Resource Manager: WAF custom rules", + timestamp=timestamp, + permissions=REQUIRED_NETWORK_PERMISSIONS, + ), + } + ) + return findings diff --git a/tests/test_enterprise_perimeter_rules.py b/tests/test_enterprise_perimeter_rules.py new file mode 100644 index 00000000..60e3e736 --- /dev/null +++ b/tests/test_enterprise_perimeter_rules.py @@ -0,0 +1,235 @@ +"""Offline positive, negative, empty, malformed, and failure tests for issue #253 rules 5–10.""" + +from types import SimpleNamespace + +import pytest + +import scanner.rules.az_net_022 as az_net_022 +import scanner.rules.az_net_023 as az_net_023 +import scanner.rules.az_net_024 as az_net_024 +import scanner.rules.az_net_025 as az_net_025 +import scanner.rules.az_net_026 as az_net_026 +import scanner.rules.az_net_027 as az_net_027 +from scanner.azure_client import AzureClient + + +def ns(**values): + return SimpleNamespace(**values) + + +_GATEWAY_ID = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/applicationGateways/appgw" +_POLICY_ID = ( + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/" + "ApplicationGatewayWebApplicationFirewallPolicies/waf" +) + + +def gateway(*, public=True, mode="Prevention", policy=True, enabled=True, sku="WAF_v2"): + return ns( + id=_GATEWAY_ID, + name="appgw", + web_application_firewall_configuration=ns( + enabled=enabled, firewall_mode=mode, rule_set_type="OWASP", rule_set_version="3.2" + ), + firewall_policy=ns(id=_POLICY_ID) if policy else None, + frontend_ip_configurations=[ns(public_ip_address=ns(id="/publicIps/pip"))] if public else [], + sku=ns(name=sku), + ) + + +def policy(*, mode="Prevention", current=True, bot=True, rate=True): + sets = [] + if current: + sets.append(ns(rule_set_type="OWASP", rule_set_version="3.2")) + if bot: + sets.append(ns(rule_set_type="Microsoft_BotManagerRuleSet", rule_set_version="1.0")) + custom = [ns(rule_type="RateLimitRule", state="Enabled")] if rate else [] + return ns( + id=_POLICY_ID, + policy_settings=ns(mode=mode), + managed_rules=ns(managed_rule_sets=sets), + custom_rules=custom, + ) + + +class FakeAzure: + def __init__(self, *, paas=None, firewalls=None, gateways=None, policies=None, diagnostics=True): + self.paas = {} if paas is None else paas + self.firewalls = [] if firewalls is None else firewalls + self.gateways = [] if gateways is None else gateways + self.policies = [] if policies is None else policies + self.diagnostics = diagnostics + + def get_critical_paas_inventory(self): + return self.paas + + def get_all_azure_firewalls(self): + return self.firewalls + + def get_application_gateways(self): + return self.gateways + + def get_waf_policies(self): + return self.policies + + def get_waf_diagnostic_logging(self, resource_id, sku_name): + return self.diagnostics + + +def paas_item(public=True): + return { + "resource_id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/store", + "resource_name": "store", + "resource_type": "Microsoft.Storage/storageAccounts", + "resource_group": "rg", + "location": "uksouth", + "public_network_access": public, + "collected_at": "2026-08-17T12:00:00+00:00", + } + + +@pytest.mark.parametrize( + "rule,client", + [ + (az_net_022, FakeAzure(paas={"storage": []})), + (az_net_023, FakeAzure(firewalls=[])), + (az_net_024, FakeAzure(gateways=[], policies=[])), + (az_net_025, FakeAzure(gateways=[])), + (az_net_026, FakeAzure(gateways=[], policies=[])), + (az_net_027, FakeAzure(gateways=[], policies=[])), + ], +) +def test_empty_inventory_is_not_applicable(rule, client): + assert rule.scan(client, "sub") == [] + + +def test_net_022_public_paas_flags_and_exception_suppresses(monkeypatch): + item = paas_item() + client = FakeAzure(paas={"storage": [item], "sql": None}) + findings = az_net_022.scan(client, "sub") + assert len(findings) == 1 + assert findings[0]["metadata"]["observed"]["approved_exception"] is False + monkeypatch.setenv("OPENSHIELD_PUBLIC_PAAS_EXCEPTIONS", item["resource_id"]) + assert az_net_022.scan(client, "sub") == [] + + +@pytest.mark.parametrize("state", [False, None]) +def test_net_022_private_or_unknown_state_does_not_flag(state): + assert az_net_022.scan(FakeAzure(paas={"storage": [paas_item(state)]}), "sub") == [] + + +@pytest.mark.parametrize("mode", ["Alert", "Off"]) +def test_net_023_flags_real_non_deny_modes(mode): + firewall = ns( + id="/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/azureFirewalls/fw", + name="fw", + threat_intel_mode=mode, + ) + assert len(az_net_023.scan(FakeAzure(firewalls=[firewall]), "sub")) == 1 + + +def test_net_023_accepts_real_deny_mode_and_preserves_unknown(): + firewall = ns( + id="/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/azureFirewalls/fw", + name="fw", + threat_intel_mode=ns(value="Deny"), + ) + assert az_net_023.scan(FakeAzure(firewalls=[firewall]), "sub") == [] + firewall.threat_intel_mode = None + assert az_net_023.scan(FakeAzure(firewalls=[firewall]), "sub") == [] + firewall.threat_intel_mode = "FutureMode" + assert az_net_023.scan(FakeAzure(firewalls=[firewall]), "sub") == [] + assert az_net_023.scan(FakeAzure(firewalls=None), "sub") == [] + + +def test_net_024_detection_flags_and_prevention_passes(): + gw = gateway() + assert len(az_net_024.scan(FakeAzure(gateways=[gw], policies=[policy(mode="Detection")]), "sub")) == 1 + assert az_net_024.scan(FakeAzure(gateways=[gw], policies=[policy(mode="Prevention")]), "sub") == [] + + +def test_net_024_missing_inventory_or_mode_is_unknown(): + assert az_net_024.scan(FakeAzure(gateways=None, policies=[]), "sub") == [] + gw = gateway(mode=None, policy=False) + assert az_net_024.scan(FakeAzure(gateways=[gw], policies=[]), "sub") == [] + + +def test_net_025_diagnostics_positive_negative_and_failure(): + gw = gateway() + assert az_net_025.scan(FakeAzure(gateways=[gw], diagnostics=True), "sub") == [] + assert len(az_net_025.scan(FakeAzure(gateways=[gw], diagnostics=False), "sub")) == 1 + assert az_net_025.scan(FakeAzure(gateways=[gw], diagnostics=None), "sub") == [] + + +@pytest.mark.parametrize("current,bot,expected", [(True, True, 0), (False, True, 1), (True, False, 1)]) +def test_net_026_requires_current_base_and_bot_rules(current, bot, expected): + findings = az_net_026.scan(FakeAzure(gateways=[gateway()], policies=[policy(current=current, bot=bot)]), "sub") + assert len(findings) == expected + + +def test_net_026_malformed_managed_rules_is_unknown(): + malformed = ns(id=_POLICY_ID, policy_settings=ns(mode="Prevention"), managed_rules=None, custom_rules=[]) + assert az_net_026.scan(FakeAzure(gateways=[gateway()], policies=[malformed]), "sub") == [] + + +def test_net_027_public_gateway_requires_enabled_rate_limit(): + gw = gateway(public=True) + assert az_net_027.scan(FakeAzure(gateways=[gw], policies=[policy(rate=True)]), "sub") == [] + assert len(az_net_027.scan(FakeAzure(gateways=[gw], policies=[policy(rate=False)]), "sub")) == 1 + disabled_policy = policy(rate=False) + disabled_policy.custom_rules = [ns(rule_type="RateLimitRule", state="Disabled")] + assert len(az_net_027.scan(FakeAzure(gateways=[gw], policies=[disabled_policy]), "sub")) == 1 + assert az_net_027.scan(FakeAzure(gateways=[gateway(public=False)], policies=[policy(rate=False)]), "sub") == [] + + +def test_net_027_ignores_public_gateway_without_waf(): + gw = gateway(public=True, policy=False, enabled=False) + assert az_net_027.scan(FakeAzure(gateways=[gw], policies=[]), "sub") == [] + + +@pytest.mark.parametrize("state", [None, "FutureState"]) +def test_net_027_preserves_unknown_rate_limit_state(state): + rate_rule = ns(rule_type="RateLimitRule") + if state is not None: + rate_rule.state = state + waf_policy = policy(rate=False) + waf_policy.custom_rules = [rate_rule] + assert az_net_027.scan(FakeAzure(gateways=[gateway()], policies=[waf_policy]), "sub") == [] + + +def test_diagnostic_collector_requires_v1_performance_category(monkeypatch): + logs = [ + ns(category="ApplicationGatewayAccessLog", enabled=True), + ns(category="ApplicationGatewayFirewallLog", enabled=True), + ] + monitor = ns(diagnostic_settings=ns(list=lambda _: [ns(logs=logs)])) + monkeypatch.setattr("scanner.azure_client.MonitorManagementClient", lambda *_: monitor) + client = AzureClient("sub", credential=object()) + assert client.get_waf_diagnostic_logging(_GATEWAY_ID, "WAF_Medium") is False + logs.append(ns(category="ApplicationGatewayPerformanceLog", enabled=True)) + assert client.get_waf_diagnostic_logging(_GATEWAY_ID, "WAF_Medium") is True + + +def test_diagnostic_collector_accepts_wafv2_without_performance_log(monkeypatch): + logs = [ + ns(category="ApplicationGatewayAccessLog", enabled=True), + ns(category="ApplicationGatewayFirewallLog", enabled=True), + ] + monitor = ns(diagnostic_settings=ns(list=lambda _: [ns(logs=logs)])) + monkeypatch.setattr("scanner.azure_client.MonitorManagementClient", lambda *_: monitor) + client = AzureClient("sub", credential=object()) + assert client.get_waf_diagnostic_logging(_GATEWAY_ID, "WAF_v2") is True + logs.pop() + assert client.get_waf_diagnostic_logging(_GATEWAY_ID, "WAF_v2") is False + + +def test_critical_paas_collector_preserves_independent_service_failure(monkeypatch): + client = AzureClient("sub", credential=object()) + monkeypatch.setattr(client, "_collect_public_storage_accounts", lambda _: [paas_item(False)]) + monkeypatch.setattr(client, "_collect_public_key_vaults", lambda _: (_ for _ in ()).throw(PermissionError())) + monkeypatch.setattr(client, "_collect_public_sql_servers", lambda _: []) + monkeypatch.setattr(client, "_collect_public_postgresql_servers", lambda _: []) + monkeypatch.setattr(client, "_collect_public_web_apps", lambda _: []) + result = client.get_critical_paas_inventory() + assert result["storage"] == [paas_item(False)] + assert result["key_vault"] is None diff --git a/tests/test_network_layer_assurance.py b/tests/test_network_layer_assurance.py index a1232e07..9be72927 100644 --- a/tests/test_network_layer_assurance.py +++ b/tests/test_network_layer_assurance.py @@ -118,7 +118,7 @@ def test_network_layer_endpoint_returns_complete_report(client, auth_headers): assert len(payload["domains"]) == 20 assert len(payload["subdomains"]) == 5 assert len(payload["controls"]) == 20 - assert len(payload["rule_classifications"]) == 19 + assert len(payload["rule_classifications"]) == 29 def test_network_layer_endpoint_hides_catalog_errors(client, auth_headers): diff --git a/tests/test_private_link_rules.py b/tests/test_private_link_rules.py new file mode 100644 index 00000000..602ebae4 --- /dev/null +++ b/tests/test_private_link_rules.py @@ -0,0 +1,122 @@ +"""Offline coverage for issue #253 Private Link controls.""" + +from types import SimpleNamespace + +import pytest + +import scanner.rules.az_net_018 as az_net_018 +import scanner.rules.az_net_019 as az_net_019 +import scanner.rules.az_net_020 as az_net_020 +import scanner.rules.az_net_021 as az_net_021 +from scanner.azure_client import AzureClient + + +def _item(**overrides): + item = { + "endpoint_id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/privateEndpoints/pe1", + "endpoint_name": "pe1", + "resource_group": "rg", + "location": "uksouth", + "target_id": "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/store1", + "connection_status": "Approved", + "dns_zone_ids": [ + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/privateDnsZones/privatelink.blob.core.windows.net" + ], + "dns_configs": [{"fqdn": "store1.blob.core.windows.net", "ip_addresses": ["10.0.0.4"]}], + "public_network_access": False, + "collected_at": "2026-08-17T12:00:00+00:00", + } + item.update(overrides) + return item + + +class FakeAzure: + def __init__(self, inventory): + self.inventory = inventory + + def get_private_link_inventory(self): + return self.inventory + + +@pytest.mark.parametrize("inventory", [[], None]) +@pytest.mark.parametrize("rule", [az_net_018, az_net_019, az_net_020, az_net_021]) +def test_empty_and_failed_inventory_never_create_false_findings(rule, inventory): + assert rule.scan(FakeAzure(inventory), "sub") == [] + + +def test_net_018_flags_public_access_and_includes_evidence(): + findings = az_net_018.scan(FakeAzure([_item(public_network_access=True)]), "sub") + assert len(findings) == 1 + metadata = findings[0]["metadata"] + assert metadata["evidence_source"].startswith("Azure Resource Manager") + assert metadata["collection_timestamp"] + assert metadata["observed"] == {"public_network_access": "Enabled"} + assert metadata["confidence"] == "HIGH" + + +@pytest.mark.parametrize("status", ["Pending", "Rejected", "Disconnected"]) +def test_net_019_flags_non_approved_connection_states(status): + findings = az_net_019.scan(FakeAzure([_item(connection_status=status)]), "sub") + assert len(findings) == 1 + assert findings[0]["metadata"]["observed"]["connection_status"] == status + + +@pytest.mark.parametrize("status", ["Approved", None, "unexpected"]) +def test_net_019_does_not_guess_for_approved_or_unknown_state(status): + assert az_net_019.scan(FakeAzure([_item(connection_status=status)]), "sub") == [] + + +def test_net_020_flags_missing_zone_but_not_permission_failure(): + assert len(az_net_020.scan(FakeAzure([_item(dns_zone_ids=[])]), "sub")) == 1 + assert az_net_020.scan(FakeAzure([_item(dns_zone_ids=None)]), "sub") == [] + + +@pytest.mark.parametrize("configs", [None, [], [{"fqdn": "broken", "ip_addresses": ["not-an-ip"]}]]) +def test_net_021_unknown_or_malformed_dns_evidence_does_not_flag(configs): + assert az_net_021.scan(FakeAzure([_item(dns_configs=configs)]), "sub") == [] + + +def test_net_021_flags_only_public_custom_dns_configuration(): + configs = [{"fqdn": "store1.blob.core.windows.net", "ip_addresses": ["20.1.2.3"]}] + findings = az_net_021.scan(FakeAzure([_item(dns_configs=configs)]), "sub") + assert len(findings) == 1 + assert findings[0]["metadata"]["observed"]["non_private_custom_dns_configs"] == configs + + +def test_net_021_does_not_claim_effective_resolution_from_private_configuration(): + # ARM customDnsConfigs describe the endpoint's expected records. A broken + # resolver path is outside this configuration-only rule's evidence scope. + item = _item(effective_dns_results=["20.1.2.3"]) + assert az_net_021.scan(FakeAzure([item]), "sub") == [] + + +def test_private_link_collector_preserves_zone_group_failure(monkeypatch): + connection = SimpleNamespace( + private_link_service_id=_item()["target_id"], + private_link_service_connection_state=SimpleNamespace(status="Approved"), + ) + endpoint = SimpleNamespace( + id=_item()["endpoint_id"], + name="pe1", + location="uksouth", + private_link_service_connections=[connection], + manual_private_link_service_connections=[], + custom_dns_configs=[], + ) + network = SimpleNamespace( + private_endpoints=SimpleNamespace(list_by_subscription=lambda: [endpoint]), + private_dns_zone_groups=SimpleNamespace(list=lambda *_: (_ for _ in ()).throw(PermissionError("denied"))), + ) + monkeypatch.setattr("scanner.azure_client.NetworkManagementClient", lambda *_: network) + monkeypatch.setattr(AzureClient, "_get_private_link_target_public_access", lambda *_: False) + inventory = AzureClient("sub", credential=object()).get_private_link_inventory() + assert inventory is not None + assert inventory[0]["dns_zone_ids"] is None + + +def test_private_link_collector_list_failure_is_unknown(monkeypatch): + network = SimpleNamespace( + private_endpoints=SimpleNamespace(list_by_subscription=lambda: (_ for _ in ()).throw(PermissionError("denied"))) + ) + monkeypatch.setattr("scanner.azure_client.NetworkManagementClient", lambda *_: network) + assert AzureClient("sub", credential=object()).get_private_link_inventory() is None