AWS Network Hardening

Overview

This page covers Amazon Web Services network hardening across the surfaces that decide whether an attacker who reaches the network edge can pivot inward, exfiltrate data, or sustain disruption. Scope is the AWS commercial regions; AWS GovCloud (US) and the China regions inherit the same controls but expose a different region table and a different STS partition (for example endpoints under amazonaws-us-gov.com) — re-verify region-table caveats before applying any of the IaC below to a non-commercial partition. CIS sub-IDs and NIST / ISO mappings throughout this page reference the AWS commercial benchmark unless explicitly annotated as a post-v3.0.0 feature or a best-practice recommendation that the current benchmark has not yet codified.

The AWS network model is the product of VPCs (regional, RFC1918-addressed virtual networks), subnets (AZ-scoped CIDR slices with a single route table), route tables (deciding whether a subnet is public, private, or isolated), Internet Gateways and NAT Gateways (controlled north-south paths), Security Groups (stateful ENI-attached firewalls), Network ACLs (stateless subnet-attached firewalls), and VPC endpoints (Gateway and Interface flavours that keep AWS-service traffic on the private fabric). The cross-cutting principles — segmentation, default-deny, private connectivity, egress filtering, DNS integrity — are explained in the General Network page; this page maps them to AWS primitives. Severity assignments follow the rubric documented in methodology; equivalence callouts at the bottom of each control point to the matching control on the Azure, GCP, and OCI sibling pages so a reader can compare modelling across providers, and the compliance-frameworks page describes why each control row carries the same seven framework columns.

Two anti-conflation callouts up front, because both pairs get conflated in audit reports and architecture reviews and the distinction matters for control design. First: Network ACLs and Security Groups are complementary, not alternatives. Security Groups are stateful and ENI-scoped (covered as aws-net-02); NACLs are stateless and subnet-scoped (covered as aws-net-03). They sit at different layers of the packet path and have different failure modes — a misconfigured SG rule can be neutralised by an explicit-deny at the NACL, and vice versa. Reviewers who insist on "pick one" are wrong; pick both, with different roles. Second: AWS WAFv2 and AWS Shield Advanced are complementary, not alternatives. WAFv2 is an L7 inspection engine that filters HTTP and HTTPS payloads (covered as aws-net-06); Shield Advanced is an L3/L4 volumetric-attack mitigation tier with 24/7 Shield Response Team engagement (covered as aws-net-07). One filters application-layer abuse; the other absorbs network-layer flooding. The bundled price tag and the overlapping product page in the AWS console obscure the architectural distinction — they answer different threat-model questions and need separate controls.

Order matters. Controls 01–04 are foundational invariants: remove the default VPC, lock admin ports against the internet, build defence-in-depth at the subnet layer, and enable VPC Block Public Access so future IGW attachment cannot silently turn a private subnet public. Controls 05 takes private connectivity off the public internet via VPC endpoints. Controls 06–07 protect internet-facing entry points at L7 and L3/L4 respectively. Control 08 ensures authoritative DNS for the organisation's public zones is integrity-signed. Control 09 closes the egress loop: even if east-west is locked down, an unfiltered NAT Gateway is a one-way exfiltration channel for any compromised workload.

aws-net-01-default-vpc-removed ! MEDIUM PREVENTIVE

Delete the default VPC in every region of every AWS account and deploy explicit non-default VPCs per workload. The default VPC ships in every region with a /16 CIDR (172.31.0.0/16), one public subnet per AZ, an Internet Gateway already attached, and a default route to the IGW — none of which match a hardened landing-zone design (AWS VPC User Guide — default VPC and subnets (accessed 2026-05)). The principle is reinforced in the General Network — segmentation section: a network the organisation did not consciously design is a network whose blast radius the organisation cannot reason about.

Remediation — AWS CLI

# Inventory: list every region's default VPC.
for region in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  aws ec2 describe-vpcs \
    --region "$region" \
    --filters Name=is-default,Values=true \
    --query 'Vpcs[].[VpcId]' --output text \
    | awk -v r="$region" 'NF{print r"\t"$1}'
done

# Per region: detach IGW, delete subnets/route-tables/IGW/VPC. Delete the VPC last.
aws ec2 delete-vpc --region eu-west-1 --vpc-id vpc-0abc123def4567890

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
# Organisation-wide SCP denying CreateDefaultVpc so deleted defaults stay deleted.
resource "aws_organizations_policy" "deny_create_default_vpc" {
  name = "deny-create-default-vpc"
  type = "SERVICE_CONTROL_POLICY"
  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid      = "DenyCreateDefaultVpc"
      Effect   = "Deny"
      Action   = ["ec2:CreateDefaultVpc", "ec2:CreateDefaultSubnet"]
      Resource = "*"
    }]
  })
}

