Skip to content

Commit df680ef

Browse files
fix(route53): resolve false positive in dangling IP check (#9952)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
1 parent 451071d commit df680ef

File tree

4 files changed

+130
-13
lines changed

4 files changed

+130
-13
lines changed

prowler/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
2424

2525
- Bump `multipart` to 1.3.1 to fix [GHSA-p2m9-wcp5-6qw3](https://github.com/defnull/multipart/security/advisories/GHSA-p2m9-wcp5-6qw3) [(#10331)](https://github.com/prowler-cloud/prowler/pull/10331)
2626

27+
### 🐞 Fixed
28+
29+
- Route53 dangling IP check false positive when using `--region` flag [(#9952)](https://github.com/prowler-cloud/prowler/pull/9952)
30+
2731
---
2832

2933
## [5.20.0] (Prowler v5.20.0)

prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,21 @@ class route53_dangling_ip_subdomain_takeover(Check):
1212
def execute(self) -> Check_Report_AWS:
1313
findings = []
1414

15+
# When --region is used, Route53 service gathers EIPs from all regions
16+
# to avoid false positives. Otherwise, use ec2_client data directly.
17+
if route53_client.all_account_elastic_ips:
18+
public_ips = list(route53_client.all_account_elastic_ips)
19+
else:
20+
public_ips = [eip.public_ip for eip in ec2_client.elastic_ips]
21+
22+
# Add Network Interface public IPs from audited regions
23+
for ni in ec2_client.network_interfaces.values():
24+
if ni.association and ni.association.get("PublicIp"):
25+
public_ips.append(ni.association.get("PublicIp"))
26+
1527
for record_set in route53_client.record_sets:
1628
# Check only A records and avoid aliases (only need to check IPs not AWS Resources)
1729
if record_set.type == "A" and not record_set.is_alias:
18-
# Gather Elastic IPs and Network Interfaces Public IPs inside the AWS Account
19-
public_ips = []
20-
public_ips.extend([eip.public_ip for eip in ec2_client.elastic_ips])
21-
# Add public IPs from Network Interfaces
22-
for network_interface in ec2_client.network_interfaces.values():
23-
if (
24-
network_interface.association
25-
and network_interface.association.get("PublicIp")
26-
):
27-
public_ips.append(network_interface.association.get("PublicIp"))
2830
for record in record_set.records:
2931
# Check if record is an IP Address
3032
if validate_ip_address(record):

prowler/providers/aws/services/route53/route53_service.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,20 @@ def __init__(self, provider):
1313
super().__init__(__class__.__name__, provider, global_service=True)
1414
self.hosted_zones = {}
1515
self.record_sets = []
16+
self.all_account_elastic_ips = []
1617
self._list_hosted_zones()
1718
self._list_query_logging_configs()
1819
self._list_tags_for_resource()
1920
self._list_resource_record_sets()
21+
# Gather Elastic IPs from all regions only when the --region flag is used,
22+
# since EC2 service will only have EIPs from the specified region(s) but
23+
# Route53 is global and can reference EIPs from any region.
24+
if (
25+
"route53_dangling_ip_subdomain_takeover"
26+
in provider.audit_metadata.expected_checks
27+
and provider._identity.audited_regions
28+
):
29+
self._get_all_region_elastic_ips()
2030

2131
def _list_hosted_zones(self):
2232
logger.info("Route53 - Listing Hosting Zones...")
@@ -77,6 +87,31 @@ def _list_resource_record_sets(self):
7787
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
7888
)
7989

90+
def _get_all_region_elastic_ips(self):
91+
"""Gather Elastic IPs from all enabled regions since Route53 is a global service.
92+
93+
When running Prowler with --region, ec2_client.elastic_ips is scoped
94+
to the specified region(s). Route53 records can reference EIPs from any
95+
region, so we need to query all enabled regions to avoid false positives.
96+
"""
97+
logger.info("Route53 - Gathering Elastic IPs from all regions...")
98+
all_regions = self.provider._enabled_regions or set(
99+
self.provider._identity.audited_regions
100+
)
101+
102+
for region in all_regions:
103+
try:
104+
regional_ec2_client = self.session.client("ec2", region_name=region)
105+
for addr in regional_ec2_client.describe_addresses().get(
106+
"Addresses", []
107+
):
108+
if "PublicIp" in addr:
109+
self.all_account_elastic_ips.append(addr["PublicIp"])
110+
except Exception as error:
111+
logger.warning(
112+
f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
113+
)
114+
80115
def _list_query_logging_configs(self):
81116
logger.info("Route53 - Listing Query Logging Configs...")
82117
try:

tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
from boto3 import client, resource
44
from moto import mock_aws
55

6-
from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider
6+
from tests.providers.aws.utils import (
7+
AWS_REGION_US_EAST_1,
8+
AWS_REGION_US_WEST_2,
9+
set_mocked_aws_provider,
10+
)
711

812
HOSTED_ZONE_NAME = "testdns.aws.com."
913

@@ -309,7 +313,10 @@ def test_hosted_zone_eip_record(self):
309313
from prowler.providers.aws.services.ec2.ec2_service import EC2
310314
from prowler.providers.aws.services.route53.route53_service import Route53
311315

312-
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
316+
aws_provider = set_mocked_aws_provider(
317+
[AWS_REGION_US_EAST_1],
318+
expected_checks=["route53_dangling_ip_subdomain_takeover"],
319+
)
313320

314321
with mock.patch(
315322
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -387,7 +394,10 @@ def test_hosted_zone_eni_record(self):
387394
from prowler.providers.aws.services.ec2.ec2_service import EC2
388395
from prowler.providers.aws.services.route53.route53_service import Route53
389396

390-
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
397+
aws_provider = set_mocked_aws_provider(
398+
[AWS_REGION_US_EAST_1],
399+
expected_checks=["route53_dangling_ip_subdomain_takeover"],
400+
)
391401

392402
with mock.patch(
393403
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -426,3 +436,69 @@ def test_hosted_zone_eni_record(self):
426436
result[0].resource_arn
427437
== f"arn:{aws_provider.identity.partition}:route53:::hostedzone/{zone_id.replace('/hostedzone/', '')}"
428438
)
439+
440+
@mock_aws
441+
def test_hosted_zone_eip_cross_region(self):
442+
"""EIP in us-west-2 referenced by Route53 A record should PASS even when auditing us-east-1 only."""
443+
conn = client("route53", region_name=AWS_REGION_US_EAST_1)
444+
ec2_west = client("ec2", region_name=AWS_REGION_US_WEST_2)
445+
446+
address = "17.5.7.3"
447+
ec2_west.allocate_address(Domain="vpc", Address=address)
448+
449+
zone_id = conn.create_hosted_zone(
450+
Name=HOSTED_ZONE_NAME, CallerReference=str(hash("foo"))
451+
)["HostedZone"]["Id"]
452+
453+
record_set_name = "foo.bar.testdns.aws.com."
454+
record_ip = address
455+
conn.change_resource_record_sets(
456+
HostedZoneId=zone_id,
457+
ChangeBatch={
458+
"Changes": [
459+
{
460+
"Action": "CREATE",
461+
"ResourceRecordSet": {
462+
"Name": record_set_name,
463+
"Type": "A",
464+
"ResourceRecords": [{"Value": record_ip}],
465+
},
466+
}
467+
]
468+
},
469+
)
470+
from prowler.providers.aws.services.ec2.ec2_service import EC2
471+
from prowler.providers.aws.services.route53.route53_service import Route53
472+
473+
# Audit only us-east-1 but enable both regions so Route53 finds the cross-region EIP
474+
aws_provider = set_mocked_aws_provider(
475+
audited_regions=[AWS_REGION_US_EAST_1],
476+
enabled_regions={AWS_REGION_US_EAST_1, AWS_REGION_US_WEST_2},
477+
expected_checks=["route53_dangling_ip_subdomain_takeover"],
478+
)
479+
480+
with mock.patch(
481+
"prowler.providers.common.provider.Provider.get_global_provider",
482+
return_value=aws_provider,
483+
):
484+
with mock.patch(
485+
"prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.route53_client",
486+
new=Route53(aws_provider),
487+
):
488+
with mock.patch(
489+
"prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.ec2_client",
490+
new=EC2(aws_provider),
491+
):
492+
from prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover import (
493+
route53_dangling_ip_subdomain_takeover,
494+
)
495+
496+
check = route53_dangling_ip_subdomain_takeover()
497+
result = check.execute()
498+
499+
assert len(result) == 1
500+
assert result[0].status == "PASS"
501+
assert (
502+
result[0].status_extended
503+
== f"Route53 record {record_ip} (name: {record_set_name}) in Hosted Zone {HOSTED_ZONE_NAME} is not a dangling IP."
504+
)

0 commit comments

Comments
 (0)