Skip to content

Commit 902a6a8

Browse files
authored
Merge pull request duo-labs#101 from 0xdabbad00/wot
wot command
2 parents f63c93a + e6ba034 commit 902a6a8

22 files changed

+837
-8
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ python cloudmapper.py collect --account-name my_account
152152
- `public`: Find public hosts and port ranges. More details [here](https://summitroute.com/blog/2018/06/13/cloudmapper_public/).
153153
- `sg_ips`: Get geoip info on CIDRs trusted in Security Groups. More details [here](https://summitroute.com/blog/2018/06/12/cloudmapper_sg_ips/).
154154
- `stats`: Show counts of resources for accounts. More details [here](https://summitroute.com/blog/2018/06/06/cloudmapper_stats/).
155+
- `wot`: Show Web Of Trust. More details [here](https://summitroute.com/blog/2018/06/13/cloudmapper_wot/).
155156

156157

157158
Licenses

commands/wot.py

+391
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
import argparse
2+
from os import path, listdir
3+
import os
4+
import json
5+
import yaml
6+
import pyjq
7+
8+
from shared.common import parse_arguments, make_list, query_aws, get_regions
9+
10+
__description__ = "Determine Web Of Trust (wot) for accounts"
11+
12+
# TODO: This command would benefit from a few days of work and some sample data sets to improve:
13+
# - How saml providers are identified. Currently only Okta is identified, and that's a hack.
14+
# Need to look for OneLogin and other providers, need to call "get-saml-provider" and openid in `collect`
15+
# - More vendors, and a dozen don't have logos
16+
# - How IAM admins are identified (need to leverage code from find_admins better)
17+
# - More services and their trust policies.
18+
19+
20+
def get_regional_vpc_peerings(region):
21+
vpc_peerings = query_aws(region.account, "ec2-describe-vpc-peering-connections", region)
22+
resource_filter = '.VpcPeeringConnections[]'
23+
return pyjq.all(resource_filter, vpc_peerings)
24+
25+
26+
def get_regional_direct_connects(region):
27+
direct_connects = query_aws(region.account, "/directconnect-describe-connections", region)
28+
resource_filter = '.connections[]'
29+
return pyjq.all(resource_filter, direct_connects)
30+
31+
32+
def add_connection(connections, source, target, reason):
33+
reasons = connections.get(Connection(source, target), [])
34+
reasons.append(reason)
35+
connections[Connection(source, target)] = reasons
36+
37+
38+
class Account(object):
39+
parent = None
40+
41+
def __init__(self, *args, **kwargs):
42+
43+
json_blob = kwargs.get('json_blob', None)
44+
account_id = kwargs.get('account_id', None)
45+
46+
if json_blob:
47+
self.name = json_blob["name"]
48+
self.id = json_blob["id"]
49+
self.type = json_blob.get('type', 'wot_account')
50+
elif account_id:
51+
self.name = account_id
52+
self.id = account_id
53+
self.type = 'unknown_account'
54+
else:
55+
raise Exception("No init value provided to Account")
56+
57+
58+
def cytoscape_data(self):
59+
response = {'data': {
60+
'id': self.id,
61+
'name': self.name,
62+
'type': self.type,
63+
'weight': len(self.name)*10
64+
}}
65+
if self.parent:
66+
response["data"]["parent"] = self.parent
67+
68+
return response
69+
70+
class Region(object):
71+
def __init__(self, parent, json_blob):
72+
self.name = json_blob["RegionName"]
73+
self.account = parent
74+
75+
76+
class Connection(object):
77+
_source = None
78+
_target = None
79+
_type = None
80+
81+
@property
82+
def source(self):
83+
return self._source
84+
85+
@property
86+
def target(self):
87+
return self._target
88+
89+
def __key(self):
90+
return (self._source.id, self._target.id, self._source.id, self._type)
91+
92+
def __eq__(self, other):
93+
return self.__key() == other.__key()
94+
95+
def __hash__(self):
96+
return hash(self.__key())
97+
98+
def __init__(self, source, target, connection_type):
99+
self._source = source
100+
self._target = target
101+
self._type = connection_type
102+
self._json = []
103+
104+
def cytoscape_data(self):
105+
return {
106+
"data": {
107+
"source": self._source.id,
108+
"target": self._target.id,
109+
"type": "edge"
110+
},
111+
"classes": self._type
112+
}
113+
114+
115+
def is_admin_policy(policy_doc):
116+
# TODO Use find_admin.py code instead of copy pasting it here.
117+
for stmt in make_list(policy_doc['Statement']):
118+
if stmt['Effect'] == 'Allow':
119+
actions = make_list(stmt.get('Action', []))
120+
for action in actions:
121+
if action == '*' or action == '*:*' or action == 'iam:*':
122+
return True
123+
return False
124+
125+
126+
def get_vpc_peerings(account, nodes, connections):
127+
# Get VPC peerings
128+
for region_json in get_regions(account):
129+
region = Region(account, region_json)
130+
for vpc_peering in get_regional_vpc_peerings(region):
131+
# Ensure it is active
132+
if vpc_peering['Status']['Code'] != 'active':
133+
continue
134+
if vpc_peering['AccepterVpcInfo']['OwnerId'] != account.id:
135+
peered_account = Account(account_id=vpc_peering['AccepterVpcInfo']['OwnerId'])
136+
nodes[peered_account.id] = peered_account
137+
connections[Connection(account, peered_account, "vpc")] = []
138+
if vpc_peering['RequesterVpcInfo']['OwnerId'] != account.id:
139+
peered_account = Account(account_id=vpc_peering['RequesterVpcInfo']['OwnerId'])
140+
nodes[peered_account.id] = peered_account
141+
connections[Connection(account, peered_account, "vpc")] = []
142+
return
143+
144+
145+
def get_direct_connects(account, nodes, connections):
146+
for region_json in get_regions(account):
147+
region = Region(account, region_json)
148+
for direct_connect in get_regional_direct_connects(region):
149+
name = direct_connect['location']
150+
location = Account(account_id=name)
151+
location.type = 'directconnect'
152+
# TODO: I could get a slightly nicer name if I had data for `directconnect describe-locations`
153+
nodes[name] = location
154+
connections[Connection(account, location, "directconnect")] = []
155+
return
156+
157+
158+
def get_iam_trusts(account, nodes, connections, connections_to_get):
159+
# Get IAM
160+
iam = query_aws(
161+
account,
162+
"iam-get-account-authorization-details",
163+
Region(account, {'RegionName':'us-east-1'}))
164+
165+
for role in pyjq.all('.RoleDetailList[]', iam):
166+
principals = pyjq.all('.AssumeRolePolicyDocument.Statement[].Principal', role)
167+
for principal in principals:
168+
assume_role_nodes = set()
169+
if principal.get('Federated', None):
170+
# TODO I should be using get-saml-provider to confirm this is really okta
171+
if "saml-provider/okta" in principal['Federated'].lower():
172+
node = Account(json_blob={'id':'okta', 'name':'okta', 'type':'Okta'})
173+
assume_role_nodes.add(node)
174+
elif principal['Federated'] == 'cognito-identity.amazonaws.com':
175+
# TODO: Should show this somehow
176+
continue
177+
else:
178+
raise Exception('Unknown federation provider: {}'.format(principal['Federated']))
179+
if principal.get('AWS', None):
180+
principal = principal['AWS']
181+
if not isinstance(principal, list):
182+
principal = [principal]
183+
for p in principal:
184+
if "arn:aws" not in p:
185+
# The role can simply be something like "AROA..."
186+
continue
187+
parts = p.split(':')
188+
account_id = parts[4]
189+
assume_role_nodes.add(Account(account_id=account_id))
190+
191+
for node in assume_role_nodes:
192+
if nodes.get(node.id, None) is None:
193+
nodes[node.id] = node
194+
access_type = 'iam'
195+
# TODO: Identify all admins better. Use code from find_admins.py
196+
for m in role['AttachedManagedPolicies']:
197+
if m['PolicyArn'] == 'arn:aws:iam::aws:policy/AdministratorAccess':
198+
access_type = 'admin'
199+
for policy in role['RolePolicyList']:
200+
policy_doc = policy['PolicyDocument']
201+
if is_admin_policy(policy_doc):
202+
access_type = 'admin'
203+
204+
if ((access_type == 'admin' and connections_to_get['admin']) or
205+
(access_type != 'admin' and connections_to_get['iam_nonadmin'])):
206+
connections[Connection(node, account, access_type)] = []
207+
return
208+
209+
210+
def get_s3_trusts(account, nodes, connections):
211+
policy_dir = './account-data/{}/us-east-1/s3-get-bucket-policy/'.format(account.name)
212+
for s3_policy_file in [f for f in listdir(policy_dir) if path.isfile(path.join(policy_dir, f)) and path.getsize(path.join(policy_dir, f)) > 4]:
213+
s3_policy = json.load(open(os.path.join(policy_dir, s3_policy_file)))
214+
s3_policy = json.loads(s3_policy['Policy'])
215+
for s in s3_policy['Statement']:
216+
principals = s.get('Principal', None)
217+
if principals is None:
218+
if s.get('NotPrincipal', None) is not None:
219+
print "WARNING: Use of NotPrincipal in {} for {}: {}".format(account.name, s3_policy_file, s)
220+
continue
221+
print 'WARNING: Malformed statement in {} for {}: {}'.format(account.name, s3_policy_file, s)
222+
continue
223+
224+
for principal in principals:
225+
assume_role_nodes = set()
226+
if principal == 'AWS':
227+
trusts = principals[principal]
228+
if not isinstance(trusts, list):
229+
trusts = [trusts]
230+
for trust in trusts:
231+
if "arn:aws" not in trust:
232+
# The role can simply be something like "*"
233+
continue
234+
parts = trust.split(':')
235+
account_id = parts[4]
236+
assume_role_nodes.add(Account(account_id=account_id))
237+
for node in assume_role_nodes:
238+
if nodes.get(node.id, None) is None:
239+
nodes[node.id] = node
240+
access_type = 's3_read'
241+
actions = s['Action']
242+
if not isinstance(actions, list):
243+
actions = [actions]
244+
for action in actions:
245+
if not action.startswith('s3:List') and not action.startswith('s3:Get'):
246+
access_type = 's3'
247+
break
248+
connections[Connection(node, account, access_type)] = []
249+
return
250+
251+
252+
def get_nodes_and_connections(account_data, nodes, connections, args):
253+
account = Account(json_blob=account_data)
254+
nodes[account.id] = account
255+
256+
connections_to_get = {
257+
'vpc': True,
258+
'direct_connect': True,
259+
'admin': True,
260+
'iam_nonadmin': True,
261+
's3': True
262+
}
263+
if args.network_only:
264+
connections_to_get = dict.fromkeys(connections_to_get, False)
265+
connections_to_get['vpc'] = True
266+
connections_to_get['direct_connect'] = True
267+
if args.admin_only:
268+
connections_to_get = dict.fromkeys(connections_to_get, False)
269+
connections_to_get['admin'] = True
270+
271+
if connections_to_get['vpc']:
272+
get_vpc_peerings(account, nodes, connections)
273+
if connections_to_get['direct_connect']:
274+
get_direct_connects(account, nodes, connections)
275+
if connections_to_get['admin'] or connections_to_get['iam_nonadmin']:
276+
get_iam_trusts(account, nodes, connections, connections_to_get)
277+
if connections_to_get['s3']:
278+
get_s3_trusts(account, nodes, connections)
279+
280+
281+
def wot(args, accounts, config):
282+
"""Collect the data and write it to a file"""
283+
284+
nodes = {}
285+
connections = {}
286+
for account in accounts:
287+
# Check if the account data exists
288+
if not os.path.exists('./account-data/{}/us-east-1/iam-get-account-authorization-details.json'.format(account['name'])):
289+
print('INFO: Skipping account {}'.format(account['name']))
290+
continue
291+
get_nodes_and_connections(account, nodes, connections, args)
292+
293+
cytoscape_json = []
294+
parents = set()
295+
296+
with open("vendor_accounts.yaml", 'r') as f:
297+
vendor_accounts = yaml.safe_load(f)
298+
299+
# Add nodes
300+
config = json.load(open('config.json'))
301+
for _, n in nodes.items():
302+
303+
# Set up parent relationship
304+
for known_account in config['accounts']:
305+
if n.id == known_account['id']:
306+
if known_account.get('tags', False):
307+
parent_name = known_account['tags'][0]
308+
n.parent = parent_name
309+
parents.add(parent_name)
310+
311+
# Ensure we don't modify the type of accounts that we are scanning,
312+
# so first check if this account was one that was scanned
313+
was_scanned = False
314+
for scanned_account in accounts:
315+
if n.id == scanned_account['id']:
316+
was_scanned = True
317+
318+
# TODO: This is a hack to set this again here, as I had an account of type 'unknown_account' somehow
319+
n.type = 'wot_account'
320+
n.name = scanned_account['name']
321+
break
322+
323+
if not was_scanned:
324+
for vendor in vendor_accounts:
325+
if n.id in vendor['accounts']:
326+
n.name = vendor['name']
327+
n.type = vendor.get('type', vendor['name'])
328+
329+
# Others
330+
for known_account in config['accounts']:
331+
if n.id == known_account['id']:
332+
n.name = known_account['name']
333+
n.type = 'known_account'
334+
if known_account.get('tags', False):
335+
n.parent = known_account['tags'][0]
336+
parents.add(n.parent)
337+
break
338+
339+
if n.type == 'unknown_account':
340+
print 'Unknown account: {}'.format(n.id)
341+
342+
# Ignore AWS accounts unless the argument was given not to
343+
if n.type == 'aws' and not args.show_aws_owned_accounts:
344+
continue
345+
346+
cytoscape_json.append(n.cytoscape_data())
347+
348+
# Add compound parent nodes
349+
for p in parents:
350+
n = Account(account_id=p)
351+
n.type = 'account_grouping'
352+
cytoscape_json.append(n.cytoscape_data())
353+
354+
355+
num_connections = 0
356+
# Add the mapping to our graph
357+
for c, reasons in connections.items():
358+
if c.source.id == c.target.id:
359+
# Ensure we don't add connections with the same nodes on either side
360+
continue
361+
if c._type != 'admin' and connections.get(Connection(c.source, c.target, 'admin'), False) is not False:
362+
# Don't show an iam connection if we have an admin connection between the same nodes
363+
continue
364+
if (c._type == 's3_read') and (connections.get(Connection(c.source, c.target, 's3'), False) is not False):
365+
# Don't show an s3 connection if we have an iam or admin connection between the same nodes
366+
continue
367+
#print "{} -> {}".format(c.source.id, c.target.id)
368+
c._json = reasons
369+
cytoscape_json.append(c.cytoscape_data())
370+
num_connections += 1
371+
print("- {} connections built".format(num_connections))
372+
373+
return cytoscape_json
374+
375+
376+
def run(arguments):
377+
parser = argparse.ArgumentParser()
378+
# TODO: Have flags for each connection type
379+
parser.add_argument("--network_only", help="Show networking connections only", default=False, action='store_true')
380+
parser.add_argument("--admin_only", help="Show admin connections only", default=False, action='store_true')
381+
382+
parser.add_argument("--show_aws_owned_accounts", help="Show accounts owned by AWS (defaults to hiding)", default=False, action='store_true')
383+
args, accounts, config = parse_arguments(arguments, parser)
384+
385+
if args.network_only and args.admin_only:
386+
print("ERROR: You cannot use network_only and admin_only at the same time")
387+
exit(-1)
388+
389+
cytoscape_json = wot(args, accounts, config)
390+
with open('web/data.json', 'w') as outfile:
391+
json.dump(cytoscape_json, outfile, indent=4)

0 commit comments

Comments
 (0)