fix(checks): report MANUAL instead of FAIL on permission and data-availability errors (#12645)

This commit is contained in:
Daniel Barranquero
2026-09-01 17:16:56 +02:00
committed by GitHub
parent e9121f5f1a
commit 51c5fa7168
73 changed files with 2852 additions and 389 deletions
+32 -1
View File
@@ -129,12 +129,42 @@ Each check **must** populate the `report.status` and `report.status_extended` fi
- Status field: `report.status`
- `PASS` – Assigned when the check confirms compliance with the configured value.
- `FAIL` – Assigned when the check detects non-compliance with the configured value.
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`).
- `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). This includes the case where Prowler could not retrieve the data needed to evaluate the resource (see below).
- Status extended field: `report.status_extended`
- It **must** end with a period (`.`).
- It **must** include the audited service, the resource, and a concise explanation of the check result, for instance: `EC2 AMI ami-0123456789 is not public.`.
### Permission and Data-Availability Errors Are Not Findings
A `FAIL` must only be emitted when an insecure condition has actually been detected. A check **must never** report `FAIL` because the underlying API call failed: missing permissions or scopes on the scanning identity, an API that is not enabled, a feature that is not licensed, or data that could not be retrieved are scan-configuration problems, not security issues. Reporting them as `FAIL` surfaces a misleading (and often high-severity) finding to the user and skews compliance scores.
When the service layer cannot obtain the data a check depends on, the check must:
1. Emit a single `MANUAL` finding scoped to the widest affected resource (the tenant, account, project or subscription), not one finding per resource. For example, if user registration details cannot be read, emit one tenant-level `MANUAL` instead of one per user.
2. Explain in `status_extended` that the check could not be evaluated and what to fix, naming the permission, scope, API or license required, for instance: `Cannot evaluate credential exposure for privileged users: unable to query Microsoft Defender XDR Advanced Hunting. Verify that the ThreatHunting.Read.All permission is granted to the scanning application.`
3. Leave the check's severity untouched. Do not override `report.check_metadata.Severity` to hide the problem.
The service layer must make the distinction possible: log the error and expose it to checks in a way that cannot be confused with a legitimate empty result. Common patterns already used in Prowler are:
- Defaulting the attribute to `None` (data could not be read) instead of `[]`/`{}` (data was read and is empty), e.g. the `metric_filters is not None` guard in `prowler/providers/aws/services/cloudwatch/lib/metric_filters.py`.
- Keeping an availability flag raised on any denied listing, e.g. `logs_client.metric_filters_unavailable` consumed by the AWS CloudWatch metric filter checks.
- Keeping an error flag or message next to the data, e.g. `entra_client.user_registration_details_error` in M365 or `*_scan_errors` in AWS Bedrock.
- Keeping a set of resources whose lookup failed, e.g. `accessapproval_client.settings_lookup_failed` in GCP.
Make sure the error branch only captures real access errors. A `404`/not-found response frequently means the feature is simply not configured, which **is** a legitimate `FAIL`; a `403` or an unexpected exception is not. An "API not enabled" error is usually a scan-configuration problem too — **except** when the API's activation is itself the control being audited (e.g. GCP Access Approval: with `accessapproval.googleapis.com` disabled the feature provably cannot be enabled, so a definitive API-disabled state is a legitimate `FAIL`, while an undetermined state stays `MANUAL`).
```python
if <service>_client.<data> is None:
report = CheckReport<Provider>(metadata=self.metadata(), resource={})
report.resource_name = "<Tenant/Account-level resource>"
report.resource_id = "<stable-id>"
report.status = "MANUAL"
report.status_extended = "Cannot evaluate <requirement>: <data> could not be retrieved. Verify that <permission/API/license> is granted to the scanning identity."
findings.append(report)
return findings
```
### Prowler's Check Severity Levels
The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below.
@@ -437,6 +467,7 @@ The metadata structure is enforced in code using a Pydantic model. For reference
- Use clear, actionable, and user-friendly language in `status_extended` to explain the result. Always provide information to identify the resource.
- Use helper functions/utilities for repeated logic to avoid code duplication. Save them in the `lib` folder of the service.
- Handle exceptions gracefully: catch errors per resource, log them, and continue processing other resources.
- Never report `FAIL` because data could not be retrieved (missing permissions, API not enabled, feature not licensed). Emit a single `MANUAL` finding explaining what is required instead; see [Permission and Data-Availability Errors Are Not Findings](#permission-and-data-availability-errors-are-not-findings).
- Document the check with a class and function level docstring explaining what it does, what it checks, and any caveats or provider-specific behaviors.
- Use type hints for the `execute()` method (e.g., `-> list[CheckReport<Provider>]`) for clarity and static analysis.
- Ensure checks are efficient; avoid excessive nested loops. If the complexity is high, consider refactoring the check.
@@ -0,0 +1 @@
Checks no longer report `FAIL` when the scanning identity lacks permissions, an API is not enabled or a feature is not licensed; they now emit a single `MANUAL` finding naming what is required, across 28 M365, Azure, AWS and GCP checks
@@ -24,10 +24,25 @@ class bedrock_agent_role_least_privilege(Check):
Returns:
A list of ``Check_Report_AWS`` with one entry per agent. The
status is ``FAIL`` when any of the criteria above is violated,
or when the execution role cannot be resolved in IAM.
status is ``FAIL`` when any of the criteria above is violated and
``MANUAL`` when the execution role cannot be resolved in IAM. When
the IAM role inventory itself could not be listed, a single
account-level ``MANUAL`` report is returned instead.
"""
findings = []
if iam_client.roles is None and bedrock_agent_client.agents:
# iam:ListRoles was denied: this is an account-wide condition, so
# emit one account-level MANUAL instead of one per agent.
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.region = iam_client.region
report.resource_id = iam_client.audited_account
report.resource_arn = iam_client.audited_account_arn
report.status = "MANUAL"
report.status_extended = "Cannot evaluate Bedrock Agent execution roles: the IAM roles could not be listed. Verify that the scanning credentials are allowed to call iam:ListRoles."
findings.append(report)
return findings
roles_by_arn = {role.arn: role for role in (iam_client.roles or [])}
for agent in bedrock_agent_client.agents.values():
@@ -39,10 +54,11 @@ class bedrock_agent_role_least_privilege(Check):
role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None
if role is None:
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role could not be "
f"resolved in IAM and cannot be evaluated for least privilege."
f"resolved in IAM and cannot be evaluated for least privilege; "
f"verify the role manually."
)
findings.append(report)
continue
@@ -15,6 +15,9 @@ class Cloudtrail(AWSService):
super().__init__(__class__.__name__, provider)
self.trail_arn_template = f"arn:{self.audited_partition}:cloudtrail:{self.region}:{self.audited_account}:trail"
self.trails = {}
# True when DescribeTrails was denied in at least one audited region,
# so the trail inventory may be incomplete.
self.trails_unavailable = False
self.__threading_call__(self._get_trails)
if self.trails:
self._get_trail_status()
@@ -79,13 +82,16 @@ class Cloudtrail(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.trails_unavailable = True
if not self.trails:
self.trails = None
else:
self.trails_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.trails_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_acls_alarm_configured(Check):
def execute(self):
"""CloudWatch log metric filter and alarm exist for Network ACL (NACL) change events.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateNetworkAcl",
@@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_acls_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_gateways_alarm_configured(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for changes to network gateways.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateCustomerGateway",
@@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_gateways_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_route_tables_alarm_configured(Check):
def execute(self):
"""Account monitors VPC route table changes with a CloudWatch Logs metric filter and alarm.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="ec2.amazonaws.com",
event_names=[
@@ -36,16 +53,29 @@ class cloudwatch_changes_to_network_route_tables_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_vpcs_alarm_configured(Check):
def execute(self):
"""AWS account has a CloudWatch Logs metric filter and alarm for VPC changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateVpc",
@@ -39,16 +56,29 @@ class cloudwatch_changes_to_vpcs_alarm_configured(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -15,7 +15,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled(
Check
):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for AWS Config configuration changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="config.amazonaws.com",
event_names=[
@@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -15,7 +15,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled(
Check
):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for CloudTrail configuration changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"CreateTrail",
@@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_authentication_failures(Check):
def execute(self):
"""Account has a CloudWatch Logs metric filter and alarm for AWS Management Console authentication failures.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=["ConsoleLogin"],
extra_clauses=[("errorMessage", "=", "Failed authentication")],
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_authentication_failures(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_aws_organizations_changes(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for AWS Organizations changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="organizations.amazonaws.com",
event_names=[
@@ -50,16 +67,29 @@ class cloudwatch_log_metric_filter_aws_organizations_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check):
def execute(self):
"""Account has a CloudWatch log metric filter and alarm for disabling or scheduled deletion of customer-managed KMS keys.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="kms.amazonaws.com",
event_names=["DisableKey", "ScheduleKeyDeletion"],
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Chec
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check):
def execute(self):
"""CloudWatch log metric filter and alarm exist for S3 bucket policy changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_source="s3.amazonaws.com",
event_names=[
@@ -38,16 +55,29 @@ class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_policy_changes(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for IAM policy changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"DeleteGroupPolicy",
@@ -44,16 +61,29 @@ class cloudwatch_log_metric_filter_policy_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_root_usage(Check):
def execute(self):
"""Account has a CloudWatch Logs metric filter and alarm for root account usage.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = r"\$\.userIdentity\.type\s*=\s*.?Root.+\$\.userIdentity\.invokedBy NOT EXISTS.+\$\.eventType\s*!=\s*.?AwsServiceEvent.?"
findings = []
@@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_root_usage(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_security_group_changes(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for security group changes.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=[
"AuthorizeSecurityGroupIngress",
@@ -34,16 +51,29 @@ class cloudwatch_log_metric_filter_security_group_changes(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_sign_in_without_mfa(Check):
def execute(self):
"""CloudWatch log metric filter and alarm exist for Management Console sign-in without MFA.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = build_metric_filter_pattern(
event_names=["ConsoleLogin"],
extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")],
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_sign_in_without_mfa(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_unauthorized_api_calls(Check):
def execute(self):
"""CloudWatch Logs metric filter and alarm exist for unauthorized API calls.
Looks for a CloudWatch Logs metric filter matching the expected pattern on a
log group used by a CloudTrail trail, with at least one alarm on its metric.
- PASS: A matching metric filter with an associated alarm exists.
- FAIL: No matching metric filter, or a filter without an alarm, was found.
- MANUAL: CloudTrail trails, log groups, metric filters or alarms could
not be listed in at least one region, so the absence of a filter/alarm
cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate the metric filter and alarm coverage for the account.
Returns:
list[Check_Report_AWS]: A single report for the account.
"""
pattern = r"\$\.errorCode\s*=\s*.?\*UnauthorizedOperation.+\$\.errorCode\s*=\s*.?AccessDenied\*.?"
findings = []
@@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_unauthorized_api_calls(Check):
self.metadata(),
)
if cloudtrail_client.trails is not None:
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
inventory_unavailable = (
cloudtrail_client.trails_unavailable
or logs_client.log_groups_unavailable
or logs_client.metric_filters_unavailable
or cloudwatch_client.metric_alarms_unavailable
)
findings.append(report)
if report is None:
report = Check_Report_AWS(metadata=self.metadata(), resource={})
report.status = "FAIL"
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
report.region = logs_client.region
report.resource_id = logs_client.audited_account
report.resource_arn = logs_client.log_group_arn_template
report.resource_tags = []
# A denied listing in any region means the inventory is incomplete: a
# PASS is still backed by a real filter and alarm, but a FAIL (nothing
# found, or a filter found without its alarm) cannot be trusted.
if report.status == "FAIL" and inventory_unavailable:
report.status = "MANUAL"
report.status_extended = "Cannot evaluate CloudWatch metric filters and alarms: CloudTrail trails, log groups, metric filters or alarms could not be listed in at least one region. Verify that the scanning credentials are allowed to call cloudtrail:DescribeTrails, logs:DescribeLogGroups, logs:DescribeMetricFilters and cloudwatch:DescribeAlarms."
findings.append(report)
return findings
@@ -19,6 +19,9 @@ class CloudWatch(AWSService):
# Call AWSService's __init__
super().__init__(__class__.__name__, provider)
self.metric_alarms = []
# True when DescribeAlarms was denied in at least one audited region,
# so the alarm inventory may be incomplete.
self.metric_alarms_unavailable = False
self.__threading_call__(self._describe_alarms)
if self.metric_alarms:
self._list_tags_for_resource()
@@ -56,13 +59,16 @@ class CloudWatch(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.metric_alarms_unavailable = True
if not self.metric_alarms:
self.metric_alarms = None
else:
self.metric_alarms_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.metric_alarms_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -92,6 +98,9 @@ class Logs(AWSService):
# index for cross-service evidence lookups.
self.all_log_groups = {}
self.log_groups = {}
# True when DescribeLogGroups was denied in at least one audited
# region, so the log group inventory may be incomplete.
self.log_groups_unavailable = False
self._log_groups_hydrated = set()
self.log_group_limit = get_resource_scan_limit(
self.audit_config, "max_cloudwatch_log_groups"
@@ -103,6 +112,9 @@ class Logs(AWSService):
self.resource_policies = {}
self.__threading_call__(self._describe_resource_policies)
self.metric_filters = []
# True when DescribeMetricFilters was denied in at least one audited
# region, so the metric filter inventory may be incomplete.
self.metric_filters_unavailable = False
self.__threading_call__(self._describe_metric_filters)
if self.log_groups:
if (
@@ -166,6 +178,9 @@ class Logs(AWSService):
arn=arn,
name=filter["filterName"],
metric=filter["metricTransformations"][0]["metricName"],
metric_namespace=filter["metricTransformations"][0].get(
"metricNamespace"
),
pattern=filter.get("filterPattern", ""),
log_group=log_group,
region=regional_client.region,
@@ -176,13 +191,16 @@ class Logs(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.metric_filters_unavailable = True
if not self.metric_filters:
self.metric_filters = None
else:
self.metric_filters_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.metric_filters_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -241,14 +259,17 @@ class Logs(AWSService):
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
self.log_groups_unavailable = True
if not self.log_groups:
self.all_log_groups = None
self.log_groups = None
else:
self.log_groups_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.log_groups_unavailable = True
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -376,6 +397,7 @@ class MetricFilter(BaseModel):
arn: str
name: str
metric: str
metric_namespace: Optional[str] = None
pattern: str
log_group: Optional[LogGroup] = None
region: str
@@ -62,7 +62,8 @@ def check_cloudwatch_log_metric_filter(
against each filter's pattern with ``re.DOTALL``.
trails: CloudTrail trails keyed by ARN; only those with a log group count.
metric_filters: CloudWatch Logs metric filters to evaluate.
metric_alarms: CloudWatch alarms, matched to a filter by metric name.
metric_alarms: CloudWatch alarms, matched to a filter by metric name and
region, and by namespace when both sides expose one.
metadata: check metadata for the emitted report.
Returns:
@@ -90,9 +91,20 @@ def check_cloudwatch_log_metric_filter(
)
report.status = "FAIL"
report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} but no alarms associated."
# 3. Check if there is an alarm for the metric
# 3. Check if there is an alarm for the metric. The alarm must
# watch the same metric name in the same region, and the same
# namespace when both sides expose one — a same-named metric
# in another namespace or region is a different metric.
for alarm in metric_alarms:
if alarm.metric == metric_filter.metric:
if (
alarm.metric == metric_filter.metric
and alarm.region == metric_filter.region
and (
not metric_filter.metric_namespace
or not alarm.name_space
or alarm.name_space == metric_filter.metric_namespace
)
):
report.status = "PASS"
report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} and alarms set."
break
@@ -15,10 +15,13 @@ class rolesanywhere_trust_anchor_pqc_pki(Check):
"""Verify that IAM Roles Anywhere trust anchors are backed by a post-quantum PKI.
For trust anchors whose source is ``AWS_ACM_PCA``, the linked Private CA's
``KeyAlgorithm`` is checked against the configured ML-DSA allowlist.
``KeyAlgorithm`` is checked against the configured ML-DSA allowlist. A CA
that exists but cannot be inspected (cross-account or missing acm-pca
permissions) is a data-availability gap and is reported as MANUAL.
Trust anchors backed by an external ``CERTIFICATE_BUNDLE`` are reported as
FAIL because their certificate signature algorithm cannot be inspected
from the IAM Roles Anywhere API alone.
FAIL by design: the bundle is user-supplied rather than an AWS-managed CA,
so migrating to an ML-DSA AWS Private CA is the remediation regardless of
the bundle's contents.
"""
def execute(self) -> list[Check_Report_AWS]:
@@ -56,13 +59,13 @@ class rolesanywhere_trust_anchor_pqc_pki(Check):
"post-quantum (ML-DSA)."
)
else:
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
f"IAM Roles Anywhere trust anchor {trust_anchor.name} is "
f"backed by Private CA {trust_anchor.acm_pca_arn}, which "
"could not be inspected (cross-account or missing "
"acm-pca permissions). Verify the CA uses an ML-DSA key "
"algorithm."
"acm-pca permissions). Verify manually that the CA uses "
"an ML-DSA key algorithm."
)
else:
source = trust_anchor.source_type or "<none>"
@@ -3,12 +3,31 @@ from prowler.providers.aws.services.s3.s3_client import s3_client
class s3_bucket_cross_region_replication(Check):
def execute(self):
"""Ensure S3 buckets replicate to a bucket in a different region.
- PASS: At least one enabled replication rule targets a bucket in another region.
- FAIL: Versioning is disabled, no enabled rule exists, or every resolvable
destination is in the same region.
- MANUAL: The versioning or replication configuration could not be retrieved
(missing permissions), or a destination bucket is outside the audited
account/scope so its region cannot be determined.
"""
def execute(self) -> list[Check_Report_AWS]:
findings = []
for bucket in s3_client.buckets.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=bucket)
if not bucket.versioning_retrieved or not bucket.replication_retrieved:
report.status = "MANUAL"
report.status_extended = f"Cannot evaluate cross region replication for S3 Bucket {bucket.name}: the versioning or replication configuration could not be retrieved. Verify that the scanning credentials are allowed to call s3:GetBucketVersioning and s3:GetReplicationConfiguration."
findings.append(report)
continue
report.status = "FAIL"
report.status_extended = f"S3 Bucket {bucket.name} does not have correct cross region replication configuration."
unresolvable_report = None
same_region_report = None
if bucket.replication_rules:
for rule in bucket.replication_rules:
if (
@@ -17,8 +36,7 @@ class s3_bucket_cross_region_replication(Check):
and rule.destination
):
if rule.destination not in s3_client.buckets:
report.status = "FAIL"
report.status_extended = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {rule.destination.split(':')[-1]} which is out of Prowler's scope."
unresolvable_report = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {rule.destination.split(':')[-1]} which is out of Prowler's scope; verify manually that the destination bucket is in a different region."
else:
destination_bucket = s3_client.buckets[rule.destination]
if destination_bucket.region != bucket.region:
@@ -26,8 +44,14 @@ class s3_bucket_cross_region_replication(Check):
report.status_extended = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {destination_bucket.name} located in region {destination_bucket.region}."
break
else:
report.status = "FAIL"
report.status_extended = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {destination_bucket.name} located in the same region."
same_region_report = f"S3 Bucket {bucket.name} has cross region replication rule {rule.id} in bucket {destination_bucket.name} located in the same region."
# Precedence: PASS > MANUAL (unresolvable destination) > FAIL
if report.status != "PASS":
if unresolvable_report:
report.status = "MANUAL"
report.status_extended = unresolvable_report
elif same_region_report:
report.status_extended = same_region_report
findings.append(report)
return findings
@@ -3,11 +3,27 @@ from prowler.providers.aws.services.s3.s3_client import s3_client
class s3_bucket_object_versioning(Check):
def execute(self):
"""Ensure S3 buckets have object versioning enabled.
- PASS: Versioning is enabled.
- FAIL: Versioning is disabled.
- MANUAL: The versioning configuration could not be retrieved (missing
permissions), so the status cannot be asserted.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate versioning for every audited bucket.
Returns:
list[Check_Report_AWS]: One report per bucket.
"""
findings = []
for bucket in s3_client.buckets.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=bucket)
if bucket.versioning:
if not bucket.versioning_retrieved:
report.status = "MANUAL"
report.status_extended = f"Cannot evaluate versioning for S3 Bucket {bucket.name}: the versioning configuration could not be retrieved. Verify that the scanning credentials are allowed to call s3:GetBucketVersioning."
elif bucket.versioning:
report.status = "PASS"
report.status_extended = (
f"S3 Bucket {bucket.name} has versioning enabled."
@@ -122,10 +122,12 @@ class S3(AWSService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
bucket.versioning_retrieved = False
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
bucket.versioning_retrieved = False
if bucket.region:
logger.error(
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -441,10 +443,12 @@ class S3(AWSService):
):
bucket.replication = None
else:
bucket.replication_retrieved = False
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
bucket.replication_retrieved = False
if regional_client:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -782,5 +786,9 @@ class Bucket(BaseModel):
tags: List[Dict[str, str]] = Field(default_factory=list)
lifecycle: List[LifeCycleRule] = Field(default_factory=list)
replication_rules: List[ReplicationRule] = Field(default_factory=list)
# False when GetBucketVersioning / GetBucketReplication failed for a reason
# other than the bucket or configuration not existing (e.g. AccessDenied).
versioning_retrieved: bool = True
replication_retrieved: bool = True
notification_config: Dict = Field(default_factory=dict)
object_sampling: Optional[BucketObjectSampling] = None
@@ -7,6 +7,21 @@ class entra_global_admin_in_less_than_five_users(Check):
findings = []
for tenant_domain, directory_roles in entra_client.directory_roles.items():
if tenant_domain in entra_client.users_retrieval_errors:
report = Check_Report_Azure(metadata=self.metadata(), resource={})
report.subscription = f"Tenant: {tenant_domain}"
report.resource_name = tenant_domain
report.resource_id = entra_client.tenant_ids[0]
report.status = "MANUAL"
report.status_extended = (
f"Cannot evaluate the number of global administrators for tenant {tenant_domain}: "
f"Microsoft Graph did not return the tenant's users "
f"({entra_client.users_retrieval_errors[tenant_domain]}). "
f"Retry the scan or review the tenant's global administrators manually."
)
findings.append(report)
continue
report = Check_Report_Azure(
metadata=self.metadata(),
resource=directory_roles.get("Global Administrator", {}),
@@ -10,6 +10,21 @@ class entra_non_privileged_user_has_mfa(Check):
findings = []
for tenant_domain, users in entra_client.users.items():
if tenant_domain in entra_client.users_retrieval_errors:
report = Check_Report_Azure(metadata=self.metadata(), resource={})
report.subscription = f"Tenant: {tenant_domain}"
report.resource_name = tenant_domain
report.resource_id = entra_client.tenant_ids[0]
report.status = "MANUAL"
report.status_extended = (
f"Cannot evaluate MFA for the tenant's non-privileged users for tenant {tenant_domain}: "
f"Microsoft Graph did not return the tenant's users "
f"({entra_client.users_retrieval_errors[tenant_domain]}). "
f"Retry the scan or review the tenant's users manually."
)
findings.append(report)
continue
for user in users.values():
if user.account_enabled and not is_privileged_user(
user, entra_client.directory_roles[tenant_domain]
@@ -10,6 +10,21 @@ class entra_privileged_user_has_mfa(Check):
findings = []
for tenant_domain, users in entra_client.users.items():
if tenant_domain in entra_client.users_retrieval_errors:
report = Check_Report_Azure(metadata=self.metadata(), resource={})
report.subscription = f"Tenant: {tenant_domain}"
report.resource_name = tenant_domain
report.resource_id = entra_client.tenant_ids[0]
report.status = "MANUAL"
report.status_extended = (
f"Cannot evaluate MFA for the tenant's privileged users for tenant {tenant_domain}: "
f"Microsoft Graph did not return the tenant's users "
f"({entra_client.users_retrieval_errors[tenant_domain]}). "
f"Retry the scan or review the tenant's users manually."
)
findings.append(report)
continue
for user_domain_name, user in users.items():
if is_privileged_user(
user, entra_client.directory_roles[tenant_domain]
@@ -39,6 +39,18 @@ class Entra(AzureService):
"Cannot initialize Entra service while event loop is running"
)
# Tenants (keyed by domain) whose sign-in activity could not be read,
# mapped to the reason. Microsoft Graph rejects the whole /users request
# with a 403 when the tenant lacks Entra ID P1/P2 or the application
# lacks AuditLog.Read.All, so users are re-fetched without
# signInActivity and the tenant is recorded here.
self.sign_in_activity_errors: dict[str, str] = {}
# Tenants (keyed by domain) whose users could not be retrieved at all
# (throttling, 5xx, network failures), mapped to the reason. An empty
# inventory caused by such an error is not evidence that the tenant
# has no users, so the user-based checks report MANUAL instead of
# evaluating it.
self.users_retrieval_errors: dict[str, str] = {}
# Get users first alone because it is a dependency for other attributes
self.users = loop.run_until_complete(self._get_users())
@@ -69,24 +81,76 @@ class Entra(AzureService):
loop.close()
async def _get_users(self):
"""Retrieve the users of every audited tenant from Microsoft Graph.
Users are requested with ``signInActivity``. When Graph rejects that
request (the tenant lacks Entra ID P1/P2 or the application lacks
``AuditLog.Read.All``), the tenant is recorded in
``self.sign_in_activity_errors`` and the users are fetched again
without ``signInActivity`` so the remaining user checks can still run.
Any other failure to retrieve the users (throttling, 5xx, network),
including a failure on a later page of the paginated response, is
recorded in ``self.users_retrieval_errors`` so the user-based checks
report MANUAL instead of evaluating an empty or partial inventory.
Returns:
dict: Tenant domain mapped to a dict of user id -> ``User``. A
tenant whose users could not be retrieved maps to an empty dict
and is recorded in ``self.users_retrieval_errors``.
"""
logger.info("Entra - Getting users...")
users = {}
base_select = ["id", "displayName", "accountEnabled"]
try:
request_configuration = RequestConfiguration(
query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=[
"id",
"displayName",
"accountEnabled",
"signInActivity",
]
)
)
for tenant, client in self.clients.items():
users.update({tenant: {}})
users_response = await client.users.get(
request_configuration=request_configuration
)
try:
users_response = await client.users.get(
request_configuration=RequestConfiguration(
query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=base_select + ["signInActivity"]
)
)
)
except Exception as error:
status = getattr(error, "response_status_code", None)
reason = self._describe_graph_error(error)
if status != 403:
# Transient or unexpected failure (throttling, 5xx,
# network): do not blame licensing/permissions, but
# record that the tenant's users are unknown so the
# user-based checks report MANUAL instead of
# evaluating an empty inventory.
self.users_retrieval_errors[tenant] = reason
logger.error(
f"{tenant} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
# A 403 means signInActivity is rejected for the whole
# request (no Entra ID P1/P2 or missing AuditLog.Read.All).
# Record it and retry without the property so the other
# user checks still run.
self.sign_in_activity_errors[tenant] = reason
logger.error(
f"{tenant} -- sign-in activity unavailable, retrying without signInActivity: {reason}"
)
try:
users_response = await client.users.get(
request_configuration=RequestConfiguration(
query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
select=base_select
)
)
)
except Exception as retry_error:
self.users_retrieval_errors[tenant] = (
self._describe_graph_error(retry_error)
)
logger.error(
f"{tenant} -- {retry_error.__class__.__name__}[{retry_error.__traceback__.tb_lineno}]: {retry_error}"
)
continue
registration_details = await self._get_user_registration_details(client)
try:
@@ -124,8 +188,15 @@ class Entra(AzureService):
users_response = await client.users.with_url(next_link).get()
except Exception as error:
# A failed page (throttling, 5xx, network) leaves the
# inventory incomplete: the users retrieved so far must
# not be treated as the whole tenant, so record the error
# and let the user-based checks report MANUAL.
self.users_retrieval_errors[tenant] = self._describe_graph_error(
error
)
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
f"{tenant} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
@@ -134,6 +205,21 @@ class Entra(AzureService):
return users
@staticmethod
def _describe_graph_error(error: Exception) -> str:
"""Return a short, single-line description of a Graph error."""
code = None
main_error = getattr(error, "error", None)
if main_error is not None:
code = getattr(main_error, "code", None)
status = getattr(error, "response_status_code", None)
parts = [error.__class__.__name__]
if status:
parts.append(f"HTTP {status}")
if code:
parts.append(str(code))
return " ".join(parts)
async def _get_user_registration_details(self, client):
registration_details = {}
try:
@@ -33,5 +33,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "The signInActivity resource requires Microsoft Entra ID P1 or P2 license. Tenants without this license will not have sign-in activity data available, and all users will be reported as never having signed in."
"Notes": "The signInActivity resource requires Microsoft Entra ID P1 or P2 license. When Microsoft Graph rejects the sign-in activity request (tenant without Entra ID P1/P2 or missing AuditLog.Read.All), the check reports a single tenant-level MANUAL finding."
}
@@ -13,49 +13,56 @@ class entra_user_with_recent_sign_in(Check):
This check evaluates each enabled user's last interactive sign-in to detect stale or dormant accounts that should be reviewed or deprovisioned. Sign-in activity requires Entra ID P1/P2 licensing.
- PASS: The enabled user signed in within the last 90 days.
- FAIL: The enabled user has not signed in for more than 90 days, or has never signed in.
- FAIL (tenant-level): No sign-in activity data is available for any enabled user, indicating missing P1/P2 licensing or Graph permissions (reported once instead of flagging every user).
- FAIL: The enabled user has not signed in for more than 90 days, or has no recorded sign-in.
- MANUAL (tenant-level): Microsoft Graph refused to return sign-in activity for the tenant (missing Entra ID P1/P2 licensing or the AuditLog.Read.All permission), or the tenant's users could not be retrieved at all, so the check cannot be evaluated; reported once per tenant.
"""
def execute(self) -> Check_Report_Azure:
def execute(self) -> list[Check_Report_Azure]:
findings = []
for tenant_domain, users in entra_client.users.items():
enabled_users = {k: v for k, v in users.items() if v.account_enabled}
if not enabled_users:
continue
# If all enabled users are missing sign-in data, avoid claiming
# they never signed in. This usually indicates missing telemetry,
# often due to licensing or Graph permission limitations.
all_null = all(u.last_sign_in is None for u in enabled_users.values())
if all_null:
first_user = next(iter(enabled_users.values()))
report = Check_Report_Azure(
metadata=self.metadata(), resource=first_user
)
if tenant_domain in entra_client.users_retrieval_errors:
report = Check_Report_Azure(metadata=self.metadata(), resource={})
report.subscription = f"Tenant: {tenant_domain}"
report.resource_name = "Sign-in Activity Data"
count = len(enabled_users)
noun = "user" if count == 1 else "users"
report.status = "FAIL"
report.resource_name = tenant_domain
report.resource_id = entra_client.tenant_ids[0]
report.status = "MANUAL"
report.status_extended = (
f"No sign-in activity data available for any of the "
f"{count} enabled {noun}. This likely means the tenant "
f"is missing Entra ID P1/P2 licensing or the required "
f"Graph permissions to read sign-in activity."
f"Cannot evaluate sign-in activity for tenant {tenant_domain}: "
f"Microsoft Graph did not return the tenant's users "
f"({entra_client.users_retrieval_errors[tenant_domain]}). "
f"Retry the scan or review the tenant's users manually."
)
findings.append(report)
continue
for user_domain_name, user in enabled_users.items():
if tenant_domain in entra_client.sign_in_activity_errors:
report = Check_Report_Azure(metadata=self.metadata(), resource={})
report.subscription = f"Tenant: {tenant_domain}"
report.resource_name = tenant_domain
report.resource_id = entra_client.tenant_ids[0]
report.status = "MANUAL"
report.status_extended = (
f"Cannot evaluate sign-in activity for tenant {tenant_domain}: "
f"Microsoft Graph did not return sign-in activity "
f"({entra_client.sign_in_activity_errors[tenant_domain]}). "
f"Verify that the tenant has Entra ID P1/P2 licensing and the "
f"scanning application has the AuditLog.Read.All permission."
)
findings.append(report)
continue
enabled_users = {k: v for k, v in users.items() if v.account_enabled}
for user in enabled_users.values():
report = Check_Report_Azure(metadata=self.metadata(), resource=user)
report.subscription = f"Tenant: {tenant_domain}"
if user.last_sign_in is None:
report.status = "FAIL"
report.status_extended = f"User {user.name} has never signed in."
report.status_extended = (
f"User {user.name} has no recorded sign-in activity."
)
else:
last = user.last_sign_in
if last.tzinfo is None:
@@ -17,7 +17,22 @@ class entra_user_with_vm_access_has_mfa(Check):
findings = []
already_reported = set()
for users in entra_client.users.values():
for tenant_domain, users in entra_client.users.items():
if tenant_domain in entra_client.users_retrieval_errors:
report = Check_Report_Azure(metadata=self.metadata(), resource={})
report.subscription = f"Tenant: {tenant_domain}"
report.resource_name = tenant_domain
report.resource_id = entra_client.tenant_ids[0]
report.status = "MANUAL"
report.status_extended = (
f"Cannot evaluate MFA for the tenant's users with VM access for tenant {tenant_domain}: "
f"Microsoft Graph did not return the tenant's users "
f"({entra_client.users_retrieval_errors[tenant_domain]}). "
f"Retry the scan or review the tenant's users manually."
)
findings.append(report)
continue
for user in users.values():
for (
subscription_id,
@@ -28,6 +28,11 @@ class GCPService:
self.client = self.__generate_client__(
self.service, api_version, self.credentials
)
# Audited projects where this service's API is definitively DISABLED,
# and projects whose API activation state could not be determined;
# both are excluded from project_ids.
self.api_disabled_project_ids: set = set()
self.api_state_unknown_project_ids: set = set()
# Only project ids that have their API enabled will be scanned
if provider.skip_api_check:
self.project_ids = provider.project_ids
@@ -69,10 +74,12 @@ class GCPService:
if response.get("state") != "DISABLED":
project_ids.append(project_id)
else:
self.api_disabled_project_ids.add(project_id)
logger.error(
f"{self.service} API has not been used in project {project_id} before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/{self.service}.googleapis.com/overview?project={project_id} then retry."
)
except Exception as error:
self.api_state_unknown_project_ids.add(project_id)
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -5,9 +5,28 @@ from prowler.providers.gcp.services.iam.accessapproval_client import (
class iam_account_access_approval_enabled(Check):
def execute(self) -> Check_Report_GCP:
"""Ensure Access Approval is enabled for every audited project.
- PASS: The project has Access Approval settings configured.
- FAIL: Access Approval is not configured (404 on the settings read), or
the accessapproval.googleapis.com API is disabled — with the API off,
Access Approval provably cannot be enabled.
- MANUAL: The settings could not be read (permission error) or the API
activation state could not be determined.
"""
def execute(self) -> list[Check_Report_GCP]:
"""Evaluate Access Approval for the audited projects.
Returns:
list[Check_Report_GCP]: One report per audited project.
"""
findings = []
for project_id in accessapproval_client.project_ids:
# Under --skip-api-check a disabled API is detected while reading
# the settings; those projects are reported by the loop below.
if project_id in accessapproval_client.api_disabled_project_ids:
continue
report = Check_Report_GCP(
metadata=self.metadata(),
resource=accessapproval_client.projects[project_id],
@@ -18,11 +37,53 @@ class iam_account_access_approval_enabled(Check):
report.status_extended = (
f"Project {project_id} has Access Approval enabled."
)
if project_id not in accessapproval_client.settings:
if project_id in accessapproval_client.settings_lookup_failed:
report.status = "MANUAL"
report.status_extended = (
f"Cannot evaluate Access Approval for project {project_id}: "
"the Access Approval settings could not be read. Verify that "
"the Access Approval API is enabled and the scanning "
"credentials have the accessapproval.settings.get permission."
)
elif project_id not in accessapproval_client.settings:
report.status = "FAIL"
report.status_extended = (
f"Project {project_id} does not have Access Approval enabled."
)
findings.append(report)
# Projects filtered out by the API-activation precheck never reach
# _get_settings(): report them instead of silently skipping. A
# definitively disabled API means Access Approval cannot be enabled
# (FAIL); an undetermined state is an evidence gap (MANUAL).
for project_id in sorted(accessapproval_client.api_disabled_project_ids):
report = Check_Report_GCP(
metadata=self.metadata(),
resource=accessapproval_client.projects[project_id],
project_id=project_id,
location=accessapproval_client.region,
)
report.status = "FAIL"
report.status_extended = (
f"Project {project_id} does not have Access Approval enabled: "
"the accessapproval.googleapis.com API is disabled."
)
findings.append(report)
for project_id in sorted(accessapproval_client.api_state_unknown_project_ids):
report = Check_Report_GCP(
metadata=self.metadata(),
resource=accessapproval_client.projects[project_id],
project_id=project_id,
location=accessapproval_client.region,
)
report.status = "MANUAL"
report.status_extended = (
f"Cannot evaluate Access Approval for project {project_id}: "
"the activation state of the accessapproval.googleapis.com API "
"could not be determined. Verify that the scanning credentials "
"can call serviceusage.services.get for the project."
)
findings.append(report)
return findings
@@ -1,5 +1,6 @@
from datetime import datetime
from googleapiclient.errors import HttpError
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -219,6 +220,10 @@ class AccessApproval(GCPService):
def __init__(self, provider: GcpProvider):
super().__init__(__class__.__name__, provider)
self.settings = {}
# Projects whose Access Approval settings could not be read because of
# a permission or API-availability error (as opposed to a 404, which
# means Access Approval is simply not enabled for the project).
self.settings_lookup_failed: set[str] = set()
self._get_settings()
def _get_settings(self):
@@ -234,7 +239,30 @@ class AccessApproval(GCPService):
project_id=project_id,
)
except HttpError as error:
if error.status_code == 404:
# Access Approval is not enabled for this project.
logger.info(
f"{self.region} -- Access Approval settings not found for project {project_id}: {error}"
)
elif error.status_code == 403 and (
"SERVICE_DISABLED" in str(error)
or "has not been used" in str(error)
):
# Under --skip-api-check the API-activation precheck does
# not run; a SERVICE_DISABLED 403 here is the same
# definitive "API disabled" state.
self.api_disabled_project_ids.add(project_id)
logger.info(
f"{self.region} -- Access Approval API disabled for project {project_id}: {error}"
)
else:
self.settings_lookup_failed.add(project_id)
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.settings_lookup_failed.add(project_id)
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -21,6 +21,8 @@ class defenderidentity_health_issues_no_open(Check):
- PASS: The health issue has been resolved (status is not open).
- FAIL: The health issue is open and requires attention.
- FAIL: No sensors are deployed (MDI cannot protect the environment).
- MANUAL: The Defender for Identity APIs could not be queried (missing
permissions), so the check cannot be evaluated.
"""
def execute(self) -> List[CheckReportM365]:
@@ -50,11 +52,12 @@ class defenderidentity_health_issues_no_open(Check):
resource_name="Defender for Identity",
resource_id="defenderIdentity",
)
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
"Defender for Identity APIs are not accessible. "
"Ensure the Service Principal has SecurityIdentitiesSensors.Read.All and "
"SecurityIdentitiesHealth.Read.All permissions granted."
"Cannot evaluate Defender for Identity health issues: the "
"Defender for Identity APIs are not accessible. Ensure the "
"scanning application has the SecurityIdentitiesSensors.Read.All "
"and SecurityIdentitiesHealth.Read.All permissions granted."
)
findings.append(report)
return findings
@@ -67,11 +70,12 @@ class defenderidentity_health_issues_no_open(Check):
resource_name="Defender for Identity",
resource_id="defenderIdentity",
)
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
f"Cannot read health issues from Defender for Identity "
f"(found {len(defenderidentity_client.sensors)} sensor(s) deployed). "
"Ensure the Service Principal has SecurityIdentitiesHealth.Read.All permission."
f"Cannot evaluate Defender for Identity health issues "
f"(found {len(defenderidentity_client.sensors)} sensor(s) deployed): "
"the health issues API is not accessible. Ensure the scanning "
"application has the SecurityIdentitiesHealth.Read.All permission granted."
)
findings.append(report)
return findings
@@ -93,7 +97,9 @@ class defenderidentity_health_issues_no_open(Check):
findings.append(report)
return findings
# If health_issues is empty list - no issues exist, this is compliant
# If health_issues is empty list - no issues exist. This is only
# compliant when sensor deployment could actually be verified: with
# the sensors API failed, an empty issue list cannot be trusted.
if not defenderidentity_client.health_issues:
report = CheckReportM365(
metadata=self.metadata(),
@@ -101,10 +107,20 @@ class defenderidentity_health_issues_no_open(Check):
resource_name="Defender for Identity",
resource_id="defenderIdentity",
)
report.status = "PASS"
report.status_extended = (
"No open health issues found in Defender for Identity."
)
if sensors_api_failed:
report.status = "MANUAL"
report.status_extended = (
"Cannot evaluate Defender for Identity health issues: no "
"open health issues were returned but sensor deployment "
"could not be verified (sensors API not accessible). Ensure "
"the scanning application has the "
"SecurityIdentitiesSensors.Read.All permission granted."
)
else:
report.status = "PASS"
report.status_extended = (
"No open health issues found in Defender for Identity."
)
findings.append(report)
return findings
@@ -25,6 +25,8 @@ class defenderxdr_critical_asset_management_pending_approvals(Check):
Results:
- PASS: No pending approvals for Critical Asset Management are found.
- FAIL: At least one asset classification has pending approvals.
- MANUAL: Defender XDR could not be queried (missing permission or Security
Exposure Management not available), so the check cannot be evaluated.
"""
def execute(self) -> List[CheckReportM365]:
@@ -47,10 +49,13 @@ class defenderxdr_critical_asset_management_pending_approvals(Check):
resource_name="Critical Asset Management",
resource_id="criticalAssetManagement",
)
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
"Unable to query Critical Asset Management status. "
"Verify that ThreatHunting.Read.All permission is granted."
"Cannot evaluate Critical Asset Management pending approvals: "
"unable to query Microsoft Defender XDR Advanced Hunting. "
"Verify that the ThreatHunting.Read.All permission is granted "
"to the scanning application and that Security Exposure "
"Management is enabled in the tenant."
)
findings.append(report)
return findings
@@ -25,6 +25,9 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check):
Results:
- PASS: No exposed credentials found OR MDE enabled but no devices
- FAIL: Exposed credentials detected OR MDE not enabled (blind spot)
- MANUAL: Defender XDR could not be queried (missing permission or
Security Exposure Management not available), so the check cannot
be evaluated
"""
def execute(self) -> list[CheckReportM365]:
@@ -46,10 +49,12 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check):
resource_name="Defender XDR",
resource_id="mdeStatus",
)
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
"Unable to query Microsoft Defender XDR status. "
"Verify that ThreatHunting.Read.All permission is granted."
"Cannot evaluate credential exposure for privileged users: "
"unable to query Microsoft Defender XDR Advanced Hunting. "
"Verify that the ThreatHunting.Read.All permission is granted "
"to the scanning application."
)
findings.append(report)
return findings
@@ -99,11 +104,11 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check):
resource_name="Defender XDR",
resource_id="exposedCredentials",
)
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
"Unable to query Security Exposure Management for exposed "
"credentials. Verify that Security Exposure Management "
"is enabled."
"Cannot evaluate credential exposure for privileged users: "
"unable to query Security Exposure Management. Verify that "
"Security Exposure Management is enabled in the tenant."
)
findings.append(report)
return findings
@@ -116,7 +116,13 @@ class DefenderXDR(M365Service):
request_body
)
if not response or not response.results:
if response is None:
# A null response object is not a successful empty query:
# the data could not be retrieved.
logger.error("DefenderXDR - Advanced Hunting returned a null response.")
return None, False
if not response.results:
return [], False
results = [
@@ -200,11 +206,20 @@ ExposureGraphEdges
TargetCategories = TargetNodeCategories
"""
results, _ = await self._run_hunting_query(query)
results, table_not_found = await self._run_hunting_query(query)
if results is None:
return None
if table_not_found:
# Security Exposure Management tables are not available in this
# tenant: the check cannot be evaluated (not a legitimate empty result).
logger.warning(
"DefenderXDR - Security Exposure Management tables are not "
"available in this tenant; results cannot be evaluated."
)
return None
return [self._parse_exposed_credential(row) for row in results if row]
def _parse_exposed_credential(self, row: Dict) -> "ExposedCredentialPrivilegedUser":
@@ -253,11 +268,20 @@ ExposureGraphNodes
| sort by Classification asc
"""
results, _ = await self._run_hunting_query(query)
results, table_not_found = await self._run_hunting_query(query)
if results is None:
return None
if table_not_found:
# Security Exposure Management tables are not available in this
# tenant: the check cannot be evaluated (not a legitimate empty result).
logger.warning(
"DefenderXDR - Security Exposure Management tables are not "
"available in this tenant; results cannot be evaluated."
)
return None
pending_approvals = []
for row in results:
if not row:
@@ -34,5 +34,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires Microsoft Defender for Cloud Apps with App Governance enabled and ThreatHunting.Read.All permission. If App Governance data is unavailable, the check fails due to missing visibility."
"Notes": "This check requires Microsoft Defender for Cloud Apps with App Governance enabled and ThreatHunting.Read.All permission. If App Governance data is unavailable, the check reports MANUAL because unused permissions cannot be evaluated."
}
@@ -15,7 +15,8 @@ class entra_app_registration_no_unused_privileged_permissions(Check):
- PASS: The app has no unused privileged permissions.
- FAIL: The app has one or more unused privileged permissions that should be revoked.
It also fails when OAuth App Governance data is not available.
- MANUAL: OAuth App Governance data is not available (App Governance not enabled or
missing permission), so the check cannot be evaluated.
"""
# InUse field values from OAuthAppInfo:
@@ -47,11 +48,12 @@ class entra_app_registration_no_unused_privileged_permissions(Check):
resource_name="OAuth Applications",
resource_id="oauthApps",
)
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = (
"OAuth App Governance data is unavailable. "
"Enable App Governance in Microsoft Defender for Cloud Apps and "
"grant ThreatHunting.Read.All to evaluate unused privileged permissions."
"Cannot evaluate unused privileged permissions: OAuth App "
"Governance data is unavailable. Enable App Governance in "
"Microsoft Defender for Cloud Apps and grant the "
"ThreatHunting.Read.All permission to the scanning application."
)
findings.append(report)
return findings
@@ -16,7 +16,8 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
- PASS: The break glass account has a FIDO2 security key (fido2SecurityKey) registered.
- MANUAL: The account has a device-bound passkey but it cannot be confirmed as FIDO2,
or no break glass accounts could be identified.
no break glass accounts could be identified, or the users / user
registration details could not be read (insufficient permissions).
- FAIL: The break glass account does not have a FIDO2 security key registered.
"""
@@ -61,6 +62,18 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
if count == total_policy_count
]
if entra_client.users_error:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Break Glass Accounts",
resource_id="breakGlassAccounts",
)
report.status = "MANUAL"
report.status_extended = f"Cannot verify FIDO2 security key registration for break glass accounts: {entra_client.users_error}."
findings.append(report)
return findings
if not break_glass_user_ids:
report = CheckReportM365(
metadata=self.metadata(),
@@ -73,6 +86,21 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
findings.append(report)
return findings
if entra_client.user_registration_details_error:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Break Glass Accounts",
resource_id="breakGlassAccounts",
)
report.status = "MANUAL"
report.status_extended = (
"Cannot verify FIDO2 security key registration for break glass "
f"accounts: {entra_client.user_registration_details_error}."
)
findings.append(report)
return findings
for user_id in break_glass_user_ids:
user = entra_client.users.get(user_id)
if not user:
@@ -85,15 +113,6 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
resource_id=user.id,
)
if entra_client.user_registration_details_error:
report.status = "FAIL"
report.status_extended = (
f"Cannot verify FIDO2 security key registration for break glass account {user.name}: "
f"{entra_client.user_registration_details_error}."
)
findings.append(report)
continue
auth_methods = set(user.authentication_methods)
has_fido2 = "fido2SecurityKey" in auth_methods
has_passkey_device_bound = "passKeyDeviceBound" in auth_methods
@@ -14,7 +14,10 @@ class entra_seamless_sso_disabled(Check):
Primary Refresh Token (PRT) support make this feature unnecessary for most organizations.
- PASS: Seamless SSO is disabled or on-premises sync is not enabled (cloud-only).
- FAIL: Seamless SSO is enabled in a hybrid deployment, or cannot verify due to insufficient permissions.
- FAIL: Seamless SSO is enabled in a hybrid deployment.
- MANUAL: Hybrid deployment whose directory sync settings could not be read
(insufficient permissions or no settings returned), so the check cannot be
evaluated.
"""
def execute(self) -> List[CheckReportM365]:
@@ -38,9 +41,9 @@ class entra_seamless_sso_disabled(Check):
resource_id=organization.id,
resource_name=organization.name,
)
# Only FAIL for hybrid orgs; cloud-only orgs don't need this permission
# Only MANUAL for hybrid orgs; cloud-only orgs don't need this permission
if organization.on_premises_sync_enabled:
report.status = "FAIL"
report.status = "MANUAL"
report.status_extended = f"Cannot verify Seamless SSO status for {organization.name}: {entra_client.directory_sync_error}."
else:
report.status = "PASS"
@@ -66,7 +69,8 @@ class entra_seamless_sso_disabled(Check):
findings.append(report)
# If no directory sync settings and no error, it's a cloud-only tenant
# No directory sync settings and no error: cloud-only organizations are
# not applicable; a hybrid organization without settings cannot be verified.
if not entra_client.directory_sync_settings:
for organization in entra_client.organizations:
report = CheckReportM365(
@@ -75,8 +79,12 @@ class entra_seamless_sso_disabled(Check):
resource_id=organization.id,
resource_name=organization.name,
)
report.status = "PASS"
report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable."
if organization.on_premises_sync_enabled:
report.status = "MANUAL"
report.status_extended = f"Cannot verify Seamless SSO status for {organization.name}: no directory synchronization settings were returned for this hybrid organization."
else:
report.status = "PASS"
report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable."
findings.append(report)
return findings
@@ -86,6 +86,10 @@ class Entra(M365Service):
self.tenant_domain = provider.identity.tenant_domain
self.tenant_id = getattr(provider.identity, "tenant_id", None)
self.user_registration_details_error: Optional[str] = None
# Set when the Microsoft Graph /users request (or its directory role
# dependencies) fails, so checks can report that users are unavailable
# instead of silently evaluating an empty directory.
self.users_error: Optional[str] = None
self.exchange_mailbox_permission_service_principals_error: Optional[str] = None
attributes = loop.run_until_complete(
gather(
@@ -924,7 +928,7 @@ class Entra(M365Service):
except ODataError as error:
error_code = getattr(error.error, "code", None) if error.error else None
if error_code == "Authorization_RequestDenied":
error_message = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All"
error_message = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All (Microsoft Graph only supports this as a delegated permission for a Global Administrator; application permissions are not supported)"
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}"
)
@@ -941,6 +945,19 @@ class Entra(M365Service):
return directory_sync_settings, error_message
async def _get_users(self):
"""Retrieve the tenant users with their directory roles and MFA registration.
Depends on ``GET /users``, ``GET /directoryRoles`` and the members of
each role. If any of those Graph calls fails, ``self.users_error`` is
set so checks can report that the directory could not be read instead
of evaluating an empty user set. Registration details are fetched via
``_get_user_registration_details``, which handles its own failures
through ``self.user_registration_details_error``.
Returns:
dict: User id mapped to ``User``. Empty (or partial, on a
mid-pagination failure) when ``self.users_error`` is set.
"""
logger.info("Entra - Getting users...")
users = {}
try:
@@ -1026,7 +1043,17 @@ class Entra(M365Service):
if not next_link:
break
users_response = await self.client.users.with_url(next_link).get()
except ODataError as error:
error_code = getattr(error.error, "code", None) if error.error else None
if error_code == "Authorization_RequestDenied":
self.users_error = "Insufficient privileges to read users and directory roles. Required permissions: User.Read.All, Directory.Read.All or RoleManagement.Read.Directory"
else:
self.users_error = f"Unable to retrieve users from Microsoft Graph ({error_code or error.__class__.__name__})"
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
self.users_error = f"Unable to retrieve users from Microsoft Graph ({error.__class__.__name__})"
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
@@ -1124,7 +1151,15 @@ OAuthAppInfo
request_body
)
if result and result.results:
if result is None:
# A null response object is not a successful empty query:
# the OAuth app inventory could not be retrieved.
logger.warning(
"Entra - Advanced Hunting returned a null response for OAuthAppInfo."
)
return None
if result.results:
for row in result.results:
row_data = row.additional_data
raw_app_id = row_data.get("OAuthAppId", "")
@@ -17,8 +17,9 @@ class entra_users_mfa_capable(Check):
evaluation.
- PASS: The member user is MFA capable.
- FAIL: The member user is not MFA capable, or MFA capability cannot be
verified due to insufficient permissions to read user registration details.
- FAIL: The member user is not MFA capable.
- MANUAL: Users or their registration details could not be read
(insufficient permissions), so MFA capability cannot be verified.
"""
def execute(self) -> List[CheckReportM365]:
@@ -37,6 +38,23 @@ class entra_users_mfa_capable(Check):
"""
findings = []
data_error = (
entra_client.users_error or entra_client.user_registration_details_error
)
if data_error:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Entra Users",
resource_id="users",
)
report.status = "MANUAL"
report.status_extended = (
f"Cannot verify MFA capability for member users: {data_error}."
)
findings.append(report)
return findings
for user in entra_client.users.values():
if user.user_type == "Guest" or not user.account_enabled:
continue
@@ -57,13 +75,7 @@ class entra_users_mfa_capable(Check):
resource_id=user.id,
)
if entra_client.user_registration_details_error:
report.status = "FAIL"
report.status_extended = (
f"Cannot verify MFA capability for user {user.name}: "
f"{entra_client.user_registration_details_error}."
)
elif not user.is_mfa_capable:
if not user.is_mfa_capable:
report.status = "FAIL"
report.status_extended = f"User {user.name} is not MFA capable."
else:
@@ -15,6 +15,9 @@ class exchange_shared_mailbox_sign_in_disabled(Check):
- PASS: Shared mailbox has sign-in blocked (AccountEnabled = False in Entra ID).
- FAIL: Shared mailbox has sign-in enabled (AccountEnabled = True in Entra ID).
- MANUAL: The Entra users could not be retrieved (tenant-level), or the
shared mailbox could not be resolved in Entra ID, so its sign-in status
cannot be verified.
"""
def execute(self) -> List[CheckReportM365]:
@@ -30,6 +33,23 @@ class exchange_shared_mailbox_sign_in_disabled(Check):
"""
findings = []
# A tenant-wide failure retrieving Entra users would otherwise surface
# as one misleading MANUAL per mailbox: report it once instead.
if exchange_client.shared_mailboxes and entra_client.users_error:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Shared Mailboxes",
resource_id="sharedMailboxes",
)
report.status = "MANUAL"
report.status_extended = (
"Cannot verify sign-in status for shared mailboxes: "
f"{entra_client.users_error}."
)
findings.append(report)
return findings
for shared_mailbox in exchange_client.shared_mailboxes:
report = CheckReportM365(
metadata=self.metadata(),
@@ -45,8 +65,8 @@ class exchange_shared_mailbox_sign_in_disabled(Check):
)
if not entra_user:
report.status = "FAIL"
report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} could not be found in Entra ID for verification."
report.status = "MANUAL"
report.status_extended = f"Cannot verify sign-in status for shared mailbox {shared_mailbox.user_principal_name}: the user could not be resolved in Entra ID."
elif entra_user.account_enabled:
report.status = "FAIL"
report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} has sign-in enabled."
+19 -1
View File
@@ -181,7 +181,25 @@ Examples:
|--------|-------------|
| `PASS` | Resource is compliant |
| `FAIL` | Resource is non-compliant |
| `MANUAL` | Requires human verification |
| `MANUAL` | Requires human verification, or the data needed to evaluate the resource could not be retrieved |
### Permission / availability errors are NOT findings
Never set `FAIL` because an API call failed (missing permission or scope, API not enabled, feature not licensed, data unavailable). That is a scan-configuration problem, not a security issue, and it surfaces as a misleading high-severity finding.
- Service: log the error and expose it distinctly from an empty result (`None` instead of `[]`, an `*_error` attribute, or a `*_lookup_failed` set). Only treat real access errors this way; a `404`/not-found usually means "not configured" and IS a legitimate `FAIL`, and a definitively disabled API is a legitimate `FAIL` when the API's activation is itself the audited control (e.g. GCP Access Approval).
- Check: emit ONE tenant/account/project/subscription-level `MANUAL` finding (not one per resource) whose `status_extended` says the check cannot be evaluated and names the required permission/API/license.
- Do not touch `report.check_metadata.Severity` to hide it.
```python
if <service>_client.<data> is None:
report = CheckReport<Provider>(metadata=self.metadata(), resource={})
report.resource_name = "<Tenant-level resource>"
report.resource_id = "<stable-id>"
report.status = "MANUAL"
report.status_extended = "Cannot evaluate <requirement>: <data> could not be retrieved. Verify that <permission> is granted to the scanning identity."
return [report]
```
---
@@ -265,11 +265,11 @@ class Test_bedrock_agent_role_least_privilege:
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
def test_agent_role_not_resolvable(self):
"""role_arn returned by GetAgent doesn't match any IAM role -> FAIL."""
"""role_arn returned by GetAgent doesn't match any IAM role -> MANUAL."""
result = _run_check(
role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist"
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert "could not be resolved" in result[0].status_extended
@@ -670,10 +670,12 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured:
)
cloudtrail_client.trails = None
cloudtrail_client.trails_unavailable = True
check = cloudwatch_changes_to_network_acls_alarm_configured()
result = check.execute()
assert len(result) == 0
assert len(result) == 1
assert result[0].status == "MANUAL"
@mock_aws
def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses(
@@ -662,9 +662,11 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_c
cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled()
)
cloudtrail_client.trails = None
cloudtrail_client.trails_unavailable = True
result = check.execute()
assert len(result) == 0
assert len(result) == 1
assert result[0].status == "MANUAL"
@mock_aws
def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses(
@@ -596,3 +596,334 @@ class Test_cloudwatch_log_metric_filter_root_usage:
== f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*"
)
assert result[0].region == AWS_REGION_US_EAST_1
def _run_with_unavailable_data(self, *, metric_filters_none, metric_alarms_none):
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import (
Cloudtrail,
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import (
CloudWatch,
Logs,
)
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
logs = Logs(aws_provider)
cloudwatch = CloudWatch(aws_provider)
# The services set these to None when the describe call is denied
# (AccessDeniedException / AccessDenied).
if metric_filters_none:
logs.metric_filters = None
logs.metric_filters_unavailable = True
if metric_alarms_none:
cloudwatch.metric_alarms = None
cloudwatch.metric_alarms_unavailable = True
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client",
new=logs,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client",
new=cloudwatch,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client",
new=Cloudtrail(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import (
cloudwatch_log_metric_filter_root_usage,
)
return cloudwatch_log_metric_filter_root_usage().execute()
@mock_aws
def test_cloudwatch_metric_filters_access_denied(self):
"""logs:DescribeMetricFilters denied -> MANUAL, not FAIL."""
result = self._run_with_unavailable_data(
metric_filters_none=True, metric_alarms_none=False
)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"metric filters or alarms could not be listed" in result[0].status_extended
)
assert "logs:DescribeMetricFilters" in result[0].status_extended
assert result[0].resource_id == AWS_ACCOUNT_NUMBER
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
def test_cloudwatch_metric_filters_partially_denied(self):
"""Filters listed in one region but denied in another -> MANUAL.
The service keeps the partial list (not None) and only raises the
``metric_filters_unavailable`` flag; with no matching filter the check
must not claim FAIL.
"""
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import (
Cloudtrail,
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import (
CloudWatch,
Logs,
)
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
logs = Logs(aws_provider)
assert logs.metric_filters == []
logs.metric_filters_unavailable = True
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client",
new=logs,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client",
new=CloudWatch(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client",
new=Cloudtrail(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import (
cloudwatch_log_metric_filter_root_usage,
)
result = cloudwatch_log_metric_filter_root_usage().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "in at least one region" in result[0].status_extended
@mock_aws
def test_cloudwatch_trails_access_denied(self):
"""cloudtrail:DescribeTrails denied -> MANUAL instead of no finding."""
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import (
Cloudtrail,
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import (
CloudWatch,
Logs,
)
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
cloudtrail = Cloudtrail(aws_provider)
cloudtrail.trails = None
cloudtrail.trails_unavailable = True
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client",
new=Logs(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client",
new=CloudWatch(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client",
new=cloudtrail,
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import (
cloudwatch_log_metric_filter_root_usage,
)
result = cloudwatch_log_metric_filter_root_usage().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "cloudtrail:DescribeTrails" in result[0].status_extended
@mock_aws
def test_cloudwatch_log_groups_access_denied(self):
"""logs:DescribeLogGroups denied -> MANUAL."""
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import (
Cloudtrail,
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import (
CloudWatch,
Logs,
)
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
logs = Logs(aws_provider)
logs.log_groups_unavailable = True
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client",
new=logs,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client",
new=CloudWatch(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client",
new=Cloudtrail(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import (
cloudwatch_log_metric_filter_root_usage,
)
result = cloudwatch_log_metric_filter_root_usage().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "logs:DescribeLogGroups" in result[0].status_extended
@mock_aws
def test_cloudwatch_metric_alarms_access_denied(self):
"""cloudwatch:DescribeAlarms denied -> MANUAL, not FAIL."""
result = self._run_with_unavailable_data(
metric_filters_none=False, metric_alarms_none=True
)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "cloudwatch:DescribeAlarms" in result[0].status_extended
def _run_with_filter(self, *, with_alarm, metric_alarms_unavailable):
"""Create a trail + matching filter (optionally its alarm) and run the check
with the alarm inventory flagged as (un)available."""
cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1)
cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1)
logs_client = client("logs", region_name=AWS_REGION_US_EAST_1)
s3_client = client("s3", region_name=AWS_REGION_US_EAST_1)
s3_client.create_bucket(Bucket="test")
logs_client.create_log_group(logGroupName="/log-group/test")
cloudtrail_client.create_trail(
Name="test_trail",
S3BucketName="test",
CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*",
)
logs_client.put_metric_filter(
logGroupName="/log-group/test",
filterName="test-filter",
filterPattern="{ $.userIdentity.type = Root && $.userIdentity.invokedBy NOT EXISTS && $.eventType != AwsServiceEvent }",
metricTransformations=[
{
"metricName": "my-metric",
"metricNamespace": "my-namespace",
"metricValue": "$.value",
}
],
)
if with_alarm:
cloudwatch_client.put_metric_alarm(
AlarmName="test-alarm",
MetricName="my-metric",
Namespace="my-namespace",
Period=10,
EvaluationPeriods=5,
Statistic="Average",
Threshold=2,
ComparisonOperator="GreaterThanThreshold",
ActionsEnabled=True,
)
from prowler.providers.aws.services.cloudtrail.cloudtrail_service import (
Cloudtrail,
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_service import (
CloudWatch,
Logs,
)
from prowler.providers.common.models import Audit_Metadata
aws_provider = set_mocked_aws_provider(
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
)
aws_provider.audit_metadata = Audit_Metadata(
services_scanned=0,
expected_checks=["cloudwatch_log_group_no_secrets_in_logs"],
completed_checks=0,
audit_progress=0,
)
cloudwatch = CloudWatch(aws_provider)
cloudwatch.metric_alarms_unavailable = metric_alarms_unavailable
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.logs_client",
new=Logs(aws_provider),
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudwatch_client",
new=cloudwatch,
),
mock.patch(
"prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage.cloudtrail_client",
new=Cloudtrail(aws_provider),
),
):
from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_root_usage.cloudwatch_log_metric_filter_root_usage import (
cloudwatch_log_metric_filter_root_usage,
)
return cloudwatch_log_metric_filter_root_usage().execute()
@mock_aws
def test_cloudwatch_match_found_despite_partial_denial_is_pass(self):
"""A filter with its alarm found in a readable region is real evidence:
PASS even if another region denied the alarm listing."""
result = self._run_with_filter(with_alarm=True, metric_alarms_unavailable=True)
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_id == "/log-group/test"
@mock_aws
def test_cloudwatch_filter_without_alarm_under_partial_denial_is_manual(self):
"""Filter found but no alarm, while the alarm listing was denied in some
region: the missing alarm cannot be asserted -> MANUAL, not FAIL."""
result = self._run_with_filter(with_alarm=False, metric_alarms_unavailable=True)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "cloudwatch:DescribeAlarms" in result[0].status_extended
@mock_aws
def test_cloudwatch_filter_without_alarm_fully_listed_is_fail(self):
"""Same setup with a complete alarm inventory stays FAIL."""
result = self._run_with_filter(
with_alarm=False, metric_alarms_unavailable=False
)
assert len(result) == 1
assert result[0].status == "FAIL"
assert "no alarms associated" in result[0].status_extended
@@ -173,6 +173,7 @@ class Test_CloudWatch_Service:
assert logs.metric_filters[0].log_group is None
assert logs.metric_filters[0].name == "test-filter"
assert logs.metric_filters[0].metric == "my-metric"
assert logs.metric_filters[0].metric_namespace == "my-namespace"
assert logs.metric_filters[0].pattern == "test-pattern"
assert logs.metric_filters[0].region == AWS_REGION_US_EAST_1
@@ -535,3 +536,39 @@ class Test_build_metric_filter_pattern:
event_names=["ConsoleLogin"],
extra_clauses=[("errorMessage", bad_operator, "Failed authentication")],
)
@mock_aws
def test_describe_log_groups_access_denied_sets_flag(self):
"""A denied DescribeLogGroups must raise log_groups_unavailable."""
from unittest import mock
from botocore.client import BaseClient
from botocore.exceptions import ClientError
orig = BaseClient._make_api_call
def deny_describe_log_groups(self, operation_name, kwarg):
if operation_name == "DescribeLogGroups":
raise ClientError(
{
"Error": {
"Code": "AccessDeniedException",
"Message": "Access Denied",
}
},
operation_name,
)
return orig(self, operation_name, kwarg)
aws_provider = set_mocked_aws_provider(
expected_checks=["cloudwatch_log_group_no_secrets_in_logs"]
)
with mock.patch(
"botocore.client.BaseClient._make_api_call",
new=deny_describe_log_groups,
):
logs = Logs(aws_provider)
assert logs.log_groups_unavailable is True
assert logs.log_groups is None
assert logs.all_log_groups is None
@@ -54,7 +54,7 @@ def _trail_log_group():
)
def _metric_filter(name, log_group, metric=METRIC_NAME):
def _metric_filter(name, log_group, metric=METRIC_NAME, namespace="CloudTrailMetrics"):
"""Build a metric filter whose pattern always matches PATTERN.
Args:
@@ -62,30 +62,34 @@ def _metric_filter(name, log_group, metric=METRIC_NAME):
log_group: the collected LogGroup, or None to model a filter whose log
group was never retrieved -- the input that used to raise.
metric: metric name an alarm has to carry for the filter to be compliant.
namespace: metric namespace the filter publishes to, or None when the
transformation does not expose one.
"""
return MetricFilter(
arn=f"arn:aws:logs:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:metric-filter/{name}",
name=name,
metric=metric,
metric_namespace=namespace,
pattern=FILTER_PATTERN,
log_group=log_group,
region=AWS_REGION,
)
def _alarm(metric=METRIC_NAME):
def _alarm(metric=METRIC_NAME, namespace="CloudTrailMetrics", region=AWS_REGION):
"""Build an alarm on metric; a non-default name models an unrelated alarm.
The check pairs alarms to filters by metric name alone, so passing a metric no
filter uses is how a filter with no alarm of its own is expressed.
The check pairs alarms to filters by metric name and region (and namespace
when both sides expose one), so passing a metric no filter uses is how a
filter with no alarm of its own is expressed.
"""
return MetricAlarm(
arn=f"arn:aws:cloudwatch:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:alarm:{metric}-alarm",
arn=f"arn:aws:cloudwatch:{region}:{AWS_ACCOUNT_NUMBER}:alarm:{metric}-alarm",
name=f"{metric}-alarm",
metric=metric,
name_space="CloudTrailMetrics",
region=AWS_REGION,
alarm_actions=[f"arn:aws:sns:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:topic-test"],
name_space=namespace,
region=region,
alarm_actions=[f"arn:aws:sns:{region}:{AWS_ACCOUNT_NUMBER}:topic-test"],
actions_enabled=True,
)
@@ -147,3 +151,52 @@ class Test_check_cloudwatch_log_metric_filter:
report.status_extended
== f"CloudWatch log group {TRAIL_LOG_GROUP_NAME} found with metric filter trail-filter but no alarms associated."
)
def test_alarm_in_other_namespace_does_not_pass(self):
"""A same-named metric in another namespace is a different metric."""
report = check_cloudwatch_log_metric_filter(
PATTERN,
_trails(),
[_metric_filter("trail-filter", _trail_log_group())],
[_alarm(namespace="OtherNamespace")],
METADATA,
)
assert report.status == "FAIL"
assert "no alarms associated" in report.status_extended
def test_alarm_in_other_region_does_not_pass(self):
"""A same-named metric in another region is a different metric."""
report = check_cloudwatch_log_metric_filter(
PATTERN,
_trails(),
[_metric_filter("trail-filter", _trail_log_group())],
[_alarm(region="us-east-1")],
METADATA,
)
assert report.status == "FAIL"
def test_alarm_without_namespace_still_matches(self):
"""Namespace is only compared when both sides expose one."""
report = check_cloudwatch_log_metric_filter(
PATTERN,
_trails(),
[_metric_filter("trail-filter", _trail_log_group())],
[_alarm(namespace=None)],
METADATA,
)
assert report.status == "PASS"
def test_filter_without_namespace_still_matches(self):
"""A filter with no namespace accepts an alarm in any namespace."""
report = check_cloudwatch_log_metric_filter(
PATTERN,
_trails(),
[_metric_filter("trail-filter", _trail_log_group(), namespace=None)],
[_alarm()],
METADATA,
)
assert report.status == "PASS"
@@ -185,7 +185,7 @@ class Test_rolesanywhere_trust_anchor_pqc_pki:
result = rolesanywhere_trust_anchor_pqc_pki().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert "could not be inspected" in result[0].status_extended
def test_certificate_bundle_source(self):
@@ -1,6 +1,8 @@
from unittest import mock
from boto3 import client
from botocore.client import BaseClient
from botocore.exceptions import ClientError
from moto import mock_aws
from tests.providers.aws.utils import (
@@ -9,6 +11,20 @@ from tests.providers.aws.utils import (
set_mocked_aws_provider,
)
_orig_make_api_call = BaseClient._make_api_call
def _deny(operation):
def mock_make_api_call(self, operation_name, kwarg):
if operation_name == operation:
raise ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access Denied"}},
operation_name,
)
return _orig_make_api_call(self, operation_name, kwarg)
return mock_make_api_call
class Test_s3_bucket_cross_region_replication:
# No Buckets
@@ -598,10 +614,10 @@ class Test_s3_bucket_cross_region_replication:
assert len(result) == 1
# US-EAST-1 Source Bucket
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== f"S3 Bucket {bucket_name_us} has cross region replication rule {repl_rule_id} in bucket {arn_bucket_eu.split(':')[-1]} which is out of Prowler's scope."
== f"S3 Bucket {bucket_name_us} has cross region replication rule {repl_rule_id} in bucket {arn_bucket_eu.split(':')[-1]} which is out of Prowler's scope; verify manually that the destination bucket is in a different region."
)
assert result[0].resource_id == bucket_name_us
assert (
@@ -609,3 +625,77 @@ class Test_s3_bucket_cross_region_replication:
== f"arn:{aws_provider.identity.partition}:s3:::{bucket_name_us}"
)
assert result[0].region == AWS_REGION_US_EAST_1
@mock_aws
def test_bucket_replication_access_denied_is_manual(self):
"""s3:GetReplicationConfiguration denied -> MANUAL, not FAIL."""
from prowler.providers.aws.services.s3.s3_service import S3
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket_test_us"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
s3_client_us_east_1.put_bucket_versioning(
Bucket=bucket_name, VersioningConfiguration={"Status": "Enabled"}
)
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_deny("GetBucketReplication"),
),
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication.s3_client",
new=S3(aws_provider),
),
):
from prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication import (
s3_bucket_cross_region_replication,
)
result = s3_bucket_cross_region_replication().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "s3:GetReplicationConfiguration" in result[0].status_extended
assert result[0].resource_id == bucket_name
@mock_aws
def test_bucket_versioning_access_denied_is_manual(self):
"""s3:GetBucketVersioning denied -> MANUAL, not FAIL."""
from prowler.providers.aws.services.s3.s3_service import S3
s3_client_us_east_1 = client("s3", region_name=AWS_REGION_US_EAST_1)
bucket_name = "bucket_test_us"
s3_client_us_east_1.create_bucket(Bucket=bucket_name)
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"botocore.client.BaseClient._make_api_call",
new=_deny("GetBucketVersioning"),
),
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication.s3_client",
new=S3(aws_provider),
),
):
from prowler.providers.aws.services.s3.s3_bucket_cross_region_replication.s3_bucket_cross_region_replication import (
s3_bucket_cross_region_replication,
)
result = s3_bucket_cross_region_replication().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "s3:GetBucketVersioning" in result[0].status_extended
@@ -1,13 +1,18 @@
from unittest import mock
from uuid import uuid4
from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider
from tests.providers.azure.azure_fixtures import (
DOMAIN,
TENANT_IDS,
set_mocked_azure_provider,
)
class Test_entra_global_admin_in_less_than_five_users:
def test_entra_no_tenants(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -33,6 +38,7 @@ class Test_entra_global_admin_in_less_than_five_users:
def test_entra_tenant_empty(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -58,6 +64,7 @@ class Test_entra_global_admin_in_less_than_five_users:
def test_entra_less_than_five_global_admins(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -111,6 +118,7 @@ class Test_entra_global_admin_in_less_than_five_users:
def test_entra_more_than_five_global_admins(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -179,6 +187,7 @@ class Test_entra_global_admin_in_less_than_five_users:
def test_entra_exactly_five_global_admins(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -240,3 +249,46 @@ class Test_entra_global_admin_in_less_than_five_users:
assert result[0].subscription == f"Tenant: {DOMAIN}"
assert result[0].resource_name == "Global Administrator"
assert result[0].resource_id == id
def test_entra_users_retrieval_error_reports_single_manual(self):
"""Graph could not return the tenant's users -> one tenant-level MANUAL."""
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.tenant_ids = [TENANT_IDS[0]]
entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_global_admin_in_less_than_five_users.entra_global_admin_in_less_than_five_users.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_global_admin_in_less_than_five_users.entra_global_admin_in_less_than_five_users import (
entra_global_admin_in_less_than_five_users,
)
from prowler.providers.azure.services.entra.entra_service import (
DirectoryRole,
)
# Directory roles were retrieved, but every member was filtered
# out because the users could not be fetched: without the error
# tracking this would be a false PASS with 0 administrators.
entra_client.directory_roles = {
DOMAIN: {
"Global Administrator": DirectoryRole(id=str(uuid4()), members=[])
}
}
entra_client.users = {DOMAIN: {}}
result = entra_global_admin_in_less_than_five_users().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "did not return the tenant's users" in result[0].status_extended
assert "503" in result[0].status_extended
assert result[0].subscription == f"Tenant: {DOMAIN}"
assert result[0].resource_id == TENANT_IDS[0]
@@ -1,13 +1,18 @@
from unittest import mock
from uuid import uuid4
from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider
from tests.providers.azure.azure_fixtures import (
DOMAIN,
TENANT_IDS,
set_mocked_azure_provider,
)
class Test_entra_non_privileged_user_has_mfa:
def test_entra_no_tenants(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -31,6 +36,7 @@ class Test_entra_non_privileged_user_has_mfa:
def test_entra_tenant_no_users(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -54,6 +60,7 @@ class Test_entra_non_privileged_user_has_mfa:
def test_entra_user_no_privileged_no_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -102,6 +109,7 @@ class Test_entra_non_privileged_user_has_mfa:
def test_entra_user_no_privileged_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -147,6 +155,7 @@ class Test_entra_non_privileged_user_has_mfa:
def test_entra_disabled_user_no_privileged_no_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -188,6 +197,7 @@ class Test_entra_non_privileged_user_has_mfa:
def test_entra_disabled_user_no_privileged_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -229,6 +239,7 @@ class Test_entra_non_privileged_user_has_mfa:
def test_entra_user_privileged_no_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -271,6 +282,7 @@ class Test_entra_non_privileged_user_has_mfa:
def test_entra_user_privileged_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -309,3 +321,36 @@ class Test_entra_non_privileged_user_has_mfa:
check = entra_non_privileged_user_has_mfa()
result = check.execute()
assert len(result) == 0
def test_entra_users_retrieval_error_reports_single_manual(self):
"""Graph could not return the tenant's users -> one tenant-level MANUAL."""
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.tenant_ids = [TENANT_IDS[0]]
entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import (
entra_non_privileged_user_has_mfa,
)
entra_client.users = {DOMAIN: {}}
entra_client.directory_roles = {DOMAIN: {}}
result = entra_non_privileged_user_has_mfa().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "did not return the tenant's users" in result[0].status_extended
assert "503" in result[0].status_extended
assert result[0].subscription == f"Tenant: {DOMAIN}"
assert result[0].resource_id == TENANT_IDS[0]
@@ -1,13 +1,18 @@
from unittest import mock
from uuid import uuid4
from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider
from tests.providers.azure.azure_fixtures import (
DOMAIN,
TENANT_IDS,
set_mocked_azure_provider,
)
class Test_entra_privileged_user_has_mfa:
def test_entra_no_tenants(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -31,6 +36,7 @@ class Test_entra_privileged_user_has_mfa:
def test_entra_tenant_no_users(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
@@ -54,6 +60,7 @@ class Test_entra_privileged_user_has_mfa:
def test_entra_user_no_privileged_no_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -94,6 +101,7 @@ class Test_entra_privileged_user_has_mfa:
def test_entra_user_no_privileged_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -134,6 +142,7 @@ class Test_entra_privileged_user_has_mfa:
def test_entra_user_privileged_no_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -181,6 +190,7 @@ class Test_entra_privileged_user_has_mfa:
def test_entra_user_privileged_mfa(self):
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
user_id = str(uuid4())
with (
@@ -224,3 +234,36 @@ class Test_entra_privileged_user_has_mfa:
assert result[0].resource_name == "foo"
assert result[0].resource_id == user_id
assert result[0].subscription == f"Tenant: {DOMAIN}"
def test_entra_users_retrieval_error_reports_single_manual(self):
"""Graph could not return the tenant's users -> one tenant-level MANUAL."""
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.tenant_ids = [TENANT_IDS[0]]
entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_privileged_user_has_mfa.entra_privileged_user_has_mfa.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_privileged_user_has_mfa.entra_privileged_user_has_mfa import (
entra_privileged_user_has_mfa,
)
entra_client.users = {DOMAIN: {}}
entra_client.directory_roles = {DOMAIN: {}}
result = entra_privileged_user_has_mfa().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "did not return the tenant's users" in result[0].status_extended
assert "503" in result[0].status_extended
assert result[0].subscription == f"Tenant: {DOMAIN}"
assert result[0].resource_id == TENANT_IDS[0]
@@ -305,3 +305,113 @@ def test_azure_entra__get_users_handles_pagination():
assert users["tenant-1"]["user-2"].account_enabled is True
assert users["tenant-1"]["user-3"].is_mfa_capable is False
assert users["tenant-1"]["user-3"].account_enabled is True
class TestGetUsersSignInActivity:
"""Service-level coverage for the signInActivity 403 fallback."""
@staticmethod
def _graph_error(status):
error = Exception("graph error")
error.response_status_code = status
return error
@staticmethod
def _users_response(value=None, next_link=None):
from types import SimpleNamespace
return SimpleNamespace(value=value or [], odata_next_link=next_link)
def _service(self, side_effect):
# SimpleNamespace instead of MagicMock: several check tests assign
# attributes on the MagicMock *class*, which would shadow instance
# child mocks here.
from types import SimpleNamespace
from unittest.mock import AsyncMock
from prowler.providers.azure.services.entra.entra_service import Entra
service = Entra.__new__(Entra)
client = SimpleNamespace(
users=SimpleNamespace(get=AsyncMock(side_effect=side_effect))
)
service.clients = {"tenant.onmicrosoft.com": client}
service.sign_in_activity_errors = {}
service.users_retrieval_errors = {}
service._get_user_registration_details = AsyncMock(return_value={})
return service
def test_403_records_tenant_and_retries_without_sign_in_activity(self):
import asyncio
service = self._service(
side_effect=[self._graph_error(403), self._users_response()]
)
users = asyncio.run(service._get_users())
assert "tenant.onmicrosoft.com" in service.sign_in_activity_errors
assert "403" in service.sign_in_activity_errors["tenant.onmicrosoft.com"]
assert service.users_retrieval_errors == {}
assert users == {"tenant.onmicrosoft.com": {}}
assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 2
def test_transient_error_does_not_blame_licensing(self):
import asyncio
service = self._service(side_effect=[self._graph_error(503)])
users = asyncio.run(service._get_users())
# The failure is not attributed to licensing/permissions, but the
# empty inventory is not trusted either: the tenant is recorded so
# the user-based checks report MANUAL.
assert service.sign_in_activity_errors == {}
assert "tenant.onmicrosoft.com" in service.users_retrieval_errors
assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"]
assert users == {"tenant.onmicrosoft.com": {}}
assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 1
def test_failing_second_page_records_users_retrieval_error(self):
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
service = self._service(
side_effect=[
self._users_response(
value=[
SimpleNamespace(
id="user-1",
display_name="user-1",
account_enabled=True,
sign_in_activity=None,
)
],
next_link="https://graph.microsoft.com/v1.0/users?$skiptoken=page2",
)
]
)
service.clients["tenant.onmicrosoft.com"].users.with_url = lambda _: (
SimpleNamespace(get=AsyncMock(side_effect=self._graph_error(503)))
)
users = asyncio.run(service._get_users())
# The first page made it into the inventory, but the tenant is marked
# unavailable: a partial inventory must not be evaluated as complete.
assert "user-1" in users["tenant.onmicrosoft.com"]
assert "tenant.onmicrosoft.com" in service.users_retrieval_errors
assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"]
assert service.sign_in_activity_errors == {}
def test_403_with_failing_retry_records_users_retrieval_error(self):
import asyncio
service = self._service(
side_effect=[self._graph_error(403), self._graph_error(503)]
)
users = asyncio.run(service._get_users())
assert "tenant.onmicrosoft.com" in service.sign_in_activity_errors
assert "tenant.onmicrosoft.com" in service.users_retrieval_errors
assert "503" in service.users_retrieval_errors["tenant.onmicrosoft.com"]
assert users == {"tenant.onmicrosoft.com": {}}
assert service.clients["tenant.onmicrosoft.com"].users.get.await_count == 2
@@ -2,12 +2,21 @@ from datetime import datetime, timedelta, timezone
from unittest import mock
from uuid import uuid4
from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider
from tests.providers.azure.azure_fixtures import (
DOMAIN,
TENANT_IDS,
set_mocked_azure_provider,
)
TENANT_ID = TENANT_IDS[0]
class Test_entra_user_with_recent_sign_in:
def test_entra_no_tenants(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
with (
mock.patch(
@@ -31,6 +40,9 @@ class Test_entra_user_with_recent_sign_in:
def test_entra_user_disabled(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
user_id = str(uuid4())
with (
@@ -63,6 +75,9 @@ class Test_entra_user_with_recent_sign_in:
def test_entra_user_never_signed_in(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
user_id = str(uuid4())
with (
@@ -93,10 +108,13 @@ class Test_entra_user_with_recent_sign_in:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "No sign-in activity data available" in result[0].status_extended
assert "no recorded sign-in activity" in result[0].status_extended
def test_entra_single_user_no_sign_in_data_reports_telemetry_gap(self):
def test_entra_single_user_no_sign_in_data_fails(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
user_id = str(uuid4())
with (
@@ -127,11 +145,13 @@ class Test_entra_user_with_recent_sign_in:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "No sign-in activity data available" in result[0].status_extended
assert "1 enabled user" in result[0].status_extended
assert "no recorded sign-in activity" in result[0].status_extended
def test_entra_user_stale_sign_in(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
user_id = str(uuid4())
with (
@@ -166,6 +186,9 @@ class Test_entra_user_with_recent_sign_in:
def test_entra_user_recent_sign_in(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
user_id = str(uuid4())
with (
@@ -198,8 +221,11 @@ class Test_entra_user_with_recent_sign_in:
assert result[0].status == "PASS"
assert "10 days ago" in result[0].status_extended
def test_entra_all_users_no_sign_in_data_license_issue(self):
def test_entra_all_users_no_sign_in_data_fail(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
with (
mock.patch(
@@ -231,14 +257,63 @@ class Test_entra_user_with_recent_sign_in:
check = entra_user_with_recent_sign_in()
result = check.execute()
# Should produce 1 finding (license warning), not 5 individual FAILs
# Graph returned the users without any sign-in: every one is stale
assert len(result) == 5
assert all(r.status == "FAIL" for r in result)
def test_entra_sign_in_activity_errors_reports_single_manual(self):
"""Graph refused signInActivity (no P1/P2 or AuditLog.Read.All) -> one tenant MANUAL."""
entra_client = mock.MagicMock
entra_client.tenant_ids = [TENANT_ID]
entra_client.sign_in_activity_errors = {
DOMAIN: "ODataError HTTP 403 Authentication_RequestFromNonPremiumTenantOrB2CTenant"
}
entra_client.users_retrieval_errors = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_service import User
from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import (
entra_user_with_recent_sign_in,
)
# Users were re-fetched without signInActivity, so they exist but
# must not be evaluated individually.
entra_client.users = {
DOMAIN: {
str(uuid4()): User(
id=str(uuid4()), name="user", account_enabled=True
)
}
}
result = entra_user_with_recent_sign_in().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Entra ID P1/P2 licensing" in result[0].status_extended
assert "5 enabled users" in result[0].status_extended
assert result[0].status == "MANUAL"
assert "Entra ID P1/P2" in result[0].status_extended
assert "AuditLog.Read.All" in result[0].status_extended
assert (
"Authentication_RequestFromNonPremiumTenantOrB2CTenant"
in result[0].status_extended
)
assert result[0].resource_id == TENANT_ID
assert result[0].resource_name == DOMAIN
assert result[0].subscription == f"Tenant: {DOMAIN}"
def test_entra_user_never_signed_in_when_telemetry_exists_for_tenant(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
with (
mock.patch(
@@ -282,12 +357,16 @@ class Test_entra_user_with_recent_sign_in:
r.status == "PASS" and "5 days ago" in r.status_extended for r in result
)
assert any(
r.status == "FAIL" and "never signed in" in r.status_extended
r.status == "FAIL"
and "no recorded sign-in activity" in r.status_extended
for r in result
)
def test_entra_user_boundary_90_days(self):
entra_client = mock.MagicMock
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {}
entra_client.tenant_ids = [TENANT_ID]
user_id = str(uuid4())
with (
@@ -319,3 +398,34 @@ class Test_entra_user_with_recent_sign_in:
assert len(result) == 1
assert result[0].status == "PASS"
assert "90 days ago" in result[0].status_extended
def test_entra_users_retrieval_error_reports_single_manual(self):
"""Graph could not return the tenant's users at all -> one tenant MANUAL."""
entra_client = mock.MagicMock
entra_client.tenant_ids = [TENANT_ID]
entra_client.sign_in_activity_errors = {}
entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in.entra_client",
new=entra_client,
),
):
from prowler.providers.azure.services.entra.entra_user_with_recent_sign_in.entra_user_with_recent_sign_in import (
entra_user_with_recent_sign_in,
)
entra_client.users = {DOMAIN: {}}
result = entra_user_with_recent_sign_in().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "did not return the tenant's users" in result[0].status_extended
assert "503" in result[0].status_extended
assert result[0].resource_id == TENANT_ID
@@ -7,6 +7,7 @@ from tests.providers.azure.azure_fixtures import (
AZURE_SUBSCRIPTION_ID,
AZURE_SUBSCRIPTION_NAME,
DOMAIN,
TENANT_IDS,
set_mocked_azure_provider,
)
@@ -18,12 +19,17 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.entra_client",
new=entra_client,
),
mock.patch(
"prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.iam_client",
new=iam_client,
@@ -47,6 +53,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
role_assigment_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
user_id = str(uuid4())
@@ -120,6 +127,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
role_assigment_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
user_id = str(uuid4())
@@ -193,6 +201,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
role_assigment_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
user_id = str(uuid4())
@@ -249,6 +258,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
role_assigment_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {}
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
user_id = str(uuid4())
@@ -306,3 +316,44 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
check = entra_user_with_vm_access_has_mfa()
result = check.execute()
assert len(result) == 0
def test_entra_users_retrieval_error_reports_single_manual(self):
"""Graph could not return the tenant's users -> one tenant-level MANUAL."""
iam_client = mock.MagicMock
iam_client.resource_groups = {}
iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
entra_client = mock.MagicMock
entra_client.resource_groups = {}
entra_client.users_retrieval_errors = {DOMAIN: "ODataError HTTP 503"}
entra_client.tenant_ids = [TENANT_IDS[0]]
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_azure_provider(),
),
mock.patch(
"prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.entra_client",
new=entra_client,
),
mock.patch(
"prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.iam_client",
new=iam_client,
),
):
from prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa import (
entra_user_with_vm_access_has_mfa,
)
iam_client.role_assignments = {}
entra_client.users = {DOMAIN: {}}
result = entra_user_with_vm_access_has_mfa().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "did not return the tenant's users" in result[0].status_extended
assert "503" in result[0].status_extended
assert result[0].subscription == f"Tenant: {DOMAIN}"
assert result[0].resource_id == TENANT_IDS[0]
@@ -8,6 +8,9 @@ from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_prov
class Test_iam_account_access_approval_enabled:
def test_iam_no_settings(self):
accessapproval_client = mock.MagicMock()
accessapproval_client.api_disabled_project_ids = set()
accessapproval_client.api_state_unknown_project_ids = set()
accessapproval_client.settings_lookup_failed = set()
accessapproval_client.settings = {}
accessapproval_client.project_ids = [GCP_PROJECT_ID]
accessapproval_client.region = "global"
@@ -51,6 +54,9 @@ class Test_iam_account_access_approval_enabled:
def test_iam_project_with_settings(self):
cloudresourcemanager_client = mock.MagicMock()
accessapproval_client = mock.MagicMock()
accessapproval_client.api_disabled_project_ids = set()
accessapproval_client.api_state_unknown_project_ids = set()
accessapproval_client.settings_lookup_failed = set()
accessapproval_client.project_ids = [GCP_PROJECT_ID]
accessapproval_client.region = "global"
accessapproval_client.projects = {
@@ -103,6 +109,9 @@ class Test_iam_account_access_approval_enabled:
def test_iam_project_with_settings_empty_project_name(self):
cloudresourcemanager_client = mock.MagicMock()
accessapproval_client = mock.MagicMock()
accessapproval_client.api_disabled_project_ids = set()
accessapproval_client.api_state_unknown_project_ids = set()
accessapproval_client.settings_lookup_failed = set()
accessapproval_client.project_ids = [GCP_PROJECT_ID]
accessapproval_client.region = "global"
accessapproval_client.projects = {
@@ -151,3 +160,126 @@ class Test_iam_account_access_approval_enabled:
assert result[0].resource_name == "GCP Project"
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == "global"
def test_iam_settings_lookup_failed(self):
"""Permission/API error reading the settings -> MANUAL, not FAIL."""
accessapproval_client = mock.MagicMock()
accessapproval_client.api_disabled_project_ids = set()
accessapproval_client.api_state_unknown_project_ids = set()
accessapproval_client.settings = {}
accessapproval_client.settings_lookup_failed = {GCP_PROJECT_ID}
accessapproval_client.project_ids = [GCP_PROJECT_ID]
accessapproval_client.region = "global"
accessapproval_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test",
labels={},
lifecycle_state="ACTIVE",
)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled.accessapproval_client",
new=accessapproval_client,
),
):
from prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled import (
iam_account_access_approval_enabled,
)
check = iam_account_access_approval_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert search(
"Access Approval settings could not be read",
result[0].status_extended,
)
assert result[0].resource_id == GCP_PROJECT_ID
assert result[0].project_id == GCP_PROJECT_ID
def test_iam_api_disabled_project_is_fail(self):
"""API definitively disabled -> Access Approval cannot be enabled -> FAIL."""
accessapproval_client = mock.MagicMock()
accessapproval_client.settings = {}
accessapproval_client.settings_lookup_failed = set()
accessapproval_client.api_disabled_project_ids = {GCP_PROJECT_ID}
accessapproval_client.api_state_unknown_project_ids = set()
accessapproval_client.project_ids = []
accessapproval_client.region = "global"
accessapproval_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test",
labels={},
lifecycle_state="ACTIVE",
)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled.accessapproval_client",
new=accessapproval_client,
),
):
from prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled import (
iam_account_access_approval_enabled,
)
result = iam_account_access_approval_enabled().execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "API is disabled" in result[0].status_extended
assert result[0].project_id == GCP_PROJECT_ID
def test_iam_api_state_unknown_project_is_manual(self):
"""API activation state undetermined -> evidence gap -> MANUAL."""
accessapproval_client = mock.MagicMock()
accessapproval_client.settings = {}
accessapproval_client.settings_lookup_failed = set()
accessapproval_client.api_disabled_project_ids = set()
accessapproval_client.api_state_unknown_project_ids = {GCP_PROJECT_ID}
accessapproval_client.project_ids = []
accessapproval_client.region = "global"
accessapproval_client.projects = {
GCP_PROJECT_ID: GCPProject(
id=GCP_PROJECT_ID,
number="123456789012",
name="test",
labels={},
lifecycle_state="ACTIVE",
)
}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled.accessapproval_client",
new=accessapproval_client,
),
):
from prowler.providers.gcp.services.iam.iam_account_access_approval_enabled.iam_account_access_approval_enabled import (
iam_account_access_approval_enabled,
)
result = iam_account_access_approval_enabled().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "could not be determined" in result[0].status_extended
@@ -1,5 +1,5 @@
from datetime import datetime
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import (
CloudResourceManager,
@@ -113,6 +113,118 @@ class TestAccessApproval:
access_approval_client.settings[GCP_PROJECT_ID].project_id
== GCP_PROJECT_ID
)
assert access_approval_client.settings_lookup_failed == set()
def _build_with_http_error(self, status):
from googleapiclient.errors import HttpError
http_error = HttpError(
resp=MagicMock(status=status, reason="error"),
content=b'{"error": {"code": %d, "message": "error"}}' % status,
uri="https://accessapproval.googleapis.com/v1/projects/123/accessApprovalSettings",
)
client = MagicMock()
client.projects().getAccessApprovalSettings().execute.side_effect = http_error
with (
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__",
new=mock_is_api_active,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
return_value=client,
),
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_gcp_provider(),
),
):
from prowler.providers.gcp.services.iam.iam_service import AccessApproval
return AccessApproval(set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]))
def test_settings_not_found_means_not_enabled(self):
"""A 404 means Access Approval is not enabled: no settings, no error."""
access_approval_client = self._build_with_http_error(404)
assert access_approval_client.settings == {}
assert access_approval_client.settings_lookup_failed == set()
def test_settings_permission_denied_is_tracked(self):
"""A 403 (or API disabled) is a lookup failure, not 'not enabled'."""
access_approval_client = self._build_with_http_error(403)
assert access_approval_client.settings == {}
assert access_approval_client.settings_lookup_failed == {GCP_PROJECT_ID}
def test_access_approval_api_disabled_is_tracked(self):
"""A DISABLED serviceusage state must land in api_disabled_project_ids."""
serviceusage_client = MagicMock()
serviceusage_client.services().get().execute.return_value = {
"state": "DISABLED"
}
provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
# The fixture is a MagicMock: make the API-activation precheck run.
provider.skip_api_check = False
with (
patch(
"prowler.providers.gcp.lib.service.service.discovery.build",
return_value=serviceusage_client,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
return_value=MagicMock(),
),
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=provider,
),
):
from prowler.providers.gcp.services.iam.iam_service import AccessApproval
access_approval_client = AccessApproval(provider)
assert access_approval_client.project_ids == []
assert access_approval_client.api_disabled_project_ids == {GCP_PROJECT_ID}
assert access_approval_client.api_state_unknown_project_ids == set()
assert access_approval_client.settings == {}
def test_access_approval_api_state_unknown_is_tracked(self):
"""A failing serviceusage call must land in api_state_unknown_project_ids."""
serviceusage_client = MagicMock()
serviceusage_client.services().get().execute.side_effect = Exception(
"PERMISSION_DENIED: serviceusage.services.get"
)
provider = set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
provider.skip_api_check = False
with (
patch(
"prowler.providers.gcp.lib.service.service.discovery.build",
return_value=serviceusage_client,
),
patch(
"prowler.providers.gcp.lib.service.service.GCPService.__generate_client__",
return_value=MagicMock(),
),
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=provider,
),
):
from prowler.providers.gcp.services.iam.iam_service import AccessApproval
access_approval_client = AccessApproval(provider)
assert access_approval_client.project_ids == []
assert access_approval_client.api_disabled_project_ids == set()
assert access_approval_client.api_state_unknown_project_ids == {
GCP_PROJECT_ID
}
class TestEssentialContacts:
@@ -94,7 +94,7 @@ class Test_defenderidentity_health_issues_no_open:
assert result[0].resource_id == "defenderIdentity"
def test_both_apis_failed(self):
"""Test when both sensors and health_issues APIs fail (None): expected FAIL with permission message."""
"""Test when both sensors and health_issues APIs fail (None): expected MANUAL with permission message."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
@@ -120,7 +120,7 @@ class Test_defenderidentity_health_issues_no_open:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert "APIs are not accessible" in result[0].status_extended
assert "SecurityIdentitiesSensors.Read.All" in result[0].status_extended
assert "SecurityIdentitiesHealth.Read.All" in result[0].status_extended
@@ -155,8 +155,11 @@ class Test_defenderidentity_health_issues_no_open:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Cannot read health issues" in result[0].status_extended
assert result[0].status == "MANUAL"
assert (
"Cannot evaluate Defender for Identity health issues"
in result[0].status_extended
)
assert "1 sensor(s) deployed" in result[0].status_extended
assert "SecurityIdentitiesHealth.Read.All" in result[0].status_extended
assert result[0].resource == {}
@@ -644,3 +647,33 @@ class Test_defenderidentity_health_issues_no_open:
)
assert result[0].resource_id == health_issue_id
assert result[0].resource_name == health_issue_name
def test_sensors_api_failed_with_empty_health_issues(self):
"""sensors=None (API failed) + health_issues=[]: PASS cannot be trusted -> MANUAL."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
defenderidentity_client.sensors = None
defenderidentity_client.health_issues = []
result = defenderidentity_health_issues_no_open().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "sensor deployment" in result[0].status_extended
assert "SecurityIdentitiesSensors.Read.All" in result[0].status_extended
@@ -10,7 +10,7 @@ class Test_defenderxdr_critical_asset_management_pending_approvals:
"""Tests for the defenderxdr_critical_asset_management_pending_approvals check."""
def test_api_failed_missing_permission(self):
"""Test FAIL when API call fails (None): missing ThreatHunting.Read.All permission."""
"""Test MANUAL when API call fails (None): missing ThreatHunting.Read.All permission."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
@@ -34,9 +34,10 @@ class Test_defenderxdr_critical_asset_management_pending_approvals:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
"Unable to query Critical Asset Management" in result[0].status_extended
"Cannot evaluate Critical Asset Management pending approvals"
in result[0].status_extended
)
assert "ThreatHunting.Read.All" in result[0].status_extended
assert result[0].resource_id == "criticalAssetManagement"
@@ -7,7 +7,7 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
"""Tests for the defenderxdr_endpoint_privileged_user_exposed_credentials check."""
def test_mde_status_api_failed(self):
"""Test FAIL when MDE status API call fails (None): missing permission."""
"""Test MANUAL when MDE status API call fails (None): missing permission."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
@@ -32,8 +32,11 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Unable to query Microsoft Defender XDR" in result[0].status_extended
assert result[0].status == "MANUAL"
assert (
"unable to query Microsoft Defender XDR Advanced Hunting"
in result[0].status_extended
)
assert "ThreatHunting.Read.All" in result[0].status_extended
assert result[0].resource_id == "mdeStatus"
@@ -103,7 +106,7 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
assert result[0].resource_id == "mdeDevices"
def test_exposed_credentials_query_failed(self):
"""Test FAIL when exposed credentials query fails (None)."""
"""Test MANUAL when exposed credentials query fails (None)."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
@@ -128,9 +131,9 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
"Unable to query Security Exposure Management"
"unable to query Security Exposure Management"
in result[0].status_extended
)
assert result[0].resource_id == "exposedCredentials"
@@ -0,0 +1,61 @@
import asyncio
from unittest import mock
from prowler.providers.m365.services.defenderxdr.defenderxdr_service import DefenderXDR
def _service_with_response(response=None, side_effect=None):
"""Build a DefenderXDR instance without running __init__, with a mocked client."""
service = DefenderXDR.__new__(DefenderXDR)
post = mock.AsyncMock(return_value=response, side_effect=side_effect)
service.client = mock.MagicMock()
service.client.security.microsoft_graph_security_run_hunting_query.post = post
return service
class TestRunHuntingQuery:
def test_null_response_is_unavailable_not_empty(self):
"""A null response object must not be treated as a successful empty query."""
service = _service_with_response(response=None)
results, table_not_found = asyncio.run(service._run_hunting_query("query"))
assert results is None
assert table_not_found is False
def test_empty_results_is_confirmed_empty(self):
response = mock.MagicMock()
response.results = []
service = _service_with_response(response=response)
results, table_not_found = asyncio.run(service._run_hunting_query("query"))
assert results == []
assert table_not_found is False
def test_table_not_found_is_flagged(self):
service = _service_with_response(
side_effect=Exception(
"'where' operator: Failed to resolve table or column expression named 'DeviceInfo'"
)
)
results, table_not_found = asyncio.run(service._run_hunting_query("query"))
assert results == []
assert table_not_found is True
def test_generic_error_is_unavailable(self):
service = _service_with_response(side_effect=Exception("403 Forbidden"))
results, table_not_found = asyncio.run(service._run_hunting_query("query"))
assert results is None
assert table_not_found is False
class TestExposedCredentials:
def test_table_not_found_propagates_as_unavailable(self):
"""Security Exposure Management tables missing -> None (MANUAL), not [] (PASS)."""
service = _service_with_response(
side_effect=Exception("Failed to resolve table ExposureGraphEdges")
)
result = asyncio.run(service._get_exposed_credentials_privileged_users())
assert result is None
def test_null_response_propagates_as_unavailable(self):
service = _service_with_response(response=None)
result = asyncio.run(service._get_pending_cam_approvals())
assert result is None
@@ -45,7 +45,7 @@ class Test_entra_app_registration_no_unused_privileged_permissions:
assert result[0].resource_id == "oauthApps"
def test_no_oauth_apps_none(self):
"""OAuth apps is None (App Governance not enabled): expected FAIL."""
"""OAuth apps is None (App Governance not enabled): expected MANUAL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
@@ -70,10 +70,10 @@ class Test_entra_app_registration_no_unused_privileged_permissions:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== "OAuth App Governance data is unavailable. Enable App Governance in Microsoft Defender for Cloud Apps and grant ThreatHunting.Read.All to evaluate unused privileged permissions."
== "Cannot evaluate unused privileged permissions: OAuth App Governance data is unavailable. Enable App Governance in Microsoft Defender for Cloud Apps and grant the ThreatHunting.Read.All permission to the scanning application."
)
assert result[0].resource == {}
assert result[0].resource_name == "OAuth Applications"
@@ -67,6 +67,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -105,6 +106,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -144,6 +146,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -181,6 +184,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -232,6 +236,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -280,6 +285,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -327,6 +333,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -375,6 +382,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -430,6 +438,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -466,6 +475,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -512,10 +522,11 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
assert result[0].resource_name == "BreakGlass1"
def test_user_registration_details_permission_error(self):
"""Test FAIL when there's a permission error reading user registration details."""
"""Test MANUAL when there's a permission error reading user registration details."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
@@ -551,28 +562,27 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
"Cannot verify FIDO2 security key registration for break glass account BreakGlass1"
"Cannot verify FIDO2 security key registration for break glass accounts"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource_name == "BreakGlass1"
assert result[0].resource_id == bg_user_id
assert result[0].resource_name == "Break Glass Accounts"
assert result[0].resource_id == "breakGlassAccounts"
def test_user_registration_details_permission_error_with_missing_user(self):
"""Per-user emission and missing-user short-circuit on the error path.
def test_user_registration_details_permission_error_multiple_users(self):
"""The registration-details error is tenant-wide: one MANUAL, not one per user.
Two break-glass user IDs are excluded from all CAPs, but only one is
present in ``entra_client.users``. With ``user_registration_details_error``
set, the present user must produce one preventive FAIL anchored to the
real user; the missing user must be skipped by the existing
``if not user: continue`` guard rather than crash or yield a synthetic
finding.
Two break-glass users are excluded from all CAPs and both are present in
``entra_client.users``. With ``user_registration_details_error`` set the
check must emit a single tenant-level MANUAL finding instead of one
per break-glass account.
"""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
@@ -590,37 +600,40 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
)
policy_id = str(uuid4())
present_user_id = str(uuid4())
missing_user_id = str(uuid4())
first_user_id = str(uuid4())
second_user_id = str(uuid4())
entra_client.conditional_access_policies = {
policy_id: _make_policy(
policy_id,
excluded_users=[present_user_id, missing_user_id],
excluded_users=[first_user_id, second_user_id],
),
}
entra_client.users = {
present_user_id: User(
id=present_user_id,
first_user_id: User(
id=first_user_id,
name="BreakGlass1",
on_premises_sync_enabled=False,
authentication_methods=[],
),
# missing_user_id intentionally absent — exercises the
# `if not user: continue` short-circuit inside the loop.
second_user_id: User(
id=second_user_id,
name="BreakGlass2",
on_premises_sync_enabled=False,
authentication_methods=[],
),
}
check = entra_break_glass_account_fido2_security_key_registered()
result = check.execute()
# One finding for the present user; the missing one is skipped.
# One tenant-level finding, regardless of how many break glass users exist.
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
"Cannot verify FIDO2 security key registration for break glass account BreakGlass1"
"Cannot verify FIDO2 security key registration for break glass accounts"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource == entra_client.users[present_user_id]
assert result[0].resource_name == "BreakGlass1"
assert result[0].resource_id == present_user_id
assert result[0].resource_name == "Break Glass Accounts"
assert result[0].resource_id == "breakGlassAccounts"
@@ -169,7 +169,7 @@ class Test_entra_seamless_sso_disabled:
assert result[0].resource_name == "Cloud Only Org"
def test_insufficient_permissions_error(self):
"""Test FAIL when there's a permission error reading directory sync settings."""
"""Test MANUAL when there's a permission error reading directory sync settings."""
entra_client = mock.MagicMock()
with (
@@ -199,7 +199,7 @@ class Test_entra_seamless_sso_disabled:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert "Cannot verify Seamless SSO status" in result[0].status_extended
assert "Insufficient privileges" in result[0].status_extended
assert (
@@ -272,3 +272,39 @@ class Test_entra_seamless_sso_disabled:
result = check.execute()
assert len(result) == 0
def test_hybrid_org_without_sync_settings_is_manual(self):
"""Hybrid org, no error, but no directory sync settings returned -> MANUAL."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = None
entra_client.organizations = [
Organization(
id="org1", name="Hybrid Org", on_premises_sync_enabled=True
)
]
result = entra_seamless_sso_disabled().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"no directory synchronization settings were returned"
in result[0].status_extended
)
assert result[0].resource_id == "org1"
@@ -12,6 +12,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -55,6 +56,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -98,6 +100,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -157,6 +160,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -196,6 +200,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -254,6 +259,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -293,6 +299,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -332,6 +339,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -372,6 +380,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -414,6 +423,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -459,6 +469,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -519,6 +530,7 @@ class Test_entra_users_mfa_capable:
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = None
with (
@@ -559,10 +571,11 @@ class Test_entra_users_mfa_capable:
assert result[0].resource_id == user_id
def test_user_registration_details_permission_error(self):
"""Test FAIL when there's a permission error reading user registration details."""
"""Test a single tenant-level MANUAL when user registration details cannot be read."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
@@ -595,26 +608,27 @@ class Test_entra_users_mfa_capable:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
"Cannot verify MFA capability for user Test User"
"Cannot verify MFA capability for member users"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource == entra_client.users[user_id]
assert result[0].resource_name == "Test User"
assert result[0].resource_id == user_id
assert result[0].resource_name == "Entra Users"
assert result[0].resource_id == "users"
def test_user_registration_details_permission_error_skips_guest_and_disabled(self):
"""CIS-scope skip (Guest, disabled) still applies on the permission-error path.
def test_user_registration_details_permission_error_with_mixed_users(self):
"""The permission-error path emits a single tenant-level MANUAL finding.
With ``user_registration_details_error`` set, only enabled member users
should receive a per-user "Cannot verify MFA capability" FAIL — guests
and disabled members are filtered out before the error branch runs.
With ``user_registration_details_error`` set, no per-user findings are
produced (a missing permission is not a per-user security issue): a
single MANUAL finding is emitted regardless of the guest/member/disabled
mix of users in the tenant.
"""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.users_error = None
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
with (
@@ -667,15 +681,44 @@ class Test_entra_users_mfa_capable:
check = entra_users_mfa_capable()
result = check.execute()
# Only the enabled member should be reported — Guest and
# disabled member are skipped before the error branch.
# A single tenant-level MANUAL finding is emitted regardless of
# how many users exist; no per-user findings are produced.
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
"Cannot verify MFA capability for user Enabled Member"
"Cannot verify MFA capability for member users"
in result[0].status_extended
)
assert "AuditLog.Read.All" in result[0].status_extended
assert result[0].resource == entra_client.users[member_id]
assert result[0].resource_name == "Enabled Member"
assert result[0].resource_id == member_id
assert result[0].resource_name == "Entra Users"
assert result[0].resource_id == "users"
def test_users_error_reports_single_manual(self):
"""Users could not be retrieved from Graph -> one tenant-level MANUAL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
entra_client.user_registration_details_error = None
entra_client.users_error = "Insufficient privileges to read users and directory roles. Required permissions: User.Read.All, Directory.Read.All or RoleManagement.Read.Directory"
entra_client.users = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_users_mfa_capable.entra_users_mfa_capable import (
entra_users_mfa_capable,
)
result = entra_users_mfa_capable().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert "Directory.Read.All" in result[0].status_extended
assert result[0].resource_name == "Entra Users"
@@ -1951,3 +1951,42 @@ class Test_Entra_Service:
assert [policy.id for policy in policies] == ["policy-1", "policy-2"]
with_url_mock.assert_called_once_with("next-link")
next_page_builder.get.assert_awaited_once()
class TestGetOAuthApps:
@staticmethod
def _entra_with_hunting_response(response):
service = entra_service.Entra.__new__(entra_service.Entra)
post = AsyncMock(return_value=response)
service.client = MagicMock()
service.client.security.microsoft_graph_security_run_hunting_query.post = post
return service
def test_null_response_returns_none_not_empty(self):
"""A null hunting response must propagate as None (MANUAL), not {} (PASS)."""
service = self._entra_with_hunting_response(None)
assert asyncio.run(service._get_oauth_apps()) is None
def test_empty_results_is_confirmed_empty(self):
response = MagicMock()
response.results = []
service = self._entra_with_hunting_response(response)
assert asyncio.run(service._get_oauth_apps()) == {}
class TestGetUsersError:
def test_users_error_set_on_graph_failure(self):
"""A failing /users request must set users_error and return no users."""
service = entra_service.Entra.__new__(entra_service.Entra)
service.users_error = None
# SimpleNamespace: check tests assign attributes on the MagicMock
# class, which would shadow instance child mocks here.
service.client = SimpleNamespace(
users=SimpleNamespace(get=AsyncMock(side_effect=Exception("boom")))
)
users = asyncio.run(service._get_users())
assert users == {}
assert service.users_error is not None
assert "Unable to retrieve users from Microsoft Graph" in service.users_error
@@ -11,6 +11,8 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
exchange_client.shared_mailboxes = []
entra_client = mock.MagicMock()
entra_client.users_error = None
entra_client.users = {}
with (
@@ -80,6 +82,7 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
account_enabled=False,
)
entra_client = mock.MagicMock()
entra_client.users_error = None
entra_client.users = {
"12345678-1234-1234-1234-123456789012": entra_user,
}
@@ -143,6 +146,7 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
account_enabled=True,
)
entra_client = mock.MagicMock()
entra_client.users_error = None
entra_client.users = {
"87654321-4321-4321-4321-210987654321": entra_user,
}
@@ -199,6 +203,8 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
exchange_client.shared_mailboxes = [shared_mailbox]
entra_client = mock.MagicMock()
entra_client.users_error = None
entra_client.users = {}
with mock.patch(
@@ -209,10 +215,10 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== "Shared mailbox orphan@contoso.com could not be found in Entra ID for verification."
== "Cannot verify sign-in status for shared mailbox orphan@contoso.com: the user could not be resolved in Entra ID."
)
assert result[0].resource_name == "Orphan Mailbox"
assert result[0].resource_id == "00000000-0000-0000-0000-000000000000"
@@ -284,6 +290,8 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
)
entra_client = mock.MagicMock()
entra_client.users_error = None
entra_client.users = {
"11111111-1111-1111-1111-111111111111": user_disabled,
"22222222-2222-2222-2222-222222222222": user_enabled,
@@ -310,8 +318,62 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
== "Shared mailbox insecure@contoso.com has sign-in enabled."
)
assert result[2].status == "FAIL"
assert result[2].status == "MANUAL"
assert (
result[2].status_extended
== "Shared mailbox unknown@contoso.com could not be found in Entra ID for verification."
== "Cannot verify sign-in status for shared mailbox unknown@contoso.com: the user could not be resolved in Entra ID."
)
def test_users_error_reports_single_tenant_manual(self):
"""Entra users collection failed -> one tenant-level MANUAL, not one per mailbox."""
from prowler.providers.m365.services.exchange.exchange_service import (
SharedMailbox,
)
exchange_client = mock.MagicMock()
exchange_client.audited_tenant = "audited_tenant"
exchange_client.audited_domain = DOMAIN
exchange_client.shared_mailboxes = [
SharedMailbox(
name=f"Mailbox {i}",
identity=f"mailbox{i}",
user_principal_name=f"mailbox{i}@contoso.com",
external_directory_object_id=f"00000000-0000-0000-0000-00000000000{i}",
)
for i in range(2)
]
entra_client = mock.MagicMock()
entra_client.users_error = "Insufficient privileges to read users and directory roles. Required permissions: User.Read.All, Directory.Read.All or RoleManagement.Read.Directory"
entra_client.users = {}
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online"
),
mock.patch(
"prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.exchange_client",
new=exchange_client,
),
mock.patch(
"prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.exchange.exchange_shared_mailbox_sign_in_disabled.exchange_shared_mailbox_sign_in_disabled import (
exchange_shared_mailbox_sign_in_disabled,
)
result = exchange_shared_mailbox_sign_in_disabled().execute()
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
"Cannot verify sign-in status for shared mailboxes"
in result[0].status_extended
)
assert result[0].resource_name == "Shared Mailboxes"