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,317 @@
1
+ """
2
+ Core Schema - Pydantic models for the capability graph.
3
+
4
+ Simplified from the SaaS version:
5
+ - No tenant_id, workspace_id, connection_id (single-account CLI)
6
+ - No SQLAlchemy relationship hints
7
+ - Added monthly_cost_usd for cost analysis
8
+ - Added proof field for evidence chains
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from datetime import datetime
15
+ from decimal import Decimal
16
+ from enum import Enum
17
+ from typing import Any
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+ INTERNET_ASSET_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
22
+
23
+
24
+ class SnapshotStatus(str, Enum):
25
+ """Status of a scan snapshot."""
26
+
27
+ running = "running"
28
+ completed = "completed"
29
+ completed_with_errors = "completed_with_errors"
30
+ failed = "failed"
31
+
32
+
33
+ class FindingSeverity(str, Enum):
34
+ """Severity level for security findings."""
35
+
36
+ critical = "critical"
37
+ high = "high"
38
+ medium = "medium"
39
+ low = "low"
40
+ info = "info"
41
+
42
+
43
+ class EdgeKind(str, Enum):
44
+ """Classification of relationship edges.
45
+
46
+ - STRUCTURAL: Context only (CONTAINS, USES) - not traversed during attack path discovery
47
+ - CAPABILITY: Attacker movement (CAN_ASSUME, MAY_*) - traversed during attack path discovery
48
+ - UNKNOWN: Unclassified - not traversed by default
49
+ """
50
+
51
+ STRUCTURAL = "structural"
52
+ CAPABILITY = "capability"
53
+ UNKNOWN = "unknown"
54
+
55
+
56
+ class ConditionResult(str, Enum):
57
+ """Tri-state result for IAM condition evaluation.
58
+
59
+ - TRUE: Condition satisfied
60
+ - FALSE: Condition not satisfied
61
+ - UNKNOWN: Cannot evaluate locally
62
+ """
63
+
64
+ TRUE = "true"
65
+ FALSE = "false"
66
+ UNKNOWN = "unknown"
67
+
68
+
69
+ class ConfidenceLevel(str, Enum):
70
+ """Confidence that an attack path is exploitable.
71
+
72
+ - HIGH: All preconditions verified
73
+ - MED: Some conditions unknown or explicit deny detected
74
+ - LOW: Missing motif components or many unknowns
75
+ """
76
+
77
+ HIGH = "high"
78
+ MED = "med"
79
+ LOW = "low"
80
+
81
+
82
+ class BaseSchema(BaseModel):
83
+ """Base configuration for all models."""
84
+
85
+ model_config = ConfigDict(
86
+ extra="forbid",
87
+ use_enum_values=True,
88
+ str_strip_whitespace=True,
89
+ )
90
+
91
+
92
+ class EdgeEvidence(BaseSchema):
93
+ """Provenance data explaining why an edge exists.
94
+
95
+ Every capability edge should include evidence explaining why it exists,
96
+ so that security analysts can verify and understand attack paths.
97
+ """
98
+
99
+ policy_sid: str | None = None
100
+ policy_arn: str | None = None
101
+ rule_id: str | None = None
102
+ source_arn: str | None = None
103
+ target_arn: str | None = None
104
+ permission: str | None = None
105
+ raw_statement: dict[str, Any] | None = None
106
+
107
+
108
+ class Snapshot(BaseSchema):
109
+ """
110
+ A snapshot represents a single scan run.
111
+
112
+ Contains metadata about the scan including timing,
113
+ status, and aggregate counts.
114
+ """
115
+
116
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
117
+ aws_account_id: str = Field(..., min_length=12, max_length=12)
118
+ regions: list[str]
119
+ status: SnapshotStatus = SnapshotStatus.running
120
+ started_at: datetime = Field(default_factory=datetime.utcnow)
121
+ completed_at: datetime | None = None
122
+
123
+ # Counts
124
+ asset_count: int = 0
125
+ relationship_count: int = 0
126
+ finding_count: int = 0
127
+ path_count: int = 0
128
+
129
+ # Metadata
130
+ scan_params: dict[str, Any] = Field(default_factory=dict)
131
+ error: str | None = None
132
+ errors: list[dict[str, Any]] | None = None
133
+
134
+
135
+ class Asset(BaseSchema):
136
+ """
137
+ An asset represents a node in the capability graph.
138
+
139
+ Assets include:
140
+ - AWS resources (EC2, IAM roles, S3 buckets, Lambda, RDS, etc.)
141
+ - Logical groupings (VPCs, subnets, security groups)
142
+ """
143
+
144
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
145
+ snapshot_id: uuid.UUID
146
+
147
+ # Identity
148
+ asset_type: str = Field(..., min_length=1, max_length=50)
149
+ aws_region: str | None = None
150
+ aws_resource_id: str = Field(..., min_length=1, max_length=255)
151
+ arn: str | None = None
152
+ name: str = Field(..., min_length=1, max_length=500)
153
+
154
+ # Properties and tags
155
+ properties: dict[str, Any] = Field(default_factory=dict)
156
+ tags: dict[str, str] = Field(default_factory=dict)
157
+ labels: set[str] = Field(default_factory=set)
158
+
159
+ # Cost analysis
160
+ monthly_cost_usd: Decimal | None = None
161
+
162
+ # Flags for analysis
163
+ is_internet_facing: bool = False
164
+ is_sensitive_target: bool = False
165
+
166
+
167
+ class Relationship(BaseSchema):
168
+ """
169
+ A relationship represents an edge in the capability graph.
170
+
171
+ Relationship types:
172
+ - TRUSTS: IAM trust relationships
173
+ - ALLOWS: Security group rules, NACLs, IAM policies
174
+ - ROUTES_TO: Route table entries, LB targets
175
+ - ATTACHED_TO: ENIs, EBS volumes, instance profiles
176
+ - CONTAINS: VPC → Subnet, Subnet → Instance
177
+
178
+ Edge kinds:
179
+ - STRUCTURAL: Context only (CONTAINS, USES) - not traversed during attack path discovery
180
+ - CAPABILITY: Attacker movement (CAN_ASSUME, MAY_*) - traversed during attack path discovery
181
+ - UNKNOWN: Unclassified - not traversed by default
182
+ """
183
+
184
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
185
+ snapshot_id: uuid.UUID
186
+
187
+ source_asset_id: uuid.UUID
188
+ target_asset_id: uuid.UUID
189
+ relationship_type: str = Field(..., min_length=1, max_length=50)
190
+
191
+ # Edge classification
192
+ edge_kind: EdgeKind = EdgeKind.UNKNOWN
193
+
194
+ # Edge properties (ports, protocols, conditions)
195
+ properties: dict[str, Any] = Field(default_factory=dict)
196
+ labels: set[str] = Field(default_factory=set)
197
+
198
+ # Evidence for capability edges
199
+ evidence: EdgeEvidence | None = None
200
+
201
+ # Condition evaluation result
202
+ conditions_evaluated: bool = True
203
+ condition_result: ConditionResult = ConditionResult.TRUE
204
+
205
+ # For attack path analysis
206
+ traversal_cost: float = 1.0 # Lower = easier to traverse
207
+
208
+ # Edge weight for scoring
209
+ edge_weight: float = 1.0
210
+
211
+
212
+ class Finding(BaseSchema):
213
+ """
214
+ A security finding discovered during scanning.
215
+
216
+ Findings include:
217
+ - Misconfigurations (public S3, overly permissive IAM)
218
+ - Security risks (missing encryption, weak TLS)
219
+ - Attack surface exposure (internet-facing without WAF)
220
+ """
221
+
222
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
223
+ snapshot_id: uuid.UUID
224
+ asset_id: uuid.UUID
225
+
226
+ finding_type: str = Field(..., min_length=1, max_length=100)
227
+ severity: FindingSeverity
228
+ title: str = Field(..., min_length=1, max_length=500)
229
+ description: str | None = None
230
+ remediation: str | None = None
231
+
232
+ # Evidence for proof-carrying output
233
+ evidence: dict[str, Any] = Field(default_factory=dict)
234
+
235
+
236
+ class AttackPath(BaseSchema):
237
+ """
238
+ An attack path from an entry point to a sensitive target.
239
+
240
+ Attack paths represent traversable routes through the graph
241
+ that could be exploited by an attacker.
242
+
243
+ Risk scoring:
244
+ - entry_confidence: How likely an attacker can reach entry (0-1)
245
+ - exploitability: Difficulty of traversing the path (higher = easier)
246
+ - impact: Value of the target (higher = more valuable)
247
+ - risk_score: Combined score (entry * exploit * impact)
248
+
249
+ Path structure:
250
+ - attack_chain_relationship_ids: Capability edges only (attack steps)
251
+ - context_relationship_ids: Structural edges for explanation
252
+ - path_relationship_ids: Legacy alias for backward compatibility
253
+ """
254
+
255
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
256
+ snapshot_id: uuid.UUID
257
+
258
+ # Path endpoints
259
+ source_asset_id: uuid.UUID # Entry point (internet-facing)
260
+ target_asset_id: uuid.UUID # Sensitive target
261
+
262
+ # Full path
263
+ path_asset_ids: list[uuid.UUID] = Field(..., min_length=2)
264
+ path_relationship_ids: list[uuid.UUID] = Field(..., min_length=1)
265
+
266
+ # Capability edges only (attack steps)
267
+ attack_chain_relationship_ids: list[uuid.UUID] = Field(default_factory=list)
268
+
269
+ # Structural edges for context (explanation)
270
+ context_relationship_ids: list[uuid.UUID] = Field(default_factory=list)
271
+
272
+ # Classification
273
+ attack_vector: str = Field(..., min_length=1, max_length=100)
274
+ path_length: int = Field(..., ge=1)
275
+
276
+ # Risk scoring
277
+ entry_confidence: Decimal = Field(..., ge=Decimal("0"), le=Decimal("1"))
278
+ exploitability_score: Decimal = Field(..., ge=Decimal("0"))
279
+ impact_score: Decimal = Field(..., ge=Decimal("0"))
280
+ risk_score: Decimal = Field(..., ge=Decimal("0"))
281
+
282
+ # Confidence level and reason
283
+ confidence_level: ConfidenceLevel = ConfidenceLevel.HIGH
284
+ confidence_reason: str = ""
285
+
286
+ # Proof chain - evidence for why this path exists
287
+ proof: dict[str, Any] = Field(default_factory=dict)
288
+
289
+
290
+ class CostCutCandidate(BaseSchema):
291
+ """
292
+ A resource that can potentially be removed or isolated.
293
+
294
+ These are assets that:
295
+ - Appear in attack paths but not in legitimate business paths
296
+ - Have no observed usage (optional, if traffic data available)
297
+ - Removal would reduce attack surface without breaking functionality
298
+ """
299
+
300
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
301
+ snapshot_id: uuid.UUID
302
+ asset_id: uuid.UUID
303
+
304
+ # Why this is a candidate
305
+ reason: str
306
+ action: str # "remove", "isolate", "restrict"
307
+ confidence: Decimal = Field(..., ge=Decimal("0"), le=Decimal("1"))
308
+
309
+ # Cost impact
310
+ monthly_savings_usd: Decimal = Field(default=Decimal("0"))
311
+
312
+ # Security impact
313
+ paths_blocked: int = 0 # How many attack paths this eliminates
314
+ risk_reduction: Decimal = Field(default=Decimal("0"))
315
+
316
+ # Evidence
317
+ proof: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,371 @@
1
+ """
2
+ IAM Policy Simulator - Test whether a principal can perform an action.
3
+
4
+ Uses AWS IAM Policy Simulator API to evaluate permissions and determine
5
+ whether a given action would be allowed or denied.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ class SimulationDecision(str, Enum):
19
+ """Result of a policy simulation."""
20
+
21
+ allowed = "allowed"
22
+ implicit_deny = "implicitDeny"
23
+ explicit_deny = "explicitDeny"
24
+
25
+
26
+ @dataclass
27
+ class SimulationResult:
28
+ """
29
+ Result of simulating an IAM action.
30
+
31
+ Attributes:
32
+ action: The action tested (e.g., 's3:GetObject')
33
+ resource: The resource tested (e.g., 'arn:aws:s3:::bucket/*')
34
+ decision: Whether allowed, implicitly denied, or explicitly denied
35
+ decision_details: Additional info about which policy affected decision
36
+ matched_statements: Policy statements that matched
37
+ """
38
+
39
+ action: str | None
40
+ resource: str
41
+ decision: SimulationDecision
42
+ decision_details: dict[str, Any] = field(default_factory=dict)
43
+ matched_statements: list[dict[str, Any]] = field(default_factory=list)
44
+
45
+ @property
46
+ def is_allowed(self) -> bool:
47
+ return self.decision == SimulationDecision.allowed
48
+
49
+ @property
50
+ def is_denied(self) -> bool:
51
+ return self.decision in (SimulationDecision.implicit_deny, SimulationDecision.explicit_deny)
52
+
53
+
54
+ @dataclass
55
+ class CanAccessResult:
56
+ """
57
+ Result of a "can X access Y?" query.
58
+
59
+ Attributes:
60
+ principal_arn: The IAM principal tested
61
+ target_resource: The resource being accessed
62
+ action: The specific action tested
63
+ can_access: Whether access is allowed
64
+ simulations: All simulation results
65
+ proof: Evidence chain for the result
66
+ """
67
+
68
+ principal_arn: str
69
+ target_resource: str
70
+ action: str | None
71
+ can_access: bool
72
+ simulations: list[SimulationResult] = field(default_factory=list)
73
+ proof: dict[str, Any] = field(default_factory=dict)
74
+
75
+
76
+ class PolicySimulator:
77
+ """
78
+ Simulate IAM policy evaluation using AWS Policy Simulator API.
79
+
80
+ This provides ground truth for "can X access Y?" questions by
81
+ using the same policy evaluation logic as AWS.
82
+ """
83
+
84
+ def __init__(self, session):
85
+ """
86
+ Initialize with a boto3 Session.
87
+
88
+ Args:
89
+ session: boto3.Session with IAM permissions
90
+ """
91
+ self._session = session
92
+ self._iam = session.client("iam")
93
+
94
+ def simulate_principal_policy(
95
+ self,
96
+ principal_arn: str,
97
+ actions: list[str],
98
+ resources: list[str],
99
+ *,
100
+ context_entries: list[dict[str, Any]] | None = None,
101
+ ) -> list[SimulationResult]:
102
+ """
103
+ Simulate whether a principal can perform actions on resources.
104
+
105
+ Args:
106
+ principal_arn: ARN of user/role to test
107
+ actions: List of actions to test (e.g., ['s3:GetObject'])
108
+ resources: List of resource ARNs to test against
109
+ context_entries: Optional context values for conditions
110
+
111
+ Returns:
112
+ List of SimulationResult for each action/resource combination
113
+ """
114
+ results = []
115
+
116
+ try:
117
+ params = {
118
+ "PolicySourceArn": principal_arn,
119
+ "ActionNames": actions,
120
+ "ResourceArns": resources,
121
+ }
122
+
123
+ if context_entries:
124
+ params["ContextEntries"] = context_entries
125
+
126
+ paginator = self._iam.get_paginator("simulate_principal_policy")
127
+
128
+ for page in paginator.paginate(**params):
129
+ for eval_result in page.get("EvaluationResults", []):
130
+ decision_str = eval_result.get("EvalDecision", "implicitDeny")
131
+
132
+ # Map AWS decision to our enum
133
+ if decision_str == "allowed":
134
+ decision = SimulationDecision.allowed
135
+ elif decision_str == "explicitDeny":
136
+ decision = SimulationDecision.explicit_deny
137
+ else:
138
+ decision = SimulationDecision.implicit_deny
139
+
140
+ result = SimulationResult(
141
+ action=eval_result.get("EvalActionName", ""),
142
+ resource=eval_result.get("EvalResourceName", "*"),
143
+ decision=decision,
144
+ decision_details=eval_result.get("EvalDecisionDetails", {}),
145
+ matched_statements=eval_result.get("MatchedStatements", []),
146
+ )
147
+ results.append(result)
148
+
149
+ except Exception as e:
150
+ log.warning("Policy simulation failed for %s: %s", principal_arn, e)
151
+ # Return implicit deny for all requested simulations
152
+ for action in actions:
153
+ for resource in resources:
154
+ results.append(
155
+ SimulationResult(
156
+ action=action,
157
+ resource=resource,
158
+ decision=SimulationDecision.implicit_deny,
159
+ decision_details={"error": str(e)},
160
+ )
161
+ )
162
+
163
+ return results
164
+
165
+ def can_access(
166
+ self,
167
+ principal_arn: str,
168
+ target_resource: str,
169
+ *,
170
+ action: str | None = None,
171
+ ) -> CanAccessResult:
172
+ """
173
+ Check if a principal can access a resource.
174
+
175
+ This is the high-level "can X access Y?" query that users run.
176
+
177
+ Args:
178
+ principal_arn: ARN of role/user
179
+ target_resource: Resource ARN or bucket name/etc.
180
+ action: Specific action to test (auto-detected if not provided)
181
+
182
+ Returns:
183
+ CanAccessResult with full proof chain
184
+ """
185
+ # Normalize resource to ARN if needed
186
+ resource_arn = self._normalize_resource(target_resource)
187
+
188
+ # Determine actions to test based on resource type
189
+ if action:
190
+ actions_to_test = [action]
191
+ else:
192
+ actions_to_test = self._infer_actions(resource_arn)
193
+
194
+ # Run simulation
195
+ resources_to_test = self._resources_for_actions(resource_arn, actions_to_test)
196
+ simulations = self.simulate_principal_policy(
197
+ principal_arn=principal_arn,
198
+ actions=actions_to_test,
199
+ resources=resources_to_test,
200
+ )
201
+
202
+ # Determine overall result - allowed if ANY action is allowed
203
+ can_access = any(s.is_allowed for s in simulations)
204
+
205
+ # Build proof
206
+ proof = {
207
+ "principal": principal_arn,
208
+ "resource": resource_arn,
209
+ "resources_tested": resources_to_test,
210
+ "actions_tested": actions_to_test,
211
+ "simulations": [
212
+ {
213
+ "action": s.action,
214
+ "decision": s.decision.value,
215
+ "matched_statements": len(s.matched_statements),
216
+ }
217
+ for s in simulations
218
+ ],
219
+ }
220
+
221
+ return CanAccessResult(
222
+ principal_arn=principal_arn,
223
+ target_resource=target_resource,
224
+ action=action or actions_to_test[0],
225
+ can_access=can_access,
226
+ simulations=simulations,
227
+ proof=proof,
228
+ )
229
+
230
+ def _normalize_resource(self, resource: str) -> str:
231
+ """Convert resource identifier to ARN."""
232
+ if resource.startswith("arn:"):
233
+ return resource
234
+
235
+ # S3 bucket
236
+ if resource.startswith("s3://"):
237
+ bucket = resource[5:].split("/")[0]
238
+ path = "/".join(resource[5:].split("/")[1:]) if "/" in resource[5:] else "*"
239
+ return f"arn:aws:s3:::{bucket}/{path}"
240
+
241
+ # Assume it's an S3 bucket name
242
+ if "." in resource or resource.islower():
243
+ return f"arn:aws:s3:::{resource}/*"
244
+
245
+ return resource
246
+
247
+ def _infer_actions(self, resource_arn: str) -> list[str]:
248
+ """Infer actions to test based on resource type."""
249
+ if ":s3:::" in resource_arn:
250
+ return ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
251
+
252
+ if ":iam::" in resource_arn and ":role/" in resource_arn:
253
+ return ["sts:AssumeRole"]
254
+
255
+ if ":secretsmanager:" in resource_arn:
256
+ return ["secretsmanager:GetSecretValue"]
257
+
258
+ if ":ssm:" in resource_arn:
259
+ return ["ssm:GetParameter"]
260
+
261
+ if ":rds:" in resource_arn:
262
+ return ["rds:DescribeDBInstances"]
263
+
264
+ if ":dynamodb:" in resource_arn:
265
+ return ["dynamodb:GetItem", "dynamodb:Scan"]
266
+
267
+ if ":lambda:" in resource_arn:
268
+ return ["lambda:InvokeFunction"]
269
+
270
+ if ":ec2:" in resource_arn:
271
+ return ["ec2:DescribeInstances"]
272
+
273
+ # Default: test read access
274
+ return ["*:Get*", "*:Describe*", "*:List*"]
275
+
276
+ def _resources_for_actions(self, resource_arn: str, actions: list[str]) -> list[str]:
277
+ """Build resource ARNs appropriate for the given actions."""
278
+ if ":s3:::" not in resource_arn:
279
+ return [resource_arn]
280
+
281
+ bucket_arn, object_arn = self._s3_variants(resource_arn)
282
+ resources: list[str] = []
283
+ if any(a.lower() == "s3:listbucket" for a in actions):
284
+ resources.append(bucket_arn)
285
+ if any(a.lower().startswith("s3:") and a.lower() != "s3:listbucket" for a in actions):
286
+ resources.append(object_arn)
287
+ if not resources:
288
+ resources = [object_arn]
289
+ return resources
290
+
291
+ def _s3_variants(self, resource_arn: str) -> tuple[str, str]:
292
+ """Return bucket ARN and object ARN variants for S3 resources."""
293
+ prefix = "arn:aws:s3:::"
294
+ if not resource_arn.startswith(prefix):
295
+ return resource_arn, resource_arn
296
+
297
+ suffix = resource_arn[len(prefix):]
298
+ if "/" in suffix:
299
+ bucket = suffix.split("/", 1)[0]
300
+ bucket_arn = f"{prefix}{bucket}"
301
+ object_arn = resource_arn
302
+ else:
303
+ bucket_arn = resource_arn
304
+ object_arn = f"{resource_arn}/*"
305
+ return bucket_arn, object_arn
306
+
307
+
308
+ class OfflineSimulator:
309
+ """
310
+ Offline policy evaluation without AWS API calls.
311
+
312
+ Uses scan data to make educated guesses about access.
313
+ Less accurate than PolicySimulator but works offline.
314
+ """
315
+
316
+ def __init__(self, assets: list[Any], relationships: list[Any]):
317
+ """
318
+ Initialize with scan data.
319
+
320
+ Args:
321
+ assets: Assets from scan
322
+ relationships: Relationships from scan
323
+ """
324
+ self._assets = {a.arn: a for a in assets if a.arn}
325
+ self._assets_by_name = {a.name: a for a in assets}
326
+ self._relationships = relationships
327
+
328
+ def can_access(
329
+ self,
330
+ principal_arn: str,
331
+ target_resource: str,
332
+ *,
333
+ action: str | None = None,
334
+ ) -> CanAccessResult:
335
+ """
336
+ Check if principal can access resource using scan data.
337
+
338
+ This uses the MAY_ACCESS relationships from the graph.
339
+ """
340
+ # Find assets
341
+ principal = self._assets.get(principal_arn) or self._assets_by_name.get(
342
+ principal_arn.split("/")[-1]
343
+ )
344
+ target = self._assets.get(target_resource) or self._assets_by_name.get(target_resource)
345
+
346
+ can_access = False
347
+ proof = {}
348
+
349
+ if principal and target:
350
+ # Check for direct relationship
351
+ for rel in self._relationships:
352
+ if (
353
+ rel.source_asset_id == principal.id
354
+ and rel.target_asset_id == target.id
355
+ and rel.relationship_type in ("MAY_ACCESS", "CAN_ASSUME", "ALLOWS")
356
+ ):
357
+ can_access = True
358
+ proof = {
359
+ "relationship_type": rel.relationship_type,
360
+ "properties": rel.properties,
361
+ }
362
+ break
363
+
364
+ return CanAccessResult(
365
+ principal_arn=principal_arn,
366
+ target_resource=target_resource,
367
+ action=action,
368
+ can_access=can_access,
369
+ simulations=[],
370
+ proof=proof,
371
+ )