Skip to content

Commit 0b46123

Browse files
feat(iam): Add trusted IP configurable option to reduce false positives in 'opensearch' check (#8631)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
1 parent d3213e9 commit 0b46123

File tree

10 files changed

+262
-1
lines changed

10 files changed

+262
-1
lines changed

docs/user-guide/cli/tutorials/configuration_file.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ The following list includes all the AWS checks with configurable variables that
7373
| `ssm_documents_set_as_public` | `trusted_account_ids` | List of Strings |
7474
| `vpc_endpoint_connections_trust_boundaries` | `trusted_account_ids` | List of Strings |
7575
| `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings |
76+
| `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings |
7677

7778

7879
## Azure

prowler/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
88

99
- `entra_conditional_access_policy_approved_client_app_required_for_mobile` check for m365 provider [(#10216)](https://github.com/prowler-cloud/prowler/pull/10216)
1010
- `entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required` check for M365 provider [(#10197)](https://github.com/prowler-cloud/prowler/pull/10197)
11+
- Add `trusted_ips` configurable option to `opensearch_service_domains_not_publicly_accessible` check to reduce false positives on IP-restricted policies [(#8631)](https://github.com/prowler-cloud/prowler/pull/8631)
1112

1213
### 🔄 Changed
1314

prowler/config/config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ aws:
7272
# trusted_account_ids : ["123456789012", "098765432109", "678901234567"]
7373
trusted_account_ids: []
7474

75+
# AWS OpenSearch Configuration (opensearch_service_domains_not_publicly_accessible)
76+
# Trusted IP addresses or CIDR ranges that should not be considered as public access, e.g.
77+
# trusted_ips: ["1.2.3.4", "10.0.0.0/8"]
78+
trusted_ips: []
79+
7580
# AWS Cloudwatch Configuration
7681
# aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days
7782
log_group_retention_days: 365

prowler/providers/aws/services/iam/lib/policy.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,56 @@ def is_condition_restricting_from_private_ip(condition_statement: dict) -> bool:
380380
return is_from_private_ip
381381

382382

383+
def is_condition_restricting_to_trusted_ips(
384+
condition_statement: dict, trusted_ips: list = None
385+
) -> bool:
386+
"""Check if the policy condition restricts access to trusted IP addresses.
387+
388+
Keyword arguments:
389+
condition_statement -- The policy condition to check. For example:
390+
{
391+
"IpAddress": {
392+
"aws:SourceIp": "X.X.X.X"
393+
}
394+
}
395+
trusted_ips -- A list of trusted IP addresses or CIDR ranges.
396+
"""
397+
if not trusted_ips:
398+
return False
399+
400+
try:
401+
CONDITION_OPERATOR = "IpAddress"
402+
CONDITION_KEY = "aws:sourceip"
403+
404+
if condition_statement.get(CONDITION_OPERATOR, {}):
405+
condition_statement[CONDITION_OPERATOR] = {
406+
k.lower(): v for k, v in condition_statement[CONDITION_OPERATOR].items()
407+
}
408+
409+
if condition_statement[CONDITION_OPERATOR].get(CONDITION_KEY, ""):
410+
if not isinstance(
411+
condition_statement[CONDITION_OPERATOR][CONDITION_KEY], list
412+
):
413+
condition_statement[CONDITION_OPERATOR][CONDITION_KEY] = [
414+
condition_statement[CONDITION_OPERATOR][CONDITION_KEY]
415+
]
416+
417+
trusted_ips_set = {ip.lower() for ip in trusted_ips}
418+
for ip in condition_statement[CONDITION_OPERATOR][CONDITION_KEY]:
419+
if ip == "*" or ip == "0.0.0.0/0":
420+
return False
421+
if ip not in trusted_ips_set:
422+
return False
423+
return True
424+
425+
except Exception as error:
426+
logger.error(
427+
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
428+
)
429+
430+
return False
431+
432+
383433
# TODO: Add logic for deny statements
384434
def is_policy_public(
385435
policy: dict,
@@ -388,6 +438,7 @@ def is_policy_public(
388438
not_allowed_actions: list = [],
389439
check_cross_service_confused_deputy=False,
390440
trusted_account_ids: list = None,
441+
trusted_ips: list = None,
391442
) -> bool:
392443
"""
393444
Check if the policy allows public access to the resource.
@@ -399,6 +450,7 @@ def is_policy_public(
399450
not_allowed_actions (list): List of actions that are not allowed, default: []. If not_allowed_actions is empty, the function will not consider the actions in the policy.
400451
check_cross_service_confused_deputy (bool): If the policy is checked for cross-service confused deputy, default: False
401452
trusted_account_ids (list): A list of trusted accound ids to reduce false positives on cross-account checks
453+
trusted_ips (list): A list of trusted IP addresses or CIDR ranges to reduce false positives on IP-based checks
402454
Returns:
403455
bool: True if the policy allows public access, False otherwise
404456
"""
@@ -511,6 +563,10 @@ def is_policy_public(
511563
and not is_condition_restricting_from_private_ip(
512564
statement.get("Condition", {})
513565
)
566+
and not is_condition_restricting_to_trusted_ips(
567+
statement.get("Condition", {}),
568+
trusted_ips,
569+
)
514570
)
515571
if is_public:
516572
break

prowler/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
class opensearch_service_domains_not_publicly_accessible(Check):
99
def execute(self):
1010
findings = []
11+
trusted_ips = opensearch_client.audit_config.get("trusted_ips", [])
1112
for domain in opensearch_client.opensearch_domains.values():
1213
report = Check_Report_AWS(metadata=self.metadata(), resource=domain)
1314
report.status = "PASS"
@@ -18,7 +19,9 @@ def execute(self):
1819
if domain.vpc_id:
1920
report.status_extended = f"Opensearch domain {domain.name} is in a VPC, then it is not publicly accessible."
2021
elif domain.access_policy is not None and is_policy_public(
21-
domain.access_policy, opensearch_client.audited_account
22+
domain.access_policy,
23+
opensearch_client.audited_account,
24+
trusted_ips=trusted_ips,
2225
):
2326
report.status = "FAIL"
2427
report.status_extended = f"Opensearch domain {domain.name} is publicly accessible via access policy."

tests/config/config_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def mock_prowler_get_latest_release(_, **kwargs):
3131
"ec2_allowed_interface_types": ["api_gateway_managed", "vpc_endpoint"],
3232
"ec2_allowed_instance_owners": ["amazon-elb"],
3333
"trusted_account_ids": [],
34+
"trusted_ips": [],
3435
"log_group_retention_days": 365,
3536
"max_idle_disconnect_timeout_in_seconds": 600,
3637
"max_disconnect_timeout_in_seconds": 300,
@@ -95,6 +96,7 @@ def mock_prowler_get_latest_release(_, **kwargs):
9596
"fargate_linux_latest_version": "1.4.0",
9697
"fargate_windows_latest_version": "1.0.0",
9798
"trusted_account_ids": [],
99+
"trusted_ips": [],
98100
"log_group_retention_days": 365,
99101
"max_idle_disconnect_timeout_in_seconds": 600,
100102
"max_disconnect_timeout_in_seconds": 300,

tests/config/fixtures/config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ aws:
7272
# trusted_account_ids : ["123456789012", "098765432109", "678901234567"]
7373
trusted_account_ids: []
7474

75+
# AWS OpenSearch Configuration (opensearch_service_domains_not_publicly_accessible)
76+
# Trusted IP addresses or CIDR ranges that should not be considered as public access, e.g.
77+
# trusted_ips: ["1.2.3.4", "10.0.0.0/8"]
78+
trusted_ips: []
79+
7580
# AWS Cloudwatch Configuration
7681
# aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days
7782
log_group_retention_days: 365

tests/config/fixtures/config_old.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ ec2_allowed_instance_owners:
2525
# trusted_account_ids : ["123456789012", "098765432109", "678901234567"]
2626
trusted_account_ids: []
2727

28+
# AWS OpenSearch Configuration (opensearch_service_domains_not_publicly_accessible)
29+
# Trusted IP addresses or CIDR ranges that should not be considered as public access, e.g.
30+
# trusted_ips: ["1.2.3.4", "10.0.0.0/8"]
31+
trusted_ips: []
32+
2833
# AWS Cloudwatch Configuration
2934
# aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days
3035
log_group_retention_days: 365

tests/providers/aws/services/iam/lib/policy_test.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
is_condition_block_restrictive_organization,
1414
is_condition_block_restrictive_sns_endpoint,
1515
is_condition_restricting_from_private_ip,
16+
is_condition_restricting_to_trusted_ips,
1617
is_policy_public,
1718
)
1819

@@ -1982,6 +1983,49 @@ def test_is_condition_restricting_from_private_ip_from_invalid_ip(self):
19821983
}
19831984
assert not is_condition_restricting_from_private_ip(condition_from_invalid_ip)
19841985

1986+
def test_is_condition_restricting_to_trusted_ips_no_trusted_ips(self):
1987+
condition = {"IpAddress": {"aws:SourceIp": "1.2.3.4"}}
1988+
assert not is_condition_restricting_to_trusted_ips(condition)
1989+
1990+
def test_is_condition_restricting_to_trusted_ips_empty_trusted_ips(self):
1991+
condition = {"IpAddress": {"aws:SourceIp": "1.2.3.4"}}
1992+
assert not is_condition_restricting_to_trusted_ips(condition, [])
1993+
1994+
def test_is_condition_restricting_to_trusted_ips_matching(self):
1995+
condition = {"IpAddress": {"aws:SourceIp": "1.2.3.4"}}
1996+
assert is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"])
1997+
1998+
def test_is_condition_restricting_to_trusted_ips_not_matching(self):
1999+
condition = {"IpAddress": {"aws:SourceIp": "5.6.7.8"}}
2000+
assert not is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"])
2001+
2002+
def test_is_condition_restricting_to_trusted_ips_wildcard(self):
2003+
condition = {"IpAddress": {"aws:SourceIp": "*"}}
2004+
assert not is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"])
2005+
2006+
def test_is_condition_restricting_to_trusted_ips_open_cidr(self):
2007+
condition = {"IpAddress": {"aws:SourceIp": "0.0.0.0/0"}}
2008+
assert not is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"])
2009+
2010+
def test_is_condition_restricting_to_trusted_ips_multiple_ips_all_trusted(self):
2011+
condition = {"IpAddress": {"aws:SourceIp": ["1.2.3.4", "5.6.7.8"]}}
2012+
assert is_condition_restricting_to_trusted_ips(
2013+
condition, ["1.2.3.4", "5.6.7.8"]
2014+
)
2015+
2016+
def test_is_condition_restricting_to_trusted_ips_multiple_ips_partial_trusted(self):
2017+
condition = {"IpAddress": {"aws:SourceIp": ["1.2.3.4", "9.9.9.9"]}}
2018+
assert not is_condition_restricting_to_trusted_ips(
2019+
condition, ["1.2.3.4", "5.6.7.8"]
2020+
)
2021+
2022+
def test_is_condition_restricting_to_trusted_ips_cidr_range(self):
2023+
condition = {"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}}
2024+
assert is_condition_restricting_to_trusted_ips(condition, ["10.0.0.0/8"])
2025+
2026+
def test_is_condition_restricting_to_trusted_ips_no_condition(self):
2027+
assert not is_condition_restricting_to_trusted_ips({}, ["1.2.3.4"])
2028+
19852029
def test_is_policy_public_(self):
19862030
policy = {
19872031
"Statement": [
@@ -2271,6 +2315,48 @@ def test_is_policy_public_ip(
22712315
}
22722316
assert is_policy_public(policy, TRUSTED_AWS_ACCOUNT_NUMBER)
22732317

2318+
def test_is_policy_public_with_trusted_ips(self):
2319+
policy = {
2320+
"Version": "2012-10-17",
2321+
"Statement": [
2322+
{
2323+
"Effect": "Allow",
2324+
"Principal": {"AWS": "*"},
2325+
"Action": ["*"],
2326+
"Condition": {
2327+
"IpAddress": {"aws:SourceIp": ["1.2.3.4", "5.6.7.8"]}
2328+
},
2329+
"Resource": "*",
2330+
}
2331+
],
2332+
}
2333+
assert not is_policy_public(
2334+
policy,
2335+
TRUSTED_AWS_ACCOUNT_NUMBER,
2336+
trusted_ips=["1.2.3.4", "5.6.7.8"],
2337+
)
2338+
2339+
def test_is_policy_public_with_trusted_ips_partial_match(self):
2340+
policy = {
2341+
"Version": "2012-10-17",
2342+
"Statement": [
2343+
{
2344+
"Effect": "Allow",
2345+
"Principal": {"AWS": "*"},
2346+
"Action": ["*"],
2347+
"Condition": {
2348+
"IpAddress": {"aws:SourceIp": ["1.2.3.4", "9.9.9.9"]}
2349+
},
2350+
"Resource": "*",
2351+
}
2352+
],
2353+
}
2354+
assert is_policy_public(
2355+
policy,
2356+
TRUSTED_AWS_ACCOUNT_NUMBER,
2357+
trusted_ips=["1.2.3.4", "5.6.7.8"],
2358+
)
2359+
22742360
def test_check_admin_access(self):
22752361
policy = {
22762362
"Version": "2012-10-17",

tests/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible_test.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@
7474
],
7575
}
7676

77+
policy_data_trusted_ip = {
78+
"Version": "2012-10-17",
79+
"Statement": [
80+
{
81+
"Effect": "Allow",
82+
"Principal": {"AWS": "*"},
83+
"Action": ["es:ESHttp*"],
84+
"Condition": {"IpAddress": {"aws:SourceIp": ["1.2.3.4", "5.6.7.8"]}},
85+
"Resource": f"arn:aws:es:us-west-2:{AWS_ACCOUNT_NUMBER}:domain/{domain_name}/*",
86+
}
87+
],
88+
}
89+
7790

7891
class Test_opensearch_service_domains_not_publicly_accessible:
7992
@mock_aws
@@ -304,3 +317,87 @@ def test_policy_data_not_restricted_whole_internet(self):
304317
assert result[0].resource_arn == domain_arn
305318
assert result[0].region == AWS_REGION_US_WEST_2
306319
assert result[0].resource_tags == []
320+
321+
@mock_aws
322+
def test_policy_data_not_restricted_with_trusted_ips(self):
323+
opensearch_client = client("opensearch", region_name=AWS_REGION_US_WEST_2)
324+
domain_arn = opensearch_client.create_domain(
325+
DomainName=domain_name,
326+
AccessPolicies=dumps(policy_data_trusted_ip),
327+
)["DomainStatus"]["ARN"]
328+
329+
aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2])
330+
aws_provider._audit_config = {"trusted_ips": ["1.2.3.4", "5.6.7.8"]}
331+
332+
from prowler.providers.aws.services.opensearch.opensearch_service import (
333+
OpenSearchService,
334+
)
335+
336+
with (
337+
mock.patch(
338+
"prowler.providers.common.provider.Provider.get_global_provider",
339+
return_value=aws_provider,
340+
),
341+
mock.patch(
342+
"prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible.opensearch_client",
343+
new=OpenSearchService(aws_provider),
344+
),
345+
):
346+
from prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible import (
347+
opensearch_service_domains_not_publicly_accessible,
348+
)
349+
350+
check = opensearch_service_domains_not_publicly_accessible()
351+
result = check.execute()
352+
assert len(result) == 1
353+
assert result[0].status == "PASS"
354+
assert (
355+
result[0].status_extended
356+
== f"Opensearch domain {domain_name} is not publicly accessible."
357+
)
358+
assert result[0].resource_id == domain_name
359+
assert result[0].resource_arn == domain_arn
360+
assert result[0].region == AWS_REGION_US_WEST_2
361+
assert result[0].resource_tags == []
362+
363+
@mock_aws
364+
def test_policy_data_not_restricted_with_trusted_ips_partial_match(self):
365+
opensearch_client = client("opensearch", region_name=AWS_REGION_US_WEST_2)
366+
domain_arn = opensearch_client.create_domain(
367+
DomainName=domain_name,
368+
AccessPolicies=dumps(policy_data_trusted_ip),
369+
)["DomainStatus"]["ARN"]
370+
371+
aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2])
372+
aws_provider._audit_config = {"trusted_ips": ["1.2.3.4"]}
373+
374+
from prowler.providers.aws.services.opensearch.opensearch_service import (
375+
OpenSearchService,
376+
)
377+
378+
with (
379+
mock.patch(
380+
"prowler.providers.common.provider.Provider.get_global_provider",
381+
return_value=aws_provider,
382+
),
383+
mock.patch(
384+
"prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible.opensearch_client",
385+
new=OpenSearchService(aws_provider),
386+
),
387+
):
388+
from prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible import (
389+
opensearch_service_domains_not_publicly_accessible,
390+
)
391+
392+
check = opensearch_service_domains_not_publicly_accessible()
393+
result = check.execute()
394+
assert len(result) == 1
395+
assert result[0].status == "FAIL"
396+
assert (
397+
result[0].status_extended
398+
== f"Opensearch domain {domain_name} is publicly accessible via access policy."
399+
)
400+
assert result[0].resource_id == domain_name
401+
assert result[0].resource_arn == domain_arn
402+
assert result[0].region == AWS_REGION_US_WEST_2
403+
assert result[0].resource_tags == []

0 commit comments

Comments
 (0)