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.
- sg_exposure_check-0.1.0/LICENSE +21 -0
- sg_exposure_check-0.1.0/PKG-INFO +206 -0
- sg_exposure_check-0.1.0/README.md +169 -0
- sg_exposure_check-0.1.0/pyproject.toml +63 -0
- sg_exposure_check-0.1.0/setup.cfg +4 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check/__init__.py +3 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check/__main__.py +5 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check/analyze.py +144 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check/cli.py +107 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check/inventory.py +141 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check/py.typed +0 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check/report.py +58 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check.egg-info/PKG-INFO +206 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check.egg-info/SOURCES.txt +20 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check.egg-info/dependency_links.txt +1 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check.egg-info/entry_points.txt +2 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check.egg-info/requires.txt +8 -0
- sg_exposure_check-0.1.0/src/sg_exposure_check.egg-info/top_level.txt +1 -0
- sg_exposure_check-0.1.0/tests/test_analyze.py +94 -0
- sg_exposure_check-0.1.0/tests/test_cli.py +59 -0
- sg_exposure_check-0.1.0/tests/test_inventory.py +53 -0
- sg_exposure_check-0.1.0/tests/test_live.py +50 -0
|
@@ -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,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
|