Practical infra coding

Flag security group rules open to the internet

mediumCloud SDK automation Must-do

Problem statement

A security review wants every inbound firewall rule that exposes something other than a website to the whole internet. Given a saved "describe security groups" response, list those rules.

The file is a simplified version of an AWS EC2 DescribeSecurityGroups response. Each group has IpPermissions; each permission has IpProtocol (tcp, udp, or -1 meaning all protocols and ports), FromPort/ToPort (absent for -1), and sources in IpRanges[].CidrIp and Ipv6Ranges[].CidrIpv6.

  • A source counts as "the internet" if it is /8 or wider for IPv4, or /32 or wider for IPv6. That catches 0.0.0.0/0 and ::/0, and also tricks like 0.0.0.0/1 that cover half the internet.
  • Public tcp 80 and 443 are allowed, but only as single-port rules. A range such as 80-443 also opens everything in between.
  • HIGH: all traffic (-1), or a range that includes a sensitive port: 22 SSH, 3389 RDP, 3306 MySQL, 5432 Postgres, 6379 Redis. MEDIUM: any other public port.
  • Rules from private ranges are fine. A CIDR that does not parse is reported as CHECK.
  • Print HIGH findings first, then by group ID. Exit 1 if there is any HIGH finding.

security_groups.json

JSON
{
"SecurityGroups": [
{"GroupId": "sg-01", "GroupName": "web-public",
"IpPermissions": [
{"IpProtocol": "tcp", "FromPort": 443, "ToPort": 443,
"IpRanges": [{"CidrIp": "0.0.0.0/0"}], "Ipv6Ranges": [{"CidrIpv6": "::/0"}]},
{"IpProtocol": "tcp", "FromPort": 22, "ToPort": 22,
"IpRanges": [{"CidrIp": "0.0.0.0/0", "Description": "temp debug"}]}
]},
{"GroupId": "sg-02", "GroupName": "db-private",
"IpPermissions": [
{"IpProtocol": "tcp", "FromPort": 5432, "ToPort": 5432,
"IpRanges": [{"CidrIp": "10.0.0.0/16"}]}
]},
{"GroupId": "sg-03", "GroupName": "legacy-app",
"IpPermissions": [
{"IpProtocol": "tcp", "FromPort": 8000, "ToPort": 9000,
"Ipv6Ranges": [{"CidrIpv6": "::/0"}]},
{"IpProtocol": "-1", "IpRanges": [{"CidrIp": "0.0.0.0/0"}]},
{"IpProtocol": "udp", "FromPort": 53, "ToPort": 53,
"IpRanges": [{"CidrIp": "192.168.0.0/24"}]}
]},
{"GroupId": "sg-04", "GroupName": "lb-edge",
"IpPermissions": [
{"IpProtocol": "tcp", "FromPort": 80, "ToPort": 443,
"IpRanges": [{"CidrIp": "0.0.0.0/0"}]},
{"IpProtocol": "tcp", "FromPort": 3389, "ToPort": 3389,
"IpRanges": [{"CidrIp": "0.0.0.0/1"}]}
]}
]
}

Examples

Example 1

Input: python solution.py security_groups.json; echo "exit: $?"

Output: HIGH sg-01 web-public tcp/22 from 0.0.0.0/0 (SSH) HIGH sg-03 legacy-app any/all from 0.0.0.0/0 (all traffic) HIGH sg-04 lb-edge tcp/3389 from 0.0.0.0/1 (RDP) MEDIUM sg-03 legacy-app tcp/8000-9000 from ::/0 (non-web port) MEDIUM sg-04 lb-edge tcp/80-443 from 0.0.0.0/0 (non-web port) 5 finding(s), 3 high exit: 1

Explanation: sg-01's 443 rules are expected. sg-02 and the DNS rule in sg-03 use private ranges. 0.0.0.0/1 is not /0 but still covers 2 billion addresses.

Hints

