ec2-security-scanner 1.0.0__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.
- ec2_security_scanner/__init__.py +11 -0
- ec2_security_scanner/checks/__init__.py +21 -0
- ec2_security_scanner/checks/access_control.py +266 -0
- ec2_security_scanner/checks/base.py +101 -0
- ec2_security_scanner/checks/instance_security.py +408 -0
- ec2_security_scanner/checks/logging_monitoring.py +212 -0
- ec2_security_scanner/checks/network_exposure.py +158 -0
- ec2_security_scanner/checks/network_security.py +543 -0
- ec2_security_scanner/checks/patch_vulnerability.py +236 -0
- ec2_security_scanner/checks/storage_security.py +326 -0
- ec2_security_scanner/checks/tagging_inventory.py +136 -0
- ec2_security_scanner/cli.py +394 -0
- ec2_security_scanner/compliance.py +1381 -0
- ec2_security_scanner/html_reporter.py +145 -0
- ec2_security_scanner/scanner.py +1811 -0
- ec2_security_scanner/templates/report.html +434 -0
- ec2_security_scanner/utils.py +270 -0
- ec2_security_scanner-1.0.0.dist-info/METADATA +708 -0
- ec2_security_scanner-1.0.0.dist-info/RECORD +23 -0
- ec2_security_scanner-1.0.0.dist-info/WHEEL +5 -0
- ec2_security_scanner-1.0.0.dist-info/entry_points.txt +2 -0
- ec2_security_scanner-1.0.0.dist-info/licenses/LICENSE +21 -0
- ec2_security_scanner-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""EC2 Security Scanner - Comprehensive AWS EC2 security auditing tool
|
|
2
|
+
with multi-framework compliance mapping."""
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0"
|
|
5
|
+
__author__ = "Toc Consulting"
|
|
6
|
+
__email__ = "tarek@tocconsulting.fr"
|
|
7
|
+
|
|
8
|
+
from .scanner import EC2SecurityScanner
|
|
9
|
+
from .compliance import ComplianceChecker
|
|
10
|
+
|
|
11
|
+
__all__ = ["EC2SecurityScanner", "ComplianceChecker"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""EC2 Security Scanner - Security Check Modules."""
|
|
2
|
+
|
|
3
|
+
from .instance_security import InstanceSecurityChecker
|
|
4
|
+
from .network_security import NetworkSecurityChecker
|
|
5
|
+
from .storage_security import StorageSecurityChecker
|
|
6
|
+
from .access_control import AccessControlChecker
|
|
7
|
+
from .logging_monitoring import LoggingMonitoringChecker
|
|
8
|
+
from .patch_vulnerability import PatchVulnerabilityChecker
|
|
9
|
+
from .network_exposure import NetworkExposureChecker
|
|
10
|
+
from .tagging_inventory import TaggingInventoryChecker
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"InstanceSecurityChecker",
|
|
14
|
+
"NetworkSecurityChecker",
|
|
15
|
+
"StorageSecurityChecker",
|
|
16
|
+
"AccessControlChecker",
|
|
17
|
+
"LoggingMonitoringChecker",
|
|
18
|
+
"PatchVulnerabilityChecker",
|
|
19
|
+
"NetworkExposureChecker",
|
|
20
|
+
"TaggingInventoryChecker",
|
|
21
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Access Control Checker - Checks D.1 through D.4.
|
|
2
|
+
|
|
3
|
+
Covers: IAM role least privilege, key pair usage, serial console access,
|
|
4
|
+
and EC2 Instance Connect endpoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Dict, Any
|
|
10
|
+
|
|
11
|
+
from botocore.exceptions import ClientError
|
|
12
|
+
|
|
13
|
+
from .base import BaseChecker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("ec2_security_scanner")
|
|
17
|
+
|
|
18
|
+
# Known overly permissive managed policies
|
|
19
|
+
ADMIN_POLICIES = [
|
|
20
|
+
"arn:aws:iam::aws:policy/AdministratorAccess",
|
|
21
|
+
"arn:aws:iam::aws:policy/PowerUserAccess",
|
|
22
|
+
"arn:aws:iam::aws:policy/IAMFullAccess",
|
|
23
|
+
"arn:aws:iam::aws:policy/AmazonEC2FullAccess",
|
|
24
|
+
"arn:aws:iam::aws:policy/AmazonS3FullAccess",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AccessControlChecker(BaseChecker):
|
|
29
|
+
"""Checks D.1-D.4: Access control configuration."""
|
|
30
|
+
|
|
31
|
+
def check_iam_role(
|
|
32
|
+
self, instance: Dict, region: str
|
|
33
|
+
) -> Dict[str, Any]:
|
|
34
|
+
"""D.1 - IAM roles attached to instances should follow least privilege.
|
|
35
|
+
|
|
36
|
+
Flags roles with *:* actions, AdministratorAccess, or wildcard resources.
|
|
37
|
+
"""
|
|
38
|
+
iam_profile = instance.get("IamInstanceProfile")
|
|
39
|
+
if not iam_profile:
|
|
40
|
+
return {
|
|
41
|
+
"has_admin_access": False,
|
|
42
|
+
"has_wildcard_actions": False,
|
|
43
|
+
"overly_permissive_policies": [],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
profile_arn = iam_profile.get("Arn", "")
|
|
47
|
+
profile_name = (
|
|
48
|
+
profile_arn.split("/")[-1] if "/" in profile_arn else profile_arn
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
iam = self.get_client("iam")
|
|
53
|
+
overly_permissive = []
|
|
54
|
+
has_admin = False
|
|
55
|
+
has_wildcard = False
|
|
56
|
+
|
|
57
|
+
# Get instance profile to find the role
|
|
58
|
+
ip_response = iam.get_instance_profile(
|
|
59
|
+
InstanceProfileName=profile_name
|
|
60
|
+
)
|
|
61
|
+
roles = ip_response.get("InstanceProfile", {}).get("Roles", [])
|
|
62
|
+
|
|
63
|
+
for role in roles:
|
|
64
|
+
role_name = role["RoleName"]
|
|
65
|
+
|
|
66
|
+
# Check attached managed policies
|
|
67
|
+
attached_policies = []
|
|
68
|
+
att_paginator = iam.get_paginator(
|
|
69
|
+
"list_attached_role_policies"
|
|
70
|
+
)
|
|
71
|
+
for page in att_paginator.paginate(
|
|
72
|
+
RoleName=role_name
|
|
73
|
+
):
|
|
74
|
+
attached_policies.extend(
|
|
75
|
+
page.get("AttachedPolicies", [])
|
|
76
|
+
)
|
|
77
|
+
for policy in attached_policies:
|
|
78
|
+
policy_arn = policy["PolicyArn"]
|
|
79
|
+
|
|
80
|
+
# Check for known admin policies
|
|
81
|
+
if any(
|
|
82
|
+
admin_arn in policy_arn
|
|
83
|
+
for admin_arn in ADMIN_POLICIES
|
|
84
|
+
):
|
|
85
|
+
has_admin = True
|
|
86
|
+
overly_permissive.append(policy["PolicyName"])
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Check policy document for wildcards
|
|
90
|
+
try:
|
|
91
|
+
policy_detail = iam.get_policy(
|
|
92
|
+
PolicyArn=policy_arn
|
|
93
|
+
)
|
|
94
|
+
version_id = policy_detail["Policy"][
|
|
95
|
+
"DefaultVersionId"
|
|
96
|
+
]
|
|
97
|
+
version = iam.get_policy_version(
|
|
98
|
+
PolicyArn=policy_arn, VersionId=version_id
|
|
99
|
+
)
|
|
100
|
+
document = version["PolicyVersion"]["Document"]
|
|
101
|
+
if isinstance(document, str):
|
|
102
|
+
document = json.loads(document)
|
|
103
|
+
|
|
104
|
+
if self._has_wildcard_permissions(document):
|
|
105
|
+
has_wildcard = True
|
|
106
|
+
overly_permissive.append(policy["PolicyName"])
|
|
107
|
+
except ClientError:
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Check inline policies
|
|
111
|
+
inline_names = []
|
|
112
|
+
inl_paginator = iam.get_paginator(
|
|
113
|
+
"list_role_policies"
|
|
114
|
+
)
|
|
115
|
+
for page in inl_paginator.paginate(
|
|
116
|
+
RoleName=role_name
|
|
117
|
+
):
|
|
118
|
+
inline_names.extend(
|
|
119
|
+
page.get("PolicyNames", [])
|
|
120
|
+
)
|
|
121
|
+
for policy_name in inline_names:
|
|
122
|
+
try:
|
|
123
|
+
inline_policy = iam.get_role_policy(
|
|
124
|
+
RoleName=role_name, PolicyName=policy_name
|
|
125
|
+
)
|
|
126
|
+
document = inline_policy.get("PolicyDocument", {})
|
|
127
|
+
if isinstance(document, str):
|
|
128
|
+
document = json.loads(document)
|
|
129
|
+
|
|
130
|
+
if self._has_wildcard_permissions(document):
|
|
131
|
+
has_wildcard = True
|
|
132
|
+
overly_permissive.append(policy_name)
|
|
133
|
+
except ClientError:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"has_admin_access": has_admin,
|
|
138
|
+
"has_wildcard_actions": has_wildcard,
|
|
139
|
+
"overly_permissive_policies": list(set(overly_permissive)),
|
|
140
|
+
}
|
|
141
|
+
except ClientError as e:
|
|
142
|
+
return self.handle_client_error(e, {
|
|
143
|
+
"has_admin_access": False,
|
|
144
|
+
"has_wildcard_actions": False,
|
|
145
|
+
"overly_permissive_policies": [],
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
def _has_wildcard_permissions(self, document: Dict) -> bool:
|
|
149
|
+
"""Check if a policy document contains overly broad permissions.
|
|
150
|
+
|
|
151
|
+
Flags:
|
|
152
|
+
- Full wildcard: Action=* (regardless of Resource).
|
|
153
|
+
- NotAction or NotResource: inverse statements grant everything
|
|
154
|
+
except what's listed, almost always over-broad.
|
|
155
|
+
- Service or resource wildcard: Action=<service>:* with a
|
|
156
|
+
Resource that is "*" or ends with ":*" (e.g. "arn:aws:s3:::*").
|
|
157
|
+
"""
|
|
158
|
+
statements = document.get("Statement", [])
|
|
159
|
+
if not isinstance(statements, list):
|
|
160
|
+
statements = [statements]
|
|
161
|
+
|
|
162
|
+
def _is_wild_resource(value: str) -> bool:
|
|
163
|
+
return value == "*" or value.endswith(":*")
|
|
164
|
+
|
|
165
|
+
for stmt in statements:
|
|
166
|
+
if stmt.get("Effect") != "Allow":
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# NotAction / NotResource almost always over-broad.
|
|
170
|
+
if "NotAction" in stmt or "NotResource" in stmt:
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
actions = stmt.get("Action", [])
|
|
174
|
+
if isinstance(actions, str):
|
|
175
|
+
actions = [actions]
|
|
176
|
+
|
|
177
|
+
resources = stmt.get("Resource", [])
|
|
178
|
+
if isinstance(resources, str):
|
|
179
|
+
resources = [resources]
|
|
180
|
+
|
|
181
|
+
# Action == "*" is admin regardless of Resource.
|
|
182
|
+
if any(a == "*" for a in actions if isinstance(a, str)):
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
# Service-wildcard action + any wildcardish resource.
|
|
186
|
+
has_service_wildcard = any(
|
|
187
|
+
isinstance(a, str) and a.endswith(":*") for a in actions
|
|
188
|
+
)
|
|
189
|
+
has_wild_resource = any(
|
|
190
|
+
isinstance(r, str) and _is_wild_resource(r)
|
|
191
|
+
for r in resources
|
|
192
|
+
)
|
|
193
|
+
if has_service_wildcard and has_wild_resource:
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
def check_key_pair(
|
|
199
|
+
self, instance: Dict, instance_id: str, region: str
|
|
200
|
+
) -> Dict[str, Any]:
|
|
201
|
+
"""D.2 - Review instances using key pairs.
|
|
202
|
+
|
|
203
|
+
Prefer SSM Session Manager or Instance Connect over SSH key pairs.
|
|
204
|
+
"""
|
|
205
|
+
key_name = instance.get("KeyName")
|
|
206
|
+
|
|
207
|
+
# Check if SSM-managed (mitigates key pair concern)
|
|
208
|
+
ssm_managed = False
|
|
209
|
+
try:
|
|
210
|
+
ssm = self.get_client("ssm", region)
|
|
211
|
+
response = ssm.describe_instance_information(
|
|
212
|
+
Filters=[{
|
|
213
|
+
"Key": "InstanceIds",
|
|
214
|
+
"Values": [instance_id],
|
|
215
|
+
}]
|
|
216
|
+
)
|
|
217
|
+
instances = response.get("InstanceInformationList", [])
|
|
218
|
+
ssm_managed = len(instances) > 0
|
|
219
|
+
except ClientError:
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"has_key_pair": key_name is not None,
|
|
224
|
+
"key_name": key_name,
|
|
225
|
+
"ssm_managed": ssm_managed,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
def check_serial_console(self, region: str) -> Dict[str, Any]:
|
|
229
|
+
"""D.3 - EC2 serial console access should be disabled at account level.
|
|
230
|
+
|
|
231
|
+
Account-level check — runs once per scan.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
ec2 = self.get_client("ec2", region)
|
|
235
|
+
response = ec2.get_serial_console_access_status()
|
|
236
|
+
enabled = response.get("SerialConsoleAccessEnabled", False)
|
|
237
|
+
return {"enabled": enabled}
|
|
238
|
+
except ClientError as e:
|
|
239
|
+
return self.handle_client_error(e, {"enabled": False})
|
|
240
|
+
|
|
241
|
+
def check_instance_connect(
|
|
242
|
+
self, vpc_id: str, region: str
|
|
243
|
+
) -> Dict[str, Any]:
|
|
244
|
+
"""D.4 - Check if EC2 Instance Connect endpoints are configured.
|
|
245
|
+
|
|
246
|
+
Informational check for secure access without public IPs.
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
ec2 = self.get_client("ec2", region)
|
|
250
|
+
endpoints = []
|
|
251
|
+
paginator = ec2.get_paginator(
|
|
252
|
+
"describe_instance_connect_endpoints"
|
|
253
|
+
)
|
|
254
|
+
for page in paginator.paginate(
|
|
255
|
+
Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]
|
|
256
|
+
):
|
|
257
|
+
endpoints.extend(
|
|
258
|
+
page.get("InstanceConnectEndpoints", [])
|
|
259
|
+
)
|
|
260
|
+
return {
|
|
261
|
+
"endpoints_configured": len(endpoints) > 0,
|
|
262
|
+
}
|
|
263
|
+
except ClientError as e:
|
|
264
|
+
return self.handle_client_error(
|
|
265
|
+
e, {"endpoints_configured": False}
|
|
266
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Base class for all security checkers."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import boto3
|
|
6
|
+
from botocore.exceptions import ClientError
|
|
7
|
+
from typing import Dict, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("ec2_security_scanner")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Error codes that all mean "the scanning role lacks this permission".
|
|
14
|
+
# EC2 raises UnauthorizedOperation; IAM/STS/SSM raise AccessDenied or
|
|
15
|
+
# AccessDeniedException depending on the service.
|
|
16
|
+
ACCESS_DENIED_CODES = frozenset({
|
|
17
|
+
"AccessDenied",
|
|
18
|
+
"AccessDeniedException",
|
|
19
|
+
"UnauthorizedOperation",
|
|
20
|
+
"UnauthorizedAccess",
|
|
21
|
+
"AuthFailure",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseChecker:
|
|
26
|
+
"""Base class for all security checkers.
|
|
27
|
+
|
|
28
|
+
Provides thread-safe AWS client creation via session_factory
|
|
29
|
+
and standardized error handling.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, session_factory=None):
|
|
33
|
+
"""Initialize the checker with optional session factory.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
session_factory: Callable that returns a boto3 session
|
|
37
|
+
(for thread safety) or a boto3 session object
|
|
38
|
+
(for backward compatibility).
|
|
39
|
+
"""
|
|
40
|
+
self.session_factory = session_factory
|
|
41
|
+
|
|
42
|
+
def get_client(self, service_name: str, region_name: str = None):
|
|
43
|
+
"""Get AWS client for the specified service.
|
|
44
|
+
|
|
45
|
+
Uses the thread-safe session factory to create clients,
|
|
46
|
+
ensuring each thread gets its own session.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
service_name: AWS service name (e.g., 'ec2', 'iam', 'ssm')
|
|
50
|
+
region_name: AWS region name (optional)
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
boto3 client for the service
|
|
54
|
+
"""
|
|
55
|
+
if self.session_factory:
|
|
56
|
+
session = (
|
|
57
|
+
self.session_factory()
|
|
58
|
+
if callable(self.session_factory)
|
|
59
|
+
else self.session_factory
|
|
60
|
+
)
|
|
61
|
+
kwargs = {"region_name": region_name} if region_name else {}
|
|
62
|
+
return session.client(service_name, **kwargs)
|
|
63
|
+
else:
|
|
64
|
+
kwargs = {"region_name": region_name} if region_name else {}
|
|
65
|
+
return boto3.client(service_name, **kwargs)
|
|
66
|
+
|
|
67
|
+
def handle_client_error(
|
|
68
|
+
self, e: ClientError, default_response: Dict[str, Any] = None
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Handle ClientError exceptions consistently.
|
|
71
|
+
|
|
72
|
+
Logs the error and returns a safe default response dict.
|
|
73
|
+
Special handling for AccessDeniedException to allow graceful
|
|
74
|
+
degradation when permissions are insufficient.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
e: ClientError exception
|
|
78
|
+
default_response: Default response dict to return
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Error response dict with 'error' key
|
|
82
|
+
"""
|
|
83
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
84
|
+
error_msg = str(e)
|
|
85
|
+
|
|
86
|
+
if error_code in ACCESS_DENIED_CODES:
|
|
87
|
+
logger.warning(
|
|
88
|
+
f"Access denied ({error_code}): {error_msg} - "
|
|
89
|
+
"scan will continue with limited results"
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
logger.warning(f"AWS API error: {error_msg}")
|
|
93
|
+
|
|
94
|
+
if default_response is None:
|
|
95
|
+
default_response = {
|
|
96
|
+
"error": error_msg,
|
|
97
|
+
}
|
|
98
|
+
else:
|
|
99
|
+
default_response["error"] = error_msg
|
|
100
|
+
|
|
101
|
+
return default_response
|