# Workload VPCs are declared explicitly; no aws_default_vpc resource is ever
# imported, so Terraform will not adopt a default VPC if one reappears.
resource "aws_vpc" "workload" {
  cidr_block           = "10.40.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = { Name = "workload-prod-eu-west-1" }
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Config managed rule asserting no default VPC exists in the region.
Resources:
  NoDefaultVpcRule:
    Type: AWS::Config::ConfigRule
    Properties:
      ConfigRuleName: ec2-no-default-vpc
      Source:
        Owner: AWS
        SourceIdentifier: VPC_DEFAULT_SECURITY_GROUP_CLOSED
      Scope:
        ComplianceResourceTypes:
          - AWS::EC2::SecurityGroup

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
5.x (verify)n/an/an/a SC-7; CM-2A.8.20; A.8.22CLD.9.5.1

Log signals

  • CloudTrail ec2:CreateDefaultVpc events — the call only succeeds if the account currently has no default VPC, so a successful invocation is by definition the regression event. Source-IP is rarely relevant; the userIdentity ARN is the actionable column.
  • CloudTrail ec2:CreateVpc events whose responseElements.vpc.isDefault is true — alternative path that achieves the same outcome via the lower-level API.
  • Config rule vpc-default-security-group-closed reporting NON_COMPLIANT on a default SG that was previously absent — surfaces when the default VPC has been re-created and is now in scope for evaluation.

Query

fields @timestamp, eventName, awsRegion, userIdentity.arn, sourceIPAddress, responseElements.vpc.vpcId, responseElements.vpc.isDefault
          | filter eventSource = "ec2.amazonaws.com" and eventName in ["CreateDefaultVpc","CreateVpc"]
          | filter eventName = "CreateDefaultVpc" or responseElements.vpc.isDefault = true
          | sort @timestamp desc
          | limit 50

The CloudWatch Logs Insights query collapses both creation paths into a single result set so the alert pipeline does not need a per-event-name branch; the awsRegion column is meaningful because default-VPC creation is per-region and the audit posture must cover every region the account is allowed to operate in.

Alert threshold

  • Any successful default-VPC creation in any region — page immediately; the documented steady-state is zero default VPCs across the org and any creation is a deliberate deviation.
  • A region newly enabled (CloudTrail account:EnableRegion) followed within 24 hours by CreateDefaultVpc from the same principal — page; the pairing suggests the enabler is trying to use the newly-opened region's auto-default behaviour as cover.
  • Any iam:PassRole or ec2:AssociateRoute activity referencing a VPC ID whose describe-vpcs reports IsDefault=true in production accounts — informational; promotes to high if the account is supposed to be 100% non-default.

Initial response

  1. Delete the default VPC with aws ec2 delete-vpc --vpc-id {id}; this requires removing default subnets, default route-table associations, and the default internet gateway in order — the org's deletion playbook captures the exact sequence.
  2. Inventory resources that were launched into the default VPC during its lifespan via aws ec2 describe-instances --filters Name=vpc-id,Values={id} and the equivalent for ENIs and load-balancers — these resources need to be relocated or terminated, not left orphaned.
  3. Open an incident via general/ir.html and rotate any IAM credentials whose principal performed the creation; the creator typically intended to launch something into the default VPC immediately and the launched workload's identity needs to be traced.

References

Equivalent on: Azure · GCP · OCI

aws-net-02-sg-no-admin-internet-ingress ! CRITICAL PREVENTIVE

No Security Group in any account may permit ingress from 0.0.0.0/0 or ::/0 on administrative ports (SSH 22, RDP 3389, MySQL 3306, PostgreSQL 5432, Oracle 1521, MongoDB 27017, Redis 6379, and any other database / management port the organisation uses). Security Groups are stateful, ENI-attached, default-deny firewalls — the most directly enforceable per-instance boundary AWS exposes (AWS VPC User Guide — Security Groups (accessed 2026-05)). This is the canonical "open the internet to my database" misconfiguration, and Shodan-style scanners find these exposures within minutes of an SG rule being saved. SGs differ from NACLs (aws-net-03) on two axes that the control design relies on: SGs are stateful (response traffic is automatically allowed) and they evaluate at the ENI rather than the subnet, so they apply per-resource not per-CIDR.

Remediation — AWS CLI

# Audit: every SG with ingress from 0.0.0.0/0 on common admin ports.
aws ec2 describe-security-groups \
  --filters Name=ip-permission.cidr,Values=0.0.0.0/0 \
            Name=ip-permission.from-port,Values=22,3389,3306,5432,1521,27017,6379 \
  --query 'SecurityGroups[].[GroupId,GroupName,VpcId]' \
  --output table

# Revoke the offending rule (example: SSH from 0.0.0.0/0).
aws ec2 revoke-security-group-ingress \
  --group-id sg-0abc123def4567890 \
  --protocol tcp --port 22 --cidr 0.0.0.0/0

# Continuous enforcement: AWS Config managed rule.
aws configservice put-config-rule --config-rule '{
  "ConfigRuleName":"restricted-common-ports",
  "Source":{"Owner":"AWS","SourceIdentifier":"RESTRICTED_INCOMING_TRAFFIC"}}'

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
# Workload SG: ingress only from a known bastion/jump SG, never the internet.
resource "aws_security_group" "app" {
  name        = "app-tier"
  description = "Application tier — admin ports never internet-exposed"
  vpc_id      = aws_vpc.workload.id
}

resource "aws_vpc_security_group_ingress_rule" "ssh_from_bastion" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.bastion.id
  ip_protocol                  = "tcp"
  from_port                    = 22
  to_port                      = 22
  description                  = "SSH from bastion SG only"
}

# Config rule + remediation for drift catch.
resource "aws_config_config_rule" "restricted_common_ports" {
  name = "restricted-common-ports"
  source {
    owner             = "AWS"
    source_identifier = "RESTRICTED_INCOMING_TRAFFIC"
  }
  input_parameters = jsonencode({
    blockedPort1 = "22", blockedPort2 = "3389", blockedPort3 = "3306",
    blockedPort4 = "5432", blockedPort5 = "1521"
  })
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: Restrictive security group — no admin-port (22/3389) ingress from 0.0.0.0/0.
Parameters:
  VpcId:
    Type: AWS::EC2::VPC::Id
  AdminCidr:
    Type: String
    Description: Internal CIDR allowed to reach admin ports (never 0.0.0.0/0).
Resources:
  AdminSafeSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: admin-safe-sg
      GroupDescription: Admin ports restricted to internal CIDR.
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          CidrIp: !Ref AdminCidr
        - IpProtocol: tcp
          FromPort: 3389
          ToPort: 3389
          CidrIp: !Ref AdminCidr

Remediation — AWS CDK (TypeScript)

import * as cdk from 'aws-cdk-lib';
import { aws_ec2 as ec2 } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export interface AdminSafeSgProps extends cdk.StackProps {
  vpc: ec2.IVpc;
  adminCidr: string;
}

export class AdminSafeSgStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: AdminSafeSgProps) {
    super(scope, id, props);

    const sg = new ec2.SecurityGroup(this, 'AdminSafeSg', {
      vpc: props.vpc,
      description: 'Admin ports restricted to internal CIDR.',
      allowAllOutbound: false,
    });
    sg.addIngressRule(ec2.Peer.ipv4(props.adminCidr), ec2.Port.tcp(22), 'SSH from internal CIDR');
    sg.addIngressRule(ec2.Peer.ipv4(props.adminCidr), ec2.Port.tcp(3389), 'RDP from internal CIDR');
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
5.2; 5.36.1; 6.23.6; 3.72.1; 2.2 SC-7(5); SC-7A.8.20; A.8.22CLD.9.5.1

Log signals

