Back to blog
AWSCloud SecurityMonitoring & LoggingNetworkingOperations & Compliance

No Alarm for Network ACL Changes (CIS 3.11)

Learn why AWS needs a CloudWatch alarm for network ACL changes (CIS 3.11), the risks of skipping it, and step-by-step CLI and Terraform fixes.

TL;DR

This check flags AWS accounts that lack a CloudWatch alarm for network ACL changes (CIS 3.11). Without it, an attacker or careless engineer can open subnet-level traffic rules with no one noticing. Fix it by piping CloudTrail logs to a CloudWatch metric filter and wiring an alarm to an SNS topic.

Network ACLs (NACLs) are the stateless firewalls that sit at the subnet boundary in your VPC. They are a coarse but powerful control: a single rule change can expose or isolate every instance in a subnet. The problem is that NACL edits are quiet by default. Nothing alerts you when an entry is added, removed, or reordered. This Lensix check, account_alarm_naclchanges, confirms that your account has monitoring in place so those changes do not slip by unseen.


What this check detects

The check looks for a CloudWatch alarm backed by a CloudWatch Logs metric filter that matches network ACL API calls in your CloudTrail event stream. Specifically, it expects coverage for these operations:

  • CreateNetworkAcl
  • CreateNetworkAclEntry
  • DeleteNetworkAcl
  • DeleteNetworkAclEntry
  • ReplaceNetworkAclEntry
  • ReplaceNetworkAclAssociation

If no metric filter matches these events, or a filter exists but has no alarm attached to it, the check fails. This maps directly to CIS AWS Foundations Benchmark control 3.11.

Note: NACLs are stateless, which means return traffic must be explicitly allowed by a separate rule. They differ from security groups, which are stateful and attach to individual network interfaces. Because a NACL applies to an entire subnet, the blast radius of a single change is much larger.


Why it matters

Imagine an attacker who has gained access to a set of IAM credentials with EC2 networking permissions. Adding an inbound allow rule to a NACL is an easy way to widen access to a subnet full of databases or internal services. If you have no alarm, that change lands silently and you only discover it during an incident review weeks later, if at all.

The risk is not only malicious. Plenty of real outages start with a well-meaning engineer who edits a NACL during a late-night debugging session, forgets it is stateless, and accidentally blocks ephemeral return ports for an entire tier. An alarm gives you a timestamped, actionable signal the moment that happens.

Concrete reasons this control earns its place:

  • Detection speed. Subnet-wide exposure or blackholing is something you want to know about in minutes, not days.
  • Audit and compliance. CIS, SOC 2, PCI DSS, and HIPAA assessors all expect change monitoring on network controls. A failing 3.11 is a common audit finding.
  • Attribution. The alarm fires with the CloudTrail event, which carries the principal, source IP, and timestamp. That makes incident response far faster.

Warning: This check depends on a multi-region CloudTrail trail that delivers logs to a CloudWatch Logs group. If CloudTrail is not feeding CloudWatch Logs, the metric filter has nothing to read and the alarm will never fire, even if it exists. Verify the upstream trail first.


How to fix it

The fix has three pieces: an SNS topic with a subscription, a CloudWatch Logs metric filter on your CloudTrail log group, and a CloudWatch alarm. The commands below assume you already have a CloudTrail trail delivering to a log group named aws-cloudtrail-logs.

Step 1: Create an SNS topic and subscribe to it

aws sns create-topic --name lensix-nacl-changes

aws sns subscribe \
  --topic-arn arn:aws:sns:us-east-1:111122223333:lensix-nacl-changes \
  --protocol email \
  --notification-endpoint [email protected]

Confirm the subscription from the email AWS sends before moving on, otherwise notifications will not be delivered.

Step 2: Create the metric filter

This pattern matches every NACL-related event name in the CloudTrail log group:

aws logs put-metric-filter \
  --log-group-name aws-cloudtrail-logs \
  --filter-name NaclChanges \
  --filter-pattern '{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }' \
  --metric-transformations \
      metricName=NaclChangeCount,metricNamespace=CISBenchmark,metricValue=1,defaultValue=0

Step 3: Create the alarm

aws cloudwatch put-metric-alarm \
  --alarm-name nacl-changes \
  --alarm-description "Alarm for network ACL changes (CIS 3.11)" \
  --metric-name NaclChangeCount \
  --namespace CISBenchmark \
  --statistic Sum \
  --period 300 \
  --threshold 1 \
  --comparison-operator GreaterThanOrEqualToThreshold \
  --evaluation-periods 1 \
  --treat-missing-data notBreaching \
  --alarm-actions arn:aws:sns:us-east-1:111122223333:lensix-nacl-changes

