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
@@ -0,0 +1,462 @@
1
+ """
2
+ Report Command - Generate reports from scan results.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
14
+ from cyntrisec.cli.output import (
15
+ build_artifact_paths,
16
+ emit_agent_or_json,
17
+ resolve_format,
18
+ suggested_actions,
19
+ )
20
+ from cyntrisec.cli.schemas import ReportResponse
21
+
22
+
23
+ def _infer_format_from_extension(output_path: Path) -> str | None:
24
+ """Infer output format from file extension."""
25
+ name = output_path.name.lower()
26
+ if name.endswith(".html"):
27
+ return "html"
28
+ if name.endswith(".json"):
29
+ return "json"
30
+ return None
31
+
32
+
33
+ @handle_errors
34
+ def report_cmd(
35
+ scan_id: str | None = typer.Option(
36
+ None,
37
+ "--scan",
38
+ "-s",
39
+ help="Scan ID (default: latest)",
40
+ ),
41
+ output: Path = typer.Option(
42
+ Path("cyntrisec-report.html"),
43
+ "--output",
44
+ "-o",
45
+ help="Output file path",
46
+ ),
47
+ title: str | None = typer.Option(
48
+ None,
49
+ "--title",
50
+ "-t",
51
+ help="Report title",
52
+ ),
53
+ format: str | None = typer.Option(
54
+ None,
55
+ "--format",
56
+ "-f",
57
+ help="Output format: html, json, agent (defaults to json when piped)",
58
+ ),
59
+ ):
60
+ """
61
+ Generate report from scan results.
62
+
63
+ Examples:
64
+
65
+ cyntrisec report --output report.html
66
+
67
+ cyntrisec report --format json --output report.json
68
+ """
69
+ from cyntrisec.storage import FileSystemStorage
70
+
71
+ storage = FileSystemStorage()
72
+ snapshot = storage.get_snapshot(scan_id)
73
+
74
+ # Infer format from output file extension if not explicitly specified
75
+ inferred_format = format
76
+ if inferred_format is None:
77
+ inferred_format = _infer_format_from_extension(output)
78
+
79
+ output_format = resolve_format(
80
+ inferred_format,
81
+ default_tty="html",
82
+ allowed=["html", "json", "agent"],
83
+ )
84
+
85
+ if not snapshot:
86
+ raise CyntriError(
87
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
88
+ message="No scan found.",
89
+ exit_code=EXIT_CODE_MAP["usage"],
90
+ )
91
+
92
+ if not title:
93
+ title = f"Cyntrisec Security Report - {snapshot.aws_account_id}"
94
+
95
+ # If caller didn't override output and we emit JSON/agent, use .json for clarity
96
+ if output_format in {"json", "agent"} and output.suffix.lower() == ".html":
97
+ output = output.with_suffix(".json")
98
+
99
+ data = storage.export_all(scan_id)
100
+
101
+ artifact_paths = build_artifact_paths(storage, scan_id)
102
+
103
+ if output_format in {"json", "agent"}:
104
+ output.write_text(json.dumps(data, indent=2, default=str))
105
+ actions = suggested_actions(
106
+ [
107
+ ("cyntrisec analyze paths --format agent", "Inspect top attack paths"),
108
+ ("cyntrisec cuts --format agent", "Prioritize fixes to block paths"),
109
+ ]
110
+ )
111
+ emit_agent_or_json(
112
+ output_format,
113
+ {
114
+ "snapshot_id": str(snapshot.id),
115
+ "account_id": snapshot.aws_account_id,
116
+ "output_path": str(output),
117
+ "findings": len(data.get("findings", [])),
118
+ "paths": len(data.get("attack_paths", [])),
119
+ },
120
+ suggested=actions,
121
+ artifact_paths=artifact_paths,
122
+ schema=ReportResponse,
123
+ )
124
+ else:
125
+ html = _generate_html(data, title)
126
+ output.write_text(html)
127
+ typer.echo(f"HTML report written to {output}")
128
+
129
+
130
+
131
+ def _generate_html(data: dict, title: str) -> str:
132
+ """Generate standalone HTML report with CLI/Terminal aesthetic."""
133
+ import html
134
+
135
+ snapshot = data.get("snapshot", {})
136
+ assets = data.get("assets", [])
137
+ findings = data.get("findings", [])
138
+ paths = data.get("attack_paths", [])
139
+
140
+ allowed_severities = {"critical", "high", "medium", "low", "info"}
141
+
142
+ def to_float(value: object, default: float = 0.0) -> float:
143
+ try:
144
+ return float(value) # type: ignore[arg-type]
145
+ except (TypeError, ValueError):
146
+ return default
147
+
148
+ def normalize_severity(value: object) -> str:
149
+ sev = str(value or "info").strip().lower()
150
+ return sev if sev in allowed_severities else "info"
151
+
152
+ class SafeHtml(str):
153
+ pass
154
+
155
+ # Count findings by severity
156
+ sev_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
157
+ for f in findings:
158
+ sev = normalize_severity(f.get("severity"))
159
+ sev_counts[sev] += 1
160
+
161
+ # Sort paths by risk (descending)
162
+ paths.sort(key=lambda p: to_float(p.get("risk_score", 0)), reverse=True)
163
+
164
+ # Sort findings by severity (critical first)
165
+ sev_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
166
+ findings.sort(key=lambda f: sev_order.get(normalize_severity(f.get("severity")), 5))
167
+
168
+ # --- CLI Report Helper Functions ---
169
+ def render_row(cells, header=False):
170
+ tag = "th" if header else "td"
171
+ row_html = "<tr>"
172
+ for cell in cells:
173
+ if isinstance(cell, SafeHtml):
174
+ cell_html = str(cell)
175
+ else:
176
+ cell_html = html.escape(str(cell), quote=True)
177
+ row_html += f"<{tag}>{cell_html}</{tag}>"
178
+ row_html += "</tr>"
179
+ return row_html
180
+
181
+ # Build Attack Paths Table
182
+ if not paths:
183
+ paths_section = '<div class="empty-state">> No attack paths discovered. System secure.</div>'
184
+ else:
185
+ rows = []
186
+ for p in paths[:25]:
187
+ risk = to_float(p.get("risk_score", 0))
188
+ vector = p.get("attack_vector", "unknown")
189
+ length = p.get("path_length", 0)
190
+ entry = to_float(p.get("entry_confidence", 0))
191
+ impact = to_float(p.get("impact_score", 0))
192
+
193
+ # Colorize Risk
194
+ risk_class = "risk-low"
195
+ if risk >= 0.7: risk_class = "risk-critical"
196
+ elif risk >= 0.4: risk_class = "risk-high"
197
+
198
+ rows.append(render_row([
199
+ SafeHtml(f'<span class="{risk_class}">{risk:.3f}</span>'),
200
+ vector,
201
+ length,
202
+ f"{entry:.2f}",
203
+ f"{impact:.2f}"
204
+ ]))
205
+
206
+ paths_section = f"""
207
+ <table class="cli-table">
208
+ <thead>{render_row(["RISK", "VECTOR", "LEN", "ENTRY", "IMPACT"], header=True)}</thead>
209
+ <tbody>{"".join(rows)}</tbody>
210
+ </table>
211
+ """
212
+
213
+ # Build Findings Table
214
+ if not findings:
215
+ findings_section = '<div class="empty-state">> No findings detected. Clean scan.</div>'
216
+ else:
217
+ rows = []
218
+ for f in findings[:50]:
219
+ sev = normalize_severity(f.get("severity"))
220
+ ftype = f.get("finding_type", "")
221
+ ftitle = f.get("title", "")
222
+ rows.append(render_row([
223
+ SafeHtml(f'<span class="badge badge-{sev}">{sev.upper()}</span>'),
224
+ ftype,
225
+ ftitle
226
+ ]))
227
+
228
+ findings_section = f"""
229
+ <table class="cli-table">
230
+ <thead>{render_row(["SEVERITY", "TYPE", "TITLE"], header=True)}</thead>
231
+ <tbody>{"".join(rows)}</tbody>
232
+ </table>
233
+ """
234
+
235
+ regions_val = ", ".join(str(r) for r in (snapshot.get("regions") or []))
236
+ regions = html.escape(regions_val, quote=True)
237
+ account_id = html.escape(str(snapshot.get("aws_account_id", "N/A")), quote=True)
238
+ generated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
239
+ safe_title = html.escape(title, quote=True)
240
+
241
+ return f"""<!DOCTYPE html>
242
+ <html lang="en">
243
+ <head>
244
+ <meta charset="UTF-8">
245
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
246
+ <title>{safe_title}</title>
247
+ <style>
248
+ :root {{
249
+ --bg: #0c0c0c;
250
+ --fg: #cccccc;
251
+ --dim: #666666;
252
+ --accent: #33ff00; /* Terminal Green */
253
+ --accent-dim: #1a8000;
254
+ --border: #333333;
255
+ --panel: #111111;
256
+
257
+ /* Severities */
258
+ --sev-critical: #ff0055;
259
+ --sev-high: #ff9900;
260
+ --sev-medium: #ffcc00;
261
+ --sev-low: #33ff00;
262
+ --sev-info: #00ccff;
263
+ }}
264
+
265
+ @font-face {{
266
+ font-family: 'Terminess';
267
+ src: local('Consolas'), local('Monaco'), local('Andale Mono'), local('Ubuntu Mono'), monospace;
268
+ }}
269
+
270
+ * {{ box-sizing: border-box; }}
271
+
272
+ body {{
273
+ background-color: var(--bg);
274
+ color: var(--fg);
275
+ font-family: 'Terminess', monospace;
276
+ font-size: 14px;
277
+ margin: 0;
278
+ padding: 20px;
279
+ line-height: 1.5;
280
+ }}
281
+
282
+ .container {{
283
+ max-width: 1200px;
284
+ margin: 0 auto;
285
+ border: 1px solid var(--border);
286
+ padding: 20px;
287
+ box-shadow: 0 0 20px rgba(0,0,0,0.5);
288
+ }}
289
+
290
+ /* Header */
291
+ header {{
292
+ border-bottom: 2px dashed var(--border);
293
+ padding-bottom: 20px;
294
+ margin-bottom: 30px;
295
+ }}
296
+
297
+ h1 {{
298
+ color: var(--accent);
299
+ text-transform: uppercase;
300
+ font-size: 24px;
301
+ margin: 0 0 10px 0;
302
+ letter-spacing: 1px;
303
+ text-shadow: 0 0 5px rgba(51, 255, 0, 0.3);
304
+ }}
305
+
306
+ .meta {{
307
+ color: var(--dim);
308
+ font-size: 12px;
309
+ }}
310
+
311
+ /* Sections */
312
+ h2 {{
313
+ color: #fff;
314
+ background: var(--border);
315
+ display: inline-block;
316
+ padding: 2px 10px;
317
+ margin: 40px 0 15px 0;
318
+ font-size: 16px;
319
+ text-transform: uppercase;
320
+ }}
321
+
322
+ /* Stats Grid */
323
+ .stats-grid {{
324
+ display: grid;
325
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
326
+ gap: 15px;
327
+ margin-bottom: 30px;
328
+ }}
329
+
330
+ .stat-card {{
331
+ border: 1px solid var(--border);
332
+ background: var(--panel);
333
+ padding: 15px;
334
+ text-align: center;
335
+ }}
336
+
337
+ .stat-val {{
338
+ font-size: 32px;
339
+ font-weight: bold;
340
+ display: block;
341
+ margin-bottom: 5px;
342
+ }}
343
+ .stat-label {{
344
+ color: var(--dim);
345
+ font-size: 10px;
346
+ text-transform: uppercase;
347
+ letter-spacing: 1px;
348
+ }}
349
+
350
+ /* Colors for Stats */
351
+ .sc-critical {{ color: var(--sev-critical); border-color: var(--sev-critical); }}
352
+ .sc-high {{ color: var(--sev-high); border-color: var(--sev-high); }}
353
+
354
+ /* Tables */
355
+ .cli-table {{
356
+ width: 100%;
357
+ border-collapse: collapse;
358
+ font-size: 13px;
359
+ }}
360
+
361
+ .cli-table th {{
362
+ text-align: left;
363
+ border-bottom: 1px solid var(--fg);
364
+ color: var(--accent);
365
+ padding: 8px;
366
+ text-transform: uppercase;
367
+ }}
368
+
369
+ .cli-table td {{
370
+ border-bottom: 1px solid var(--border);
371
+ padding: 8px;
372
+ color: #eeeeee;
373
+ }}
374
+
375
+ .cli-table tr:hover td {{
376
+ background-color: #1a1a1a;
377
+ color: #fff;
378
+ }}
379
+
380
+ /* Badges & Pills */
381
+ .badge {{
382
+ padding: 2px 6px;
383
+ font-size: 10px;
384
+ font-weight: bold;
385
+ text-transform: uppercase;
386
+ border: 1px solid;
387
+ }}
388
+
389
+ .badge-critical {{ color: var(--sev-critical); border-color: var(--sev-critical); }}
390
+ .badge-high {{ color: var(--sev-high); border-color: var(--sev-high); }}
391
+ .badge-medium {{ color: var(--sev-medium); border-color: var(--sev-medium); }}
392
+ .badge-low {{ color: var(--sev-low); border-color: var(--sev-low); }}
393
+ .badge-info {{ color: var(--sev-info); border-color: var(--sev-info); }}
394
+
395
+ .risk-critical {{ color: var(--sev-critical); font-weight: bold; }}
396
+ .risk-high {{ color: var(--sev-high); }}
397
+ .risk-low {{ color: var(--sev-low); }}
398
+
399
+ .footer {{
400
+ margin-top: 50px;
401
+ border-top: 1px dotted var(--border);
402
+ padding-top: 15px;
403
+ text-align: center;
404
+ font-size: 11px;
405
+ color: var(--dim);
406
+ }}
407
+
408
+ a {{ color: var(--fg); text-decoration: none; border-bottom: 1px solid var(--dim); }}
409
+ a:hover {{ color: var(--accent); border-color: var(--accent); }}
410
+
411
+ .empty-state {{
412
+ padding: 20px;
413
+ color: var(--dim);
414
+ font-style: italic;
415
+ border: 1px dashed var(--border);
416
+ }}
417
+ </style>
418
+ </head>
419
+ <body>
420
+ <div class="container">
421
+ <header>
422
+ <h1>> {safe_title}_</h1>
423
+ <div class="meta">
424
+ TARGET_ACCOUNT: {account_id} <br>
425
+ REGIONS.......: {regions} <br>
426
+ TIMESTAMP.....: {generated_at} <br>
427
+ STATUS........: {len(findings)} FINDINGS DETECTED
428
+ </div>
429
+ </header>
430
+
431
+ <div class="stats-grid">
432
+ <div class="stat-card">
433
+ <span class="stat-val" style="color:#fff">{len(assets)}</span>
434
+ <span class="stat-label">Assets Scanned</span>
435
+ </div>
436
+ <div class="stat-card">
437
+ <span class="stat-val" style="color:var(--sev-high)">{len(paths)}</span>
438
+ <span class="stat-label">Attack Paths</span>
439
+ </div>
440
+ <div class="stat-card sc-critical">
441
+ <span class="stat-val">{sev_counts["critical"]}</span>
442
+ <span class="stat-label">Critical</span>
443
+ </div>
444
+ <div class="stat-card sc-high">
445
+ <span class="stat-val">{sev_counts["high"]}</span>
446
+ <span class="stat-label">High Risk</span>
447
+ </div>
448
+ </div>
449
+
450
+ <h2>// DETECTED ATTACK PATHS</h2>
451
+ {paths_section}
452
+
453
+ <h2>// SECURITY FINDINGS</h2>
454
+ {findings_section}
455
+
456
+ <div class="footer">
457
+ [ CYNTRISEC SECURITY CLI v0.1 ] <br>
458
+ <a href="https://cyntrisec.com">DOCUMENTATION</a> | <a href="https://github.com/cyntrisec/cyntrisec-cli">SOURCE</a>
459
+ </div>
460
+ </div>
461
+ </body>
462
+ </html>"""
cyntrisec/cli/scan.py ADDED
@@ -0,0 +1,207 @@
1
+ """
2
+ Scan Command - Run AWS scans.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+
9
+ import typer
10
+
11
+ from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
12
+ from cyntrisec.cli.output import (
13
+ build_artifact_paths,
14
+ emit_agent_or_json,
15
+ resolve_format,
16
+ suggested_actions,
17
+ )
18
+ from cyntrisec.cli.schemas import ScanResponse
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ @handle_errors
24
+ def scan_cmd(
25
+ role_arn: str | None = typer.Option(
26
+ None,
27
+ "--role-arn",
28
+ "-r",
29
+ help="AWS IAM role ARN to assume (read-only access)",
30
+ ),
31
+ regions: str = typer.Option(
32
+ "us-east-1",
33
+ "--regions",
34
+ help="Comma-separated list of AWS regions to scan",
35
+ ),
36
+ external_id: str | None = typer.Option(
37
+ None,
38
+ "--external-id",
39
+ "-e",
40
+ help="External ID for role assumption",
41
+ ),
42
+ role_session_name: str | None = typer.Option(
43
+ None,
44
+ "--role-session-name",
45
+ help="Session name for role assumption (default: cyntrisec-scan)",
46
+ ),
47
+ profile: str | None = typer.Option(
48
+ None,
49
+ "--profile",
50
+ "-p",
51
+ help="AWS CLI profile for base credentials",
52
+ ),
53
+ format: str | None = typer.Option(
54
+ None,
55
+ "--format",
56
+ "-f",
57
+ help="Output format: text, json, agent (defaults to json when piped)",
58
+ ),
59
+ business_config: str | None = typer.Option(
60
+ None,
61
+ "--business-config",
62
+ "-b",
63
+ help="Path to business configuration file (yaml/json)",
64
+ ),
65
+ ):
66
+ """
67
+ Run AWS security scan.
68
+
69
+ Scans an AWS account using read-only API calls to discover:
70
+
71
+ - Infrastructure resources (EC2, IAM, S3, Lambda, RDS, etc.)
72
+
73
+ - Network connectivity and security groups
74
+
75
+ - Attack paths through the infrastructure
76
+
77
+ - Security misconfigurations
78
+
79
+ Examples:
80
+
81
+ cyntrisec scan --role-arn arn:aws:iam::123456789012:role/ReadOnly
82
+
83
+ cyntrisec scan -r arn:aws:iam::123456789012:role/ReadOnly --regions us-east-1,eu-west-1
84
+ """
85
+ from cyntrisec.aws import AwsScanner
86
+ from cyntrisec.storage import FileSystemStorage
87
+
88
+ # Parse regions
89
+ region_list = [r.strip() for r in regions.split(",")]
90
+ output_format = resolve_format(
91
+ format,
92
+ default_tty="text",
93
+ allowed=["text", "json", "agent"],
94
+ )
95
+
96
+ typer.echo("Starting AWS scan...", err=True)
97
+ typer.echo(f" Role: {role_arn or 'default credentials'}", err=True)
98
+ typer.echo(f" Regions: {', '.join(region_list)}", err=True)
99
+
100
+ # Create storage and scanner
101
+ storage = FileSystemStorage()
102
+ scanner = AwsScanner(storage)
103
+
104
+ try:
105
+ snapshot = scanner.scan(
106
+ regions=region_list,
107
+ role_arn=role_arn,
108
+ external_id=external_id,
109
+ role_session_name=role_session_name,
110
+ profile=profile,
111
+ business_config=business_config,
112
+ )
113
+ except PermissionError as e:
114
+ raise CyntriError(
115
+ error_code=ErrorCode.AWS_ACCESS_DENIED,
116
+ message=str(e),
117
+ exit_code=EXIT_CODE_MAP["usage"],
118
+ )
119
+ except Exception as e:
120
+ log.exception("Scan failed")
121
+ raise CyntriError(
122
+ error_code=ErrorCode.INTERNAL_ERROR,
123
+ message=str(e),
124
+ exit_code=EXIT_CODE_MAP["internal"],
125
+ )
126
+
127
+ # Print summary
128
+ typer.echo("", err=True)
129
+ typer.echo("Scan complete!", err=True)
130
+ typer.echo(f" Assets: {snapshot.asset_count}", err=True)
131
+ typer.echo(f" Relationships: {snapshot.relationship_count}", err=True)
132
+ typer.echo(f" Findings: {snapshot.finding_count}", err=True)
133
+ typer.echo(f" Attack paths: {snapshot.path_count}", err=True)
134
+
135
+ # Print warnings if there were partial failures
136
+ if snapshot.errors:
137
+ typer.echo("", err=True)
138
+ typer.echo("Warnings:", err=True)
139
+ for err in snapshot.errors:
140
+ service = err.get("service", "unknown")
141
+ region = err.get("region", "")
142
+ error_msg = err.get("error", "unknown error")
143
+ if region:
144
+ typer.echo(f" - Failed to collect {service} in {region}: {error_msg}", err=True)
145
+ else:
146
+ typer.echo(f" - Failed to collect {service}: {error_msg}", err=True)
147
+
148
+ typer.echo("", err=True)
149
+ typer.echo("Run 'cyntrisec analyze paths' to view attack paths", err=True)
150
+ typer.echo("Run 'cyntrisec report' to generate HTML report", err=True)
151
+
152
+ # Get the scan_id (directory name) for use in suggested actions
153
+ scan_id = storage.resolve_scan_id(None) # Get latest scan_id
154
+ artifact_paths = build_artifact_paths(storage, scan_id)
155
+
156
+ # Build warnings from snapshot errors
157
+ warnings = None
158
+ if snapshot.errors:
159
+ warnings = [
160
+ f"Failed to collect {err.get('service', 'unknown')}"
161
+ + (f" in {err['region']}" if 'region' in err else "")
162
+ + f": {err.get('error', 'unknown error')}"
163
+ for err in snapshot.errors
164
+ ]
165
+
166
+ # Determine status based on errors
167
+ status = "completed_with_errors" if snapshot.errors else "success"
168
+
169
+ summary = {
170
+ "scan_id": scan_id,
171
+ "snapshot_id": str(snapshot.id),
172
+ "status": status,
173
+ "account_id": snapshot.aws_account_id,
174
+ "regions": snapshot.regions,
175
+ "asset_count": snapshot.asset_count,
176
+ "relationship_count": snapshot.relationship_count,
177
+ "finding_count": snapshot.finding_count,
178
+ "attack_path_count": snapshot.path_count,
179
+ "warnings": warnings,
180
+ }
181
+ followups = suggested_actions(
182
+ [
183
+ (f"cyntrisec analyze paths --scan {scan_id}", "Review discovered attack paths"),
184
+ (
185
+ f"cyntrisec cuts --snapshot {snapshot.id}",
186
+ "Prioritize fixes that block paths",
187
+ ),
188
+ (
189
+ f"cyntrisec report --scan {scan_id} --output cyntrisec-report.html",
190
+ "Generate a full report",
191
+ ),
192
+ ]
193
+ )
194
+
195
+ if output_format in {"json", "agent"}:
196
+ emit_agent_or_json(
197
+ output_format,
198
+ summary,
199
+ suggested=followups,
200
+ artifact_paths=artifact_paths,
201
+ schema=ScanResponse,
202
+ )
203
+
204
+ # Exit code based on paths found
205
+ if snapshot.path_count > 0:
206
+ raise typer.Exit(1) # Paths found
207
+ raise typer.Exit(0)