Back to Checks
aws

CloudWatch Logs metric filter and alarm exist for IAM policy changes

cloudwatch_log_metric_filter_policy_changes
Service: cloudwatchSeverity: medium
OfficialBuilt-inVerified
Categories:logging
Resource Type:AwsCloudWatchAlarm

CloudWatch uses a metric filter and alarm to track IAM policy changes recorded by CloudTrail (e.g., CreatePolicy, DeletePolicy, version changes, inline policy edits, policy attach/detach). This finding reflects whether that filter and an associated alarm are present on the trail's log group.

Risk

Absent alerting on IAM policy changes, privilege modifications can go unnoticed, enabling privilege escalation, hidden backdoors, or permission revocations. This threatens confidentiality and integrity, and may impact availability if critical access is removed or misconfigured.

Run this check with Prowler

Run in Prowler Cloud
Run
prowler aws --checks cloudwatch_log_metric_filter_policy_changes

Recommendation

Create a metric filter for IAM policy create/update/delete and attach/detach events with an alarm to notify responders.

  • Enforce least privilege and separation of duties for policy changes
  • Require approvals and central logging across Regions/accounts
  • Integrate alerts with incident response

Remediation

Terraform

# Terraform: Metric filter and alarm for IAM policy changes
resource "aws_cloudwatch_log_metric_filter" "<example_resource_name>" {
  name           = "<example_resource_name>"
  log_group_name = "<example_resource_name>"  # CloudTrail log group

  # CRITICAL: Pattern matching IAM policy change events required by the check
  pattern = "{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}"

  metric_transformation {
    name      = "<example_resource_name>"      # CRITICAL: Metric created from filter
    namespace = "CISBenchmark"                 # CRITICAL: Namespace for the metric
    value     = "1"
  }
}

resource "aws_cloudwatch_metric_alarm" "<example_resource_name>" {
  alarm_name          = "<example_resource_name>"
  # CRITICAL: Alarm on the metric when >= 1 event occurs
  metric_name         = aws_cloudwatch_log_metric_filter.<example_resource_name>.metric_transformation[0].name
  namespace           = aws_cloudwatch_log_metric_filter.<example_resource_name>.metric_transformation[0].namespace
  statistic           = "Sum"
  period              = 300
  evaluation_periods  = 1
  threshold           = 1
  comparison_operator = "GreaterThanOrEqualToThreshold"
}

NativeIaC

# CloudFormation: Create metric filter and alarm for IAM policy changes
Resources:
  IAMPolicyChangeMetricFilter:
    Type: AWS::Logs::MetricFilter
    Properties:
      LogGroupName: <example_resource_name>  # IMPORTANT: CloudTrail log group to monitor
      # CRITICAL: Pattern matching IAM policy change events required by the check
      FilterPattern: '{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}'
      MetricTransformations:
        - MetricName: <example_resource_name>   # CRITICAL: Metric created from filter
          MetricNamespace: CISBenchmark        # CRITICAL: Namespace for the metric
          MetricValue: "1"

  IAMPolicyChangeAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: <example_resource_name>
      # CRITICAL: Alarm on the metric created above when >= 1 event occurs
      MetricName: <example_resource_name>
      Namespace: CISBenchmark
      Statistic: Sum
      Period: 300
      EvaluationPeriods: 1
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold

Other

1Open the CloudWatch console > Logs > Log groups and select the CloudTrail log group
2Create metric filter:
3- Filter pattern: {($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}
4- Metric name: <example_resource_name>
5- Namespace: CISBenchmark
6- Metric value: 1
7On the Metric filters tab, select the new filter and choose Create alarm
8Set: Statistic=Sum, Period=5 minutes, Threshold type=Static, Greater/Equal, Threshold=1, Evaluation periods=1
9Create the alarm

Check Code

from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.cloudtrail.cloudtrail_client import (
    cloudtrail_client,
)
from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
    cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
    build_metric_filter_pattern,
    check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client


class cloudwatch_log_metric_filter_policy_changes(Check):
    """CloudWatch Logs metric filter and alarm exist for IAM policy changes.

    Looks for a CloudWatch Logs metric filter matching the expected pattern on a
    log group used by a CloudTrail trail, with at least one alarm on its metric.

    - PASS: A matching metric filter with an associated alarm exists.
    - FAIL: No matching metric filter, or a filter without an alarm, was found.
    - MANUAL: CloudTrail trails, log groups, metric filters or alarms could
      not be listed in at least one region, so the absence of a filter/alarm
      cannot be asserted.
    """

    def execute(self) -> list[Check_Report_AWS]:
        """Evaluate the metric filter and alarm coverage for the account.

        Returns:
            list[Check_Report_AWS]: A single report for the account.
        """
        pattern = build_metric_filter_pattern(
            event_names=[
                "DeleteGroupPolicy",
                "DeleteRolePolicy",
                "DeleteUserPolicy",
                "PutGroupPolicy",
                "PutRolePolicy",
                "PutUserPolicy",
                "CreatePolicy",
                "DeletePolicy",
                "CreatePolicyVersion",
                "DeletePolicyVersion",
                "AttachRolePolicy",
                "DetachRolePolicy",
                "AttachUserPolicy",
                "DetachUserPolicy",
                "AttachGroupPolicy",
                "DetachGroupPolicy",
            ],
        )
        findings = []

        report = check_cloudwatch_log_metric_filter(
            pattern,
            cloudtrail_client.trails,
            logs_client.metric_filters,
            cloudwatch_client.metric_alarms,
            self.metadata(),
        )

        inventory_unavailable = (
            cloudtrail_client.trails_unavailable
            or logs_client.log_groups_unavailable
            or logs_client.metric_filters_unavailable
            or cloudwatch_client.metric_alarms_unavailable
        )

        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