Flag security group rules open to the internet
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
/8or wider for IPv4, or/32or wider for IPv6. That catches0.0.0.0/0and::/0, and also tricks like0.0.0.0/1that cover half the internet. - Public
tcp80 and 443 are allowed, but only as single-port rules. A range such as80-443also 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
{ "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
- Collect sources from both lists.
sources()yields IPv4 and IPv6 CIDRs, so the same logic covers both. - Parse, do not string-compare.
ipaddress.ip_network(..., strict=False)understands any notation, andprefixlenmeasures how wide the range is. Anything/8or wider (IPv4) or/32or 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. - Handle "all traffic" first.
-1has no ports; it is always HIGH. - 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-443is therefore flagged. - Grade by overlap. A sensitive port is hit if it falls anywhere inside
FromPort..ToPort, so a0-65535rule shows every sensitive service it exposes. - 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.
O(r)Space O(r)import ipaddressimport jsonimport 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/0on 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.