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
cyntrisec/cli/cuts.py ADDED
@@ -0,0 +1,231 @@
1
+ """
2
+ cuts command - Find minimal remediations that block attack paths.
3
+
4
+ Usage:
5
+ cyntrisec cuts [OPTIONS]
6
+
7
+ Examples:
8
+ cyntrisec cuts # Show top 5 remediations
9
+ cyntrisec cuts --max-cuts 10 # Show top 10
10
+ cyntrisec cuts --format json # Machine-readable output
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import typer
16
+ from rich import box
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+
21
+ from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
22
+ from cyntrisec.cli.output import (
23
+ build_artifact_paths,
24
+ emit_agent_or_json,
25
+ resolve_format,
26
+ suggested_actions,
27
+ )
28
+ from cyntrisec.cli.schemas import CutsResponse
29
+ from cyntrisec.core.cost_estimator import CostEstimator
30
+ from cyntrisec.core.cuts import MinCutFinder
31
+ from cyntrisec.core.graph import GraphBuilder
32
+ from cyntrisec.storage import FileSystemStorage
33
+
34
+ console = Console()
35
+
36
+
37
+ @handle_errors
38
+ def cuts_cmd(
39
+ max_cuts: int = typer.Option(
40
+ 5,
41
+ "--max-cuts",
42
+ "-n",
43
+ help="Maximum number of remediations to return",
44
+ ),
45
+ format: str | None = typer.Option(
46
+ None,
47
+ "--format",
48
+ "-f",
49
+ help="Output format: table, json, agent (defaults to json when piped)",
50
+ ),
51
+ snapshot_id: str | None = typer.Option(
52
+ None,
53
+ "--snapshot",
54
+ "-s",
55
+ help="Snapshot UUID (default: latest; scan_id accepted)",
56
+ ),
57
+ cost_source: str = typer.Option(
58
+ "estimate",
59
+ "--cost-source",
60
+ help="Cost data source: estimate (static), pricing-api, cost-explorer",
61
+ ),
62
+ ):
63
+ """
64
+ Find minimal remediations that block the most attack paths.
65
+
66
+ Uses a greedy set-cover algorithm to identify the smallest set of
67
+ changes that would disconnect entry points from sensitive targets.
68
+
69
+ Exit codes:
70
+ 0 - No attack paths (nothing to cut)
71
+ 0 - Remediations found
72
+ 2 - Error
73
+ """
74
+ output_format = resolve_format(
75
+ format,
76
+ default_tty="table",
77
+ allowed=["table", "json", "agent"],
78
+ )
79
+
80
+ storage = FileSystemStorage()
81
+
82
+ # Load data from storage
83
+ assets = storage.get_assets(snapshot_id)
84
+ relationships = storage.get_relationships(snapshot_id)
85
+ paths = storage.get_attack_paths(snapshot_id)
86
+ snapshot = storage.get_snapshot(snapshot_id)
87
+
88
+ if not assets or not snapshot:
89
+ raise CyntriError(
90
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
91
+ message="No scan data found. Run 'cyntrisec scan' first.",
92
+ exit_code=EXIT_CODE_MAP["usage"],
93
+ )
94
+
95
+ if not paths:
96
+ console.print("[green]No attack paths found - nothing to remediate.[/green]")
97
+ raise typer.Exit(0)
98
+
99
+ # Build graph and find cuts
100
+ graph = GraphBuilder().build(assets=assets, relationships=relationships)
101
+ finder = MinCutFinder()
102
+ result = finder.find_cuts(graph, paths, max_cuts=max_cuts)
103
+
104
+ if output_format in {"json", "agent"}:
105
+ cost_estimator = CostEstimator(source=cost_source)
106
+ payload = _build_payload(result, snapshot, graph, cost_estimator)
107
+ top_rem = result.remediations[0] if result.remediations else None
108
+ scan_id = storage.resolve_scan_id(snapshot_id)
109
+ followups = suggested_actions(
110
+ [
111
+ (
112
+ f"cyntrisec can {top_rem.source_name} access {top_rem.target_name}"
113
+ if top_rem
114
+ else "",
115
+ "Verify the highest-priority remediation closes access" if top_rem else "",
116
+ ),
117
+ (
118
+ f"cyntrisec report --scan {scan_id}" if scan_id else "",
119
+ "Export a full report for stakeholders" if scan_id else "",
120
+ ),
121
+ ]
122
+ )
123
+ artifact_paths = build_artifact_paths(storage, snapshot_id)
124
+ emit_agent_or_json(
125
+ output_format,
126
+ payload,
127
+ suggested=followups,
128
+ artifact_paths=artifact_paths,
129
+ schema=CutsResponse,
130
+ )
131
+ else:
132
+ _output_table(result, snapshot)
133
+
134
+
135
+ def _output_table(result, snapshot):
136
+ """Display results as a rich table."""
137
+ # Header panel
138
+ console.print()
139
+ console.print(
140
+ Panel(
141
+ f"[bold]Minimal Cut Analysis[/bold]\n"
142
+ f"Account: {snapshot.aws_account_id if snapshot else 'unknown'}\n"
143
+ f"Attack Paths: {result.total_paths} -> "
144
+ f"[green]{result.paths_blocked} blocked[/green] "
145
+ f"({result.coverage:.0%} coverage)",
146
+ title="cyntrisec cuts",
147
+ border_style="cyan",
148
+ )
149
+ )
150
+ console.print()
151
+
152
+ if not result.remediations:
153
+ console.print("[yellow]No remediations found that would block attack paths.[/yellow]")
154
+ return
155
+
156
+ # Main table
157
+ table = Table(
158
+ title=f"Top {len(result.remediations)} Remediations",
159
+ box=box.ROUNDED,
160
+ show_header=True,
161
+ header_style="bold cyan",
162
+ )
163
+ table.add_column("#", style="dim", width=3)
164
+ table.add_column("Blocks", justify="right", style="green", width=8)
165
+ table.add_column("Action", style="yellow", width=15)
166
+ table.add_column("Remediation", style="white", min_width=40)
167
+
168
+ for i, rem in enumerate(result.remediations, 1):
169
+ table.add_row(
170
+ str(i),
171
+ f"{len(rem.paths_blocked)} paths",
172
+ rem.action,
173
+ rem.description,
174
+ )
175
+
176
+ console.print(table)
177
+ console.print()
178
+
179
+ # Summary
180
+ if result.coverage < 1.0:
181
+ remaining = result.total_paths - result.paths_blocked
182
+ console.print(
183
+ f"[yellow]{remaining} paths remain unblocked. "
184
+ f"Increase --max-cuts to find more.[/yellow]"
185
+ )
186
+ else:
187
+ console.print(
188
+ f"[green]All {result.total_paths} attack paths can be blocked "
189
+ f"with {len(result.remediations)} changes.[/green]"
190
+ )
191
+
192
+
193
+ def _build_payload(result, snapshot, graph=None, cost_estimator=None):
194
+ """Build structured output for JSON/agent modes."""
195
+ remediations = []
196
+
197
+ for i, rem in enumerate(result.remediations):
198
+ rem_dict = {
199
+ "priority": i + 1,
200
+ "action": rem.action,
201
+ "description": rem.description,
202
+ "relationship_type": rem.relationship_type,
203
+ "source": rem.source_name,
204
+ "target": rem.target_name,
205
+ "paths_blocked": len(rem.paths_blocked),
206
+ "path_ids": [str(p) for p in rem.paths_blocked],
207
+ }
208
+
209
+ # Add cost estimate for target asset if available
210
+ if cost_estimator and graph:
211
+ target_asset = graph.asset(rem.relationship.target_asset_id)
212
+ if target_asset:
213
+ estimate = cost_estimator.estimate(target_asset)
214
+ if estimate:
215
+ rem_dict["estimated_monthly_savings"] = float(
216
+ estimate.monthly_cost_usd_estimate
217
+ )
218
+ rem_dict["cost_source"] = estimate.cost_source
219
+ rem_dict["cost_confidence"] = estimate.confidence
220
+ rem_dict["cost_assumptions"] = estimate.assumptions
221
+
222
+ remediations.append(rem_dict)
223
+
224
+ return {
225
+ "snapshot_id": str(snapshot.id) if snapshot else None,
226
+ "account_id": snapshot.aws_account_id if snapshot else None,
227
+ "total_paths": result.total_paths,
228
+ "paths_blocked": result.paths_blocked,
229
+ "coverage": result.coverage,
230
+ "remediations": remediations,
231
+ }
cyntrisec/cli/diff.py ADDED
@@ -0,0 +1,332 @@
1
+ """
2
+ diff command - Compare two scan snapshots to detect changes.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ import typer
10
+ from rich import box
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+
15
+ from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
16
+ from cyntrisec.cli.output import (
17
+ build_artifact_paths,
18
+ emit_agent_or_json,
19
+ resolve_format,
20
+ suggested_actions,
21
+ )
22
+ from cyntrisec.cli.schemas import DiffResponse
23
+ from cyntrisec.core.diff import ChangeType, SnapshotDiff
24
+ from cyntrisec.storage import FileSystemStorage
25
+
26
+ console = Console()
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ def _find_scan_id(scan_ids: list[str], partial: str) -> str:
31
+ """Find a scan ID by partial match."""
32
+ if partial in scan_ids:
33
+ return partial
34
+ for scan_id in scan_ids:
35
+ if partial in scan_id:
36
+ return scan_id
37
+ return partial
38
+
39
+
40
+ @handle_errors
41
+ def diff_cmd(
42
+ old_snapshot: str | None = typer.Option(
43
+ None,
44
+ "--old",
45
+ "-o",
46
+ help="Old snapshot ID (default: second most recent)",
47
+ ),
48
+ new_snapshot: str | None = typer.Option(
49
+ None,
50
+ "--new",
51
+ "-n",
52
+ help="New snapshot ID (default: most recent)",
53
+ ),
54
+ format: str | None = typer.Option(
55
+ None,
56
+ "--format",
57
+ "-f",
58
+ help="Output format: table, json, agent (defaults to json when piped)",
59
+ ),
60
+ show_all: bool = typer.Option(
61
+ False,
62
+ "--all",
63
+ "-a",
64
+ help="Show all changes including assets and relationships",
65
+ ),
66
+ ):
67
+ """
68
+ Compare two scan snapshots to detect changes.
69
+ """
70
+ output_format = resolve_format(
71
+ format,
72
+ default_tty="table",
73
+ allowed=["table", "json", "agent"],
74
+ )
75
+
76
+ storage = FileSystemStorage()
77
+ scan_ids = storage.list_scans()
78
+ if len(scan_ids) < 2:
79
+ raise CyntriError(
80
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
81
+ message="Need at least 2 scans to compare. Run 'cyntrisec scan' again.",
82
+ exit_code=EXIT_CODE_MAP["usage"],
83
+ )
84
+
85
+ new_scan_id = _find_scan_id(scan_ids, new_snapshot) if new_snapshot else scan_ids[0]
86
+ old_scan_id = _find_scan_id(scan_ids, old_snapshot) if old_snapshot else scan_ids[1]
87
+
88
+ old_snap = storage.get_snapshot(old_scan_id)
89
+ new_snap = storage.get_snapshot(new_scan_id)
90
+ if not old_snap or not new_snap:
91
+ raise CyntriError(
92
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
93
+ message="Could not load snapshots for diff.",
94
+ exit_code=EXIT_CODE_MAP["usage"],
95
+ )
96
+
97
+ old_assets = storage.get_assets(old_scan_id)
98
+ old_rels = storage.get_relationships(old_scan_id)
99
+ old_paths = storage.get_attack_paths(old_scan_id)
100
+ old_findings = storage.get_findings(old_scan_id)
101
+
102
+ new_assets = storage.get_assets(new_scan_id)
103
+ new_rels = storage.get_relationships(new_scan_id)
104
+ new_paths = storage.get_attack_paths(new_scan_id)
105
+ new_findings = storage.get_findings(new_scan_id)
106
+
107
+ differ = SnapshotDiff()
108
+ result = differ.diff(
109
+ old_assets=old_assets,
110
+ old_relationships=old_rels,
111
+ old_paths=old_paths,
112
+ old_findings=old_findings,
113
+ new_assets=new_assets,
114
+ new_relationships=new_rels,
115
+ new_paths=new_paths,
116
+ new_findings=new_findings,
117
+ old_snapshot_id=old_snap.id,
118
+ new_snapshot_id=new_snap.id,
119
+ )
120
+
121
+ if output_format in {"json", "agent"}:
122
+ payload = _build_payload(result, old_snap, new_snap, show_all)
123
+ actions = suggested_actions(
124
+ [
125
+ (
126
+ f"cyntrisec analyze paths --scan {new_scan_id}",
127
+ "Review new attack paths",
128
+ ),
129
+ (
130
+ f"cyntrisec cuts --snapshot {new_snap.id}",
131
+ "Find fixes for new regressions",
132
+ ),
133
+ ]
134
+ )
135
+ emit_agent_or_json(
136
+ output_format,
137
+ payload,
138
+ suggested=actions,
139
+ status="regressions" if result.has_regressions else "success",
140
+ artifact_paths=build_artifact_paths(storage, new_scan_id),
141
+ schema=DiffResponse,
142
+ )
143
+ else:
144
+ _output_table(result, old_snap, new_snap, show_all)
145
+
146
+ if result.has_regressions:
147
+ raise typer.Exit(1)
148
+ raise typer.Exit(0)
149
+
150
+
151
+ def _output_table(result, old_snap, new_snap, show_all: bool):
152
+ """Display diff as formatted tables."""
153
+ console.print()
154
+
155
+ summary = result.summary
156
+
157
+ if result.has_regressions:
158
+ status_icon = "REGRESSION"
159
+ status_color = "red"
160
+ status_text = "REGRESSIONS DETECTED"
161
+ elif result.has_improvements:
162
+ status_icon = "IMPROVED"
163
+ status_color = "green"
164
+ status_text = "IMPROVEMENTS FOUND"
165
+ else:
166
+ status_icon = "="
167
+ status_color = "cyan"
168
+ status_text = "NO SECURITY CHANGES"
169
+
170
+ console.print(
171
+ Panel(
172
+ f"[bold {status_color}]{status_icon} {status_text}[/bold {status_color}]\n\n"
173
+ f"Comparing:\n"
174
+ f" Old: {old_snap.aws_account_id} @ {old_snap.started_at:%Y-%m-%d %H:%M}\n"
175
+ f" New: {new_snap.aws_account_id} @ {new_snap.started_at:%Y-%m-%d %H:%M}",
176
+ title="cyntrisec diff",
177
+ border_style=status_color,
178
+ )
179
+ )
180
+ console.print()
181
+
182
+ summary_table = Table(box=box.ROUNDED, show_header=True, header_style="bold")
183
+ summary_table.add_column("Category", style="cyan")
184
+ summary_table.add_column("Added", justify="right", style="green")
185
+ summary_table.add_column("Removed", justify="right", style="red")
186
+
187
+ summary_table.add_row("Assets", f"+{summary['assets_added']}", f"-{summary['assets_removed']}")
188
+ summary_table.add_row(
189
+ "Relationships",
190
+ f"+{summary['relationships_added']}",
191
+ f"-{summary['relationships_removed']}",
192
+ )
193
+ summary_table.add_row(
194
+ "Attack Paths", f"+{summary['paths_added']}", f"-{summary['paths_removed']}"
195
+ )
196
+ summary_table.add_row(
197
+ "Findings", f"+{summary['findings_new']}", f"-{summary['findings_resolved']}"
198
+ )
199
+
200
+ console.print(summary_table)
201
+ console.print()
202
+
203
+ if result.path_changes:
204
+ path_table = Table(
205
+ title="Attack Path Changes",
206
+ box=box.ROUNDED,
207
+ show_header=True,
208
+ header_style="bold yellow",
209
+ )
210
+ path_table.add_column("Status", width=12)
211
+ path_table.add_column("Vector", width=20)
212
+ path_table.add_column("Risk", justify="right", width=8)
213
+
214
+ for change in result.path_changes:
215
+ status = (
216
+ "[red]+ NEW (regression)[/red]"
217
+ if change.change_type == ChangeType.added
218
+ else "[green]- FIXED[/green]"
219
+ )
220
+ path_table.add_row(
221
+ status, change.path.attack_vector, f"{float(change.path.risk_score):.2f}"
222
+ )
223
+
224
+ console.print(path_table)
225
+ console.print()
226
+
227
+ if result.finding_changes:
228
+ finding_table = Table(title="Finding Changes", box=box.ROUNDED, show_header=True)
229
+ finding_table.add_column("Status", width=12)
230
+ finding_table.add_column("Severity", width=10)
231
+ finding_table.add_column("Finding", min_width=30)
232
+
233
+ for change in result.finding_changes:
234
+ status = (
235
+ "[red]+ NEW[/red]"
236
+ if change.change_type == ChangeType.added
237
+ else "[green]- FIXED[/green]"
238
+ )
239
+ sev_style = {
240
+ "critical": "red bold",
241
+ "high": "red",
242
+ "medium": "yellow",
243
+ "low": "dim",
244
+ }.get(change.finding.severity, "white")
245
+ finding_table.add_row(
246
+ status,
247
+ f"[{sev_style}]{change.finding.severity.upper()}[/]",
248
+ change.finding.title[:50],
249
+ )
250
+
251
+ console.print(finding_table)
252
+ console.print()
253
+
254
+ if show_all and result.asset_changes:
255
+ asset_table = Table(title="Asset Changes", box=box.SIMPLE)
256
+ asset_table.add_column("Status", width=8)
257
+ asset_table.add_column("Type", width=15)
258
+ asset_table.add_column("Name", min_width=30)
259
+
260
+ for change in result.asset_changes[:20]:
261
+ status = (
262
+ "[green]+[/green]" if change.change_type == ChangeType.added else "[red]-[/red]"
263
+ )
264
+ asset_table.add_row(status, change.asset.asset_type, change.asset.name[:40])
265
+
266
+ if len(result.asset_changes) > 20:
267
+ asset_table.add_row("...", f"+{len(result.asset_changes) - 20} more", "")
268
+
269
+ console.print(asset_table)
270
+ console.print()
271
+
272
+
273
+ def _build_payload(result, old_snap, new_snap, show_all: bool = False):
274
+ """Build structured output for JSON/agent formats."""
275
+ payload = {
276
+ "old_snapshot": {
277
+ "id": str(old_snap.id),
278
+ "account_id": old_snap.aws_account_id,
279
+ "timestamp": old_snap.started_at.isoformat(),
280
+ },
281
+ "new_snapshot": {
282
+ "id": str(new_snap.id),
283
+ "account_id": new_snap.aws_account_id,
284
+ "timestamp": new_snap.started_at.isoformat(),
285
+ },
286
+ "summary": result.summary,
287
+ "has_regressions": result.has_regressions,
288
+ "has_improvements": result.has_improvements,
289
+ "path_changes": [
290
+ {
291
+ "change_type": c.change_type.value,
292
+ "attack_vector": c.path.attack_vector,
293
+ "risk_score": float(c.path.risk_score),
294
+ "is_regression": c.is_regression,
295
+ "is_improvement": c.is_improvement,
296
+ }
297
+ for c in result.path_changes
298
+ ],
299
+ "finding_changes": [
300
+ {
301
+ "change_type": c.change_type.value,
302
+ "severity": c.finding.severity,
303
+ "title": c.finding.title,
304
+ "is_regression": c.is_regression,
305
+ }
306
+ for c in result.finding_changes
307
+ ],
308
+ }
309
+
310
+ # Include asset_changes and relationship_changes when show_all is True
311
+ if show_all:
312
+ payload["asset_changes"] = [
313
+ {
314
+ "change_type": c.change_type.value,
315
+ "asset_id": str(c.asset.id),
316
+ "asset_type": c.asset.asset_type,
317
+ "name": c.asset.name,
318
+ }
319
+ for c in result.asset_changes
320
+ ]
321
+ payload["relationship_changes"] = [
322
+ {
323
+ "change_type": c.change_type.value,
324
+ "relationship_id": str(c.relationship.id),
325
+ "relationship_type": c.relationship.relationship_type,
326
+ "source_id": str(c.relationship.source_asset_id),
327
+ "target_id": str(c.relationship.target_asset_id),
328
+ }
329
+ for c in result.relationship_changes
330
+ ]
331
+
332
+ return payload
@@ -0,0 +1,105 @@
1
+ """
2
+ Central error handling and envelope utilities.
3
+
4
+ Defines a small error taxonomy and helpers to wrap CLI commands so that
5
+ all failures return a consistent agent-friendly envelope.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import typer
16
+ from pydantic import ValidationError
17
+
18
+ from cyntrisec.cli.output import emit_agent_or_json
19
+
20
+
21
+ class ErrorCode:
22
+ AWS_ACCESS_DENIED = "AWS_ACCESS_DENIED"
23
+ AWS_THROTTLED = "AWS_THROTTLED"
24
+ AWS_REGION_DISABLED = "AWS_REGION_DISABLED"
25
+ AUTH_ERROR = "AUTH_ERROR"
26
+ SNAPSHOT_NOT_FOUND = "SNAPSHOT_NOT_FOUND"
27
+ SCHEMA_MISMATCH = "SCHEMA_MISMATCH"
28
+ INVALID_QUERY = "INVALID_QUERY"
29
+ INTERNAL_ERROR = "INTERNAL_ERROR"
30
+
31
+
32
+ EXIT_CODE_MAP = {
33
+ "ok": 0,
34
+ "findings": 1,
35
+ "usage": 2,
36
+ "transient": 3,
37
+ "internal": 4,
38
+ }
39
+
40
+
41
+ @dataclass
42
+ class CyntriError(Exception):
43
+ error_code: str
44
+ message: str
45
+ details: Any | None = None
46
+ hint: str | None = None
47
+ retryable: bool = False
48
+ status: str = "error"
49
+ exit_code: int = EXIT_CODE_MAP["internal"]
50
+
51
+ def to_payload(self) -> dict:
52
+ return {
53
+ "message": self.message,
54
+ "details": self.details,
55
+ "hint": self.hint,
56
+ }
57
+
58
+
59
+ def handle_errors(command: Callable) -> Callable:
60
+ """
61
+ Decorator to wrap Typer commands and emit consistent envelopes on failure.
62
+ """
63
+
64
+ @functools.wraps(command)
65
+ def wrapper(*args, **kwargs):
66
+ from typer.models import OptionInfo
67
+
68
+ clean_kwargs = {k: (None if isinstance(v, OptionInfo) else v) for k, v in kwargs.items()}
69
+ format_arg = clean_kwargs.get("format") or clean_kwargs.get("output_format")
70
+ try:
71
+ return command(*args, **clean_kwargs)
72
+ except CyntriError as ce:
73
+ emit_agent_or_json(
74
+ "agent" if format_arg in {"agent", "json"} else "json",
75
+ ce.to_payload(),
76
+ status=ce.status,
77
+ error_code=ce.error_code,
78
+ message=ce.message,
79
+ suggested=[],
80
+ )
81
+ raise typer.Exit(code=ce.exit_code)
82
+ except ValidationError as ve:
83
+ emit_agent_or_json(
84
+ "json",
85
+ {"errors": ve.errors()},
86
+ status="error",
87
+ error_code=ErrorCode.SCHEMA_MISMATCH,
88
+ message="Response schema validation failed",
89
+ suggested=[],
90
+ )
91
+ raise typer.Exit(code=EXIT_CODE_MAP["internal"])
92
+ except typer.Exit:
93
+ raise
94
+ except Exception as e: # pragma: no cover
95
+ emit_agent_or_json(
96
+ "json",
97
+ {"message": str(e)},
98
+ status="error",
99
+ error_code=ErrorCode.INTERNAL_ERROR,
100
+ message=str(e),
101
+ suggested=[],
102
+ )
103
+ raise typer.Exit(code=EXIT_CODE_MAP["internal"])
104
+
105
+ return wrapper