  • CloudTrail ec2:AuthorizeSecurityGroupIngress events whose requestParameters.ipPermissions.items array contains an ipRanges entry with cidrIp=0.0.0.0/0 alongside a fromPort in the administrative-port set: 22 (SSH), 3389 (RDP), 5985-5986 (WinRM), 5432 (Postgres direct), 3306 (MySQL direct), 1433 (MSSQL direct), 6379 (Redis direct), 27017 (MongoDB direct).
  • The IPv6 variant: ipv6Ranges.cidrIpv6=::/0 on the same admin-port set — IPv6 ingress is frequently overlooked in the IPv4-focused security-group authoring habits.
  • Config rule restricted-ssh or restricted-common-ports evaluating NON_COMPLIANT on any production security-group — backstop for cases where the operator authored the rule via the console rather than the CLI and the CloudTrail filter missed an idiom variant.

Query

fields @timestamp, eventName, requestParameters.groupId, requestParameters.ipPermissions, userIdentity.arn, sourceIPAddress
          | filter eventSource = "ec2.amazonaws.com" and eventName = "AuthorizeSecurityGroupIngress"
          | filter @message like /0\.0\.0\.0\/0/ or @message like /::\/0/
          | filter @message like /"fromPort":22/ or @message like /"fromPort":3389/ or @message like /"fromPort":5985/ or @message like /"fromPort":1433/
          | sort @timestamp desc
          | limit 50

The CloudWatch Logs Insights filter uses raw-message regex against the ipPermissions JSON because port and CIDR live inside a nested array; this is the canonical pattern for security-group event analysis and is faster than a fully-typed filter for ad-hoc investigations.

Alert threshold

  • Any admin-port + Internet-CIDR rule introduced on a production security-group — page immediately; the change is high-confidence wrong and an SCP-deny is the correct preventive (already in place via aws-iam-08-scp-deny-list).
  • An admin-port rule introduced on a non-production security-group — high-priority ticket within one business hour; non-production exposures still constitute an attack surface and frequently serve as the pivot point in real incidents.
  • More than three admin-port + Internet rule attempts denied by SCP within 24 hours from the same principal — page; the repeated attempts indicate the principal is either probing the preventive or unaware of the SCP and warrants an urgent conversation.

Initial response

  1. Revoke the rule with aws ec2 revoke-security-group-ingress using the rule-id from the CloudTrail event; replace it (if the operator's intent was legitimate) with a rule referencing a corporate-egress CIDR or a peer security-group rather than an Internet CIDR.
  2. Pull VPC Flow Logs for the security-group's ENIs over the exposure window and enumerate every ACCEPT flow on the affected port from non-corporate CIDRs — every such flow is a candidate active-attack data point.
  3. Open an incident via general/ir.html if any inbound flow was observed; correlate against GuardDuty findings on the same instances and rotate any credentials reachable from the exposed workload.

References

Equivalent on: Azure · GCP · OCI

aws-net-03-nacl-explicit-deny ! HIGH PREVENTIVE

Use Network ACLs as a defence-in-depth tier under Security Groups. Apply default-deny egress on private subnets and explicit-deny rules for known-bad CIDRs (threat intelligence feeds, sanctions lists, the organisation's own dropped-prefix set) at the NACL layer (AWS VPC User Guide — Network ACLs (accessed 2026-05)). NACLs are stateless and subnet-scoped: every flow needs both an inbound and a corresponding outbound rule, and the rule list applies to every ENI in the subnet without exception. This pair of properties is exactly what makes NACLs the right tier for blanket subnet-wide policy that you do not want a single SG misconfiguration to bypass — the SG sits behind the NACL on the packet path, so a permissive SG cannot re-open something a NACL has explicitly denied. NACLs are AWS-unique; Azure NSGs are stateful (so they play the SG role, not the NACL role), GCP's hierarchical firewall policies and OCI's security lists are the closest functional analogs (see equivalence callout).

Remediation — AWS CLI

# Create a NACL and attach to the private subnet.
ACL_ID=$(aws ec2 create-network-acl \
  --vpc-id vpc-0abc123def4567890 \
  --query 'NetworkAcl.NetworkAclId' --output text)

# Allow ephemeral inbound (stateless rules: must be explicit).
aws ec2 create-network-acl-entry \
  --network-acl-id "$ACL_ID" --rule-number 100 \
  --protocol tcp --port-range From=1024,To=65535 \
  --cidr-block 0.0.0.0/0 --rule-action allow

# Default-deny egress except explicit allow to the NAT prefix.
aws ec2 create-network-acl-entry \
  --network-acl-id "$ACL_ID" --rule-number 100 --egress \
  --protocol tcp --port-range From=443,To=443 \
  --cidr-block 10.40.0.0/16 --rule-action allow

# Explicit deny known-bad CIDR (example).
aws ec2 create-network-acl-entry \
  --network-acl-id "$ACL_ID" --rule-number 50 \
  --protocol -1 --cidr-block 198.51.100.0/24 --rule-action deny

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_network_acl" "private" {
  vpc_id     = aws_vpc.workload.id
  subnet_ids = aws_subnet.private[*].id
  tags = { Name = "private-tier-acl" }
}

# Explicit-deny known-bad CIDRs first (lower rule numbers win).
resource "aws_network_acl_rule" "deny_known_bad" {
  for_each       = toset(var.known_bad_cidrs)
  network_acl_id = aws_network_acl.private.id
  rule_number    = 50 + index(var.known_bad_cidrs, each.value)
  rule_action    = "deny"
  protocol       = "-1"
  cidr_block     = each.value
}

# Egress: allow only HTTPS to the NAT-fronted prefix list, deny everything else.
resource "aws_network_acl_rule" "egress_https_only" {
  network_acl_id = aws_network_acl.private.id
  rule_number    = 200
  egress         = true
  rule_action    = "allow"
  protocol       = "tcp"
  from_port      = 443
  to_port        = 443
  cidr_block     = "0.0.0.0/0"
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: Subnet network ACL with explicit deny rules for known-bad ingress.
Parameters:
  VpcId:
    Type: AWS::EC2::VPC::Id
Resources:
  HardenedNacl:
    Type: AWS::EC2::NetworkAcl
    Properties:
      VpcId: !Ref VpcId
  DenyTorExitNodes:
    Type: AWS::EC2::NetworkAclEntry
    Properties:
      NetworkAclId: !Ref HardenedNacl
      RuleNumber: 100
      Protocol: -1
      RuleAction: deny
      Egress: false
      CidrBlock: 192.0.2.0/24

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
5.x (verify)n/an/an/a SC-7; AC-4A.8.20; A.8.22CLD.9.5.1

Log signals

  • CloudTrail ec2:DeleteNetworkAclEntry targeting a deny-rule entry on a NACL whose VPC tag indicates production — removes a stateless deny barrier and shifts enforcement entirely onto the stateful security-group layer below.
  • CloudTrail ec2:CreateNetworkAclEntry with requestParameters.ruleAction=allow and requestParameters.cidrBlock=0.0.0.0/0 at a low rule-number (NACLs evaluate by number ascending) — effectively overrides any deny entry with a higher number that the operator intended to keep.
  • CloudTrail ec2:ReplaceNetworkAclAssociation swapping a subnet's NACL from the locked-down custom NACL back to the VPC's default NACL — the default NACL permits all traffic and is a frequent escape hatch for operators wanting to bypass the custom rules without explicitly modifying them.

Query

fields @timestamp, eventName, requestParameters.networkAclId, requestParameters.ruleNumber, requestParameters.ruleAction, requestParameters.cidrBlock, userIdentity.arn
          | filter eventSource = "ec2.amazonaws.com" and eventName in ["DeleteNetworkAclEntry","CreateNetworkAclEntry","ReplaceNetworkAclEntry","ReplaceNetworkAclAssociation"]
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query covers all four mutation paths in one pass; downstream alert routing branches on eventName because the severity tier differs (delete-deny is paging-priority; allow-with-low-number is high-priority).

Alert threshold

  • Any delete of a deny-rule entry on a production NACL — page; the deny entries are deliberately authored and the steady-state mutation rate is essentially zero.
  • An allow-rule at rule-number below 100 with cidrBlock=0.0.0.0/0 — high-priority ticket; the low rule-number means it evaluates before most other rules and effectively becomes an "allow all" override.
  • ReplaceNetworkAclAssociation reverting a subnet to the default NACL — page; the default NACL is permit-all and any subnet swapped back to it has lost the entire NACL-layer enforcement until a follow-up association is made.

Initial response

  1. Restore the deny entry from IaC: aws ec2 create-network-acl-entry --network-acl-id {id} --rule-number {n} --protocol {p} --rule-action deny --cidr-block {cidr}; for association reverts, re-associate the subnet with the custom NACL via replace-network-acl-association.
  2. Inspect VPC Flow Logs for the affected subnet during the gap window — flows that would have been NACL-denied appear as ACCEPT records with security-group-only enforcement and need a manual review against the SG rule set.
  3. Open an incident per general/ir.html if the gap window aligns with any GuardDuty finding on instances in the subnet; the NACL deletion is often paired with a follow-on instance compromise attempt.

References

Closest analog (NACL is AWS-unique; see overview anti-conflation prose): Azure NSG outbound default · GCP hierarchical firewall · OCI security list

aws-net-04-vpc-block-public-access ! CRITICAL PREVENTIVE

Enable VPC Block Public Access (BPA) in block-bidirectional mode in every region of every account, and pin the setting with an SCP that denies disabling it. VPC BPA is a Nov 2024 feature that fences every VPC in a region against internet traffic through Internet Gateways and egress-only IGWs in a single per-region setting — independent of any SG, NACL, or route-table configuration (AWS VPC User Guide — Block Public Access (accessed 2026-05)). This is the network-equivalent of S3 Block Public Access (covered as aws-data-01 on the AWS Data page): a region-wide invariant that catches every future misconfiguration in a single setting, including the cases where a workload owner attaches an IGW to a subnet that the platform team thought was permanently private. Note: VPC BPA is distinct from S3 BPA — they protect different resource families and both must be on. The CIS AWS Foundations Benchmark v3.0.0 (Jan 2024) predates the feature, so the CIS cell reads n/a (post-v3.0.0); re-verify at writing time in case a benchmark patch has added a sub-ID.

Remediation — AWS CLI

# Enable VPC Block Public Access in block-bidirectional mode, per region, per account.
for region in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  aws ec2 modify-vpc-block-public-access-options \
    --region "$region" \
    --internet-gateway-block-mode block-bidirectional
done

# Verify.
aws ec2 describe-vpc-block-public-access-options --region eu-west-1 \
  --query 'VpcBlockPublicAccessOptions.{Mode:InternetGatewayBlockMode,State:State}'

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_vpc_block_public_access_options" "this" {
  internet_gateway_block_mode = "block-bidirectional"
}

# SCP pinning: deny any principal in any member account from disabling BPA.
resource "aws_organizations_policy" "deny_disable_vpc_bpa" {
  name = "deny-disable-vpc-bpa"
  type = "SERVICE_CONTROL_POLICY"
  content = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid    = "DenyDisableVpcBpa"
      Effect = "Deny"
      Action = [
        "ec2:DisableVpcBlockPublicAccess",
        "ec2:ModifyVpcBlockPublicAccessOptions"
      ]
      Resource = "*"
      Condition = {
        StringNotEquals = {
          "aws:PrincipalArn" = "arn:aws:iam::${var.platform_account_id}:role/PlatformNetworkAdmin"
        }
      }
    }]
  })
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: VPC Block Public Access (declarative policy banning IGW/EGW attachment org-wide).
Parameters:
  OrgId:
    Type: String
Resources:
  VpcBpaPolicy:
    Type: AWS::EC2::VPCBlockPublicAccessOptions
    Properties:
      InternetGatewayBlockMode: block-bidirectional

Remediation — AWS CDK (TypeScript)

import * as cdk from 'aws-cdk-lib';
import { aws_ec2 as ec2 } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class VpcBlockPublicAccessStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new ec2.CfnVPCBlockPublicAccessOptions(this, 'VpcBpa', {
      internetGatewayBlockMode: 'block-bidirectional',
    });
  }
}

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
n/a (post-v3.0.0)n/an/an/a SC-7; CM-7A.8.20; A.8.22CLD.9.5.1

Log signals

  • CloudTrail ec2:ModifyVpcBlockPublicAccessOptions where requestParameters.internetGatewayBlockMode shifts from block-bidirectional or block-ingress to off — disables the account-level public-access guard entirely.
  • CloudTrail ec2:CreateVpcBlockPublicAccessExclusion events — adds a per-VPC or per-subnet carve-out from the account-level block; legitimate carve-outs exist but every new one warrants review since the steady-state count is small and bounded.
  • CloudTrail ec2:DeleteVpcBlockPublicAccessExclusion against an exclusion previously authored by IaC — removes an exclusion the org expects to persist, often as a side-effect of a half-applied Terraform run that left manual state behind.

Query

fields @timestamp, eventName, requestParameters.internetGatewayBlockMode, requestParameters.exclusionId, requestParameters.resourceArn, userIdentity.arn
          | filter eventSource = "ec2.amazonaws.com" and eventName in ["ModifyVpcBlockPublicAccessOptions","CreateVpcBlockPublicAccessExclusion","DeleteVpcBlockPublicAccessExclusion","ModifyVpcBlockPublicAccessExclusion"]
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query covers both the account-level switch and the per-VPC exclusion surface; pair with a daily completeness check that lists describe-vpc-block-public-access-options per region and asserts block-bidirectional.

