Skip to content

Commit d5136ff

Browse files
ncc-erik-steringerErik Steringer
and
Erik Steringer
authored
Merge in v1.1.5 (#105)
* added wrongadmin preset query, updated logging statement in sts_edges, added cross-account test cases, bumped version number to 1.1.5 * implemented change in the simulator: AWSServiceRoleFor... roles should be ignoring SCP restrictions Co-authored-by: Erik Steringer <erik.steringer@nccgroup.com>
1 parent a79e332 commit d5136ff

File tree

8 files changed

+593
-8
lines changed

8 files changed

+593
-8
lines changed

principalmapper/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@
1515
# You should have received a copy of the GNU Affero General Public License
1616
# along with Principal Mapper. If not, see <https://www.gnu.org/licenses/>.
1717

18-
__version__ = '1.1.4'
18+
__version__ = '1.1.5'

principalmapper/graphing/gathering.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ def update_admin_status(nodes: List[Node], scps: Optional[List[List[dict]]] = No
809809

810810
# check if node can update an attached customer-managed policy (assumes SetAsDefault is set to True)
811811
for attached_policy in node.attached_policies:
812-
if attached_policy.arn != node.arn:
812+
if attached_policy.arn != node.arn and ':aws:policy/' not in attached_policy.arn:
813813
if query_interface.local_check_authorization_handling_mfa(node, 'iam:CreatePolicyVersion',
814814
attached_policy.arn, {},
815815
service_control_policy_groups=scps)[0]:
@@ -829,7 +829,7 @@ def update_admin_status(nodes: List[Node], scps: Optional[List[List[dict]]] = No
829829
node.is_admin = True
830830
break # as above
831831
for attached_policy in group.attached_policies:
832-
if attached_policy.arn != group.arn:
832+
if attached_policy.arn != group.arn and ':aws:policy/' not in attached_policy.arn:
833833
if query_interface.local_check_authorization_handling_mfa(node, 'iam:CreatePolicyVersion',
834834
attached_policy.arn, {},
835835
service_control_policy_groups=scps)[0]:

principalmapper/graphing/sts_edges.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]]
3939
"""Fulfills expected method return_edges. If the session object is None, performs checks in offline-mode"""
4040

4141
result = generate_edges_locally(nodes, scps)
42-
logging.info('Generating Edges based on STS')
42+
logger.info('Generating Edges based on STS')
4343

4444
for edge in result:
4545
logger.info("Found new edge: {}".format(edge.describe_edge()))
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper.
2+
#
3+
# Principal Mapper is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# Principal Mapper is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU Affero General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
14+
# along with Principal Mapper. If not, see <https://www.gnu.org/licenses/>.
15+
16+
import copy
17+
import json
18+
import logging
19+
import re
20+
from typing import List, Dict, Tuple, Optional
21+
22+
from principalmapper.common import Graph, Policy, Node
23+
from principalmapper.querying import query_interface
24+
from principalmapper.querying.local_policy_simulation import policy_has_matching_statement
25+
from principalmapper.util import arns
26+
from principalmapper.util.case_insensitive_dict import CaseInsensitiveDict
27+
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
def handle_preset_query(graph: Graph, tokens: List[str], skip_admins: bool = False) -> None:
33+
"""Handles a human-readable query that's been chunked into tokens, and prints the results. Prints out the
34+
principals in the account who are marked Admin but do not have the AdministratorAccess managed policy or an
35+
inline equivalent.
36+
37+
Tokens should be:
38+
39+
* "preset"
40+
* "wrongadmin"
41+
"""
42+
43+
wal = compose_wrong_admin_list(graph)
44+
for node, reasons in wal:
45+
print('{}'.format(node.searchable_name()))
46+
for reason in reasons:
47+
print(' * {}'.format(reason))
48+
print()
49+
50+
51+
def _is_admin_or_equiv_policy(policy: Policy) -> bool:
52+
"""Given a Policy return true if the policy is the AdministratorAccess policy or if the policy document
53+
is effectively the same."""
54+
55+
if ':aws:policy/AdministratorAccess' in policy.arn:
56+
return True
57+
58+
for stmt in policy.policy_doc['Statement']:
59+
action_flag, resource_flag = False, False
60+
if 'Action' in stmt and 'Resource' in stmt and stmt['Effect'] == 'Allow':
61+
if isinstance(stmt['Action'], str):
62+
if stmt['Action'] == '*':
63+
action_flag = True
64+
else:
65+
for action in stmt['Action']:
66+
if action == '*':
67+
action_flag = True
68+
break
69+
70+
if isinstance(stmt['Resource'], str):
71+
if stmt['Resource'] == '*':
72+
resource_flag = True
73+
else:
74+
for resource in stmt['Resource']:
75+
if resource == '*':
76+
resource_flag = True
77+
break
78+
if action_flag and resource_flag:
79+
return True
80+
81+
return False
82+
83+
84+
def _get_admin_reason(node: Node) -> List[str]:
85+
"""Return a list of reasons why this given node is an admin."""
86+
87+
result = []
88+
logger.debug("Checking if {} is an admin".format(node.searchable_name()))
89+
node_type = arns.get_resource(node.arn).split('/')[0]
90+
91+
# check if node can modify its own inline policies
92+
if node_type == 'user':
93+
action = 'iam:PutUserPolicy'
94+
else: # node_type == 'role'
95+
action = 'iam:PutRolePolicy'
96+
if query_interface.local_check_authorization_handling_mfa(node, action, node.arn, {})[0]:
97+
result.append('Can call {} to add/update their own inline policies'.format(action))
98+
99+
# check if node can attach the AdministratorAccess policy to itself
100+
if node_type == 'user':
101+
action = 'iam:AttachUserPolicy'
102+
else:
103+
action = 'iam:AttachRolePolicy'
104+
condition_keys = {'iam:PolicyARN': 'arn:aws:iam::aws:policy/AdministratorAccess'}
105+
if query_interface.local_check_authorization_handling_mfa(node, action, node.arn, condition_keys)[0]:
106+
result.append('Can call {} to attach the AdministratorAccess policy to itself'.format(action))
107+
108+
# check if node can create a role and attach the AdministratorAccess policy or an inline policy
109+
if query_interface.local_check_authorization_handling_mfa(node, 'iam:CreateRole', '*', {})[0]:
110+
if query_interface.local_check_authorization_handling_mfa(node, 'iam:AttachRolePolicy', '*',
111+
condition_keys)[0]:
112+
result.append('Can create an IAM Role (iam:CreateRole) and attach the AdministratorAccess policy to it (iam:AttachRolePolicy)'.format(action))
113+
if query_interface.local_check_authorization_handling_mfa(node, 'iam:PutRolePolicy', '*', condition_keys)[0]:
114+
result.append('Can create an IAM Role (iam:CreateRole) and create an inline policy for it (iam:PutRolePolicy)'.format(action))
115+
116+
# check if node can update an attached customer-managed policy (assumes SetAsDefault is set to True)
117+
for attached_policy in node.attached_policies:
118+
if attached_policy.arn != node.arn and ':aws:policy/' not in attached_policy.arn:
119+
if query_interface.local_check_authorization_handling_mfa(node, 'iam:CreatePolicyVersion',
120+
attached_policy.arn, {})[0]:
121+
result.append('Can modify the attached managed policy {} (iam:CreatePolicyVersion)'.format(attached_policy.arn))
122+
break # reduce output
123+
124+
# check if node is a user, and if it can attach or modify any of its groups's policies
125+
if node_type == 'user':
126+
for group in node.group_memberships:
127+
group_name = group.arn.split('/')[-1]
128+
129+
if query_interface.local_check_authorization_handling_mfa(node, 'iam:PutGroupPolicy', group.arn, {})[0]:
130+
result.append('Can add/update an inline policy for the group {} (iam:PutGroupPolicy)'.format(group_name))
131+
132+
if query_interface.local_check_authorization_handling_mfa(node, 'iam:AttachGroupPolicy', group.arn,
133+
condition_keys)[0]:
134+
result.append('Can attach the AdministratorAccess policy to the group {} (iam:AttachGroupPolicy)'.format(group_name))
135+
136+
for attached_policy in group.attached_policies:
137+
if attached_policy.arn != group.arn and ':aws:policy/' not in attached_policy.arn:
138+
if query_interface.local_check_authorization_handling_mfa(node, 'iam:CreatePolicyVersion',
139+
attached_policy.arn, {})[0]:
140+
result.append('Can update the managed policy {} that is attached to the group {} (iam:CreatePolicyVersion)'.format(attached_policy.arn, group_name))
141+
break # reduce output
142+
143+
return result
144+
145+
146+
def compose_wrong_admin_list(graph: Graph) -> List[Tuple[Node, List[str]]]:
147+
"""Given a Graph, return the collection of principals that are admins but do not have the
148+
AdminstratorAccess policy or an equivalent inline policy, along with a list of what policy/policies
149+
and statements that could be the source of the admin-access."""
150+
151+
result = []
152+
153+
# iterate through all nodes
154+
for node in graph.nodes:
155+
156+
# skip non-admins
157+
if not node.is_admin:
158+
continue
159+
160+
# skip principals with Admin or equiv policy
161+
flag = False
162+
for attached_policy in node.attached_policies:
163+
if _is_admin_or_equiv_policy(attached_policy):
164+
flag = True
165+
break
166+
if flag:
167+
continue
168+
169+
# skip IAM Users in IAM Groups with Admin or equiv policy
170+
if ':user/' in node.arn:
171+
flag = False
172+
for group in node.group_memberships:
173+
for attached_policy in group.attached_policies:
174+
if _is_admin_or_equiv_policy(attached_policy):
175+
flag = True
176+
break
177+
if flag:
178+
break
179+
if flag:
180+
continue
181+
182+
# at this point we have a node that's an admin, so let's find the potentially responsible statements
183+
result.append((node, _get_admin_reason(node)))
184+
185+
return result

principalmapper/querying/query_actions.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from typing import Optional, List
2424

2525
from principalmapper.common import Graph
26-
from principalmapper.querying.presets import privesc, connected, clusters, endgame, serviceaccess
26+
from principalmapper.querying.presets import privesc, connected, clusters, endgame, serviceaccess, wrongadmin
2727
from principalmapper.querying.query_interface import search_authorization_for, search_authorization_full
2828
from principalmapper.util import arns
2929

@@ -45,6 +45,7 @@
4545
* clusters (tag key)
4646
* endgame (service|"*")
4747
* serviceaccess
48+
* wrongadmin
4849
"""
4950

5051

@@ -202,6 +203,8 @@ def handle_preset(graph: Graph, query: str, skip_admins: bool = False) -> None:
202203
endgame.handle_preset_query(graph, tokens, skip_admins)
203204
elif tokens[1] == 'serviceaccess':
204205
serviceaccess.handle_preset_query(graph, tokens, skip_admins)
206+
elif tokens[1] == 'wrongadmin':
207+
wrongadmin.handle_preset_query(graph, tokens, skip_admins)
205208
else:
206209
_print_query_help()
207210
return
@@ -275,9 +278,11 @@ def argquery(graph: Graph, principal_param: Optional[str], action_param: Optiona
275278
endgame.handle_preset_query(graph, ['', '', resource_param], skip_admins)
276279
elif preset_param == 'serviceaccess':
277280
serviceaccess.handle_preset_query(graph, [], skip_admins)
281+
elif preset_param == 'wrongadmin':
282+
wrongadmin.handle_preset_query(graph, [], skip_admins)
278283
else:
279284
raise ValueError('Parameter for "preset" is not valid. Expected values: "privesc", "connected", '
280-
'"clusters", "endgame", or "serviceaccess".')
285+
'"clusters", "endgame", "serviceaccess", or "wrongadmin".')
281286

282287
else:
283288
argquery_response(graph, principal_param, action_param, resource_param, condition_param, skip_admins,

principalmapper/querying/query_interface.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,8 @@ def local_check_authorization_full(principal: Node, action_to_check: str, resour
288288
prepped_condition_keys = _prepare_condition_context(conditions_keys_copy)
289289
prepped_condition_keys.update(_infer_condition_keys(principal, prepped_condition_keys))
290290

291+
is_not_service_linked_role = not _check_if_service_linked_role(principal)
292+
291293
logger.debug(
292294
'Testing authorization for: principal: {}, action: {}, resource: {}, conditions: {}, Resource Policy: {}, SCPs: {}, Session Policy: {}'.format(
293295
principal.arn,
@@ -309,8 +311,9 @@ def local_check_authorization_full(principal: Node, action_to_check: str, resour
309311
for policy in iam_group.attached_policies:
310312
if policy_has_matching_statement(policy, 'Deny', action_to_check, resource_to_check, prepped_condition_keys):
311313
logger.debug('Explicit Deny: Principal\'s IAM Group policies')
314+
return False
312315

313-
if service_control_policy_groups is not None:
316+
if service_control_policy_groups is not None and is_not_service_linked_role:
314317
for service_control_policy_group in service_control_policy_groups:
315318
for service_control_policy in service_control_policy_group:
316319
if policy_has_matching_statement(service_control_policy, 'Deny', action_to_check, resource_to_check, prepped_condition_keys):
@@ -335,7 +338,7 @@ def local_check_authorization_full(principal: Node, action_to_check: str, resour
335338
return False
336339

337340
# Check SCPs
338-
if service_control_policy_groups is not None:
341+
if service_control_policy_groups is not None and is_not_service_linked_role:
339342
for service_control_policy_group in service_control_policy_groups:
340343
# For every group of SCPs (policies attached to the ancestors of the account and the current account), the
341344
# group of SCPs have to have a matching allow statement
@@ -399,6 +402,17 @@ def local_check_authorization_full(principal: Node, action_to_check: str, resour
399402
return False
400403

401404

405+
def _check_if_service_linked_role(principal: Node) -> bool:
406+
"""Given a Node, determine if it should be treated as a service-linked role. This affects SCP policy decisions as
407+
described in
408+
https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html#not-restricted-by-scp"""
409+
410+
if ':role/' in principal.arn:
411+
role_name = principal.arn.split('/')[-1]
412+
return role_name.startswith('AWSServiceRoleFor')
413+
return False
414+
415+
402416
def simulation_api_check_authorization(iamclient, principal: Node, action_to_check: str, resource_to_check: str,
403417
condition_keys_to_check: dict) -> bool:
404418
"""DO NOT USE THIS FUNCTION, IT WILL ONLY THROW A NotImplementedError."""

0 commit comments

Comments
 (0)