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,862 @@
1
+ """
2
+ MCP Server - Model Context Protocol server for AI agent integration.
3
+
4
+ Exposes Cyntrisec capabilities as MCP tools that AI agents can invoke directly.
5
+
6
+ Usage:
7
+ cyntrisec serve # Start MCP server (stdio transport)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ # MCP support - optional dependency
18
+ try:
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ HAS_MCP = True
22
+ except ImportError:
23
+ HAS_MCP = False
24
+ FastMCP = None
25
+
26
+ from cyntrisec.cli.remediate import _terraform_snippet
27
+ from cyntrisec.core.compliance import ComplianceChecker, Framework
28
+ from cyntrisec.core.cuts import MinCutFinder
29
+ from cyntrisec.core.diff import SnapshotDiff
30
+ from cyntrisec.core.graph import GraphBuilder
31
+ from cyntrisec.core.simulator import OfflineSimulator
32
+ from cyntrisec.core.waste import WasteAnalyzer
33
+ from cyntrisec.storage import FileSystemStorage
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+
38
+ # Error codes for MCP responses (mirrors CLI error taxonomy)
39
+ MCP_ERROR_SNAPSHOT_NOT_FOUND = "SNAPSHOT_NOT_FOUND"
40
+ MCP_ERROR_INSUFFICIENT_DATA = "INSUFFICIENT_DATA"
41
+
42
+
43
+ def mcp_error(error_code: str, message: str) -> dict[str, Any]:
44
+ """Return a consistent error envelope for MCP tool responses."""
45
+ return {
46
+ "status": "error",
47
+ "error_code": error_code,
48
+ "message": message,
49
+ "data": None,
50
+ }
51
+
52
+
53
+ @dataclass
54
+ class SessionState:
55
+ """
56
+ Lightweight session cache for MCP server calls.
57
+
58
+ Caches scan data for the current snapshot to avoid repeated disk reads
59
+ and keeps track of the active snapshot id for successive tool calls.
60
+ """
61
+
62
+ storage: FileSystemStorage = field(default_factory=FileSystemStorage)
63
+ snapshot_id: str | None = None
64
+ _cache: dict[tuple[str, str | None], object] = field(default_factory=dict)
65
+
66
+ def set_snapshot(self, snapshot_id: str | None) -> str | None:
67
+ """Set or update the active snapshot id and clear cache if changed."""
68
+ # Resolve the identifier to a scan_id (directory name)
69
+ resolved_id = self.storage.resolve_scan_id(snapshot_id)
70
+ if resolved_id and resolved_id != self.snapshot_id:
71
+ self._cache.clear()
72
+ self.snapshot_id = resolved_id
73
+ elif resolved_id is None and self.snapshot_id is None:
74
+ # Try to seed from latest snapshot if present
75
+ snap = self.storage.get_snapshot()
76
+ if snap:
77
+ self.snapshot_id = self.storage.resolve_scan_id(None)
78
+ return self.snapshot_id
79
+
80
+ def _key(self, kind: str, snapshot_id: str | None) -> tuple[str, str | None]:
81
+ resolved_id = self.storage.resolve_scan_id(snapshot_id) if snapshot_id else self.snapshot_id
82
+ return (kind, resolved_id or self.snapshot_id)
83
+
84
+ def get_snapshot(self, snapshot_id: str | None = None):
85
+ resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
86
+ snap = self.storage.get_snapshot(resolved_id)
87
+ if snap and not self.snapshot_id:
88
+ self.snapshot_id = resolved_id or self.storage.resolve_scan_id(None)
89
+ return snap
90
+
91
+ def get_assets(self, snapshot_id: str | None = None):
92
+ resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
93
+ key = self._key("assets", resolved_id)
94
+ if key not in self._cache:
95
+ self._cache[key] = self.storage.get_assets(resolved_id)
96
+ return self._cache[key]
97
+
98
+ def get_relationships(self, snapshot_id: str | None = None):
99
+ resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
100
+ key = self._key("relationships", resolved_id)
101
+ if key not in self._cache:
102
+ self._cache[key] = self.storage.get_relationships(resolved_id)
103
+ return self._cache[key]
104
+
105
+ def get_paths(self, snapshot_id: str | None = None):
106
+ resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
107
+ key = self._key("paths", resolved_id)
108
+ if key not in self._cache:
109
+ self._cache[key] = self.storage.get_attack_paths(resolved_id)
110
+ return self._cache[key]
111
+
112
+ def get_findings(self, snapshot_id: str | None = None):
113
+ resolved_id = self.storage.resolve_scan_id(snapshot_id or self.snapshot_id)
114
+ key = self._key("findings", resolved_id)
115
+ if key not in self._cache:
116
+ self._cache[key] = self.storage.get_findings(resolved_id)
117
+ return self._cache[key]
118
+
119
+ def clear_cache(self) -> None:
120
+ self._cache.clear()
121
+
122
+
123
+ def create_mcp_server() -> FastMCP:
124
+ """
125
+ Create and configure the MCP server with all tools.
126
+
127
+ Returns:
128
+ Configured FastMCP instance
129
+ """
130
+ if not HAS_MCP:
131
+ raise ImportError("MCP SDK not installed. Run: pip install mcp")
132
+
133
+ mcp = FastMCP(
134
+ name="cyntrisec", instructions="AWS capability graph analysis and attack path discovery"
135
+ )
136
+ session = SessionState()
137
+
138
+ _register_session_tools(mcp, session)
139
+ _register_graph_tools(mcp, session)
140
+ _register_insight_tools(mcp, session)
141
+
142
+ return mcp
143
+
144
+
145
+ def _register_session_tools(mcp, session):
146
+ """Register session and summary tools."""
147
+
148
+ @mcp.tool()
149
+ def get_findings(
150
+ severity: str | None = None,
151
+ max_findings: int = 20,
152
+ snapshot_id: str | None = None,
153
+ ) -> dict[str, Any]:
154
+ """
155
+ Get security findings from the scan.
156
+
157
+ Args:
158
+ severity: Filter by severity (CRITICAL, HIGH, MEDIUM, LOW)
159
+ max_findings: Maximum number of findings to return (default: 20)
160
+ snapshot_id: Optional snapshot ID (default: latest)
161
+
162
+ Returns:
163
+ List of security findings with severity and descriptions.
164
+ """
165
+ snapshot = session.get_snapshot(snapshot_id)
166
+ if not snapshot:
167
+ return mcp_error(
168
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
169
+ )
170
+
171
+ findings = session.get_findings(snapshot_id)
172
+ session.set_snapshot(snapshot_id)
173
+
174
+ # Filter by severity if specified
175
+ if severity:
176
+ severity_upper = severity.upper()
177
+ findings = [f for f in findings if f.severity.upper() == severity_upper]
178
+
179
+ return {
180
+ "total": len(findings),
181
+ "findings": [
182
+ {
183
+ "id": str(f.id),
184
+ "title": f.title,
185
+ "severity": f.severity,
186
+ "finding_type": f.finding_type,
187
+ "description": f.description,
188
+ "remediation": f.remediation,
189
+ }
190
+ for f in findings[:max_findings]
191
+ ],
192
+ }
193
+
194
+ @mcp.tool()
195
+ def get_assets(
196
+ asset_type: str | None = None,
197
+ search: str | None = None,
198
+ max_assets: int = 50,
199
+ snapshot_id: str | None = None,
200
+ ) -> dict[str, Any]:
201
+ """
202
+ Get assets from the scan with optional filtering.
203
+
204
+ Args:
205
+ asset_type: Filter by type (e.g., "iam:role", "ec2:instance", "s3:bucket")
206
+ search: Search by name or ARN (case-insensitive)
207
+ max_assets: Maximum number of assets to return (default: 50)
208
+ snapshot_id: Optional snapshot ID (default: latest)
209
+
210
+ Returns:
211
+ List of assets with their properties.
212
+ """
213
+ snapshot = session.get_snapshot(snapshot_id)
214
+ if not snapshot:
215
+ return mcp_error(
216
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
217
+ )
218
+
219
+ assets = session.get_assets(snapshot_id)
220
+ session.set_snapshot(snapshot_id)
221
+
222
+ # Filter by type if specified
223
+ if asset_type:
224
+ assets = [a for a in assets if a.asset_type.lower() == asset_type.lower()]
225
+
226
+ # Search by name or ARN
227
+ if search:
228
+ search_lower = search.lower()
229
+ assets = [
230
+ a for a in assets
231
+ if search_lower in (a.name or "").lower()
232
+ or search_lower in (a.arn or "").lower()
233
+ ]
234
+
235
+ return {
236
+ "total": len(assets),
237
+ "assets": [
238
+ {
239
+ "id": str(a.id),
240
+ "type": a.asset_type,
241
+ "name": a.name,
242
+ "arn": a.arn,
243
+ "region": a.aws_region,
244
+ "is_internet_facing": a.is_internet_facing,
245
+ "is_sensitive_target": a.is_sensitive_target,
246
+ }
247
+ for a in assets[:max_assets]
248
+ ],
249
+ }
250
+
251
+ @mcp.tool()
252
+ def get_relationships(
253
+ relationship_type: str | None = None,
254
+ source_name: str | None = None,
255
+ target_name: str | None = None,
256
+ max_relationships: int = 50,
257
+ snapshot_id: str | None = None,
258
+ ) -> dict[str, Any]:
259
+ """
260
+ Get relationships between assets with optional filtering.
261
+
262
+ Args:
263
+ relationship_type: Filter by type (e.g., "CAN_ASSUME", "CAN_REACH", "MAY_ACCESS")
264
+ source_name: Filter by source asset name
265
+ target_name: Filter by target asset name
266
+ max_relationships: Maximum number to return (default: 50)
267
+ snapshot_id: Optional snapshot ID (default: latest)
268
+
269
+ Returns:
270
+ List of relationships with source, target, and type.
271
+ """
272
+ snapshot = session.get_snapshot(snapshot_id)
273
+ if not snapshot:
274
+ return mcp_error(
275
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
276
+ )
277
+
278
+ relationships = session.get_relationships(snapshot_id)
279
+ assets = session.get_assets(snapshot_id)
280
+ session.set_snapshot(snapshot_id)
281
+
282
+ # Build asset lookup for names
283
+ asset_map = {str(a.id): a for a in assets}
284
+
285
+ # Filter by relationship type
286
+ if relationship_type:
287
+ relationships = [
288
+ r for r in relationships
289
+ if r.relationship_type.upper() == relationship_type.upper()
290
+ ]
291
+
292
+ # Filter by source name
293
+ if source_name:
294
+ source_lower = source_name.lower()
295
+ relationships = [
296
+ r for r in relationships
297
+ if (asset := asset_map.get(str(r.source_asset_id))) and
298
+ asset.name and source_lower in asset.name.lower()
299
+ ]
300
+
301
+ # Filter by target name
302
+ if target_name:
303
+ target_lower = target_name.lower()
304
+ relationships = [
305
+ r for r in relationships
306
+ if (asset := asset_map.get(str(r.target_asset_id))) and
307
+ asset.name and target_lower in asset.name.lower()
308
+ ]
309
+
310
+ def get_asset_name(asset_id):
311
+ asset = asset_map.get(str(asset_id))
312
+ return asset.name if asset else None
313
+
314
+ return {
315
+ "total": len(relationships),
316
+ "relationships": [
317
+ {
318
+ "id": str(r.id),
319
+ "type": r.relationship_type,
320
+ "source_id": str(r.source_asset_id),
321
+ "source_name": get_asset_name(r.source_asset_id),
322
+ "target_id": str(r.target_asset_id),
323
+ "target_name": get_asset_name(r.target_asset_id),
324
+ "edge_kind": r.edge_kind.value if hasattr(r.edge_kind, 'value') else r.edge_kind,
325
+ }
326
+ for r in relationships[:max_relationships]
327
+ ],
328
+ }
329
+
330
+ @mcp.tool()
331
+ def get_scan_summary(snapshot_id: str | None = None) -> dict[str, Any]:
332
+ """
333
+ Get summary of the latest AWS scan.
334
+
335
+ Returns asset counts, finding counts, and attack path counts.
336
+ """
337
+ snapshot = session.get_snapshot(snapshot_id)
338
+ session.set_snapshot(snapshot_id or (snapshot and str(snapshot.id)))
339
+
340
+ if not snapshot:
341
+ return mcp_error(
342
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
343
+ )
344
+
345
+ return {
346
+ "snapshot_id": str(snapshot.id),
347
+ "account_id": snapshot.aws_account_id,
348
+ "regions": snapshot.regions,
349
+ "status": snapshot.status,
350
+ "started_at": snapshot.started_at.isoformat(),
351
+ "asset_count": snapshot.asset_count,
352
+ "relationship_count": snapshot.relationship_count,
353
+ "finding_count": snapshot.finding_count,
354
+ "attack_path_count": snapshot.path_count,
355
+ }
356
+
357
+ @mcp.tool()
358
+ def set_session_snapshot(snapshot_id: str | None = None) -> dict[str, Any]:
359
+ """
360
+ Set or retrieve the active snapshot id used for subsequent calls.
361
+
362
+ Args:
363
+ snapshot_id: Optional scan id/directory name. If omitted, returns current/ latest.
364
+ """
365
+ sid = session.set_snapshot(snapshot_id)
366
+ snap = session.get_snapshot(sid)
367
+ return {
368
+ "snapshot_id": str(snap.id) if snap else sid,
369
+ "active": sid,
370
+ "available_scans": session.storage.list_scans(),
371
+ }
372
+
373
+ @mcp.tool()
374
+ def list_tools() -> dict[str, Any]:
375
+ """
376
+ List all available Cyntrisec tools.
377
+
378
+ Returns:
379
+ List of tools with descriptions.
380
+ """
381
+ return {
382
+ "tools": [
383
+ # Discovery & Session
384
+ {"name": "list_tools", "description": "List all available Cyntrisec tools"},
385
+ {"name": "set_session_snapshot", "description": "Set active snapshot for session"},
386
+ {"name": "get_scan_summary", "description": "Get summary of latest AWS scan"},
387
+ # Assets & Relationships
388
+ {"name": "get_assets", "description": "Get assets with optional type/name filtering"},
389
+ {"name": "get_relationships", "description": "Get relationships between assets"},
390
+ {"name": "get_findings", "description": "Get security findings with severity filtering"},
391
+ # Attack Paths
392
+ {"name": "get_attack_paths", "description": "Get discovered attack paths with risk scores"},
393
+ {"name": "explain_path", "description": "Get detailed breakdown of an attack path"},
394
+ {"name": "explain_finding", "description": "Get detailed explanation of a security finding"},
395
+ # Remediation
396
+ {"name": "get_remediations", "description": "Find optimal fixes for attack paths"},
397
+ {"name": "get_terraform_snippet", "description": "Get Terraform code for a remediation"},
398
+ # Access & Permissions
399
+ {"name": "check_access", "description": "Test if principal can access resource"},
400
+ {"name": "get_unused_permissions", "description": "Find unused IAM permissions"},
401
+ # Compliance & Diff
402
+ {"name": "check_compliance", "description": "Check CIS AWS or SOC 2 compliance"},
403
+ {"name": "compare_scans", "description": "Compare latest scan to previous"},
404
+ ]
405
+ }
406
+
407
+
408
+ def _register_graph_tools(mcp, session):
409
+ """Register graph analysis tools."""
410
+
411
+ @mcp.tool()
412
+ def get_attack_paths(
413
+ max_paths: int = 10,
414
+ min_risk: float = 0.0,
415
+ snapshot_id: str | None = None,
416
+ ) -> dict[str, Any]:
417
+ """
418
+ Get discovered attack paths from the latest scan.
419
+
420
+ Args:
421
+ max_paths: Maximum number of paths to return (default: 10)
422
+ min_risk: Minimum risk score filter (0.0-1.0, default: 0.0)
423
+ snapshot_id: Optional snapshot ID (default: latest)
424
+
425
+ Returns:
426
+ List of attack paths with risk scores, confidence, and traversed assets.
427
+ """
428
+ snapshot = session.get_snapshot(snapshot_id)
429
+ if not snapshot:
430
+ return mcp_error(
431
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
432
+ )
433
+
434
+ paths = session.get_paths(snapshot_id)
435
+ assets = session.get_assets(snapshot_id)
436
+ session.set_snapshot(snapshot_id)
437
+
438
+ # Build asset lookup
439
+ asset_map = {str(a.id): a for a in assets}
440
+
441
+ # Filter by min risk
442
+ if min_risk > 0:
443
+ paths = [p for p in paths if p.risk_score >= min_risk]
444
+
445
+ def get_asset_name(asset_id):
446
+ if not asset_id:
447
+ return None
448
+ asset = asset_map.get(str(asset_id))
449
+ return asset.name if asset else str(asset_id)
450
+
451
+ return {
452
+ "total": len(paths),
453
+ "paths": [
454
+ {
455
+ "id": str(p.id),
456
+ "attack_vector": p.attack_vector,
457
+ "risk_score": float(p.risk_score),
458
+ "confidence_level": (
459
+ p.confidence_level.value
460
+ if hasattr(p.confidence_level, "value")
461
+ else p.confidence_level
462
+ ),
463
+ "source_name": get_asset_name(p.source_asset_id),
464
+ "target_name": get_asset_name(p.target_asset_id),
465
+ "path_length": len(p.path_asset_ids) if p.path_asset_ids else 0,
466
+ "path_assets": [get_asset_name(aid) for aid in (p.path_asset_ids or [])],
467
+ }
468
+ for p in paths[:max_paths]
469
+ ],
470
+ }
471
+
472
+ @mcp.tool()
473
+ def explain_path(path_id: str, snapshot_id: str | None = None) -> dict[str, Any]:
474
+ """
475
+ Get detailed explanation of an attack path.
476
+
477
+ Args:
478
+ path_id: The attack path ID to explain
479
+ snapshot_id: Optional snapshot ID (default: latest)
480
+
481
+ Returns:
482
+ Detailed breakdown of the attack path with each hop explained.
483
+ """
484
+ snapshot = session.get_snapshot(snapshot_id)
485
+ if not snapshot:
486
+ return mcp_error(
487
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
488
+ )
489
+
490
+ paths = session.get_paths(snapshot_id)
491
+ assets = session.get_assets(snapshot_id)
492
+ relationships = session.get_relationships(snapshot_id)
493
+ session.set_snapshot(snapshot_id)
494
+
495
+ # Find the path
496
+ target_path = None
497
+ for p in paths:
498
+ if str(p.id) == path_id:
499
+ target_path = p
500
+ break
501
+
502
+ if not target_path:
503
+ return mcp_error("PATH_NOT_FOUND", f"Attack path {path_id} not found.")
504
+
505
+ # Build lookups
506
+ asset_map = {str(a.id): a for a in assets}
507
+ rel_map = {str(r.id): r for r in relationships}
508
+
509
+ # Build path explanation
510
+ hops = []
511
+ path_asset_ids = target_path.path_asset_ids or []
512
+ path_rel_ids = target_path.attack_chain_relationship_ids or []
513
+
514
+ for i, asset_id in enumerate(path_asset_ids):
515
+ asset = asset_map.get(str(asset_id))
516
+ hop = {
517
+ "step": i + 1,
518
+ "asset_name": asset.name if asset else str(asset_id),
519
+ "asset_type": asset.asset_type if asset else None,
520
+ "asset_arn": asset.arn if asset else None,
521
+ }
522
+
523
+ # Add relationship to next hop if exists
524
+ if i < len(path_rel_ids):
525
+ rel = rel_map.get(str(path_rel_ids[i]))
526
+ if rel:
527
+ hop["next_via"] = rel.relationship_type
528
+ if rel.evidence and rel.evidence.permission:
529
+ hop["permission"] = rel.evidence.permission
530
+
531
+ hops.append(hop)
532
+
533
+ return {
534
+ "path_id": path_id,
535
+ "attack_vector": target_path.attack_vector,
536
+ "risk_score": float(target_path.risk_score),
537
+ "confidence_level": (
538
+ target_path.confidence_level.value
539
+ if hasattr(target_path.confidence_level, "value")
540
+ else target_path.confidence_level
541
+ ),
542
+ "summary": f"Attack path from {hops[0]['asset_name'] if hops else 'unknown'} to {hops[-1]['asset_name'] if hops else 'unknown'} via {len(hops)} hops",
543
+ "hops": hops,
544
+ }
545
+
546
+ @mcp.tool()
547
+ def explain_finding(finding_id: str, snapshot_id: str | None = None) -> dict[str, Any]:
548
+ """
549
+ Get detailed explanation of a security finding.
550
+
551
+ Args:
552
+ finding_id: The finding ID to explain
553
+ snapshot_id: Optional snapshot ID (default: latest)
554
+
555
+ Returns:
556
+ Detailed explanation with context, impact, and remediation steps.
557
+ """
558
+ snapshot = session.get_snapshot(snapshot_id)
559
+ if not snapshot:
560
+ return mcp_error(
561
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
562
+ )
563
+
564
+ findings = session.get_findings(snapshot_id)
565
+ session.set_snapshot(snapshot_id)
566
+
567
+ # Find the finding
568
+ target_finding = None
569
+ for f in findings:
570
+ if str(f.id) == finding_id:
571
+ target_finding = f
572
+ break
573
+
574
+ if not target_finding:
575
+ return mcp_error("FINDING_NOT_FOUND", f"Finding {finding_id} not found.")
576
+
577
+ return {
578
+ "finding_id": finding_id,
579
+ "title": target_finding.title,
580
+ "severity": target_finding.severity,
581
+ "finding_type": target_finding.finding_type,
582
+ "asset_id": str(target_finding.asset_id),
583
+ "description": target_finding.description,
584
+ "impact": f"This {target_finding.severity} severity finding affects asset {target_finding.asset_id}",
585
+ "remediation": target_finding.remediation,
586
+ "evidence": target_finding.evidence if hasattr(target_finding, 'evidence') else {},
587
+ }
588
+
589
+ @mcp.tool()
590
+ def check_access(
591
+ principal: str, resource: str, snapshot_id: str | None = None
592
+ ) -> dict[str, Any]:
593
+ """
594
+ Test if a principal can access a resource.
595
+
596
+ Args:
597
+ principal: IAM role or user name (e.g., "ECforS")
598
+ resource: Target resource (e.g., "s3://prod-bucket")
599
+
600
+ Returns:
601
+ Whether access is allowed and via which relationship.
602
+ """
603
+ snapshot = session.get_snapshot(snapshot_id)
604
+ assets = session.get_assets(snapshot_id)
605
+ relationships = session.get_relationships(snapshot_id)
606
+ session.set_snapshot(snapshot_id or (snapshot and str(snapshot.id)))
607
+
608
+ if not snapshot:
609
+ return mcp_error(MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found.")
610
+
611
+ # OfflineSimulator takes assets and relationships, not a graph
612
+ simulator = OfflineSimulator(assets=assets, relationships=relationships)
613
+ result = simulator.can_access(principal, resource)
614
+
615
+ return {
616
+ "principal": result.principal_arn,
617
+ "resource": result.target_resource,
618
+ "can_access": result.can_access,
619
+ "via": result.proof.get("relationship_type", None),
620
+ }
621
+
622
+
623
+ def _register_insight_tools(mcp, session):
624
+ """Register insight and remediation tools."""
625
+
626
+ @mcp.tool()
627
+ def get_remediations(max_cuts: int = 5, snapshot_id: str | None = None) -> dict[str, Any]:
628
+ """
629
+ Find optimal remediations that block attack paths.
630
+
631
+ Uses min-cut algorithm to find smallest set of changes
632
+ that block all attack paths.
633
+
634
+ Args:
635
+ max_cuts: Maximum number of remediations (default: 5)
636
+
637
+ Returns:
638
+ List of remediations with coverage percentages.
639
+ """
640
+ snapshot = session.get_snapshot(snapshot_id)
641
+ if not snapshot:
642
+ return mcp_error(
643
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
644
+ )
645
+
646
+ assets = session.get_assets(snapshot_id)
647
+ relationships = session.get_relationships(snapshot_id)
648
+ paths = session.get_paths(snapshot_id)
649
+ session.set_snapshot(snapshot_id)
650
+
651
+ if not paths:
652
+ return {"total_paths": 0, "remediations": []}
653
+
654
+ graph = GraphBuilder().build(assets=assets, relationships=relationships)
655
+ finder = MinCutFinder()
656
+ result = finder.find_cuts(graph, paths, max_cuts=max_cuts)
657
+
658
+ return {
659
+ "total_paths": result.total_paths,
660
+ "paths_blocked": result.paths_blocked,
661
+ "coverage": float(result.coverage),
662
+ "remediations": [
663
+ {
664
+ "source": r.source_name,
665
+ "target": r.target_name,
666
+ "relationship_type": r.relationship_type,
667
+ "paths_blocked": len(r.paths_blocked),
668
+ "recommendation": r.description,
669
+ "estimated_savings": float(r.cost_savings),
670
+ "roi_score": float(r.roi_score),
671
+ }
672
+ for r in result.remediations
673
+ ],
674
+ }
675
+
676
+ @mcp.tool()
677
+ def get_terraform_snippet(
678
+ source_name: str,
679
+ target_name: str,
680
+ relationship_type: str,
681
+ snapshot_id: str | None = None,
682
+ ) -> dict[str, Any]:
683
+ """
684
+ Get Terraform code snippet for a specific remediation.
685
+
686
+ Args:
687
+ source_name: Name of the source asset
688
+ target_name: Name of the target asset
689
+ relationship_type: Type of relationship (e.g., "CAN_ASSUME", "ALLOWS_TRAFFIC_TO")
690
+ snapshot_id: Optional snapshot ID (default: latest)
691
+
692
+ Returns:
693
+ Terraform HCL code snippet for the remediation.
694
+ """
695
+ snapshot = session.get_snapshot(snapshot_id)
696
+ if not snapshot:
697
+ return mcp_error(
698
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
699
+ )
700
+
701
+ assets = session.get_assets(snapshot_id)
702
+ session.set_snapshot(snapshot_id)
703
+
704
+ # Find source and target assets to get ARNs
705
+ source_asset = None
706
+ target_asset = None
707
+ for a in assets:
708
+ if a.name and source_name.lower() in a.name.lower():
709
+ source_asset = a
710
+ if a.name and target_name.lower() in a.name.lower():
711
+ target_asset = a
712
+
713
+ terraform_code = _terraform_snippet(
714
+ action="restrict",
715
+ source=source_name,
716
+ target=target_name,
717
+ relationship_type=relationship_type.upper(),
718
+ source_arn=source_asset.arn if source_asset else None,
719
+ target_arn=target_asset.arn if target_asset else None,
720
+ )
721
+
722
+ return {
723
+ "source": source_name,
724
+ "target": target_name,
725
+ "relationship_type": relationship_type,
726
+ "terraform": terraform_code,
727
+ "note": "Review and customize this snippet before applying. This is a starting point.",
728
+ }
729
+
730
+ @mcp.tool()
731
+ def get_unused_permissions(
732
+ days_threshold: int = 90, snapshot_id: str | None = None
733
+ ) -> dict[str, Any]:
734
+ """
735
+ Find unused IAM permissions (blast radius reduction opportunities).
736
+
737
+ Args:
738
+ days_threshold: Days of inactivity to consider unused
739
+
740
+ Returns:
741
+ Unused permissions grouped by role with reduction percentages.
742
+ """
743
+ snapshot = session.get_snapshot(snapshot_id)
744
+ if not snapshot:
745
+ return mcp_error(
746
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
747
+ )
748
+
749
+ assets = session.get_assets(snapshot_id)
750
+ session.set_snapshot(snapshot_id)
751
+
752
+ # WasteAnalyzer takes only days_threshold, then analyze_from_assets takes assets
753
+ analyzer = WasteAnalyzer(days_threshold=days_threshold)
754
+ report = analyzer.analyze_from_assets(assets=assets)
755
+
756
+ return {
757
+ "total_unused": report.total_unused,
758
+ "total_reduction": float(report.blast_radius_reduction),
759
+ "roles": [
760
+ {
761
+ "role_name": r.role_name,
762
+ "unused_count": r.unused_services,
763
+ "blast_radius_reduction": float(r.blast_radius_reduction),
764
+ }
765
+ for r in report.role_reports[:10]
766
+ ],
767
+ }
768
+
769
+ @mcp.tool()
770
+ def check_compliance(
771
+ framework: str = "cis-aws", snapshot_id: str | None = None
772
+ ) -> dict[str, Any]:
773
+ """
774
+ Check compliance against CIS AWS or SOC 2 framework.
775
+
776
+ Args:
777
+ framework: "cis-aws" or "soc2"
778
+
779
+ Returns:
780
+ Compliance score and failing controls.
781
+ """
782
+ snapshot = session.get_snapshot(snapshot_id)
783
+ if not snapshot:
784
+ return mcp_error(
785
+ MCP_ERROR_SNAPSHOT_NOT_FOUND, "No scan data found. Run 'cyntrisec scan' first."
786
+ )
787
+
788
+ findings = session.get_findings(snapshot_id)
789
+ assets = session.get_assets(snapshot_id)
790
+ session.set_snapshot(snapshot_id)
791
+
792
+ fw = Framework.CIS_AWS if "cis" in framework.lower() else Framework.SOC2
793
+ checker = ComplianceChecker()
794
+ report = checker.check(findings, assets, framework=fw, collection_errors=snapshot.errors)
795
+ summary = checker.summary(report)
796
+
797
+ return {
798
+ "framework": fw.value,
799
+ "compliance_score": summary["compliance_score"],
800
+ "passing": summary["passing"],
801
+ "failing": summary["failing"],
802
+ "failing_controls": [
803
+ {"id": r.control.id, "title": r.control.title}
804
+ for r in report.results
805
+ if not r.is_passing
806
+ ],
807
+ }
808
+
809
+ @mcp.tool()
810
+ def compare_scans() -> dict[str, Any]:
811
+ """
812
+ Compare latest scan to previous scan.
813
+
814
+ Returns:
815
+ Changes in assets, relationships, and attack paths.
816
+ """
817
+ scan_ids = session.storage.list_scans()
818
+
819
+ if len(scan_ids) < 2:
820
+ return mcp_error(MCP_ERROR_INSUFFICIENT_DATA, "Need at least 2 scans to compare.")
821
+
822
+ new_id, old_id = scan_ids[0], scan_ids[1]
823
+
824
+ old_snapshot = session.storage.get_snapshot(old_id)
825
+ new_snapshot = session.storage.get_snapshot(new_id)
826
+ if not old_snapshot or not new_snapshot:
827
+ return mcp_error(MCP_ERROR_SNAPSHOT_NOT_FOUND, "Could not load snapshots for comparison.")
828
+
829
+ differ = SnapshotDiff()
830
+ result = differ.diff(
831
+ old_assets=session.storage.get_assets(old_id),
832
+ old_relationships=session.storage.get_relationships(old_id),
833
+ old_paths=session.storage.get_attack_paths(old_id),
834
+ old_findings=session.storage.get_findings(old_id),
835
+ new_assets=session.storage.get_assets(new_id),
836
+ new_relationships=session.storage.get_relationships(new_id),
837
+ new_paths=session.storage.get_attack_paths(new_id),
838
+ new_findings=session.storage.get_findings(new_id),
839
+ old_snapshot_id=old_snapshot.id,
840
+ new_snapshot_id=new_snapshot.id,
841
+ )
842
+
843
+ return {
844
+ "has_regressions": result.has_regressions,
845
+ "has_improvements": result.has_improvements,
846
+ "summary": result.summary,
847
+ }
848
+
849
+
850
+ def run_mcp_server():
851
+ """Run the MCP server with stdio transport."""
852
+ if not HAS_MCP:
853
+ print("Error: MCP SDK not installed. Run: pip install mcp", file=sys.stderr)
854
+ sys.exit(1)
855
+
856
+ # Configure logging to stderr to avoid corrupting stdio
857
+ logging.basicConfig(
858
+ level=logging.WARNING, stream=sys.stderr, format="%(levelname)s: %(message)s"
859
+ )
860
+
861
+ mcp = create_mcp_server()
862
+ mcp.run(transport="stdio")