Alert threshold

  • Any internetGatewayBlockMode shift to off — page immediately; the account-wide control is a fleet-protection backstop and disabling it widens the blast radius of every other network-misconfiguration class simultaneously.
  • Any new exclusion created in production — high-priority ticket within 30 minutes; the exclusion's scope (account / VPC / subnet) drives the severity, with account-wide being page-priority.
  • An exclusion deletion that does not correspond to a tracked change-management ticket — informational; the deletion narrows the exclusion surface (which is desirable) but indicates IaC drift that should be reconciled.

Initial response

  1. Restore the block-mode with aws ec2 modify-vpc-block-public-access-options --internet-gateway-block-mode block-bidirectional; confirm via describe-vpc-block-public-access-options read-back.
  2. Inventory IGWs and public subnets created during the off-window via CloudTrail ec2:CreateInternetGateway + ec2:AttachInternetGateway + ec2:AssociateRouteTable — these resources may now violate the restored bidirectional block and need cleanup before the next eventually-consistent enforcement sweep terminates them implicitly.
  3. Open an incident via general/ir.html if any public-facing workload was launched during the window; the workload's Internet exposure during the off-window is a candidate attack-surface review and the workload's reachable secrets need rotation per aws-ir-06-credential-rotation-playbook.

References

Equivalent on: Azure · GCP · OCI

aws-net-06-wafv2-managed-rules ! HIGH PREVENTIVE

Attach AWS WAFv2 with the AWS Managed Rules (Core Rule Set, Known Bad Inputs, Amazon IP Reputation, SQL Database, and Bot Control where the workload's economics justify the per-request fee) to every internet-facing Application Load Balancer, Amazon API Gateway, and Amazon CloudFront distribution (AWS WAF Developer Guide — managed rule groups (accessed 2026-05)). WAFv2 inspects HTTP and HTTPS payloads — request URI, headers, body, cookies, query strings — and is therefore an L7 control. It is not a substitute for Shield Advanced (aws-net-07): WAFv2 cannot help with a 1 Tbps volumetric SYN flood any more than Shield can stop an SQLi attempt in a well-formed HTTPS request. They are layered, not alternative. The "v2" naming matters: WAF Classic is deprecated for new use; new content should always read "AWS WAFv2" on first use. Severity HIGH PREVENTIVE because managed rules block known-exploit-pattern traffic at the edge before it ever reaches the workload's parsing logic.

Remediation — AWS CLI

# Create a REGIONAL Web ACL with AWS Managed Rules and associate to an ALB.
aws wafv2 create-web-acl \
  --name app-edge-acl --scope REGIONAL \
  --default-action Allow={} \
  --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=appEdgeAcl \
  --rules file://managed-rules.json

# Attach to an ALB.
aws wafv2 associate-web-acl \
  --web-acl-arn arn:aws:wafv2:eu-west-1:111122223333:regional/webacl/app-edge-acl/abcd \
  --resource-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/lb-1

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_wafv2_web_acl" "app_edge" {
  name  = "app-edge-acl"
  scope = "REGIONAL"

  default_action { allow {} }

  rule {
    name     = "AWS-AWSManagedRulesCommonRuleSet"
    priority = 10
    override_action { none {} }
    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesCommonRuleSet"
        vendor_name = "AWS"
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "CommonRuleSet"
      sampled_requests_enabled   = true
    }
  }

  rule {
    name     = "AWS-AWSManagedRulesKnownBadInputsRuleSet"
    priority = 20
    override_action { none {} }
    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesKnownBadInputsRuleSet"
        vendor_name = "AWS"
      }
    }
    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "KnownBadInputs"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "appEdgeAcl"
    sampled_requests_enabled   = true
  }
}

resource "aws_wafv2_web_acl_association" "alb" {
  resource_arn = aws_lb.app.arn
  web_acl_arn  = aws_wafv2_web_acl.app_edge.arn
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: WAFv2 Web ACL with AWS managed rule groups (CommonRuleSet + KnownBadInputs).
Resources:
  WebAcl:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: edge-web-acl
      Scope: REGIONAL
      DefaultAction:
        Allow: {}
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: edge-web-acl
      Rules:
        - Name: AWS-AWSManagedRulesCommonRuleSet
          Priority: 0
          OverrideAction:
            None: {}
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesCommonRuleSet
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: common-rule-set
        - Name: AWS-AWSManagedRulesKnownBadInputsRuleSet
          Priority: 1
          OverrideAction:
            None: {}
          Statement:
            ManagedRuleGroupStatement:
              VendorName: AWS
              Name: AWSManagedRulesKnownBadInputsRuleSet
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: known-bad-inputs

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
(best-practices)n/an/an/a SC-7(11); SI-3A.8.20; A.8.23CLD.9.5.1

Log signals

  • CloudTrail wafv2:UpdateWebACL events removing or disabling a managed rule group from the AWS-managed rule set (AWSManagedRulesCommonRuleSet, AWSManagedRulesKnownBadInputsRuleSet, AWSManagedRulesSQLiRuleSet) — the canonical baseline rule groups that the org policy mandates be in count or block mode.
  • wafv2:UpdateWebACL where a managed rule group's overrideAction shifts from none (rule fires its native block) to count (rule observes but does not block) — silently downgrades enforcement from blocking to logging without removing the rule group.
  • wafv2:DisassociateWebACL calls — removes the WebACL from its CloudFront / ALB / API Gateway resource so the WAF stops evaluating traffic entirely.

Query

fields @timestamp, eventName, requestParameters.name, requestParameters.scope, requestParameters.rules, requestParameters.resourceArn, userIdentity.arn
          | filter eventSource = "wafv2.amazonaws.com" and eventName in ["UpdateWebACL","DisassociateWebACL","DeleteWebACL"]
          | sort @timestamp desc
          | limit 50

For UpdateWebACL the rule diff is the actionable signal; pair the CloudWatch Logs Insights query with a Lambda that runs aws wafv2 get-web-acl on a 5-minute cadence and diffs the rule set against the IaC-canonical version, raising an alarm on any drift.

Alert threshold

  • Any disable / removal of AWSManagedRulesCommonRuleSet or AWSManagedRulesKnownBadInputsRuleSet — page immediately; these two rule groups carry the majority of the OWASP Top 10 coverage and removing either is a deliberate posture downgrade.
  • overrideAction shift to count on any managed rule group — high-priority ticket; the count mode is appropriate for tuning but should be time-bound and tracked in a change-management ticket.
  • DisassociateWebACL on a production resource — page; the workload is now unprotected at the WAF layer and the only remaining defences are at the application or network layer.

Initial response

  1. Restore the rule set from IaC: aws wafv2 update-web-acl --name {name} --scope {scope} --id {id} --rules file://canonical-rules.json --default-action Block={}; confirm via get-web-acl that the managed rule groups are present and in overrideAction=NONE.
  2. For DisassociateWebACL, re-associate via aws wafv2 associate-web-acl against the affected ALB / CloudFront ARN; confirm the WAF evaluates a synthetic block-pattern request before closing the ticket.
  3. Inventory CloudFront / ALB access logs during the gap window for any request pattern that would have been blocked (SQL injection, XSS, known-bad-input strings); flag the matching source IPs and open an incident per general/ir.html if any match the org's threat-intel feed.

References

Equivalent on: Azure · GCP · OCI

aws-net-07-shield-advanced ! MEDIUM RESPONSIVE

Enable AWS Shield Advanced on internet-facing CloudFront distributions, Global Accelerators, Network Load Balancers, and Elastic IPs that front revenue-bearing or otherwise reputation-critical traffic, and engage the Shield Response Team (SRT) playbook for the organisation (AWS Shield Developer Guide (accessed 2026-05)). Shield Standard is automatic, free, and already protects every AWS-resident resource against common L3/L4 attacks; Shield Advanced is the paid responsive tier that adds 24/7 SRT engagement, attack analytics, cost protection (DDoS-related scaling charges are credited back), and per-flow mitigation tuning. The typology is RESPONSIVE because Shield's value-add over Standard is in the mitigation feedback loop during a sustained attack, not in preventing volumetric traffic from ever arriving — there is no preventive moat against a 1 Tbps SYN flood at the network edge. Severity MEDIUM because most workloads have viable cheaper alternatives (CloudFront's built-in mitigation plus WAFv2 rate-based rules suffices for the L3/L4 cases that mid-sized workloads see); MEDIUM is reserved for "you should do this if the dollar value of an outage warrants the $3,000/month commitment". Cross-reference the L7 mitigations in aws-net-06 — Shield Advanced and WAFv2 are layered, not substitutes, and the bundled console treatment can obscure that.

