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,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