Skip to content

Commit f63c93a

Browse files
authored
Merge pull request duo-labs#100 from 0xdabbad00/public
Add public command
2 parents 2c401ea + a6f3088 commit f63c93a

File tree

6 files changed

+173
-12
lines changed

6 files changed

+173
-12
lines changed

README.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,12 @@ python cloudmapper.py collect --account-name my_account
147147
# Commands
148148

149149
- `collect`: Collect metadata about an account. More details [here](https://summitroute.com/blog/2018/06/05/cloudmapper_collect/).
150+
- `find_admins`: Look at IAM policies to identify admin users and roles and spot potential IAM issues. More details [here](https://summitroute.com/blog/2018/06/12/cloudmapper_find_admins/).
150151
- `prepare`/`webserver`: See [Network Visualizations](docs/network_visualizations.md)
151-
- `stats`: Show counts of resources for accounts. More details [here](https://summitroute.com/blog/2018/06/06/cloudmapper_stats/).
152+
- `public`: Find public hosts and port ranges. More details [here](https://summitroute.com/blog/2018/06/13/cloudmapper_public/).
152153
- `sg_ips`: Get geoip info on CIDRs trusted in Security Groups. More details [here](https://summitroute.com/blog/2018/06/12/cloudmapper_sg_ips/).
153-
- `find_admins`: Look at IAM policies to identify admin users and roles and spot potential IAM issues. More details [here](https://summitroute.com/blog/2018/06/12/cloudmapper_find_admins/).
154+
- `stats`: Show counts of resources for accounts. More details [here](https://summitroute.com/blog/2018/06/06/cloudmapper_stats/).
155+
154156

155157
Licenses
156158
--------

commands/find_admins.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def find_admins(accounts, config):
102102
continue
103103
log_error('Problem opening iam data, skipping account', location, [file_name])
104104
continue
105-
105+
106106
admin_policies = []
107107
policy_action_counts = {}
108108
for policy in iam['Policies']:
@@ -112,7 +112,7 @@ def find_admins(accounts, config):
112112

113113
if is_admin_policy(policy_doc, location):
114114
admin_policies.append(policy['Arn'])
115-
if ('arn:aws:iam::aws:policy/AdministratorAccess' in policy['Arn'] or
115+
if ('arn:aws:iam::aws:policy/AdministratorAccess' in policy['Arn'] or
116116
'arn:aws:iam::aws:policy/IAMFullAccess' in policy['Arn']):
117117
# Ignore the admin policies that are obviously admin
118118
continue

commands/prepare.py

+20-8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@
3333

3434
__description__ = "Generate network connection information file"
3535

36+
MUTE = False
37+
38+
def log(msg):
39+
if MUTE:
40+
return
41+
print msg
42+
43+
3644
def get_vpcs(region, outputfilter):
3745
vpc_filter = ""
3846
if "vpc-ids" in outputfilter:
@@ -196,8 +204,12 @@ def get_connections(cidrs, vpc, outputfilter):
196204
def build_data_structure(account_data, config, outputfilter):
197205
cytoscape_json = []
198206

207+
if outputfilter.get('mute', False):
208+
global MUTE
209+
MUTE = True
210+
199211
account = Account(None, account_data)
200-
print("Building data for account {} ({})".format(account.name, account.local_id))
212+
log("Building data for account {} ({})".format(account.name, account.local_id))
201213

202214
cytoscape_json.append(account.cytoscape_data())
203215
for region_json in get_regions(account, outputfilter):
@@ -260,7 +272,7 @@ def build_data_structure(account_data, config, outputfilter):
260272
cytoscape_json.append(region.cytoscape_data())
261273
account.addChild(region)
262274

263-
print("- {} nodes built in region {}".format(node_count_per_region, region.local_id))
275+
log("- {} nodes built in region {}".format(node_count_per_region, region.local_id))
264276

265277
# Get VPC peerings
266278
for region in account.children:
@@ -301,7 +313,7 @@ def build_data_structure(account_data, config, outputfilter):
301313
if cidr.is_used:
302314
used_cidrs += 1
303315
cytoscape_json.append(cidr.cytoscape_data())
304-
print("- {} external CIDRs built".format(used_cidrs))
316+
log("- {} external CIDRs built".format(used_cidrs))
305317

306318
total_number_of_nodes = len(cytoscape_json)
307319

@@ -312,17 +324,17 @@ def build_data_structure(account_data, config, outputfilter):
312324
continue
313325
c._json = reasons
314326
cytoscape_json.append(c.cytoscape_data())
315-
print("- {} connections built".format(len(connections)))
327+
log("- {} connections built".format(len(connections)))
316328

317329
# Check if we have a lot of data, and if so, show a warning
318330
# Numbers chosen here are arbitrary
319331
MAX_NODES_FOR_WARNING = 200
320332
MAX_EDGES_FOR_WARNING = 500
321333
if total_number_of_nodes > MAX_NODES_FOR_WARNING or len(connections) > MAX_EDGES_FOR_WARNING:
322-
print("WARNING: There are {} total nodes and {} total edges.".format(total_number_of_nodes, len(connections)))
323-
print(" This will be difficult to display and may be too complex to make sense of.")
324-
print(" Consider reducing the number of items in the diagram by viewing a single")
325-
print(" region, ignoring internal edges, or other filtering.")
334+
log("WARNING: There are {} total nodes and {} total edges.".format(total_number_of_nodes, len(connections)))
335+
log(" This will be difficult to display and may be too complex to make sense of.")
336+
log(" Consider reducing the number of items in the diagram by viewing a single")
337+
log(" region, ignoring internal edges, or other filtering.")
326338

327339
return cytoscape_json
328340

commands/public.py

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import print_function
2+
import sys
3+
import json
4+
import pyjq
5+
from shared.common import parse_arguments
6+
from commands.prepare import build_data_structure
7+
8+
__description__ = "Find publicly exposed services and their ports"
9+
10+
# TODO Look for IPv6 also
11+
# TODO Look at more services from https://github.com/arkadiyt/aws_public_ips
12+
# TODO Integrate into something to more easily port scan and screenshot web services
13+
14+
def regroup_ranges(rgs):
15+
"""
16+
Functions to reduce sets of ranges.
17+
18+
Examples:
19+
[[80,80],[80,80]] -> [80,80]
20+
[[80,80],[0,65000]] -> [0,65000]
21+
22+
Taken from https://stackoverflow.com/questions/47656430/given-a-list-of-tuples-representing-ranges-condense-the-ranges-write-a-functio
23+
"""
24+
25+
def overlap(r1, r2):
26+
"""
27+
Check for overlap in ranges.
28+
Returns -1 to ensure ranges like (2, 3) and (4, 5) merge into (2, 5)
29+
"""
30+
return r1[1] >= r2[0] - 1
31+
32+
def merge_range(r1, r2):
33+
s1, e1 = r1
34+
s2, e2 = r2
35+
return (min(s1, s2), max(e1, e2))
36+
37+
assert all([s <= e for s, e in rgs])
38+
if len(rgs) == 0:
39+
return rgs
40+
41+
rgs.sort()
42+
43+
regrouped = [rgs[0]]
44+
45+
for r2 in rgs[1:]:
46+
r1 = regrouped.pop()
47+
if overlap(r1, r2):
48+
regrouped.append(merge_range(r1, r2))
49+
else:
50+
regrouped.append(r1)
51+
regrouped.append(r2)
52+
53+
return regrouped
54+
55+
56+
def port_ranges_string(port_ranges):
57+
"""
58+
Given an array of tuple port ranges return a string that makes this more readable.
59+
Ex. [[80,80],[443,445]] -> "80,443-445"
60+
"""
61+
62+
def port_range_string(port_range):
63+
if port_range[0] == port_range[1]:
64+
return '{}'.format(port_range[0])
65+
return '{}-{}'.format(port_range[0], port_range[1])
66+
return ','.join(map(port_range_string, port_ranges))
67+
68+
69+
def log_warning(msg):
70+
print('WARNING: {}'.format(msg), file=sys.stderr)
71+
72+
73+
def public(accounts, config):
74+
for account in accounts:
75+
# Get the data from the `prepare` command
76+
outputfilter = {'internal_edges': False, 'read_replicas': False, 'inter_rds_edges': False, 'azs': False, 'collapse_by_tag': None, 'mute': True}
77+
network = build_data_structure(account, config, outputfilter)
78+
79+
# Look at all the edges for ones connected to the public Internet (0.0.0.0/0)
80+
for edge in pyjq.all('.[].data|select(.type=="edge")|select(.source=="0.0.0.0/0")', network):
81+
82+
# Find the node at the other end of this edge
83+
target = {'arn': edge['target'], 'account': account['name']}
84+
target_node = pyjq.first('.[].data|select(.id=="{}")'.format(target['arn']), network, {})
85+
86+
# Depending on the type of node, identify what the IP or hostname is
87+
if target_node['type'] == 'elb':
88+
target['type'] = 'elb'
89+
target['hostname'] = target_node['node_data']['DNSName']
90+
elif target_node['type'] == 'autoscaling':
91+
target['type'] = 'autoscaling'
92+
target['hostname'] = target_node['node_data'].get('PublicIpAddress', '')
93+
if target['hostname'] == '':
94+
target['hostname'] = target_node['node_data']['PublicDnsName']
95+
elif target_node['type'] == 'rds':
96+
target['type'] = 'rds'
97+
target['hostname'] = target_node['node_data']['Endpoint']['Address']
98+
elif target_node['type'] == 'ec2':
99+
target['type'] = 'ec2'
100+
dns_name = target_node['node_data'].get('PublicDnsName', '')
101+
target['hostname'] = target_node['node_data'].get('PublicIpAddress', dns_name)
102+
else:
103+
print(pyjq.first('.[].data|select(.id=="{}")|[.type, (.node_data|keys)]'.format(target['arn']), network, {}))
104+
105+
# Check if any protocol is allowed (indicated by IpProtocol == -1)
106+
ingress = pyjq.all('.[].IpPermissions[]', edge.get('node_data', {}))
107+
if pyjq.first('.[]|select(.IpProtocol=="-1")|.IpProtocol', ingress, '1') == '-1':
108+
log_warning('All protocols allowed access to {}'.format(target))
109+
range_string = '0-65535'
110+
else:
111+
# from_port and to_port mean the beginning and end of a port range
112+
# We only care about TCP (6) and UDP (17)
113+
# For more info see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/security-group-rules-reference.html
114+
selection = 'select((.IpProtocol=="tcp") or (.IpProtocol=="udp")) | select(.IpRanges[].CidrIp=="0.0.0.0/0")'
115+
port_ranges = pyjq.all('.[]|{}| [.FromPort,.ToPort]'.format(selection), ingress)
116+
range_string = port_ranges_string(regroup_ranges(port_ranges))
117+
118+
target['ports'] = range_string
119+
if target['ports'] == "":
120+
issue_msg = 'No ports open for tcp or udp (probably can only be pinged). Rules that are not tcp or udp: {} -- {}'
121+
log_warning(issue_msg.format(json.dumps(pyjq.all('.[]|select((.IpProtocol!="tcp") and (.IpProtocol!="udp"))'.format(selection), ingress)), account))
122+
print(json.dumps(target, indent=4, sort_keys=True))
123+
124+
125+
def run(arguments):
126+
_, accounts, config = parse_arguments(arguments)
127+
public(accounts, config)

utils/nmap_scan.sh

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/bash
2+
# Expected format:
3+
# ```
4+
# 127.0.0.1:22,80,443
5+
# ```
6+
7+
# Convert the `public` output with:
8+
# python cloudmapper.py public --account demo | jq -r '.hostname+":"+.ports' | sort | uniq > /tmp/host_ports.txt
9+
# ./utils/nmap_scan.sh /tmp/host_ports.txt
10+
11+
cat $1 | while read line;do IP=$(echo -n $line | awk -F\: '{ print $1 }'); PLIST=$(echo -n $line | awk -F\: '{ print $2 }'); nmap -sV -oG - -PN -p$PLIST $IP;done

utils/screenshot.sh

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/bin/bash
2+
# Screenshot the host passed as a parameter.
3+
# Kills any existing Firefox, and tries to run it in headless mode for 10 seconds.
4+
# Must set `toolkit.startup.max_resumed_crashes` to `-1` in `about:config`
5+
6+
killall firefox-bin
7+
echo $1
8+
mkdir -p screenshots
9+
gtimeout -s9 10 /Applications/Firefox.app/Contents/MacOS/firefox-bin --headless --window-size=1024,1024 --screenshot screenshots/$1.png $1

0 commit comments

Comments
 (0)