sg-exposure-check 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Narayana Vemburaj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.4
2
+ Name: sg-exposure-check
3
+ Version: 0.1.0
4
+ Summary: Find AWS security groups that expose sensitive ports to the internet, and show which instances, load balancers and databases they actually reach.
5
+ Author: Narayana Vemburaj
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/narayana006/sg-exposure-check
8
+ Project-URL: Source, https://github.com/narayana006/sg-exposure-check
9
+ Project-URL: Issues, https://github.com/narayana006/sg-exposure-check/issues
10
+ Project-URL: Changelog, https://github.com/narayana006/sg-exposure-check/releases
11
+ Keywords: aws,security,security-groups,ec2,vpc,cloud-security,audit,boto3,devsecops,sre
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Classifier: Topic :: System :: Networking :: Monitoring
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: boto3>=1.26
30
+ Requires-Dist: PyYAML>=6.0
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7; extra == "dev"
33
+ Requires-Dist: moto[ec2]>=5.0; extra == "dev"
34
+ Requires-Dist: build>=1.2; extra == "dev"
35
+ Requires-Dist: twine>=5.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # sg-exposure-check
39
+
40
+ Find AWS security groups that expose sensitive ports to the internet, and show which instances, load balancers and databases they actually reach.
41
+
42
+ **Status:** works today: offline analysis of `describe-security-groups` and `describe-network-interfaces` exports, a read-only live mode (boto3, tested with [moto](https://github.com/getmoto/moto)), detection of `0.0.0.0/0` and `::/0` ingress, all-traffic and all-ports rules, a configurable sensitive-port list, attachment resolution through network interfaces, and severity that drops when nothing public is behind the group. Text and JSON output.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install sg-exposure-check
48
+ ```
49
+
50
+ Python 3.10 or newer. Dependencies: `boto3`, `PyYAML`.
51
+
52
+ ## Quick start
53
+
54
+ [`examples/`](examples/) holds an illustrative, hand-written export for a fictional account (`123456789012`, documentation IP ranges):
55
+
56
+ ```bash
57
+ sg-exposure-check -i examples/security-groups.json -i examples/network-interfaces.json
58
+ ```
59
+
60
+ ```text
61
+ Checked 7 security groups: 9 internet-open rules in 5 groups (1 critical, 1 high, 1 medium, 3 low, 3 info)
62
+
63
+ [CRITICAL] sg-0a1b2c3d4e5f00005 (batch-workers) tcp/0-65535 from ::/0: all-ports
64
+ -> ec2-instance i-0a1b2c3d4e5f00005 (eni-0f0e0d0c0b0a00005, ipv6)
65
+ -> ec2-instance i-0a1b2c3d4e5f00006 (eni-0f0e0d0c0b0a00006, ipv6)
66
+ [HIGH] sg-0a1b2c3d4e5f00001 (bastion-ssh) tcp/22 from 0.0.0.0/0: SSH (22)
67
+ -> ec2-instance i-0a1b2c3d4e5f00001 (eni-0f0e0d0c0b0a00001, public 203.0.113.10)
68
+ [MEDIUM] sg-0a1b2c3d4e5f00003 (orders-db) tcp/5432 from 0.0.0.0/0: PostgreSQL (5432)
69
+ -> rds RDSNetworkInterface (eni-0f0e0d0c0b0a00004)
70
+ note: no attached interface has a public IPv4 address
71
+ [LOW] sg-0a1b2c3d4e5f00001 (bastion-ssh) icmp/- from 0.0.0.0/0: other-protocol
72
+ -> ec2-instance i-0a1b2c3d4e5f00001 (eni-0f0e0d0c0b0a00001, public 203.0.113.10)
73
+ [LOW] sg-0a1b2c3d4e5f00004 (legacy-debug) all/all from 0.0.0.0/0: all-traffic
74
+ note: not attached to any network interface
75
+ [LOW] sg-0a1b2c3d4e5f00005 (batch-workers) tcp/8080 from 0.0.0.0/0: open-port
76
+ -> ec2-instance i-0a1b2c3d4e5f00005 (eni-0f0e0d0c0b0a00005, ipv6)
77
+ -> ec2-instance i-0a1b2c3d4e5f00006 (eni-0f0e0d0c0b0a00006, ipv6)
78
+ note: no attached interface has a public IPv4 address
79
+ [INFO] sg-0a1b2c3d4e5f00002 (web-alb) tcp/443 from 0.0.0.0/0: web
80
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00002, public 203.0.113.21)
81
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00003, public 203.0.113.22)
82
+ [INFO] sg-0a1b2c3d4e5f00002 (web-alb) tcp/443 from ::/0: web
83
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00002, public 203.0.113.21)
84
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00003, public 203.0.113.22)
85
+ note: no attached interface has a public IPv6 address
86
+ [INFO] sg-0a1b2c3d4e5f00002 (web-alb) tcp/80 from 0.0.0.0/0: web
87
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00002, public 203.0.113.21)
88
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00003, public 203.0.113.22)
89
+ ```
90
+
91
+ Exit code is `1` because at least one finding is `high` or worse (`--fail-on high` is the default).
92
+
93
+ ## Checking your own account
94
+
95
+ Export once, analyse offline as often as you like:
96
+
97
+ ```bash
98
+ aws ec2 describe-security-groups --region us-east-1 > sgs.json
99
+ aws ec2 describe-network-interfaces --region us-east-1 > enis.json
100
+ sg-exposure-check -i sgs.json -i enis.json
101
+ ```
102
+
103
+ Or read live (strictly read-only) and optionally keep a snapshot of what was read:
104
+
105
+ ```bash
106
+ sg-exposure-check --live --region us-east-1 --profile audit
107
+ sg-exposure-check --live --region us-east-1 --vpc-id vpc-0123456789abcdef0 --save inventory.json
108
+ sg-exposure-check -i inventory.json # replay later, offline
109
+ ```
110
+
111
+ Without network interface data the tool still flags open rules, but it cannot say what they reach and leaves severities unadjusted (it prints a warning).
112
+
113
+ ## How findings are rated
114
+
115
+ Only ingress rules with a source of exactly `0.0.0.0/0` or `::/0` are reported. Rules that reference other security groups, prefix lists or narrower CIDRs are ignored.
116
+
117
+ | Category | Rule | Base severity |
118
+ |---|---|---|
119
+ | `all-traffic` | protocol `-1` (all protocols, all ports) | critical |
120
+ | `all-ports` | TCP or UDP `0-65535` (or `1-65535`) | critical |
121
+ | `sensitive` | TCP/UDP port range that includes a sensitive port | high |
122
+ | `open-port` | any other TCP/UDP port or range | medium |
123
+ | `web` | exactly port 80 or 443 | info |
124
+ | `other-protocol` | ICMP and other non-TCP/UDP protocols | low |
125
+
126
+ Then the severity is adjusted by what the group is attached to:
127
+
128
+ - **Not attached to any network interface:** capped at `low`. The rule is still a latent risk if someone attaches the group later.
129
+ - **Attached, but no attached interface has a public address of that family** (`Association.PublicIp` for IPv4 rules, any IPv6 address for `::/0` rules): lowered one level. Note this is a heuristic: traffic can still arrive through a load balancer or other path that does not use this group.
130
+ - **Attached to an interface with a public address:** unchanged.
131
+
132
+ Each finding lists the network interfaces using the group and what they belong to. That is worked out from the interface's `Attachment.InstanceId`, `InterfaceType`, `RequesterId` and the `Description` that AWS services write on interfaces they manage (for example `ELB app/<name>/<id>` for an Application Load Balancer and `RDSNetworkInterface` for RDS). Kinds recognised: `ec2-instance`, `alb`, `nlb`, `gwlb`, `elb-classic`, `rds`, `lambda`, `elasticache`, `efs`, `nat-gateway`, `vpc-endpoint`, `transit-gateway`; anything else is shown as `eni` with its raw description. RDS interfaces do not carry the DB instance identifier, so those appear as `rds RDSNetworkInterface`.
133
+
134
+ Default sensitive ports: FTP (20, 21), SSH (22), Telnet (23), SMTP (25), MS RPC (135), NetBIOS (137, 139), SMB (445), SQL Server (1433), Oracle (1521), NFS (2049), ZooKeeper (2181), Docker API (2375, 2376), MySQL (3306), RDP (3389), PostgreSQL (5432), Kibana (5601), VNC (5900), CouchDB (5984), Redis (6379), Cassandra (7001, 9042), HDFS NameNode (8020), Kafka (9092), Elasticsearch (9200, 9300), Memcached (11211), MongoDB (27017).
135
+
136
+ ## Usage
137
+
138
+ ```
139
+ sg-exposure-check (-i FILE [-i FILE ...] | --live) [options]
140
+
141
+ -i, --input FILE describe-security-groups and/or describe-network-interfaces
142
+ JSON; repeat for several files, '-' reads stdin
143
+ --live read with ec2:DescribeSecurityGroups and
144
+ ec2:DescribeNetworkInterfaces
145
+ --region, --profile for --live
146
+ --vpc-id VPC --live only: restrict to one VPC
147
+ --save FILE --live only: write the collected inventory as JSON
148
+ --ports-file FILE YAML/JSON {port: label}; replaces the default list
149
+ --port PORT[=LABEL] add a sensitive port (repeatable), e.g. --port 8080=Jenkins
150
+ --min-severity SEV hide findings below info|low|medium|high|critical
151
+ --fail-on SEV|never exit 1 if any shown finding is at least SEV (default high)
152
+ -f, --format text|json
153
+ ```
154
+
155
+ Exit codes: `0` nothing at or above `--fail-on`, `1` findings at or above it, `2` bad input or AWS error.
156
+
157
+ ## Required permissions
158
+
159
+ `--live` only, read-only:
160
+
161
+ - `ec2:DescribeSecurityGroups`
162
+ - `ec2:DescribeNetworkInterfaces`
163
+
164
+ Offline mode needs no AWS access.
165
+
166
+ ## Features
167
+
168
+ - Offline input from AWS CLI exports (separate or combined files, stdin)
169
+ - Live read-only collection with pagination, VPC filter and `--save` snapshots
170
+ - Flags `0.0.0.0/0` and `::/0` ingress, all-traffic and all-ports rules
171
+ - Configurable sensitive ports (`--ports-file`, `--port`)
172
+ - Resolves which instances, load balancers, RDS, Lambda and other interfaces use each group
173
+ - Lower severity for unattached groups and groups with no public address behind them
174
+ - Text and JSON output, `--min-severity`, `--fail-on` exit codes
175
+
176
+ ## Roadmap
177
+
178
+ Not built yet:
179
+
180
+ - Network ACL and route table checks (is the subnet actually internet-routable?)
181
+ - Multi-region scan in one run
182
+ - Flag broad but non-zero CIDRs (e.g. `/8` from outside your network)
183
+
184
+ ## Limitations
185
+
186
+ - It looks at security groups only. Network ACLs, route tables, internet gateways and load balancer listeners are not evaluated, so "has a public address" is an approximation of "reachable from the internet".
187
+ - A rule open to `0.0.0.0/0` on an interface without a public IP may still be reachable through a NAT, NLB or VPC peering path; such findings are downgraded, not dropped.
188
+ - Identifying what owns an interface relies on descriptions and fields that AWS services set; unusual or future services fall back to `eni` with the raw description.
189
+ - One region per live run.
190
+
191
+ ## Contributing
192
+
193
+ Issues and pull requests are welcome.
194
+
195
+ ```bash
196
+ pip install -e ".[dev]"
197
+ pytest
198
+ ```
199
+
200
+ Tests never contact AWS: the live path runs against moto.
201
+
202
+ Built and maintained by [Narayana Vemburaj](https://github.com/narayana006).
203
+
204
+ ## License
205
+
206
+ [MIT](LICENSE)
@@ -0,0 +1,169 @@
1
+ # sg-exposure-check
2
+
3
+ Find AWS security groups that expose sensitive ports to the internet, and show which instances, load balancers and databases they actually reach.
4
+
5
+ **Status:** works today: offline analysis of `describe-security-groups` and `describe-network-interfaces` exports, a read-only live mode (boto3, tested with [moto](https://github.com/getmoto/moto)), detection of `0.0.0.0/0` and `::/0` ingress, all-traffic and all-ports rules, a configurable sensitive-port list, attachment resolution through network interfaces, and severity that drops when nothing public is behind the group. Text and JSON output.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install sg-exposure-check
11
+ ```
12
+
13
+ Python 3.10 or newer. Dependencies: `boto3`, `PyYAML`.
14
+
15
+ ## Quick start
16
+
17
+ [`examples/`](examples/) holds an illustrative, hand-written export for a fictional account (`123456789012`, documentation IP ranges):
18
+
19
+ ```bash
20
+ sg-exposure-check -i examples/security-groups.json -i examples/network-interfaces.json
21
+ ```
22
+
23
+ ```text
24
+ Checked 7 security groups: 9 internet-open rules in 5 groups (1 critical, 1 high, 1 medium, 3 low, 3 info)
25
+
26
+ [CRITICAL] sg-0a1b2c3d4e5f00005 (batch-workers) tcp/0-65535 from ::/0: all-ports
27
+ -> ec2-instance i-0a1b2c3d4e5f00005 (eni-0f0e0d0c0b0a00005, ipv6)
28
+ -> ec2-instance i-0a1b2c3d4e5f00006 (eni-0f0e0d0c0b0a00006, ipv6)
29
+ [HIGH] sg-0a1b2c3d4e5f00001 (bastion-ssh) tcp/22 from 0.0.0.0/0: SSH (22)
30
+ -> ec2-instance i-0a1b2c3d4e5f00001 (eni-0f0e0d0c0b0a00001, public 203.0.113.10)
31
+ [MEDIUM] sg-0a1b2c3d4e5f00003 (orders-db) tcp/5432 from 0.0.0.0/0: PostgreSQL (5432)
32
+ -> rds RDSNetworkInterface (eni-0f0e0d0c0b0a00004)
33
+ note: no attached interface has a public IPv4 address
34
+ [LOW] sg-0a1b2c3d4e5f00001 (bastion-ssh) icmp/- from 0.0.0.0/0: other-protocol
35
+ -> ec2-instance i-0a1b2c3d4e5f00001 (eni-0f0e0d0c0b0a00001, public 203.0.113.10)
36
+ [LOW] sg-0a1b2c3d4e5f00004 (legacy-debug) all/all from 0.0.0.0/0: all-traffic
37
+ note: not attached to any network interface
38
+ [LOW] sg-0a1b2c3d4e5f00005 (batch-workers) tcp/8080 from 0.0.0.0/0: open-port
39
+ -> ec2-instance i-0a1b2c3d4e5f00005 (eni-0f0e0d0c0b0a00005, ipv6)
40
+ -> ec2-instance i-0a1b2c3d4e5f00006 (eni-0f0e0d0c0b0a00006, ipv6)
41
+ note: no attached interface has a public IPv4 address
42
+ [INFO] sg-0a1b2c3d4e5f00002 (web-alb) tcp/443 from 0.0.0.0/0: web
43
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00002, public 203.0.113.21)
44
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00003, public 203.0.113.22)
45
+ [INFO] sg-0a1b2c3d4e5f00002 (web-alb) tcp/443 from ::/0: web
46
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00002, public 203.0.113.21)
47
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00003, public 203.0.113.22)
48
+ note: no attached interface has a public IPv6 address
49
+ [INFO] sg-0a1b2c3d4e5f00002 (web-alb) tcp/80 from 0.0.0.0/0: web
50
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00002, public 203.0.113.21)
51
+ -> alb app/web-alb/50dc6c495c0c9188 (eni-0f0e0d0c0b0a00003, public 203.0.113.22)
52
+ ```
53
+
54
+ Exit code is `1` because at least one finding is `high` or worse (`--fail-on high` is the default).
55
+
56
+ ## Checking your own account
57
+
58
+ Export once, analyse offline as often as you like:
59
+
60
+ ```bash
61
+ aws ec2 describe-security-groups --region us-east-1 > sgs.json
62
+ aws ec2 describe-network-interfaces --region us-east-1 > enis.json
63
+ sg-exposure-check -i sgs.json -i enis.json
64
+ ```
65
+
66
+ Or read live (strictly read-only) and optionally keep a snapshot of what was read:
67
+
68
+ ```bash
69
+ sg-exposure-check --live --region us-east-1 --profile audit
70
+ sg-exposure-check --live --region us-east-1 --vpc-id vpc-0123456789abcdef0 --save inventory.json
71
+ sg-exposure-check -i inventory.json # replay later, offline
72
+ ```
73
+
74
+ Without network interface data the tool still flags open rules, but it cannot say what they reach and leaves severities unadjusted (it prints a warning).
75
+
76
+ ## How findings are rated
77
+
78
+ Only ingress rules with a source of exactly `0.0.0.0/0` or `::/0` are reported. Rules that reference other security groups, prefix lists or narrower CIDRs are ignored.
79
+
80
+ | Category | Rule | Base severity |
81
+ |---|---|---|
82
+ | `all-traffic` | protocol `-1` (all protocols, all ports) | critical |
83
+ | `all-ports` | TCP or UDP `0-65535` (or `1-65535`) | critical |
84
+ | `sensitive` | TCP/UDP port range that includes a sensitive port | high |
85
+ | `open-port` | any other TCP/UDP port or range | medium |
86
+ | `web` | exactly port 80 or 443 | info |
87
+ | `other-protocol` | ICMP and other non-TCP/UDP protocols | low |
88
+
89
+ Then the severity is adjusted by what the group is attached to:
90
+
91
+ - **Not attached to any network interface:** capped at `low`. The rule is still a latent risk if someone attaches the group later.
92
+ - **Attached, but no attached interface has a public address of that family** (`Association.PublicIp` for IPv4 rules, any IPv6 address for `::/0` rules): lowered one level. Note this is a heuristic: traffic can still arrive through a load balancer or other path that does not use this group.
93
+ - **Attached to an interface with a public address:** unchanged.
94
+
95
+ Each finding lists the network interfaces using the group and what they belong to. That is worked out from the interface's `Attachment.InstanceId`, `InterfaceType`, `RequesterId` and the `Description` that AWS services write on interfaces they manage (for example `ELB app/<name>/<id>` for an Application Load Balancer and `RDSNetworkInterface` for RDS). Kinds recognised: `ec2-instance`, `alb`, `nlb`, `gwlb`, `elb-classic`, `rds`, `lambda`, `elasticache`, `efs`, `nat-gateway`, `vpc-endpoint`, `transit-gateway`; anything else is shown as `eni` with its raw description. RDS interfaces do not carry the DB instance identifier, so those appear as `rds RDSNetworkInterface`.
96
+
97
+ Default sensitive ports: FTP (20, 21), SSH (22), Telnet (23), SMTP (25), MS RPC (135), NetBIOS (137, 139), SMB (445), SQL Server (1433), Oracle (1521), NFS (2049), ZooKeeper (2181), Docker API (2375, 2376), MySQL (3306), RDP (3389), PostgreSQL (5432), Kibana (5601), VNC (5900), CouchDB (5984), Redis (6379), Cassandra (7001, 9042), HDFS NameNode (8020), Kafka (9092), Elasticsearch (9200, 9300), Memcached (11211), MongoDB (27017).
98
+
99
+ ## Usage
100
+
101
+ ```
102
+ sg-exposure-check (-i FILE [-i FILE ...] | --live) [options]
103
+
104
+ -i, --input FILE describe-security-groups and/or describe-network-interfaces
105
+ JSON; repeat for several files, '-' reads stdin
106
+ --live read with ec2:DescribeSecurityGroups and
107
+ ec2:DescribeNetworkInterfaces
108
+ --region, --profile for --live
109
+ --vpc-id VPC --live only: restrict to one VPC
110
+ --save FILE --live only: write the collected inventory as JSON
111
+ --ports-file FILE YAML/JSON {port: label}; replaces the default list
112
+ --port PORT[=LABEL] add a sensitive port (repeatable), e.g. --port 8080=Jenkins
113
+ --min-severity SEV hide findings below info|low|medium|high|critical
114
+ --fail-on SEV|never exit 1 if any shown finding is at least SEV (default high)
115
+ -f, --format text|json
116
+ ```
117
+
118
+ Exit codes: `0` nothing at or above `--fail-on`, `1` findings at or above it, `2` bad input or AWS error.
119
+
120
+ ## Required permissions
121
+
122
+ `--live` only, read-only:
123
+
124
+ - `ec2:DescribeSecurityGroups`
125
+ - `ec2:DescribeNetworkInterfaces`
126
+
127
+ Offline mode needs no AWS access.
128
+
129
+ ## Features
130
+
131
+ - Offline input from AWS CLI exports (separate or combined files, stdin)
132
+ - Live read-only collection with pagination, VPC filter and `--save` snapshots
133
+ - Flags `0.0.0.0/0` and `::/0` ingress, all-traffic and all-ports rules
134
+ - Configurable sensitive ports (`--ports-file`, `--port`)
135
+ - Resolves which instances, load balancers, RDS, Lambda and other interfaces use each group
136
+ - Lower severity for unattached groups and groups with no public address behind them
137
+ - Text and JSON output, `--min-severity`, `--fail-on` exit codes
138
+
139
+ ## Roadmap
140
+
141
+ Not built yet:
142
+
143
+ - Network ACL and route table checks (is the subnet actually internet-routable?)
144
+ - Multi-region scan in one run
145
+ - Flag broad but non-zero CIDRs (e.g. `/8` from outside your network)
146
+
147
+ ## Limitations
148
+
149
+ - It looks at security groups only. Network ACLs, route tables, internet gateways and load balancer listeners are not evaluated, so "has a public address" is an approximation of "reachable from the internet".
150
+ - A rule open to `0.0.0.0/0` on an interface without a public IP may still be reachable through a NAT, NLB or VPC peering path; such findings are downgraded, not dropped.
151
+ - Identifying what owns an interface relies on descriptions and fields that AWS services set; unusual or future services fall back to `eni` with the raw description.
152
+ - One region per live run.
153
+
154
+ ## Contributing
155
+
156
+ Issues and pull requests are welcome.
157
+
158
+ ```bash
159
+ pip install -e ".[dev]"
160
+ pytest
161
+ ```
162
+
163
+ Tests never contact AWS: the live path runs against moto.
164
+
165
+ Built and maintained by [Narayana Vemburaj](https://github.com/narayana006).
166
+
167
+ ## License
168
+
169
+ [MIT](LICENSE)
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sg-exposure-check"
7
+ dynamic = ["version"]
8
+ description = "Find AWS security groups that expose sensitive ports to the internet, and show which instances, load balancers and databases they actually reach."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Narayana Vemburaj" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = ["boto3>=1.26", "PyYAML>=6.0"]
15
+ keywords = [
16
+ "aws",
17
+ "security",
18
+ "security-groups",
19
+ "ec2",
20
+ "vpc",
21
+ "cloud-security",
22
+ "audit",
23
+ "boto3",
24
+ "devsecops",
25
+ "sre",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 4 - Beta",
29
+ "Environment :: Console",
30
+ "Intended Audience :: System Administrators",
31
+ "Intended Audience :: Information Technology",
32
+ "Operating System :: OS Independent",
33
+ "Programming Language :: Python :: 3",
34
+ "Programming Language :: Python :: 3.10",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ "Programming Language :: Python :: 3.13",
38
+ "Topic :: Security",
39
+ "Topic :: System :: Systems Administration",
40
+ "Topic :: System :: Networking :: Monitoring",
41
+ "Typing :: Typed",
42
+ ]
43
+
44
+ [project.optional-dependencies]
45
+ dev = ["pytest>=7", "moto[ec2]>=5.0", "build>=1.2", "twine>=5.0"]
46
+
47
+ [project.scripts]
48
+ sg-exposure-check = "sg_exposure_check.cli:main"
49
+
50
+ [project.urls]
51
+ Homepage = "https://github.com/narayana006/sg-exposure-check"
52
+ Source = "https://github.com/narayana006/sg-exposure-check"
53
+ Issues = "https://github.com/narayana006/sg-exposure-check/issues"
54
+ Changelog = "https://github.com/narayana006/sg-exposure-check/releases"
55
+
56
+ [tool.setuptools.dynamic]
57
+ version = { attr = "sg_exposure_check.__version__" }
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
+ [tool.pytest.ini_options]
63
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Find security groups open to the internet and show what they actually reach."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,144 @@
1
+ """Find internet-open ingress rules and rate them by what they actually reach."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .inventory import Inventory, Target
11
+
12
+ SEVERITIES = ["info", "low", "medium", "high", "critical"]
13
+
14
+ DEFAULT_SENSITIVE_PORTS: dict[int, str] = {
15
+ 20: "FTP data", 21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 135: "MS RPC",
16
+ 137: "NetBIOS", 139: "NetBIOS", 445: "SMB", 1433: "SQL Server", 1521: "Oracle",
17
+ 2049: "NFS", 2181: "ZooKeeper", 2375: "Docker API", 2376: "Docker API (TLS)",
18
+ 3306: "MySQL", 3389: "RDP", 5432: "PostgreSQL", 5601: "Kibana", 5900: "VNC",
19
+ 5984: "CouchDB", 6379: "Redis", 7001: "Cassandra", 8020: "HDFS NameNode",
20
+ 9042: "Cassandra CQL", 9092: "Kafka", 9200: "Elasticsearch", 9300: "Elasticsearch",
21
+ 11211: "Memcached", 27017: "MongoDB",
22
+ }
23
+ WEB_PORTS = {80, 443}
24
+ _PROTOCOLS = {"-1": "all", "6": "tcp", "17": "udp", "1": "icmp", "58": "icmpv6"}
25
+ WORLD = {"0.0.0.0/0": "ipv4", "::/0": "ipv6"}
26
+
27
+
28
+ @dataclass
29
+ class Finding:
30
+ group_id: str
31
+ group_name: str
32
+ vpc_id: str
33
+ protocol: str
34
+ ports: str
35
+ source: str
36
+ category: str # all-traffic | all-ports | sensitive | open-port | web | other-protocol
37
+ severity: str
38
+ services: list[str]
39
+ targets: list[Target] | None # None: network interfaces not collected
40
+ notes: list[str] = field(default_factory=list)
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "group_id": self.group_id,
45
+ "group_name": self.group_name,
46
+ "vpc_id": self.vpc_id,
47
+ "protocol": self.protocol,
48
+ "ports": self.ports,
49
+ "source": self.source,
50
+ "category": self.category,
51
+ "severity": self.severity,
52
+ "services": self.services,
53
+ "targets": None if self.targets is None else [t.to_dict() for t in self.targets],
54
+ "notes": self.notes,
55
+ }
56
+
57
+
58
+ def load_ports(path: str | Path) -> dict[int, str]:
59
+ """Load a {port: label} mapping from JSON or YAML (``22: SSH``)."""
60
+ path = Path(path)
61
+ text = path.read_text(encoding="utf-8")
62
+ if path.suffix.lower() == ".json":
63
+ raw = json.loads(text)
64
+ else:
65
+ import yaml
66
+
67
+ raw = yaml.safe_load(text)
68
+ if not isinstance(raw, dict):
69
+ raise ValueError(f"{path}: expected a mapping of port to label")
70
+ return {int(k): str(v) for k, v in raw.items()}
71
+
72
+
73
+ def _shift(severity: str, by: int, floor: str = "info") -> str:
74
+ idx = max(SEVERITIES.index(severity) + by, SEVERITIES.index(floor))
75
+ return SEVERITIES[min(idx, len(SEVERITIES) - 1)]
76
+
77
+
78
+ def classify(perm: dict[str, Any], sensitive: dict[int, str]) -> tuple[str, str, str, str, list[str]]:
79
+ """Return (protocol, ports, category, base severity, matched service labels) for a rule."""
80
+ proto = _PROTOCOLS.get(str(perm.get("IpProtocol", "-1")), str(perm.get("IpProtocol")))
81
+ if proto == "all":
82
+ return "all", "all", "all-traffic", "critical", []
83
+ if proto not in ("tcp", "udp"):
84
+ return proto, "-", "other-protocol", "low", []
85
+ lo, hi = perm.get("FromPort", 0), perm.get("ToPort", 65535)
86
+ if lo is None or hi is None or lo < 0 or hi < 0:
87
+ lo, hi = 0, 65535
88
+ ports = str(lo) if lo == hi else f"{lo}-{hi}"
89
+ if lo <= 1 and hi >= 65535:
90
+ return proto, ports, "all-ports", "critical", []
91
+ hits = [f"{sensitive[p]} ({p})" for p in sorted(sensitive) if lo <= p <= hi]
92
+ if hits:
93
+ return proto, ports, "sensitive", "high", hits
94
+ if lo == hi and lo in WEB_PORTS:
95
+ return proto, ports, "web", "info", []
96
+ return proto, ports, "open-port", "medium", []
97
+
98
+
99
+ def _world_sources(perm: dict[str, Any]) -> list[str]:
100
+ found = [r["CidrIp"] for r in perm.get("IpRanges", []) if r.get("CidrIp") == "0.0.0.0/0"]
101
+ found += [r["CidrIpv6"] for r in perm.get("Ipv6Ranges", []) if r.get("CidrIpv6") == "::/0"]
102
+ return found
103
+
104
+
105
+ def _reachable(t: Target, family: str) -> bool:
106
+ return bool(t.public_ipv4) if family == "ipv4" else t.has_ipv6
107
+
108
+
109
+ def analyze(inv: Inventory, sensitive: dict[int, str] | None = None) -> list[Finding]:
110
+ sensitive = DEFAULT_SENSITIVE_PORTS if sensitive is None else sensitive
111
+ by_group = inv.targets_by_group()
112
+ findings: list[Finding] = []
113
+ for sg in inv.security_groups:
114
+ gid = sg["GroupId"]
115
+ targets = None if by_group is None else by_group.get(gid, [])
116
+ for perm in sg.get("IpPermissions", []):
117
+ for source in _world_sources(perm):
118
+ proto, ports, category, severity, services = classify(perm, sensitive)
119
+ notes: list[str] = []
120
+ if targets is None:
121
+ notes.append("attachments unknown (no network interface data)")
122
+ elif not targets:
123
+ if SEVERITIES.index(severity) > SEVERITIES.index("low"):
124
+ severity = "low"
125
+ notes.append("not attached to any network interface")
126
+ elif not any(_reachable(t, WORLD[source]) for t in targets):
127
+ family = "IPv4" if WORLD[source] == "ipv4" else "IPv6"
128
+ severity = _shift(severity, -1)
129
+ notes.append(f"no attached interface has a public {family} address")
130
+ findings.append(Finding(
131
+ group_id=gid,
132
+ group_name=sg.get("GroupName", ""),
133
+ vpc_id=sg.get("VpcId", "-"),
134
+ protocol=proto,
135
+ ports=ports,
136
+ source=source,
137
+ category=category,
138
+ severity=severity,
139
+ services=services,
140
+ targets=targets,
141
+ notes=notes,
142
+ ))
143
+ findings.sort(key=lambda f: (-SEVERITIES.index(f.severity), f.group_id, f.ports, f.source))
144
+ return findings