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.
- cyntrisec/__init__.py +3 -0
- cyntrisec/__main__.py +6 -0
- cyntrisec/aws/__init__.py +6 -0
- cyntrisec/aws/collectors/__init__.py +17 -0
- cyntrisec/aws/collectors/ec2.py +30 -0
- cyntrisec/aws/collectors/iam.py +116 -0
- cyntrisec/aws/collectors/lambda_.py +45 -0
- cyntrisec/aws/collectors/network.py +70 -0
- cyntrisec/aws/collectors/rds.py +38 -0
- cyntrisec/aws/collectors/s3.py +68 -0
- cyntrisec/aws/collectors/usage.py +188 -0
- cyntrisec/aws/credentials.py +153 -0
- cyntrisec/aws/normalizers/__init__.py +17 -0
- cyntrisec/aws/normalizers/ec2.py +115 -0
- cyntrisec/aws/normalizers/iam.py +182 -0
- cyntrisec/aws/normalizers/lambda_.py +83 -0
- cyntrisec/aws/normalizers/network.py +225 -0
- cyntrisec/aws/normalizers/rds.py +130 -0
- cyntrisec/aws/normalizers/s3.py +184 -0
- cyntrisec/aws/relationship_builder.py +1359 -0
- cyntrisec/aws/scanner.py +303 -0
- cyntrisec/cli/__init__.py +5 -0
- cyntrisec/cli/analyze.py +747 -0
- cyntrisec/cli/ask.py +412 -0
- cyntrisec/cli/can.py +307 -0
- cyntrisec/cli/comply.py +226 -0
- cyntrisec/cli/cuts.py +231 -0
- cyntrisec/cli/diff.py +332 -0
- cyntrisec/cli/errors.py +105 -0
- cyntrisec/cli/explain.py +348 -0
- cyntrisec/cli/main.py +114 -0
- cyntrisec/cli/manifest.py +893 -0
- cyntrisec/cli/output.py +117 -0
- cyntrisec/cli/remediate.py +643 -0
- cyntrisec/cli/report.py +462 -0
- cyntrisec/cli/scan.py +207 -0
- cyntrisec/cli/schemas.py +391 -0
- cyntrisec/cli/serve.py +164 -0
- cyntrisec/cli/setup.py +260 -0
- cyntrisec/cli/validate.py +101 -0
- cyntrisec/cli/waste.py +323 -0
- cyntrisec/core/__init__.py +31 -0
- cyntrisec/core/business_config.py +110 -0
- cyntrisec/core/business_logic.py +131 -0
- cyntrisec/core/compliance.py +437 -0
- cyntrisec/core/cost_estimator.py +301 -0
- cyntrisec/core/cuts.py +360 -0
- cyntrisec/core/diff.py +361 -0
- cyntrisec/core/graph.py +202 -0
- cyntrisec/core/paths.py +830 -0
- cyntrisec/core/schema.py +317 -0
- cyntrisec/core/simulator.py +371 -0
- cyntrisec/core/waste.py +309 -0
- cyntrisec/mcp/__init__.py +5 -0
- cyntrisec/mcp/server.py +862 -0
- cyntrisec/storage/__init__.py +7 -0
- cyntrisec/storage/filesystem.py +344 -0
- cyntrisec/storage/memory.py +113 -0
- cyntrisec/storage/protocol.py +92 -0
- cyntrisec-0.1.7.dist-info/METADATA +672 -0
- cyntrisec-0.1.7.dist-info/RECORD +65 -0
- cyntrisec-0.1.7.dist-info/WHEEL +4 -0
- cyntrisec-0.1.7.dist-info/entry_points.txt +2 -0
- cyntrisec-0.1.7.dist-info/licenses/LICENSE +190 -0
- 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
|