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/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
|
cyntrisec/cli/errors.py
ADDED
|
@@ -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
|