Remediation — AWS CLI

# Subscribe the account to Shield Advanced (one-time per AWS Organization payer).
aws shield subscribe-to-proactive-engagement \
  --proactive-engagement-status ENABLED \
  --emergency-contact-list EmailAddress=ddos-oncall@example.com,PhoneNumber=+1234567890

# Create a Shield protection on a CloudFront distribution.
aws shield create-protection \
  --name cf-revenue-edge \
  --resource-arn arn:aws:cloudfront::111122223333:distribution/E1A2B3C4D5E6F7

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_shield_protection" "cloudfront_edge" {
  name         = "cf-revenue-edge"
  resource_arn = aws_cloudfront_distribution.edge.arn
  tags         = { tier = "revenue-bearing" }
}

resource "aws_shield_protection" "nlb_public_api" {
  name         = "nlb-public-api"
  resource_arn = aws_lb.public_api.arn
}

# Proactive engagement (SRT contact).
resource "aws_shield_proactive_engagement" "this" {
  enabled = true
  emergency_contact {
    contact_notes = "Primary DDoS oncall"
    email_address = var.ddos_oncall_email
    phone_number  = var.ddos_oncall_phone
  }
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: Shield Advanced subscription binding a protection to an edge CloudFront/ALB resource ARN.
Parameters:
  ProtectedResourceArn:
    Type: String
Resources:
  ShieldProtection:
    Type: AWS::Shield::Protection
    Properties:
      Name: edge-shield-protection
      ResourceArn: !Ref ProtectedResourceArn

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
(best-practices)n/an/an/a SC-5; SC-5(2)A.8.20; A.5.30CLD.9.5.1

Log signals

  • CloudTrail shield:DeleteProtection events targeting protections on production CloudFront distributions, Global Accelerators, ALBs, or Elastic IPs — disables the DDoS-advanced detection and mitigation pipeline for the affected resource.
  • CloudTrail shield:DisassociateDRTRole — removes the AWS DDoS Response Team's read access to the account, blocking the human-in-the-loop side of the Shield Advanced response capability.
  • CloudTrail shield:UpdateProtectionGroup where aggregation shifts from SUM / MAX to MEAN, or where the protection group's member-list narrows — reduces the aggregation surface that Shield Advanced uses for cross-resource attack correlation.

Query

fields @timestamp, eventName, requestParameters.protectionId, requestParameters.resourceArn, requestParameters.protectionGroupId, requestParameters.aggregation, userIdentity.arn
          | filter eventSource = "shield.amazonaws.com" and eventName in ["DeleteProtection","DisassociateDRTRole","UpdateProtectionGroup","DeleteProtectionGroup","DisableApplicationLayerAutomaticResponse"]
          | sort @timestamp desc
          | limit 100

Use the CloudWatch Logs Insights query in conjunction with a daily completeness check that lists aws shield list-protections and asserts that every documented production internet-facing resource ARN appears in the result set — a missing ARN is the same as a delete from the protection standpoint.

Alert threshold

  • Any DeleteProtection on a production internet-facing resource — page immediately; the resource is now relying on Shield Standard only, which lacks application-layer attack mitigation.
  • DisassociateDRTRole — page within 10 minutes; the DRT role is the human-escalation path during an active attack and removing it is functionally equivalent to disabling the support channel.
  • Protection-group aggregation shift to MEAN — high-priority ticket; the MEAN aggregation is mathematically valid but dilutes single-resource spikes that the SUM/MAX modes would surface, producing a slower mitigation response.

Initial response

  1. Restore the protection with aws shield create-protection --name {name} --resource-arn {arn}; for the DRT role disassociation, re-associate via aws shield associate-drt-role --role-arn arn:aws:iam::{acct}:role/AWSDDoSResponseTeam.
  2. Pull Shield Advanced attack-summary metrics from CloudWatch (AWS/DDoSProtection namespace) for the protected resources over the prior 14 days and verify whether any active attack was in progress at the time of the deletion — Shield Advanced surfaces attack data even on Standard-protected resources but the detail is reduced.
  3. Open an incident via general/ir.html if any active attack was in progress at the moment of disable; the disable is a likely deliberate signal-suppression in that case and the principal should be investigated as a possible adversary or compromised account.

References

Equivalent on: Azure · GCP · OCI

aws-net-08-route53-dnssec ! MEDIUM PREVENTIVE

Enable DNSSEC signing on every Amazon Route 53 public hosted zone the organisation owns. DNSSEC binds each DNS record to a cryptographic signature that resolvers (and intermediate caches) can validate, defeating cache-poisoning and on-path response-rewrite attacks (Amazon Route 53 DNSSEC documentation (accessed 2026-05)). The principle is reinforced in the General Network guidance on DNS integrity. Two AWS-specific constraints to flag in advance: (1) the KMS key signing the DNSSEC keys must be an asymmetric ECC_NIST_P256 key in the us-east-1 region — Route 53 is a global service hosted out of us-east-1 and will reject keys from other regions; (2) Route 53 private hosted zones do not support DNSSEC and never will, so the control applies only to public zones. CIS AWS Foundations v3.0.0 does not codify DNSSEC as a numbered control today, hence the best-practices cell — re-verify at writing time in case a future benchmark patch adds a sub-ID.

Remediation — AWS CLI

# Step 1: create the KSK-signing KMS key in us-east-1 (required by Route 53).
aws kms create-key \
  --region us-east-1 \
  --customer-master-key-spec ECC_NIST_P256 \
  --key-usage SIGN_VERIFY \
  --description "Route 53 DNSSEC KSK signing key"

# Step 2: create a key signing key on the zone, bound to the KMS key.
aws route53 create-key-signing-key \
  --hosted-zone-id Z0123456ABCDEF \
  --key-management-service-arn arn:aws:kms:us-east-1:111122223333:key/ \
  --name example_com_ksk \
  --status ACTIVE

# Step 3: enable signing for the hosted zone.
aws route53 enable-hosted-zone-dnssec --hosted-zone-id Z0123456ABCDEF

# Step 4: lodge the DS record at the parent (registrar-specific).

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
# KMS key MUST be in us-east-1 — Route 53 hard requirement.
provider "aws" {
  alias  = "useast1"
  region = "us-east-1"
}

resource "aws_kms_key" "dnssec" {
  provider                 = aws.useast1
  customer_master_key_spec = "ECC_NIST_P256"
  key_usage                = "SIGN_VERIFY"
  deletion_window_in_days  = 30
  description              = "Route 53 DNSSEC KSK signing key"
}

resource "aws_route53_key_signing_key" "example" {
  hosted_zone_id             = aws_route53_zone.public.id
  key_management_service_arn = aws_kms_key.dnssec.arn
  name                       = "example_com_ksk"
}

resource "aws_route53_hosted_zone_dnssec" "example" {
  depends_on     = [aws_route53_key_signing_key.example]
  hosted_zone_id = aws_route53_zone.public.id
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: Route 53 DNSSEC key-signing key + DNSSEC enabled on the hosted zone.
Parameters:
  HostedZoneId:
    Type: String
  KmsKeyArn:
    Type: String
Resources:
  Ksk:
    Type: AWS::Route53::KeySigningKey
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: primary-ksk
      KeyManagementServiceArn: !Ref KmsKeyArn
      Status: ACTIVE
  DnssecEnable:
    Type: AWS::Route53::DNSSEC
    DependsOn: Ksk
    Properties:
      HostedZoneId: !Ref HostedZoneId

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
(best-practices)n/an/an/a SC-20; SC-21A.8.21CLD.9.5.1

Log signals

  • CloudTrail route53:DisableHostedZoneDNSSEC events targeting a public hosted zone where the org policy requires DNSSEC — strips the cryptographic chain-of-trust and opens the zone to spoofing at any resolver downstream that was validating.
  • CloudTrail route53:DeactivateKeySigningKey — leaves DNSSEC nominally enabled but the KSK no longer signs, producing a slower-failure profile where validators may still trust cached signatures until they expire.
  • CloudTrail route53:ChangeResourceRecordSets where the changeBatch.changes array touches DS records at the parent zone (TLD registrar) without a corresponding KSK rotation event — typically indicates a misconfigured key rollover that breaks the chain-of-trust at the delegation point.

Query

fields @timestamp, eventName, requestParameters.hostedZoneId, requestParameters.keySigningKeyName, requestParameters.changeBatch, userIdentity.arn
          | filter eventSource = "route53.amazonaws.com" and eventName in ["DisableHostedZoneDNSSEC","DeactivateKeySigningKey","DeleteKeySigningKey","ChangeResourceRecordSets"]
          | filter eventName != "ChangeResourceRecordSets" or @message like /"Type":"DS"/
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query keeps the DS-record case in the same result set with a conditional filter so the alert pipeline sees all DNSSEC-relevant mutations as a single stream; downstream routing branches on event-name severity.

Alert threshold

  • Any DisableHostedZoneDNSSEC on a production zone — page immediately; the disable cascades through resolver caches over hours but the trust break is instant for validators that don't cache.
  • DeactivateKeySigningKey outside a tracked KSK rotation ticket — high-priority ticket; the deactivation may be a legitimate rollover step but every such event should have a matching change ticket and a follow-up activation within the documented rotation window.
  • DS-record change without a corresponding KSK rotation event — page; the DS delegation is the parent's view of the child's trust anchor and changes here directly break validation.

Initial response

  1. Re-enable DNSSEC with aws route53 enable-hosted-zone-dnssec --hosted-zone-id {id} and confirm the KSK is active via get-dnssec; if a DS record was changed at the registrar, coordinate with the registrar's change process to restore the canonical DS values.
  2. Verify resolver behaviour end-to-end with dig +dnssec {domain} @8.8.8.8 +short and confirm ad (authenticated-data) flag in the response — the absence of ad indicates the trust chain is still broken from a public-resolver perspective.
  3. Open an incident per general/ir.html; if the disable was extended (more than 1 hour), audit DNS query logs at the recursive resolvers for any spike in NXDOMAIN or unexpected A-record answers during the window as evidence of attempted cache-poisoning.

References

Equivalent on: Azure · GCP · OCI

aws-net-09-egress-controls ! HIGH PREVENTIVE

Restrict egress from private subnets at two layers: default-deny at NACL plus SG egress rules tied to specific destinations, and a stateful inspection tier — either AWS Network Firewall in the egress path or a transit-gateway-routed forward proxy — that enforces FQDN-allowlisting for non-AWS-service traffic (AWS Network Firewall Developer Guide (accessed 2026-05)). The general principle — workloads talk only to destinations they need — is documented at General Network — egress control. A NAT Gateway that translates any-destination outbound traffic is a one-way exfiltration channel: any compromised workload (a malicious dependency in npm install at build time, a Log4Shell-style probe at runtime) can talk to any host on the public internet without any further AWS-side authorisation. Severity HIGH PREVENTIVE because egress filtering is the single largest exfiltration-path mitigation AWS exposes once east-west is constrained; it is the missing complement to aws-net-05 endpoints (which only covers AWS-service traffic).

Remediation — AWS CLI

# Create a Network Firewall rule group with an FQDN allowlist.
aws network-firewall create-rule-group \
  --rule-group-name egress-fqdn-allowlist \
  --type STATEFUL --capacity 100 \
  --rule-group '{"RulesSource":{"RulesSourceList":{
    "Targets":["updates.example.com","registry.npmjs.org","pypi.org"],
    "TargetTypes":["TLS_SNI","HTTP_HOST"],
    "GeneratedRulesType":"ALLOWLIST"}}}'

