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/setup.py ADDED
@@ -0,0 +1,260 @@
1
+ """
2
+ Setup Commands - Generate IAM roles and configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
13
+ from cyntrisec.cli.output import emit_agent_or_json, resolve_format, suggested_actions
14
+ from cyntrisec.cli.schemas import SetupIamResponse
15
+
16
+ setup_app = typer.Typer(help="Setup commands")
17
+
18
+
19
+ @setup_app.command("iam")
20
+ @handle_errors
21
+ def setup_iam(
22
+ account_id: str = typer.Argument(
23
+ ...,
24
+ help="AWS account ID (12 digits)",
25
+ ),
26
+ role_name: str = typer.Option(
27
+ "CyntrisecReadOnly",
28
+ "--role-name",
29
+ "-n",
30
+ help="Name for the IAM role",
31
+ ),
32
+ external_id: str | None = typer.Option(
33
+ None,
34
+ "--external-id",
35
+ "-e",
36
+ help="External ID for extra security",
37
+ ),
38
+ format: str = typer.Option(
39
+ "terraform",
40
+ "--format",
41
+ "-f",
42
+ help="Output format: terraform, cloudformation, policy",
43
+ ),
44
+ output: Path | None = typer.Option(
45
+ None,
46
+ "--output",
47
+ "-o",
48
+ help="Output file (default: stdout)",
49
+ ),
50
+ output_format: str | None = typer.Option(
51
+ None,
52
+ "--output-format",
53
+ help="Render format: text, json, agent (defaults to json when piped)",
54
+ ),
55
+ ):
56
+ """
57
+ Generate IAM role for AWS scanning.
58
+
59
+ Creates a read-only IAM role that Cyntrisec can assume.
60
+
61
+ Examples:
62
+
63
+ cyntrisec setup iam 123456789012 --output role.tf
64
+
65
+ cyntrisec setup iam 123456789012 --format policy
66
+
67
+ cyntrisec setup iam 123456789012 --external-id my-id --format cloudformation
68
+ """
69
+ # Validate
70
+ if not account_id.isdigit() or len(account_id) != 12:
71
+ raise CyntriError(
72
+ error_code=ErrorCode.INVALID_QUERY,
73
+ message="Account ID must be exactly 12 digits",
74
+ exit_code=EXIT_CODE_MAP["usage"],
75
+ )
76
+
77
+ resolved_output_format = resolve_format(
78
+ output_format,
79
+ default_tty="text",
80
+ allowed=["text", "json", "agent"],
81
+ )
82
+
83
+ # Read-only policy
84
+ policy = {
85
+ "Version": "2012-10-17",
86
+ "Statement": [
87
+ {
88
+ "Sid": "CyntrisecReadOnly",
89
+ "Effect": "Allow",
90
+ "Action": [
91
+ "ec2:Describe*",
92
+ "iam:Get*",
93
+ "iam:List*",
94
+ "s3:GetBucketAcl",
95
+ "s3:GetBucketPolicy",
96
+ "s3:GetBucketPolicyStatus",
97
+ "s3:GetBucketPublicAccessBlock",
98
+ "s3:GetBucketLocation",
99
+ "s3:ListBucket",
100
+ "s3:ListAllMyBuckets",
101
+ "lambda:GetFunction",
102
+ "lambda:GetFunctionConfiguration",
103
+ "lambda:GetPolicy",
104
+ "lambda:ListFunctions",
105
+ "rds:Describe*",
106
+ "elasticloadbalancing:Describe*",
107
+ "route53:List*",
108
+ "route53:Get*",
109
+ "cloudfront:Get*",
110
+ "cloudfront:List*",
111
+ "apigateway:GET",
112
+ "sts:GetCallerIdentity",
113
+ ],
114
+ "Resource": "*",
115
+ }
116
+ ],
117
+ }
118
+
119
+ if format == "terraform":
120
+ result = _gen_terraform(account_id, role_name, external_id, policy)
121
+ elif format == "cloudformation":
122
+ result = _gen_cloudformation(role_name, external_id, policy)
123
+ elif format == "policy":
124
+ result = json.dumps(policy, indent=2)
125
+ else:
126
+ raise CyntriError(
127
+ error_code=ErrorCode.INVALID_QUERY,
128
+ message=f"Unknown format: {format}",
129
+ exit_code=EXIT_CODE_MAP["usage"],
130
+ )
131
+
132
+ payload = {
133
+ "account_id": account_id,
134
+ "role_name": role_name,
135
+ "external_id": external_id,
136
+ "template_format": format,
137
+ "template": result,
138
+ }
139
+
140
+ if output:
141
+ output.write_text(result)
142
+ payload["output_path"] = str(output)
143
+ typer.echo(f"Written to {output}", err=True)
144
+ elif resolved_output_format == "text":
145
+ typer.echo(result)
146
+
147
+ if resolved_output_format in {"json", "agent"}:
148
+ actions = suggested_actions(
149
+ [
150
+ (
151
+ f"cyntrisec validate-role --role-arn arn:aws:iam::{account_id}:role/{role_name}",
152
+ "Verify trust and permissions",
153
+ ),
154
+ ("cyntrisec scan --role-arn <role_arn>", "Kick off the first scan"),
155
+ ]
156
+ )
157
+ emit_agent_or_json(
158
+ resolved_output_format,
159
+ payload,
160
+ suggested=actions,
161
+ schema=SetupIamResponse,
162
+ )
163
+
164
+
165
+ def _gen_terraform(account_id: str, role_name: str, external_id: str | None, policy: dict) -> str:
166
+ """Generate Terraform configuration."""
167
+ safe_name = role_name.lower().replace("-", "_")
168
+
169
+ # Build assume role policy with proper Condition structure inside jsonencode
170
+ assume_statement = {
171
+ "Effect": "Allow",
172
+ "Principal": {"AWS": f"arn:aws:iam::{account_id}:root"},
173
+ "Action": "sts:AssumeRole",
174
+ }
175
+ if external_id:
176
+ assume_statement["Condition"] = {
177
+ "StringEquals": {"sts:ExternalId": external_id}
178
+ }
179
+
180
+ assume_policy = {
181
+ "Version": "2012-10-17",
182
+ "Statement": [assume_statement],
183
+ }
184
+
185
+ # Format JSON for HCL embedding
186
+ assume_policy_json = json.dumps(assume_policy, indent=4)
187
+ policy_json = json.dumps(policy, indent=4)
188
+
189
+ return f'''# Cyntrisec Read-Only IAM Role
190
+ # Usage: cyntrisec scan --role-arn <output.role_arn>{f" --external-id {external_id}" if external_id else ""}
191
+
192
+ resource "aws_iam_role" "{safe_name}" {{
193
+ name = "{role_name}"
194
+
195
+ assume_role_policy = jsonencode({assume_policy_json})
196
+
197
+ tags = {{
198
+ Purpose = "Cyntrisec"
199
+ ManagedBy = "terraform"
200
+ ReadOnly = "true"
201
+ }}
202
+ }}
203
+
204
+ resource "aws_iam_role_policy" "{safe_name}_policy" {{
205
+ name = "{role_name}Policy"
206
+ role = aws_iam_role.{safe_name}.id
207
+ policy = jsonencode({policy_json})
208
+ }}
209
+
210
+ output "role_arn" {{
211
+ value = aws_iam_role.{safe_name}.arn
212
+ }}
213
+ '''
214
+
215
+
216
+ def _gen_cloudformation(role_name: str, external_id: str | None, policy: dict) -> str:
217
+ """Generate CloudFormation template."""
218
+ cond = ""
219
+ if external_id:
220
+ cond = f'''
221
+ Condition:
222
+ StringEquals:
223
+ sts:ExternalId: "{external_id}"'''
224
+
225
+ policy_yaml = json.dumps(policy, indent=8).replace("\n", "\n ")
226
+
227
+ return f"""AWSTemplateFormatVersion: "2010-09-09"
228
+ Description: Cyntrisec Read-Only IAM Role
229
+
230
+ Resources:
231
+ CyntrisecRole:
232
+ Type: AWS::IAM::Role
233
+ Properties:
234
+ RoleName: {role_name}
235
+ AssumeRolePolicyDocument:
236
+ Version: "2012-10-17"
237
+ Statement:
238
+ - Effect: Allow
239
+ Principal:
240
+ AWS: !Sub "arn:aws:iam::${{AWS::AccountId}}:root"
241
+ Action: sts:AssumeRole{cond}
242
+ Tags:
243
+ - Key: Purpose
244
+ Value: Cyntrisec
245
+ - Key: ReadOnly
246
+ Value: "true"
247
+
248
+ CyntrisecPolicy:
249
+ Type: AWS::IAM::Policy
250
+ Properties:
251
+ PolicyName: {role_name}Policy
252
+ Roles: [!Ref CyntrisecRole]
253
+ PolicyDocument: {policy_yaml}
254
+
255
+ Outputs:
256
+ RoleArn:
257
+ Value: !GetAtt CyntrisecRole.Arn
258
+ Export:
259
+ Name: CyntrisecRoleArn
260
+ """
@@ -0,0 +1,101 @@
1
+ """
2
+ Validate Role Command - Check AWS role trust without running a full scan.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import typer
8
+
9
+ from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
10
+ from cyntrisec.cli.output import emit_agent_or_json, resolve_format, suggested_actions
11
+ from cyntrisec.cli.schemas import ValidateRoleResponse
12
+
13
+
14
+ @handle_errors
15
+ def validate_role_cmd(
16
+ role_arn: str = typer.Option(
17
+ ...,
18
+ "--role-arn",
19
+ "-r",
20
+ help="AWS IAM role ARN to validate",
21
+ ),
22
+ external_id: str | None = typer.Option(
23
+ None,
24
+ "--external-id",
25
+ "-e",
26
+ help="External ID for role assumption",
27
+ ),
28
+ profile: str | None = typer.Option(
29
+ None,
30
+ "--profile",
31
+ "-p",
32
+ help="AWS CLI profile for base credentials",
33
+ ),
34
+ json_output: bool = typer.Option(
35
+ False,
36
+ "--json",
37
+ help="Output as JSON",
38
+ ),
39
+ format: str | None = typer.Option(
40
+ None,
41
+ "--format",
42
+ "-f",
43
+ help="Output format: text, json, agent (defaults to json when piped)",
44
+ ),
45
+ ):
46
+ """
47
+ Validate that an IAM role can be assumed.
48
+
49
+ Performs STS AssumeRole + GetCallerIdentity to verify trust.
50
+ Useful for testing role configuration before running a full scan.
51
+
52
+ Examples:
53
+
54
+ cyntrisec validate-role --role-arn arn:aws:iam::123456789012:role/ReadOnly
55
+
56
+ cyntrisec validate-role -r arn:aws:iam::123456789012:role/ReadOnly --json
57
+ """
58
+ from cyntrisec.aws.credentials import CredentialProvider
59
+
60
+ typer.echo(f"Validating role: {role_arn}", err=True)
61
+ resolved_format = resolve_format(
62
+ "json" if json_output and format is None else format,
63
+ default_tty="text",
64
+ allowed=["text", "json", "agent"],
65
+ )
66
+
67
+ creds = CredentialProvider(profile=profile)
68
+ try:
69
+ session = creds.assume_role(role_arn, external_id=external_id)
70
+ except PermissionError as e:
71
+ raise CyntriError(
72
+ error_code=ErrorCode.AWS_ACCESS_DENIED,
73
+ message=str(e),
74
+ exit_code=EXIT_CODE_MAP["usage"],
75
+ )
76
+ identity = session.client("sts").get_caller_identity()
77
+
78
+ result = {
79
+ "success": True,
80
+ "role_arn": role_arn,
81
+ "account": identity.get("Account"),
82
+ "arn": identity.get("Arn"),
83
+ "user_id": identity.get("UserId"),
84
+ }
85
+
86
+ if resolved_format in {"json", "agent"}:
87
+ actions = suggested_actions(
88
+ [
89
+ (f"cyntrisec scan --role-arn {role_arn}", "Start a scan with the validated role"),
90
+ ("cyntrisec report --format json", "Export results for audit"),
91
+ ]
92
+ )
93
+ emit_agent_or_json(resolved_format, result, suggested=actions, schema=ValidateRoleResponse)
94
+ else:
95
+ typer.echo("", err=True)
96
+ typer.echo("Role validation successful!", err=True)
97
+ typer.echo(f" Account: {identity['Account']}", err=True)
98
+ typer.echo(f" ARN: {identity['Arn']}", err=True)
99
+ typer.echo(f" UserId: {identity['UserId']}", err=True)
100
+
101
+ raise typer.Exit(0)
cyntrisec/cli/waste.py ADDED
@@ -0,0 +1,323 @@
1
+ """
2
+ waste command - Find unused IAM capabilities for blast radius reduction.
3
+
4
+ Usage:
5
+ cyntrisec waste [OPTIONS]
6
+
7
+ Examples:
8
+ cyntrisec waste # Analyze using scan data (no AWS calls)
9
+ cyntrisec waste --live # Fetch live usage data from AWS
10
+ cyntrisec waste --days 90 # Consider unused if not accessed in 90 days
11
+ cyntrisec waste --format json # Machine-readable output
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+
18
+ import typer
19
+ from rich import box
20
+ from rich.console import Console
21
+ from rich.panel import Panel
22
+ from rich.table import Table
23
+
24
+ from cyntrisec.cli.errors import EXIT_CODE_MAP, CyntriError, ErrorCode, handle_errors
25
+ from cyntrisec.cli.output import (
26
+ build_artifact_paths,
27
+ emit_agent_or_json,
28
+ resolve_format,
29
+ suggested_actions,
30
+ )
31
+ from cyntrisec.cli.schemas import WasteResponse
32
+ from cyntrisec.core.cost_estimator import CostEstimator
33
+ from cyntrisec.core.waste import WasteAnalyzer
34
+ from cyntrisec.storage import FileSystemStorage
35
+
36
+ console = Console()
37
+ status_console = Console(stderr=True)
38
+ log = logging.getLogger(__name__)
39
+
40
+
41
+ @handle_errors
42
+ def waste_cmd(
43
+ days: int = typer.Option(
44
+ 90,
45
+ "--days",
46
+ "-d",
47
+ help="Days threshold for considering a permission unused",
48
+ ),
49
+ live: bool = typer.Option(
50
+ False,
51
+ "--live",
52
+ "-l",
53
+ help="Fetch live usage data from AWS (requires IAM permissions)",
54
+ ),
55
+ role_arn: str | None = typer.Option(
56
+ None,
57
+ "--role-arn",
58
+ "-r",
59
+ help="AWS role to assume for live analysis",
60
+ ),
61
+ external_id: str | None = typer.Option(
62
+ None,
63
+ "--external-id",
64
+ "-e",
65
+ help="External ID for role assumption",
66
+ ),
67
+ format: str | None = typer.Option(
68
+ None,
69
+ "--format",
70
+ "-f",
71
+ help="Output format: table, json, agent (defaults to json when piped)",
72
+ ),
73
+ cost_source: str = typer.Option(
74
+ "estimate",
75
+ "--cost-source",
76
+ help="Cost data source: estimate (static), pricing-api, cost-explorer",
77
+ ),
78
+ max_roles: int = typer.Option(
79
+ 20,
80
+ "--max-roles",
81
+ help="Maximum number of roles to analyze (API throttling)",
82
+ ),
83
+ snapshot_id: str | None = typer.Option(
84
+ None,
85
+ "--snapshot",
86
+ "-s",
87
+ help="Snapshot UUID (default: latest; scan_id accepted)",
88
+ ),
89
+ ):
90
+ """
91
+ Analyze IAM roles for unused permissions (blast radius reduction).
92
+
93
+ Compares granted permissions against actual usage to identify
94
+ opportunities to reduce attack surface.
95
+
96
+ Without --live, uses heuristic analysis of scan data.
97
+ With --live, fetches actual usage data from AWS IAM Access Advisor.
98
+ """
99
+ output_format = resolve_format(
100
+ format,
101
+ default_tty="table",
102
+ allowed=["table", "json", "agent"],
103
+ )
104
+
105
+ storage = FileSystemStorage()
106
+ assets = storage.get_assets(snapshot_id)
107
+ snapshot = storage.get_snapshot(snapshot_id)
108
+
109
+ if not assets or not snapshot:
110
+ raise CyntriError(
111
+ error_code=ErrorCode.SNAPSHOT_NOT_FOUND,
112
+ message="No scan data found. Run 'cyntrisec scan' first.",
113
+ exit_code=EXIT_CODE_MAP["usage"],
114
+ )
115
+
116
+ analyzer = WasteAnalyzer(days_threshold=days)
117
+ usage_reports = None
118
+
119
+ if live:
120
+ # Fetch real usage data from AWS
121
+ live_console = console if output_format == "table" else status_console
122
+ usage_reports = _collect_live_usage(
123
+ assets,
124
+ role_arn,
125
+ external_id,
126
+ max_roles,
127
+ status_console=live_console,
128
+ )
129
+
130
+ # Run analysis
131
+ report = analyzer.analyze_from_assets(assets, usage_reports)
132
+
133
+ if output_format in {"json", "agent"}:
134
+ cost_estimator = CostEstimator(source=cost_source)
135
+ payload = _build_payload(report, snapshot, days, cost_estimator, assets)
136
+ actions = suggested_actions(
137
+ [
138
+ (
139
+ f"cyntrisec comply --snapshot {snapshot.id} --format agent",
140
+ "Connect unused permissions to compliance gaps",
141
+ ),
142
+ (
143
+ f"cyntrisec cuts --snapshot {snapshot.id}",
144
+ "Prioritize fixes that remove risky unused permissions",
145
+ ),
146
+ ]
147
+ )
148
+ emit_agent_or_json(
149
+ output_format,
150
+ payload,
151
+ suggested=actions,
152
+ artifact_paths=build_artifact_paths(storage, snapshot_id),
153
+ schema=WasteResponse,
154
+ )
155
+ else:
156
+ _output_table(report, snapshot, days)
157
+
158
+
159
+ def _collect_live_usage(assets, role_arn, external_id, max_roles, *, status_console):
160
+ """Collect live usage data from AWS."""
161
+ from cyntrisec.aws import CredentialProvider
162
+ from cyntrisec.aws.collectors.usage import UsageCollector
163
+
164
+ status_console.print("[cyan]Fetching live usage data from AWS...[/cyan]")
165
+
166
+ provider = CredentialProvider()
167
+ if role_arn:
168
+ session = provider.assume_role(role_arn, external_id=external_id)
169
+ else:
170
+ session = provider.default_session()
171
+
172
+ collector = UsageCollector(session)
173
+
174
+ # Get IAM role ARNs from assets
175
+ role_arns = [a.arn for a in assets if a.asset_type == "iam:role" and a.arn]
176
+
177
+ if not role_arns:
178
+ status_console.print("[yellow]No IAM roles found in scan data.[/yellow]")
179
+ return []
180
+
181
+ status_console.print(f"[dim]Analyzing {min(len(role_arns), max_roles)} roles...[/dim]")
182
+ return collector.collect_all_roles(role_arns, max_roles=max_roles)
183
+
184
+
185
+ def _output_table(report, snapshot, days):
186
+ """Display results as a rich table."""
187
+ console.print()
188
+
189
+ # Summary panel
190
+ reduction_pct = report.blast_radius_reduction * 100
191
+ console.print(
192
+ Panel(
193
+ f"[bold]Unused Permissions Analysis[/bold]\n"
194
+ f"Account: {snapshot.aws_account_id if snapshot else 'unknown'}\n"
195
+ f"Threshold: {days} days\n"
196
+ f"Unused: {report.total_unused} / {report.total_permissions} permissions\n"
197
+ f"Blast Radius Reduction: [green]{reduction_pct:.0f}%[/green]",
198
+ title="cyntrisec waste",
199
+ border_style="yellow",
200
+ )
201
+ )
202
+ console.print()
203
+
204
+ if not report.role_reports:
205
+ console.print("[green]No obvious waste found.[/green]")
206
+ console.print("[dim]Run with --live for detailed IAM Access Advisor analysis.[/dim]")
207
+ return
208
+
209
+ # Table per role with findings
210
+ for role_report in report.role_reports:
211
+ if not role_report.unused_capabilities:
212
+ continue
213
+
214
+ table = Table(
215
+ title=f"Role: {role_report.role_name}",
216
+ box=box.ROUNDED,
217
+ show_header=True,
218
+ header_style="bold yellow",
219
+ )
220
+ table.add_column("Risk", style="bold", width=8)
221
+ table.add_column("Service", width=25)
222
+ table.add_column("Status", width=20)
223
+ table.add_column("Recommendation", min_width=30)
224
+
225
+ for cap in role_report.unused_capabilities:
226
+ risk_style = {
227
+ "critical": "red bold",
228
+ "high": "red",
229
+ "medium": "yellow",
230
+ "low": "dim",
231
+ }.get(cap.risk_level, "white")
232
+
233
+ if cap.days_unused is None:
234
+ status = "[red]Never used[/red]"
235
+ else:
236
+ status = f"[yellow]{cap.days_unused} days[/yellow]"
237
+
238
+ table.add_row(
239
+ f"[{risk_style}]{cap.risk_level.upper()}[/]",
240
+ cap.service_name,
241
+ status,
242
+ cap.recommendation,
243
+ )
244
+
245
+ console.print(table)
246
+ console.print()
247
+
248
+ # Summary
249
+ console.print(
250
+ f"[yellow]Remove {report.total_unused} unused permissions to reduce "
251
+ f"blast radius by {reduction_pct:.0f}%[/yellow]"
252
+ )
253
+
254
+
255
+ def _build_payload(report, snapshot, days, cost_estimator=None, assets=None):
256
+ """Build structured output with optional cost estimates."""
257
+ # Build asset lookup for cost estimation
258
+ asset_lookup = {a.id: a for a in assets} if assets else {}
259
+
260
+ return {
261
+ "snapshot_id": str(snapshot.id) if snapshot else None,
262
+ "account_id": snapshot.aws_account_id if snapshot else None,
263
+ "days_threshold": days,
264
+ "total_permissions": report.total_permissions,
265
+ "total_unused": report.total_unused,
266
+ "blast_radius_reduction": report.blast_radius_reduction,
267
+ "roles": [
268
+ {
269
+ "role_arn": r.role_arn,
270
+ "role_name": r.role_name,
271
+ "total_services": r.total_services,
272
+ "unused_services": r.unused_services,
273
+ "reduction": r.blast_radius_reduction,
274
+ "unused_capabilities": [
275
+ _build_capability_dict(c, r, cost_estimator, asset_lookup)
276
+ for c in r.unused_capabilities
277
+ ],
278
+ }
279
+ for r in report.role_reports
280
+ ],
281
+ }
282
+
283
+
284
+ def _build_capability_dict(c, role_report, cost_estimator, asset_lookup):
285
+ """Build capability dict with optional cost estimate."""
286
+ result = {
287
+ "service": c.service,
288
+ "service_name": c.service_name,
289
+ "days_unused": c.days_unused,
290
+ "risk_level": c.risk_level,
291
+ "recommendation": c.recommendation,
292
+ }
293
+
294
+ if cost_estimator:
295
+ estimate = _estimate_service_cost(c.service, asset_lookup, cost_estimator)
296
+ if estimate:
297
+ result["monthly_cost_usd_estimate"] = float(estimate.monthly_cost_usd_estimate)
298
+ result["cost_source"] = estimate.cost_source
299
+ result["confidence"] = estimate.confidence
300
+ result["assumptions"] = estimate.assumptions
301
+ else:
302
+ result["monthly_cost_usd_estimate"] = None
303
+ result["cost_source"] = cost_estimator.source
304
+ result["confidence"] = "unknown"
305
+ result["assumptions"] = ["No cost estimate available for this service"]
306
+
307
+ return result
308
+
309
+
310
+ def _estimate_service_cost(service: str, asset_lookup: dict, cost_estimator: CostEstimator):
311
+ """Estimate cost for a service by inspecting matching assets."""
312
+ if not service or service in {"*", "unknown"}:
313
+ return None
314
+ best = None
315
+ for asset in asset_lookup.values():
316
+ if not asset.asset_type.startswith(f"{service}:"):
317
+ continue
318
+ estimate = cost_estimator.estimate(asset)
319
+ if not estimate:
320
+ continue
321
+ if not best or estimate.monthly_cost_usd_estimate > best.monthly_cost_usd_estimate:
322
+ best = estimate
323
+ return best