Describe Stale Security Groups: Find and Remove Stale Rules

Network switch with rows of connected blue and yellow Ethernet cables

Photo by Brett Sayles on Pexels

To describe stale security groups, call DescribeStaleSecurityGroups once per VPC. It returns the rules that reference a security group that no longer exists, or one reached through a VPC peering connection or transit gateway that was deleted. AWS doesn’t remove these rules for you. Match each one to its rule ID with DescribeSecurityGroupRules, then revoke it.

This example is for network and platform engineers who clean up VPCs after peering connections, transit gateways or whole accounts have been retired. When you describe stale security groups by hand you have to run the CLI VPC by VPC and still work out which rule ID to delete. The TypeScript script for the AWS SDK for JavaScript v3 does both for every VPC in a Region.

By default it only reports. With --group-ids and --apply it writes the matched rules to a JSON file and revokes them from the groups you listed, nothing else. It doesn’t look for groups nobody uses or rules open to the internet; the scripts to find unused security groups that no resource references and find security groups open to the internet on common ports cover those.

What makes a security group rule stale?

A rule can point at another security group instead of a CIDR range. When that referenced group, or the path to it, disappears, the rule becomes stale. The DescribeStaleSecurityGroups API reference lists the cases:

  • The rule references a security group that was deleted.
  • The rule references a group in a peer VPC, and the VPC peering connection was deleted.
  • The rule references a group across a transit gateway, and the transit gateway was deleted or its security group referencing feature was turned off.
  • The rule depends on a security group VPC association that was disassociated.

The VPC peering guide adds the part that matters for cleanup: a stale rule is not removed automatically. It also says that if a rule is stale because the peering connection was deleted, it stops being stale when you create a new peering connection between the same VPCs. An old rule can quietly start allowing traffic again, which is the main reason to remove stale rules instead of ignoring them.

Two limits to keep in mind. You can only reference a peer VPC’s security group when the peering is in the same Region, so cross-Region peering rules use CIDR blocks and never show up as stale. And the PrefixListIds field on a stale rule is documented as not applicable, so rules that point at managed prefix lists aren’t covered by this API.

Why remove stale rules at all?

A stale rule doesn’t allow traffic today, but it still has costs. It counts toward the security group’s rule quota, it makes reviews harder because nobody knows what sg-04c3b2a1f0e9d8765 used to be, and it can come back to life as described above. NIST’s Guidelines on Firewalls and Firewall Policy (SP 800-41 Rev. 1) recommends reviewing firewall policy at regular intervals, noting that a review can uncover rules that are no longer needed. Security group rules are the AWS version of that ruleset.

Stale rules usually appear after a peering connection is removed. If you’re about to do that, run the VPC peering connections audit script first and this one afterwards. The same applies to transit gateways; the example to find unused transit gateway attachments helps decide which to remove.

What does the script do?

  1. Lists every VPCpaginateDescribeVpcs in the Region from --region, AWS_REGION or us-east-1.
  2. Describes stale security groups per VPCpaginateDescribeStaleSecurityGroups with the required VpcId. Each result has StaleIpPermissions (inbound) and StaleIpPermissionsEgress (outbound), with the referenced group, peer VPC, peering connection ID and PeeringStatus.
  3. Finds the rule IDsDescribeStaleSecurityGroups doesn’t return rule IDs, so the script calls paginateDescribeSecurityGroupRules with a group-id filter and matches direction, ReferencedGroupInfo.GroupId, protocol and ports.
  4. Revokes only what you nameWith --apply, it saves the matched rules to stale-sg-rules-<region>-<timestamp>.json, then calls RevokeSecurityGroupIngress and RevokeSecurityGroupEgress with SecurityGroupRuleIds for each group in --group-ids.

The guide to AWS SDK v3 paginators and async iterators explains the paginate* helpers used here.

Prerequisites

  • Node.js 18 or later, npm, tsx and @aws-sdk/client-ec2.
  • A read-only profile for the report and a network admin profile for --apply.
  • If the security groups are managed by CloudFormation, CDK or Terraform, remove the rules in code. A console or script change creates drift; the script to detect CloudFormation drift across all stacks shows where.

