Skip to content

Commit 7d9a157

Browse files
authored
[WIP] Statement scanning performance improvements (#156)
* PolicyDocument.service_wildcard now uses set() internally. Remove duplicate calls to `get_all_service_prefixes()` * Add cached-property package & cache `Statement.expanded_actions` calls. The `expanded_actions` is an expensive call to policy_sentry. Nearly every other method on the StatementDetail class accesses the expanded_actions attribute. Caching this property results in a dramatic improvement when processing wildcard statements. * Simplify loops and conditions in StatementDetail to remove redundancies.
1 parent 56e34bb commit 7d9a157

File tree

3 files changed

+74
-77
lines changed

3 files changed

+74
-77
lines changed

cloudsplaining/scan/policy_document.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -183,37 +183,32 @@ def credentials_exposure(self):
183183
@property
184184
def service_wildcard(self):
185185
"""Determine if the policy gives access to all actions within a service - simple grepping"""
186-
services = []
186+
services = set()
187+
all_service_prefixes = get_all_service_prefixes()
188+
187189
for statement in self.statements:
188190
logger.debug("Evaluating statement: %s", statement.json)
189-
if statement.effect == "Allow":
191+
if statement.effect_allow:
190192
if isinstance(statement.actions, list):
191193
for action in statement.actions:
192194
# If the action is a straight up *
193195
if action == "*":
194196
logger.debug("All actions are allowed by this policy")
195-
services.extend(get_all_service_prefixes())
197+
services.update(all_service_prefixes)
196198
# Otherwise, it will take the format of service:*
197199
else:
198200
service, this_action = action.split(":")
199201
# service:*
200202
if this_action == "*":
201-
services.append(service)
203+
services.add(service)
202204
elif isinstance(statement.actions, str):
203205
# If the action is a straight up *
204206
if statement.actions == "*":
205207
logger.debug("All actions are allowed by this policy")
206-
services.append(get_all_service_prefixes())
208+
services.update(all_service_prefixes)
207209
else:
208210
service, this_action = statement.actions.split(":")
209211
# service:*
210212
if this_action == "*":
211-
services.append(service)
212-
if services:
213-
# Remove duplicates and sort
214-
services = list(dict.fromkeys(services))
215-
these_services = services.copy()
216-
these_services.sort()
217-
return these_services
218-
else:
219-
return []
213+
services.add(service)
214+
return sorted(services)

cloudsplaining/scan/statement_detail.py

Lines changed: 64 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Abstracts evaluation of IAM Policy statements."""
22
import logging
3+
4+
from cached_property import cached_property
5+
36
from policy_sentry.analysis.analyze import determine_actions_to_expand
47
from policy_sentry.querying.actions import (
58
remove_actions_not_matching_access_level,
@@ -20,7 +23,7 @@
2023
logger = logging.getLogger(__name__)
2124
logging.getLogger("policy_sentry").setLevel(logging.WARNING)
2225

23-
all_actions = get_all_actions()
26+
ALL_ACTIONS = get_all_actions()
2427

2528

2629
# pylint: disable=too-many-instance-attributes
@@ -36,6 +39,9 @@ def __init__(self, statement):
3639
self.resources = self._resources()
3740
self.actions = self._actions()
3841
self.not_action = self._not_action()
42+
43+
self.has_resource_constraints = _has_resource_constraints(self.resources)
44+
3945
self.not_action_effective_actions = self._not_action_effective_actions()
4046
self.not_resource = self._not_resource()
4147

@@ -84,65 +90,66 @@ def _not_action_effective_actions(self):
8490
effective_actions = []
8591
if not self.not_action:
8692
return None
87-
not_actions_expanded = determine_actions_to_expand(self.not_action)
88-
not_actions_expanded_lowercase = [x.lower() for x in not_actions_expanded]
93+
94+
not_actions_expanded_lowercase = [
95+
a.lower()
96+
for a in determine_actions_to_expand(self.not_action)
97+
]
8998

9099
# Effect: Allow && Resource != "*"
91100
if self.has_resource_constraints and self.effect_allow:
92101
opposite_actions = []
93102
for arn in self.resources:
94103
actions_specific_to_arn = get_actions_matching_arn(arn)
95104
if actions_specific_to_arn:
96-
opposite_actions.extend(get_actions_matching_arn(arn))
105+
opposite_actions.extend(actions_specific_to_arn)
97106

98107
for opposite_action in opposite_actions:
99108
# If it's in NotActions, then it is not an action we want
100-
if opposite_action.lower() in not_actions_expanded_lowercase:
101-
pass
102-
# Otherwise add it
103-
else:
109+
if opposite_action.lower() not in not_actions_expanded_lowercase:
104110
effective_actions.append(opposite_action)
105111
effective_actions.sort()
106112
return effective_actions
113+
107114
# Effect: Allow, Resource != "*", and Action == prefix:*
108-
elif not self.has_resource_constraints and self.effect_allow:
115+
if not self.has_resource_constraints and self.effect_allow:
109116
# Then we calculate the reverse using all_actions
110-
for action in all_actions:
111-
# If it's in NotActions, then it is not an action we want
112-
if action.lower() in not_actions_expanded_lowercase:
113-
pass
114-
# Otherwise add it
115-
else:
116-
effective_actions.append(action)
117+
118+
# If it's in NotActions, then it is not an action we want
119+
effective_actions = [
120+
action for action in ALL_ACTIONS
121+
if action.lower() not in not_actions_expanded_lowercase
122+
]
123+
117124
effective_actions.sort()
118125
return effective_actions
119-
elif self.has_resource_constraints and self.effect_deny:
126+
127+
if self.has_resource_constraints and self.effect_deny:
120128
logger.debug("NOTE: Haven't decided if we support Effect Deny here?")
121129
return None
122-
elif not self.has_resource_constraints and self.effect_deny:
130+
131+
if not self.has_resource_constraints and self.effect_deny:
123132
logger.debug("NOTE: Haven't decided if we support Effect Deny here?")
124133
return None
125134
# only including this so Pylint doesn't yell at us
126-
else:
127-
return None # pragma: no cover
135+
return None # pragma: no cover
128136

129137
@property
130138
def has_not_resource_with_allow(self):
131139
"""Per the AWS documentation, the NotResource should NEVER be used with the Allow Effect.
132140
See documentation here. https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_notresource.html#notresource-element-combinations"""
133-
result = False
134-
if self.not_resource:
135-
if self.effect_allow:
136-
result = True
137-
logger.warning(
138-
"Per the AWS documentation, the NotResource should never be used with the "
139-
"Allow Effect. We suggest changing this ASAP"
140-
)
141-
return result
141+
if self.not_resource and self.effect_allow:
142+
logger.warning(
143+
"Per the AWS documentation, the NotResource should never be used with the "
144+
"Allow Effect. We suggest changing this ASAP"
145+
)
146+
return True
147+
return False
142148

143-
@property
149+
@cached_property
144150
def expanded_actions(self):
145151
"""Expands the full list of allowed actions from the Policy/"""
152+
146153
if self.actions:
147154
expanded = determine_actions_to_expand(self.actions)
148155
expanded.sort()
@@ -167,30 +174,11 @@ def effect_allow(self):
167174
@property
168175
def services_in_use(self):
169176
"""Get a list of the services in use by the statement."""
170-
service_prefixes = []
177+
service_prefixes = set()
171178
for action in self.expanded_actions:
172179
service, action_name = action.split(":") # pylint: disable=unused-variable
173-
if service not in service_prefixes:
174-
service_prefixes.append(service)
175-
service_prefixes.sort()
176-
return service_prefixes
177-
178-
@property
179-
def has_resource_constraints(self):
180-
"""Determine whether or not the statement allows resource constraints."""
181-
answer = True
182-
if len(self.resources) == 0:
183-
# This is probably a NotResources situation which we do not support.
184-
pass
185-
if len(self.resources) == 1:
186-
if self.resources[0] == "*":
187-
answer = False
188-
elif len(self.resources) > 1: # pragma: no cover
189-
# It's possible that someone writes a bad policy that includes both a resource ARN as well as a wildcard.
190-
for resource in self.resources:
191-
if resource == "*":
192-
answer = False
193-
return answer
180+
service_prefixes.add(service)
181+
return sorted(service_prefixes)
194182

195183
@property
196184
def permissions_management_actions_without_constraints(self):
@@ -235,13 +223,11 @@ def missing_resource_constraints(self, exclusions=DEFAULT_EXCLUSIONS):
235223
"Please use the Exclusions object."
236224
)
237225
actions_missing_resource_constraints = []
238-
if len(self.resources) == 1:
239-
if self.resources[0] == "*":
240-
actions_missing_resource_constraints = remove_wildcard_only_actions(
241-
self.expanded_actions
242-
)
243-
results = exclusions.get_allowed_actions(actions_missing_resource_constraints)
244-
return results
226+
if len(self.resources) == 1 and self.resources[0] == "*":
227+
actions_missing_resource_constraints = remove_wildcard_only_actions(
228+
self.expanded_actions
229+
)
230+
return exclusions.get_allowed_actions(actions_missing_resource_constraints)
245231

246232
def missing_resource_constraints_for_modify_actions(
247233
self, exclusions=DEFAULT_EXCLUSIONS
@@ -258,18 +244,20 @@ def missing_resource_constraints_for_modify_actions(
258244
"Please use the Exclusions object."
259245
)
260246
# This initially includes read-only and modify level actions
261-
if exclusions.include_actions is None:
262-
always_look_for_actions = [] # pragma: no cover
247+
if exclusions.include_actions:
248+
always_look_for_actions = [x.lower() for x in exclusions.include_actions]
263249
else:
264-
always_look_for_actions = exclusions.include_actions
250+
always_look_for_actions = []
251+
265252
actions_missing_resource_constraints = self.missing_resource_constraints(
266253
exclusions
267254
)
268255

269256
always_actions_found = []
270257
for action in actions_missing_resource_constraints:
271-
if action.lower() in [x.lower() for x in always_look_for_actions]:
258+
if action.lower() in always_look_for_actions:
272259
always_actions_found.append(action)
260+
273261
modify_actions_missing_constraints = remove_read_level_actions(
274262
actions_missing_resource_constraints
275263
)
@@ -282,3 +270,16 @@ def missing_resource_constraints_for_modify_actions(
282270
)
283271
modify_actions_missing_constraints.sort()
284272
return modify_actions_missing_constraints
273+
274+
275+
def _has_resource_constraints(resources):
276+
"""Determine whether or not the statement allows resource constraints."""
277+
if len(resources) == 0:
278+
# This is probably a NotResources situation which we do not support.
279+
pass
280+
if len(resources) == 1 and resources[0] == "*":
281+
return False
282+
elif len(resources) > 1: # pragma: no cover
283+
# It's possible that someone writes a bad policy that includes both a resource ARN as well as a wildcard.
284+
return not any(resource == "*" for resource in resources)
285+
return True

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
beautifulsoup4==4.9.3
22
boto3==1.16.43
33
botocore==1.19.43
4+
cached-property==1.5.2
45
certifi==2020.12.5
56
chardet==4.0.0
67
click==7.1.2

0 commit comments

Comments
 (0)