Approach

  1. Collect sources from both lists. sources() yields IPv4 and IPv6 CIDRs, so the same logic covers both.
  2. Parse, do not string-compare. ipaddress.ip_network(..., strict=False) understands any notation, and prefixlen measures how wide the range is. Anything /8 or wider (IPv4) or /32 or wider (IPv6) is treated as public. The thresholds are a policy choice; the point is to compare sizes, not strings. Unparseable input is reported, not ignored.
  3. Handle "all traffic" first. -1 has no ports; it is always HIGH.
  4. Allow only exact web rules. A rule is skipped only if it is a single port and (protocol, port) is in the allow-list. 80-443 is therefore flagged.
  5. Grade by overlap. A sensitive port is hit if it falls anywhere inside FromPort..ToPort, so a 0-65535 rule shows every sensitive service it exposes.
  6. Sort and exit. HIGH first, then group ID; exit 1 on any HIGH so this can run in CI against infrastructure plans or as a scheduled audit.

r is the total number of (rule, source) pairs.

ComplexityTime O(r)Space O(r)
Python
import ipaddress
import json
import sys
ALLOWED_PUBLIC = {("tcp", 80), ("tcp", 443)}
SENSITIVE = {22: "SSH", 3389: "RDP", 5432: "Postgres", 3306: "MySQL", 6379: "Redis"}
WIDE_PREFIX = {4: 8, 6: 32} # anything this broad or broader counts as "the internet"
def sources(perm):
for r in perm.get("IpRanges", []):
yield r["CidrIp"]
for r in perm.get("Ipv6Ranges", []):
yield r["CidrIpv6"]
def port_text(perm):
if perm.get("IpProtocol") == "-1":
return "all", "all traffic"
lo, hi = perm.get("FromPort"), perm.get("ToPort")
return (str(lo) if lo == hi else f"{lo}-{hi}"), None
def findings(sg):
for perm in sg.get("IpPermissions", []):
proto = perm.get("IpProtocol")
for cidr in sources(perm):
try:
net = ipaddress.ip_network(cidr, strict=False)
except ValueError:
yield ("CHECK", f"unparseable source {cidr!r}")
continue
if net.prefixlen > WIDE_PREFIX[net.version]:
continue # a specific network, fine
ports, note = port_text(perm)
lo, hi = perm.get("FromPort"), perm.get("ToPort")
if proto != "-1" and lo == hi and (proto, lo) in ALLOWED_PUBLIC:
continue # public web port, expected
hits = [name for p, name in SENSITIVE.items()
if proto == "-1" or (proto == "tcp" and lo <= p <= hi)]
severity = "HIGH" if note or hits else "MEDIUM"
detail = note or (", ".join(sorted(hits)) if hits else "non-web port")
yield (severity, f"{proto if proto != '-1' else 'any'}/{ports} from {cidr} ({detail})")
def main(path):
with open(path, encoding="utf-8") as f:
data = json.load(f)
rows = []
for sg in data.get("SecurityGroups", []):
for severity, text in findings(sg):
rows.append((severity != "HIGH", sg["GroupId"], sg.get("GroupName", "-"), severity, text))
for _, gid, name, severity, text in sorted(rows):
print(f"{severity:<6} {gid} {name:<11} {text}")
high = sum(1 for r in rows if r[3] == "HIGH")
print(f"{len(rows)} finding(s), {high} high")
return 1 if high else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "security_groups.json"))

Follow-up questions

  • Add an exceptions file (sg-01:22 until 2026-10-01, reason: incident debug) and expire old exceptions.
  • Also report which instances use each flagged group, so the owner knows what is exposed.
  • Generate the API calls that would replace 0.0.0.0/0 on port 22 with your office range, in dry-run mode.

Frequently asked questions

An SSH or database port open to the internet is one of the most common cloud misconfigurations, and scanning for it is standard security automation. The exercise checks whether you think about the edge cases that attackers find first: IPv6, broad-but-not-zero CIDRs, port ranges and all-traffic rules.

No. A bastion host or VPN endpoint might legitimately accept traffic from anywhere on one port. Real audit tools support an exceptions list (by group ID or tag) with a reason and an expiry date, so accepted risks are documented instead of silently ignored.

Rules whose source is another security group or a prefix list are not CIDRs and need resolving first. Egress rules are not checked. And a public rule only matters if something is actually reachable: an instance with a public IP or a load balancer in front of it.