Which IAM permissions does it need?

stale-security-group-rules-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadVpcAndRules",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeVpcs",
        "ec2:DescribeStaleSecurityGroups",
        "ec2:DescribeSecurityGroupRules"
      ],
      "Resource": "*"
    },
    {
      "Sid": "RevokeWithApply",
      "Effect": "Allow",
      "Action": [
        "ec2:RevokeSecurityGroupIngress",
        "ec2:RevokeSecurityGroupEgress"
      ],
      "Resource": "arn:aws:ec2:us-east-1:111122223333:security-group/*"
    }
  ]
}

The three describe actions don’t support resource-level permissions, so they need "Resource": "*". Drop the second statement for an audit-only role. The free IAM policy generator for TypeScript code produces a starting point from any script like this one.

The script to describe stale security groups in every VPC

find-stale-security-group-rules.ts

// find-stale-security-group-rules.ts
// Runs DescribeStaleSecurityGroups for every VPC in a Region and matches each stale rule to its
// security group rule ID. Report only unless you pass --apply with --group-ids: then it saves the
// matched rules to a JSON backup and revokes them from the security groups you named.
// Usage:
//   npx tsx find-stale-security-group-rules.ts [--region eu-west-1]
//   npx tsx find-stale-security-group-rules.ts --group-ids sg-0abc123,sg-0def456 --apply
import { writeFile } from "node:fs/promises";
import {
  EC2Client,
  RevokeSecurityGroupEgressCommand,
  RevokeSecurityGroupIngressCommand,
  paginateDescribeSecurityGroupRules,
  paginateDescribeStaleSecurityGroups,
  paginateDescribeVpcs,
  type SecurityGroupRule,
  type StaleIpPermission,
} from "@aws-sdk/client-ec2";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const groupIds = new Set((flag("--group-ids") ?? "").split(",").map((s) => s.trim()).filter(Boolean));
const apply = args.includes("--apply");
const ec2 = new EC2Client({ region });

interface Finding {
  Vpc: string;
  Group: string;
  GroupName: string;
  Direction: "ingress" | "egress";
  Ports: string;
  References: string;
  Peering: string;
  RuleId: string;
}

const ports = (p: { IpProtocol?: string; FromPort?: number; ToPort?: number }): string =>
  p.IpProtocol === "-1" ? "all traffic" : `${p.IpProtocol} ${p.FromPort}${p.FromPort === p.ToPort ? "" : `-${p.ToPort}`}`;

// Finds the rule ID(s) in the group that reference the stale group with the same protocol and ports.
function matchRules(rules: SecurityGroupRule[], perm: StaleIpPermission, refGroupId: string, egress: boolean): SecurityGroupRule[] {
  return rules.filter(
    (r) =>
      r.IsEgress === egress &&
      r.ReferencedGroupInfo?.GroupId === refGroupId &&
      r.IpProtocol === perm.IpProtocol &&
      r.FromPort === perm.FromPort &&
      r.ToPort === perm.ToPort,
  );
}

async function rulesFor(groupId: string): Promise<SecurityGroupRule[]> {
  const out: SecurityGroupRule[] = [];
  const pages = paginateDescribeSecurityGroupRules({ client: ec2 }, { Filters: [{ Name: "group-id", Values: [groupId] }] });
  for await (const page of pages) out.push(...(page.SecurityGroupRules ?? []));
  return out;
}

