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.
Files changed (65) hide show
  1. cyntrisec/__init__.py +3 -0
  2. cyntrisec/__main__.py +6 -0
  3. cyntrisec/aws/__init__.py +6 -0
  4. cyntrisec/aws/collectors/__init__.py +17 -0
  5. cyntrisec/aws/collectors/ec2.py +30 -0
  6. cyntrisec/aws/collectors/iam.py +116 -0
  7. cyntrisec/aws/collectors/lambda_.py +45 -0
  8. cyntrisec/aws/collectors/network.py +70 -0
  9. cyntrisec/aws/collectors/rds.py +38 -0
  10. cyntrisec/aws/collectors/s3.py +68 -0
  11. cyntrisec/aws/collectors/usage.py +188 -0
  12. cyntrisec/aws/credentials.py +153 -0
  13. cyntrisec/aws/normalizers/__init__.py +17 -0
  14. cyntrisec/aws/normalizers/ec2.py +115 -0
  15. cyntrisec/aws/normalizers/iam.py +182 -0
  16. cyntrisec/aws/normalizers/lambda_.py +83 -0
  17. cyntrisec/aws/normalizers/network.py +225 -0
  18. cyntrisec/aws/normalizers/rds.py +130 -0
  19. cyntrisec/aws/normalizers/s3.py +184 -0
  20. cyntrisec/aws/relationship_builder.py +1359 -0
  21. cyntrisec/aws/scanner.py +303 -0
  22. cyntrisec/cli/__init__.py +5 -0
  23. cyntrisec/cli/analyze.py +747 -0
  24. cyntrisec/cli/ask.py +412 -0
  25. cyntrisec/cli/can.py +307 -0
  26. cyntrisec/cli/comply.py +226 -0
  27. cyntrisec/cli/cuts.py +231 -0
  28. cyntrisec/cli/diff.py +332 -0
  29. cyntrisec/cli/errors.py +105 -0
  30. cyntrisec/cli/explain.py +348 -0
  31. cyntrisec/cli/main.py +114 -0
  32. cyntrisec/cli/manifest.py +893 -0
  33. cyntrisec/cli/output.py +117 -0
  34. cyntrisec/cli/remediate.py +643 -0
  35. cyntrisec/cli/report.py +462 -0
  36. cyntrisec/cli/scan.py +207 -0
  37. cyntrisec/cli/schemas.py +391 -0
  38. cyntrisec/cli/serve.py +164 -0
  39. cyntrisec/cli/setup.py +260 -0
  40. cyntrisec/cli/validate.py +101 -0
  41. cyntrisec/cli/waste.py +323 -0
  42. cyntrisec/core/__init__.py +31 -0
  43. cyntrisec/core/business_config.py +110 -0
  44. cyntrisec/core/business_logic.py +131 -0
  45. cyntrisec/core/compliance.py +437 -0
  46. cyntrisec/core/cost_estimator.py +301 -0
  47. cyntrisec/core/cuts.py +360 -0
  48. cyntrisec/core/diff.py +361 -0
  49. cyntrisec/core/graph.py +202 -0
  50. cyntrisec/core/paths.py +830 -0
  51. cyntrisec/core/schema.py +317 -0
  52. cyntrisec/core/simulator.py +371 -0
  53. cyntrisec/core/waste.py +309 -0
  54. cyntrisec/mcp/__init__.py +5 -0
  55. cyntrisec/mcp/server.py +862 -0
  56. cyntrisec/storage/__init__.py +7 -0
  57. cyntrisec/storage/filesystem.py +344 -0
  58. cyntrisec/storage/memory.py +113 -0
  59. cyntrisec/storage/protocol.py +92 -0
  60. cyntrisec-0.1.7.dist-info/METADATA +672 -0
  61. cyntrisec-0.1.7.dist-info/RECORD +65 -0
  62. cyntrisec-0.1.7.dist-info/WHEEL +4 -0
  63. cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
  64. cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
  65. 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