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/waste.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Waste Analyzer - Identify unused IAM capabilities for blast radius reduction.
|
|
3
|
+
|
|
4
|
+
Analyzes the gap between "permissions granted" and "permissions used"
|
|
5
|
+
to find waste that increases attack surface without providing value.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
|
|
13
|
+
from cyntrisec.core.schema import Asset
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class UnusedCapability:
|
|
18
|
+
"""
|
|
19
|
+
An unused permission that contributes to blast radius.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
role_arn: The IAM role with this capability
|
|
23
|
+
role_name: Human-readable role name
|
|
24
|
+
service: AWS service namespace (e.g., 's3', 'iam')
|
|
25
|
+
service_name: Full service name (e.g., 'Amazon S3')
|
|
26
|
+
days_unused: Number of days since last use (None = never used)
|
|
27
|
+
risk_level: 'critical', 'high', 'medium', 'low'
|
|
28
|
+
recommendation: What to do about it
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
role_arn: str
|
|
32
|
+
role_name: str
|
|
33
|
+
service: str
|
|
34
|
+
service_name: str
|
|
35
|
+
days_unused: int | None = None # None means never used
|
|
36
|
+
risk_level: str = "medium"
|
|
37
|
+
recommendation: str = ""
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def never_used(self) -> bool:
|
|
41
|
+
return self.days_unused is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class WasteReport:
|
|
46
|
+
"""
|
|
47
|
+
Summary of unused capabilities in an AWS account.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
role_reports: Waste analysis per role
|
|
51
|
+
total_unused: Total count of unused service permissions
|
|
52
|
+
blast_radius_reduction: Estimated % reduction if cleaned up
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
role_reports: list[RoleWasteReport] = field(default_factory=list)
|
|
56
|
+
total_unused: int = 0
|
|
57
|
+
total_permissions: int = 0
|
|
58
|
+
analyzed_at: datetime = field(default_factory=datetime.utcnow)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def blast_radius_reduction(self) -> float:
|
|
62
|
+
"""Percentage of permissions that are unused."""
|
|
63
|
+
if self.total_permissions == 0:
|
|
64
|
+
return 0.0
|
|
65
|
+
return self.total_unused / self.total_permissions
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class RoleWasteReport:
|
|
70
|
+
"""Waste analysis for a single IAM role."""
|
|
71
|
+
|
|
72
|
+
role_arn: str
|
|
73
|
+
role_name: str
|
|
74
|
+
unused_capabilities: list[UnusedCapability] = field(default_factory=list)
|
|
75
|
+
total_services: int = 0
|
|
76
|
+
unused_services: int = 0
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def blast_radius_reduction(self) -> float:
|
|
80
|
+
"""Percentage reduction if unused capabilities removed."""
|
|
81
|
+
if self.total_services == 0:
|
|
82
|
+
return 0.0
|
|
83
|
+
return self.unused_services / self.total_services
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# High-risk services that should be flagged if unused
|
|
87
|
+
HIGH_RISK_SERVICES = {
|
|
88
|
+
"iam": "critical", # Identity management
|
|
89
|
+
"sts": "high", # Token service
|
|
90
|
+
"kms": "high", # Encryption keys
|
|
91
|
+
"secretsmanager": "high", # Secrets
|
|
92
|
+
"ssm": "high", # Systems Manager
|
|
93
|
+
"ec2": "medium", # Compute
|
|
94
|
+
"s3": "medium", # Storage
|
|
95
|
+
"lambda": "medium", # Functions
|
|
96
|
+
"rds": "medium", # Databases
|
|
97
|
+
"dynamodb": "medium", # NoSQL
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class WasteAnalyzer:
|
|
102
|
+
"""
|
|
103
|
+
Analyze IAM permissions for unused capabilities.
|
|
104
|
+
|
|
105
|
+
Compares granted permissions against actual usage to find
|
|
106
|
+
opportunities for blast radius reduction.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, days_threshold: int = 90):
|
|
110
|
+
"""
|
|
111
|
+
Initialize analyzer.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
days_threshold: Consider unused if not accessed in this many days
|
|
115
|
+
"""
|
|
116
|
+
self.days_threshold = days_threshold
|
|
117
|
+
self._cutoff = datetime.utcnow() - timedelta(days=days_threshold)
|
|
118
|
+
|
|
119
|
+
def _is_aws_managed_role(self, role: Asset) -> bool:
|
|
120
|
+
"""Check if role is AWS-managed and should be excluded from waste analysis."""
|
|
121
|
+
name = role.name or ""
|
|
122
|
+
arn = role.arn or role.aws_resource_id or ""
|
|
123
|
+
|
|
124
|
+
# AWS service-linked roles
|
|
125
|
+
if name.startswith("AWSServiceRole"):
|
|
126
|
+
return True
|
|
127
|
+
if name.startswith("AWSReservedSSO_"):
|
|
128
|
+
return True
|
|
129
|
+
if "/aws-service-role/" in arn:
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def analyze_from_usage_reports(
|
|
135
|
+
self,
|
|
136
|
+
usage_reports: list[any], # List[RoleUsageReport]
|
|
137
|
+
) -> WasteReport:
|
|
138
|
+
"""
|
|
139
|
+
Analyze usage reports to find waste.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
usage_reports: List of RoleUsageReport from UsageCollector
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
WasteReport with all unused capabilities
|
|
146
|
+
"""
|
|
147
|
+
report = WasteReport()
|
|
148
|
+
|
|
149
|
+
for usage in usage_reports:
|
|
150
|
+
role_waste = self._analyze_role(usage)
|
|
151
|
+
report.role_reports.append(role_waste)
|
|
152
|
+
report.total_unused += role_waste.unused_services
|
|
153
|
+
report.total_permissions += role_waste.total_services
|
|
154
|
+
|
|
155
|
+
return report
|
|
156
|
+
|
|
157
|
+
def _analyze_role(self, usage) -> RoleWasteReport:
|
|
158
|
+
"""Analyze a single role's usage."""
|
|
159
|
+
role_waste = RoleWasteReport(
|
|
160
|
+
role_arn=usage.role_arn,
|
|
161
|
+
role_name=usage.role_name,
|
|
162
|
+
total_services=len(usage.services),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
for svc in usage.services:
|
|
166
|
+
# Check if unused (never accessed or not accessed within threshold)
|
|
167
|
+
is_unused = False
|
|
168
|
+
days_unused = None
|
|
169
|
+
|
|
170
|
+
if svc.last_authenticated is None:
|
|
171
|
+
is_unused = True
|
|
172
|
+
days_unused = None # Never used
|
|
173
|
+
else:
|
|
174
|
+
# Handle timezone-aware datetimes
|
|
175
|
+
last_auth = svc.last_authenticated
|
|
176
|
+
if hasattr(last_auth, "replace"):
|
|
177
|
+
last_auth = last_auth.replace(tzinfo=None)
|
|
178
|
+
|
|
179
|
+
if last_auth < self._cutoff:
|
|
180
|
+
is_unused = True
|
|
181
|
+
days_unused = (datetime.utcnow() - last_auth).days
|
|
182
|
+
|
|
183
|
+
if is_unused:
|
|
184
|
+
role_waste.unused_services += 1
|
|
185
|
+
|
|
186
|
+
# Determine risk level
|
|
187
|
+
namespace = svc.service_namespace.lower()
|
|
188
|
+
risk_level = HIGH_RISK_SERVICES.get(namespace, "low")
|
|
189
|
+
|
|
190
|
+
# Build recommendation
|
|
191
|
+
if days_unused is None:
|
|
192
|
+
recommendation = f"Remove {svc.service_name} access - never used"
|
|
193
|
+
else:
|
|
194
|
+
recommendation = (
|
|
195
|
+
f"Remove {svc.service_name} access - unused for {days_unused} days"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
role_waste.unused_capabilities.append(
|
|
199
|
+
UnusedCapability(
|
|
200
|
+
role_arn=usage.role_arn,
|
|
201
|
+
role_name=usage.role_name,
|
|
202
|
+
service=namespace,
|
|
203
|
+
service_name=svc.service_name,
|
|
204
|
+
days_unused=days_unused,
|
|
205
|
+
risk_level=risk_level,
|
|
206
|
+
recommendation=recommendation,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Sort by risk level
|
|
211
|
+
risk_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
212
|
+
role_waste.unused_capabilities.sort(
|
|
213
|
+
key=lambda x: (risk_order.get(x.risk_level, 4), x.service)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return role_waste
|
|
217
|
+
|
|
218
|
+
def analyze_from_assets(
|
|
219
|
+
self,
|
|
220
|
+
assets: list[Asset],
|
|
221
|
+
usage_reports: list[any] | None = None,
|
|
222
|
+
) -> WasteReport:
|
|
223
|
+
"""
|
|
224
|
+
Analyze assets for potential waste.
|
|
225
|
+
|
|
226
|
+
This is a simpler analysis that doesn't require live AWS access.
|
|
227
|
+
It looks at attack paths and identifies roles that only appear
|
|
228
|
+
in attack paths (pure attack surface, no legitimate use).
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
assets: Assets from a scan
|
|
232
|
+
usage_reports: Optional live usage data
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
WasteReport
|
|
236
|
+
"""
|
|
237
|
+
if usage_reports:
|
|
238
|
+
return self.analyze_from_usage_reports(usage_reports)
|
|
239
|
+
|
|
240
|
+
# Fallback: offline analysis based on attached policies
|
|
241
|
+
report = WasteReport()
|
|
242
|
+
|
|
243
|
+
# Find IAM roles, excluding AWS-managed service roles
|
|
244
|
+
roles = [
|
|
245
|
+
a for a in assets
|
|
246
|
+
if a.asset_type == "iam:role" and not self._is_aws_managed_role(a)
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
for role in roles:
|
|
250
|
+
role_waste = RoleWasteReport(
|
|
251
|
+
role_arn=role.arn or role.aws_resource_id,
|
|
252
|
+
role_name=role.name,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
policy_docs = role.properties.get("policy_documents", [])
|
|
256
|
+
services_granted = self._extract_services_from_policies(policy_docs)
|
|
257
|
+
role_waste.total_services = len(services_granted)
|
|
258
|
+
|
|
259
|
+
if services_granted:
|
|
260
|
+
role_waste.unused_capabilities.append(
|
|
261
|
+
UnusedCapability(
|
|
262
|
+
role_arn=role.arn or role.aws_resource_id,
|
|
263
|
+
role_name=role.name,
|
|
264
|
+
service="unknown",
|
|
265
|
+
service_name="Usage data unavailable",
|
|
266
|
+
days_unused=None,
|
|
267
|
+
risk_level="info",
|
|
268
|
+
recommendation="Use --live for accurate usage analysis",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if role_waste.unused_capabilities:
|
|
273
|
+
report.role_reports.append(role_waste)
|
|
274
|
+
report.total_unused += role_waste.unused_services
|
|
275
|
+
report.total_permissions += role_waste.total_services
|
|
276
|
+
|
|
277
|
+
return report
|
|
278
|
+
|
|
279
|
+
def _extract_services_from_policies(self, policy_docs: list[dict]) -> list[str]:
|
|
280
|
+
"""Extract service namespaces from policy documents."""
|
|
281
|
+
services: set[str] = set()
|
|
282
|
+
wildcard = False
|
|
283
|
+
for policy in policy_docs or []:
|
|
284
|
+
statements = policy.get("Statement", [])
|
|
285
|
+
if isinstance(statements, dict):
|
|
286
|
+
statements = [statements]
|
|
287
|
+
for statement in statements:
|
|
288
|
+
if statement.get("Effect") != "Allow":
|
|
289
|
+
continue
|
|
290
|
+
actions = statement.get("Action")
|
|
291
|
+
if not actions:
|
|
292
|
+
continue
|
|
293
|
+
if isinstance(actions, str):
|
|
294
|
+
actions = [actions]
|
|
295
|
+
for action in actions:
|
|
296
|
+
if not isinstance(action, str):
|
|
297
|
+
continue
|
|
298
|
+
if action == "*":
|
|
299
|
+
wildcard = True
|
|
300
|
+
continue
|
|
301
|
+
if ":" in action:
|
|
302
|
+
service = action.split(":", 1)[0]
|
|
303
|
+
if service and service != "*":
|
|
304
|
+
services.add(service.lower())
|
|
305
|
+
elif action:
|
|
306
|
+
services.add(action.lower())
|
|
307
|
+
if wildcard and not services:
|
|
308
|
+
services.update(HIGH_RISK_SERVICES.keys())
|
|
309
|
+
return sorted(services)
|