async function main(): Promise<void> {
  if (apply && groupIds.size === 0) {
    console.error("--apply needs --group-ids with the security groups to clean up.");
    process.exit(1);
  }
  const findings: Finding[] = [];
  const matched = new Map<string, SecurityGroupRule>();
  let vpcCount = 0;

  for await (const vpcPage of paginateDescribeVpcs({ client: ec2 }, {})) {
    for (const vpc of vpcPage.Vpcs ?? []) {
      if (!vpc.VpcId) continue;
      vpcCount++;
      for await (const page of paginateDescribeStaleSecurityGroups({ client: ec2 }, { VpcId: vpc.VpcId })) {
        for (const sg of page.StaleSecurityGroupSet ?? []) {
          if (!sg.GroupId) continue;
          const rules = await rulesFor(sg.GroupId);
          const sets: [StaleIpPermission[], boolean][] = [
            [sg.StaleIpPermissions ?? [], false],
            [sg.StaleIpPermissionsEgress ?? [], true],
          ];
          for (const [perms, egress] of sets) {
            for (const perm of perms) {
              for (const pair of perm.UserIdGroupPairs ?? []) {
                const hits = pair.GroupId ? matchRules(rules, perm, pair.GroupId, egress) : [];
                for (const h of hits) if (h.SecurityGroupRuleId) matched.set(h.SecurityGroupRuleId, h);
                findings.push({
                  Vpc: vpc.VpcId,
                  Group: sg.GroupId,
                  GroupName: sg.GroupName ?? "",
                  Direction: egress ? "egress" : "ingress",
                  Ports: ports(perm),
                  References: `${pair.UserId ? `${pair.UserId}/` : ""}${pair.GroupId ?? "?"}`,
                  Peering: pair.VpcPeeringConnectionId ? `${pair.VpcPeeringConnectionId} (${pair.PeeringStatus ?? "?"})` : "-",
                  RuleId: hits.map((h) => h.SecurityGroupRuleId).join(",") || "not matched",
                });
              }
            }
          }
        }
      }
    }
  }

  console.log(`VPCs checked in ${region}: ${vpcCount}; stale rules: ${findings.length}`);
  if (findings.length) console.table(findings);
  if (!apply) {
    console.log("Report only. Pass --group-ids and --apply to revoke the matched rules.");
    return;
  }

  const toRevoke = [...matched.values()].filter((r) => r.GroupId && groupIds.has(r.GroupId));
  if (toRevoke.length === 0) {
    console.log("No matched stale rules in the groups you named.");
    return;
  }
  const backup = `stale-sg-rules-${region}-${Date.now()}.json`;
  await writeFile(backup, JSON.stringify(toRevoke, null, 2));
  console.log(`Saved ${toRevoke.length} rules to ${backup}`);

  for (const groupId of groupIds) {
    const inGroup = toRevoke.filter((r) => r.GroupId === groupId);
    const ingress = inGroup.filter((r) => !r.IsEgress).map((r) => r.SecurityGroupRuleId ?? "");
    const egress = inGroup.filter((r) => r.IsEgress).map((r) => r.SecurityGroupRuleId ?? "");
    try {
      if (ingress.length) await ec2.send(new RevokeSecurityGroupIngressCommand({ GroupId: groupId, SecurityGroupRuleIds: ingress }));
      if (egress.length) await ec2.send(new RevokeSecurityGroupEgressCommand({ GroupId: groupId, SecurityGroupRuleIds: egress }));
      console.log(`${groupId}: revoked ${ingress.length} ingress and ${egress.length} egress rules`);
    } catch (err) {
      console.log(`${groupId}: error ${err instanceof Error ? err.name : String(err)}`);
    }
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript @types/node

# Report for one Region
AWS_PROFILE=readonly npx tsx find-stale-security-group-rules.ts --region eu-west-1

# Revoke the matched stale rules in one group
AWS_PROFILE=network-admin npx tsx find-stale-security-group-rules.ts --region eu-west-1 --group-ids sg-0a12b34c56d78e901 --apply

Sample output

Output

VPCs checked in us-east-1: 2; stale rules: 2
┌─────────┬─────────────────────────┬────────────────────────┬──────────────┬───────────┬───────────────┬─────────────────────────────────────┬───────────────────────────────────┬─────────────────────────┐
│ (index) │ Vpc                     │ Group                  │ GroupName    │ Direction │ Ports         │ References                          │ Peering                           │ RuleId                  │
├─────────┼─────────────────────────┼────────────────────────┼──────────────┼───────────┼───────────────┼─────────────────────────────────────┼───────────────────────────────────┼─────────────────────────┤
│ 0       │ 'vpc-0123abcd4567ef890' │ 'sg-0a12b34c56d78e901' │ 'orders-api' │ 'ingress' │ 'tcp 5432'    │ '444455556666/sg-0f9e8d7c6b5a41234' │ 'pcx-0d1e2f3a4b5c6d7e8 (deleted)' │ 'sgr-0b1c2d3e4f5a6b7c8' │
│ 1       │ 'vpc-0123abcd4567ef890' │ 'sg-0a12b34c56d78e901' │ 'orders-api' │ 'egress'  │ 'all traffic' │ 'sg-04c3b2a1f0e9d8765'              │ '-'                               │ 'sgr-09a8b7c6d5e4f3a21' │
└─────────┴─────────────────────────┴────────────────────────┴──────────────┴───────────┴───────────────┴─────────────────────────────────────┴───────────────────────────────────┴─────────────────────────┘
Report only. Pass --group-ids and --apply to revoke the matched rules.

$ npx tsx find-stale-security-group-rules.ts --group-ids sg-0a12b34c56d78e901 --apply
...
Saved 2 rules to stale-sg-rules-us-east-1-1790668087254.json
sg-0a12b34c56d78e901: revoked 1 ingress and 1 egress rules

IDs are illustrative. The inbound PostgreSQL rule still points at a group in account 444455556666 through a peering connection whose status is deleted. The outbound rule references a group that no longer exists. Both were matched to rule IDs, saved and revoked.

What should you check before revoking?

  • Is the peering coming back? If a replacement peering between the same VPCs is planned, the rule will become active again. Decide whether you want that access or a new, reviewed rule.
  • Who owns the group? Check tags and your infrastructure code. Removing a rule outside the code works until the next deploy puts it back.
  • What did the rule allow? Keep the JSON backup with your change record. It holds the protocol, ports, description and referenced group of every revoked rule.
  • Is anything connecting now? A stale rule points at a group or path that no longer exists, so revoking it shouldn’t break a working connection. If a connection fails after the cleanup, the cause is elsewhere; troubleshooting EC2 SSH connection failures walks through the usual suspects. VPC Flow Logs show what’s actually reaching an interface; the example to find VPCs without flow logs checks they’re on.

Troubleshooting

  • RuleId shows not matched. The stale rule’s protocol or ports didn’t line up with a rule from DescribeSecurityGroupRules. Remove it in the console under Security groups, Actions, Manage stale rules.
  • InvalidSecurityGroupRuleId.NotFound. The EC2 error reference says the specified security group rule does not exist. Someone removed it between the scan and the revoke; run the report again.
  • InvalidGroup.NotFound. The security group itself is gone, or the ID in --group-ids belongs to another Region.
  • UnauthorizedOperation. Your role lacks the revoke permission, or an SCP blocks it. The guide to troubleshoot AWS IAM access denied errors helps find which.

Ask ChatWithCloud instead

Ask ChatWithCloud “Do any security groups in this Region have stale rules from deleted peering connections?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result, using one profile and Region per session. It runs generated code without a confirmation step, so keep it on a read-only profile and revoke rules with the script above. The ChatWithCloud security page covers what’s sent for processing, and the guide to troubleshoot AWS infrastructure with an AI CLI has more network questions to try.

Frequently asked questions

How do I find stale security group rules with the AWS CLI?

Run aws ec2 describe-stale-security-groups --vpc-id vpc-0123abcd4567ef890 for each VPC. The VpcId parameter is required, so loop over aws ec2 describe-vpcs to cover a whole Region.

Does AWS delete stale security group rules automatically?

No. Stale rules stay in the security group until you revoke them with RevokeSecurityGroupIngress or RevokeSecurityGroupEgress, or delete them in the VPC console.

Can a stale rule become active again?

Yes. If it’s stale because a peering connection was deleted, creating a new peering connection between the same VPCs makes the rule active again.

Does DescribeStaleSecurityGroups cover transit gateways?

Yes. It includes rules that reference groups across a transit gateway that was deleted or had security group referencing turned off.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud