cyntrisec 0.1.7__py3-none-any.whl
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.
- cyntrisec/__init__.py +3 -0
- cyntrisec/__main__.py +6 -0
- cyntrisec/aws/__init__.py +6 -0
- cyntrisec/aws/collectors/__init__.py +17 -0
- cyntrisec/aws/collectors/ec2.py +30 -0
- cyntrisec/aws/collectors/iam.py +116 -0
- cyntrisec/aws/collectors/lambda_.py +45 -0
- cyntrisec/aws/collectors/network.py +70 -0
- cyntrisec/aws/collectors/rds.py +38 -0
- cyntrisec/aws/collectors/s3.py +68 -0
- cyntrisec/aws/collectors/usage.py +188 -0
- cyntrisec/aws/credentials.py +153 -0
- cyntrisec/aws/normalizers/__init__.py +17 -0
- cyntrisec/aws/normalizers/ec2.py +115 -0
- cyntrisec/aws/normalizers/iam.py +182 -0
- cyntrisec/aws/normalizers/lambda_.py +83 -0
- cyntrisec/aws/normalizers/network.py +225 -0
- cyntrisec/aws/normalizers/rds.py +130 -0
- cyntrisec/aws/normalizers/s3.py +184 -0
- cyntrisec/aws/relationship_builder.py +1359 -0
- cyntrisec/aws/scanner.py +303 -0
- cyntrisec/cli/__init__.py +5 -0
- cyntrisec/cli/analyze.py +747 -0
- cyntrisec/cli/ask.py +412 -0
- cyntrisec/cli/can.py +307 -0
- cyntrisec/cli/comply.py +226 -0
- cyntrisec/cli/cuts.py +231 -0
- cyntrisec/cli/diff.py +332 -0
- cyntrisec/cli/errors.py +105 -0
- cyntrisec/cli/explain.py +348 -0
- cyntrisec/cli/main.py +114 -0
- cyntrisec/cli/manifest.py +893 -0
- cyntrisec/cli/output.py +117 -0
- cyntrisec/cli/remediate.py +643 -0
- cyntrisec/cli/report.py +462 -0
- cyntrisec/cli/scan.py +207 -0
- cyntrisec/cli/schemas.py +391 -0
- cyntrisec/cli/serve.py +164 -0
- cyntrisec/cli/setup.py +260 -0
- cyntrisec/cli/validate.py +101 -0
- cyntrisec/cli/waste.py +323 -0
- cyntrisec/core/__init__.py +31 -0
- cyntrisec/core/business_config.py +110 -0
- cyntrisec/core/business_logic.py +131 -0
- cyntrisec/core/compliance.py +437 -0
- cyntrisec/core/cost_estimator.py +301 -0
- cyntrisec/core/cuts.py +360 -0
- cyntrisec/core/diff.py +361 -0
- cyntrisec/core/graph.py +202 -0
- cyntrisec/core/paths.py +830 -0
- cyntrisec/core/schema.py +317 -0
- cyntrisec/core/simulator.py +371 -0
- cyntrisec/core/waste.py +309 -0
- cyntrisec/mcp/__init__.py +5 -0
- cyntrisec/mcp/server.py +862 -0
- cyntrisec/storage/__init__.py +7 -0
- cyntrisec/storage/filesystem.py +344 -0
- cyntrisec/storage/memory.py +113 -0
- cyntrisec/storage/protocol.py +92 -0
- cyntrisec-0.1.7.dist-info/METADATA +672 -0
- cyntrisec-0.1.7.dist-info/RECORD +65 -0
- cyntrisec-0.1.7.dist-info/WHEEL +4 -0
- cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
- cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
- cyntrisec-0.1.7.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,1359 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Relationship Builder - Create relationships between assets from different normalizers.
|
|
3
|
+
|
|
4
|
+
This module runs after all normalizers have completed to wire up cross-service connections:
|
|
5
|
+
- Security Group → EC2 Instance (ALLOWS_TRAFFIC_TO)
|
|
6
|
+
- Subnet → EC2 Instance (CONTAINS)
|
|
7
|
+
- EC2 Instance → IAM Role (CAN_ASSUME via instance profile)
|
|
8
|
+
- Lambda → IAM Role (CAN_ASSUME via execution role)
|
|
9
|
+
- IAM Role → IAM Role (CAN_ASSUME via sts:AssumeRole permission + trust policy)
|
|
10
|
+
- Load Balancer → Security Group (USES)
|
|
11
|
+
- IAM Role → Sensitive Target (MAY_READ_SECRET, MAY_READ_PARAMETER, MAY_DECRYPT, etc.)
|
|
12
|
+
- IAM Role → IAM Role (CAN_PASS_TO via iam:PassRole permission)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fnmatch
|
|
18
|
+
import ipaddress
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from cyntrisec.core.schema import (
|
|
24
|
+
INTERNET_ASSET_ID,
|
|
25
|
+
Asset,
|
|
26
|
+
ConditionResult,
|
|
27
|
+
EdgeEvidence,
|
|
28
|
+
EdgeKind,
|
|
29
|
+
Relationship,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class EvaluationContext:
|
|
35
|
+
"""Context for evaluating IAM policy conditions.
|
|
36
|
+
|
|
37
|
+
Contains information about the source principal and network context
|
|
38
|
+
that can be used to evaluate IAM conditions.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# VPC endpoint ID if the request comes through a VPC endpoint
|
|
42
|
+
source_vpce: str | None = None
|
|
43
|
+
|
|
44
|
+
# VPC ID of the source
|
|
45
|
+
source_vpc_id: str | None = None
|
|
46
|
+
|
|
47
|
+
# Principal tags (key -> value)
|
|
48
|
+
principal_tags: dict[str, str] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
# Source IP address or CIDR
|
|
51
|
+
source_ip: str | None = None
|
|
52
|
+
|
|
53
|
+
# AWS account ID
|
|
54
|
+
account_id: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConditionEvaluator:
|
|
58
|
+
"""
|
|
59
|
+
Evaluates IAM policy conditions with tri-state results.
|
|
60
|
+
|
|
61
|
+
This class evaluates IAM policy Condition clauses and returns a tri-state result:
|
|
62
|
+
- TRUE: Condition is satisfied
|
|
63
|
+
- FALSE: Condition is not satisfied
|
|
64
|
+
- UNKNOWN: Cannot evaluate locally (unsupported condition or missing context)
|
|
65
|
+
|
|
66
|
+
Supported conditions:
|
|
67
|
+
- aws:SourceVpce: Checks if request comes from specified VPC endpoint
|
|
68
|
+
- aws:PrincipalTag: Checks if principal has matching tag
|
|
69
|
+
|
|
70
|
+
All other conditions return UNKNOWN.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
# Set of condition keys we can evaluate
|
|
74
|
+
SUPPORTED_CONDITIONS: set[str] = {
|
|
75
|
+
"aws:SourceVpce",
|
|
76
|
+
"aws:sourcevpce", # Case variations
|
|
77
|
+
"aws:PrincipalTag",
|
|
78
|
+
"aws:principaltag",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def evaluate(
|
|
82
|
+
self,
|
|
83
|
+
conditions: dict[str, Any],
|
|
84
|
+
context: EvaluationContext,
|
|
85
|
+
) -> ConditionResult:
|
|
86
|
+
"""
|
|
87
|
+
Evaluate IAM policy conditions against the provided context.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
conditions: The Condition block from an IAM policy statement
|
|
91
|
+
context: The evaluation context with source information
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
ConditionResult.TRUE if all conditions are satisfied
|
|
95
|
+
ConditionResult.FALSE if any condition is not satisfied
|
|
96
|
+
ConditionResult.UNKNOWN if any condition cannot be evaluated
|
|
97
|
+
"""
|
|
98
|
+
if not conditions:
|
|
99
|
+
return ConditionResult.TRUE
|
|
100
|
+
|
|
101
|
+
has_unknown = False
|
|
102
|
+
|
|
103
|
+
for operator, condition_block in conditions.items():
|
|
104
|
+
if not isinstance(condition_block, dict):
|
|
105
|
+
has_unknown = True
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
for condition_key, condition_value in condition_block.items():
|
|
109
|
+
result = self._evaluate_single_condition(
|
|
110
|
+
operator, condition_key, condition_value, context
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if result == ConditionResult.FALSE:
|
|
114
|
+
return ConditionResult.FALSE
|
|
115
|
+
elif result == ConditionResult.UNKNOWN:
|
|
116
|
+
has_unknown = True
|
|
117
|
+
|
|
118
|
+
return ConditionResult.UNKNOWN if has_unknown else ConditionResult.TRUE
|
|
119
|
+
|
|
120
|
+
def _evaluate_single_condition(
|
|
121
|
+
self,
|
|
122
|
+
operator: str,
|
|
123
|
+
condition_key: str,
|
|
124
|
+
condition_value: Any,
|
|
125
|
+
context: EvaluationContext,
|
|
126
|
+
) -> ConditionResult:
|
|
127
|
+
"""Evaluate a single condition."""
|
|
128
|
+
# Normalize condition key for comparison
|
|
129
|
+
key_lower = condition_key.lower()
|
|
130
|
+
|
|
131
|
+
# Handle aws:SourceVpce
|
|
132
|
+
if key_lower == "aws:sourcevpce":
|
|
133
|
+
return self.evaluate_source_vpce(operator, condition_value, context)
|
|
134
|
+
|
|
135
|
+
# Handle aws:PrincipalTag/*
|
|
136
|
+
if key_lower.startswith("aws:principaltag/"):
|
|
137
|
+
tag_key = condition_key.split("/", 1)[1] if "/" in condition_key else ""
|
|
138
|
+
return self.evaluate_principal_tag(operator, tag_key, condition_value, context)
|
|
139
|
+
|
|
140
|
+
# Unsupported condition - return UNKNOWN
|
|
141
|
+
return ConditionResult.UNKNOWN
|
|
142
|
+
|
|
143
|
+
def evaluate_source_vpce(
|
|
144
|
+
self,
|
|
145
|
+
operator: str,
|
|
146
|
+
condition_value: Any,
|
|
147
|
+
context: EvaluationContext,
|
|
148
|
+
) -> ConditionResult:
|
|
149
|
+
"""
|
|
150
|
+
Evaluate aws:SourceVpce condition.
|
|
151
|
+
|
|
152
|
+
Checks if the source VPC endpoint matches the expected value(s).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
operator: The condition operator (StringEquals, StringLike, etc.)
|
|
156
|
+
condition_value: The expected VPC endpoint ID(s)
|
|
157
|
+
context: The evaluation context
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
ConditionResult indicating if the condition is satisfied
|
|
161
|
+
"""
|
|
162
|
+
if context.source_vpce is None:
|
|
163
|
+
return ConditionResult.UNKNOWN
|
|
164
|
+
|
|
165
|
+
# Normalize condition value to list
|
|
166
|
+
expected_values = self._normalize_condition_value(condition_value)
|
|
167
|
+
|
|
168
|
+
# Handle different operators
|
|
169
|
+
operator_lower = operator.lower()
|
|
170
|
+
|
|
171
|
+
if operator_lower in ("stringequals", "stringequalsifexists"):
|
|
172
|
+
# Exact match
|
|
173
|
+
if context.source_vpce in expected_values:
|
|
174
|
+
return ConditionResult.TRUE
|
|
175
|
+
return ConditionResult.FALSE
|
|
176
|
+
|
|
177
|
+
elif operator_lower in ("stringnotequals", "stringnotequalsifexists"):
|
|
178
|
+
# Not equal
|
|
179
|
+
if context.source_vpce not in expected_values:
|
|
180
|
+
return ConditionResult.TRUE
|
|
181
|
+
return ConditionResult.FALSE
|
|
182
|
+
|
|
183
|
+
elif operator_lower in ("stringlike", "stringlikeifexists"):
|
|
184
|
+
# Wildcard match
|
|
185
|
+
for pattern in expected_values:
|
|
186
|
+
if fnmatch.fnmatch(context.source_vpce, pattern):
|
|
187
|
+
return ConditionResult.TRUE
|
|
188
|
+
return ConditionResult.FALSE
|
|
189
|
+
|
|
190
|
+
elif operator_lower in ("stringnotlike", "stringnotlikeifexists"):
|
|
191
|
+
# Not like (wildcard)
|
|
192
|
+
for pattern in expected_values:
|
|
193
|
+
if fnmatch.fnmatch(context.source_vpce, pattern):
|
|
194
|
+
return ConditionResult.FALSE
|
|
195
|
+
return ConditionResult.TRUE
|
|
196
|
+
|
|
197
|
+
# Unsupported operator
|
|
198
|
+
return ConditionResult.UNKNOWN
|
|
199
|
+
|
|
200
|
+
def evaluate_principal_tag(
|
|
201
|
+
self,
|
|
202
|
+
operator: str,
|
|
203
|
+
tag_key: str,
|
|
204
|
+
condition_value: Any,
|
|
205
|
+
context: EvaluationContext,
|
|
206
|
+
) -> ConditionResult:
|
|
207
|
+
"""
|
|
208
|
+
Evaluate aws:PrincipalTag condition.
|
|
209
|
+
|
|
210
|
+
Checks if the principal has a tag with the specified key and value.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
operator: The condition operator (StringEquals, StringLike, etc.)
|
|
214
|
+
tag_key: The tag key to check
|
|
215
|
+
condition_value: The expected tag value(s)
|
|
216
|
+
context: The evaluation context
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
ConditionResult indicating if the condition is satisfied
|
|
220
|
+
"""
|
|
221
|
+
if not context.principal_tags:
|
|
222
|
+
return ConditionResult.UNKNOWN
|
|
223
|
+
|
|
224
|
+
# Get the actual tag value from context
|
|
225
|
+
actual_value = context.principal_tags.get(tag_key)
|
|
226
|
+
|
|
227
|
+
if actual_value is None:
|
|
228
|
+
# Tag doesn't exist - for IfExists operators, this is TRUE
|
|
229
|
+
operator_lower = operator.lower()
|
|
230
|
+
if "ifexists" in operator_lower:
|
|
231
|
+
return ConditionResult.TRUE
|
|
232
|
+
return ConditionResult.FALSE
|
|
233
|
+
|
|
234
|
+
# Normalize condition value to list
|
|
235
|
+
expected_values = self._normalize_condition_value(condition_value)
|
|
236
|
+
|
|
237
|
+
# Handle different operators
|
|
238
|
+
operator_lower = operator.lower()
|
|
239
|
+
|
|
240
|
+
if operator_lower in ("stringequals", "stringequalsifexists"):
|
|
241
|
+
if actual_value in expected_values:
|
|
242
|
+
return ConditionResult.TRUE
|
|
243
|
+
return ConditionResult.FALSE
|
|
244
|
+
|
|
245
|
+
elif operator_lower in ("stringnotequals", "stringnotequalsifexists"):
|
|
246
|
+
if actual_value not in expected_values:
|
|
247
|
+
return ConditionResult.TRUE
|
|
248
|
+
return ConditionResult.FALSE
|
|
249
|
+
|
|
250
|
+
elif operator_lower in ("stringlike", "stringlikeifexists"):
|
|
251
|
+
for pattern in expected_values:
|
|
252
|
+
if fnmatch.fnmatch(actual_value, pattern):
|
|
253
|
+
return ConditionResult.TRUE
|
|
254
|
+
return ConditionResult.FALSE
|
|
255
|
+
|
|
256
|
+
elif operator_lower in ("stringnotlike", "stringnotlikeifexists"):
|
|
257
|
+
for pattern in expected_values:
|
|
258
|
+
if fnmatch.fnmatch(actual_value, pattern):
|
|
259
|
+
return ConditionResult.FALSE
|
|
260
|
+
return ConditionResult.TRUE
|
|
261
|
+
|
|
262
|
+
# Unsupported operator
|
|
263
|
+
return ConditionResult.UNKNOWN
|
|
264
|
+
|
|
265
|
+
def _normalize_condition_value(self, value: Any) -> list[str]:
|
|
266
|
+
"""Normalize condition value to a list of strings."""
|
|
267
|
+
if isinstance(value, str):
|
|
268
|
+
return [value]
|
|
269
|
+
if isinstance(value, list):
|
|
270
|
+
return [str(v) for v in value]
|
|
271
|
+
return [str(value)]
|
|
272
|
+
|
|
273
|
+
def check_explicit_deny_presence(
|
|
274
|
+
self,
|
|
275
|
+
role: Asset,
|
|
276
|
+
target_arn: str,
|
|
277
|
+
action: str,
|
|
278
|
+
) -> tuple[bool, str]:
|
|
279
|
+
"""
|
|
280
|
+
Check if explicit deny might apply to this access.
|
|
281
|
+
|
|
282
|
+
Detects the presence of:
|
|
283
|
+
- Identity policy Deny statements
|
|
284
|
+
- Permission boundaries
|
|
285
|
+
- SCP presence (if org data available)
|
|
286
|
+
- Resource policy Deny statements
|
|
287
|
+
|
|
288
|
+
When explicit deny is detected but cannot be fully evaluated,
|
|
289
|
+
confidence should be lowered to MED or LOW.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
role: The IAM role asset to check
|
|
293
|
+
target_arn: The target resource ARN
|
|
294
|
+
action: The IAM action being checked
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Tuple of (has_potential_deny, reason) where:
|
|
298
|
+
- has_potential_deny: True if explicit deny might apply
|
|
299
|
+
- reason: Specific explanation of what couldn't be fully evaluated
|
|
300
|
+
"""
|
|
301
|
+
reasons: list[str] = []
|
|
302
|
+
|
|
303
|
+
# Check identity policy denies
|
|
304
|
+
policy_docs = role.properties.get("policy_documents", [])
|
|
305
|
+
for policy in policy_docs:
|
|
306
|
+
if self._has_deny_statement(policy, target_arn, action):
|
|
307
|
+
reasons.append("identity policy Deny statement present")
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
# Check permission boundary presence
|
|
311
|
+
permission_boundary = role.properties.get("permission_boundary")
|
|
312
|
+
if permission_boundary:
|
|
313
|
+
reasons.append("permission boundary attached")
|
|
314
|
+
|
|
315
|
+
# Check SCP presence (if we have org data)
|
|
316
|
+
scp_present = role.properties.get("scp_present")
|
|
317
|
+
if scp_present:
|
|
318
|
+
reasons.append("SCP may apply")
|
|
319
|
+
|
|
320
|
+
# Check for inline policy denies
|
|
321
|
+
inline_policies = role.properties.get("inline_policies", [])
|
|
322
|
+
for policy in inline_policies:
|
|
323
|
+
if self._has_deny_statement(policy, target_arn, action):
|
|
324
|
+
reasons.append("inline policy Deny statement present")
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
# Check attached managed policy denies
|
|
328
|
+
attached_policies = role.properties.get("attached_policy_documents", [])
|
|
329
|
+
for policy in attached_policies:
|
|
330
|
+
if self._has_deny_statement(policy, target_arn, action):
|
|
331
|
+
reasons.append("attached managed policy Deny statement present")
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
if reasons:
|
|
335
|
+
return True, "possible explicit deny not fully evaluated: " + ", ".join(reasons)
|
|
336
|
+
return False, ""
|
|
337
|
+
|
|
338
|
+
def _has_deny_statement(
|
|
339
|
+
self,
|
|
340
|
+
policy: dict[str, Any],
|
|
341
|
+
target_arn: str,
|
|
342
|
+
action: str,
|
|
343
|
+
) -> bool:
|
|
344
|
+
"""
|
|
345
|
+
Check if a policy document contains a Deny statement that might apply.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
policy: The policy document
|
|
349
|
+
target_arn: The target resource ARN
|
|
350
|
+
action: The IAM action being checked
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
True if a potentially applicable Deny statement exists
|
|
354
|
+
"""
|
|
355
|
+
statements = policy.get("Statement", [])
|
|
356
|
+
if isinstance(statements, dict):
|
|
357
|
+
statements = [statements]
|
|
358
|
+
|
|
359
|
+
for statement in statements:
|
|
360
|
+
if not isinstance(statement, dict):
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
effect = statement.get("Effect", "Allow")
|
|
364
|
+
if effect != "Deny":
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# Check if action matches
|
|
368
|
+
stmt_actions = statement.get("Action", [])
|
|
369
|
+
if isinstance(stmt_actions, str):
|
|
370
|
+
stmt_actions = [stmt_actions]
|
|
371
|
+
|
|
372
|
+
action_matches = False
|
|
373
|
+
for stmt_action in stmt_actions:
|
|
374
|
+
if stmt_action == "*" or fnmatch.fnmatch(action.lower(), stmt_action.lower()):
|
|
375
|
+
action_matches = True
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
if not action_matches:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
# Check if resource matches
|
|
382
|
+
stmt_resources = statement.get("Resource", [])
|
|
383
|
+
if isinstance(stmt_resources, str):
|
|
384
|
+
stmt_resources = [stmt_resources]
|
|
385
|
+
|
|
386
|
+
for stmt_resource in stmt_resources:
|
|
387
|
+
if stmt_resource == "*" or fnmatch.fnmatch(target_arn, stmt_resource):
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class ActionParser:
|
|
394
|
+
"""
|
|
395
|
+
Parses IAM actions using fnmatch against known capability set.
|
|
396
|
+
|
|
397
|
+
This class matches IAM policy Action/NotAction patterns against a predefined
|
|
398
|
+
set of capability-granting actions. It does NOT enumerate or expand wildcards
|
|
399
|
+
to a full action list; instead it matches patterns directly using fnmatch.
|
|
400
|
+
|
|
401
|
+
The matching logic:
|
|
402
|
+
- For Action: a capability is matched if any Action pattern matches it
|
|
403
|
+
- For NotAction: a capability is allowed unless it matches a NotAction pattern
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
# Capability-granting actions mapped to edge types
|
|
407
|
+
# These are the specific IAM actions that grant meaningful capabilities
|
|
408
|
+
CAPABILITY_ACTIONS: dict[str, str] = {
|
|
409
|
+
"secretsmanager:GetSecretValue": "MAY_READ_SECRET",
|
|
410
|
+
"ssm:GetParameter": "MAY_READ_PARAMETER",
|
|
411
|
+
"ssm:GetParameters": "MAY_READ_PARAMETER",
|
|
412
|
+
"ssm:GetParametersByPath": "MAY_READ_PARAMETER",
|
|
413
|
+
"kms:Decrypt": "MAY_DECRYPT",
|
|
414
|
+
"s3:GetObject": "MAY_READ_S3_OBJECT",
|
|
415
|
+
"lambda:CreateFunction": "MAY_CREATE_LAMBDA",
|
|
416
|
+
"lambda:UpdateFunctionConfiguration": "MAY_CREATE_LAMBDA",
|
|
417
|
+
"iam:PassRole": "CAN_PASS_TO",
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
def get_matched_capabilities(self, statement: dict[str, Any]) -> set[str]:
|
|
421
|
+
"""
|
|
422
|
+
Get capability actions matched by this IAM policy statement.
|
|
423
|
+
|
|
424
|
+
Uses fnmatch to check if statement's Action patterns match
|
|
425
|
+
any capability action. Does NOT enumerate all AWS actions.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
statement: An IAM policy statement dict with Action/NotAction fields
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Set of matched capability action strings (e.g., "secretsmanager:GetSecretValue")
|
|
432
|
+
"""
|
|
433
|
+
actions = self._normalize_actions(statement.get("Action", []))
|
|
434
|
+
not_actions = self._normalize_actions(statement.get("NotAction", []))
|
|
435
|
+
|
|
436
|
+
matched: set[str] = set()
|
|
437
|
+
|
|
438
|
+
# Case 1: Statement has Action field
|
|
439
|
+
if actions:
|
|
440
|
+
for capability_action in self.CAPABILITY_ACTIONS:
|
|
441
|
+
# Check if any Action pattern matches this capability
|
|
442
|
+
if self._any_pattern_matches(actions, capability_action):
|
|
443
|
+
# Check it's not excluded by NotAction (if both are present)
|
|
444
|
+
if not not_actions or not self._any_pattern_matches(not_actions, capability_action):
|
|
445
|
+
matched.add(capability_action)
|
|
446
|
+
|
|
447
|
+
# Case 2: Statement has only NotAction field (no Action)
|
|
448
|
+
# For NotAction: capability is allowed unless it matches NotAction pattern
|
|
449
|
+
elif not_actions:
|
|
450
|
+
for capability_action in self.CAPABILITY_ACTIONS:
|
|
451
|
+
if not self._any_pattern_matches(not_actions, capability_action):
|
|
452
|
+
matched.add(capability_action)
|
|
453
|
+
|
|
454
|
+
return matched
|
|
455
|
+
|
|
456
|
+
def get_edge_type_for_action(self, action: str) -> str | None:
|
|
457
|
+
"""
|
|
458
|
+
Get the edge type for a specific capability action.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
action: The IAM action string (e.g., "secretsmanager:GetSecretValue")
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
The edge type string (e.g., "MAY_READ_SECRET") or None if not a capability action
|
|
465
|
+
"""
|
|
466
|
+
return self.CAPABILITY_ACTIONS.get(action)
|
|
467
|
+
|
|
468
|
+
def _any_pattern_matches(self, patterns: list[str], action: str) -> bool:
|
|
469
|
+
"""
|
|
470
|
+
Check if any pattern matches the action using fnmatch.
|
|
471
|
+
|
|
472
|
+
This supports wildcards in patterns:
|
|
473
|
+
- "s3:*" matches "s3:GetObject"
|
|
474
|
+
- "s3:Get*" matches "s3:GetObject" and "s3:GetBucketPolicy"
|
|
475
|
+
- "*" matches any action
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
patterns: List of IAM action patterns (may contain wildcards)
|
|
479
|
+
action: The specific action to check
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
True if any pattern matches the action
|
|
483
|
+
"""
|
|
484
|
+
for pattern in patterns:
|
|
485
|
+
# Handle case-insensitive matching for IAM actions
|
|
486
|
+
# IAM actions are case-insensitive, but we normalize to lowercase for matching
|
|
487
|
+
pattern_lower = pattern.lower()
|
|
488
|
+
action_lower = action.lower()
|
|
489
|
+
|
|
490
|
+
if fnmatch.fnmatch(action_lower, pattern_lower):
|
|
491
|
+
return True
|
|
492
|
+
return False
|
|
493
|
+
|
|
494
|
+
def _normalize_actions(self, actions: Any) -> list[str]:
|
|
495
|
+
"""
|
|
496
|
+
Normalize Action/NotAction field to list of strings.
|
|
497
|
+
|
|
498
|
+
IAM policies can have Action/NotAction as either a string or list.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
actions: The Action or NotAction field value
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
List of action strings
|
|
505
|
+
"""
|
|
506
|
+
if not actions:
|
|
507
|
+
return []
|
|
508
|
+
if isinstance(actions, str):
|
|
509
|
+
return [actions]
|
|
510
|
+
if isinstance(actions, list):
|
|
511
|
+
return [a for a in actions if isinstance(a, str)]
|
|
512
|
+
return []
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class RelationshipBuilder:
|
|
516
|
+
"""
|
|
517
|
+
Build relationships between assets from different sources.
|
|
518
|
+
|
|
519
|
+
This is a post-processing step that runs after all normalizers complete.
|
|
520
|
+
It creates edges that require knowledge of both source and target assets.
|
|
521
|
+
"""
|
|
522
|
+
|
|
523
|
+
def __init__(self, snapshot_id: uuid.UUID):
|
|
524
|
+
self._snapshot_id = snapshot_id
|
|
525
|
+
# Indexes populated during build
|
|
526
|
+
self._by_type: dict[str, list[Asset]] = {}
|
|
527
|
+
self._sg_by_id: dict[str, Asset] = {}
|
|
528
|
+
self._subnet_by_id: dict[str, Asset] = {}
|
|
529
|
+
self._assets_by_sg: dict[str, list[Asset]] = {}
|
|
530
|
+
|
|
531
|
+
def build(self, assets: list[Asset]) -> list[Relationship]:
|
|
532
|
+
"""
|
|
533
|
+
Build all cross-service relationships.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
assets: All assets from all normalizers
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
List of new relationships to add
|
|
540
|
+
"""
|
|
541
|
+
# Ensure Internet asset exists
|
|
542
|
+
self._ensure_internet_asset(assets)
|
|
543
|
+
|
|
544
|
+
# Build indexes
|
|
545
|
+
self._index_assets(assets)
|
|
546
|
+
|
|
547
|
+
# Build relationships by category
|
|
548
|
+
relationships: list[Relationship] = []
|
|
549
|
+
relationships.extend(self._build_ec2_relationships())
|
|
550
|
+
relationships.extend(self._build_lambda_relationships())
|
|
551
|
+
relationships.extend(self._build_loadbalancer_relationships())
|
|
552
|
+
relationships.extend(self._build_iam_access_relationships(assets))
|
|
553
|
+
relationships.extend(self._build_pass_role_relationships(assets))
|
|
554
|
+
relationships.extend(self._build_lambda_creation_relationships(assets))
|
|
555
|
+
relationships.extend(self._build_role_to_role_assume_relationships(assets))
|
|
556
|
+
relationships.extend(self._build_network_reachability_relationships())
|
|
557
|
+
relationships.extend(self._build_identity_relationships())
|
|
558
|
+
|
|
559
|
+
return relationships
|
|
560
|
+
|
|
561
|
+
def _index_assets(self, assets: list[Asset]) -> None:
|
|
562
|
+
"""Build lookup indexes for fast asset access."""
|
|
563
|
+
self._by_type = {}
|
|
564
|
+
self._sg_by_id = {}
|
|
565
|
+
self._subnet_by_id = {}
|
|
566
|
+
self._assets_by_sg = {}
|
|
567
|
+
|
|
568
|
+
for asset in assets:
|
|
569
|
+
self._by_type.setdefault(asset.asset_type, []).append(asset)
|
|
570
|
+
|
|
571
|
+
if asset.asset_type == "ec2:security-group":
|
|
572
|
+
self._sg_by_id[asset.aws_resource_id] = asset
|
|
573
|
+
elif asset.asset_type == "ec2:subnet":
|
|
574
|
+
self._subnet_by_id[asset.aws_resource_id] = asset
|
|
575
|
+
|
|
576
|
+
# Index by security group
|
|
577
|
+
sg_ids = asset.properties.get("security_groups", [])
|
|
578
|
+
for sg_id in sg_ids:
|
|
579
|
+
self._assets_by_sg.setdefault(sg_id, []).append(asset)
|
|
580
|
+
|
|
581
|
+
def _ensure_internet_asset(self, assets: list[Asset]) -> None:
|
|
582
|
+
"""Ensure the Internet pseudo-asset exists."""
|
|
583
|
+
for asset in assets:
|
|
584
|
+
if asset.id == INTERNET_ASSET_ID:
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
internet = Asset(
|
|
588
|
+
id=INTERNET_ASSET_ID,
|
|
589
|
+
snapshot_id=self._snapshot_id,
|
|
590
|
+
asset_type="pseudo:internet",
|
|
591
|
+
aws_resource_id="internet",
|
|
592
|
+
name="Internet",
|
|
593
|
+
is_internet_facing=True,
|
|
594
|
+
properties={"description": "The Internet (0.0.0.0/0)"}
|
|
595
|
+
)
|
|
596
|
+
assets.append(internet)
|
|
597
|
+
|
|
598
|
+
def _build_network_reachability_relationships(self) -> list[Relationship]:
|
|
599
|
+
"""Build CAN_REACH edges based on network accessibility."""
|
|
600
|
+
relationships: list[Relationship] = []
|
|
601
|
+
|
|
602
|
+
# Iterate over all security groups
|
|
603
|
+
for sg in self._sg_by_id.values():
|
|
604
|
+
targets = self._assets_by_sg.get(sg.aws_resource_id, [])
|
|
605
|
+
if not targets:
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
ingress_rules = sg.properties.get("ingress_rules", [])
|
|
609
|
+
|
|
610
|
+
for rule in ingress_rules:
|
|
611
|
+
# 6.1 Internet Reachability (0.0.0.0/0)
|
|
612
|
+
for ip_range in rule.get("IpRanges", []):
|
|
613
|
+
cidr = ip_range.get("CidrIp")
|
|
614
|
+
if cidr == "0.0.0.0/0":
|
|
615
|
+
for target in targets:
|
|
616
|
+
relationships.append(
|
|
617
|
+
self._create_can_reach_edge(
|
|
618
|
+
INTERNET_ASSET_ID,
|
|
619
|
+
target.id,
|
|
620
|
+
rule,
|
|
621
|
+
source_label="world"
|
|
622
|
+
)
|
|
623
|
+
)
|
|
624
|
+
# 6.3 CIDR Containment
|
|
625
|
+
elif cidr:
|
|
626
|
+
try:
|
|
627
|
+
rule_net = ipaddress.ip_network(cidr)
|
|
628
|
+
# Check against all known subnets
|
|
629
|
+
for subnet in self._subnet_by_id.values():
|
|
630
|
+
subnet_cidr = subnet.properties.get("cidr_block")
|
|
631
|
+
if subnet_cidr:
|
|
632
|
+
subnet_net = ipaddress.ip_network(subnet_cidr)
|
|
633
|
+
if rule_net.supernet_of(subnet_net):
|
|
634
|
+
for target in targets:
|
|
635
|
+
relationships.append(
|
|
636
|
+
self._create_can_reach_edge(
|
|
637
|
+
subnet.id,
|
|
638
|
+
target.id,
|
|
639
|
+
rule,
|
|
640
|
+
source_label=subnet.name,
|
|
641
|
+
confidence=0.5
|
|
642
|
+
)
|
|
643
|
+
)
|
|
644
|
+
except ValueError:
|
|
645
|
+
continue
|
|
646
|
+
|
|
647
|
+
# IPv6 Internet Reachability
|
|
648
|
+
for ipv6_range in rule.get("Ipv6Ranges", []):
|
|
649
|
+
if ipv6_range.get("CidrIpv6") == "::/0":
|
|
650
|
+
for target in targets:
|
|
651
|
+
relationships.append(
|
|
652
|
+
self._create_can_reach_edge(
|
|
653
|
+
INTERNET_ASSET_ID,
|
|
654
|
+
target.id,
|
|
655
|
+
rule,
|
|
656
|
+
source_label="world"
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# 6.2 Lateral SG-to-SG Reachability
|
|
661
|
+
for group_pair in rule.get("UserIdGroupPairs", []):
|
|
662
|
+
source_group_id = group_pair.get("GroupId")
|
|
663
|
+
if source_group_id and source_group_id in self._sg_by_id:
|
|
664
|
+
source_sg = self._sg_by_id[source_group_id]
|
|
665
|
+
for target in targets:
|
|
666
|
+
relationships.append(
|
|
667
|
+
self._create_can_reach_edge(
|
|
668
|
+
source_sg.id,
|
|
669
|
+
target.id,
|
|
670
|
+
rule,
|
|
671
|
+
source_label=source_sg.name
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
return relationships
|
|
676
|
+
|
|
677
|
+
def _create_can_reach_edge(
|
|
678
|
+
self,
|
|
679
|
+
source_id: uuid.UUID,
|
|
680
|
+
target_id: uuid.UUID,
|
|
681
|
+
rule: dict,
|
|
682
|
+
source_label: str,
|
|
683
|
+
confidence: float = 1.0,
|
|
684
|
+
) -> Relationship:
|
|
685
|
+
"""Create a CAN_REACH relationship from ingress rule.
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
source_id: Source asset UUID (e.g., Internet, SG, or Subnet)
|
|
689
|
+
target_id: Target asset UUID
|
|
690
|
+
rule: Security group ingress rule dict
|
|
691
|
+
source_label: Human-readable source label
|
|
692
|
+
confidence: Confidence score (1.0=HIGH, 0.5=MED for CIDR inference)
|
|
693
|
+
"""
|
|
694
|
+
from_port = rule.get("FromPort")
|
|
695
|
+
to_port = rule.get("ToPort")
|
|
696
|
+
protocol = rule.get("IpProtocol")
|
|
697
|
+
|
|
698
|
+
props = {
|
|
699
|
+
"protocol": protocol,
|
|
700
|
+
"port_range": f"{from_port}-{to_port}" if from_port is not None else "all",
|
|
701
|
+
"source": source_label,
|
|
702
|
+
"confidence": confidence,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return Relationship(
|
|
706
|
+
snapshot_id=self._snapshot_id,
|
|
707
|
+
source_asset_id=source_id,
|
|
708
|
+
target_asset_id=target_id,
|
|
709
|
+
relationship_type="CAN_REACH",
|
|
710
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
711
|
+
properties=props,
|
|
712
|
+
edge_weight=1.0,
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
def _build_identity_relationships(self) -> list[Relationship]:
|
|
716
|
+
"""
|
|
717
|
+
Build USE_IDENTITY edges from Compute resources to their Network Identity (SGs).
|
|
718
|
+
This allows traversal from an Instance to the SG node, enabling access to CAN_REACH edges
|
|
719
|
+
originating from that SG.
|
|
720
|
+
"""
|
|
721
|
+
relationships: list[Relationship] = []
|
|
722
|
+
|
|
723
|
+
for sg_id, sg_asset in self._sg_by_id.items():
|
|
724
|
+
members = self._assets_by_sg.get(sg_id, [])
|
|
725
|
+
for member in members:
|
|
726
|
+
relationships.append(
|
|
727
|
+
Relationship(
|
|
728
|
+
snapshot_id=self._snapshot_id,
|
|
729
|
+
source_asset_id=member.id,
|
|
730
|
+
target_asset_id=sg_asset.id,
|
|
731
|
+
relationship_type="USE_IDENTITY",
|
|
732
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
733
|
+
properties={}
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
return relationships
|
|
737
|
+
|
|
738
|
+
def _build_ec2_relationships(self) -> list[Relationship]:
|
|
739
|
+
"""Build relationships for EC2 instances."""
|
|
740
|
+
relationships: list[Relationship] = []
|
|
741
|
+
|
|
742
|
+
for instance in self._by_type.get("ec2:instance", []):
|
|
743
|
+
props = instance.properties
|
|
744
|
+
|
|
745
|
+
# Security Group → Instance
|
|
746
|
+
relationships.extend(self._sg_to_instance_rels(instance, props))
|
|
747
|
+
|
|
748
|
+
# Subnet → Instance
|
|
749
|
+
rel = self._subnet_to_instance_rel(instance, props)
|
|
750
|
+
if rel:
|
|
751
|
+
relationships.append(rel)
|
|
752
|
+
|
|
753
|
+
# Instance → IAM Role (via instance profile)
|
|
754
|
+
relationships.extend(self._instance_to_role_rels(instance, props))
|
|
755
|
+
|
|
756
|
+
return relationships
|
|
757
|
+
|
|
758
|
+
def _sg_to_instance_rels(self, instance: Asset, props: dict) -> list[Relationship]:
|
|
759
|
+
"""Create Security Group → Instance relationships."""
|
|
760
|
+
relationships = []
|
|
761
|
+
for sg_id in props.get("security_groups", []):
|
|
762
|
+
if sg_id in self._sg_by_id:
|
|
763
|
+
sg_asset = self._sg_by_id[sg_id]
|
|
764
|
+
relationships.append(
|
|
765
|
+
Relationship(
|
|
766
|
+
snapshot_id=self._snapshot_id,
|
|
767
|
+
source_asset_id=sg_asset.id,
|
|
768
|
+
target_asset_id=instance.id,
|
|
769
|
+
relationship_type="ALLOWS_TRAFFIC_TO",
|
|
770
|
+
edge_kind=EdgeKind.STRUCTURAL,
|
|
771
|
+
properties={"open_to_world": self._is_sg_open_to_world(sg_asset)},
|
|
772
|
+
)
|
|
773
|
+
)
|
|
774
|
+
return relationships
|
|
775
|
+
|
|
776
|
+
def _subnet_to_instance_rel(self, instance: Asset, props: dict) -> Relationship | None:
|
|
777
|
+
"""Create Subnet → Instance containment relationship."""
|
|
778
|
+
subnet_id = props.get("subnet_id")
|
|
779
|
+
if subnet_id and subnet_id in self._subnet_by_id:
|
|
780
|
+
return Relationship(
|
|
781
|
+
snapshot_id=self._snapshot_id,
|
|
782
|
+
source_asset_id=self._subnet_by_id[subnet_id].id,
|
|
783
|
+
target_asset_id=instance.id,
|
|
784
|
+
relationship_type="CONTAINS",
|
|
785
|
+
edge_kind=EdgeKind.STRUCTURAL,
|
|
786
|
+
)
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
def _instance_to_role_rels(self, instance: Asset, props: dict) -> list[Relationship]:
|
|
790
|
+
"""Create Instance → IAM Role relationships via instance profile."""
|
|
791
|
+
relationships = []
|
|
792
|
+
profile_arn = props.get("iam_instance_profile")
|
|
793
|
+
if not profile_arn:
|
|
794
|
+
return relationships
|
|
795
|
+
|
|
796
|
+
for profile in self._by_type.get("iam:instance-profile", []):
|
|
797
|
+
if profile.arn == profile_arn or profile.aws_resource_id == profile_arn:
|
|
798
|
+
role_arns = profile.properties.get("role_arns") or []
|
|
799
|
+
primary_role = profile.properties.get("role_arn")
|
|
800
|
+
if primary_role and primary_role not in role_arns:
|
|
801
|
+
role_arns.append(primary_role)
|
|
802
|
+
|
|
803
|
+
for role in self._by_type.get("iam:role", []):
|
|
804
|
+
if role.arn in role_arns:
|
|
805
|
+
relationships.append(
|
|
806
|
+
Relationship(
|
|
807
|
+
snapshot_id=self._snapshot_id,
|
|
808
|
+
source_asset_id=instance.id,
|
|
809
|
+
target_asset_id=role.id,
|
|
810
|
+
relationship_type="CAN_ASSUME",
|
|
811
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
812
|
+
properties={"via": "instance_profile"},
|
|
813
|
+
)
|
|
814
|
+
)
|
|
815
|
+
return relationships
|
|
816
|
+
|
|
817
|
+
def _build_lambda_relationships(self) -> list[Relationship]:
|
|
818
|
+
"""Build Lambda → IAM Role relationships."""
|
|
819
|
+
relationships = []
|
|
820
|
+
for func in self._by_type.get("lambda:function", []):
|
|
821
|
+
role_arn = func.properties.get("role")
|
|
822
|
+
if not role_arn:
|
|
823
|
+
continue
|
|
824
|
+
|
|
825
|
+
for role in self._by_type.get("iam:role", []):
|
|
826
|
+
if role.arn == role_arn:
|
|
827
|
+
relationships.append(
|
|
828
|
+
Relationship(
|
|
829
|
+
snapshot_id=self._snapshot_id,
|
|
830
|
+
source_asset_id=func.id,
|
|
831
|
+
target_asset_id=role.id,
|
|
832
|
+
relationship_type="CAN_ASSUME",
|
|
833
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
834
|
+
properties={"via": "execution_role"},
|
|
835
|
+
)
|
|
836
|
+
)
|
|
837
|
+
return relationships
|
|
838
|
+
|
|
839
|
+
def _build_loadbalancer_relationships(self) -> list[Relationship]:
|
|
840
|
+
"""Build Load Balancer → Security Group relationships."""
|
|
841
|
+
relationships = []
|
|
842
|
+
for lb in self._by_type.get("elbv2:load-balancer", []):
|
|
843
|
+
for sg_id in lb.properties.get("security_groups", []):
|
|
844
|
+
if sg_id in self._sg_by_id:
|
|
845
|
+
relationships.append(
|
|
846
|
+
Relationship(
|
|
847
|
+
snapshot_id=self._snapshot_id,
|
|
848
|
+
source_asset_id=lb.id,
|
|
849
|
+
target_asset_id=self._sg_by_id[sg_id].id,
|
|
850
|
+
relationship_type="USES",
|
|
851
|
+
edge_kind=EdgeKind.STRUCTURAL,
|
|
852
|
+
)
|
|
853
|
+
)
|
|
854
|
+
return relationships
|
|
855
|
+
|
|
856
|
+
def _build_iam_access_relationships(self, assets: list[Asset]) -> list[Relationship]:
|
|
857
|
+
"""Build IAM Role → Sensitive Target access relationships with action-specific edges.
|
|
858
|
+
|
|
859
|
+
Creates action-specific capability edges instead of generic MAY_ACCESS:
|
|
860
|
+
- MAY_READ_SECRET for secretsmanager:GetSecretValue
|
|
861
|
+
- MAY_READ_PARAMETER for ssm:GetParameter*
|
|
862
|
+
- MAY_DECRYPT for kms:Decrypt
|
|
863
|
+
- MAY_READ_S3_OBJECT for s3:GetObject
|
|
864
|
+
|
|
865
|
+
Each capability edge includes evidence with policy_sid, target_arn, permission,
|
|
866
|
+
and raw_statement for provenance tracking.
|
|
867
|
+
"""
|
|
868
|
+
relationships = []
|
|
869
|
+
action_parser = ActionParser()
|
|
870
|
+
|
|
871
|
+
# Collect roles used by compute resources
|
|
872
|
+
compute_roles = self._collect_compute_roles()
|
|
873
|
+
|
|
874
|
+
# Create action-specific relationships to sensitive targets
|
|
875
|
+
sensitive_targets = [a for a in assets if a.is_sensitive_target]
|
|
876
|
+
role_lookup = {role.id: role for role in self._by_type.get("iam:role", [])}
|
|
877
|
+
|
|
878
|
+
# Map target asset types to relevant capability actions
|
|
879
|
+
target_type_to_actions = {
|
|
880
|
+
"secretsmanager:secret": ["secretsmanager:GetSecretValue"],
|
|
881
|
+
"ssm:parameter": ["ssm:GetParameter", "ssm:GetParameters", "ssm:GetParametersByPath"],
|
|
882
|
+
"kms:key": ["kms:Decrypt"],
|
|
883
|
+
"s3:bucket": ["s3:GetObject"],
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
for role_id in compute_roles:
|
|
887
|
+
role = role_lookup.get(role_id)
|
|
888
|
+
if not role:
|
|
889
|
+
continue
|
|
890
|
+
|
|
891
|
+
policy_docs = role.properties.get("policy_documents", [])
|
|
892
|
+
|
|
893
|
+
for target in sensitive_targets:
|
|
894
|
+
target_arn = target.arn or target.aws_resource_id
|
|
895
|
+
if not target_arn or role_id == target.id:
|
|
896
|
+
continue
|
|
897
|
+
|
|
898
|
+
# Get relevant actions for this target type
|
|
899
|
+
relevant_actions = target_type_to_actions.get(target.asset_type, [])
|
|
900
|
+
if not relevant_actions:
|
|
901
|
+
continue
|
|
902
|
+
|
|
903
|
+
# Check each policy document for matching capabilities
|
|
904
|
+
for policy in policy_docs:
|
|
905
|
+
policy_arn = policy.get("PolicyArn") or policy.get("Arn")
|
|
906
|
+
|
|
907
|
+
for statement in self._iter_policy_statements(policy):
|
|
908
|
+
if statement.get("Effect") != "Allow":
|
|
909
|
+
continue
|
|
910
|
+
|
|
911
|
+
# Check if statement resources match target
|
|
912
|
+
resources = self._normalize_resources(statement.get("Resource"))
|
|
913
|
+
if not self._resources_match_target(resources, [], target_arn):
|
|
914
|
+
continue
|
|
915
|
+
|
|
916
|
+
# Get matched capabilities from this statement
|
|
917
|
+
matched_capabilities = action_parser.get_matched_capabilities(statement)
|
|
918
|
+
|
|
919
|
+
# Create edges for relevant matched capabilities
|
|
920
|
+
for capability_action in matched_capabilities:
|
|
921
|
+
if capability_action in relevant_actions:
|
|
922
|
+
edge_type = action_parser.get_edge_type_for_action(capability_action)
|
|
923
|
+
if edge_type:
|
|
924
|
+
# Create evidence for provenance tracking
|
|
925
|
+
evidence = EdgeEvidence(
|
|
926
|
+
policy_sid=statement.get("Sid"),
|
|
927
|
+
policy_arn=policy_arn,
|
|
928
|
+
source_arn=role.arn,
|
|
929
|
+
target_arn=target_arn,
|
|
930
|
+
permission=capability_action,
|
|
931
|
+
raw_statement=statement,
|
|
932
|
+
)
|
|
933
|
+
|
|
934
|
+
relationships.append(
|
|
935
|
+
Relationship(
|
|
936
|
+
snapshot_id=self._snapshot_id,
|
|
937
|
+
source_asset_id=role_id,
|
|
938
|
+
target_asset_id=target.id,
|
|
939
|
+
relationship_type=edge_type,
|
|
940
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
941
|
+
evidence=evidence,
|
|
942
|
+
properties={
|
|
943
|
+
"via": "iam_policy",
|
|
944
|
+
"action": capability_action,
|
|
945
|
+
},
|
|
946
|
+
)
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
return relationships
|
|
950
|
+
|
|
951
|
+
def _collect_policy_resources(self, policy_docs: list[dict]) -> tuple[list[str], list[str]]:
|
|
952
|
+
"""Extract allowed and denied resources from policy documents."""
|
|
953
|
+
allowed: list[str] = []
|
|
954
|
+
denied: list[str] = []
|
|
955
|
+
for policy in policy_docs:
|
|
956
|
+
for statement in self._iter_policy_statements(policy):
|
|
957
|
+
effect = statement.get("Effect", "Allow")
|
|
958
|
+
resources = self._normalize_resources(statement.get("Resource"))
|
|
959
|
+
|
|
960
|
+
if effect == "Allow":
|
|
961
|
+
allowed.extend(resources)
|
|
962
|
+
elif effect == "Deny":
|
|
963
|
+
denied.extend(resources)
|
|
964
|
+
|
|
965
|
+
return allowed, denied
|
|
966
|
+
|
|
967
|
+
@staticmethod
|
|
968
|
+
def _iter_policy_statements(policy: dict) -> list[dict]:
|
|
969
|
+
"""Return policy statements as a list."""
|
|
970
|
+
statements = policy.get("Statement", [])
|
|
971
|
+
if isinstance(statements, list):
|
|
972
|
+
return statements
|
|
973
|
+
if isinstance(statements, dict):
|
|
974
|
+
return [statements]
|
|
975
|
+
return []
|
|
976
|
+
|
|
977
|
+
@staticmethod
|
|
978
|
+
def _normalize_resources(resource_value) -> list[str]:
|
|
979
|
+
"""Normalize Resource field into a list of strings."""
|
|
980
|
+
if not resource_value:
|
|
981
|
+
return []
|
|
982
|
+
if isinstance(resource_value, list):
|
|
983
|
+
return [r for r in resource_value if isinstance(r, str)]
|
|
984
|
+
if isinstance(resource_value, str):
|
|
985
|
+
return [resource_value]
|
|
986
|
+
return []
|
|
987
|
+
|
|
988
|
+
@staticmethod
|
|
989
|
+
def _resources_match_target(allowed: list[str], denied: list[str], target_arn: str) -> bool:
|
|
990
|
+
"""Return True when matches allowed and NOT denied."""
|
|
991
|
+
# Check explicit deny first
|
|
992
|
+
for resource in denied:
|
|
993
|
+
if resource == "*" or fnmatch.fnmatchcase(target_arn, resource):
|
|
994
|
+
return False
|
|
995
|
+
|
|
996
|
+
# Check allow
|
|
997
|
+
for resource in allowed:
|
|
998
|
+
if resource == "*" or fnmatch.fnmatchcase(target_arn, resource):
|
|
999
|
+
return True
|
|
1000
|
+
return False
|
|
1001
|
+
|
|
1002
|
+
def _collect_compute_roles(self) -> set[uuid.UUID]:
|
|
1003
|
+
"""Collect IAM roles used by EC2 instances and Lambda functions."""
|
|
1004
|
+
roles: set[uuid.UUID] = set()
|
|
1005
|
+
|
|
1006
|
+
# EC2 instance roles
|
|
1007
|
+
for instance in self._by_type.get("ec2:instance", []):
|
|
1008
|
+
profile_arn = instance.properties.get("iam_instance_profile")
|
|
1009
|
+
if profile_arn:
|
|
1010
|
+
for profile in self._by_type.get("iam:instance-profile", []):
|
|
1011
|
+
if profile.arn == profile_arn or profile.aws_resource_id == profile_arn:
|
|
1012
|
+
role_arns = profile.properties.get("role_arns") or []
|
|
1013
|
+
primary_role = profile.properties.get("role_arn")
|
|
1014
|
+
if primary_role and primary_role not in role_arns:
|
|
1015
|
+
role_arns.append(primary_role)
|
|
1016
|
+
for role in self._by_type.get("iam:role", []):
|
|
1017
|
+
if role.arn in role_arns:
|
|
1018
|
+
roles.add(role.id)
|
|
1019
|
+
|
|
1020
|
+
# Lambda execution roles
|
|
1021
|
+
for func in self._by_type.get("lambda:function", []):
|
|
1022
|
+
role_arn = func.properties.get("role")
|
|
1023
|
+
if role_arn:
|
|
1024
|
+
for role in self._by_type.get("iam:role", []):
|
|
1025
|
+
if role.arn == role_arn:
|
|
1026
|
+
roles.add(role.id)
|
|
1027
|
+
|
|
1028
|
+
return roles
|
|
1029
|
+
|
|
1030
|
+
def _is_sg_open_to_world(self, sg_asset: Asset) -> bool:
|
|
1031
|
+
"""Check if a security group has 0.0.0.0/0 or ::/0 ingress rules."""
|
|
1032
|
+
for rule in sg_asset.properties.get("ingress_rules", []):
|
|
1033
|
+
for ip_range in rule.get("IpRanges", []):
|
|
1034
|
+
if ip_range.get("CidrIp") == "0.0.0.0/0":
|
|
1035
|
+
return True
|
|
1036
|
+
for ip_range in rule.get("Ipv6Ranges", []):
|
|
1037
|
+
if ip_range.get("CidrIpv6") == "::/0":
|
|
1038
|
+
return True
|
|
1039
|
+
return False
|
|
1040
|
+
|
|
1041
|
+
def _build_pass_role_relationships(self, assets: list[Asset]) -> list[Relationship]:
|
|
1042
|
+
"""Build IAM Role -> Role relationships via PassRole (Privilege Escalation).
|
|
1043
|
+
|
|
1044
|
+
Each CAN_PASS_TO edge includes evidence with policy_sid, target_arn, permission,
|
|
1045
|
+
and raw_statement for provenance tracking.
|
|
1046
|
+
"""
|
|
1047
|
+
relationships = []
|
|
1048
|
+
roles = [a for a in assets if a.asset_type == "iam:role"]
|
|
1049
|
+
|
|
1050
|
+
for source_role in roles:
|
|
1051
|
+
policy_docs = source_role.properties.get("policy_documents", [])
|
|
1052
|
+
|
|
1053
|
+
# Collect statements that grant PassRole
|
|
1054
|
+
passrole_statements: list[tuple[dict, str | None, dict]] = [] # (statement, policy_arn, policy)
|
|
1055
|
+
for policy in policy_docs:
|
|
1056
|
+
policy_arn = policy.get("PolicyArn") or policy.get("Arn")
|
|
1057
|
+
for statement in self._iter_policy_statements(policy):
|
|
1058
|
+
if statement.get("Effect") != "Allow":
|
|
1059
|
+
continue
|
|
1060
|
+
|
|
1061
|
+
actions = statement.get("Action", [])
|
|
1062
|
+
if isinstance(actions, str):
|
|
1063
|
+
actions = [actions]
|
|
1064
|
+
|
|
1065
|
+
if any(fnmatch.fnmatchcase("iam:PassRole", a) for a in actions):
|
|
1066
|
+
passrole_statements.append((statement, policy_arn, policy))
|
|
1067
|
+
|
|
1068
|
+
if not passrole_statements:
|
|
1069
|
+
continue
|
|
1070
|
+
|
|
1071
|
+
for target_role in roles:
|
|
1072
|
+
if source_role.id == target_role.id:
|
|
1073
|
+
continue
|
|
1074
|
+
|
|
1075
|
+
target_arn = target_role.arn or target_role.aws_resource_id
|
|
1076
|
+
if not target_arn:
|
|
1077
|
+
continue
|
|
1078
|
+
|
|
1079
|
+
# Check if source can pass target and find the granting statement
|
|
1080
|
+
for statement, policy_arn, policy in passrole_statements:
|
|
1081
|
+
resources = self._normalize_resources(statement.get("Resource"))
|
|
1082
|
+
can_pass = False
|
|
1083
|
+
for res in resources:
|
|
1084
|
+
if res == "*" or fnmatch.fnmatchcase(target_arn, res):
|
|
1085
|
+
can_pass = True
|
|
1086
|
+
break
|
|
1087
|
+
|
|
1088
|
+
if can_pass:
|
|
1089
|
+
# Create evidence for provenance tracking
|
|
1090
|
+
evidence = EdgeEvidence(
|
|
1091
|
+
policy_sid=statement.get("Sid"),
|
|
1092
|
+
policy_arn=policy_arn,
|
|
1093
|
+
source_arn=source_role.arn,
|
|
1094
|
+
target_arn=target_arn,
|
|
1095
|
+
permission="iam:PassRole",
|
|
1096
|
+
raw_statement=statement,
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
relationships.append(
|
|
1100
|
+
Relationship(
|
|
1101
|
+
snapshot_id=self._snapshot_id,
|
|
1102
|
+
source_asset_id=source_role.id,
|
|
1103
|
+
target_asset_id=target_role.id,
|
|
1104
|
+
relationship_type="CAN_PASS_TO",
|
|
1105
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
1106
|
+
evidence=evidence,
|
|
1107
|
+
properties={"via": "iam_pass_role"},
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
break # Only create one edge per source-target pair
|
|
1111
|
+
return relationships
|
|
1112
|
+
|
|
1113
|
+
def _build_lambda_creation_relationships(self, assets: list[Asset]) -> list[Relationship]:
|
|
1114
|
+
"""Build IAM Role -> Lambda Service relationships for lambda creation capabilities.
|
|
1115
|
+
|
|
1116
|
+
Creates MAY_CREATE_LAMBDA edges when a role has:
|
|
1117
|
+
- lambda:CreateFunction permission
|
|
1118
|
+
- lambda:UpdateFunctionConfiguration permission
|
|
1119
|
+
|
|
1120
|
+
These edges are used to validate the PassRole motif for privilege escalation.
|
|
1121
|
+
The target is a synthetic "lambda-service" asset representing the Lambda service.
|
|
1122
|
+
|
|
1123
|
+
Each MAY_CREATE_LAMBDA edge includes evidence with policy_sid, permission,
|
|
1124
|
+
and raw_statement for provenance tracking.
|
|
1125
|
+
"""
|
|
1126
|
+
relationships = []
|
|
1127
|
+
action_parser = ActionParser()
|
|
1128
|
+
|
|
1129
|
+
# Lambda creation actions that grant MAY_CREATE_LAMBDA capability
|
|
1130
|
+
lambda_creation_actions = ["lambda:CreateFunction", "lambda:UpdateFunctionConfiguration"]
|
|
1131
|
+
|
|
1132
|
+
roles = [a for a in assets if a.asset_type == "iam:role"]
|
|
1133
|
+
|
|
1134
|
+
# Find or create a synthetic Lambda service asset for targeting
|
|
1135
|
+
# We'll use a well-known UUID for the Lambda service
|
|
1136
|
+
lambda_service_id = uuid.UUID("00000000-0000-0000-0000-00000000000a")
|
|
1137
|
+
|
|
1138
|
+
for role in roles:
|
|
1139
|
+
policy_docs = role.properties.get("policy_documents", [])
|
|
1140
|
+
|
|
1141
|
+
for policy in policy_docs:
|
|
1142
|
+
policy_arn = policy.get("PolicyArn") or policy.get("Arn")
|
|
1143
|
+
|
|
1144
|
+
for statement in self._iter_policy_statements(policy):
|
|
1145
|
+
if statement.get("Effect") != "Allow":
|
|
1146
|
+
continue
|
|
1147
|
+
|
|
1148
|
+
# Get matched capabilities from this statement
|
|
1149
|
+
matched_capabilities = action_parser.get_matched_capabilities(statement)
|
|
1150
|
+
|
|
1151
|
+
# Check if any lambda creation action is matched
|
|
1152
|
+
for capability_action in matched_capabilities:
|
|
1153
|
+
if capability_action in lambda_creation_actions:
|
|
1154
|
+
# Create evidence for provenance tracking
|
|
1155
|
+
evidence = EdgeEvidence(
|
|
1156
|
+
policy_sid=statement.get("Sid"),
|
|
1157
|
+
policy_arn=policy_arn,
|
|
1158
|
+
source_arn=role.arn,
|
|
1159
|
+
target_arn="arn:aws:lambda:::service",
|
|
1160
|
+
permission=capability_action,
|
|
1161
|
+
raw_statement=statement,
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
relationships.append(
|
|
1165
|
+
Relationship(
|
|
1166
|
+
snapshot_id=self._snapshot_id,
|
|
1167
|
+
source_asset_id=role.id,
|
|
1168
|
+
target_asset_id=lambda_service_id,
|
|
1169
|
+
relationship_type="MAY_CREATE_LAMBDA",
|
|
1170
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
1171
|
+
evidence=evidence,
|
|
1172
|
+
properties={
|
|
1173
|
+
"via": "iam_policy",
|
|
1174
|
+
"action": capability_action,
|
|
1175
|
+
},
|
|
1176
|
+
)
|
|
1177
|
+
)
|
|
1178
|
+
# Only create one edge per role (even if multiple actions match)
|
|
1179
|
+
break
|
|
1180
|
+
else:
|
|
1181
|
+
continue
|
|
1182
|
+
break # Break out of statement loop if we created an edge
|
|
1183
|
+
else:
|
|
1184
|
+
continue
|
|
1185
|
+
break # Break out of policy loop if we created an edge
|
|
1186
|
+
|
|
1187
|
+
return relationships
|
|
1188
|
+
|
|
1189
|
+
def _build_role_to_role_assume_relationships(self, assets: list[Asset]) -> list[Relationship]:
|
|
1190
|
+
"""Build IAM Role -> Role CAN_ASSUME relationships via sts:AssumeRole.
|
|
1191
|
+
|
|
1192
|
+
Creates CAN_ASSUME edges when:
|
|
1193
|
+
1. Source role has sts:AssumeRole permission on target role's ARN (identity policy)
|
|
1194
|
+
2. Target role's trust policy allows the source role to assume it
|
|
1195
|
+
|
|
1196
|
+
Each CAN_ASSUME edge includes evidence with policy_sid, target_arn, permission,
|
|
1197
|
+
and raw_statement for provenance tracking.
|
|
1198
|
+
"""
|
|
1199
|
+
relationships = []
|
|
1200
|
+
roles = [a for a in assets if a.asset_type == "iam:role"]
|
|
1201
|
+
|
|
1202
|
+
# Build lookup for roles by ARN for efficient trust policy checking
|
|
1203
|
+
roles_by_arn: dict[str, Asset] = {}
|
|
1204
|
+
for role in roles:
|
|
1205
|
+
if role.arn:
|
|
1206
|
+
roles_by_arn[role.arn] = role
|
|
1207
|
+
|
|
1208
|
+
for source_role in roles:
|
|
1209
|
+
policy_docs = source_role.properties.get("policy_documents", [])
|
|
1210
|
+
|
|
1211
|
+
# Collect statements that grant sts:AssumeRole
|
|
1212
|
+
assume_statements: list[tuple[dict, str | None, dict]] = []
|
|
1213
|
+
for policy in policy_docs:
|
|
1214
|
+
policy_arn = policy.get("PolicyArn") or policy.get("Arn")
|
|
1215
|
+
for statement in self._iter_policy_statements(policy):
|
|
1216
|
+
if statement.get("Effect") != "Allow":
|
|
1217
|
+
continue
|
|
1218
|
+
|
|
1219
|
+
actions = statement.get("Action", [])
|
|
1220
|
+
if isinstance(actions, str):
|
|
1221
|
+
actions = [actions]
|
|
1222
|
+
|
|
1223
|
+
# Check for sts:AssumeRole permission (case-insensitive, wildcard support)
|
|
1224
|
+
has_assume = any(
|
|
1225
|
+
fnmatch.fnmatch("sts:assumerole", a.lower()) or
|
|
1226
|
+
fnmatch.fnmatch("sts:*", a.lower()) or
|
|
1227
|
+
a == "*"
|
|
1228
|
+
for a in actions
|
|
1229
|
+
)
|
|
1230
|
+
if has_assume:
|
|
1231
|
+
assume_statements.append((statement, policy_arn, policy))
|
|
1232
|
+
|
|
1233
|
+
if not assume_statements:
|
|
1234
|
+
continue
|
|
1235
|
+
|
|
1236
|
+
for target_role in roles:
|
|
1237
|
+
if source_role.id == target_role.id:
|
|
1238
|
+
continue
|
|
1239
|
+
|
|
1240
|
+
target_arn = target_role.arn or target_role.aws_resource_id
|
|
1241
|
+
if not target_arn:
|
|
1242
|
+
continue
|
|
1243
|
+
|
|
1244
|
+
# Check if source can assume target via identity policy
|
|
1245
|
+
for statement, policy_arn, policy in assume_statements:
|
|
1246
|
+
resources = self._normalize_resources(statement.get("Resource"))
|
|
1247
|
+
can_assume_identity = False
|
|
1248
|
+
for res in resources:
|
|
1249
|
+
if res == "*" or fnmatch.fnmatch(target_arn, res):
|
|
1250
|
+
can_assume_identity = True
|
|
1251
|
+
break
|
|
1252
|
+
|
|
1253
|
+
if not can_assume_identity:
|
|
1254
|
+
continue
|
|
1255
|
+
|
|
1256
|
+
# Check target's trust policy allows source
|
|
1257
|
+
if not self._trust_policy_allows(target_role, source_role):
|
|
1258
|
+
continue
|
|
1259
|
+
|
|
1260
|
+
# Both identity and trust policies allow - create edge
|
|
1261
|
+
evidence = EdgeEvidence(
|
|
1262
|
+
policy_sid=statement.get("Sid"),
|
|
1263
|
+
policy_arn=policy_arn,
|
|
1264
|
+
source_arn=source_role.arn,
|
|
1265
|
+
target_arn=target_arn,
|
|
1266
|
+
permission="sts:AssumeRole",
|
|
1267
|
+
raw_statement=statement,
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
relationships.append(
|
|
1271
|
+
Relationship(
|
|
1272
|
+
snapshot_id=self._snapshot_id,
|
|
1273
|
+
source_asset_id=source_role.id,
|
|
1274
|
+
target_asset_id=target_role.id,
|
|
1275
|
+
relationship_type="CAN_ASSUME",
|
|
1276
|
+
edge_kind=EdgeKind.CAPABILITY,
|
|
1277
|
+
evidence=evidence,
|
|
1278
|
+
properties={"via": "sts_assume_role"},
|
|
1279
|
+
)
|
|
1280
|
+
)
|
|
1281
|
+
break # Only create one edge per source-target pair
|
|
1282
|
+
|
|
1283
|
+
return relationships
|
|
1284
|
+
|
|
1285
|
+
def _trust_policy_allows(self, target_role: Asset, source_role: Asset) -> bool:
|
|
1286
|
+
"""Check if target role's trust policy allows source role to assume it.
|
|
1287
|
+
|
|
1288
|
+
Args:
|
|
1289
|
+
target_role: The role being assumed
|
|
1290
|
+
source_role: The role attempting to assume
|
|
1291
|
+
|
|
1292
|
+
Returns:
|
|
1293
|
+
True if the trust policy explicitly allows the source role
|
|
1294
|
+
"""
|
|
1295
|
+
trust_policy = target_role.properties.get("trust_policy")
|
|
1296
|
+
if not trust_policy:
|
|
1297
|
+
return False
|
|
1298
|
+
|
|
1299
|
+
source_arn = source_role.arn
|
|
1300
|
+
if not source_arn:
|
|
1301
|
+
return False
|
|
1302
|
+
|
|
1303
|
+
# Extract account ID from source ARN for account-level trust checks
|
|
1304
|
+
# ARN format: arn:aws:iam::ACCOUNT_ID:role/RoleName
|
|
1305
|
+
source_account = None
|
|
1306
|
+
if source_arn.startswith("arn:aws:iam::"):
|
|
1307
|
+
parts = source_arn.split(":")
|
|
1308
|
+
if len(parts) >= 5:
|
|
1309
|
+
source_account = parts[4]
|
|
1310
|
+
|
|
1311
|
+
for statement in trust_policy.get("Statement", []):
|
|
1312
|
+
if statement.get("Effect") != "Allow":
|
|
1313
|
+
continue
|
|
1314
|
+
|
|
1315
|
+
# Check Action allows sts:AssumeRole
|
|
1316
|
+
actions = statement.get("Action", [])
|
|
1317
|
+
if isinstance(actions, str):
|
|
1318
|
+
actions = [actions]
|
|
1319
|
+
|
|
1320
|
+
allows_assume = any(
|
|
1321
|
+
a == "sts:AssumeRole" or a == "sts:*" or a == "*"
|
|
1322
|
+
for a in actions
|
|
1323
|
+
)
|
|
1324
|
+
if not allows_assume:
|
|
1325
|
+
continue
|
|
1326
|
+
|
|
1327
|
+
principal = statement.get("Principal", {})
|
|
1328
|
+
|
|
1329
|
+
# Handle wildcard principal (trusts anyone)
|
|
1330
|
+
if principal == "*":
|
|
1331
|
+
return True
|
|
1332
|
+
|
|
1333
|
+
# Handle AWS principal
|
|
1334
|
+
aws_principals = principal.get("AWS", [])
|
|
1335
|
+
if isinstance(aws_principals, str):
|
|
1336
|
+
aws_principals = [aws_principals]
|
|
1337
|
+
|
|
1338
|
+
for aws_principal in aws_principals:
|
|
1339
|
+
# Wildcard - trusts any AWS principal
|
|
1340
|
+
if aws_principal == "*":
|
|
1341
|
+
return True
|
|
1342
|
+
|
|
1343
|
+
# Exact role ARN match
|
|
1344
|
+
if aws_principal == source_arn:
|
|
1345
|
+
return True
|
|
1346
|
+
|
|
1347
|
+
# Account root match (arn:aws:iam::ACCOUNT_ID:root)
|
|
1348
|
+
if source_account and aws_principal == f"arn:aws:iam::{source_account}:root":
|
|
1349
|
+
return True
|
|
1350
|
+
|
|
1351
|
+
# Account ID match (just the account number)
|
|
1352
|
+
if source_account and aws_principal == source_account:
|
|
1353
|
+
return True
|
|
1354
|
+
|
|
1355
|
+
# Wildcard pattern match on role ARN
|
|
1356
|
+
if fnmatch.fnmatch(source_arn, aws_principal):
|
|
1357
|
+
return True
|
|
1358
|
+
|
|
1359
|
+
return False
|