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/can.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
can command - Test if a principal can access a resource.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
cyntrisec can PRINCIPAL access RESOURCE [OPTIONS]
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
cyntrisec can ECforS access s3://secret-bucket
|
|
9
|
+
cyntrisec can arn:aws:iam::123:role/MyRole access s3://data-bucket
|
|
10
|
+
cyntrisec can MyRole access MyAdminRole --action sts:AssumeRole
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich import box
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
|
|
24
|
+
from cyntrisec.cli.output import emit_agent_or_json, resolve_format, suggested_actions
|
|
25
|
+
from cyntrisec.cli.schemas import CanResponse
|
|
26
|
+
from cyntrisec.storage import FileSystemStorage
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
status_console = Console(stderr=True)
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@handle_errors
|
|
34
|
+
def can_cmd(
|
|
35
|
+
principal: str = typer.Argument(
|
|
36
|
+
...,
|
|
37
|
+
help="IAM principal (role/user name or ARN)",
|
|
38
|
+
),
|
|
39
|
+
access: str = typer.Argument(
|
|
40
|
+
...,
|
|
41
|
+
help="Literal 'access' keyword",
|
|
42
|
+
),
|
|
43
|
+
resource: str = typer.Argument(
|
|
44
|
+
...,
|
|
45
|
+
help="Target resource (ARN, bucket name, or s3://path)",
|
|
46
|
+
),
|
|
47
|
+
action: str | None = typer.Option(
|
|
48
|
+
None,
|
|
49
|
+
"--action",
|
|
50
|
+
"-a",
|
|
51
|
+
help="Specific action to test (auto-detected if not provided)",
|
|
52
|
+
),
|
|
53
|
+
live: bool = typer.Option(
|
|
54
|
+
False,
|
|
55
|
+
"--live",
|
|
56
|
+
"-l",
|
|
57
|
+
help="Use AWS Policy Simulator API (requires IAM permissions)",
|
|
58
|
+
),
|
|
59
|
+
role_arn: str | None = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--role-arn",
|
|
62
|
+
"-r",
|
|
63
|
+
help="AWS role to assume for live simulation",
|
|
64
|
+
),
|
|
65
|
+
external_id: str | None = typer.Option(
|
|
66
|
+
None,
|
|
67
|
+
"--external-id",
|
|
68
|
+
"-e",
|
|
69
|
+
help="External ID for role assumption",
|
|
70
|
+
),
|
|
71
|
+
format: str | None = typer.Option(
|
|
72
|
+
None,
|
|
73
|
+
"--format",
|
|
74
|
+
"-f",
|
|
75
|
+
help="Output format: text, json, agent (defaults to json when piped)",
|
|
76
|
+
),
|
|
77
|
+
snapshot_id: str | None = typer.Option(
|
|
78
|
+
None,
|
|
79
|
+
"--snapshot",
|
|
80
|
+
"-s",
|
|
81
|
+
help="Snapshot UUID (default: latest; scan_id accepted)",
|
|
82
|
+
),
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Test if a principal can access a resource.
|
|
86
|
+
|
|
87
|
+
Uses natural language syntax: "can PRINCIPAL access RESOURCE"
|
|
88
|
+
|
|
89
|
+
Without --live, uses scan data and graph relationships.
|
|
90
|
+
With --live, queries AWS IAM Policy Simulator for ground truth.
|
|
91
|
+
|
|
92
|
+
Examples:
|
|
93
|
+
cyntrisec can ECforS access s3://bucket
|
|
94
|
+
cyntrisec can MyRole access arn:aws:secretsmanager:...:secret
|
|
95
|
+
cyntrisec can AdminRole access ProdDB --action rds:CreateDBSnapshot
|
|
96
|
+
"""
|
|
97
|
+
if access.lower() != "access":
|
|
98
|
+
raise CyntriError(
|
|
99
|
+
error_code=ErrorCode.INVALID_QUERY,
|
|
100
|
+
message="Expected 'access' keyword. Usage: cyntrisec can PRINCIPAL access RESOURCE",
|
|
101
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
output_format = resolve_format(
|
|
105
|
+
format,
|
|
106
|
+
default_tty="text",
|
|
107
|
+
allowed=["text", "json", "agent"],
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
storage = FileSystemStorage()
|
|
111
|
+
assets = storage.get_assets(snapshot_id)
|
|
112
|
+
relationships = storage.get_relationships(snapshot_id)
|
|
113
|
+
snapshot = storage.get_snapshot(snapshot_id)
|
|
114
|
+
|
|
115
|
+
if not assets or not snapshot:
|
|
116
|
+
raise CyntriError(
|
|
117
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
|
|
118
|
+
message="No scan data found. Run 'cyntrisec scan' first.",
|
|
119
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Resolve principal to ARN
|
|
123
|
+
principal_arn = _resolve_principal(principal, assets)
|
|
124
|
+
|
|
125
|
+
if live:
|
|
126
|
+
try:
|
|
127
|
+
live_console = console if output_format == "text" else status_console
|
|
128
|
+
result = _simulate_live(
|
|
129
|
+
principal_arn,
|
|
130
|
+
resource,
|
|
131
|
+
action,
|
|
132
|
+
role_arn,
|
|
133
|
+
external_id,
|
|
134
|
+
status_console=live_console,
|
|
135
|
+
)
|
|
136
|
+
except PermissionError as e:
|
|
137
|
+
raise CyntriError(
|
|
138
|
+
error_code=ErrorCode.AWS_ACCESS_DENIED,
|
|
139
|
+
message=str(e),
|
|
140
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
result = _simulate_offline(principal_arn, resource, action, assets, relationships)
|
|
144
|
+
|
|
145
|
+
# Get scan_id and snapshot_id for suggested actions
|
|
146
|
+
scan_id = storage.resolve_scan_id(snapshot_id)
|
|
147
|
+
snapshot_uuid = str(snapshot.id) if snapshot else None
|
|
148
|
+
|
|
149
|
+
if output_format in {"json", "agent"}:
|
|
150
|
+
payload = _build_payload(result, snapshot)
|
|
151
|
+
followups = suggested_actions(
|
|
152
|
+
[
|
|
153
|
+
(
|
|
154
|
+
f"cyntrisec cuts --snapshot {snapshot_uuid}"
|
|
155
|
+
if snapshot_uuid and result.can_access
|
|
156
|
+
else "",
|
|
157
|
+
"Identify changes that would block this access"
|
|
158
|
+
if snapshot_uuid and result.can_access
|
|
159
|
+
else "",
|
|
160
|
+
),
|
|
161
|
+
(
|
|
162
|
+
f"cyntrisec analyze paths --scan {scan_id}"
|
|
163
|
+
if scan_id and not result.can_access
|
|
164
|
+
else "",
|
|
165
|
+
"Review other risky paths from the latest scan"
|
|
166
|
+
if scan_id and not result.can_access
|
|
167
|
+
else "",
|
|
168
|
+
),
|
|
169
|
+
(
|
|
170
|
+
f"cyntrisec can {principal_arn} access {resource} --live" if not live else "",
|
|
171
|
+
"Validate against live IAM policy simulation" if not live else "",
|
|
172
|
+
),
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
emit_agent_or_json(output_format, payload, suggested=followups, schema=CanResponse)
|
|
176
|
+
else:
|
|
177
|
+
_output_text(result, snapshot)
|
|
178
|
+
|
|
179
|
+
# Exit with code based on result
|
|
180
|
+
raise typer.Exit(0 if result.can_access else 1)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _resolve_principal(principal: str, assets) -> str:
|
|
184
|
+
"""Resolve principal name to ARN."""
|
|
185
|
+
if principal.startswith("arn:"):
|
|
186
|
+
return principal
|
|
187
|
+
|
|
188
|
+
# Find by name in assets
|
|
189
|
+
for asset in assets:
|
|
190
|
+
if asset.asset_type == "iam:role" and asset.name == principal:
|
|
191
|
+
return asset.arn or asset.aws_resource_id
|
|
192
|
+
if asset.asset_type == "iam:user" and asset.name == principal:
|
|
193
|
+
return asset.arn or asset.aws_resource_id
|
|
194
|
+
|
|
195
|
+
# Return as-is and let the simulator handle it
|
|
196
|
+
return principal
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _simulate_live(principal_arn, resource, action, role_arn, external_id, *, status_console):
|
|
200
|
+
"""Run live simulation using AWS API."""
|
|
201
|
+
from cyntrisec.aws import CredentialProvider
|
|
202
|
+
from cyntrisec.core.simulator import PolicySimulator
|
|
203
|
+
|
|
204
|
+
status_console.print("[cyan]Running live policy simulation...[/cyan]")
|
|
205
|
+
|
|
206
|
+
provider = CredentialProvider()
|
|
207
|
+
if role_arn:
|
|
208
|
+
session = provider.assume_role(role_arn, external_id=external_id)
|
|
209
|
+
else:
|
|
210
|
+
session = provider.default_session()
|
|
211
|
+
|
|
212
|
+
simulator = PolicySimulator(session)
|
|
213
|
+
return simulator.can_access(principal_arn, resource, action=action)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _simulate_offline(principal_arn, resource, action, assets, relationships):
|
|
217
|
+
"""Run offline simulation using scan data."""
|
|
218
|
+
from cyntrisec.core.simulator import OfflineSimulator
|
|
219
|
+
|
|
220
|
+
simulator = OfflineSimulator(assets, relationships)
|
|
221
|
+
return simulator.can_access(principal_arn, resource, action=action)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _output_text(result, snapshot):
|
|
225
|
+
"""Display result as formatted text."""
|
|
226
|
+
console.print()
|
|
227
|
+
|
|
228
|
+
if result.can_access:
|
|
229
|
+
icon = "ALLOW"
|
|
230
|
+
color = "green"
|
|
231
|
+
status = "YES"
|
|
232
|
+
else:
|
|
233
|
+
icon = "DENY"
|
|
234
|
+
color = "red"
|
|
235
|
+
status = "NO"
|
|
236
|
+
|
|
237
|
+
console.print(
|
|
238
|
+
Panel(
|
|
239
|
+
f"[bold {color}]{icon} {status}[/bold {color}]: "
|
|
240
|
+
f"[white]{result.principal_arn.split('/')[-1]}[/white] can "
|
|
241
|
+
f"{'access' if result.can_access else '[dim]NOT[/dim] access'} "
|
|
242
|
+
f"[cyan]{result.target_resource}[/cyan]",
|
|
243
|
+
title="cyntrisec can",
|
|
244
|
+
border_style=color,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# Show proof
|
|
249
|
+
if result.simulations:
|
|
250
|
+
console.print()
|
|
251
|
+
table = Table(box=box.ROUNDED, show_header=True, header_style="bold")
|
|
252
|
+
table.add_column("Action", style="cyan")
|
|
253
|
+
table.add_column("Decision", width=15)
|
|
254
|
+
table.add_column("Matched", justify="right")
|
|
255
|
+
|
|
256
|
+
for sim in result.simulations:
|
|
257
|
+
if sim.decision.value == "allowed":
|
|
258
|
+
decision = "[green]ALLOWED[/green]"
|
|
259
|
+
elif sim.decision.value == "explicitDeny":
|
|
260
|
+
decision = "[red]EXPLICIT DENY[/red]"
|
|
261
|
+
else:
|
|
262
|
+
decision = "[yellow]IMPLICIT DENY[/yellow]"
|
|
263
|
+
|
|
264
|
+
table.add_row(
|
|
265
|
+
sim.action,
|
|
266
|
+
decision,
|
|
267
|
+
str(len(sim.matched_statements)),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
console.print(table)
|
|
271
|
+
elif result.proof:
|
|
272
|
+
console.print()
|
|
273
|
+
console.print(f"[dim]Via: {result.proof.get('relationship_type', 'graph analysis')}[/dim]")
|
|
274
|
+
|
|
275
|
+
console.print()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _build_payload(result, snapshot):
|
|
279
|
+
"""Build structured output for JSON/agent formats."""
|
|
280
|
+
payload = {
|
|
281
|
+
"snapshot_id": str(snapshot.id) if snapshot else None,
|
|
282
|
+
"principal": result.principal_arn,
|
|
283
|
+
"resource": result.target_resource,
|
|
284
|
+
"action": result.action,
|
|
285
|
+
"can_access": result.can_access,
|
|
286
|
+
"simulations": [
|
|
287
|
+
{
|
|
288
|
+
"action": s.action,
|
|
289
|
+
"resource": s.resource,
|
|
290
|
+
"decision": s.decision.value,
|
|
291
|
+
"matched_statements": len(s.matched_statements),
|
|
292
|
+
}
|
|
293
|
+
for s in result.simulations
|
|
294
|
+
]
|
|
295
|
+
if result.simulations
|
|
296
|
+
else [],
|
|
297
|
+
"proof": result.proof,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
# Add mode and disclaimer for offline simulation
|
|
301
|
+
if not result.simulations:
|
|
302
|
+
payload["mode"] = "offline"
|
|
303
|
+
payload["disclaimer"] = "Offline results are based on graph relationships only. Use --live for authoritative policy simulation."
|
|
304
|
+
else:
|
|
305
|
+
payload["mode"] = "live"
|
|
306
|
+
|
|
307
|
+
return payload
|
cyntrisec/cli/comply.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
comply command - Check compliance against security frameworks.
|
|
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.progress import BarColumn, Progress, TextColumn
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
|
|
17
|
+
from cyntrisec.cli.output import (
|
|
18
|
+
build_artifact_paths,
|
|
19
|
+
emit_agent_or_json,
|
|
20
|
+
resolve_format,
|
|
21
|
+
suggested_actions,
|
|
22
|
+
)
|
|
23
|
+
from cyntrisec.cli.schemas import ComplyResponse
|
|
24
|
+
from cyntrisec.core.compliance import ComplianceChecker, Framework
|
|
25
|
+
from cyntrisec.storage import FileSystemStorage
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@handle_errors
|
|
32
|
+
def comply_cmd(
|
|
33
|
+
framework: str = typer.Option(
|
|
34
|
+
"cis-aws",
|
|
35
|
+
"--framework",
|
|
36
|
+
"-fw",
|
|
37
|
+
help="Compliance framework: cis-aws, soc2",
|
|
38
|
+
),
|
|
39
|
+
format: str | None = typer.Option(
|
|
40
|
+
None,
|
|
41
|
+
"--format",
|
|
42
|
+
"-f",
|
|
43
|
+
help="Output format: table, json, agent (defaults to json when piped)",
|
|
44
|
+
),
|
|
45
|
+
show_passing: bool = typer.Option(
|
|
46
|
+
False,
|
|
47
|
+
"--show-passing",
|
|
48
|
+
"-p",
|
|
49
|
+
help="Show passing controls (default: only failing)",
|
|
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
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
Check compliance against security frameworks.
|
|
60
|
+
"""
|
|
61
|
+
output_format = resolve_format(
|
|
62
|
+
format,
|
|
63
|
+
default_tty="table",
|
|
64
|
+
allowed=["table", "json", "agent"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
storage = FileSystemStorage()
|
|
68
|
+
findings = storage.get_findings(snapshot_id)
|
|
69
|
+
assets = storage.get_assets(snapshot_id)
|
|
70
|
+
snapshot = storage.get_snapshot(snapshot_id)
|
|
71
|
+
|
|
72
|
+
if not snapshot:
|
|
73
|
+
raise CyntriError(
|
|
74
|
+
error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
|
|
75
|
+
message="No scan data found. Run 'cyntrisec scan' first.",
|
|
76
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Map CLI string to Framework enum
|
|
80
|
+
framework_map = {
|
|
81
|
+
"cis-aws": Framework.CIS_AWS,
|
|
82
|
+
"cis_aws": Framework.CIS_AWS,
|
|
83
|
+
"CIS-AWS": Framework.CIS_AWS,
|
|
84
|
+
"soc2": Framework.SOC2,
|
|
85
|
+
"SOC2": Framework.SOC2,
|
|
86
|
+
}
|
|
87
|
+
fw = framework_map.get(framework)
|
|
88
|
+
if not fw:
|
|
89
|
+
raise CyntriError(
|
|
90
|
+
error_code=ErrorCode.INVALID_QUERY,
|
|
91
|
+
message=f"Invalid framework: {framework}. Use 'cis-aws' or 'soc2'.",
|
|
92
|
+
exit_code=EXIT_CODE_MAP["usage"],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
checker = ComplianceChecker()
|
|
96
|
+
results = checker.check(findings, assets, framework=fw, collection_errors=snapshot.errors)
|
|
97
|
+
|
|
98
|
+
# Get scan_id for suggested actions
|
|
99
|
+
scan_id = storage.resolve_scan_id(snapshot_id)
|
|
100
|
+
|
|
101
|
+
if output_format in {"json", "agent"}:
|
|
102
|
+
payload = _build_payload(results, fw, snapshot, show_passing)
|
|
103
|
+
# Generate appropriate suggested actions based on compliance status
|
|
104
|
+
if results.failing > 0:
|
|
105
|
+
first_failing = next((r for r in results.results if r.status != "pass"), None)
|
|
106
|
+
actions = suggested_actions(
|
|
107
|
+
[
|
|
108
|
+
(
|
|
109
|
+
f"cyntrisec explain control {first_failing.control.id}"
|
|
110
|
+
if first_failing
|
|
111
|
+
else "",
|
|
112
|
+
"Explain top failing control" if first_failing else "",
|
|
113
|
+
),
|
|
114
|
+
(
|
|
115
|
+
f"cyntrisec cuts --snapshot {snapshot.id}" if snapshot else "",
|
|
116
|
+
"Map compliance fixes to attack path cuts" if snapshot else "",
|
|
117
|
+
),
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
# Clean scan - no failing controls
|
|
122
|
+
actions = suggested_actions(
|
|
123
|
+
[
|
|
124
|
+
(
|
|
125
|
+
f"cyntrisec diff --old {scan_id}" if scan_id else "",
|
|
126
|
+
"Monitor for compliance drift" if scan_id else "",
|
|
127
|
+
),
|
|
128
|
+
(
|
|
129
|
+
"cyntrisec scan",
|
|
130
|
+
"Run periodic scans to maintain compliance",
|
|
131
|
+
),
|
|
132
|
+
]
|
|
133
|
+
)
|
|
134
|
+
emit_agent_or_json(
|
|
135
|
+
output_format,
|
|
136
|
+
payload,
|
|
137
|
+
suggested=actions,
|
|
138
|
+
artifact_paths=build_artifact_paths(storage, snapshot_id),
|
|
139
|
+
schema=ComplyResponse,
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
_output_table(results, fw, show_passing)
|
|
143
|
+
|
|
144
|
+
if results.failing == 0:
|
|
145
|
+
raise typer.Exit(0)
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _output_table(results, framework: Framework, show_passing: bool):
|
|
150
|
+
"""Render compliance results."""
|
|
151
|
+
passing = results.passing
|
|
152
|
+
failing = results.failing
|
|
153
|
+
unknown = results.unknown
|
|
154
|
+
evaluated = passing + failing
|
|
155
|
+
total = evaluated + unknown
|
|
156
|
+
score = results.compliance_score * 100
|
|
157
|
+
|
|
158
|
+
console.print()
|
|
159
|
+
console.print(
|
|
160
|
+
Panel(
|
|
161
|
+
f"[bold]Compliance Report[/bold]\n"
|
|
162
|
+
f"Framework: {framework.value}\n"
|
|
163
|
+
f"Score: {score:.0f}% ({passing}/{evaluated})"
|
|
164
|
+
+ (f" Unknown: {unknown}" if unknown else ""),
|
|
165
|
+
title="cyntrisec comply",
|
|
166
|
+
border_style="green" if failing == 0 else "red",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
console.print()
|
|
170
|
+
with Progress(
|
|
171
|
+
TextColumn("[progress.description]{task.description}"),
|
|
172
|
+
BarColumn(),
|
|
173
|
+
TextColumn("{task.completed}/{task.total}"),
|
|
174
|
+
) as progress:
|
|
175
|
+
task = progress.add_task("Controls", total=total)
|
|
176
|
+
progress.update(task, completed=passing)
|
|
177
|
+
|
|
178
|
+
table = Table(
|
|
179
|
+
title="Controls",
|
|
180
|
+
box=box.ROUNDED,
|
|
181
|
+
show_header=True,
|
|
182
|
+
header_style="bold cyan",
|
|
183
|
+
)
|
|
184
|
+
table.add_column("Status", width=10)
|
|
185
|
+
table.add_column("Control")
|
|
186
|
+
table.add_column("Severity", width=12)
|
|
187
|
+
table.add_column("Description", min_width=40)
|
|
188
|
+
|
|
189
|
+
for r in results.results:
|
|
190
|
+
if r.status == "pass" and not show_passing:
|
|
191
|
+
continue
|
|
192
|
+
if r.status == "pass":
|
|
193
|
+
status = "[green]PASS[/green]"
|
|
194
|
+
elif r.status == "fail":
|
|
195
|
+
status = "[red]FAIL[/red]"
|
|
196
|
+
else:
|
|
197
|
+
status = "[yellow]UNKNOWN[/yellow]"
|
|
198
|
+
table.add_row(status, r.control.id, r.control.severity, r.control.title[:60])
|
|
199
|
+
|
|
200
|
+
console.print(table)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build_payload(results, framework: Framework, snapshot, show_passing: bool):
|
|
204
|
+
"""Build structured payload for JSON/agent outputs."""
|
|
205
|
+
return {
|
|
206
|
+
"framework": framework.value,
|
|
207
|
+
"compliance_score": results.compliance_score,
|
|
208
|
+
"passing": results.passing,
|
|
209
|
+
"failing": results.failing,
|
|
210
|
+
"controls": [
|
|
211
|
+
{
|
|
212
|
+
"id": r.control.id,
|
|
213
|
+
"title": r.control.title,
|
|
214
|
+
"status": r.status,
|
|
215
|
+
"severity": r.control.severity,
|
|
216
|
+
"description": r.control.title,
|
|
217
|
+
}
|
|
218
|
+
for r in results.results
|
|
219
|
+
if show_passing or r.status != "pass"
|
|
220
|
+
],
|
|
221
|
+
"data_gaps": [
|
|
222
|
+
{"control_id": ctrl_id, **gap} for ctrl_id, gap in results.data_gaps.items()
|
|
223
|
+
]
|
|
224
|
+
if results.data_gaps
|
|
225
|
+
else [],
|
|
226
|
+
}
|