To find open security groups in AWS, call EC2 DescribeSecurityGroups and flag inbound rules whose source is 0.0.0.0/0 or ::/0. Treat a rule as high risk when it’s all traffic (protocol -1) or when its port range covers SSH (22), RDP (3389) or a database port. The read-only script below does this per region and shows which groups are in use.
An SSH port open to the world is one of the most common findings in any AWS review, and one of the easiest to miss: a rule added “temporarily” during an incident, a port range of 0-65535 that nobody reads closely, or an IPv6 rule next to a carefully restricted IPv4 one. This example is for engineers who want to find open security groups in AWS quickly and get a prioritized list instead of a raw dump of every rule.
It’s one of our runnable AWS practical examples in TypeScript. The sibling script to find public and private S3 buckets covers the storage side of the same exposure review.
Which rules count as “too open”?
The script reports every inbound rule open to 0.0.0.0/0 (all IPv4) or ::/0 (all IPv6), and grades it:
| Severity | Rule looks like | Why |
|---|---|---|
| HIGH | Protocol -1 (all traffic) from the internet |
Every port on every attached resource is reachable. |
| HIGH | TCP/UDP range that includes 22, 3389, 3306, 5432, 1433, 6379, 27017 and similar | Remote administration and databases attract constant automated scanning and password guessing. |
| MEDIUM | Any other TCP/UDP port from the internet, such as 80 or 443 | Often intended (a public load balancer), but worth confirming. |
Port ranges are checked by overlap, not by exact match. A rule for 1000-4000 is flagged because it covers MySQL on 3306 and RDP on 3389, and 0-65535 is flagged for every sensitive port at once. ICMP rules are skipped, because their FromPort and ToPort fields hold ICMP type and code, not ports. The CIS Amazon Web Services Benchmarks make the same point: no security group should allow ingress from 0.0.0.0/0 or ::/0 to remote server administration ports.
What does the script do?
- Counts group usage
paginateDescribeNetworkInterfacestallies how many network interfaces use each group, so an open rule on a group with 12 ENIs sorts above one on an unused group. - Reads every group’s inbound rules
paginateDescribeSecurityGroups, thenIpPermissionswith bothIpRangesandIpv6Ranges. - Grades each world-open ruleAll-traffic rules and ranges that overlap the sensitive-port list are HIGH; other ports are MEDIUM.
- Reports and sets an exit codeA table or
--json, and exit code 1 when any HIGH finding exists, so it can gate a CI pipeline.--all-regionsscans every enabled region.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-ec2package. - A profile with
AWS_REGIONset, or pass--all-regions. Security groups are regional.
Which IAM permissions does it need?
Three EC2 describe actions. EC2 describe calls don’t support resource-level permissions, so "Resource": "*" is the tightest scope available.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadSecurityGroupsAndUsage",
"Effect": "Allow",
"Action": [
"ec2:DescribeSecurityGroups",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeRegions"
],
"Resource": "*"
}
]
}
For how to tighten policies that do support resource ARNs, see the walkthrough to review an IAM policy for least privilege. The free tool that generates IAM policies from TypeScript code lists the actions if you extend the script, for example to revoke rules.
The script to find open security groups in AWS
// open-security-groups.ts
// Finds security group inbound rules open to the whole internet (0.0.0.0/0 or ::/0)
// on sensitive ports, including "all traffic" rules and port ranges that cover them.
// Read-only. Usage: npx tsx open-security-groups.ts [--all-regions] [--json]
import {
EC2Client,
DescribeRegionsCommand,
paginateDescribeSecurityGroups,
paginateDescribeNetworkInterfaces,
type IpPermission,
} from "@aws-sdk/client-ec2";
// Ports that should never be reachable from 0.0.0.0/0 or ::/0.
const SENSITIVE_PORTS: Record<number, string> = {
20: "FTP data", 21: "FTP", 22: "SSH", 23: "Telnet", 135: "MS RPC", 445: "SMB",
1433: "SQL Server", 1521: "Oracle", 2375: "Docker API", 2376: "Docker API (TLS)",
3306: "MySQL/MariaDB", 3389: "RDP", 5432: "PostgreSQL", 5601: "Kibana",
5900: "VNC", 5984: "CouchDB", 6379: "Redis", 9042: "Cassandra", 9200: "Elasticsearch/OpenSearch",
11211: "Memcached", 27017: "MongoDB",
};
const WORLD = new Set(["0.0.0.0/0", "::/0"]);
type Finding = {
region: string;
groupId: string;
groupName: string;
vpcId: string;
inUse: number;
source: string;
protocol: string;
ports: string;
severity: "HIGH" | "MEDIUM";
exposes: string;
};
// Returns the sensitive ports a rule exposes, or "ALL" for all-traffic rules.
function exposedPorts(p: IpPermission): { ports: string; hits: string[] } {
const proto = p.IpProtocol ?? "";
if (proto === "-1") return { ports: "all", hits: ["ALL TRAFFIC"] };
// Only TCP (6) and UDP (17) use ports; ICMP rules carry type/code in FromPort/ToPort.
if (!["tcp", "udp", "6", "17"].includes(proto)) return { ports: `${proto} (no ports)`, hits: [] };
const from = p.FromPort ?? 0;
const to = p.ToPort ?? 65535;
const hits = Object.entries(SENSITIVE_PORTS)
.filter(([port]) => Number(port) >= from && Number(port) <= to)
.map(([port, name]) => `${port} ${name}`);
return { ports: from === to ? String(from) : `${from}-${to}`, hits };
}
async function scanRegion(region: string): Promise<Finding[]> {
const ec2 = new EC2Client({ region });
// Count network interfaces per group, so you can see which open groups are actually in use.
const inUse = new Map<string, number>();
for await (const page of paginateDescribeNetworkInterfaces({ client: ec2 }, {})) {
for (const eni of page.NetworkInterfaces ?? []) {
for (const g of eni.Groups ?? []) {
if (g.GroupId) inUse.set(g.GroupId, (inUse.get(g.GroupId) ?? 0) + 1);
}
}
}
const findings: Finding[] = [];
for await (const page of paginateDescribeSecurityGroups({ client: ec2 }, {})) {
for (const sg of page.SecurityGroups ?? []) {
for (const perm of sg.IpPermissions ?? []) {
const sources = [
...(perm.IpRanges ?? []).map((r) => r.CidrIp),
...(perm.Ipv6Ranges ?? []).map((r) => r.CidrIpv6),
].filter((c): c is string => c !== undefined && WORLD.has(c));
if (sources.length === 0) continue;
const { ports, hits } = exposedPorts(perm);
for (const source of sources) {
findings.push({
region,
groupId: sg.GroupId ?? "",
groupName: sg.GroupName ?? "",
vpcId: sg.VpcId ?? "",
inUse: inUse.get(sg.GroupId ?? "") ?? 0,
source,
protocol: perm.IpProtocol === "-1" ? "all" : perm.IpProtocol ?? "",
ports,
// HIGH: a sensitive port or all traffic. MEDIUM: open to the world on other ports (review, e.g. 80/443).
severity: hits.length > 0 ? "HIGH" : "MEDIUM",
exposes: hits.join(", "),
});
}
}
}
}
return findings;
}
async function main(): Promise<void> {
const allRegions = process.argv.includes("--all-regions");
let regions = [process.env.AWS_REGION ?? "us-east-1"];
if (allRegions) {
const res = await new EC2Client({ region: regions[0] }).send(new DescribeRegionsCommand({}));
regions = (res.Regions ?? []).map((r) => r.RegionName).filter((r): r is string => !!r);
}
const findings: Finding[] = [];
for (const region of regions) findings.push(...(await scanRegion(region)));
findings.sort((a, b) => a.severity.localeCompare(b.severity) || b.inUse - a.inUse);
if (process.argv.includes("--json")) {
console.log(JSON.stringify(findings, null, 2));
return;
}
if (findings.length === 0) {
console.log(`No inbound rules open to 0.0.0.0/0 or ::/0 in ${regions.join(", ")}.`);
return;
}
console.table(
findings.map((f) => ({
Severity: f.severity,
Region: f.region,
Group: `${f.groupId} (${f.groupName})`,
ENIs: f.inUse,
Source: f.source,
Proto: f.protocol,
Ports: f.ports,
Exposes: f.exposes,
})),
);
const high = findings.filter((f) => f.severity === "HIGH");
console.log(`${findings.length} world-open rules, ${high.length} HIGH (sensitive ports or all traffic)`);
process.exitCode = high.length > 0 ? 1 : 0; // non-zero exit for CI when HIGH findings exist
}
main().catch((err: unknown) => {
console.error(err);
process.exit(2);
});
Adjust SENSITIVE_PORTS to your environment: add application admin ports, or remove ones you don’t run. The script reads only CIDR sources. Rules that reference prefix lists or other security groups aren’t world-open by themselves and are skipped.
How do you run it?
npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# One region
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx open-security-groups.ts
# Every enabled region, JSON for a ticket or a CI artifact
AWS_PROFILE=readonly npx tsx open-security-groups.ts --all-regions --json > open-sgs.json
echo "exit code: $?" # 1 when any HIGH finding exists
Sample output
┌─────────┬──────────┬─────────────┬─────────────────────────────────────┬──────┬─────────────┬───────┬─────────────┬────────────────────────────────┐
│ (index) │ Severity │ Region │ Group │ ENIs │ Source │ Proto │ Ports │ Exposes │
├─────────┼──────────┼─────────────┼─────────────────────────────────────┼──────┼─────────────┼───────┼─────────────┼────────────────────────────────┤
│ 0 │ 'HIGH' │ 'us-east-1' │ 'sg-0a1b2c3d4e5f60718 (bastion)' │ 2 │ '0.0.0.0/0' │ 'tcp' │ '22' │ '22 SSH' │
│ 1 │ 'HIGH' │ 'us-east-1' │ 'sg-0a1b2c3d4e5f60718 (bastion)' │ 2 │ '::/0' │ 'tcp' │ '22' │ '22 SSH' │
│ 2 │ 'HIGH' │ 'us-east-1' │ 'sg-0123456789abcdef0 (legacy-app)' │ 1 │ '0.0.0.0/0' │ 'tcp' │ '3000-3400' │ '3306 MySQL/MariaDB, 3389 RDP' │
│ 3 │ 'HIGH' │ 'us-east-1' │ 'sg-0fedcba9876543210 (debug-temp)' │ 0 │ '0.0.0.0/0' │ 'all' │ 'all' │ 'ALL TRAFFIC' │
│ 4 │ 'MEDIUM' │ 'us-east-1' │ 'sg-0b2c3d4e5f6071829 (public-alb)' │ 4 │ '0.0.0.0/0' │ 'tcp' │ '443' │ '' │
│ 5 │ 'MEDIUM' │ 'us-east-1' │ 'sg-0b2c3d4e5f6071829 (public-alb)' │ 4 │ '::/0' │ 'tcp' │ '443' │ '' │
└─────────┴──────────┴─────────────┴─────────────────────────────────────┴──────┴─────────────┴───────┴─────────────┴────────────────────────────────┘
6 world-open rules, 4 HIGH (sensitive ports or all traffic)
Group IDs, names and counts are illustrative. The legacy-app row shows why ranges matter: a single 3000-3400 rule exposes both MySQL and RDP.
How do you fix an open security group?
Replace the world-open source with something narrower instead of simply deleting the rule, so you don’t break a working path. To see who actually connects before you narrow a rule, turn on flow logs with the script to find VPCs without flow logs. For SSH and RDP, the usual replacements are AWS Systems Manager Session Manager (no inbound port at all) or a rule limited to your VPN’s CIDR. For databases, allow only the application’s security group as the source. If removing a rule locks you out of an instance, the batch’s guide to troubleshoot why you can’t SSH into an EC2 instance walks through the checks.
A world-open rule is only reachable if something with a public IP address uses the group. The script to find EC2 instances with public IP addresses starts from that side and prints the world-open rules next to each address. Cross-check the HIGH findings against the report of EC2 instances by type, launch time and region to see which instances are actually exposed. On those instances, also find EC2 instances without IMDSv2 and require it, so an SSRF bug in an exposed app can’t read the instance role’s credentials.
Troubleshooting
UnauthorizedOperation. The profile lacks one of the describe actions, or an SCP blocks the region. The guide to troubleshoot AWS IAM access denied errors covers decoding EC2’s encoded authorization messages.AuthFailurewith--all-regions.DescribeRegionsreturns enabled regions only, but an SCP may still deny some. Run per region, or catch errors insidescanRegion.- A group with 0 ENIs still has findings. It’s unused today but can be attached tomorrow. Delete unused groups or remove the rule; the script to find unused security groups in your AWS account checks every dependency before deleting.
- Network ACLs and egress aren’t covered. The script reads inbound security group rules only. Default VPC security groups should also restrict all traffic. Default VPCs you don’t use can go altogether; the script to find and delete default VPCs you aren’t using checks every Region.
Ask ChatWithCloud instead
You can ask ChatWithCloud “Which security groups allow SSH or RDP from 0.0.0.0/0, and which instances use them?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and summarizes the answer. The guide to analyze your AWS security posture with an AI CLI has more questions in this style. ChatWithCloud works on one profile and region per session and runs changes without a confirmation step, so don’t ask it to revoke rules unless you mean it. See how ChatWithCloud handles your AWS credentials and data before you connect a production account.
Frequently asked questions
How do I find security groups open to 0.0.0.0/0 with the AWS CLI?
Run aws ec2 describe-security-groups --filters Name=ip-permission.cidr,Values=0.0.0.0/0, and repeat with Name=ip-permission.ipv6-cidr,Values=::/0. It lists groups, not graded rules.
Is port 443 open to the world a problem?
Not on a public load balancer or web server, where it’s the point. It is a problem on a database or internal service, which is why the script reports it as MEDIUM for review.
Does a security group rule with all traffic include IPv6?
Only if the rule has a ::/0 range. IPv4 and IPv6 sources are separate entries, which is why the script checks both lists.
Can AWS find open security groups for me?
AWS Security Hub and AWS Config have managed checks for unrestricted SSH and other ports, at extra cost. This script is a free, read-only spot check.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud