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.
@@ -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