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
cyntrisec/core/schema.py
ADDED
|
@@ -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
|
+
)
|