# Create the firewall policy referencing the rule group.
aws network-firewall create-firewall-policy \
  --firewall-policy-name egress-policy \
  --firewall-policy '{"StatefulRuleGroupReferences":[{"ResourceArn":""}],
    "StatelessDefaultActions":["aws:forward_to_sfe"],
    "StatelessFragmentDefaultActions":["aws:forward_to_sfe"]}'

# Create the firewall and attach to the egress subnet(s).
aws network-firewall create-firewall \
  --firewall-name workload-egress \
  --firewall-policy-arn  \
  --vpc-id vpc-0abc123def4567890 \
  --subnet-mappings SubnetId=subnet-0egress1 SubnetId=subnet-0egress2

Remediation — Terraform

# Terraform AWS provider ~> 5.0
# Source: AWS docs (accessed 2026-05)
resource "aws_networkfirewall_rule_group" "egress_allowlist" {
  capacity = 100
  name     = "egress-fqdn-allowlist"
  type     = "STATEFUL"
  rule_group {
    rules_source {
      rules_source_list {
        target_types         = ["TLS_SNI", "HTTP_HOST"]
        generated_rules_type = "ALLOWLIST"
        targets              = var.egress_fqdn_allowlist
      }
    }
  }
}

resource "aws_networkfirewall_firewall_policy" "egress" {
  name = "egress-policy"
  firewall_policy {
    stateless_default_actions          = ["aws:forward_to_sfe"]
    stateless_fragment_default_actions = ["aws:forward_to_sfe"]
    stateful_rule_group_reference {
      resource_arn = aws_networkfirewall_rule_group.egress_allowlist.arn
    }
  }
}