Once these three resources exist and the trail is feeding CloudWatch Logs, the alarm will transition to ALARM within minutes of any NACL change and publish to your SNS topic.

Tip: CIS 3.11 belongs to a family of metric-filter alarms (3.1 through 3.14) that all follow the same shape. Rather than building each one by hand, manage them as a single reusable module so adding the next control is a one-line change.

Terraform version

If you manage infrastructure as code, this is the cleaner path. The module below creates all three resources together:

resource "aws_sns_topic" "nacl_changes" {
  name = "lensix-nacl-changes"
}

resource "aws_sns_topic_subscription" "nacl_email" {
  topic_arn = aws_sns_topic.nacl_changes.arn
  protocol  = "email"
  endpoint  = "[email protected]"
}

resource "aws_cloudwatch_log_metric_filter" "nacl_changes" {
  name           = "NaclChanges"
  log_group_name = "aws-cloudtrail-logs"

  pattern = <<-PATTERN
    { ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }
  PATTERN

  metric_transformation {
    name          = "NaclChangeCount"
    namespace     = "CISBenchmark"
    value         = "1"
    default_value = "0"
  }
}

resource "aws_cloudwatch_metric_alarm" "nacl_changes" {
  alarm_name          = "nacl-changes"
  alarm_description   = "Alarm for network ACL changes (CIS 3.11)"
  namespace           = "CISBenchmark"
  metric_name         = aws_cloudwatch_log_metric_filter.nacl_changes.metric_transformation[0].name
  statistic           = "Sum"
  period              = 300
  threshold           = 1
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = 1
  treat_missing_data  = "notBreaching"
  alarm_actions       = [aws_sns_topic.nacl_changes.arn]
}

Danger: Do not respond to a NACL change alert by reflexively deleting or reverting the entry in production. A NACL change may be legitimate, and rolling it back without context can blackhole a live subnet. Confirm the change against your change records and the CloudTrail principal before acting.


How to prevent it from happening again

Setting the alarm once is not enough. Accounts get created, trails get reconfigured, and someone eventually deletes a metric filter while cleaning up. Bake the control into your delivery process so it stays in place.

  • Make it a module. Package the SNS topic, metric filter, and alarm as a Terraform module and apply it to every account through your landing zone or account factory. New accounts inherit the control automatically.
  • Gate it in CI. Run a policy-as-code scan on every pull request that touches infrastructure. Open Policy Agent with conftest or Checkov can both assert that the NACL alarm resources exist before a plan is allowed to merge.
  • Use a Service Control Policy as backstop. An SCP cannot create an alarm, but it can deny deletion of the CloudTrail trail and log group that the alarm depends on, which closes the most common way the control gets quietly broken.

Here is a small Checkov-style custom check expressed as an OPA rule that fails a plan missing the NACL alarm metric name:

package cisbenchmark

deny[msg] {
  not has_nacl_alarm
  msg := "Missing CloudWatch alarm for network ACL changes (CIS 3.11)"
}

has_nacl_alarm {
  some r
  input.resource.aws_cloudwatch_metric_alarm[r].metric_name == "NaclChangeCount"
}

Tip: Lensix re-runs account_alarm_naclchanges on a schedule, so if someone removes the alarm after merge, you get a finding without waiting for the next audit. Pair the CI gate with continuous scanning to catch both build-time and drift-time gaps.


Best practices

  • Centralize alarm notifications. Point the SNS topic at a single security channel, then fan out to email, Slack, or PagerDuty. One place to manage subscriptions beats per-account inboxes.
  • Cover the whole CIS 3.x family at once. NACL changes are one of fourteen monitoring controls. Deploy them together so you do not end up with partial coverage that passes some audits and fails others.
  • Aggregate logging across regions. Use a multi-region trail and a single CloudWatch Logs group so a change in any region is caught. A trail scoped to one region leaves blind spots.
  • Tag and review the alarm itself. Add an alarm description that names the CIS control, and review the SNS subscription quarterly to make sure the on-call destination is still valid.
  • Reduce noise with context. When the alarm fires, enrich the alert with the CloudTrail event detail so responders see who made the change and from where, not just that a change happened.

NACL change monitoring is cheap to set up and pays for itself the first time it catches an unexpected subnet-level edit. Get the alarm in place, manage it as code, and let continuous scanning confirm it stays that way.