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,747 @@
1
+ """
2
+ Analyze Commands - Analyze scan results.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ log = logging.getLogger(__name__)
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 (
23
+ AnalyzeFindingsResponse,
24
+ AnalyzePathsResponse,
25
+ BusinessAnalysisResponse,
26
+ )
27
+ from cyntrisec.core.cost_estimator import CostEstimator
28
+
29
+ analyze_app = typer.Typer(help="Analyze scan results")
30
+
31
+
32
+ @analyze_app.command("paths")
33
+ @handle_errors
34
+ def analyze_paths(
35
+ scan_id: str | None = typer.Option(
36
+ None,
37
+ "--scan",
38
+ "-s",
39
+ help="Scan ID (default: latest)",
40
+ ),
41
+ min_risk: float = typer.Option(
42
+ 0.0,
43
+ "--min-risk",
44
+ help="Minimum risk score (0-1)",
45
+ ),
46
+ limit: int = typer.Option(
47
+ 20,
48
+ "--limit",
49
+ "-n",
50
+ help="Maximum number of paths to show",
51
+ ),
52
+ format: str | None = typer.Option(
53
+ None,
54
+ "--format",
55
+ "-f",
56
+ help="Output format: table, json, agent (defaults to json when piped)",
57
+ ),
58
+ verify: bool = typer.Option(
59
+ False,
60
+ "--verify",
61
+ help="Verify paths using AWS Policy Simulator (requires AWS credentials)",
62
+ ),
63
+ ):
64
+ """
65
+ Show attack paths from scan results.
66
+
67
+ Attack paths are routes from internet-facing entry points
68
+ to sensitive targets through the infrastructure.
69
+
70
+ Examples:
71
+
72
+ cyntrisec analyze paths --min-risk 0.5
73
+
74
+ cyntrisec analyze paths --format json | jq '.paths[:5]'
75
+ """
76
+ from cyntrisec.storage import FileSystemStorage
77
+
78
+ storage = FileSystemStorage()
79
+ snapshot = storage.get_snapshot(scan_id)
80
+ if not snapshot:
81
+ raise CyntriError(
82
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
83
+ message="No scan data found. Run 'cyntrisec scan' first.",
84
+ exit_code=EXIT_CODE_MAP["usage"],
85
+ )
86
+ paths = storage.get_attack_paths(scan_id)
87
+ output_format = resolve_format(
88
+ format,
89
+ default_tty="table",
90
+ allowed=["table", "json", "agent"],
91
+ )
92
+
93
+ # Filter by risk
94
+ if min_risk > 0:
95
+ paths = [p for p in paths if float(p.risk_score) >= min_risk]
96
+
97
+ # Sort by risk
98
+ paths.sort(key=lambda p: float(p.risk_score), reverse=True)
99
+
100
+ total_paths = len(paths)
101
+ paths = paths[:limit]
102
+
103
+ # Verify paths if requested
104
+ if verify:
105
+ from cyntrisec.aws.credentials import CredentialProvider
106
+ from cyntrisec.core.simulator import PolicySimulator
107
+
108
+ try:
109
+ # Initialize simulator
110
+ provider = CredentialProvider()
111
+ session = provider.default_session()
112
+ simulator = PolicySimulator(session)
113
+
114
+ # Hydrate assets for verification
115
+ all_assets = {a.id: a for a in storage.get_assets(scan_id)}
116
+
117
+ typer.echo("Verifying paths with AWS Policy Simulator...", err=True)
118
+
119
+ for path in paths:
120
+ if not path.path_asset_ids or len(path.path_asset_ids) < 2:
121
+ continue
122
+
123
+ # Verify last hop if it's a capability edge
124
+ # (Ideally we'd verify the whole chain, but let's start with the immediate impact)
125
+ target_id = path.path_asset_ids[-1]
126
+ source_id = path.path_asset_ids[-2]
127
+
128
+ source_asset = all_assets.get(uuid.UUID(str(source_id)))
129
+ target_asset = all_assets.get(uuid.UUID(str(target_id)))
130
+
131
+ if not source_asset or not target_asset:
132
+ continue
133
+
134
+ # Only check if source is an IAM principal
135
+ if source_asset.asset_type not in ("iam:role", "iam:user"):
136
+ continue
137
+
138
+ # Find the relationship to get the action
139
+ # We need the graph or relationship list, but storage.get_attack_paths relies on
140
+ # stored paths which usually have IDs. We don't have relationships loaded here easily.
141
+ # However, we can infer action from edge type if we loaded relationships, OR
142
+ # we can try common actions based on target type.
143
+
144
+ # For now, let's use the simulator's inference
145
+ try:
146
+ result = simulator.can_access(
147
+ principal_arn=source_asset.arn or source_asset.aws_resource_id,
148
+ target_resource=target_asset.arn or target_asset.aws_resource_id
149
+ )
150
+
151
+ if result.can_access:
152
+ if path.confidence_level != "high":
153
+ path.confidence_level = "high"
154
+ path.confidence_reason = f"Verified via AWS Policy Simulator (Action: {result.action})"
155
+ else:
156
+ path.confidence_level = "low"
157
+ path.confidence_reason = "Verification Failed: AWS Policy Simulator denied access"
158
+
159
+ except Exception as ex:
160
+ log.debug("Path verification failed for %s: %s", path.id, ex)
161
+
162
+ except Exception as e:
163
+ typer.echo(f"Verification failed: {e}", err=True)
164
+ # Don't fail the command, just warn
165
+
166
+
167
+ if output_format in {"json", "agent"}:
168
+ artifact_paths = build_artifact_paths(storage, scan_id)
169
+ data = {
170
+ "paths": [p.model_dump(mode="json") for p in paths],
171
+ "returned": len(paths),
172
+ "total": total_paths,
173
+ }
174
+ snapshot_uuid = str(snapshot.id)
175
+ actions = suggested_actions(
176
+ [
177
+ (
178
+ f"cyntrisec cuts --snapshot {snapshot_uuid}",
179
+ "Prioritize fixes that block these paths",
180
+ ),
181
+ (
182
+ "cyntrisec explain path instance-compromise",
183
+ "Get human-friendly context for a path",
184
+ ),
185
+ ]
186
+ )
187
+ emit_agent_or_json(
188
+ output_format,
189
+ data,
190
+ suggested=actions,
191
+ artifact_paths=artifact_paths,
192
+ schema=AnalyzePathsResponse,
193
+ )
194
+ return
195
+
196
+ # Table format
197
+ if not paths:
198
+ typer.echo("No attack paths found.")
199
+ return
200
+
201
+ typer.echo(f"{'Risk':<8} {'Conf':<6} {'Vector':<25} {'Length':<8} {'Entry':<8} {'Impact':<8}")
202
+ typer.echo("-" * 75)
203
+
204
+ for p in paths:
205
+ risk = float(p.risk_score)
206
+ conf = (p.confidence_level or "UNK")[:3]
207
+ vector = p.attack_vector[:24]
208
+ length = p.path_length
209
+ entry = float(p.entry_confidence)
210
+ impact = float(p.impact_score)
211
+
212
+ # Color coding
213
+ color = None
214
+ if risk >= 0.7:
215
+ color = typer.colors.RED
216
+ elif risk >= 0.4:
217
+ color = typer.colors.YELLOW
218
+
219
+ line = f"{risk:<8.3f} {conf:<6} {vector:<25} {length:<8} {entry:<8.3f} {impact:<8.3f}"
220
+ if color:
221
+ typer.secho(line, fg=color)
222
+ else:
223
+ typer.echo(line)
224
+
225
+ typer.echo("")
226
+ typer.echo(f"Total: {len(paths)} paths")
227
+
228
+
229
+ @analyze_app.command("findings")
230
+ @handle_errors
231
+ def analyze_findings(
232
+ scan_id: str | None = typer.Option(
233
+ None,
234
+ "--scan",
235
+ "-s",
236
+ help="Scan ID (default: latest)",
237
+ ),
238
+ severity: str | None = typer.Option(
239
+ None,
240
+ "--severity",
241
+ help="Filter by severity: critical, high, medium, low, info",
242
+ ),
243
+ format: str | None = typer.Option(
244
+ None,
245
+ "--format",
246
+ "-f",
247
+ help="Output format: table, json, agent (defaults to json when piped)",
248
+ ),
249
+ ):
250
+ """
251
+ Show security findings from scan results.
252
+ """
253
+ from cyntrisec.storage import FileSystemStorage
254
+
255
+ storage = FileSystemStorage()
256
+ snapshot = storage.get_snapshot(scan_id)
257
+ if not snapshot:
258
+ raise CyntriError(
259
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
260
+ message="No scan data found. Run 'cyntrisec scan' first.",
261
+ exit_code=EXIT_CODE_MAP["usage"],
262
+ )
263
+ findings = storage.get_findings(scan_id)
264
+ output_format = resolve_format(
265
+ format,
266
+ default_tty="table",
267
+ allowed=["table", "json", "agent"],
268
+ )
269
+
270
+ # Filter by severity
271
+ if severity:
272
+ findings = [f for f in findings if f.severity == severity]
273
+
274
+ # Sort by severity
275
+ severity_order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
276
+ findings.sort(key=lambda f: severity_order.get(f.severity, 5))
277
+
278
+ if output_format in {"json", "agent"}:
279
+ artifact_paths = build_artifact_paths(storage, scan_id)
280
+ data = {
281
+ "findings": [f.model_dump(mode="json") for f in findings],
282
+ "total": len(findings),
283
+ "filter": severity or "any",
284
+ }
285
+ actions = suggested_actions(
286
+ [
287
+ (
288
+ f"cyntrisec explain finding {findings[0].finding_type}" if findings else "",
289
+ "See remediation context for the most common finding" if findings else "",
290
+ ),
291
+ ("cyntrisec comply --format agent", "Map findings to compliance controls"),
292
+ ]
293
+ )
294
+ emit_agent_or_json(
295
+ output_format,
296
+ data,
297
+ suggested=actions,
298
+ artifact_paths=artifact_paths,
299
+ schema=AnalyzeFindingsResponse,
300
+ )
301
+ return
302
+
303
+ if not findings:
304
+ typer.echo("No findings found.")
305
+ return
306
+
307
+ typer.echo(f"{'Severity':<10} {'Type':<35} {'Title':<50}")
308
+ typer.echo("-" * 95)
309
+
310
+ for f in findings:
311
+ sev = f.severity.upper()[:9]
312
+ ftype = f.finding_type[:34]
313
+ title = f.title[:49]
314
+ typer.echo(f"{sev:<10} {ftype:<35} {title:<50}")
315
+
316
+ typer.echo("")
317
+ typer.echo(f"Total: {len(findings)} findings")
318
+
319
+
320
+ @analyze_app.command("stats")
321
+ @handle_errors
322
+ def analyze_stats(
323
+ scan_id: str | None = typer.Option(
324
+ None,
325
+ "--scan",
326
+ "-s",
327
+ help="Scan ID (default: latest)",
328
+ ),
329
+ format: str | None = typer.Option(
330
+ None,
331
+ "--format",
332
+ "-f",
333
+ help="Output format: text, json, agent (defaults to json when piped)",
334
+ ),
335
+ ):
336
+ """
337
+ Show summary statistics for a scan.
338
+ """
339
+ from cyntrisec.storage import FileSystemStorage
340
+
341
+ storage = FileSystemStorage()
342
+ output_format = resolve_format(
343
+ format,
344
+ default_tty="text",
345
+ allowed=["text", "json", "agent"],
346
+ )
347
+
348
+ snapshot = storage.get_snapshot(scan_id)
349
+ if not snapshot:
350
+ if output_format in {"json", "agent"}:
351
+ from cyntrisec.cli.errors import ErrorCode
352
+ emit_agent_or_json(
353
+ output_format,
354
+ {},
355
+ status="error",
356
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND.value,
357
+ message="No scan found.",
358
+ )
359
+ raise typer.Exit(2)
360
+ typer.echo("No scan found.", err=True)
361
+ raise typer.Exit(2)
362
+
363
+ assets = storage.get_assets(scan_id)
364
+ findings = storage.get_findings(scan_id)
365
+ paths = storage.get_attack_paths(scan_id)
366
+ resolved_scan_id = storage.resolve_scan_id(scan_id)
367
+
368
+ if output_format in {"json", "agent"}:
369
+ from cyntrisec.cli.schemas import AnalyzeStatsResponse
370
+ payload = {
371
+ "snapshot_id": str(snapshot.id),
372
+ "scan_id": resolved_scan_id,
373
+ "account_id": snapshot.aws_account_id,
374
+ "asset_count": len(assets),
375
+ "relationship_count": snapshot.relationship_count,
376
+ "finding_count": len(findings),
377
+ "path_count": len(paths),
378
+ "regions": snapshot.regions,
379
+ "status": snapshot.status.value if hasattr(snapshot.status, 'value') else str(snapshot.status),
380
+ }
381
+ actions = suggested_actions(
382
+ [
383
+ (f"cyntrisec analyze paths --scan {resolved_scan_id or 'latest'}", "View attack paths"),
384
+ (f"cyntrisec analyze findings --scan {resolved_scan_id or 'latest'}", "View security findings"),
385
+ ]
386
+ )
387
+ emit_agent_or_json(
388
+ output_format,
389
+ payload,
390
+ suggested=actions,
391
+ artifact_paths=build_artifact_paths(storage, scan_id),
392
+ schema=AnalyzeStatsResponse,
393
+ )
394
+ raise typer.Exit(0)
395
+
396
+ typer.echo("=== Scan Statistics ===")
397
+ typer.echo("")
398
+ typer.echo(f"Account: {snapshot.aws_account_id}")
399
+ typer.echo(f"Regions: {', '.join(snapshot.regions)}")
400
+ typer.echo(f"Status: {snapshot.status}")
401
+ typer.echo(f"Started: {snapshot.started_at}")
402
+ typer.echo(f"Completed: {snapshot.completed_at}")
403
+ typer.echo("")
404
+
405
+ typer.echo("--- Counts ---")
406
+ typer.echo(f"Assets: {len(assets)}")
407
+ typer.echo(f"Findings: {len(findings)}")
408
+ typer.echo(f"Attack paths: {len(paths)}")
409
+ typer.echo("")
410
+
411
+ # Asset types
412
+ asset_types = {}
413
+ for a in assets:
414
+ asset_types[a.asset_type] = asset_types.get(a.asset_type, 0) + 1
415
+
416
+ typer.echo("--- Assets by Type ---")
417
+ for t, count in sorted(asset_types.items(), key=lambda x: -x[1])[:10]:
418
+ typer.echo(f" {t}: {count}")
419
+
420
+ # Finding severities
421
+ severities = {}
422
+ for f in findings:
423
+ severities[f.severity] = severities.get(f.severity, 0) + 1
424
+
425
+ if severities:
426
+ typer.echo("")
427
+ typer.echo("--- Findings by Severity ---")
428
+ for sev in ["critical", "high", "medium", "low", "info"]:
429
+ if sev in severities:
430
+ typer.echo(f" {sev}: {severities[sev]}")
431
+
432
+ # Attack path stats
433
+ if paths:
434
+ risks = [float(p.risk_score) for p in paths]
435
+ typer.echo("")
436
+ typer.echo("--- Attack Paths ---")
437
+ typer.echo(f" Highest risk: {max(risks):.3f}")
438
+ typer.echo(f" Average risk: {sum(risks) / len(risks):.3f}")
439
+
440
+
441
+ @analyze_app.command("business")
442
+ @handle_errors
443
+ def analyze_business(
444
+ entrypoints: str | None = typer.Option(
445
+ None,
446
+ "--entrypoints",
447
+ "-e",
448
+ help="Comma-separated business entrypoint names/arns",
449
+ ),
450
+ business_entrypoint: list[str] | None = typer.Option(
451
+ None,
452
+ "--business-entrypoint",
453
+ "-b",
454
+ help="Repeatable business entrypoint (name or ARN)",
455
+ ),
456
+ business_tags: list[str] | None = typer.Option(
457
+ None,
458
+ "--business-tag",
459
+ help="Tag filters marking business assets (repeatable, key=value or comma-separated)",
460
+ ),
461
+ business_config: str | None = typer.Option(
462
+ None,
463
+ "--business-config",
464
+ help="Path to business config (JSON or YAML) with entrypoints/tags",
465
+ ),
466
+ report: bool = typer.Option(
467
+ False,
468
+ "--report",
469
+ help="Output coverage report for business-required assets",
470
+ ),
471
+ scan_id: str | None = typer.Option(
472
+ None,
473
+ "--scan",
474
+ "-s",
475
+ help="Scan ID (default: latest)",
476
+ ),
477
+ format: str | None = typer.Option(
478
+ None,
479
+ "--format",
480
+ "-f",
481
+ help="Output format: table, json, agent (defaults to json when piped)",
482
+ ),
483
+ cost_source: str = typer.Option(
484
+ "estimate",
485
+ "--cost-source",
486
+ help="Cost data source: estimate (static), pricing-api, cost-explorer",
487
+ ),
488
+ ):
489
+ """
490
+ Analyze business-required entrypoints vs attack-reachable assets.
491
+
492
+ Waste ~= attackable assets minus business-required set.
493
+ """
494
+ from cyntrisec.storage import FileSystemStorage
495
+
496
+ output_format = resolve_format(
497
+ format,
498
+ default_tty="table",
499
+ allowed=["table", "json", "agent"],
500
+ )
501
+
502
+ cost_estimator = CostEstimator(source=cost_source)
503
+ storage = FileSystemStorage()
504
+ assets = storage.get_assets(scan_id)
505
+ snapshot = storage.get_snapshot(scan_id)
506
+
507
+ if not assets or not snapshot:
508
+ raise CyntriError(
509
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
510
+ message="No scan data found. Run 'cyntrisec scan' first.",
511
+ exit_code=EXIT_CODE_MAP["usage"],
512
+ )
513
+
514
+ # Parse business config from all sources
515
+ config = _parse_business_config(
516
+ entrypoints, business_entrypoint, business_tags, business_config
517
+ )
518
+
519
+ # Classify assets
520
+ path_assets = {aid for p in storage.get_attack_paths(scan_id) for aid in p.path_asset_ids}
521
+ analysis = _classify_assets(assets, config, path_assets)
522
+
523
+ # Sort waste by cost priority
524
+ sorted_waste = cost_estimator.sort_by_cost_priority(analysis["waste_candidates"])
525
+
526
+ # Build result
527
+ result = _build_business_result(
528
+ config, analysis, sorted_waste, path_assets, cost_estimator, report
529
+ )
530
+
531
+ if output_format in {"json", "agent"}:
532
+ emit_agent_or_json(
533
+ output_format,
534
+ result,
535
+ suggested=suggested_actions(
536
+ [
537
+ ("cyntrisec waste --format agent", "Review unused permissions/waste"),
538
+ (
539
+ "cyntrisec cuts --format agent",
540
+ "Prioritize fixes to reduce attackable surface",
541
+ ),
542
+ (
543
+ "cyntrisec analyze business --report --format agent",
544
+ "Show full business coverage report",
545
+ ),
546
+ ]
547
+ ),
548
+ artifact_paths=build_artifact_paths(storage, scan_id),
549
+ schema=BusinessAnalysisResponse,
550
+ )
551
+ return
552
+
553
+ _output_business_table(analysis)
554
+
555
+
556
+ def _parse_business_config(
557
+ entrypoints: str | None,
558
+ business_entrypoint: list[str] | None,
559
+ business_tags: list[str] | None,
560
+ business_config: str | None,
561
+ ) -> dict:
562
+ """Parse business configuration from all input sources."""
563
+ entry_list = [e.strip() for e in (entrypoints.split(",") if entrypoints else []) if e.strip()]
564
+ if business_entrypoint:
565
+ entry_list.extend([e for e in business_entrypoint if e])
566
+
567
+ tag_filters = _parse_tag_filters(business_tags)
568
+ critical_assets: set = set()
569
+
570
+ if business_config:
571
+ cfg_path = Path(business_config)
572
+ if not cfg_path.exists():
573
+ raise CyntriError(
574
+ error_code=ErrorCode.INVALID_QUERY,
575
+ message=f"Business config not found at {business_config}",
576
+ exit_code=EXIT_CODE_MAP["usage"],
577
+ )
578
+ loaded = _load_config_file(cfg_path)
579
+ if isinstance(loaded, dict):
580
+ entry_list.extend([str(e) for e in loaded.get("entrypoints", [])])
581
+ for key, value in (loaded.get("tags", {}) or {}).items():
582
+ tag_filters[str(key)] = str(value)
583
+ critical_assets = {str(item) for item in loaded.get("critical_assets", []) or []}
584
+
585
+ return {
586
+ "entry_list": [e for e in entry_list if e],
587
+ "tag_filters": tag_filters,
588
+ "critical_assets": critical_assets,
589
+ }
590
+
591
+
592
+ def _parse_tag_filters(business_tags: list[str] | None) -> dict:
593
+ """Parse tag filters from CLI option."""
594
+ tag_filters: dict[str, str] = {}
595
+ if not business_tags:
596
+ return tag_filters
597
+
598
+ for raw in business_tags:
599
+ for pair in raw.split(","):
600
+ if not pair:
601
+ continue
602
+ if "=" not in pair:
603
+ raise CyntriError(
604
+ error_code=ErrorCode.INVALID_QUERY,
605
+ message=f"Invalid tag filter '{pair}'. Use key=value.",
606
+ exit_code=EXIT_CODE_MAP["usage"],
607
+ )
608
+ key, value = pair.split("=", 1)
609
+ tag_filters[key.strip()] = value.strip()
610
+
611
+ return tag_filters
612
+
613
+
614
+ def _load_config_file(cfg_path: Path) -> dict:
615
+ """Load YAML or JSON config file."""
616
+ text = cfg_path.read_text(encoding="utf-8")
617
+ try:
618
+ import yaml # type: ignore
619
+
620
+ return yaml.safe_load(text) or {}
621
+ except Exception:
622
+ pass
623
+ try:
624
+ import json
625
+
626
+ return json.loads(text)
627
+ except Exception:
628
+ raise CyntriError(
629
+ error_code=ErrorCode.SCHEMA_MISMATCH,
630
+ message="Failed to parse business config (expected YAML or JSON).",
631
+ exit_code=EXIT_CODE_MAP["usage"],
632
+ )
633
+
634
+
635
+ def _classify_assets(assets, config: dict, path_assets: set) -> dict:
636
+ """Classify assets into business, attackable, and waste categories."""
637
+ entry_list = config["entry_list"]
638
+ tag_filters = config["tag_filters"]
639
+ critical_assets = config["critical_assets"]
640
+
641
+ business_assets = []
642
+ attackable = []
643
+ waste_candidates = []
644
+
645
+ for asset in assets:
646
+ attackable_flag = (
647
+ asset.is_internet_facing or asset.is_sensitive_target or asset.id in path_assets
648
+ )
649
+ if attackable_flag:
650
+ attackable.append(asset)
651
+
652
+ reasons = _get_business_reasons(asset, entry_list, tag_filters, critical_assets)
653
+
654
+ if reasons:
655
+ business_assets.append(
656
+ {
657
+ "name": asset.name,
658
+ "asset_type": asset.asset_type,
659
+ "asset_id": str(asset.id),
660
+ "reason": ",".join(reasons),
661
+ "tags": asset.tags,
662
+ }
663
+ )
664
+ elif attackable_flag:
665
+ waste_candidates.append(asset)
666
+
667
+ return {
668
+ "business_assets": business_assets,
669
+ "attackable": attackable,
670
+ "waste_candidates": waste_candidates,
671
+ }
672
+
673
+
674
+ def _get_business_reasons(asset, entry_list, tag_filters, critical_assets) -> list:
675
+ """Determine why an asset is considered business-required."""
676
+ reasons = []
677
+ if asset.name in entry_list or (asset.arn and asset.arn in entry_list):
678
+ reasons.append("entrypoint")
679
+ if tag_filters and all(asset.tags.get(k) == v for k, v in tag_filters.items()):
680
+ reasons.append("tags")
681
+ if asset.name in critical_assets or (asset.arn and asset.arn in critical_assets):
682
+ reasons.append("config-critical")
683
+ return reasons
684
+
685
+
686
+ def _build_business_result(
687
+ config, analysis, sorted_waste, path_assets, cost_estimator, report
688
+ ) -> dict:
689
+ """Build the result dictionary for business analysis."""
690
+ return {
691
+ "entrypoints_requested": config["entry_list"],
692
+ "entrypoints_found": [a["name"] for a in analysis["business_assets"]],
693
+ "attackable_count": len(analysis["attackable"]),
694
+ "business_required_count": len(analysis["business_assets"]),
695
+ "waste_candidates": [
696
+ _build_waste_candidate_dict(a, path_assets, cost_estimator) for a in sorted_waste[:20]
697
+ ],
698
+ "waste_candidate_count": len(analysis["waste_candidates"]),
699
+ "business_assets": analysis["business_assets"] if report else None,
700
+ "unknown_assets": [
701
+ {
702
+ "name": a.name,
703
+ "asset_type": a.asset_type,
704
+ "asset_id": str(a.id),
705
+ "reason": "attackable_not_business",
706
+ "tags": a.tags,
707
+ }
708
+ for a in analysis["waste_candidates"][:20]
709
+ ]
710
+ if report
711
+ else None,
712
+ }
713
+
714
+
715
+ def _output_business_table(analysis: dict) -> None:
716
+ """Output business analysis in table format."""
717
+ typer.echo("=== Business vs Attackable Analysis ===")
718
+ typer.echo(f"Attackable assets: {len(analysis['attackable'])}")
719
+ typer.echo(f"Business-required (provided): {len(analysis['business_assets'])}")
720
+ typer.echo(f"Waste candidates (attackable minus business): {len(analysis['waste_candidates'])}")
721
+ if analysis["waste_candidates"]:
722
+ typer.echo("\nSample waste candidates:")
723
+ for a in analysis["waste_candidates"][:10]:
724
+ typer.echo(f"- {a.name} ({a.asset_type})")
725
+
726
+
727
+ def _build_waste_candidate_dict(asset, path_assets, cost_estimator):
728
+ """Build waste candidate dict with optional cost estimate."""
729
+ result = {
730
+ "name": asset.name,
731
+ "asset_type": asset.asset_type,
732
+ "asset_id": str(asset.id),
733
+ "reason": "in attack paths" if asset.id in path_assets else "internet-facing/sensitive",
734
+ "monthly_cost_usd": float(asset.monthly_cost_usd)
735
+ if getattr(asset, "monthly_cost_usd", None)
736
+ else None,
737
+ }
738
+
739
+ if cost_estimator:
740
+ estimate = cost_estimator.estimate(asset)
741
+ if estimate:
742
+ result["monthly_cost_usd_estimate"] = float(estimate.monthly_cost_usd_estimate)
743
+ result["cost_source"] = estimate.cost_source
744
+ result["confidence"] = estimate.confidence
745
+ result["assumptions"] = estimate.assumptions
746
+
747
+ return result