resource "aws_networkfirewall_firewall" "egress" {
  name                = "workload-egress"
  firewall_policy_arn = aws_networkfirewall_firewall_policy.egress.arn
  vpc_id              = aws_vpc.workload.id

  dynamic "subnet_mapping" {
    for_each = aws_subnet.egress
    content { subnet_id = subnet_mapping.value.id }
  }
}

Remediation — CloudFormation

AWSTemplateFormatVersion: '2010-09-09'
Description: Network Firewall stateful rule group denying outbound traffic to non-allowlisted SNI domains.
Resources:
  EgressDomainAllowlist:
    Type: AWS::NetworkFirewall::RuleGroup
    Properties:
      RuleGroupName: egress-domain-allowlist
      Type: STATEFUL
      Capacity: 100
      RuleGroup:
        RulesSource:
          RulesSourceList:
            TargetTypes:
              - TLS_SNI
              - HTTP_HOST
            Targets:
              - .amazonaws.com
              - .example-corp.internal
            GeneratedRulesType: ALLOWLIST

Compliance mapping

CIS AWS Foundations v3.0.0 CIS Microsoft Azure Foundations v3.0.0 CIS GCP Foundation v4.0.0 CIS OCI Foundation v2.0.0 NIST SP 800-53 rev5 ISO/IEC 27001:2022 ISO/IEC 27017:2015
(best-practices)n/an/an/a SC-7(5); AC-4A.8.20; A.8.22CLD.9.5.1

Log signals

  • CloudTrail network-firewall:UpdateRuleGroup events where the rule diff weakens an egress allow-list (adds a permissive FQDN such as *.s3.amazonaws.com with no path-prefix scoping, or widens an IP-range list) — the most direct path to exfiltration via DNS-over-allowed-FQDN tunnelling.
  • CloudTrail network-firewall:DisassociateFirewallPolicy — detaches the policy from the firewall, leaving the firewall in place but enforcing no rules; produces a passing healthcheck while removing all egress controls.
  • VPC Flow Logs egress from the workload-VPC's NAT-gateway ENI to destinations outside the documented egress allow-list — surfaces traffic that the firewall is supposed to drop but is letting through, indicating either a rule-set regression or a rule-evaluation bug.

Query

fields @timestamp, eventName, requestParameters.ruleGroupName, requestParameters.ruleGroup, requestParameters.firewallName, requestParameters.firewallPolicyArn, userIdentity.arn
          | filter eventSource = "network-firewall.amazonaws.com" and eventName in ["UpdateRuleGroup","DisassociateFirewallPolicy","UpdateFirewallPolicy","DeleteFirewall"]
          | sort @timestamp desc
          | limit 100

The CloudWatch Logs Insights query is the cheapest tier; for the rule-diff case, pair with a Lambda that captures describe-rule-group on a 5-minute cadence and diffs against the IaC-canonical YAML, raising an alarm on any addition to the egress allow-list.

Alert threshold

  • Any rule addition to the egress allow-list of a production rule-group that introduces a wildcard FQDN — page immediately; wildcard FQDNs in egress allow-lists are the canonical DNS-tunnel cover and the org policy bans them.
  • DisassociateFirewallPolicy on a production firewall — page; the firewall is now a no-op and egress controls have evaporated even though the topology looks intact.
  • NAT-gateway egress to destinations outside the allow-list above 10 flows per minute — high-priority ticket; the firewall is either misconfigured or being bypassed via a route-table change and both cases warrant immediate investigation.

Initial response

  1. Restore the rule-group from IaC: aws network-firewall update-rule-group --rule-group-arn {arn} --rule-group file://canonical-rules.json --type STATEFUL; confirm via describe-rule-group read-back and validate that StatefulRuleOptions.RuleOrder=STRICT_ORDER if the org policy requires strict evaluation order.
  2. Re-associate the firewall-policy with aws network-firewall associate-firewall-policy; verify the firewall returns to READY state and that traffic is being evaluated by synthesising a request to a blocked destination from a debug instance.
  3. Inventory VPC Flow Logs and Network Firewall alert logs for any egress to suspicious destinations during the policy-detached window — every such flow is a candidate exfiltration data point and should be cross-referenced against threat-intel feeds and GuardDuty findings before opening an incident per general/ir.html.

References

Equivalent on: Azure · GCP · OCI

Sources