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/ask.py ADDED
@@ -0,0 +1,412 @@
1
+ """
2
+ ask command - Natural language queries over scan results.
3
+
4
+ Maps simple natural language questions to existing commands/results and
5
+ returns agent-friendly structured output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.panel import Panel
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 AskResponse
24
+
25
+ console = Console()
26
+
27
+
28
+ @handle_errors
29
+ def ask_cmd(
30
+ query: str = typer.Argument(..., help="Natural language question"),
31
+ snapshot_id: str | None = typer.Option(
32
+ None,
33
+ "--snapshot",
34
+ "-s",
35
+ help="Snapshot UUID (default: latest; scan_id accepted)",
36
+ ),
37
+ format: str | None = typer.Option(
38
+ None,
39
+ "--format",
40
+ "-f",
41
+ help="Output format: text, json, agent (defaults to json when piped)",
42
+ ),
43
+ ):
44
+ """
45
+ Ask natural language questions about the security graph.
46
+
47
+ Examples:
48
+ cyntrisec ask "what can reach the production database?"
49
+ cyntrisec ask "show me public s3 buckets"
50
+ cyntrisec ask "which roles have admin access?"
51
+ """
52
+ from cyntrisec.storage import FileSystemStorage
53
+
54
+ output_format = resolve_format(
55
+ format,
56
+ default_tty="text",
57
+ allowed=["text", "json", "agent"],
58
+ )
59
+
60
+ classification = _classify_query(query)
61
+ intent = classification["intent"]
62
+ storage = FileSystemStorage()
63
+ snapshot = storage.get_snapshot(snapshot_id)
64
+
65
+ if not snapshot:
66
+ raise CyntriError(
67
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
68
+ message="No scan data found. Run 'cyntrisec scan' first.",
69
+ exit_code=EXIT_CODE_MAP["usage"],
70
+ )
71
+
72
+ response = _execute_intent(classification, query, storage, snapshot_id)
73
+
74
+ if output_format in {"json", "agent"}:
75
+ actions = suggested_actions(response["suggested_actions"])
76
+ payload = {
77
+ "query": query,
78
+ "intent": intent,
79
+ "results": response["results"],
80
+ "snapshot_id": str(snapshot.id),
81
+ "entities": classification["entities"],
82
+ "resolved": response.get("resolved_query", intent),
83
+ }
84
+ emit_agent_or_json(
85
+ output_format,
86
+ payload,
87
+ suggested=actions,
88
+ artifact_paths=build_artifact_paths(storage, snapshot_id),
89
+ schema=AskResponse,
90
+ )
91
+ raise typer.Exit(0)
92
+
93
+ _print_text_response(query, intent, response, snapshot)
94
+ raise typer.Exit(0)
95
+
96
+
97
+ def _classify_query(query: str) -> dict:
98
+ """Heuristic intent classification with lightweight scoring and entity extraction."""
99
+ q = query.lower()
100
+ entities = _extract_entities(query)
101
+
102
+ intents = {
103
+ "attack_paths": ["reach", "path", "attack", "kill chain", "route"],
104
+ "public_s3": ["bucket", "s3"], # Removed "public" - handled in scoring
105
+ "public_ec2": ["ec2", "instance", "server", "compute"], # New intent for EC2
106
+ "admin_roles": ["admin", "root", "privileged", "full access"],
107
+ "compliance": ["compliance", "cis", "soc2", "benchmark"],
108
+ "access_check": ["can", "access", "reach", "allowed"],
109
+ "waste": ["unused", "waste", "reduce", "permissions", "cost"],
110
+ }
111
+
112
+ scores = {}
113
+ for intent, keywords in intents.items():
114
+ score = sum(1 for kw in keywords if kw in q)
115
+ # boost if entities suggest buckets/roles
116
+ if intent == "public_s3" and entities.get("buckets"):
117
+ score += 2
118
+ # Boost S3 intent only if "public" AND "bucket" or "s3" are present
119
+ if intent == "public_s3" and "public" in q and ("bucket" in q or "s3" in q):
120
+ score += 3
121
+ # Boost EC2 intent if "public" AND EC2-related keywords are present
122
+ if intent == "public_ec2" and "public" in q and any(kw in q for kw in ["ec2", "instance", "server"]):
123
+ score += 3
124
+ if intent == "admin_roles" and entities.get("roles"):
125
+ score += 2
126
+ if intent == "access_check" and (entities.get("arns") or entities.get("roles")):
127
+ score += 2
128
+ if intent == "waste" and ("cost" in q or "spend" in q):
129
+ score += 1
130
+ scores[intent] = score
131
+
132
+ # choose highest scoring intent or default general
133
+ intent = max(scores, key=scores.get)
134
+ if scores[intent] == 0:
135
+ intent = "general"
136
+
137
+ return {"intent": intent, "scores": scores, "entities": entities}
138
+
139
+
140
+ def _extract_entities(query: str) -> dict:
141
+ """Extract simple entities like bucket names, ARNs, and role-like tokens."""
142
+ buckets = re.findall(r"s3://[\w\-.]+", query)
143
+ buckets += re.findall(r"\b([a-z0-9\-\.]+bucket[a-z0-9\-\.]*)\b", query, re.IGNORECASE)
144
+ arns = re.findall(r"arn:[^\s]+", query)
145
+ roles = re.findall(r"\b[A-Za-z0-9+=,.@_-]+Role\b", query, re.IGNORECASE)
146
+ roles += re.findall(r"\brole[:/ ]+([A-Za-z0-9+=,.@_-]+)\b", query, re.IGNORECASE)
147
+ return {
148
+ "buckets": list({b for b in buckets}),
149
+ "arns": list({a for a in arns}),
150
+ "roles": list({r for r in roles}),
151
+ }
152
+
153
+
154
+ def _execute_intent(classification: dict, query: str, storage, snapshot_id: str | None):
155
+ """Execute intent using existing data; returns results and suggested actions."""
156
+ intent = classification["intent"]
157
+ entities = classification["entities"]
158
+
159
+ if intent == "attack_paths":
160
+ paths = storage.get_attack_paths(snapshot_id)
161
+ top = [
162
+ {
163
+ "attack_vector": p.attack_vector,
164
+ "risk_score": float(p.risk_score),
165
+ "source": str(p.source_asset_id),
166
+ "target": str(p.target_asset_id),
167
+ }
168
+ for p in sorted(paths, key=lambda p: float(p.risk_score), reverse=True)[:5]
169
+ ]
170
+ return {
171
+ "results": {"attack_paths": top, "count": len(paths)},
172
+ "resolved_query": "list_top_attack_paths",
173
+ "suggested_actions": [
174
+ ("cyntrisec analyze paths --format agent", "List full attack paths"),
175
+ ("cyntrisec cuts --format agent", "Get remediations to block paths"),
176
+ ],
177
+ }
178
+
179
+ if intent == "public_s3":
180
+ assets = storage.get_assets(snapshot_id)
181
+ public_buckets = [
182
+ {"name": a.name, "arn": a.arn or a.aws_resource_id}
183
+ for a in assets
184
+ if "s3" in a.asset_type.lower()
185
+ and ("public" in a.name.lower() or a.properties.get("public"))
186
+ ]
187
+ if not public_buckets and entities.get("buckets"):
188
+ public_buckets = [{"name": b, "arn": ""} for b in entities["buckets"]]
189
+ return {
190
+ "results": {"public_buckets": public_buckets, "count": len(public_buckets)},
191
+ "resolved_query": "list_public_buckets",
192
+ "suggested_actions": [
193
+ ("cyntrisec explain finding s3_public_bucket", "See why public buckets are risky"),
194
+ (
195
+ "cyntrisec can <principal> access s3://bucket --format agent",
196
+ "Verify specific access",
197
+ ),
198
+ ],
199
+ }
200
+
201
+ if intent == "public_ec2":
202
+ assets = storage.get_assets(snapshot_id)
203
+ public_instances = [
204
+ {"name": a.name, "arn": a.arn or a.aws_resource_id, "public_ip": a.properties.get("public_ip")}
205
+ for a in assets
206
+ if a.asset_type == "ec2:instance"
207
+ and (a.properties.get("public_ip") or a.properties.get("public_dns_name"))
208
+ ]
209
+ return {
210
+ "results": {"public_ec2_instances": public_instances, "count": len(public_instances)},
211
+ "resolved_query": "list_public_ec2_instances",
212
+ "suggested_actions": [
213
+ ("cyntrisec explain finding ec2-public-ip", "See why public EC2 instances are risky"),
214
+ ("cyntrisec analyze paths --format agent", "Review attack paths involving these instances"),
215
+ ],
216
+ }
217
+
218
+ if intent == "admin_roles":
219
+ assets = storage.get_assets(snapshot_id)
220
+ roles = [
221
+ {"name": a.name, "arn": a.arn or a.aws_resource_id}
222
+ for a in assets
223
+ if a.asset_type == "iam:role" and re.search(r"admin", a.name, re.IGNORECASE)
224
+ ]
225
+ if not roles and entities.get("roles"):
226
+ roles = [{"name": r, "arn": ""} for r in entities["roles"]]
227
+ return {
228
+ "results": {"admin_like_roles": roles, "count": len(roles)},
229
+ "resolved_query": "list_admin_like_roles",
230
+ "suggested_actions": [
231
+ (
232
+ "cyntrisec can <role> access <resource> --format agent",
233
+ "Validate least privilege",
234
+ ),
235
+ ("cyntrisec waste --format agent", "Find unused permissions"),
236
+ ],
237
+ }
238
+
239
+ if intent == "access_check":
240
+ principal = entities["roles"][0] if entities.get("roles") else None
241
+ target = (
242
+ entities["arns"][0]
243
+ if entities.get("arns")
244
+ else (entities["buckets"][0] if entities.get("buckets") else None)
245
+ )
246
+
247
+ # Query graph data for access-related information
248
+ paths = storage.get_attack_paths(snapshot_id)
249
+ assets = storage.get_assets(snapshot_id)
250
+
251
+ # Build asset lookup for matching targets
252
+ asset_lookup = {}
253
+ for a in assets:
254
+ asset_lookup[str(a.id)] = a
255
+ if a.arn:
256
+ asset_lookup[a.arn.lower()] = a
257
+ if a.name:
258
+ asset_lookup[a.name.lower()] = a
259
+ if a.aws_resource_id:
260
+ asset_lookup[a.aws_resource_id.lower()] = a
261
+
262
+ # Find paths to/from the target
263
+ relevant_paths = []
264
+ if target:
265
+ target_lower = target.lower()
266
+ for p in paths:
267
+ target_asset = asset_lookup.get(str(p.target_asset_id))
268
+ source_asset = asset_lookup.get(str(p.source_asset_id))
269
+
270
+ # Check if target matches the path's target or source
271
+ target_matches = (
272
+ target_asset and (
273
+ target_lower in (target_asset.arn or "").lower() or
274
+ target_lower in (target_asset.name or "").lower() or
275
+ target_lower in (target_asset.aws_resource_id or "").lower()
276
+ )
277
+ )
278
+ source_matches = (
279
+ source_asset and (
280
+ target_lower in (source_asset.arn or "").lower() or
281
+ target_lower in (source_asset.name or "").lower() or
282
+ target_lower in (source_asset.aws_resource_id or "").lower()
283
+ )
284
+ )
285
+
286
+ if target_matches or source_matches:
287
+ relevant_paths.append(p)
288
+
289
+ target_display = target or query
290
+
291
+ # If we found relevant paths, return graph results
292
+ if relevant_paths:
293
+ top_paths = sorted(relevant_paths, key=lambda p: float(p.risk_score), reverse=True)[:5]
294
+ return {
295
+ "results": {
296
+ "paths_to_target": len(relevant_paths),
297
+ "target": target_display,
298
+ "top_paths": [
299
+ {
300
+ "attack_vector": p.attack_vector,
301
+ "risk_score": float(p.risk_score),
302
+ "source": str(p.source_asset_id),
303
+ "target": str(p.target_asset_id),
304
+ "path_length": p.path_length,
305
+ }
306
+ for p in top_paths
307
+ ],
308
+ },
309
+ "resolved_query": "graph_access_check",
310
+ "suggested_actions": [
311
+ ("cyntrisec analyze paths --format agent", "View all attack paths"),
312
+ ("cyntrisec cuts --format agent", "Get remediations to block paths"),
313
+ (
314
+ f"cyntrisec can <principal> access {target or '<resource>'} --live --format agent",
315
+ "Run live access simulation for precise results",
316
+ ),
317
+ ],
318
+ }
319
+
320
+ # No paths found - return helpful response with graph context
321
+ principal_display = principal or "<principal>"
322
+ resource_display = target_display
323
+ return {
324
+ "results": {
325
+ "paths_to_target": 0,
326
+ "target": target_display,
327
+ "message": f"No attack paths found to '{target_display}' in the graph.",
328
+ },
329
+ "resolved_query": "graph_access_check",
330
+ "suggested_actions": [
331
+ (
332
+ f"cyntrisec can {principal_display} access {resource_display} --live --format agent",
333
+ "Run live access simulation",
334
+ ),
335
+ ("cyntrisec analyze paths --format agent", "View all attack paths"),
336
+ ],
337
+ }
338
+
339
+ if intent == "waste":
340
+ return {
341
+ "results": {"message": "Analyze unused permissions to reduce blast radius."},
342
+ "resolved_query": "waste_candidates",
343
+ "suggested_actions": [
344
+ ("cyntrisec waste --format agent", "Find unused permissions"),
345
+ ],
346
+ }
347
+
348
+ if intent == "compliance":
349
+ return {
350
+ "results": {"message": "Run compliance checks with 'cyntrisec comply --format agent'."},
351
+ "resolved_query": "compliance_check",
352
+ "suggested_actions": [
353
+ ("cyntrisec comply --format agent", "Check CIS/SOC2 compliance"),
354
+ (
355
+ "cyntrisec explain control CIS-AWS:1.1 --format agent",
356
+ "Explain specific controls",
357
+ ),
358
+ ],
359
+ }
360
+
361
+ return {
362
+ "results": {
363
+ "message": "Query understood. Use analyze paths/findings, cuts, can, or comply for details."
364
+ },
365
+ "resolved_query": "general_help",
366
+ "suggested_actions": [
367
+ ("cyntrisec analyze paths --format agent", "View attack paths"),
368
+ ("cyntrisec analyze findings --format agent", "Review findings by severity"),
369
+ ],
370
+ }
371
+
372
+
373
+ def _print_text_response(query: str, intent: str, response: dict, snapshot):
374
+ """Render a simple text response."""
375
+ console.print()
376
+ console.print(
377
+ Panel(
378
+ f"Query: {query}\n"
379
+ f"Intent: {intent}\n"
380
+ f"Snapshot: {snapshot.aws_account_id if snapshot else 'unknown'}",
381
+ title="cyntrisec ask",
382
+ border_style="cyan",
383
+ )
384
+ )
385
+
386
+ results = response.get("results", {})
387
+ if intent == "attack_paths":
388
+ paths = results.get("attack_paths", [])
389
+ if not paths:
390
+ console.print("[yellow]No attack paths found.[/yellow]")
391
+ else:
392
+ for p in paths:
393
+ console.print(f"- {p['attack_vector']} (risk {p['risk_score']:.2f})")
394
+ elif intent == "public_s3":
395
+ buckets = results.get("public_buckets", [])
396
+ if not buckets:
397
+ console.print("[yellow]No public buckets detected by heuristics.[/yellow]")
398
+ else:
399
+ for b in buckets:
400
+ console.print(f"- {b['name']} ({b['arn']})")
401
+ elif intent == "admin_roles":
402
+ roles = results.get("admin_like_roles", [])
403
+ if not roles:
404
+ console.print("[yellow]No admin-like roles detected by name pattern.[/yellow]")
405
+ else:
406
+ for r in roles:
407
+ console.print(f"- {r['name']} ({r['arn']})")
408
+ else:
409
+ console.print(results.get("message", ""))
410
+
411
+ console.print()
412
+ console.print("[dim]Use --format agent for structured responses and follow-ups.[/dim]")