Back to Checks
aws

Account monitors VPC route table changes with a CloudWatch Logs metric filter and alarm

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

VPC route table changes are captured from CloudTrail logs by a CloudWatch Logs metric filter with an associated alarm for events like CreateRoute, CreateRouteTable, ReplaceRoute, ReplaceRouteTableAssociation, DeleteRoute, DeleteRouteTable, and DisassociateRouteTable.

Risk

Without monitoring of route table changes, unauthorized or accidental edits can redirect traffic, bypass inspection, or blackhole routes, impacting confidentiality (exfiltration), integrity (tampered paths), and availability (outages from misrouted traffic).

Run this check with Prowler

Run in Prowler Cloud
Run
prowler aws --checks cloudwatch_changes_to_network_route_tables_alarm_configured

Recommendation

Implement a CloudWatch Logs metric filter and alarm on CloudTrail for these route table events and notify responders. Enforce least privilege for route modifications, require change control, and apply defense in depth with VPC Flow Logs and guardrails to prevent and quickly contain unsafe routing changes.

Remediation

Terraform

# Metric filter + alarm for VPC route table changes
resource "aws_cloudwatch_log_metric_filter" "routes" {
  name           = "<example_resource_name>"
  log_group_name = "<example_resource_name>"
  # CRITICAL: Detect EC2 route table change events in CloudTrail logs
  pattern = "{($.eventSource = ec2.amazonaws.com) && (($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable))}"

  metric_transformation {
    name      = "<example_resource_name>"
    namespace = "<example_resource_name>"
    value     = "1"
  }
}

resource "aws_cloudwatch_metric_alarm" "routes" {
  alarm_name          = "<example_resource_name>"
  # CRITICAL: Alarm targets the metric from the filter above
  metric_name         = "<example_resource_name>"
  namespace           = "<example_resource_name>"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = 1
  period              = 300
  statistic           = "Sum"
  threshold           = 1
}

NativeIaC

# CloudFormation: Metric filter + alarm for VPC route table changes
Resources:
  RouteTableChangeMetricFilter:
    Type: AWS::Logs::MetricFilter
    Properties:
      LogGroupName: "<example_resource_name>"
      # CRITICAL: Detect EC2 route table change events in CloudTrail logs
      # Includes eventSource and the required eventNames
      FilterPattern: '{($.eventSource = ec2.amazonaws.com) && (($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable))}'
      MetricTransformations:
        - MetricValue: "1"
          MetricNamespace: "<example_resource_name>"
          MetricName: "<example_resource_name>"

  RouteTableChangeAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      # CRITICAL: Alarm on the metric from the filter above
      Namespace: "<example_resource_name>"
      MetricName: "<example_resource_name>"
      ComparisonOperator: GreaterThanOrEqualToThreshold
      EvaluationPeriods: 1
      Period: 300
      Statistic: Sum
      Threshold: 1

Other

1In the AWS console, open CloudWatch > Log groups and select your CloudTrail log group
2Go to Metric filters > Create metric filter
3Set Filter pattern to:
4{($.eventSource = ec2.amazonaws.com) && (($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable))}
5Name the metric and set Metric value to 1; choose any namespace/name
6Create the filter
7From the filter, click Create alarm
8Set Statistic: Sum, Period: 5 minutes, Threshold type: Static, Threshold: 1, Whenever: Greater/Equal
9Create the alarm (notifications optional)

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_changes_to_network_route_tables_alarm_configured(Check):
    """Account monitors VPC route table changes with a CloudWatch Logs metric filter and alarm.

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

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

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

        Returns:
            list[Check_Report_AWS]: A single report for the account.
        """
        pattern = build_metric_filter_pattern(
            event_source="ec2.amazonaws.com",
            event_names=[
                "CreateRoute",
                "CreateRouteTable",
                "ReplaceRoute",
                "ReplaceRouteTableAssociation",
                "DeleteRouteTable",
                "DeleteRoute",
                "DisassociateRouteTable",
            ],
        )
        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