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/__init__.py
ADDED
cyntrisec/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""AWS resource collectors."""
|
|
2
|
+
|
|
3
|
+
from cyntrisec.aws.collectors.ec2 import Ec2Collector
|
|
4
|
+
from cyntrisec.aws.collectors.iam import IamCollector
|
|
5
|
+
from cyntrisec.aws.collectors.lambda_ import LambdaCollector
|
|
6
|
+
from cyntrisec.aws.collectors.network import NetworkCollector
|
|
7
|
+
from cyntrisec.aws.collectors.rds import RdsCollector
|
|
8
|
+
from cyntrisec.aws.collectors.s3 import S3Collector
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Ec2Collector",
|
|
12
|
+
"IamCollector",
|
|
13
|
+
"S3Collector",
|
|
14
|
+
"LambdaCollector",
|
|
15
|
+
"RdsCollector",
|
|
16
|
+
"NetworkCollector",
|
|
17
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""EC2 Collector - Collect EC2 instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Ec2Collector:
|
|
11
|
+
"""Collect EC2 resources."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: boto3.Session, region: str):
|
|
14
|
+
self._ec2 = session.client("ec2", region_name=region)
|
|
15
|
+
self._region = region
|
|
16
|
+
|
|
17
|
+
def collect_all(self) -> dict[str, Any]:
|
|
18
|
+
"""Collect all EC2 data."""
|
|
19
|
+
return {
|
|
20
|
+
"instances": self._collect_instances(),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def _collect_instances(self) -> list[dict]:
|
|
24
|
+
"""Collect EC2 instances."""
|
|
25
|
+
instances = []
|
|
26
|
+
paginator = self._ec2.get_paginator("describe_instances")
|
|
27
|
+
for page in paginator.paginate():
|
|
28
|
+
for reservation in page.get("Reservations", []):
|
|
29
|
+
instances.extend(reservation.get("Instances", []))
|
|
30
|
+
return instances
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""IAM Collector - Collect IAM users, roles, policies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IamCollector:
|
|
11
|
+
"""Collect IAM resources (global)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: boto3.Session):
|
|
14
|
+
self._iam = session.client("iam")
|
|
15
|
+
|
|
16
|
+
def collect_all(self) -> dict[str, Any]:
|
|
17
|
+
"""Collect all IAM data."""
|
|
18
|
+
return {
|
|
19
|
+
"users": self._collect_users(),
|
|
20
|
+
"roles": self._collect_roles(),
|
|
21
|
+
"policies": self._collect_policies(),
|
|
22
|
+
"instance_profiles": self._collect_instance_profiles(),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
def _collect_users(self) -> list[dict]:
|
|
26
|
+
"""Collect IAM users."""
|
|
27
|
+
users = []
|
|
28
|
+
paginator = self._iam.get_paginator("list_users")
|
|
29
|
+
for page in paginator.paginate():
|
|
30
|
+
users.extend(page.get("Users", []))
|
|
31
|
+
return users
|
|
32
|
+
|
|
33
|
+
def _collect_roles(self) -> list[dict]:
|
|
34
|
+
"""Collect IAM roles with trust policies."""
|
|
35
|
+
roles = []
|
|
36
|
+
paginator = self._iam.get_paginator("list_roles")
|
|
37
|
+
for page in paginator.paginate():
|
|
38
|
+
for role in page.get("Roles", []):
|
|
39
|
+
role_name = role.get("RoleName")
|
|
40
|
+
if role_name:
|
|
41
|
+
role["InlinePolicies"] = self._collect_inline_role_policies(role_name)
|
|
42
|
+
role["AttachedPolicies"] = self._collect_attached_role_policies(role_name)
|
|
43
|
+
# Trust policy is included in list_roles
|
|
44
|
+
roles.append(role)
|
|
45
|
+
return roles
|
|
46
|
+
|
|
47
|
+
def _collect_policies(self) -> list[dict]:
|
|
48
|
+
"""Collect customer-managed policies."""
|
|
49
|
+
policies = []
|
|
50
|
+
paginator = self._iam.get_paginator("list_policies")
|
|
51
|
+
for page in paginator.paginate(Scope="Local"):
|
|
52
|
+
policies.extend(page.get("Policies", []))
|
|
53
|
+
return policies
|
|
54
|
+
|
|
55
|
+
def _collect_inline_role_policies(self, role_name: str) -> list[dict]:
|
|
56
|
+
"""Collect inline policy documents for a role."""
|
|
57
|
+
policies: list[dict] = []
|
|
58
|
+
paginator = self._iam.get_paginator("list_role_policies")
|
|
59
|
+
for page in paginator.paginate(RoleName=role_name):
|
|
60
|
+
for policy_name in page.get("PolicyNames", []):
|
|
61
|
+
try:
|
|
62
|
+
response = self._iam.get_role_policy(
|
|
63
|
+
RoleName=role_name,
|
|
64
|
+
PolicyName=policy_name,
|
|
65
|
+
)
|
|
66
|
+
except Exception:
|
|
67
|
+
continue
|
|
68
|
+
policies.append(
|
|
69
|
+
{
|
|
70
|
+
"PolicyName": policy_name,
|
|
71
|
+
"Document": response.get("PolicyDocument"),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
return policies
|
|
75
|
+
|
|
76
|
+
def _collect_attached_role_policies(self, role_name: str) -> list[dict]:
|
|
77
|
+
"""Collect attached managed policy documents for a role."""
|
|
78
|
+
policies: list[dict] = []
|
|
79
|
+
paginator = self._iam.get_paginator("list_attached_role_policies")
|
|
80
|
+
for page in paginator.paginate(RoleName=role_name):
|
|
81
|
+
for policy in page.get("AttachedPolicies", []):
|
|
82
|
+
policy_arn = policy.get("PolicyArn")
|
|
83
|
+
if not policy_arn:
|
|
84
|
+
continue
|
|
85
|
+
document = self._get_managed_policy_document(policy_arn)
|
|
86
|
+
policies.append(
|
|
87
|
+
{
|
|
88
|
+
"PolicyName": policy.get("PolicyName"),
|
|
89
|
+
"PolicyArn": policy_arn,
|
|
90
|
+
"Document": document,
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
return policies
|
|
94
|
+
|
|
95
|
+
def _get_managed_policy_document(self, policy_arn: str) -> dict | None:
|
|
96
|
+
"""Fetch the default policy document for a managed policy."""
|
|
97
|
+
try:
|
|
98
|
+
policy = self._iam.get_policy(PolicyArn=policy_arn)
|
|
99
|
+
version_id = policy.get("Policy", {}).get("DefaultVersionId")
|
|
100
|
+
if not version_id:
|
|
101
|
+
return None
|
|
102
|
+
version = self._iam.get_policy_version(
|
|
103
|
+
PolicyArn=policy_arn,
|
|
104
|
+
VersionId=version_id,
|
|
105
|
+
)
|
|
106
|
+
return version.get("PolicyVersion", {}).get("Document")
|
|
107
|
+
except Exception:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _collect_instance_profiles(self) -> list[dict]:
|
|
111
|
+
"""Collect IAM instance profiles and attached roles."""
|
|
112
|
+
profiles = []
|
|
113
|
+
paginator = self._iam.get_paginator("list_instance_profiles")
|
|
114
|
+
for page in paginator.paginate():
|
|
115
|
+
profiles.extend(page.get("InstanceProfiles", []))
|
|
116
|
+
return profiles
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Lambda Collector - Collect Lambda functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
from botocore.exceptions import ClientError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LambdaCollector:
|
|
12
|
+
"""Collect Lambda resources."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, session: boto3.Session, region: str):
|
|
15
|
+
self._lambda = session.client("lambda", region_name=region)
|
|
16
|
+
self._region = region
|
|
17
|
+
|
|
18
|
+
def collect_all(self) -> dict[str, Any]:
|
|
19
|
+
"""Collect all Lambda data."""
|
|
20
|
+
functions = self._collect_functions()
|
|
21
|
+
|
|
22
|
+
# Enrich with policies
|
|
23
|
+
for func in functions:
|
|
24
|
+
name = func["FunctionName"]
|
|
25
|
+
func["Policy"] = self._get_function_policy(name)
|
|
26
|
+
|
|
27
|
+
return {"functions": functions}
|
|
28
|
+
|
|
29
|
+
def _collect_functions(self) -> list[dict]:
|
|
30
|
+
"""List all Lambda functions."""
|
|
31
|
+
functions = []
|
|
32
|
+
paginator = self._lambda.get_paginator("list_functions")
|
|
33
|
+
for page in paginator.paginate():
|
|
34
|
+
functions.extend(page.get("Functions", []))
|
|
35
|
+
return functions
|
|
36
|
+
|
|
37
|
+
def _get_function_policy(self, function_name: str) -> dict | None:
|
|
38
|
+
"""Get function resource policy."""
|
|
39
|
+
try:
|
|
40
|
+
response = self._lambda.get_policy(FunctionName=function_name)
|
|
41
|
+
return {"Policy": response.get("Policy")}
|
|
42
|
+
except ClientError as e:
|
|
43
|
+
if e.response["Error"]["Code"] == "ResourceNotFoundException":
|
|
44
|
+
return None
|
|
45
|
+
return {"Error": str(e)}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Network Collector - Collect VPCs, subnets, security groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NetworkCollector:
|
|
11
|
+
"""Collect network resources."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: boto3.Session, region: str):
|
|
14
|
+
self._session = session
|
|
15
|
+
self._ec2 = session.client("ec2", region_name=region)
|
|
16
|
+
self._region = region
|
|
17
|
+
|
|
18
|
+
def collect_all(self) -> dict[str, Any]:
|
|
19
|
+
"""Collect all network data."""
|
|
20
|
+
return {
|
|
21
|
+
"vpcs": self._collect_vpcs(),
|
|
22
|
+
"subnets": self._collect_subnets(),
|
|
23
|
+
"security_groups": self._collect_security_groups(),
|
|
24
|
+
"route_tables": self._collect_route_tables(),
|
|
25
|
+
"internet_gateways": self._collect_internet_gateways(),
|
|
26
|
+
"nat_gateways": self._collect_nat_gateways(),
|
|
27
|
+
"load_balancers": self._collect_load_balancers(),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def _collect_vpcs(self) -> list[dict]:
|
|
31
|
+
"""Collect VPCs."""
|
|
32
|
+
response = self._ec2.describe_vpcs()
|
|
33
|
+
return response.get("Vpcs", [])
|
|
34
|
+
|
|
35
|
+
def _collect_subnets(self) -> list[dict]:
|
|
36
|
+
"""Collect subnets."""
|
|
37
|
+
response = self._ec2.describe_subnets()
|
|
38
|
+
return response.get("Subnets", [])
|
|
39
|
+
|
|
40
|
+
def _collect_security_groups(self) -> list[dict]:
|
|
41
|
+
"""Collect security groups."""
|
|
42
|
+
sgs = []
|
|
43
|
+
paginator = self._ec2.get_paginator("describe_security_groups")
|
|
44
|
+
for page in paginator.paginate():
|
|
45
|
+
sgs.extend(page.get("SecurityGroups", []))
|
|
46
|
+
return sgs
|
|
47
|
+
|
|
48
|
+
def _collect_route_tables(self) -> list[dict]:
|
|
49
|
+
"""Collect route tables."""
|
|
50
|
+
response = self._ec2.describe_route_tables()
|
|
51
|
+
return response.get("RouteTables", [])
|
|
52
|
+
|
|
53
|
+
def _collect_internet_gateways(self) -> list[dict]:
|
|
54
|
+
"""Collect internet gateways."""
|
|
55
|
+
response = self._ec2.describe_internet_gateways()
|
|
56
|
+
return response.get("InternetGateways", [])
|
|
57
|
+
|
|
58
|
+
def _collect_nat_gateways(self) -> list[dict]:
|
|
59
|
+
"""Collect NAT gateways."""
|
|
60
|
+
response = self._ec2.describe_nat_gateways()
|
|
61
|
+
return response.get("NatGateways", [])
|
|
62
|
+
|
|
63
|
+
def _collect_load_balancers(self) -> list[dict]:
|
|
64
|
+
"""Collect ELBv2 load balancers."""
|
|
65
|
+
try:
|
|
66
|
+
elb = self._session.client("elbv2", region_name=self._region)
|
|
67
|
+
response = elb.describe_load_balancers()
|
|
68
|
+
return response.get("LoadBalancers", [])
|
|
69
|
+
except Exception:
|
|
70
|
+
return []
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""RDS Collector - Collect RDS instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RdsCollector:
|
|
11
|
+
"""Collect RDS resources."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session: boto3.Session, region: str):
|
|
14
|
+
self._rds = session.client("rds", region_name=region)
|
|
15
|
+
self._region = region
|
|
16
|
+
|
|
17
|
+
def collect_all(self) -> dict[str, Any]:
|
|
18
|
+
"""Collect all RDS data."""
|
|
19
|
+
return {
|
|
20
|
+
"instances": self._collect_instances(),
|
|
21
|
+
"clusters": self._collect_clusters(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def _collect_instances(self) -> list[dict]:
|
|
25
|
+
"""Collect RDS DB instances."""
|
|
26
|
+
instances = []
|
|
27
|
+
paginator = self._rds.get_paginator("describe_db_instances")
|
|
28
|
+
for page in paginator.paginate():
|
|
29
|
+
instances.extend(page.get("DBInstances", []))
|
|
30
|
+
return instances
|
|
31
|
+
|
|
32
|
+
def _collect_clusters(self) -> list[dict]:
|
|
33
|
+
"""Collect RDS Aurora clusters."""
|
|
34
|
+
clusters = []
|
|
35
|
+
paginator = self._rds.get_paginator("describe_db_clusters")
|
|
36
|
+
for page in paginator.paginate():
|
|
37
|
+
clusters.extend(page.get("DBClusters", []))
|
|
38
|
+
return clusters
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""S3 Collector - Collect S3 buckets and policies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
from botocore.exceptions import ClientError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class S3Collector:
|
|
12
|
+
"""Collect S3 resources (global)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, session: boto3.Session):
|
|
15
|
+
self._s3 = session.client("s3")
|
|
16
|
+
|
|
17
|
+
def collect_all(self) -> dict[str, Any]:
|
|
18
|
+
"""Collect all S3 data."""
|
|
19
|
+
buckets = self._collect_buckets()
|
|
20
|
+
|
|
21
|
+
# Enrich with policies and ACLs
|
|
22
|
+
for bucket in buckets:
|
|
23
|
+
name = bucket["Name"]
|
|
24
|
+
bucket["Policy"] = self._get_bucket_policy(name)
|
|
25
|
+
bucket["Acl"] = self._get_bucket_acl(name)
|
|
26
|
+
bucket["PublicAccessBlock"] = self._get_public_access_block(name)
|
|
27
|
+
bucket["Location"] = self._get_bucket_location(name)
|
|
28
|
+
|
|
29
|
+
return {"buckets": buckets}
|
|
30
|
+
|
|
31
|
+
def _collect_buckets(self) -> list[dict]:
|
|
32
|
+
"""List all buckets."""
|
|
33
|
+
response = self._s3.list_buckets()
|
|
34
|
+
return response.get("Buckets", [])
|
|
35
|
+
|
|
36
|
+
def _get_bucket_policy(self, bucket_name: str) -> dict | None:
|
|
37
|
+
"""Get bucket policy."""
|
|
38
|
+
try:
|
|
39
|
+
response = self._s3.get_bucket_policy(Bucket=bucket_name)
|
|
40
|
+
return {"Policy": response.get("Policy")}
|
|
41
|
+
except ClientError as e:
|
|
42
|
+
if e.response["Error"]["Code"] == "NoSuchBucketPolicy":
|
|
43
|
+
return None
|
|
44
|
+
return {"Error": str(e)}
|
|
45
|
+
|
|
46
|
+
def _get_bucket_acl(self, bucket_name: str) -> dict | None:
|
|
47
|
+
"""Get bucket ACL."""
|
|
48
|
+
try:
|
|
49
|
+
return self._s3.get_bucket_acl(Bucket=bucket_name)
|
|
50
|
+
except ClientError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def _get_public_access_block(self, bucket_name: str) -> dict | None:
|
|
54
|
+
"""Get public access block configuration."""
|
|
55
|
+
try:
|
|
56
|
+
response = self._s3.get_public_access_block(Bucket=bucket_name)
|
|
57
|
+
return response.get("PublicAccessBlockConfiguration")
|
|
58
|
+
except ClientError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def _get_bucket_location(self, bucket_name: str) -> str:
|
|
62
|
+
"""Get bucket region."""
|
|
63
|
+
try:
|
|
64
|
+
response = self._s3.get_bucket_location(Bucket=bucket_name)
|
|
65
|
+
# None means us-east-1
|
|
66
|
+
return response.get("LocationConstraint") or "us-east-1"
|
|
67
|
+
except ClientError:
|
|
68
|
+
return "unknown"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Usage Collector - Collect IAM last-accessed data for waste analysis.
|
|
3
|
+
|
|
4
|
+
Uses AWS IAM's generate_service_last_accessed_details API to determine
|
|
5
|
+
which permissions have actually been used vs which are just granted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ServiceAccess:
|
|
21
|
+
"""Service access record from IAM last-accessed data."""
|
|
22
|
+
|
|
23
|
+
service_name: str
|
|
24
|
+
service_namespace: str
|
|
25
|
+
last_authenticated: datetime | None = None
|
|
26
|
+
last_authenticated_entity: str | None = None
|
|
27
|
+
total_authenticated_entities: int = 0
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_unused(self) -> bool:
|
|
31
|
+
"""Check if service was never accessed."""
|
|
32
|
+
return self.last_authenticated is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ActionAccess:
|
|
37
|
+
"""Action-level access record."""
|
|
38
|
+
|
|
39
|
+
action_name: str
|
|
40
|
+
last_accessed: datetime | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_unused(self) -> bool:
|
|
44
|
+
return self.last_accessed is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RoleUsageReport:
|
|
49
|
+
"""Usage report for an IAM role."""
|
|
50
|
+
|
|
51
|
+
role_arn: str
|
|
52
|
+
role_name: str
|
|
53
|
+
services: list[ServiceAccess] = field(default_factory=list)
|
|
54
|
+
actions: list[ActionAccess] = field(default_factory=list)
|
|
55
|
+
generated_at: datetime = field(default_factory=datetime.utcnow)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def unused_services(self) -> list[ServiceAccess]:
|
|
59
|
+
return [s for s in self.services if s.is_unused]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def used_services(self) -> list[ServiceAccess]:
|
|
63
|
+
return [s for s in self.services if not s.is_unused]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class UsageCollector:
|
|
67
|
+
"""
|
|
68
|
+
Collect IAM usage data using AWS Access Advisor.
|
|
69
|
+
|
|
70
|
+
The last-accessed data shows which services a role has permissions for
|
|
71
|
+
and when those permissions were last used.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, session):
|
|
75
|
+
"""
|
|
76
|
+
Initialize with a boto3 Session.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
session: boto3.Session with IAM permissions
|
|
80
|
+
"""
|
|
81
|
+
self._session = session
|
|
82
|
+
self._iam = session.client("iam")
|
|
83
|
+
|
|
84
|
+
def get_role_usage(
|
|
85
|
+
self,
|
|
86
|
+
role_arn: str,
|
|
87
|
+
*,
|
|
88
|
+
max_wait_seconds: int = 30,
|
|
89
|
+
) -> RoleUsageReport | None:
|
|
90
|
+
"""
|
|
91
|
+
Get usage report for an IAM role.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
role_arn: ARN of the role to analyze
|
|
95
|
+
max_wait_seconds: Maximum time to wait for report generation
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
RoleUsageReport with service access data, or None if failed
|
|
99
|
+
"""
|
|
100
|
+
role_name = role_arn.split("/")[-1]
|
|
101
|
+
log.debug("Generating last-accessed report for %s", role_name)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
# Start the report generation
|
|
105
|
+
response = self._iam.generate_service_last_accessed_details(
|
|
106
|
+
Arn=role_arn,
|
|
107
|
+
Granularity="SERVICE_LEVEL",
|
|
108
|
+
)
|
|
109
|
+
job_id = response["JobId"]
|
|
110
|
+
|
|
111
|
+
# Poll for completion
|
|
112
|
+
start_time = time.time()
|
|
113
|
+
while time.time() - start_time < max_wait_seconds:
|
|
114
|
+
result = self._iam.get_service_last_accessed_details(JobId=job_id)
|
|
115
|
+
status = result.get("JobStatus")
|
|
116
|
+
|
|
117
|
+
if status == "COMPLETED":
|
|
118
|
+
return self._parse_report(role_arn, role_name, result)
|
|
119
|
+
elif status == "FAILED":
|
|
120
|
+
log.warning("Last-accessed report failed for %s", role_name)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
time.sleep(1)
|
|
124
|
+
|
|
125
|
+
log.warning("Timeout waiting for last-accessed report for %s", role_name)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
log.debug("Error getting usage for %s: %s", role_name, e)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def _parse_report(
|
|
133
|
+
self,
|
|
134
|
+
role_arn: str,
|
|
135
|
+
role_name: str,
|
|
136
|
+
result: dict[str, Any],
|
|
137
|
+
) -> RoleUsageReport:
|
|
138
|
+
"""Parse the IAM last-accessed response."""
|
|
139
|
+
services = []
|
|
140
|
+
|
|
141
|
+
for svc in result.get("ServicesLastAccessed", []):
|
|
142
|
+
last_auth = svc.get("LastAuthenticated")
|
|
143
|
+
services.append(
|
|
144
|
+
ServiceAccess(
|
|
145
|
+
service_name=svc.get("ServiceName", ""),
|
|
146
|
+
service_namespace=svc.get("ServiceNamespace", ""),
|
|
147
|
+
last_authenticated=last_auth,
|
|
148
|
+
last_authenticated_entity=svc.get("LastAuthenticatedEntity"),
|
|
149
|
+
total_authenticated_entities=svc.get("TotalAuthenticatedEntities", 0),
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return RoleUsageReport(
|
|
154
|
+
role_arn=role_arn,
|
|
155
|
+
role_name=role_name,
|
|
156
|
+
services=services,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def collect_all_roles(
|
|
160
|
+
self,
|
|
161
|
+
role_arns: list[str],
|
|
162
|
+
*,
|
|
163
|
+
max_roles: int = 50,
|
|
164
|
+
) -> list[RoleUsageReport]:
|
|
165
|
+
"""
|
|
166
|
+
Collect usage reports for multiple roles.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
role_arns: List of role ARNs to analyze
|
|
170
|
+
max_roles: Maximum number of roles to analyze (API throttling)
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of usage reports
|
|
174
|
+
"""
|
|
175
|
+
reports = []
|
|
176
|
+
|
|
177
|
+
for i, arn in enumerate(role_arns[:max_roles]):
|
|
178
|
+
log.info(
|
|
179
|
+
"Analyzing role %d/%d: %s",
|
|
180
|
+
i + 1,
|
|
181
|
+
min(len(role_arns), max_roles),
|
|
182
|
+
arn.split("/")[-1],
|
|
183
|
+
)
|
|
184
|
+
report = self.get_role_usage(arn)
|
|
185
|
+
if report:
|
|
186
|
+
reports.append(report)
|
|
187
|
+
|
|
188
|
+
return reports
|