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/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
@@ -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
+ }