cyntrisec 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cyntrisec/__init__.py +3 -0
- cyntrisec/__main__.py +6 -0
- cyntrisec/aws/__init__.py +6 -0
- cyntrisec/aws/collectors/__init__.py +17 -0
- cyntrisec/aws/collectors/ec2.py +30 -0
- cyntrisec/aws/collectors/iam.py +116 -0
- cyntrisec/aws/collectors/lambda_.py +45 -0
- cyntrisec/aws/collectors/network.py +70 -0
- cyntrisec/aws/collectors/rds.py +38 -0
- cyntrisec/aws/collectors/s3.py +68 -0
- cyntrisec/aws/collectors/usage.py +188 -0
- cyntrisec/aws/credentials.py +153 -0
- cyntrisec/aws/normalizers/__init__.py +17 -0
- cyntrisec/aws/normalizers/ec2.py +115 -0
- cyntrisec/aws/normalizers/iam.py +182 -0
- cyntrisec/aws/normalizers/lambda_.py +83 -0
- cyntrisec/aws/normalizers/network.py +225 -0
- cyntrisec/aws/normalizers/rds.py +130 -0
- cyntrisec/aws/normalizers/s3.py +184 -0
- cyntrisec/aws/relationship_builder.py +1359 -0
- cyntrisec/aws/scanner.py +303 -0
- cyntrisec/cli/__init__.py +5 -0
- cyntrisec/cli/analyze.py +747 -0
- cyntrisec/cli/ask.py +412 -0
- cyntrisec/cli/can.py +307 -0
- cyntrisec/cli/comply.py +226 -0
- cyntrisec/cli/cuts.py +231 -0
- cyntrisec/cli/diff.py +332 -0
- cyntrisec/cli/errors.py +105 -0
- cyntrisec/cli/explain.py +348 -0
- cyntrisec/cli/main.py +114 -0
- cyntrisec/cli/manifest.py +893 -0
- cyntrisec/cli/output.py +117 -0
- cyntrisec/cli/remediate.py +643 -0
- cyntrisec/cli/report.py +462 -0
- cyntrisec/cli/scan.py +207 -0
- cyntrisec/cli/schemas.py +391 -0
- cyntrisec/cli/serve.py +164 -0
- cyntrisec/cli/setup.py +260 -0
- cyntrisec/cli/validate.py +101 -0
- cyntrisec/cli/waste.py +323 -0
- cyntrisec/core/__init__.py +31 -0
- cyntrisec/core/business_config.py +110 -0
- cyntrisec/core/business_logic.py +131 -0
- cyntrisec/core/compliance.py +437 -0
- cyntrisec/core/cost_estimator.py +301 -0
- cyntrisec/core/cuts.py +360 -0
- cyntrisec/core/diff.py +361 -0
- cyntrisec/core/graph.py +202 -0
- cyntrisec/core/paths.py +830 -0
- cyntrisec/core/schema.py +317 -0
- cyntrisec/core/simulator.py +371 -0
- cyntrisec/core/waste.py +309 -0
- cyntrisec/mcp/__init__.py +5 -0
- cyntrisec/mcp/server.py +862 -0
- cyntrisec/storage/__init__.py +7 -0
- cyntrisec/storage/filesystem.py +344 -0
- cyntrisec/storage/memory.py +113 -0
- cyntrisec/storage/protocol.py +92 -0
- cyntrisec-0.1.7.dist-info/METADATA +672 -0
- cyntrisec-0.1.7.dist-info/RECORD +65 -0
- cyntrisec-0.1.7.dist-info/WHEEL +4 -0
- cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
- cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
- cyntrisec-0.1.7.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost Estimator - Static pricing for common AWS resources.
|
|
3
|
+
|
|
4
|
+
Provides monthly cost estimates without requiring additional AWS permissions.
|
|
5
|
+
Uses conservative static pricing for "hero" resources where waste is obvious.
|
|
6
|
+
|
|
7
|
+
Sources:
|
|
8
|
+
- estimate: Static rules based on public AWS pricing (default)
|
|
9
|
+
- pricing-api: AWS Pricing API (future)
|
|
10
|
+
- cost-explorer: Real billing data (future, opt-in)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from cyntrisec.core.schema import Asset
|
|
20
|
+
|
|
21
|
+
# Average hours per month
|
|
22
|
+
HOURS_PER_MONTH = Decimal("730")
|
|
23
|
+
|
|
24
|
+
# Static pricing (US-East-1, approximate)
|
|
25
|
+
# These are conservative estimates for ranking purposes
|
|
26
|
+
STATIC_PRICING = {
|
|
27
|
+
# NAT Gateway: $0.045/hr + $0.045/GB processed
|
|
28
|
+
"ec2:nat-gateway": {
|
|
29
|
+
"hourly": Decimal("0.045"),
|
|
30
|
+
"monthly_base": Decimal("32.85"), # 730 * 0.045
|
|
31
|
+
"confidence": "high",
|
|
32
|
+
"assumptions": [
|
|
33
|
+
"$0.045/hr NAT Gateway base rate (us-east-1)",
|
|
34
|
+
"730 hours/month",
|
|
35
|
+
"Data processing fees not included",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
# Elastic IP (unattached): $0.005/hr
|
|
39
|
+
"ec2:elastic-ip": {
|
|
40
|
+
"hourly": Decimal("0.005"),
|
|
41
|
+
"monthly_base": Decimal("3.65"), # 730 * 0.005
|
|
42
|
+
"confidence": "high",
|
|
43
|
+
"assumptions": [
|
|
44
|
+
"$0.005/hr for unused Elastic IP",
|
|
45
|
+
"Only applies when not attached to running instance",
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
# ALB: $0.0225/hr + LCU charges
|
|
49
|
+
"elbv2:load-balancer:application": {
|
|
50
|
+
"hourly": Decimal("0.0225"),
|
|
51
|
+
"monthly_base": Decimal("16.43"), # 730 * 0.0225
|
|
52
|
+
"confidence": "medium",
|
|
53
|
+
"assumptions": [
|
|
54
|
+
"$0.0225/hr ALB base rate",
|
|
55
|
+
"LCU charges not included (traffic-dependent)",
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
# NLB: $0.0225/hr + NLCU charges
|
|
59
|
+
"elbv2:load-balancer:network": {
|
|
60
|
+
"hourly": Decimal("0.0225"),
|
|
61
|
+
"monthly_base": Decimal("16.43"),
|
|
62
|
+
"confidence": "medium",
|
|
63
|
+
"assumptions": [
|
|
64
|
+
"$0.0225/hr NLB base rate",
|
|
65
|
+
"NLCU charges not included",
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
# EBS gp2/gp3: ~$0.10/GB-month
|
|
69
|
+
"ec2:ebs-volume:gp2": {
|
|
70
|
+
"per_gb_month": Decimal("0.10"),
|
|
71
|
+
"confidence": "high",
|
|
72
|
+
"assumptions": ["$0.10/GB-month for gp2"],
|
|
73
|
+
},
|
|
74
|
+
"ec2:ebs-volume:gp3": {
|
|
75
|
+
"per_gb_month": Decimal("0.08"),
|
|
76
|
+
"confidence": "high",
|
|
77
|
+
"assumptions": ["$0.08/GB-month for gp3 base"],
|
|
78
|
+
},
|
|
79
|
+
# RDS instance classes (approximate)
|
|
80
|
+
"rds:db-instance:db.t3.micro": {
|
|
81
|
+
"monthly_base": Decimal("12.41"),
|
|
82
|
+
"confidence": "medium",
|
|
83
|
+
"assumptions": ["On-demand pricing, single-AZ"],
|
|
84
|
+
},
|
|
85
|
+
"rds:db-instance:db.t3.small": {
|
|
86
|
+
"monthly_base": Decimal("24.82"),
|
|
87
|
+
"confidence": "medium",
|
|
88
|
+
"assumptions": ["On-demand pricing, single-AZ"],
|
|
89
|
+
},
|
|
90
|
+
"rds:db-instance:db.t3.medium": {
|
|
91
|
+
"monthly_base": Decimal("49.64"),
|
|
92
|
+
"confidence": "medium",
|
|
93
|
+
"assumptions": ["On-demand pricing, single-AZ"],
|
|
94
|
+
},
|
|
95
|
+
"rds:db-instance:db.m5.large": {
|
|
96
|
+
"monthly_base": Decimal("124.10"),
|
|
97
|
+
"confidence": "medium",
|
|
98
|
+
"assumptions": ["On-demand pricing, single-AZ"],
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Optional regional overrides for static pricing
|
|
103
|
+
REGIONAL_PRICING: dict[str, dict[str, dict[str, Any]]] = {}
|
|
104
|
+
|
|
105
|
+
# Priority ranking for known high-cost resources
|
|
106
|
+
COST_PRIORITY = [
|
|
107
|
+
"ec2:nat-gateway",
|
|
108
|
+
"rds:db-instance",
|
|
109
|
+
"elbv2:load-balancer",
|
|
110
|
+
"ec2:ebs-volume",
|
|
111
|
+
"ec2:elastic-ip",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class CostEstimate:
|
|
117
|
+
"""Cost estimate with provenance metadata."""
|
|
118
|
+
|
|
119
|
+
monthly_cost_usd_estimate: Decimal
|
|
120
|
+
cost_source: str # "estimate", "pricing-api", "cost-explorer"
|
|
121
|
+
confidence: str # "high", "medium", "low"
|
|
122
|
+
assumptions: list[str] = field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict[str, Any]:
|
|
125
|
+
return {
|
|
126
|
+
"monthly_cost_usd_estimate": float(self.monthly_cost_usd_estimate),
|
|
127
|
+
"cost_source": self.cost_source,
|
|
128
|
+
"confidence": self.confidence,
|
|
129
|
+
"assumptions": self.assumptions,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CostEstimator:
|
|
134
|
+
"""
|
|
135
|
+
Estimate monthly costs for AWS resources.
|
|
136
|
+
|
|
137
|
+
Default mode uses static pricing rules that require no extra permissions.
|
|
138
|
+
Future modes can use AWS Pricing API or Cost Explorer for more accuracy.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, source: str = "estimate", *, region: str = "us-east-1"):
|
|
142
|
+
"""
|
|
143
|
+
Initialize estimator.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
source: Cost data source - "estimate", "pricing-api", "cost-explorer"
|
|
147
|
+
"""
|
|
148
|
+
self._source = source
|
|
149
|
+
self._region = region
|
|
150
|
+
if source not in ("estimate", "pricing-api", "cost-explorer"):
|
|
151
|
+
raise ValueError(f"Unknown cost source: {source}")
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def source(self) -> str:
|
|
155
|
+
"""Return configured cost source."""
|
|
156
|
+
return self._source
|
|
157
|
+
|
|
158
|
+
def estimate(self, asset: Asset) -> CostEstimate | None:
|
|
159
|
+
"""
|
|
160
|
+
Estimate monthly cost for an asset.
|
|
161
|
+
|
|
162
|
+
Returns None if no estimate is available for this asset type.
|
|
163
|
+
"""
|
|
164
|
+
region = asset.aws_region or self._region
|
|
165
|
+
if self._source == "estimate":
|
|
166
|
+
return self._static_estimate(asset, region)
|
|
167
|
+
elif self._source == "pricing-api":
|
|
168
|
+
# Future: call AWS Pricing API
|
|
169
|
+
return self._static_estimate(asset, region) # Fallback for now
|
|
170
|
+
elif self._source == "cost-explorer":
|
|
171
|
+
# Future: call Cost Explorer
|
|
172
|
+
return None # Requires opt-in
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _static_estimate(self, asset: Asset, region: str) -> CostEstimate | None:
|
|
176
|
+
"""Generate estimate from static pricing rules."""
|
|
177
|
+
asset_type = asset.asset_type
|
|
178
|
+
props = asset.properties or {}
|
|
179
|
+
pricing_table = REGIONAL_PRICING.get(region, STATIC_PRICING)
|
|
180
|
+
region_fallback = region != "us-east-1" and region not in REGIONAL_PRICING
|
|
181
|
+
|
|
182
|
+
# NAT Gateway
|
|
183
|
+
if asset_type == "ec2:nat-gateway":
|
|
184
|
+
pricing = pricing_table["ec2:nat-gateway"]
|
|
185
|
+
return CostEstimate(
|
|
186
|
+
monthly_cost_usd_estimate=pricing["monthly_base"],
|
|
187
|
+
cost_source="estimate",
|
|
188
|
+
confidence=pricing["confidence"],
|
|
189
|
+
assumptions=self._assumptions_with_region(pricing["assumptions"], region_fallback),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Elastic IP (only if unattached)
|
|
193
|
+
if asset_type == "ec2:elastic-ip":
|
|
194
|
+
# Check if attached to an instance
|
|
195
|
+
instance_id = props.get("instance_id") or props.get("InstanceId")
|
|
196
|
+
if not instance_id:
|
|
197
|
+
pricing = pricing_table["ec2:elastic-ip"]
|
|
198
|
+
return CostEstimate(
|
|
199
|
+
monthly_cost_usd_estimate=pricing["monthly_base"],
|
|
200
|
+
cost_source="estimate",
|
|
201
|
+
confidence=pricing["confidence"],
|
|
202
|
+
assumptions=self._assumptions_with_region(pricing["assumptions"], region_fallback),
|
|
203
|
+
)
|
|
204
|
+
return None # Attached EIPs are free
|
|
205
|
+
|
|
206
|
+
# Load Balancers
|
|
207
|
+
if asset_type == "elbv2:load-balancer":
|
|
208
|
+
lb_type = props.get("type", "application").lower()
|
|
209
|
+
key = f"elbv2:load-balancer:{lb_type}"
|
|
210
|
+
if key in pricing_table:
|
|
211
|
+
pricing = pricing_table[key]
|
|
212
|
+
return CostEstimate(
|
|
213
|
+
monthly_cost_usd_estimate=pricing["monthly_base"],
|
|
214
|
+
cost_source="estimate",
|
|
215
|
+
confidence=pricing["confidence"],
|
|
216
|
+
assumptions=self._assumptions_with_region(pricing["assumptions"], region_fallback),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# EBS Volumes
|
|
220
|
+
if asset_type == "ec2:ebs-volume":
|
|
221
|
+
volume_type = props.get("volume_type", props.get("VolumeType", "gp2")).lower()
|
|
222
|
+
size_gb = props.get("size", props.get("Size", 0))
|
|
223
|
+
key = f"ec2:ebs-volume:{volume_type}"
|
|
224
|
+
|
|
225
|
+
if key in pricing_table and size_gb:
|
|
226
|
+
pricing = pricing_table[key]
|
|
227
|
+
monthly = pricing["per_gb_month"] * Decimal(str(size_gb))
|
|
228
|
+
return CostEstimate(
|
|
229
|
+
monthly_cost_usd_estimate=monthly,
|
|
230
|
+
cost_source="estimate",
|
|
231
|
+
confidence=pricing["confidence"],
|
|
232
|
+
assumptions=self._assumptions_with_region(
|
|
233
|
+
pricing["assumptions"] + [f"{size_gb} GB volume"], region_fallback
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# RDS Instances
|
|
238
|
+
if asset_type == "rds:db-instance":
|
|
239
|
+
db_class = props.get("db_instance_class", props.get("DBInstanceClass", ""))
|
|
240
|
+
key = f"rds:db-instance:{db_class}"
|
|
241
|
+
|
|
242
|
+
if key in pricing_table:
|
|
243
|
+
pricing = pricing_table[key]
|
|
244
|
+
return CostEstimate(
|
|
245
|
+
monthly_cost_usd_estimate=pricing["monthly_base"],
|
|
246
|
+
cost_source="estimate",
|
|
247
|
+
confidence=pricing["confidence"],
|
|
248
|
+
assumptions=self._assumptions_with_region(pricing["assumptions"], region_fallback),
|
|
249
|
+
)
|
|
250
|
+
# Unknown class - return None with low confidence indicator
|
|
251
|
+
fallback_estimate = self._unknown_rds_estimate(pricing_table)
|
|
252
|
+
return CostEstimate(
|
|
253
|
+
monthly_cost_usd_estimate=fallback_estimate,
|
|
254
|
+
cost_source="estimate",
|
|
255
|
+
confidence="unknown",
|
|
256
|
+
assumptions=self._assumptions_with_region(
|
|
257
|
+
["Unknown RDS class - estimate uses median of known classes"],
|
|
258
|
+
region_fallback,
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def _unknown_rds_estimate(pricing_table: dict[str, dict[str, Any]]) -> Decimal:
|
|
266
|
+
"""Return a fallback estimate for unknown RDS classes."""
|
|
267
|
+
values = [
|
|
268
|
+
p["monthly_base"]
|
|
269
|
+
for key, p in pricing_table.items()
|
|
270
|
+
if key.startswith("rds:db-instance:") and "monthly_base" in p
|
|
271
|
+
]
|
|
272
|
+
if not values:
|
|
273
|
+
return Decimal("1")
|
|
274
|
+
values_sorted = sorted(values)
|
|
275
|
+
return values_sorted[len(values_sorted) // 2]
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _assumptions_with_region(assumptions: list[str], region_fallback: bool) -> list[str]:
|
|
279
|
+
"""Add region fallback note when needed."""
|
|
280
|
+
if region_fallback:
|
|
281
|
+
return assumptions + ["Pricing fallback: us-east-1 (region not supported)"]
|
|
282
|
+
return assumptions
|
|
283
|
+
|
|
284
|
+
def get_priority(self, asset: Asset) -> int:
|
|
285
|
+
"""
|
|
286
|
+
Get cost priority ranking for an asset.
|
|
287
|
+
|
|
288
|
+
Lower number = higher priority (more likely to be expensive waste).
|
|
289
|
+
Returns 999 for unknown types.
|
|
290
|
+
"""
|
|
291
|
+
asset_type = asset.asset_type
|
|
292
|
+
|
|
293
|
+
for i, prefix in enumerate(COST_PRIORITY):
|
|
294
|
+
if asset_type.startswith(prefix):
|
|
295
|
+
return i
|
|
296
|
+
|
|
297
|
+
return 999 # Unknown type
|
|
298
|
+
|
|
299
|
+
def sort_by_cost_priority(self, assets: list[Asset]) -> list[Asset]:
|
|
300
|
+
"""Sort assets by cost priority (highest cost first)."""
|
|
301
|
+
return sorted(assets, key=lambda a: self.get_priority(a))
|
cyntrisec/core/cuts.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal Cut Finder - Find optimal remediations that block attack paths.
|
|
3
|
+
|
|
4
|
+
Uses a greedy set-cover approximation approach to find the minimum set of
|
|
5
|
+
edges (relationships) whose removal disconnects all entry points from
|
|
6
|
+
all sensitive targets.
|
|
7
|
+
|
|
8
|
+
The algorithm works by:
|
|
9
|
+
1. Building a flow network from entry points to targets
|
|
10
|
+
2. Finding edges that appear on multiple attack paths
|
|
11
|
+
3. Selecting edges that block the most paths with fewest changes
|
|
12
|
+
4. Ranking remediations based on ROI (Risk Reduction vs Cost Savings)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import uuid
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
|
|
23
|
+
from cyntrisec.core.cost_estimator import CostEstimator
|
|
24
|
+
from cyntrisec.core.graph import AwsGraph
|
|
25
|
+
from cyntrisec.core.schema import AttackPath, CostCutCandidate, Relationship
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Remediation:
|
|
32
|
+
"""
|
|
33
|
+
A proposed remediation action that blocks attack paths.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
relationship: The edge to remove/modify
|
|
37
|
+
action: Type of remediation (remove, restrict, isolate)
|
|
38
|
+
description: Human-readable description
|
|
39
|
+
paths_blocked: List of attack path IDs this blocks
|
|
40
|
+
priority: Base priority (paths blocked)
|
|
41
|
+
cost_savings: Estimated monthly USD savings if implemented
|
|
42
|
+
roi_score: Combined score (Priority + Cost Factor)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
relationship: Relationship
|
|
46
|
+
action: str
|
|
47
|
+
description: str
|
|
48
|
+
paths_blocked: list[uuid.UUID] = field(default_factory=list)
|
|
49
|
+
priority: float = 0.0
|
|
50
|
+
cost_savings: Decimal = Decimal("0")
|
|
51
|
+
roi_score: float = 0.0
|
|
52
|
+
|
|
53
|
+
# Metadata for display
|
|
54
|
+
source_name: str = ""
|
|
55
|
+
target_name: str = ""
|
|
56
|
+
relationship_type: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class CutResult:
|
|
61
|
+
"""
|
|
62
|
+
Result of the minimal cut analysis.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
remediations: Ordered list of recommended fixes (highest ROI first)
|
|
66
|
+
total_paths: Total attack paths in the graph
|
|
67
|
+
paths_blocked: Number of paths blocked by all remediations
|
|
68
|
+
coverage: Percentage of paths blocked (0-1)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
remediations: list[Remediation]
|
|
72
|
+
total_paths: int
|
|
73
|
+
paths_blocked: int
|
|
74
|
+
coverage: float
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MinCutFinder:
|
|
78
|
+
"""
|
|
79
|
+
Finds minimal set of edge removals to block all attack paths.
|
|
80
|
+
|
|
81
|
+
Uses a greedy set-cover approximation:
|
|
82
|
+
1. Count how many attack paths each edge appears on
|
|
83
|
+
2. Select edge that appears on most paths
|
|
84
|
+
3. Remove those paths from consideration
|
|
85
|
+
4. Repeat until all paths covered or budget exhausted
|
|
86
|
+
|
|
87
|
+
ROI Ranking:
|
|
88
|
+
After identifying minimal cuts, we sort them by a combined score:
|
|
89
|
+
ROI = (Paths_Blocked * 1.0) + (Monthly_Savings * 0.1)
|
|
90
|
+
This rewards high-security impact AND cost savings.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, cost_estimator: CostEstimator | None = None):
|
|
94
|
+
self.cost_estimator = cost_estimator or CostEstimator()
|
|
95
|
+
|
|
96
|
+
def find_cuts(
|
|
97
|
+
self,
|
|
98
|
+
graph: AwsGraph,
|
|
99
|
+
paths: list[AttackPath],
|
|
100
|
+
*,
|
|
101
|
+
max_cuts: int = 10,
|
|
102
|
+
relationship_types: set[str] | None = None,
|
|
103
|
+
) -> CutResult:
|
|
104
|
+
"""
|
|
105
|
+
Find minimal set of edges to remove that blocks all attack paths.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
graph: The capability graph
|
|
109
|
+
paths: Attack paths discovered by PathFinder
|
|
110
|
+
max_cuts: Maximum number of remediations to return
|
|
111
|
+
relationship_types: If provided, only consider these edge types
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
CutResult with ordered list of remediations
|
|
115
|
+
"""
|
|
116
|
+
if not paths:
|
|
117
|
+
return CutResult(
|
|
118
|
+
remediations=[],
|
|
119
|
+
total_paths=0,
|
|
120
|
+
paths_blocked=0,
|
|
121
|
+
coverage=1.0,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Build indexes
|
|
125
|
+
edge_to_paths, relationship_lookup = self._build_indexes(graph, paths)
|
|
126
|
+
|
|
127
|
+
# Greedy set cover
|
|
128
|
+
remediations: list[Remediation] = []
|
|
129
|
+
remaining_paths: set[uuid.UUID] = {p.id for p in paths}
|
|
130
|
+
used_edges: set[uuid.UUID] = set()
|
|
131
|
+
|
|
132
|
+
# We collect more candidates than max_cuts initally to allow re-ranking,
|
|
133
|
+
# but the greedy algorithm is iterative (dependant choices).
|
|
134
|
+
# Optimization: We stick to greedy set cover for *correctness* (blocking paths),
|
|
135
|
+
# then rank the chosen set by ROI to show best ones first?
|
|
136
|
+
# NO: Set cover order matters.
|
|
137
|
+
# Alternative: At each greedy step, pick Best(ROI) instead of Best(Coverage).
|
|
138
|
+
# This might result in MORE cuts needed, but they are cheaper.
|
|
139
|
+
# For Phase 2 MVP: We perform standard set cover, THEN rank the resulting independent cuts.
|
|
140
|
+
# (Assuming the cuts found are roughly independent, which isn't always true but works
|
|
141
|
+
# for list output).
|
|
142
|
+
|
|
143
|
+
while remaining_paths and len(remediations) < max_cuts:
|
|
144
|
+
# Modified Greedy: Score edges by (Coverage + Cost_Weight)
|
|
145
|
+
best_edge_id, best_coverage = self._find_best_edge_roi(
|
|
146
|
+
graph,
|
|
147
|
+
edge_to_paths,
|
|
148
|
+
relationship_lookup,
|
|
149
|
+
used_edges,
|
|
150
|
+
remaining_paths,
|
|
151
|
+
relationship_types
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if not best_edge_id or not best_coverage:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
# Add remediation
|
|
158
|
+
used_edges.add(best_edge_id)
|
|
159
|
+
remaining_paths -= best_coverage
|
|
160
|
+
|
|
161
|
+
rel = relationship_lookup[best_edge_id]
|
|
162
|
+
remediation = self._create_remediation(
|
|
163
|
+
graph, rel, best_coverage
|
|
164
|
+
)
|
|
165
|
+
remediations.append(remediation)
|
|
166
|
+
|
|
167
|
+
total_paths = len(paths)
|
|
168
|
+
blocked = total_paths - len(remaining_paths)
|
|
169
|
+
|
|
170
|
+
# Sort final list by ROI just to be sure presentation is optimal
|
|
171
|
+
remediations.sort(key=lambda x: x.roi_score, reverse=True)
|
|
172
|
+
|
|
173
|
+
return CutResult(
|
|
174
|
+
remediations=remediations,
|
|
175
|
+
total_paths=total_paths,
|
|
176
|
+
paths_blocked=blocked,
|
|
177
|
+
coverage=blocked / total_paths if total_paths > 0 else 1.0,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _build_indexes(
|
|
181
|
+
self, graph: AwsGraph, paths: list[AttackPath]
|
|
182
|
+
) -> tuple[dict[uuid.UUID, set[uuid.UUID]], dict[uuid.UUID, Relationship]]:
|
|
183
|
+
"""Build lookup indexes for edges and relationships."""
|
|
184
|
+
edge_to_paths: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
|
|
185
|
+
relationship_lookup: dict[uuid.UUID, Relationship] = {}
|
|
186
|
+
|
|
187
|
+
for path in paths:
|
|
188
|
+
for rel_id in path.path_relationship_ids:
|
|
189
|
+
edge_to_paths[rel_id].add(path.id)
|
|
190
|
+
|
|
191
|
+
for rel in graph.all_relationships():
|
|
192
|
+
relationship_lookup[rel.id] = rel
|
|
193
|
+
|
|
194
|
+
return edge_to_paths, relationship_lookup
|
|
195
|
+
|
|
196
|
+
def _find_best_edge_roi(
|
|
197
|
+
self,
|
|
198
|
+
graph: AwsGraph,
|
|
199
|
+
edge_to_paths: dict[uuid.UUID, set[uuid.UUID]],
|
|
200
|
+
relationship_lookup: dict[uuid.UUID, Relationship],
|
|
201
|
+
used_edges: set[uuid.UUID],
|
|
202
|
+
remaining_paths: set[uuid.UUID],
|
|
203
|
+
relationship_types: set[str] | None,
|
|
204
|
+
) -> tuple[uuid.UUID | None, set[uuid.UUID]]:
|
|
205
|
+
"""Find the edge with best ROI (Coverage + Cost)."""
|
|
206
|
+
|
|
207
|
+
best_edge_id: uuid.UUID | None = None
|
|
208
|
+
best_coverage: set[uuid.UUID] = set()
|
|
209
|
+
best_score: float = -1.0 # ROI score
|
|
210
|
+
|
|
211
|
+
for edge_id, covered_paths in edge_to_paths.items():
|
|
212
|
+
if edge_id in used_edges or edge_id not in relationship_lookup:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
rel = relationship_lookup[edge_id]
|
|
216
|
+
|
|
217
|
+
if relationship_types and rel.relationship_type not in relationship_types:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
coverage = covered_paths & remaining_paths
|
|
221
|
+
if not coverage:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# Calculate Score
|
|
225
|
+
security_score = len(coverage)
|
|
226
|
+
|
|
227
|
+
# Cost Savings
|
|
228
|
+
# We assume cutting an edge might allow removing the Target Asset?
|
|
229
|
+
# Or is the edge associated with a cost (e.g. NAT Gateway)?
|
|
230
|
+
# Simplification: If we isolate a target, we count its cost as potential savings.
|
|
231
|
+
target = graph.asset(rel.target_asset_id)
|
|
232
|
+
savings = Decimal("0")
|
|
233
|
+
if target:
|
|
234
|
+
est = self.cost_estimator.estimate(target)
|
|
235
|
+
if est:
|
|
236
|
+
savings = est.monthly_cost_usd_estimate
|
|
237
|
+
|
|
238
|
+
# ROI Formula: Paths + (Savings * 0.1)
|
|
239
|
+
# e.g. 5 paths + $50 * 0.1 = 10 score
|
|
240
|
+
# e.g. 1 path + $100 * 0.1 = 11 score (Cost wins)
|
|
241
|
+
roi = float(security_score) + (float(savings) * 0.05)
|
|
242
|
+
|
|
243
|
+
if roi > best_score:
|
|
244
|
+
best_edge_id = edge_id
|
|
245
|
+
best_coverage = coverage
|
|
246
|
+
best_score = roi
|
|
247
|
+
|
|
248
|
+
return best_edge_id, best_coverage
|
|
249
|
+
|
|
250
|
+
def _create_remediation(
|
|
251
|
+
self, graph: AwsGraph, rel: Relationship, paths_blocked: set[uuid.UUID]
|
|
252
|
+
) -> Remediation:
|
|
253
|
+
"""Create a Remediation object from a relationship."""
|
|
254
|
+
source = graph.asset(rel.source_asset_id)
|
|
255
|
+
target = graph.asset(rel.target_asset_id)
|
|
256
|
+
|
|
257
|
+
savings = Decimal("0")
|
|
258
|
+
if target:
|
|
259
|
+
est = self.cost_estimator.estimate(target)
|
|
260
|
+
if est:
|
|
261
|
+
savings = est.monthly_cost_usd_estimate
|
|
262
|
+
|
|
263
|
+
roi = float(len(paths_blocked)) + (float(savings) * 0.05)
|
|
264
|
+
|
|
265
|
+
return Remediation(
|
|
266
|
+
relationship=rel,
|
|
267
|
+
action=self._determine_action(rel),
|
|
268
|
+
description=self._build_description(rel, source, target),
|
|
269
|
+
paths_blocked=list(paths_blocked),
|
|
270
|
+
priority=len(paths_blocked),
|
|
271
|
+
cost_savings=savings,
|
|
272
|
+
roi_score=roi,
|
|
273
|
+
source_name=source.name if source else "unknown",
|
|
274
|
+
target_name=target.name if target else "unknown",
|
|
275
|
+
relationship_type=rel.relationship_type,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def _determine_action(self, rel: Relationship) -> str:
|
|
279
|
+
"""Determine the remediation action based on relationship type."""
|
|
280
|
+
action_map = {
|
|
281
|
+
"ALLOWS_TRAFFIC_TO": "restrict",
|
|
282
|
+
"MAY_ACCESS": "restrict_policy",
|
|
283
|
+
"CAN_ASSUME": "remove_trust",
|
|
284
|
+
"CONTAINS": "isolate",
|
|
285
|
+
"USES": "remove",
|
|
286
|
+
}
|
|
287
|
+
return action_map.get(rel.relationship_type, "review")
|
|
288
|
+
|
|
289
|
+
def _build_description(
|
|
290
|
+
self,
|
|
291
|
+
rel: Relationship,
|
|
292
|
+
source: object | None,
|
|
293
|
+
target: object | None,
|
|
294
|
+
) -> str:
|
|
295
|
+
"""Build human-readable remediation description."""
|
|
296
|
+
source_name = getattr(source, "name", "unknown") if source else "unknown"
|
|
297
|
+
target_name = getattr(target, "name", "unknown") if target else "unknown"
|
|
298
|
+
getattr(source, "asset_type", "unknown") if source else "unknown"
|
|
299
|
+
|
|
300
|
+
if rel.relationship_type == "ALLOWS_TRAFFIC_TO":
|
|
301
|
+
# Check if it's 0.0.0.0/0
|
|
302
|
+
if rel.properties.get("open_to_world"):
|
|
303
|
+
return f"Remove 0.0.0.0/0 ingress from {source_name}"
|
|
304
|
+
return f"Restrict traffic from {source_name} to {target_name}"
|
|
305
|
+
|
|
306
|
+
elif rel.relationship_type == "MAY_ACCESS":
|
|
307
|
+
return f"Restrict {source_name} access to {target_name}"
|
|
308
|
+
|
|
309
|
+
elif rel.relationship_type == "CAN_ASSUME":
|
|
310
|
+
via = rel.properties.get("via", "")
|
|
311
|
+
if via == "instance_profile":
|
|
312
|
+
return f"Remove instance profile from {source_name} or restrict role {target_name}"
|
|
313
|
+
return f"Remove trust relationship: {source_name} → {target_name}"
|
|
314
|
+
|
|
315
|
+
elif rel.relationship_type == "CONTAINS":
|
|
316
|
+
return f"Isolate {target_name} from {source_name}"
|
|
317
|
+
|
|
318
|
+
else:
|
|
319
|
+
return f"Review {rel.relationship_type}: {source_name} → {target_name}"
|
|
320
|
+
|
|
321
|
+
def to_cost_cut_candidates(
|
|
322
|
+
self,
|
|
323
|
+
cut_result: CutResult,
|
|
324
|
+
snapshot_id: uuid.UUID,
|
|
325
|
+
graph: AwsGraph,
|
|
326
|
+
) -> list[CostCutCandidate]:
|
|
327
|
+
"""
|
|
328
|
+
Convert CutResult to CostCutCandidate models for storage.
|
|
329
|
+
"""
|
|
330
|
+
candidates: list[CostCutCandidate] = []
|
|
331
|
+
|
|
332
|
+
for rem in cut_result.remediations:
|
|
333
|
+
# Calculate risk reduction based on paths blocked
|
|
334
|
+
risk_reduction = (
|
|
335
|
+
Decimal(str(len(rem.paths_blocked) / cut_result.total_paths))
|
|
336
|
+
if cut_result.total_paths > 0
|
|
337
|
+
else Decimal("0")
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
candidate = CostCutCandidate(
|
|
341
|
+
snapshot_id=snapshot_id,
|
|
342
|
+
asset_id=rem.relationship.target_asset_id,
|
|
343
|
+
reason=rem.description,
|
|
344
|
+
action=rem.action,
|
|
345
|
+
confidence=Decimal("0.8"), # Greedy algorithm confidence
|
|
346
|
+
paths_blocked=len(rem.paths_blocked),
|
|
347
|
+
risk_reduction=risk_reduction,
|
|
348
|
+
monthly_savings_usd=rem.cost_savings,
|
|
349
|
+
proof={
|
|
350
|
+
"relationship_id": str(rem.relationship.id),
|
|
351
|
+
"relationship_type": rem.relationship_type,
|
|
352
|
+
"source": rem.source_name,
|
|
353
|
+
"target": rem.target_name,
|
|
354
|
+
"paths_blocked": [str(p) for p in rem.paths_blocked],
|
|
355
|
+
"roi_score": rem.roi_score
|
|
356
|
+
},
|
|
357
|
+
)
|
|
358
|
+
candidates.append(candidate)
|
|
359
|
+
|
|
360
|
+
return candidates
|