mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
fix(checks): report MANUAL instead of FAIL on permission and data-availability errors (#12645)
This commit is contained in:
@@ -129,12 +129,42 @@ Each check **must** populate the `report.status` and `report.status_extended` fi
|
|||||||
- Status field: `report.status`
|
- Status field: `report.status`
|
||||||
- `PASS` – Assigned when the check confirms compliance with the configured value.
|
- `PASS` – Assigned when the check confirms compliance with the configured value.
|
||||||
- `FAIL` – Assigned when the check detects non-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`
|
- Status extended field: `report.status_extended`
|
||||||
- It **must** end with a period (`.`).
|
- 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.`.
|
- 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
|
### 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.
|
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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
||||||
+20
-4
@@ -24,10 +24,25 @@ class bedrock_agent_role_least_privilege(Check):
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of ``Check_Report_AWS`` with one entry per agent. The
|
A list of ``Check_Report_AWS`` with one entry per agent. The
|
||||||
status is ``FAIL`` when any of the criteria above is violated,
|
status is ``FAIL`` when any of the criteria above is violated and
|
||||||
or when the execution role cannot be resolved in IAM.
|
``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 = []
|
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 [])}
|
roles_by_arn = {role.arn: role for role in (iam_client.roles or [])}
|
||||||
|
|
||||||
for agent in bedrock_agent_client.agents.values():
|
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
|
role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None
|
||||||
if role is None:
|
if role is None:
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
f"Bedrock Agent {agent.name} execution role could not be "
|
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)
|
findings.append(report)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class Cloudtrail(AWSService):
|
|||||||
super().__init__(__class__.__name__, provider)
|
super().__init__(__class__.__name__, provider)
|
||||||
self.trail_arn_template = f"arn:{self.audited_partition}:cloudtrail:{self.region}:{self.audited_account}:trail"
|
self.trail_arn_template = f"arn:{self.audited_partition}:cloudtrail:{self.region}:{self.audited_account}:trail"
|
||||||
self.trails = {}
|
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)
|
self.__threading_call__(self._get_trails)
|
||||||
if self.trails:
|
if self.trails:
|
||||||
self._get_trail_status()
|
self._get_trail_status()
|
||||||
@@ -79,13 +82,16 @@ class Cloudtrail(AWSService):
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
|
self.trails_unavailable = True
|
||||||
if not self.trails:
|
if not self.trails:
|
||||||
self.trails = None
|
self.trails = None
|
||||||
else:
|
else:
|
||||||
|
self.trails_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
self.trails_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
|
|||||||
+41
-11
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_changes_to_network_acls_alarm_configured(Check):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=[
|
event_names=[
|
||||||
"CreateNetworkAcl",
|
"CreateNetworkAcl",
|
||||||
@@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_acls_alarm_configured(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_changes_to_network_gateways_alarm_configured(Check):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=[
|
event_names=[
|
||||||
"CreateCustomerGateway",
|
"CreateCustomerGateway",
|
||||||
@@ -34,16 +51,29 @@ class cloudwatch_changes_to_network_gateways_alarm_configured(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -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):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_source="ec2.amazonaws.com",
|
event_source="ec2.amazonaws.com",
|
||||||
event_names=[
|
event_names=[
|
||||||
@@ -36,16 +53,29 @@ class cloudwatch_changes_to_network_route_tables_alarm_configured(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_changes_to_vpcs_alarm_configured(Check):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=[
|
event_names=[
|
||||||
"CreateVpc",
|
"CreateVpc",
|
||||||
@@ -39,16 +56,29 @@ class cloudwatch_changes_to_vpcs_alarm_configured(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -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(
|
class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled(
|
||||||
Check
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_source="config.amazonaws.com",
|
event_source="config.amazonaws.com",
|
||||||
event_names=[
|
event_names=[
|
||||||
@@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -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(
|
class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled(
|
||||||
Check
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=[
|
event_names=[
|
||||||
"CreateTrail",
|
"CreateTrail",
|
||||||
@@ -35,16 +52,29 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_log_metric_filter_authentication_failures(Check):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=["ConsoleLogin"],
|
event_names=["ConsoleLogin"],
|
||||||
extra_clauses=[("errorMessage", "=", "Failed authentication")],
|
extra_clauses=[("errorMessage", "=", "Failed authentication")],
|
||||||
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_authentication_failures(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_log_metric_filter_aws_organizations_changes(Check):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_source="organizations.amazonaws.com",
|
event_source="organizations.amazonaws.com",
|
||||||
event_names=[
|
event_names=[
|
||||||
@@ -50,16 +67,29 @@ class cloudwatch_log_metric_filter_aws_organizations_changes(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -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):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_source="kms.amazonaws.com",
|
event_source="kms.amazonaws.com",
|
||||||
event_names=["DisableKey", "ScheduleKeyDeletion"],
|
event_names=["DisableKey", "ScheduleKeyDeletion"],
|
||||||
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Chec
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -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):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_source="s3.amazonaws.com",
|
event_source="s3.amazonaws.com",
|
||||||
event_names=[
|
event_names=[
|
||||||
@@ -38,16 +55,29 @@ class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_log_metric_filter_policy_changes(Check):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=[
|
event_names=[
|
||||||
"DeleteGroupPolicy",
|
"DeleteGroupPolicy",
|
||||||
@@ -44,16 +61,29 @@ class cloudwatch_log_metric_filter_policy_changes(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_log_metric_filter_root_usage(Check):
|
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.?"
|
pattern = r"\$\.userIdentity\.type\s*=\s*.?Root.+\$\.userIdentity\.invokedBy NOT EXISTS.+\$\.eventType\s*!=\s*.?AwsServiceEvent.?"
|
||||||
findings = []
|
findings = []
|
||||||
|
|
||||||
@@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_root_usage(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -13,7 +13,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_log_metric_filter_security_group_changes(Check):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=[
|
event_names=[
|
||||||
"AuthorizeSecurityGroupIngress",
|
"AuthorizeSecurityGroupIngress",
|
||||||
@@ -34,16 +51,29 @@ class cloudwatch_log_metric_filter_security_group_changes(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -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):
|
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(
|
pattern = build_metric_filter_pattern(
|
||||||
event_names=["ConsoleLogin"],
|
event_names=["ConsoleLogin"],
|
||||||
extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")],
|
extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")],
|
||||||
@@ -28,16 +45,29 @@ class cloudwatch_log_metric_filter_sign_in_without_mfa(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
+41
-11
@@ -12,7 +12,24 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
|
|||||||
|
|
||||||
|
|
||||||
class cloudwatch_log_metric_filter_unauthorized_api_calls(Check):
|
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\*.?"
|
pattern = r"\$\.errorCode\s*=\s*.?\*UnauthorizedOperation.+\$\.errorCode\s*=\s*.?AccessDenied\*.?"
|
||||||
findings = []
|
findings = []
|
||||||
|
|
||||||
@@ -24,16 +41,29 @@ class cloudwatch_log_metric_filter_unauthorized_api_calls(Check):
|
|||||||
self.metadata(),
|
self.metadata(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if cloudtrail_client.trails is not None:
|
inventory_unavailable = (
|
||||||
if report is None:
|
cloudtrail_client.trails_unavailable
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource={})
|
or logs_client.log_groups_unavailable
|
||||||
report.status = "FAIL"
|
or logs_client.metric_filters_unavailable
|
||||||
report.status_extended = "No CloudWatch log groups found with metric filters or alarms associated."
|
or cloudwatch_client.metric_alarms_unavailable
|
||||||
report.region = logs_client.region
|
)
|
||||||
report.resource_id = logs_client.audited_account
|
|
||||||
report.resource_arn = logs_client.log_group_arn_template
|
|
||||||
report.resource_tags = []
|
|
||||||
|
|
||||||
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
|
return findings
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class CloudWatch(AWSService):
|
|||||||
# Call AWSService's __init__
|
# Call AWSService's __init__
|
||||||
super().__init__(__class__.__name__, provider)
|
super().__init__(__class__.__name__, provider)
|
||||||
self.metric_alarms = []
|
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)
|
self.__threading_call__(self._describe_alarms)
|
||||||
if self.metric_alarms:
|
if self.metric_alarms:
|
||||||
self._list_tags_for_resource()
|
self._list_tags_for_resource()
|
||||||
@@ -56,13 +59,16 @@ class CloudWatch(AWSService):
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
|
self.metric_alarms_unavailable = True
|
||||||
if not self.metric_alarms:
|
if not self.metric_alarms:
|
||||||
self.metric_alarms = None
|
self.metric_alarms = None
|
||||||
else:
|
else:
|
||||||
|
self.metric_alarms_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
self.metric_alarms_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {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.
|
# index for cross-service evidence lookups.
|
||||||
self.all_log_groups = {}
|
self.all_log_groups = {}
|
||||||
self.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_groups_hydrated = set()
|
||||||
self.log_group_limit = get_resource_scan_limit(
|
self.log_group_limit = get_resource_scan_limit(
|
||||||
self.audit_config, "max_cloudwatch_log_groups"
|
self.audit_config, "max_cloudwatch_log_groups"
|
||||||
@@ -103,6 +112,9 @@ class Logs(AWSService):
|
|||||||
self.resource_policies = {}
|
self.resource_policies = {}
|
||||||
self.__threading_call__(self._describe_resource_policies)
|
self.__threading_call__(self._describe_resource_policies)
|
||||||
self.metric_filters = []
|
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)
|
self.__threading_call__(self._describe_metric_filters)
|
||||||
if self.log_groups:
|
if self.log_groups:
|
||||||
if (
|
if (
|
||||||
@@ -166,6 +178,9 @@ class Logs(AWSService):
|
|||||||
arn=arn,
|
arn=arn,
|
||||||
name=filter["filterName"],
|
name=filter["filterName"],
|
||||||
metric=filter["metricTransformations"][0]["metricName"],
|
metric=filter["metricTransformations"][0]["metricName"],
|
||||||
|
metric_namespace=filter["metricTransformations"][0].get(
|
||||||
|
"metricNamespace"
|
||||||
|
),
|
||||||
pattern=filter.get("filterPattern", ""),
|
pattern=filter.get("filterPattern", ""),
|
||||||
log_group=log_group,
|
log_group=log_group,
|
||||||
region=regional_client.region,
|
region=regional_client.region,
|
||||||
@@ -176,13 +191,16 @@ class Logs(AWSService):
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
|
self.metric_filters_unavailable = True
|
||||||
if not self.metric_filters:
|
if not self.metric_filters:
|
||||||
self.metric_filters = None
|
self.metric_filters = None
|
||||||
else:
|
else:
|
||||||
|
self.metric_filters_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
self.metric_filters_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
@@ -241,14 +259,17 @@ class Logs(AWSService):
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
|
self.log_groups_unavailable = True
|
||||||
if not self.log_groups:
|
if not self.log_groups:
|
||||||
self.all_log_groups = None
|
self.all_log_groups = None
|
||||||
self.log_groups = None
|
self.log_groups = None
|
||||||
else:
|
else:
|
||||||
|
self.log_groups_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
self.log_groups_unavailable = True
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
@@ -376,6 +397,7 @@ class MetricFilter(BaseModel):
|
|||||||
arn: str
|
arn: str
|
||||||
name: str
|
name: str
|
||||||
metric: str
|
metric: str
|
||||||
|
metric_namespace: Optional[str] = None
|
||||||
pattern: str
|
pattern: str
|
||||||
log_group: Optional[LogGroup] = None
|
log_group: Optional[LogGroup] = None
|
||||||
region: str
|
region: str
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ def check_cloudwatch_log_metric_filter(
|
|||||||
against each filter's pattern with ``re.DOTALL``.
|
against each filter's pattern with ``re.DOTALL``.
|
||||||
trails: CloudTrail trails keyed by ARN; only those with a log group count.
|
trails: CloudTrail trails keyed by ARN; only those with a log group count.
|
||||||
metric_filters: CloudWatch Logs metric filters to evaluate.
|
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.
|
metadata: check metadata for the emitted report.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -90,9 +91,20 @@ def check_cloudwatch_log_metric_filter(
|
|||||||
)
|
)
|
||||||
report.status = "FAIL"
|
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."
|
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:
|
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 = "PASS"
|
||||||
report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} and alarms set."
|
report.status_extended = f"CloudWatch log group {metric_filter.log_group.name} found with metric filter {metric_filter.name} and alarms set."
|
||||||
break
|
break
|
||||||
|
|||||||
+9
-6
@@ -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.
|
"""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
|
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
|
Trust anchors backed by an external ``CERTIFICATE_BUNDLE`` are reported as
|
||||||
FAIL because their certificate signature algorithm cannot be inspected
|
FAIL by design: the bundle is user-supplied rather than an AWS-managed CA,
|
||||||
from the IAM Roles Anywhere API alone.
|
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]:
|
def execute(self) -> list[Check_Report_AWS]:
|
||||||
@@ -56,13 +59,13 @@ class rolesanywhere_trust_anchor_pqc_pki(Check):
|
|||||||
"post-quantum (ML-DSA)."
|
"post-quantum (ML-DSA)."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
f"IAM Roles Anywhere trust anchor {trust_anchor.name} is "
|
f"IAM Roles Anywhere trust anchor {trust_anchor.name} is "
|
||||||
f"backed by Private CA {trust_anchor.acm_pca_arn}, which "
|
f"backed by Private CA {trust_anchor.acm_pca_arn}, which "
|
||||||
"could not be inspected (cross-account or missing "
|
"could not be inspected (cross-account or missing "
|
||||||
"acm-pca permissions). Verify the CA uses an ML-DSA key "
|
"acm-pca permissions). Verify manually that the CA uses "
|
||||||
"algorithm."
|
"an ML-DSA key algorithm."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
source = trust_anchor.source_type or "<none>"
|
source = trust_anchor.source_type or "<none>"
|
||||||
|
|||||||
+29
-5
@@ -3,12 +3,31 @@ from prowler.providers.aws.services.s3.s3_client import s3_client
|
|||||||
|
|
||||||
|
|
||||||
class s3_bucket_cross_region_replication(Check):
|
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 = []
|
findings = []
|
||||||
for bucket in s3_client.buckets.values():
|
for bucket in s3_client.buckets.values():
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource=bucket)
|
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 = "FAIL"
|
||||||
report.status_extended = f"S3 Bucket {bucket.name} does not have correct cross region replication configuration."
|
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:
|
if bucket.replication_rules:
|
||||||
for rule in bucket.replication_rules:
|
for rule in bucket.replication_rules:
|
||||||
if (
|
if (
|
||||||
@@ -17,8 +36,7 @@ class s3_bucket_cross_region_replication(Check):
|
|||||||
and rule.destination
|
and rule.destination
|
||||||
):
|
):
|
||||||
if rule.destination not in s3_client.buckets:
|
if rule.destination not in s3_client.buckets:
|
||||||
report.status = "FAIL"
|
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."
|
||||||
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."
|
|
||||||
else:
|
else:
|
||||||
destination_bucket = s3_client.buckets[rule.destination]
|
destination_bucket = s3_client.buckets[rule.destination]
|
||||||
if destination_bucket.region != bucket.region:
|
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}."
|
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
|
break
|
||||||
else:
|
else:
|
||||||
report.status = "FAIL"
|
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."
|
||||||
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."
|
# 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)
|
findings.append(report)
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
+18
-2
@@ -3,11 +3,27 @@ from prowler.providers.aws.services.s3.s3_client import s3_client
|
|||||||
|
|
||||||
|
|
||||||
class s3_bucket_object_versioning(Check):
|
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 = []
|
findings = []
|
||||||
for bucket in s3_client.buckets.values():
|
for bucket in s3_client.buckets.values():
|
||||||
report = Check_Report_AWS(metadata=self.metadata(), resource=bucket)
|
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 = "PASS"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
f"S3 Bucket {bucket.name} has versioning enabled."
|
f"S3 Bucket {bucket.name} has versioning enabled."
|
||||||
|
|||||||
@@ -122,10 +122,12 @@ class S3(AWSService):
|
|||||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
bucket.versioning_retrieved = False
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
bucket.versioning_retrieved = False
|
||||||
if bucket.region:
|
if bucket.region:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{bucket.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
@@ -441,10 +443,12 @@ class S3(AWSService):
|
|||||||
):
|
):
|
||||||
bucket.replication = None
|
bucket.replication = None
|
||||||
else:
|
else:
|
||||||
|
bucket.replication_retrieved = False
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
bucket.replication_retrieved = False
|
||||||
if regional_client:
|
if regional_client:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {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)
|
tags: List[Dict[str, str]] = Field(default_factory=list)
|
||||||
lifecycle: List[LifeCycleRule] = Field(default_factory=list)
|
lifecycle: List[LifeCycleRule] = Field(default_factory=list)
|
||||||
replication_rules: List[ReplicationRule] = 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)
|
notification_config: Dict = Field(default_factory=dict)
|
||||||
object_sampling: Optional[BucketObjectSampling] = None
|
object_sampling: Optional[BucketObjectSampling] = None
|
||||||
|
|||||||
+15
@@ -7,6 +7,21 @@ class entra_global_admin_in_less_than_five_users(Check):
|
|||||||
findings = []
|
findings = []
|
||||||
|
|
||||||
for tenant_domain, directory_roles in entra_client.directory_roles.items():
|
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(
|
report = Check_Report_Azure(
|
||||||
metadata=self.metadata(),
|
metadata=self.metadata(),
|
||||||
resource=directory_roles.get("Global Administrator", {}),
|
resource=directory_roles.get("Global Administrator", {}),
|
||||||
|
|||||||
+15
@@ -10,6 +10,21 @@ class entra_non_privileged_user_has_mfa(Check):
|
|||||||
findings = []
|
findings = []
|
||||||
|
|
||||||
for tenant_domain, users in entra_client.users.items():
|
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():
|
for user in users.values():
|
||||||
if user.account_enabled and not is_privileged_user(
|
if user.account_enabled and not is_privileged_user(
|
||||||
user, entra_client.directory_roles[tenant_domain]
|
user, entra_client.directory_roles[tenant_domain]
|
||||||
|
|||||||
+15
@@ -10,6 +10,21 @@ class entra_privileged_user_has_mfa(Check):
|
|||||||
findings = []
|
findings = []
|
||||||
|
|
||||||
for tenant_domain, users in entra_client.users.items():
|
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():
|
for user_domain_name, user in users.items():
|
||||||
if is_privileged_user(
|
if is_privileged_user(
|
||||||
user, entra_client.directory_roles[tenant_domain]
|
user, entra_client.directory_roles[tenant_domain]
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ class Entra(AzureService):
|
|||||||
"Cannot initialize Entra service while event loop is running"
|
"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
|
# Get users first alone because it is a dependency for other attributes
|
||||||
self.users = loop.run_until_complete(self._get_users())
|
self.users = loop.run_until_complete(self._get_users())
|
||||||
|
|
||||||
@@ -69,24 +81,76 @@ class Entra(AzureService):
|
|||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
async def _get_users(self):
|
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...")
|
logger.info("Entra - Getting users...")
|
||||||
users = {}
|
users = {}
|
||||||
|
base_select = ["id", "displayName", "accountEnabled"]
|
||||||
try:
|
try:
|
||||||
request_configuration = RequestConfiguration(
|
|
||||||
query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
|
|
||||||
select=[
|
|
||||||
"id",
|
|
||||||
"displayName",
|
|
||||||
"accountEnabled",
|
|
||||||
"signInActivity",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for tenant, client in self.clients.items():
|
for tenant, client in self.clients.items():
|
||||||
users.update({tenant: {}})
|
users.update({tenant: {}})
|
||||||
users_response = await client.users.get(
|
try:
|
||||||
request_configuration=request_configuration
|
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)
|
registration_details = await self._get_user_registration_details(client)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -124,8 +188,15 @@ class Entra(AzureService):
|
|||||||
users_response = await client.users.with_url(next_link).get()
|
users_response = await client.users.with_url(next_link).get()
|
||||||
|
|
||||||
except Exception as error:
|
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(
|
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:
|
except Exception as error:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -134,6 +205,21 @@ class Entra(AzureService):
|
|||||||
|
|
||||||
return users
|
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):
|
async def _get_user_registration_details(self, client):
|
||||||
registration_details = {}
|
registration_details = {}
|
||||||
try:
|
try:
|
||||||
|
|||||||
+1
-1
@@ -33,5 +33,5 @@
|
|||||||
],
|
],
|
||||||
"DependsOn": [],
|
"DependsOn": [],
|
||||||
"RelatedTo": [],
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-27
@@ -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.
|
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.
|
- 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: The enabled user has not signed in for more than 90 days, or has no recorded sign-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).
|
- 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 = []
|
findings = []
|
||||||
|
|
||||||
for tenant_domain, users in entra_client.users.items():
|
for tenant_domain, users in entra_client.users.items():
|
||||||
enabled_users = {k: v for k, v in users.items() if v.account_enabled}
|
if tenant_domain in entra_client.users_retrieval_errors:
|
||||||
|
report = Check_Report_Azure(metadata=self.metadata(), resource={})
|
||||||
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
|
|
||||||
)
|
|
||||||
report.subscription = f"Tenant: {tenant_domain}"
|
report.subscription = f"Tenant: {tenant_domain}"
|
||||||
report.resource_name = "Sign-in Activity Data"
|
report.resource_name = tenant_domain
|
||||||
count = len(enabled_users)
|
report.resource_id = entra_client.tenant_ids[0]
|
||||||
noun = "user" if count == 1 else "users"
|
report.status = "MANUAL"
|
||||||
report.status = "FAIL"
|
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
f"No sign-in activity data available for any of the "
|
f"Cannot evaluate sign-in activity for tenant {tenant_domain}: "
|
||||||
f"{count} enabled {noun}. This likely means the tenant "
|
f"Microsoft Graph did not return the tenant's users "
|
||||||
f"is missing Entra ID P1/P2 licensing or the required "
|
f"({entra_client.users_retrieval_errors[tenant_domain]}). "
|
||||||
f"Graph permissions to read sign-in activity."
|
f"Retry the scan or review the tenant's users manually."
|
||||||
)
|
)
|
||||||
findings.append(report)
|
findings.append(report)
|
||||||
continue
|
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 = Check_Report_Azure(metadata=self.metadata(), resource=user)
|
||||||
report.subscription = f"Tenant: {tenant_domain}"
|
report.subscription = f"Tenant: {tenant_domain}"
|
||||||
|
|
||||||
if user.last_sign_in is None:
|
if user.last_sign_in is None:
|
||||||
report.status = "FAIL"
|
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:
|
else:
|
||||||
last = user.last_sign_in
|
last = user.last_sign_in
|
||||||
if last.tzinfo is None:
|
if last.tzinfo is None:
|
||||||
|
|||||||
+16
-1
@@ -17,7 +17,22 @@ class entra_user_with_vm_access_has_mfa(Check):
|
|||||||
findings = []
|
findings = []
|
||||||
already_reported = set()
|
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 user in users.values():
|
||||||
for (
|
for (
|
||||||
subscription_id,
|
subscription_id,
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class GCPService:
|
|||||||
self.client = self.__generate_client__(
|
self.client = self.__generate_client__(
|
||||||
self.service, api_version, self.credentials
|
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
|
# Only project ids that have their API enabled will be scanned
|
||||||
if provider.skip_api_check:
|
if provider.skip_api_check:
|
||||||
self.project_ids = provider.project_ids
|
self.project_ids = provider.project_ids
|
||||||
@@ -69,10 +74,12 @@ class GCPService:
|
|||||||
if response.get("state") != "DISABLED":
|
if response.get("state") != "DISABLED":
|
||||||
project_ids.append(project_id)
|
project_ids.append(project_id)
|
||||||
else:
|
else:
|
||||||
|
self.api_disabled_project_ids.add(project_id)
|
||||||
logger.error(
|
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."
|
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:
|
except Exception as error:
|
||||||
|
self.api_state_unknown_project_ids.add(project_id)
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
|
|||||||
+63
-2
@@ -5,9 +5,28 @@ from prowler.providers.gcp.services.iam.accessapproval_client import (
|
|||||||
|
|
||||||
|
|
||||||
class iam_account_access_approval_enabled(Check):
|
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 = []
|
findings = []
|
||||||
for project_id in accessapproval_client.project_ids:
|
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(
|
report = Check_Report_GCP(
|
||||||
metadata=self.metadata(),
|
metadata=self.metadata(),
|
||||||
resource=accessapproval_client.projects[project_id],
|
resource=accessapproval_client.projects[project_id],
|
||||||
@@ -18,11 +37,53 @@ class iam_account_access_approval_enabled(Check):
|
|||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
f"Project {project_id} has Access Approval enabled."
|
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 = "FAIL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
f"Project {project_id} does not have Access Approval enabled."
|
f"Project {project_id} does not have Access Approval enabled."
|
||||||
)
|
)
|
||||||
findings.append(report)
|
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
|
return findings
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
from pydantic.v1 import BaseModel
|
from pydantic.v1 import BaseModel
|
||||||
|
|
||||||
from prowler.lib.logger import logger
|
from prowler.lib.logger import logger
|
||||||
@@ -219,6 +220,10 @@ class AccessApproval(GCPService):
|
|||||||
def __init__(self, provider: GcpProvider):
|
def __init__(self, provider: GcpProvider):
|
||||||
super().__init__(__class__.__name__, provider)
|
super().__init__(__class__.__name__, provider)
|
||||||
self.settings = {}
|
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()
|
self._get_settings()
|
||||||
|
|
||||||
def _get_settings(self):
|
def _get_settings(self):
|
||||||
@@ -234,7 +239,30 @@ class AccessApproval(GCPService):
|
|||||||
project_id=project_id,
|
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:
|
except Exception as error:
|
||||||
|
self.settings_lookup_failed.add(project_id)
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
|
|||||||
+29
-13
@@ -21,6 +21,8 @@ class defenderidentity_health_issues_no_open(Check):
|
|||||||
- PASS: The health issue has been resolved (status is not open).
|
- PASS: The health issue has been resolved (status is not open).
|
||||||
- FAIL: The health issue is open and requires attention.
|
- FAIL: The health issue is open and requires attention.
|
||||||
- FAIL: No sensors are deployed (MDI cannot protect the environment).
|
- 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]:
|
def execute(self) -> List[CheckReportM365]:
|
||||||
@@ -50,11 +52,12 @@ class defenderidentity_health_issues_no_open(Check):
|
|||||||
resource_name="Defender for Identity",
|
resource_name="Defender for Identity",
|
||||||
resource_id="defenderIdentity",
|
resource_id="defenderIdentity",
|
||||||
)
|
)
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
"Defender for Identity APIs are not accessible. "
|
"Cannot evaluate Defender for Identity health issues: the "
|
||||||
"Ensure the Service Principal has SecurityIdentitiesSensors.Read.All and "
|
"Defender for Identity APIs are not accessible. Ensure the "
|
||||||
"SecurityIdentitiesHealth.Read.All permissions granted."
|
"scanning application has the SecurityIdentitiesSensors.Read.All "
|
||||||
|
"and SecurityIdentitiesHealth.Read.All permissions granted."
|
||||||
)
|
)
|
||||||
findings.append(report)
|
findings.append(report)
|
||||||
return findings
|
return findings
|
||||||
@@ -67,11 +70,12 @@ class defenderidentity_health_issues_no_open(Check):
|
|||||||
resource_name="Defender for Identity",
|
resource_name="Defender for Identity",
|
||||||
resource_id="defenderIdentity",
|
resource_id="defenderIdentity",
|
||||||
)
|
)
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
f"Cannot read health issues from Defender for Identity "
|
f"Cannot evaluate Defender for Identity health issues "
|
||||||
f"(found {len(defenderidentity_client.sensors)} sensor(s) deployed). "
|
f"(found {len(defenderidentity_client.sensors)} sensor(s) deployed): "
|
||||||
"Ensure the Service Principal has SecurityIdentitiesHealth.Read.All permission."
|
"the health issues API is not accessible. Ensure the scanning "
|
||||||
|
"application has the SecurityIdentitiesHealth.Read.All permission granted."
|
||||||
)
|
)
|
||||||
findings.append(report)
|
findings.append(report)
|
||||||
return findings
|
return findings
|
||||||
@@ -93,7 +97,9 @@ class defenderidentity_health_issues_no_open(Check):
|
|||||||
findings.append(report)
|
findings.append(report)
|
||||||
return findings
|
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:
|
if not defenderidentity_client.health_issues:
|
||||||
report = CheckReportM365(
|
report = CheckReportM365(
|
||||||
metadata=self.metadata(),
|
metadata=self.metadata(),
|
||||||
@@ -101,10 +107,20 @@ class defenderidentity_health_issues_no_open(Check):
|
|||||||
resource_name="Defender for Identity",
|
resource_name="Defender for Identity",
|
||||||
resource_id="defenderIdentity",
|
resource_id="defenderIdentity",
|
||||||
)
|
)
|
||||||
report.status = "PASS"
|
if sensors_api_failed:
|
||||||
report.status_extended = (
|
report.status = "MANUAL"
|
||||||
"No open health issues found in Defender for Identity."
|
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)
|
findings.append(report)
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -25,6 +25,8 @@ class defenderxdr_critical_asset_management_pending_approvals(Check):
|
|||||||
Results:
|
Results:
|
||||||
- PASS: No pending approvals for Critical Asset Management are found.
|
- PASS: No pending approvals for Critical Asset Management are found.
|
||||||
- FAIL: At least one asset classification has pending approvals.
|
- 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]:
|
def execute(self) -> List[CheckReportM365]:
|
||||||
@@ -47,10 +49,13 @@ class defenderxdr_critical_asset_management_pending_approvals(Check):
|
|||||||
resource_name="Critical Asset Management",
|
resource_name="Critical Asset Management",
|
||||||
resource_id="criticalAssetManagement",
|
resource_id="criticalAssetManagement",
|
||||||
)
|
)
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
"Unable to query Critical Asset Management status. "
|
"Cannot evaluate Critical Asset Management pending approvals: "
|
||||||
"Verify that ThreatHunting.Read.All permission is granted."
|
"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)
|
findings.append(report)
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
+12
-7
@@ -25,6 +25,9 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check):
|
|||||||
Results:
|
Results:
|
||||||
- PASS: No exposed credentials found OR MDE enabled but no devices
|
- PASS: No exposed credentials found OR MDE enabled but no devices
|
||||||
- FAIL: Exposed credentials detected OR MDE not enabled (blind spot)
|
- 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]:
|
def execute(self) -> list[CheckReportM365]:
|
||||||
@@ -46,10 +49,12 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check):
|
|||||||
resource_name="Defender XDR",
|
resource_name="Defender XDR",
|
||||||
resource_id="mdeStatus",
|
resource_id="mdeStatus",
|
||||||
)
|
)
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
"Unable to query Microsoft Defender XDR status. "
|
"Cannot evaluate credential exposure for privileged users: "
|
||||||
"Verify that ThreatHunting.Read.All permission is granted."
|
"unable to query Microsoft Defender XDR Advanced Hunting. "
|
||||||
|
"Verify that the ThreatHunting.Read.All permission is granted "
|
||||||
|
"to the scanning application."
|
||||||
)
|
)
|
||||||
findings.append(report)
|
findings.append(report)
|
||||||
return findings
|
return findings
|
||||||
@@ -99,11 +104,11 @@ class defenderxdr_endpoint_privileged_user_exposed_credentials(Check):
|
|||||||
resource_name="Defender XDR",
|
resource_name="Defender XDR",
|
||||||
resource_id="exposedCredentials",
|
resource_id="exposedCredentials",
|
||||||
)
|
)
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
"Unable to query Security Exposure Management for exposed "
|
"Cannot evaluate credential exposure for privileged users: "
|
||||||
"credentials. Verify that Security Exposure Management "
|
"unable to query Security Exposure Management. Verify that "
|
||||||
"is enabled."
|
"Security Exposure Management is enabled in the tenant."
|
||||||
)
|
)
|
||||||
findings.append(report)
|
findings.append(report)
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
@@ -116,7 +116,13 @@ class DefenderXDR(M365Service):
|
|||||||
request_body
|
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
|
return [], False
|
||||||
|
|
||||||
results = [
|
results = [
|
||||||
@@ -200,11 +206,20 @@ ExposureGraphEdges
|
|||||||
TargetCategories = TargetNodeCategories
|
TargetCategories = TargetNodeCategories
|
||||||
"""
|
"""
|
||||||
|
|
||||||
results, _ = await self._run_hunting_query(query)
|
results, table_not_found = await self._run_hunting_query(query)
|
||||||
|
|
||||||
if results is None:
|
if results is None:
|
||||||
return 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]
|
return [self._parse_exposed_credential(row) for row in results if row]
|
||||||
|
|
||||||
def _parse_exposed_credential(self, row: Dict) -> "ExposedCredentialPrivilegedUser":
|
def _parse_exposed_credential(self, row: Dict) -> "ExposedCredentialPrivilegedUser":
|
||||||
@@ -253,11 +268,20 @@ ExposureGraphNodes
|
|||||||
| sort by Classification asc
|
| 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:
|
if results is None:
|
||||||
return 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 = []
|
pending_approvals = []
|
||||||
for row in results:
|
for row in results:
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
+1
-1
@@ -34,5 +34,5 @@
|
|||||||
],
|
],
|
||||||
"DependsOn": [],
|
"DependsOn": [],
|
||||||
"RelatedTo": [],
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-5
@@ -15,7 +15,8 @@ class entra_app_registration_no_unused_privileged_permissions(Check):
|
|||||||
|
|
||||||
- PASS: The app has no unused privileged permissions.
|
- PASS: The app has no unused privileged permissions.
|
||||||
- FAIL: The app has one or more unused privileged permissions that should be revoked.
|
- 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:
|
# InUse field values from OAuthAppInfo:
|
||||||
@@ -47,11 +48,12 @@ class entra_app_registration_no_unused_privileged_permissions(Check):
|
|||||||
resource_name="OAuth Applications",
|
resource_name="OAuth Applications",
|
||||||
resource_id="oauthApps",
|
resource_id="oauthApps",
|
||||||
)
|
)
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = (
|
report.status_extended = (
|
||||||
"OAuth App Governance data is unavailable. "
|
"Cannot evaluate unused privileged permissions: OAuth App "
|
||||||
"Enable App Governance in Microsoft Defender for Cloud Apps and "
|
"Governance data is unavailable. Enable App Governance in "
|
||||||
"grant ThreatHunting.Read.All to evaluate unused privileged permissions."
|
"Microsoft Defender for Cloud Apps and grant the "
|
||||||
|
"ThreatHunting.Read.All permission to the scanning application."
|
||||||
)
|
)
|
||||||
findings.append(report)
|
findings.append(report)
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
+29
-10
@@ -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.
|
- 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,
|
- 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.
|
- 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 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:
|
if not break_glass_user_ids:
|
||||||
report = CheckReportM365(
|
report = CheckReportM365(
|
||||||
metadata=self.metadata(),
|
metadata=self.metadata(),
|
||||||
@@ -73,6 +86,21 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
|
|||||||
findings.append(report)
|
findings.append(report)
|
||||||
return findings
|
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:
|
for user_id in break_glass_user_ids:
|
||||||
user = entra_client.users.get(user_id)
|
user = entra_client.users.get(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -85,15 +113,6 @@ class entra_break_glass_account_fido2_security_key_registered(Check):
|
|||||||
resource_id=user.id,
|
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)
|
auth_methods = set(user.authentication_methods)
|
||||||
has_fido2 = "fido2SecurityKey" in auth_methods
|
has_fido2 = "fido2SecurityKey" in auth_methods
|
||||||
has_passkey_device_bound = "passKeyDeviceBound" in auth_methods
|
has_passkey_device_bound = "passKeyDeviceBound" in auth_methods
|
||||||
|
|||||||
+14
-6
@@ -14,7 +14,10 @@ class entra_seamless_sso_disabled(Check):
|
|||||||
Primary Refresh Token (PRT) support make this feature unnecessary for most organizations.
|
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).
|
- 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]:
|
def execute(self) -> List[CheckReportM365]:
|
||||||
@@ -38,9 +41,9 @@ class entra_seamless_sso_disabled(Check):
|
|||||||
resource_id=organization.id,
|
resource_id=organization.id,
|
||||||
resource_name=organization.name,
|
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:
|
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}."
|
report.status_extended = f"Cannot verify Seamless SSO status for {organization.name}: {entra_client.directory_sync_error}."
|
||||||
else:
|
else:
|
||||||
report.status = "PASS"
|
report.status = "PASS"
|
||||||
@@ -66,7 +69,8 @@ class entra_seamless_sso_disabled(Check):
|
|||||||
|
|
||||||
findings.append(report)
|
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:
|
if not entra_client.directory_sync_settings:
|
||||||
for organization in entra_client.organizations:
|
for organization in entra_client.organizations:
|
||||||
report = CheckReportM365(
|
report = CheckReportM365(
|
||||||
@@ -75,8 +79,12 @@ class entra_seamless_sso_disabled(Check):
|
|||||||
resource_id=organization.id,
|
resource_id=organization.id,
|
||||||
resource_name=organization.name,
|
resource_name=organization.name,
|
||||||
)
|
)
|
||||||
report.status = "PASS"
|
if organization.on_premises_sync_enabled:
|
||||||
report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable."
|
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)
|
findings.append(report)
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ class Entra(M365Service):
|
|||||||
self.tenant_domain = provider.identity.tenant_domain
|
self.tenant_domain = provider.identity.tenant_domain
|
||||||
self.tenant_id = getattr(provider.identity, "tenant_id", None)
|
self.tenant_id = getattr(provider.identity, "tenant_id", None)
|
||||||
self.user_registration_details_error: Optional[str] = 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
|
self.exchange_mailbox_permission_service_principals_error: Optional[str] = None
|
||||||
attributes = loop.run_until_complete(
|
attributes = loop.run_until_complete(
|
||||||
gather(
|
gather(
|
||||||
@@ -924,7 +928,7 @@ class Entra(M365Service):
|
|||||||
except ODataError as error:
|
except ODataError as error:
|
||||||
error_code = getattr(error.error, "code", None) if error.error else None
|
error_code = getattr(error.error, "code", None) if error.error else None
|
||||||
if error_code == "Authorization_RequestDenied":
|
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(
|
logger.error(
|
||||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}"
|
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}"
|
||||||
)
|
)
|
||||||
@@ -941,6 +945,19 @@ class Entra(M365Service):
|
|||||||
return directory_sync_settings, error_message
|
return directory_sync_settings, error_message
|
||||||
|
|
||||||
async def _get_users(self):
|
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...")
|
logger.info("Entra - Getting users...")
|
||||||
users = {}
|
users = {}
|
||||||
try:
|
try:
|
||||||
@@ -1026,7 +1043,17 @@ class Entra(M365Service):
|
|||||||
if not next_link:
|
if not next_link:
|
||||||
break
|
break
|
||||||
users_response = await self.client.users.with_url(next_link).get()
|
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:
|
except Exception as error:
|
||||||
|
self.users_error = f"Unable to retrieve users from Microsoft Graph ({error.__class__.__name__})"
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||||
)
|
)
|
||||||
@@ -1124,7 +1151,15 @@ OAuthAppInfo
|
|||||||
request_body
|
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:
|
for row in result.results:
|
||||||
row_data = row.additional_data
|
row_data = row.additional_data
|
||||||
raw_app_id = row_data.get("OAuthAppId", "")
|
raw_app_id = row_data.get("OAuthAppId", "")
|
||||||
|
|||||||
+21
-9
@@ -17,8 +17,9 @@ class entra_users_mfa_capable(Check):
|
|||||||
evaluation.
|
evaluation.
|
||||||
|
|
||||||
- PASS: The member user is MFA capable.
|
- PASS: The member user is MFA capable.
|
||||||
- FAIL: The member user is not MFA capable, or MFA capability cannot be
|
- FAIL: The member user is not MFA capable.
|
||||||
verified due to insufficient permissions to read user registration details.
|
- MANUAL: Users or their registration details could not be read
|
||||||
|
(insufficient permissions), so MFA capability cannot be verified.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def execute(self) -> List[CheckReportM365]:
|
def execute(self) -> List[CheckReportM365]:
|
||||||
@@ -37,6 +38,23 @@ class entra_users_mfa_capable(Check):
|
|||||||
"""
|
"""
|
||||||
findings = []
|
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():
|
for user in entra_client.users.values():
|
||||||
if user.user_type == "Guest" or not user.account_enabled:
|
if user.user_type == "Guest" or not user.account_enabled:
|
||||||
continue
|
continue
|
||||||
@@ -57,13 +75,7 @@ class entra_users_mfa_capable(Check):
|
|||||||
resource_id=user.id,
|
resource_id=user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if entra_client.user_registration_details_error:
|
if not user.is_mfa_capable:
|
||||||
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:
|
|
||||||
report.status = "FAIL"
|
report.status = "FAIL"
|
||||||
report.status_extended = f"User {user.name} is not MFA capable."
|
report.status_extended = f"User {user.name} is not MFA capable."
|
||||||
else:
|
else:
|
||||||
|
|||||||
+22
-2
@@ -15,6 +15,9 @@ class exchange_shared_mailbox_sign_in_disabled(Check):
|
|||||||
|
|
||||||
- PASS: Shared mailbox has sign-in blocked (AccountEnabled = False in Entra ID).
|
- PASS: Shared mailbox has sign-in blocked (AccountEnabled = False in Entra ID).
|
||||||
- FAIL: Shared mailbox has sign-in enabled (AccountEnabled = True 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]:
|
def execute(self) -> List[CheckReportM365]:
|
||||||
@@ -30,6 +33,23 @@ class exchange_shared_mailbox_sign_in_disabled(Check):
|
|||||||
"""
|
"""
|
||||||
findings = []
|
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:
|
for shared_mailbox in exchange_client.shared_mailboxes:
|
||||||
report = CheckReportM365(
|
report = CheckReportM365(
|
||||||
metadata=self.metadata(),
|
metadata=self.metadata(),
|
||||||
@@ -45,8 +65,8 @@ class exchange_shared_mailbox_sign_in_disabled(Check):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not entra_user:
|
if not entra_user:
|
||||||
report.status = "FAIL"
|
report.status = "MANUAL"
|
||||||
report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} could not be found in Entra ID for verification."
|
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:
|
elif entra_user.account_enabled:
|
||||||
report.status = "FAIL"
|
report.status = "FAIL"
|
||||||
report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} has sign-in enabled."
|
report.status_extended = f"Shared mailbox {shared_mailbox.user_principal_name} has sign-in enabled."
|
||||||
|
|||||||
@@ -181,7 +181,25 @@ Examples:
|
|||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `PASS` | Resource is compliant |
|
| `PASS` | Resource is compliant |
|
||||||
| `FAIL` | Resource is non-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]
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -265,11 +265,11 @@ class Test_bedrock_agent_role_least_privilege:
|
|||||||
|
|
||||||
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
|
@mock_aws(config={"iam": {"load_aws_managed_policies": True}})
|
||||||
def test_agent_role_not_resolvable(self):
|
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(
|
result = _run_check(
|
||||||
role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist"
|
role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert "could not be resolved" in result[0].status_extended
|
assert "could not be resolved" in result[0].status_extended
|
||||||
|
|||||||
+3
-1
@@ -670,10 +670,12 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured:
|
|||||||
)
|
)
|
||||||
|
|
||||||
cloudtrail_client.trails = None
|
cloudtrail_client.trails = None
|
||||||
|
cloudtrail_client.trails_unavailable = True
|
||||||
check = cloudwatch_changes_to_network_acls_alarm_configured()
|
check = cloudwatch_changes_to_network_acls_alarm_configured()
|
||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 0
|
assert len(result) == 1
|
||||||
|
assert result[0].status == "MANUAL"
|
||||||
|
|
||||||
@mock_aws
|
@mock_aws
|
||||||
def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses(
|
def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses(
|
||||||
|
|||||||
+3
-1
@@ -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()
|
cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled()
|
||||||
)
|
)
|
||||||
cloudtrail_client.trails = None
|
cloudtrail_client.trails = None
|
||||||
|
cloudtrail_client.trails_unavailable = True
|
||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 0
|
assert len(result) == 1
|
||||||
|
assert result[0].status == "MANUAL"
|
||||||
|
|
||||||
@mock_aws
|
@mock_aws
|
||||||
def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses(
|
def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses(
|
||||||
|
|||||||
+331
@@ -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:*"
|
== 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
|
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].log_group is None
|
||||||
assert logs.metric_filters[0].name == "test-filter"
|
assert logs.metric_filters[0].name == "test-filter"
|
||||||
assert logs.metric_filters[0].metric == "my-metric"
|
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].pattern == "test-pattern"
|
||||||
assert logs.metric_filters[0].region == AWS_REGION_US_EAST_1
|
assert logs.metric_filters[0].region == AWS_REGION_US_EAST_1
|
||||||
|
|
||||||
@@ -535,3 +536,39 @@ class Test_build_metric_filter_pattern:
|
|||||||
event_names=["ConsoleLogin"],
|
event_names=["ConsoleLogin"],
|
||||||
extra_clauses=[("errorMessage", bad_operator, "Failed authentication")],
|
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.
|
"""Build a metric filter whose pattern always matches PATTERN.
|
||||||
|
|
||||||
Args:
|
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
|
log_group: the collected LogGroup, or None to model a filter whose log
|
||||||
group was never retrieved -- the input that used to raise.
|
group was never retrieved -- the input that used to raise.
|
||||||
metric: metric name an alarm has to carry for the filter to be compliant.
|
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(
|
return MetricFilter(
|
||||||
arn=f"arn:aws:logs:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:metric-filter/{name}",
|
arn=f"arn:aws:logs:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:metric-filter/{name}",
|
||||||
name=name,
|
name=name,
|
||||||
metric=metric,
|
metric=metric,
|
||||||
|
metric_namespace=namespace,
|
||||||
pattern=FILTER_PATTERN,
|
pattern=FILTER_PATTERN,
|
||||||
log_group=log_group,
|
log_group=log_group,
|
||||||
region=AWS_REGION,
|
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.
|
"""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
|
The check pairs alarms to filters by metric name and region (and namespace
|
||||||
filter uses is how a filter with no alarm of its own is expressed.
|
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(
|
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",
|
name=f"{metric}-alarm",
|
||||||
metric=metric,
|
metric=metric,
|
||||||
name_space="CloudTrailMetrics",
|
name_space=namespace,
|
||||||
region=AWS_REGION,
|
region=region,
|
||||||
alarm_actions=[f"arn:aws:sns:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:topic-test"],
|
alarm_actions=[f"arn:aws:sns:{region}:{AWS_ACCOUNT_NUMBER}:topic-test"],
|
||||||
actions_enabled=True,
|
actions_enabled=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,3 +151,52 @@ class Test_check_cloudwatch_log_metric_filter:
|
|||||||
report.status_extended
|
report.status_extended
|
||||||
== f"CloudWatch log group {TRAIL_LOG_GROUP_NAME} found with metric filter trail-filter but no alarms associated."
|
== 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"
|
||||||
|
|||||||
+1
-1
@@ -185,7 +185,7 @@ class Test_rolesanywhere_trust_anchor_pqc_pki:
|
|||||||
|
|
||||||
result = rolesanywhere_trust_anchor_pqc_pki().execute()
|
result = rolesanywhere_trust_anchor_pqc_pki().execute()
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert "could not be inspected" in result[0].status_extended
|
assert "could not be inspected" in result[0].status_extended
|
||||||
|
|
||||||
def test_certificate_bundle_source(self):
|
def test_certificate_bundle_source(self):
|
||||||
|
|||||||
+92
-2
@@ -1,6 +1,8 @@
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from boto3 import client
|
from boto3 import client
|
||||||
|
from botocore.client import BaseClient
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
from moto import mock_aws
|
from moto import mock_aws
|
||||||
|
|
||||||
from tests.providers.aws.utils import (
|
from tests.providers.aws.utils import (
|
||||||
@@ -9,6 +11,20 @@ from tests.providers.aws.utils import (
|
|||||||
set_mocked_aws_provider,
|
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:
|
class Test_s3_bucket_cross_region_replication:
|
||||||
# No Buckets
|
# No Buckets
|
||||||
@@ -598,10 +614,10 @@ class Test_s3_bucket_cross_region_replication:
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
# US-EAST-1 Source Bucket
|
# US-EAST-1 Source Bucket
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
assert (
|
||||||
result[0].status_extended
|
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 result[0].resource_id == bucket_name_us
|
||||||
assert (
|
assert (
|
||||||
@@ -609,3 +625,77 @@ class Test_s3_bucket_cross_region_replication:
|
|||||||
== f"arn:{aws_provider.identity.partition}:s3:::{bucket_name_us}"
|
== f"arn:{aws_provider.identity.partition}:s3:::{bucket_name_us}"
|
||||||
)
|
)
|
||||||
assert result[0].region == AWS_REGION_US_EAST_1
|
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
|
||||||
|
|||||||
+53
-1
@@ -1,13 +1,18 @@
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
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:
|
class Test_entra_global_admin_in_less_than_five_users:
|
||||||
def test_entra_no_tenants(self):
|
def test_entra_no_tenants(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_tenant_empty(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_less_than_five_global_admins(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_more_than_five_global_admins(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_exactly_five_global_admins(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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].subscription == f"Tenant: {DOMAIN}"
|
||||||
assert result[0].resource_name == "Global Administrator"
|
assert result[0].resource_name == "Global Administrator"
|
||||||
assert result[0].resource_id == id
|
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]
|
||||||
|
|||||||
+46
-1
@@ -1,13 +1,18 @@
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
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:
|
class Test_entra_non_privileged_user_has_mfa:
|
||||||
def test_entra_no_tenants(self):
|
def test_entra_no_tenants(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_tenant_no_users(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_user_no_privileged_no_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -102,6 +109,7 @@ class Test_entra_non_privileged_user_has_mfa:
|
|||||||
def test_entra_user_no_privileged_mfa(self):
|
def test_entra_user_no_privileged_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -147,6 +155,7 @@ class Test_entra_non_privileged_user_has_mfa:
|
|||||||
def test_entra_disabled_user_no_privileged_no_mfa(self):
|
def test_entra_disabled_user_no_privileged_no_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -188,6 +197,7 @@ class Test_entra_non_privileged_user_has_mfa:
|
|||||||
def test_entra_disabled_user_no_privileged_mfa(self):
|
def test_entra_disabled_user_no_privileged_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -229,6 +239,7 @@ class Test_entra_non_privileged_user_has_mfa:
|
|||||||
def test_entra_user_privileged_no_mfa(self):
|
def test_entra_user_privileged_no_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -271,6 +282,7 @@ class Test_entra_non_privileged_user_has_mfa:
|
|||||||
def test_entra_user_privileged_mfa(self):
|
def test_entra_user_privileged_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -309,3 +321,36 @@ class Test_entra_non_privileged_user_has_mfa:
|
|||||||
check = entra_non_privileged_user_has_mfa()
|
check = entra_non_privileged_user_has_mfa()
|
||||||
result = check.execute()
|
result = check.execute()
|
||||||
assert len(result) == 0
|
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]
|
||||||
|
|||||||
+44
-1
@@ -1,13 +1,18 @@
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
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:
|
class Test_entra_privileged_user_has_mfa:
|
||||||
def test_entra_no_tenants(self):
|
def test_entra_no_tenants(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_tenant_no_users(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"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):
|
def test_entra_user_no_privileged_no_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -94,6 +101,7 @@ class Test_entra_privileged_user_has_mfa:
|
|||||||
def test_entra_user_no_privileged_mfa(self):
|
def test_entra_user_no_privileged_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -134,6 +142,7 @@ class Test_entra_privileged_user_has_mfa:
|
|||||||
def test_entra_user_privileged_no_mfa(self):
|
def test_entra_user_privileged_no_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -181,6 +190,7 @@ class Test_entra_privileged_user_has_mfa:
|
|||||||
def test_entra_user_privileged_mfa(self):
|
def test_entra_user_privileged_mfa(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -224,3 +234,36 @@ class Test_entra_privileged_user_has_mfa:
|
|||||||
assert result[0].resource_name == "foo"
|
assert result[0].resource_name == "foo"
|
||||||
assert result[0].resource_id == user_id
|
assert result[0].resource_id == user_id
|
||||||
assert result[0].subscription == f"Tenant: {DOMAIN}"
|
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-2"].account_enabled is True
|
||||||
assert users["tenant-1"]["user-3"].is_mfa_capable is False
|
assert users["tenant-1"]["user-3"].is_mfa_capable is False
|
||||||
assert users["tenant-1"]["user-3"].account_enabled is True
|
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
|
||||||
|
|||||||
+121
-11
@@ -2,12 +2,21 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
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:
|
class Test_entra_user_with_recent_sign_in:
|
||||||
def test_entra_no_tenants(self):
|
def test_entra_no_tenants(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
|
entra_client.sign_in_activity_errors = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
|
entra_client.tenant_ids = [TENANT_ID]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
@@ -31,6 +40,9 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
|
|
||||||
def test_entra_user_disabled(self):
|
def test_entra_user_disabled(self):
|
||||||
entra_client = mock.MagicMock
|
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())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -63,6 +75,9 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
|
|
||||||
def test_entra_user_never_signed_in(self):
|
def test_entra_user_never_signed_in(self):
|
||||||
entra_client = mock.MagicMock
|
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())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -93,10 +108,13 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
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 = mock.MagicMock
|
||||||
|
entra_client.sign_in_activity_errors = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
|
entra_client.tenant_ids = [TENANT_ID]
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -127,11 +145,13 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
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
|
||||||
assert "1 enabled user" in result[0].status_extended
|
|
||||||
|
|
||||||
def test_entra_user_stale_sign_in(self):
|
def test_entra_user_stale_sign_in(self):
|
||||||
entra_client = mock.MagicMock
|
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())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -166,6 +186,9 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
|
|
||||||
def test_entra_user_recent_sign_in(self):
|
def test_entra_user_recent_sign_in(self):
|
||||||
entra_client = mock.MagicMock
|
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())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -198,8 +221,11 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
assert result[0].status == "PASS"
|
assert result[0].status == "PASS"
|
||||||
assert "10 days ago" in result[0].status_extended
|
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 = mock.MagicMock
|
||||||
|
entra_client.sign_in_activity_errors = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
|
entra_client.tenant_ids = [TENANT_ID]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
@@ -231,14 +257,63 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
|
|
||||||
check = entra_user_with_recent_sign_in()
|
check = entra_user_with_recent_sign_in()
|
||||||
result = check.execute()
|
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 len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert "Entra ID P1/P2 licensing" in result[0].status_extended
|
assert "Entra ID P1/P2" in result[0].status_extended
|
||||||
assert "5 enabled users" 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):
|
def test_entra_user_never_signed_in_when_telemetry_exists_for_tenant(self):
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
|
entra_client.sign_in_activity_errors = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
|
entra_client.tenant_ids = [TENANT_ID]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
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
|
r.status == "PASS" and "5 days ago" in r.status_extended for r in result
|
||||||
)
|
)
|
||||||
assert any(
|
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
|
for r in result
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_entra_user_boundary_90_days(self):
|
def test_entra_user_boundary_90_days(self):
|
||||||
entra_client = mock.MagicMock
|
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())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -319,3 +398,34 @@ class Test_entra_user_with_recent_sign_in:
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "PASS"
|
assert result[0].status == "PASS"
|
||||||
assert "90 days ago" in result[0].status_extended
|
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
|
||||||
|
|||||||
+51
@@ -7,6 +7,7 @@ from tests.providers.azure.azure_fixtures import (
|
|||||||
AZURE_SUBSCRIPTION_ID,
|
AZURE_SUBSCRIPTION_ID,
|
||||||
AZURE_SUBSCRIPTION_NAME,
|
AZURE_SUBSCRIPTION_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
TENANT_IDS,
|
||||||
set_mocked_azure_provider,
|
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}
|
iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||||
with (
|
with (
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||||
return_value=set_mocked_azure_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(
|
mock.patch(
|
||||||
"prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.iam_client",
|
"prowler.providers.azure.services.entra.entra_user_with_vm_access_has_mfa.entra_user_with_vm_access_has_mfa.iam_client",
|
||||||
new=iam_client,
|
new=iam_client,
|
||||||
@@ -47,6 +53,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
|
|||||||
role_assigment_id = str(uuid4())
|
role_assigment_id = str(uuid4())
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
@@ -120,6 +127,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
|
|||||||
role_assigment_id = str(uuid4())
|
role_assigment_id = str(uuid4())
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
@@ -193,6 +201,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
|
|||||||
role_assigment_id = str(uuid4())
|
role_assigment_id = str(uuid4())
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||||
user_id = str(uuid4())
|
user_id = str(uuid4())
|
||||||
|
|
||||||
@@ -249,6 +258,7 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa:
|
|||||||
role_assigment_id = str(uuid4())
|
role_assigment_id = str(uuid4())
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.resource_groups = {}
|
entra_client.resource_groups = {}
|
||||||
|
entra_client.users_retrieval_errors = {}
|
||||||
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
entra_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME}
|
||||||
user_id = str(uuid4())
|
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()
|
check = entra_user_with_vm_access_has_mfa()
|
||||||
result = check.execute()
|
result = check.execute()
|
||||||
assert len(result) == 0
|
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]
|
||||||
|
|||||||
+132
@@ -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:
|
class Test_iam_account_access_approval_enabled:
|
||||||
def test_iam_no_settings(self):
|
def test_iam_no_settings(self):
|
||||||
accessapproval_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.settings = {}
|
accessapproval_client.settings = {}
|
||||||
accessapproval_client.project_ids = [GCP_PROJECT_ID]
|
accessapproval_client.project_ids = [GCP_PROJECT_ID]
|
||||||
accessapproval_client.region = "global"
|
accessapproval_client.region = "global"
|
||||||
@@ -51,6 +54,9 @@ class Test_iam_account_access_approval_enabled:
|
|||||||
def test_iam_project_with_settings(self):
|
def test_iam_project_with_settings(self):
|
||||||
cloudresourcemanager_client = mock.MagicMock()
|
cloudresourcemanager_client = mock.MagicMock()
|
||||||
accessapproval_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.project_ids = [GCP_PROJECT_ID]
|
||||||
accessapproval_client.region = "global"
|
accessapproval_client.region = "global"
|
||||||
accessapproval_client.projects = {
|
accessapproval_client.projects = {
|
||||||
@@ -103,6 +109,9 @@ class Test_iam_account_access_approval_enabled:
|
|||||||
def test_iam_project_with_settings_empty_project_name(self):
|
def test_iam_project_with_settings_empty_project_name(self):
|
||||||
cloudresourcemanager_client = mock.MagicMock()
|
cloudresourcemanager_client = mock.MagicMock()
|
||||||
accessapproval_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.project_ids = [GCP_PROJECT_ID]
|
||||||
accessapproval_client.region = "global"
|
accessapproval_client.region = "global"
|
||||||
accessapproval_client.projects = {
|
accessapproval_client.projects = {
|
||||||
@@ -151,3 +160,126 @@ class Test_iam_account_access_approval_enabled:
|
|||||||
assert result[0].resource_name == "GCP Project"
|
assert result[0].resource_name == "GCP Project"
|
||||||
assert result[0].project_id == GCP_PROJECT_ID
|
assert result[0].project_id == GCP_PROJECT_ID
|
||||||
assert result[0].location == "global"
|
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 datetime import datetime
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import (
|
from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import (
|
||||||
CloudResourceManager,
|
CloudResourceManager,
|
||||||
@@ -113,6 +113,118 @@ class TestAccessApproval:
|
|||||||
access_approval_client.settings[GCP_PROJECT_ID].project_id
|
access_approval_client.settings[GCP_PROJECT_ID].project_id
|
||||||
== GCP_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:
|
class TestEssentialContacts:
|
||||||
|
|||||||
+37
-4
@@ -94,7 +94,7 @@ class Test_defenderidentity_health_issues_no_open:
|
|||||||
assert result[0].resource_id == "defenderIdentity"
|
assert result[0].resource_id == "defenderIdentity"
|
||||||
|
|
||||||
def test_both_apis_failed(self):
|
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 = mock.MagicMock()
|
||||||
defenderidentity_client.audited_tenant = "audited_tenant"
|
defenderidentity_client.audited_tenant = "audited_tenant"
|
||||||
defenderidentity_client.audited_domain = DOMAIN
|
defenderidentity_client.audited_domain = DOMAIN
|
||||||
@@ -120,7 +120,7 @@ class Test_defenderidentity_health_issues_no_open:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
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 "APIs are not accessible" in result[0].status_extended
|
||||||
assert "SecurityIdentitiesSensors.Read.All" in result[0].status_extended
|
assert "SecurityIdentitiesSensors.Read.All" in result[0].status_extended
|
||||||
assert "SecurityIdentitiesHealth.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()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert "Cannot read health issues" in result[0].status_extended
|
assert (
|
||||||
|
"Cannot evaluate Defender for Identity health issues"
|
||||||
|
in result[0].status_extended
|
||||||
|
)
|
||||||
assert "1 sensor(s) deployed" 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 "SecurityIdentitiesHealth.Read.All" in result[0].status_extended
|
||||||
assert result[0].resource == {}
|
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_id == health_issue_id
|
||||||
assert result[0].resource_name == health_issue_name
|
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
|
||||||
|
|||||||
+4
-3
@@ -10,7 +10,7 @@ class Test_defenderxdr_critical_asset_management_pending_approvals:
|
|||||||
"""Tests for the defenderxdr_critical_asset_management_pending_approvals check."""
|
"""Tests for the defenderxdr_critical_asset_management_pending_approvals check."""
|
||||||
|
|
||||||
def test_api_failed_missing_permission(self):
|
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 = mock.MagicMock()
|
||||||
defenderxdr_client.audited_tenant = "audited_tenant"
|
defenderxdr_client.audited_tenant = "audited_tenant"
|
||||||
defenderxdr_client.audited_domain = DOMAIN
|
defenderxdr_client.audited_domain = DOMAIN
|
||||||
@@ -34,9 +34,10 @@ class Test_defenderxdr_critical_asset_management_pending_approvals:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
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 "ThreatHunting.Read.All" in result[0].status_extended
|
||||||
assert result[0].resource_id == "criticalAssetManagement"
|
assert result[0].resource_id == "criticalAssetManagement"
|
||||||
|
|||||||
+9
-6
@@ -7,7 +7,7 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
|
|||||||
"""Tests for the defenderxdr_endpoint_privileged_user_exposed_credentials check."""
|
"""Tests for the defenderxdr_endpoint_privileged_user_exposed_credentials check."""
|
||||||
|
|
||||||
def test_mde_status_api_failed(self):
|
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 = mock.MagicMock()
|
||||||
defenderxdr_client.audited_tenant = "audited_tenant"
|
defenderxdr_client.audited_tenant = "audited_tenant"
|
||||||
defenderxdr_client.audited_domain = DOMAIN
|
defenderxdr_client.audited_domain = DOMAIN
|
||||||
@@ -32,8 +32,11 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert "Unable to query Microsoft Defender XDR" in result[0].status_extended
|
assert (
|
||||||
|
"unable to query Microsoft Defender XDR Advanced Hunting"
|
||||||
|
in result[0].status_extended
|
||||||
|
)
|
||||||
assert "ThreatHunting.Read.All" in result[0].status_extended
|
assert "ThreatHunting.Read.All" in result[0].status_extended
|
||||||
assert result[0].resource_id == "mdeStatus"
|
assert result[0].resource_id == "mdeStatus"
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
|
|||||||
assert result[0].resource_id == "mdeDevices"
|
assert result[0].resource_id == "mdeDevices"
|
||||||
|
|
||||||
def test_exposed_credentials_query_failed(self):
|
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 = mock.MagicMock()
|
||||||
defenderxdr_client.audited_tenant = "audited_tenant"
|
defenderxdr_client.audited_tenant = "audited_tenant"
|
||||||
defenderxdr_client.audited_domain = DOMAIN
|
defenderxdr_client.audited_domain = DOMAIN
|
||||||
@@ -128,9 +131,9 @@ class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
assert (
|
||||||
"Unable to query Security Exposure Management"
|
"unable to query Security Exposure Management"
|
||||||
in result[0].status_extended
|
in result[0].status_extended
|
||||||
)
|
)
|
||||||
assert result[0].resource_id == "exposedCredentials"
|
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
|
||||||
+3
-3
@@ -45,7 +45,7 @@ class Test_entra_app_registration_no_unused_privileged_permissions:
|
|||||||
assert result[0].resource_id == "oauthApps"
|
assert result[0].resource_id == "oauthApps"
|
||||||
|
|
||||||
def test_no_oauth_apps_none(self):
|
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 = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
@@ -70,10 +70,10 @@ class Test_entra_app_registration_no_unused_privileged_permissions:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
assert (
|
||||||
result[0].status_extended
|
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 == {}
|
||||||
assert result[0].resource_name == "OAuth Applications"
|
assert result[0].resource_name == "OAuth Applications"
|
||||||
|
|||||||
+39
-26
@@ -67,6 +67,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -105,6 +106,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -144,6 +146,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -181,6 +184,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -232,6 +236,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -280,6 +285,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -327,6 +333,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -375,6 +382,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -430,6 +438,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -466,6 +475,7 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -512,10 +522,11 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
assert result[0].resource_name == "BreakGlass1"
|
assert result[0].resource_name == "BreakGlass1"
|
||||||
|
|
||||||
def test_user_registration_details_permission_error(self):
|
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 = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
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"
|
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -551,28 +562,27 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
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
|
in result[0].status_extended
|
||||||
)
|
)
|
||||||
assert "AuditLog.Read.All" in result[0].status_extended
|
assert "AuditLog.Read.All" in result[0].status_extended
|
||||||
assert result[0].resource_name == "BreakGlass1"
|
assert result[0].resource_name == "Break Glass Accounts"
|
||||||
assert result[0].resource_id == bg_user_id
|
assert result[0].resource_id == "breakGlassAccounts"
|
||||||
|
|
||||||
def test_user_registration_details_permission_error_with_missing_user(self):
|
def test_user_registration_details_permission_error_multiple_users(self):
|
||||||
"""Per-user emission and missing-user short-circuit on the error path.
|
"""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
|
Two break-glass users are excluded from all CAPs and both are present in
|
||||||
present in ``entra_client.users``. With ``user_registration_details_error``
|
``entra_client.users``. With ``user_registration_details_error`` set the
|
||||||
set, the present user must produce one preventive FAIL anchored to the
|
check must emit a single tenant-level MANUAL finding instead of one
|
||||||
real user; the missing user must be skipped by the existing
|
per break-glass account.
|
||||||
``if not user: continue`` guard rather than crash or yield a synthetic
|
|
||||||
finding.
|
|
||||||
"""
|
"""
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
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"
|
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -590,37 +600,40 @@ class Test_entra_break_glass_account_fido2_security_key_registered:
|
|||||||
)
|
)
|
||||||
|
|
||||||
policy_id = str(uuid4())
|
policy_id = str(uuid4())
|
||||||
present_user_id = str(uuid4())
|
first_user_id = str(uuid4())
|
||||||
missing_user_id = str(uuid4())
|
second_user_id = str(uuid4())
|
||||||
|
|
||||||
entra_client.conditional_access_policies = {
|
entra_client.conditional_access_policies = {
|
||||||
policy_id: _make_policy(
|
policy_id: _make_policy(
|
||||||
policy_id,
|
policy_id,
|
||||||
excluded_users=[present_user_id, missing_user_id],
|
excluded_users=[first_user_id, second_user_id],
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
entra_client.users = {
|
entra_client.users = {
|
||||||
present_user_id: User(
|
first_user_id: User(
|
||||||
id=present_user_id,
|
id=first_user_id,
|
||||||
name="BreakGlass1",
|
name="BreakGlass1",
|
||||||
on_premises_sync_enabled=False,
|
on_premises_sync_enabled=False,
|
||||||
authentication_methods=[],
|
authentication_methods=[],
|
||||||
),
|
),
|
||||||
# missing_user_id intentionally absent — exercises the
|
second_user_id: User(
|
||||||
# `if not user: continue` short-circuit inside the loop.
|
id=second_user_id,
|
||||||
|
name="BreakGlass2",
|
||||||
|
on_premises_sync_enabled=False,
|
||||||
|
authentication_methods=[],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
check = entra_break_glass_account_fido2_security_key_registered()
|
check = entra_break_glass_account_fido2_security_key_registered()
|
||||||
result = check.execute()
|
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 len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
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
|
in result[0].status_extended
|
||||||
)
|
)
|
||||||
assert "AuditLog.Read.All" 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 == "Break Glass Accounts"
|
||||||
assert result[0].resource_name == "BreakGlass1"
|
assert result[0].resource_id == "breakGlassAccounts"
|
||||||
assert result[0].resource_id == present_user_id
|
|
||||||
|
|||||||
+38
-2
@@ -169,7 +169,7 @@ class Test_entra_seamless_sso_disabled:
|
|||||||
assert result[0].resource_name == "Cloud Only Org"
|
assert result[0].resource_name == "Cloud Only Org"
|
||||||
|
|
||||||
def test_insufficient_permissions_error(self):
|
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()
|
entra_client = mock.MagicMock()
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -199,7 +199,7 @@ class Test_entra_seamless_sso_disabled:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
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 "Cannot verify Seamless SSO status" in result[0].status_extended
|
||||||
assert "Insufficient privileges" in result[0].status_extended
|
assert "Insufficient privileges" in result[0].status_extended
|
||||||
assert (
|
assert (
|
||||||
@@ -272,3 +272,39 @@ class Test_entra_seamless_sso_disabled:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 0
|
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"
|
||||||
|
|||||||
+61
-18
@@ -12,6 +12,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -55,6 +56,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -98,6 +100,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -157,6 +160,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -196,6 +200,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -254,6 +259,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -293,6 +299,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -332,6 +339,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -372,6 +380,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -414,6 +423,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -459,6 +469,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -519,6 +530,7 @@ class Test_entra_users_mfa_capable:
|
|||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
entra_client.audited_domain = DOMAIN
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.user_registration_details_error = None
|
entra_client.user_registration_details_error = None
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -559,10 +571,11 @@ class Test_entra_users_mfa_capable:
|
|||||||
assert result[0].resource_id == user_id
|
assert result[0].resource_id == user_id
|
||||||
|
|
||||||
def test_user_registration_details_permission_error(self):
|
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 = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
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"
|
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -595,26 +608,27 @@ class Test_entra_users_mfa_capable:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
assert (
|
||||||
"Cannot verify MFA capability for user Test User"
|
"Cannot verify MFA capability for member users"
|
||||||
in result[0].status_extended
|
in result[0].status_extended
|
||||||
)
|
)
|
||||||
assert "AuditLog.Read.All" 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 == "Entra Users"
|
||||||
assert result[0].resource_name == "Test User"
|
assert result[0].resource_id == "users"
|
||||||
assert result[0].resource_id == user_id
|
|
||||||
|
|
||||||
def test_user_registration_details_permission_error_skips_guest_and_disabled(self):
|
def test_user_registration_details_permission_error_with_mixed_users(self):
|
||||||
"""CIS-scope skip (Guest, disabled) still applies on the permission-error path.
|
"""The permission-error path emits a single tenant-level MANUAL finding.
|
||||||
|
|
||||||
With ``user_registration_details_error`` set, only enabled member users
|
With ``user_registration_details_error`` set, no per-user findings are
|
||||||
should receive a per-user "Cannot verify MFA capability" FAIL — guests
|
produced (a missing permission is not a per-user security issue): a
|
||||||
and disabled members are filtered out before the error branch runs.
|
single MANUAL finding is emitted regardless of the guest/member/disabled
|
||||||
|
mix of users in the tenant.
|
||||||
"""
|
"""
|
||||||
entra_client = mock.MagicMock
|
entra_client = mock.MagicMock
|
||||||
entra_client.audited_tenant = "audited_tenant"
|
entra_client.audited_tenant = "audited_tenant"
|
||||||
entra_client.audited_domain = DOMAIN
|
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"
|
entra_client.user_registration_details_error = "Insufficient privileges to read user registration details. Required permission: AuditLog.Read.All"
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -667,15 +681,44 @@ class Test_entra_users_mfa_capable:
|
|||||||
check = entra_users_mfa_capable()
|
check = entra_users_mfa_capable()
|
||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
# Only the enabled member should be reported — Guest and
|
# A single tenant-level MANUAL finding is emitted regardless of
|
||||||
# disabled member are skipped before the error branch.
|
# how many users exist; no per-user findings are produced.
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
assert (
|
||||||
"Cannot verify MFA capability for user Enabled Member"
|
"Cannot verify MFA capability for member users"
|
||||||
in result[0].status_extended
|
in result[0].status_extended
|
||||||
)
|
)
|
||||||
assert "AuditLog.Read.All" 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 == "Entra Users"
|
||||||
assert result[0].resource_name == "Enabled Member"
|
assert result[0].resource_id == "users"
|
||||||
assert result[0].resource_id == member_id
|
|
||||||
|
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"]
|
assert [policy.id for policy in policies] == ["policy-1", "policy-2"]
|
||||||
with_url_mock.assert_called_once_with("next-link")
|
with_url_mock.assert_called_once_with("next-link")
|
||||||
next_page_builder.get.assert_awaited_once()
|
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
|
||||||
|
|||||||
+66
-4
@@ -11,6 +11,8 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
|
|||||||
exchange_client.shared_mailboxes = []
|
exchange_client.shared_mailboxes = []
|
||||||
|
|
||||||
entra_client = mock.MagicMock()
|
entra_client = mock.MagicMock()
|
||||||
|
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.users = {}
|
entra_client.users = {}
|
||||||
|
|
||||||
with (
|
with (
|
||||||
@@ -80,6 +82,7 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
|
|||||||
account_enabled=False,
|
account_enabled=False,
|
||||||
)
|
)
|
||||||
entra_client = mock.MagicMock()
|
entra_client = mock.MagicMock()
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.users = {
|
entra_client.users = {
|
||||||
"12345678-1234-1234-1234-123456789012": entra_user,
|
"12345678-1234-1234-1234-123456789012": entra_user,
|
||||||
}
|
}
|
||||||
@@ -143,6 +146,7 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
|
|||||||
account_enabled=True,
|
account_enabled=True,
|
||||||
)
|
)
|
||||||
entra_client = mock.MagicMock()
|
entra_client = mock.MagicMock()
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.users = {
|
entra_client.users = {
|
||||||
"87654321-4321-4321-4321-210987654321": entra_user,
|
"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]
|
exchange_client.shared_mailboxes = [shared_mailbox]
|
||||||
|
|
||||||
entra_client = mock.MagicMock()
|
entra_client = mock.MagicMock()
|
||||||
|
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.users = {}
|
entra_client.users = {}
|
||||||
|
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
@@ -209,10 +215,10 @@ class Test_exchange_shared_mailbox_sign_in_disabled:
|
|||||||
result = check.execute()
|
result = check.execute()
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].status == "FAIL"
|
assert result[0].status == "MANUAL"
|
||||||
assert (
|
assert (
|
||||||
result[0].status_extended
|
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_name == "Orphan Mailbox"
|
||||||
assert result[0].resource_id == "00000000-0000-0000-0000-000000000000"
|
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 = mock.MagicMock()
|
||||||
|
|
||||||
|
entra_client.users_error = None
|
||||||
entra_client.users = {
|
entra_client.users = {
|
||||||
"11111111-1111-1111-1111-111111111111": user_disabled,
|
"11111111-1111-1111-1111-111111111111": user_disabled,
|
||||||
"22222222-2222-2222-2222-222222222222": user_enabled,
|
"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."
|
== "Shared mailbox insecure@contoso.com has sign-in enabled."
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result[2].status == "FAIL"
|
assert result[2].status == "MANUAL"
|
||||||
assert (
|
assert (
|
||||||
result[2].status_extended
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user