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
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Network Normalizer - Transform network data to canonical schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cyntrisec.core.schema import Asset, Finding, FindingSeverity, Relationship
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NetworkNormalizer:
|
|
12
|
+
"""Normalize network data to canonical assets and relationships."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
snapshot_id: uuid.UUID,
|
|
17
|
+
region: str,
|
|
18
|
+
account_id: str,
|
|
19
|
+
):
|
|
20
|
+
self._snapshot_id = snapshot_id
|
|
21
|
+
self._region = region
|
|
22
|
+
self._account_id = account_id
|
|
23
|
+
self._asset_map: dict[str, Asset] = {}
|
|
24
|
+
|
|
25
|
+
def normalize(
|
|
26
|
+
self,
|
|
27
|
+
data: dict[str, Any],
|
|
28
|
+
) -> tuple[list[Asset], list[Relationship], list[Finding]]:
|
|
29
|
+
"""Normalize network data."""
|
|
30
|
+
assets: list[Asset] = []
|
|
31
|
+
relationships: list[Relationship] = []
|
|
32
|
+
findings: list[Finding] = []
|
|
33
|
+
|
|
34
|
+
# VPCs
|
|
35
|
+
for vpc in data.get("vpcs", []):
|
|
36
|
+
asset = self._normalize_vpc(vpc)
|
|
37
|
+
assets.append(asset)
|
|
38
|
+
self._asset_map[vpc["VpcId"]] = asset
|
|
39
|
+
|
|
40
|
+
# Subnets
|
|
41
|
+
for subnet in data.get("subnets", []):
|
|
42
|
+
asset = self._normalize_subnet(subnet)
|
|
43
|
+
assets.append(asset)
|
|
44
|
+
self._asset_map[subnet["SubnetId"]] = asset
|
|
45
|
+
|
|
46
|
+
# Create VPC -> Subnet relationship
|
|
47
|
+
vpc_id = subnet.get("VpcId")
|
|
48
|
+
if vpc_id and vpc_id in self._asset_map:
|
|
49
|
+
relationships.append(
|
|
50
|
+
Relationship(
|
|
51
|
+
snapshot_id=self._snapshot_id,
|
|
52
|
+
source_asset_id=self._asset_map[vpc_id].id,
|
|
53
|
+
target_asset_id=asset.id,
|
|
54
|
+
relationship_type="CONTAINS",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Security Groups
|
|
59
|
+
for sg in data.get("security_groups", []):
|
|
60
|
+
asset, sg_findings = self._normalize_security_group(sg)
|
|
61
|
+
assets.append(asset)
|
|
62
|
+
self._asset_map[sg["GroupId"]] = asset
|
|
63
|
+
findings.extend(sg_findings)
|
|
64
|
+
|
|
65
|
+
# Load Balancers
|
|
66
|
+
for lb in data.get("load_balancers", []):
|
|
67
|
+
asset = self._normalize_load_balancer(lb)
|
|
68
|
+
assets.append(asset)
|
|
69
|
+
|
|
70
|
+
return assets, relationships, findings
|
|
71
|
+
|
|
72
|
+
def _normalize_vpc(self, vpc: dict[str, Any]) -> Asset:
|
|
73
|
+
"""Normalize a VPC."""
|
|
74
|
+
vpc_id = vpc["VpcId"]
|
|
75
|
+
|
|
76
|
+
name = vpc_id
|
|
77
|
+
for tag in vpc.get("Tags", []):
|
|
78
|
+
if tag["Key"] == "Name":
|
|
79
|
+
name = tag["Value"]
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
return Asset(
|
|
83
|
+
snapshot_id=self._snapshot_id,
|
|
84
|
+
asset_type="ec2:vpc",
|
|
85
|
+
aws_region=self._region,
|
|
86
|
+
aws_resource_id=vpc_id,
|
|
87
|
+
arn=f"arn:aws:ec2:{self._region}:{self._account_id}:vpc/{vpc_id}",
|
|
88
|
+
name=name,
|
|
89
|
+
properties={
|
|
90
|
+
"cidr_block": vpc.get("CidrBlock"),
|
|
91
|
+
"is_default": vpc.get("IsDefault", False),
|
|
92
|
+
"state": vpc.get("State"),
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _normalize_subnet(self, subnet: dict[str, Any]) -> Asset:
|
|
97
|
+
"""Normalize a subnet."""
|
|
98
|
+
subnet_id = subnet["SubnetId"]
|
|
99
|
+
|
|
100
|
+
name = subnet_id
|
|
101
|
+
for tag in subnet.get("Tags", []):
|
|
102
|
+
if tag["Key"] == "Name":
|
|
103
|
+
name = tag["Value"]
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
# Determine if public (has auto-assign public IP)
|
|
107
|
+
is_public = subnet.get("MapPublicIpOnLaunch", False)
|
|
108
|
+
|
|
109
|
+
return Asset(
|
|
110
|
+
snapshot_id=self._snapshot_id,
|
|
111
|
+
asset_type="ec2:subnet",
|
|
112
|
+
aws_region=self._region,
|
|
113
|
+
aws_resource_id=subnet_id,
|
|
114
|
+
arn=f"arn:aws:ec2:{self._region}:{self._account_id}:subnet/{subnet_id}",
|
|
115
|
+
name=name,
|
|
116
|
+
properties={
|
|
117
|
+
"vpc_id": subnet.get("VpcId"),
|
|
118
|
+
"cidr_block": subnet.get("CidrBlock"),
|
|
119
|
+
"availability_zone": subnet.get("AvailabilityZone"),
|
|
120
|
+
"map_public_ip_on_launch": is_public,
|
|
121
|
+
},
|
|
122
|
+
is_internet_facing=is_public,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _normalize_security_group(
|
|
126
|
+
self,
|
|
127
|
+
sg: dict[str, Any],
|
|
128
|
+
) -> tuple[Asset, list[Finding]]:
|
|
129
|
+
"""Normalize a security group."""
|
|
130
|
+
sg_id = sg["GroupId"]
|
|
131
|
+
sg_name = sg.get("GroupName", sg_id)
|
|
132
|
+
|
|
133
|
+
asset = Asset(
|
|
134
|
+
snapshot_id=self._snapshot_id,
|
|
135
|
+
asset_type="ec2:security-group",
|
|
136
|
+
aws_region=self._region,
|
|
137
|
+
aws_resource_id=sg_id,
|
|
138
|
+
arn=f"arn:aws:ec2:{self._region}:{self._account_id}:security-group/{sg_id}",
|
|
139
|
+
name=sg_name,
|
|
140
|
+
properties={
|
|
141
|
+
"vpc_id": sg.get("VpcId"),
|
|
142
|
+
"description": sg.get("Description"),
|
|
143
|
+
"ingress_rules": sg.get("IpPermissions", []),
|
|
144
|
+
"egress_rules": sg.get("IpPermissionsEgress", []),
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
findings: list[Finding] = []
|
|
149
|
+
|
|
150
|
+
# Check for overly permissive ingress rules
|
|
151
|
+
for rule in sg.get("IpPermissions", []):
|
|
152
|
+
for ip_range in rule.get("IpRanges", []):
|
|
153
|
+
cidr = ip_range.get("CidrIp", "")
|
|
154
|
+
if cidr == "0.0.0.0/0":
|
|
155
|
+
from_port = rule.get("FromPort", "all")
|
|
156
|
+
to_port = rule.get("ToPort", "all")
|
|
157
|
+
protocol = rule.get("IpProtocol", "all")
|
|
158
|
+
severity = self._severity_for_open_rule(from_port, to_port, protocol)
|
|
159
|
+
|
|
160
|
+
findings.append(
|
|
161
|
+
Finding(
|
|
162
|
+
snapshot_id=self._snapshot_id,
|
|
163
|
+
asset_id=asset.id,
|
|
164
|
+
finding_type="security-group-open-to-world",
|
|
165
|
+
severity=severity,
|
|
166
|
+
title=f"Security group {sg_name} allows inbound from 0.0.0.0/0",
|
|
167
|
+
description=f"Ingress rule allows traffic from anywhere on port {from_port}-{to_port}",
|
|
168
|
+
remediation="Restrict the source IP range to known addresses",
|
|
169
|
+
evidence={"rule": rule, "cidr": cidr},
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
for ip_range in rule.get("Ipv6Ranges", []):
|
|
173
|
+
cidr = ip_range.get("CidrIpv6", "")
|
|
174
|
+
if cidr == "::/0":
|
|
175
|
+
from_port = rule.get("FromPort", "all")
|
|
176
|
+
to_port = rule.get("ToPort", "all")
|
|
177
|
+
protocol = rule.get("IpProtocol", "all")
|
|
178
|
+
severity = self._severity_for_open_rule(from_port, to_port, protocol)
|
|
179
|
+
|
|
180
|
+
findings.append(
|
|
181
|
+
Finding(
|
|
182
|
+
snapshot_id=self._snapshot_id,
|
|
183
|
+
asset_id=asset.id,
|
|
184
|
+
finding_type="security-group-open-to-world",
|
|
185
|
+
severity=severity,
|
|
186
|
+
title=f"Security group {sg_name} allows inbound from ::/0",
|
|
187
|
+
description=f"Ingress rule allows traffic from anywhere on port {from_port}-{to_port}",
|
|
188
|
+
remediation="Restrict the source IP range to known addresses",
|
|
189
|
+
evidence={"rule": rule, "cidr": cidr},
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return asset, findings
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _severity_for_open_rule(from_port, to_port, protocol) -> FindingSeverity:
|
|
197
|
+
"""Determine severity for an open ingress rule."""
|
|
198
|
+
if from_port in [22, 3389] or to_port in [22, 3389]:
|
|
199
|
+
return FindingSeverity.critical
|
|
200
|
+
if protocol == "-1":
|
|
201
|
+
return FindingSeverity.critical
|
|
202
|
+
return FindingSeverity.high
|
|
203
|
+
|
|
204
|
+
def _normalize_load_balancer(self, lb: dict[str, Any]) -> Asset:
|
|
205
|
+
"""Normalize a load balancer."""
|
|
206
|
+
lb_arn = lb["LoadBalancerArn"]
|
|
207
|
+
lb_name = lb.get("LoadBalancerName", lb_arn.split("/")[-1])
|
|
208
|
+
scheme = lb.get("Scheme", "internal")
|
|
209
|
+
|
|
210
|
+
return Asset(
|
|
211
|
+
snapshot_id=self._snapshot_id,
|
|
212
|
+
asset_type="elbv2:load-balancer",
|
|
213
|
+
aws_region=self._region,
|
|
214
|
+
aws_resource_id=lb_arn,
|
|
215
|
+
arn=lb_arn,
|
|
216
|
+
name=lb_name,
|
|
217
|
+
properties={
|
|
218
|
+
"type": lb.get("Type"),
|
|
219
|
+
"scheme": scheme,
|
|
220
|
+
"dns_name": lb.get("DNSName"),
|
|
221
|
+
"vpc_id": lb.get("VpcId"),
|
|
222
|
+
"security_groups": lb.get("SecurityGroups", []),
|
|
223
|
+
},
|
|
224
|
+
is_internet_facing=(scheme == "internet-facing"),
|
|
225
|
+
)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""RDS Normalizer - Transform RDS data to canonical schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cyntrisec.core.schema import Asset, Finding, FindingSeverity, Relationship
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RdsNormalizer:
|
|
12
|
+
"""Normalize RDS data to canonical assets."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
snapshot_id: uuid.UUID,
|
|
17
|
+
region: str,
|
|
18
|
+
account_id: str,
|
|
19
|
+
):
|
|
20
|
+
self._snapshot_id = snapshot_id
|
|
21
|
+
self._region = region
|
|
22
|
+
self._account_id = account_id
|
|
23
|
+
|
|
24
|
+
def normalize(
|
|
25
|
+
self,
|
|
26
|
+
data: dict[str, Any],
|
|
27
|
+
) -> tuple[list[Asset], list[Relationship], list[Finding]]:
|
|
28
|
+
"""Normalize RDS data."""
|
|
29
|
+
assets: list[Asset] = []
|
|
30
|
+
findings: list[Finding] = []
|
|
31
|
+
|
|
32
|
+
for instance in data.get("instances", []):
|
|
33
|
+
asset, instance_findings = self._normalize_instance(instance)
|
|
34
|
+
assets.append(asset)
|
|
35
|
+
findings.extend(instance_findings)
|
|
36
|
+
|
|
37
|
+
for cluster in data.get("clusters", []):
|
|
38
|
+
asset, cluster_findings = self._normalize_cluster(cluster)
|
|
39
|
+
assets.append(asset)
|
|
40
|
+
findings.extend(cluster_findings)
|
|
41
|
+
|
|
42
|
+
return assets, [], findings
|
|
43
|
+
|
|
44
|
+
def _normalize_instance(
|
|
45
|
+
self,
|
|
46
|
+
instance: dict[str, Any],
|
|
47
|
+
) -> tuple[Asset, list[Finding]]:
|
|
48
|
+
"""Normalize an RDS DB instance."""
|
|
49
|
+
db_id = instance["DBInstanceIdentifier"]
|
|
50
|
+
db_arn = instance["DBInstanceArn"]
|
|
51
|
+
|
|
52
|
+
asset = Asset(
|
|
53
|
+
snapshot_id=self._snapshot_id,
|
|
54
|
+
asset_type="rds:db-instance",
|
|
55
|
+
aws_region=self._region,
|
|
56
|
+
aws_resource_id=db_arn,
|
|
57
|
+
arn=db_arn,
|
|
58
|
+
name=db_id,
|
|
59
|
+
properties={
|
|
60
|
+
"engine": instance.get("Engine"),
|
|
61
|
+
"engine_version": instance.get("EngineVersion"),
|
|
62
|
+
"instance_class": instance.get("DBInstanceClass"),
|
|
63
|
+
"storage_encrypted": instance.get("StorageEncrypted"),
|
|
64
|
+
"publicly_accessible": instance.get("PubliclyAccessible"),
|
|
65
|
+
"multi_az": instance.get("MultiAZ"),
|
|
66
|
+
"vpc_security_groups": [
|
|
67
|
+
sg["VpcSecurityGroupId"] for sg in instance.get("VpcSecurityGroups", [])
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
is_internet_facing=instance.get("PubliclyAccessible", False),
|
|
71
|
+
is_sensitive_target=True, # Databases are sensitive
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
findings: list[Finding] = []
|
|
75
|
+
|
|
76
|
+
# Check for public accessibility
|
|
77
|
+
if instance.get("PubliclyAccessible"):
|
|
78
|
+
findings.append(
|
|
79
|
+
Finding(
|
|
80
|
+
snapshot_id=self._snapshot_id,
|
|
81
|
+
asset_id=asset.id,
|
|
82
|
+
finding_type="rds-publicly-accessible",
|
|
83
|
+
severity=FindingSeverity.critical,
|
|
84
|
+
title=f"RDS instance {db_id} is publicly accessible",
|
|
85
|
+
description="Database is configured to be publicly accessible from the internet",
|
|
86
|
+
remediation="Disable public accessibility and use VPC endpoints or bastion hosts",
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Check for encryption
|
|
91
|
+
if not instance.get("StorageEncrypted"):
|
|
92
|
+
findings.append(
|
|
93
|
+
Finding(
|
|
94
|
+
snapshot_id=self._snapshot_id,
|
|
95
|
+
asset_id=asset.id,
|
|
96
|
+
finding_type="rds-not-encrypted",
|
|
97
|
+
severity=FindingSeverity.high,
|
|
98
|
+
title=f"RDS instance {db_id} is not encrypted",
|
|
99
|
+
description="Database storage is not encrypted at rest",
|
|
100
|
+
remediation="Enable storage encryption (requires database recreation)",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return asset, findings
|
|
105
|
+
|
|
106
|
+
def _normalize_cluster(
|
|
107
|
+
self,
|
|
108
|
+
cluster: dict[str, Any],
|
|
109
|
+
) -> tuple[Asset, list[Finding]]:
|
|
110
|
+
"""Normalize an RDS Aurora cluster."""
|
|
111
|
+
cluster_id = cluster["DBClusterIdentifier"]
|
|
112
|
+
cluster_arn = cluster["DBClusterArn"]
|
|
113
|
+
|
|
114
|
+
asset = Asset(
|
|
115
|
+
snapshot_id=self._snapshot_id,
|
|
116
|
+
asset_type="rds:db-cluster",
|
|
117
|
+
aws_region=self._region,
|
|
118
|
+
aws_resource_id=cluster_arn,
|
|
119
|
+
arn=cluster_arn,
|
|
120
|
+
name=cluster_id,
|
|
121
|
+
properties={
|
|
122
|
+
"engine": cluster.get("Engine"),
|
|
123
|
+
"engine_version": cluster.get("EngineVersion"),
|
|
124
|
+
"storage_encrypted": cluster.get("StorageEncrypted"),
|
|
125
|
+
"multi_az": cluster.get("MultiAZ"),
|
|
126
|
+
},
|
|
127
|
+
is_sensitive_target=True,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return asset, []
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""S3 Normalizer - Transform S3 data to canonical schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from cyntrisec.core.schema import Asset, Finding, FindingSeverity, Relationship
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class S3Normalizer:
|
|
13
|
+
"""Normalize S3 data to canonical assets and findings."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, snapshot_id: uuid.UUID):
|
|
16
|
+
self._snapshot_id = snapshot_id
|
|
17
|
+
|
|
18
|
+
def normalize(
|
|
19
|
+
self,
|
|
20
|
+
data: dict[str, Any],
|
|
21
|
+
) -> tuple[list[Asset], list[Relationship], list[Finding]]:
|
|
22
|
+
"""Normalize S3 data."""
|
|
23
|
+
assets: list[Asset] = []
|
|
24
|
+
findings: list[Finding] = []
|
|
25
|
+
|
|
26
|
+
for bucket in data.get("buckets", []):
|
|
27
|
+
asset, bucket_findings = self._normalize_bucket(bucket)
|
|
28
|
+
assets.append(asset)
|
|
29
|
+
findings.extend(bucket_findings)
|
|
30
|
+
|
|
31
|
+
return assets, [], findings
|
|
32
|
+
|
|
33
|
+
def _normalize_bucket(
|
|
34
|
+
self,
|
|
35
|
+
bucket: dict[str, Any],
|
|
36
|
+
) -> tuple[Asset, list[Finding]]:
|
|
37
|
+
"""Normalize an S3 bucket."""
|
|
38
|
+
bucket_name = bucket["Name"]
|
|
39
|
+
region = bucket.get("Location", "us-east-1")
|
|
40
|
+
|
|
41
|
+
# Check if this is a sensitive bucket (by name heuristic)
|
|
42
|
+
is_sensitive = any(
|
|
43
|
+
kw in bucket_name.lower()
|
|
44
|
+
for kw in ["backup", "secret", "credential", "key", "log", "audit"]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
asset = Asset(
|
|
48
|
+
snapshot_id=self._snapshot_id,
|
|
49
|
+
asset_type="s3:bucket",
|
|
50
|
+
aws_region=region,
|
|
51
|
+
aws_resource_id=f"arn:aws:s3:::{bucket_name}",
|
|
52
|
+
arn=f"arn:aws:s3:::{bucket_name}",
|
|
53
|
+
name=bucket_name,
|
|
54
|
+
properties={
|
|
55
|
+
"creation_date": str(bucket.get("CreationDate")),
|
|
56
|
+
"region": region,
|
|
57
|
+
"has_policy": bucket.get("Policy") is not None,
|
|
58
|
+
"public_access_block": bucket.get("PublicAccessBlock"),
|
|
59
|
+
},
|
|
60
|
+
is_sensitive_target=is_sensitive,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
findings: list[Finding] = []
|
|
64
|
+
|
|
65
|
+
# Check ACL for public access
|
|
66
|
+
acl = bucket.get("Acl", {})
|
|
67
|
+
for grant in acl.get("Grants", []):
|
|
68
|
+
grantee = grant.get("Grantee", {})
|
|
69
|
+
grantee_uri = grantee.get("URI", "")
|
|
70
|
+
|
|
71
|
+
if "AllUsers" in grantee_uri:
|
|
72
|
+
findings.append(
|
|
73
|
+
Finding(
|
|
74
|
+
snapshot_id=self._snapshot_id,
|
|
75
|
+
asset_id=asset.id,
|
|
76
|
+
finding_type="s3-bucket-public-acl",
|
|
77
|
+
severity=FindingSeverity.critical,
|
|
78
|
+
title=f"S3 bucket {bucket_name} has public ACL",
|
|
79
|
+
description="Bucket ACL grants access to all users (public)",
|
|
80
|
+
remediation="Remove public ACL grants and enable Block Public Access",
|
|
81
|
+
evidence={"grant": grant},
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
elif "AuthenticatedUsers" in grantee_uri:
|
|
85
|
+
findings.append(
|
|
86
|
+
Finding(
|
|
87
|
+
snapshot_id=self._snapshot_id,
|
|
88
|
+
asset_id=asset.id,
|
|
89
|
+
finding_type="s3-bucket-authenticated-users-acl",
|
|
90
|
+
severity=FindingSeverity.high,
|
|
91
|
+
title=f"S3 bucket {bucket_name} allows authenticated users",
|
|
92
|
+
description="Bucket ACL grants access to any authenticated AWS user",
|
|
93
|
+
remediation="Remove this grant - it allows any AWS account access",
|
|
94
|
+
evidence={"grant": grant},
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Check public access block
|
|
99
|
+
pab = bucket.get("PublicAccessBlock")
|
|
100
|
+
if not pab:
|
|
101
|
+
findings.append(
|
|
102
|
+
Finding(
|
|
103
|
+
snapshot_id=self._snapshot_id,
|
|
104
|
+
asset_id=asset.id,
|
|
105
|
+
finding_type="s3-bucket-no-public-access-block",
|
|
106
|
+
severity=FindingSeverity.medium,
|
|
107
|
+
title=f"S3 bucket {bucket_name} has no public access block",
|
|
108
|
+
description="Block Public Access is not configured for this bucket",
|
|
109
|
+
remediation="Enable Block Public Access at the bucket level",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
elif not all(
|
|
113
|
+
[
|
|
114
|
+
pab.get("BlockPublicAcls"),
|
|
115
|
+
pab.get("IgnorePublicAcls"),
|
|
116
|
+
pab.get("BlockPublicPolicy"),
|
|
117
|
+
pab.get("RestrictPublicBuckets"),
|
|
118
|
+
]
|
|
119
|
+
):
|
|
120
|
+
findings.append(
|
|
121
|
+
Finding(
|
|
122
|
+
snapshot_id=self._snapshot_id,
|
|
123
|
+
asset_id=asset.id,
|
|
124
|
+
finding_type="s3-bucket-partial-public-access-block",
|
|
125
|
+
severity=FindingSeverity.low,
|
|
126
|
+
title=f"S3 bucket {bucket_name} has partial public access block",
|
|
127
|
+
description="Some Block Public Access settings are not enabled",
|
|
128
|
+
evidence={"public_access_block": pab},
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Check bucket policy for public access
|
|
133
|
+
policy = bucket.get("Policy")
|
|
134
|
+
if policy:
|
|
135
|
+
findings.extend(self._analyze_bucket_policy(asset, bucket_name, policy))
|
|
136
|
+
|
|
137
|
+
return asset, findings
|
|
138
|
+
|
|
139
|
+
def _analyze_bucket_policy(
|
|
140
|
+
self,
|
|
141
|
+
asset: Asset,
|
|
142
|
+
bucket_name: str,
|
|
143
|
+
policy: Any,
|
|
144
|
+
) -> list[Finding]:
|
|
145
|
+
"""Analyze bucket policy for public access."""
|
|
146
|
+
try:
|
|
147
|
+
policy_doc = json.loads(policy) if isinstance(policy, str) else policy
|
|
148
|
+
except (json.JSONDecodeError, TypeError):
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
statements = policy_doc.get("Statement", [])
|
|
152
|
+
findings: list[Finding] = []
|
|
153
|
+
for statement in statements if isinstance(statements, list) else [statements]:
|
|
154
|
+
effect = statement.get("Effect", "")
|
|
155
|
+
principal = statement.get("Principal", {})
|
|
156
|
+
if effect != "Allow":
|
|
157
|
+
continue
|
|
158
|
+
if self._is_public_principal(principal):
|
|
159
|
+
findings.append(
|
|
160
|
+
Finding(
|
|
161
|
+
snapshot_id=self._snapshot_id,
|
|
162
|
+
asset_id=asset.id,
|
|
163
|
+
finding_type="s3-bucket-public-policy",
|
|
164
|
+
severity=FindingSeverity.critical,
|
|
165
|
+
title=f"S3 bucket {bucket_name} has public bucket policy",
|
|
166
|
+
description="Bucket policy allows public access via Principal: *",
|
|
167
|
+
remediation="Review and restrict the bucket policy",
|
|
168
|
+
evidence={"statement": statement},
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
return findings
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _is_public_principal(principal: Any) -> bool:
|
|
175
|
+
"""Return True when Principal implies public access."""
|
|
176
|
+
if principal == "*":
|
|
177
|
+
return True
|
|
178
|
+
if isinstance(principal, dict):
|
|
179
|
+
aws_principal = principal.get("AWS")
|
|
180
|
+
if aws_principal == "*" or aws_principal == ["*"]:
|
|
181
|
+
return True
|
|
182
|
+
if isinstance(aws_principal, list) and "*" in aws_principal:
|
|
183
|
+
return True
|
|
184
|
+
return False
|