aws-cis-controls-assessment 1.0.3__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.
- aws_cis_assessment/__init__.py +11 -0
- aws_cis_assessment/cli/__init__.py +3 -0
- aws_cis_assessment/cli/examples.py +274 -0
- aws_cis_assessment/cli/main.py +1259 -0
- aws_cis_assessment/cli/utils.py +356 -0
- aws_cis_assessment/config/__init__.py +1 -0
- aws_cis_assessment/config/config_loader.py +328 -0
- aws_cis_assessment/config/rules/cis_controls_ig1.yaml +590 -0
- aws_cis_assessment/config/rules/cis_controls_ig2.yaml +412 -0
- aws_cis_assessment/config/rules/cis_controls_ig3.yaml +100 -0
- aws_cis_assessment/controls/__init__.py +1 -0
- aws_cis_assessment/controls/base_control.py +400 -0
- aws_cis_assessment/controls/ig1/__init__.py +239 -0
- aws_cis_assessment/controls/ig1/control_1_1.py +586 -0
- aws_cis_assessment/controls/ig1/control_2_2.py +231 -0
- aws_cis_assessment/controls/ig1/control_3_3.py +718 -0
- aws_cis_assessment/controls/ig1/control_3_4.py +235 -0
- aws_cis_assessment/controls/ig1/control_4_1.py +461 -0
- aws_cis_assessment/controls/ig1/control_access_keys.py +310 -0
- aws_cis_assessment/controls/ig1/control_advanced_security.py +512 -0
- aws_cis_assessment/controls/ig1/control_backup_recovery.py +510 -0
- aws_cis_assessment/controls/ig1/control_cloudtrail_logging.py +197 -0
- aws_cis_assessment/controls/ig1/control_critical_security.py +422 -0
- aws_cis_assessment/controls/ig1/control_data_protection.py +898 -0
- aws_cis_assessment/controls/ig1/control_iam_advanced.py +573 -0
- aws_cis_assessment/controls/ig1/control_iam_governance.py +493 -0
- aws_cis_assessment/controls/ig1/control_iam_policies.py +383 -0
- aws_cis_assessment/controls/ig1/control_instance_optimization.py +100 -0
- aws_cis_assessment/controls/ig1/control_network_enhancements.py +203 -0
- aws_cis_assessment/controls/ig1/control_network_security.py +672 -0
- aws_cis_assessment/controls/ig1/control_s3_enhancements.py +173 -0
- aws_cis_assessment/controls/ig1/control_s3_security.py +422 -0
- aws_cis_assessment/controls/ig1/control_vpc_security.py +235 -0
- aws_cis_assessment/controls/ig2/__init__.py +172 -0
- aws_cis_assessment/controls/ig2/control_3_10.py +698 -0
- aws_cis_assessment/controls/ig2/control_3_11.py +1330 -0
- aws_cis_assessment/controls/ig2/control_5_2.py +393 -0
- aws_cis_assessment/controls/ig2/control_advanced_encryption.py +355 -0
- aws_cis_assessment/controls/ig2/control_codebuild_security.py +263 -0
- aws_cis_assessment/controls/ig2/control_encryption_rest.py +382 -0
- aws_cis_assessment/controls/ig2/control_encryption_transit.py +382 -0
- aws_cis_assessment/controls/ig2/control_network_ha.py +467 -0
- aws_cis_assessment/controls/ig2/control_remaining_encryption.py +426 -0
- aws_cis_assessment/controls/ig2/control_remaining_rules.py +363 -0
- aws_cis_assessment/controls/ig2/control_service_logging.py +402 -0
- aws_cis_assessment/controls/ig3/__init__.py +49 -0
- aws_cis_assessment/controls/ig3/control_12_8.py +395 -0
- aws_cis_assessment/controls/ig3/control_13_1.py +467 -0
- aws_cis_assessment/controls/ig3/control_3_14.py +523 -0
- aws_cis_assessment/controls/ig3/control_7_1.py +359 -0
- aws_cis_assessment/core/__init__.py +1 -0
- aws_cis_assessment/core/accuracy_validator.py +425 -0
- aws_cis_assessment/core/assessment_engine.py +1266 -0
- aws_cis_assessment/core/audit_trail.py +491 -0
- aws_cis_assessment/core/aws_client_factory.py +313 -0
- aws_cis_assessment/core/error_handler.py +607 -0
- aws_cis_assessment/core/models.py +166 -0
- aws_cis_assessment/core/scoring_engine.py +459 -0
- aws_cis_assessment/reporters/__init__.py +8 -0
- aws_cis_assessment/reporters/base_reporter.py +454 -0
- aws_cis_assessment/reporters/csv_reporter.py +835 -0
- aws_cis_assessment/reporters/html_reporter.py +2162 -0
- aws_cis_assessment/reporters/json_reporter.py +561 -0
- aws_cis_controls_assessment-1.0.3.dist-info/METADATA +248 -0
- aws_cis_controls_assessment-1.0.3.dist-info/RECORD +77 -0
- aws_cis_controls_assessment-1.0.3.dist-info/WHEEL +5 -0
- aws_cis_controls_assessment-1.0.3.dist-info/entry_points.txt +2 -0
- aws_cis_controls_assessment-1.0.3.dist-info/licenses/LICENSE +21 -0
- aws_cis_controls_assessment-1.0.3.dist-info/top_level.txt +2 -0
- docs/README.md +94 -0
- docs/assessment-logic.md +766 -0
- docs/cli-reference.md +698 -0
- docs/config-rule-mappings.md +393 -0
- docs/developer-guide.md +858 -0
- docs/installation.md +299 -0
- docs/troubleshooting.md +634 -0
- docs/user-guide.md +487 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""AWS Client Factory for managing AWS service clients with credential handling."""
|
|
2
|
+
|
|
3
|
+
import boto3
|
|
4
|
+
import time
|
|
5
|
+
import random
|
|
6
|
+
from typing import Dict, List, Optional, Any
|
|
7
|
+
from botocore.exceptions import ClientError, NoCredentialsError, PartialCredentialsError
|
|
8
|
+
from botocore.config import Config
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AWSClientFactory:
|
|
15
|
+
"""Centralized AWS service client management with credential handling."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, credentials: Optional[Dict[str, str]] = None, regions: Optional[List[str]] = None):
|
|
18
|
+
"""Initialize with AWS credentials and target regions.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
credentials: Optional dict with AWS credentials (access_key_id, secret_access_key, session_token)
|
|
22
|
+
regions: List of AWS regions to support. If None, uses current region.
|
|
23
|
+
"""
|
|
24
|
+
self.credentials = credentials or {}
|
|
25
|
+
self.regions = regions or ['us-east-1'] # Default to us-east-1 if not specified
|
|
26
|
+
self._clients = {} # Cache for boto3 clients
|
|
27
|
+
self._session = None
|
|
28
|
+
self._account_info = None
|
|
29
|
+
|
|
30
|
+
# Configure boto3 with retry settings
|
|
31
|
+
self._config = Config(
|
|
32
|
+
retries={
|
|
33
|
+
'max_attempts': 3,
|
|
34
|
+
'mode': 'adaptive'
|
|
35
|
+
},
|
|
36
|
+
max_pool_connections=50
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Initialize session
|
|
40
|
+
self._initialize_session()
|
|
41
|
+
|
|
42
|
+
def _initialize_session(self):
|
|
43
|
+
"""Initialize boto3 session with provided credentials."""
|
|
44
|
+
try:
|
|
45
|
+
if self.credentials:
|
|
46
|
+
# Check if profile_name is provided
|
|
47
|
+
if 'profile_name' in self.credentials:
|
|
48
|
+
self._session = boto3.Session(
|
|
49
|
+
profile_name=self.credentials['profile_name'],
|
|
50
|
+
region_name=self.regions[0] if self.regions else None
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
# Use explicit credentials
|
|
54
|
+
self._session = boto3.Session(
|
|
55
|
+
aws_access_key_id=self.credentials.get('aws_access_key_id'),
|
|
56
|
+
aws_secret_access_key=self.credentials.get('aws_secret_access_key'),
|
|
57
|
+
aws_session_token=self.credentials.get('aws_session_token'),
|
|
58
|
+
region_name=self.regions[0] if self.regions else None
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
# Use default credential chain
|
|
62
|
+
self._session = boto3.Session(
|
|
63
|
+
region_name=self.regions[0] if self.regions else None
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
logger.info("AWS session initialized successfully")
|
|
67
|
+
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Failed to initialize AWS session: {e}")
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
def get_client(self, service_name: str, region: Optional[str] = None) -> boto3.client:
|
|
73
|
+
"""Get AWS service client for specified service and region.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
service_name: AWS service name (e.g., 'ec2', 'iam', 's3')
|
|
77
|
+
region: AWS region. If None, uses first region from regions list.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Boto3 client for the specified service
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ValueError: If region is not in supported regions list
|
|
84
|
+
ClientError: If client creation fails
|
|
85
|
+
"""
|
|
86
|
+
if region is None:
|
|
87
|
+
region = self.regions[0]
|
|
88
|
+
|
|
89
|
+
if region not in self.regions:
|
|
90
|
+
raise ValueError(f"Region {region} not in supported regions: {self.regions}")
|
|
91
|
+
|
|
92
|
+
# Create cache key
|
|
93
|
+
cache_key = f"{service_name}_{region}"
|
|
94
|
+
|
|
95
|
+
# Return cached client if available
|
|
96
|
+
if cache_key in self._clients:
|
|
97
|
+
return self._clients[cache_key]
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Create new client
|
|
101
|
+
client = self._session.client(
|
|
102
|
+
service_name,
|
|
103
|
+
region_name=region,
|
|
104
|
+
config=self._config
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Cache the client
|
|
108
|
+
self._clients[cache_key] = client
|
|
109
|
+
|
|
110
|
+
logger.debug(f"Created {service_name} client for region {region}")
|
|
111
|
+
return client
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error(f"Failed to create {service_name} client for region {region}: {e}")
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
def validate_credentials(self) -> bool:
|
|
118
|
+
"""Validate AWS credentials and permissions.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
True if credentials are valid, False otherwise
|
|
122
|
+
"""
|
|
123
|
+
try:
|
|
124
|
+
sts_client = self.get_client('sts')
|
|
125
|
+
response = sts_client.get_caller_identity()
|
|
126
|
+
|
|
127
|
+
# Store account info for later use
|
|
128
|
+
self._account_info = {
|
|
129
|
+
'account_id': response.get('Account'),
|
|
130
|
+
'user_id': response.get('UserId'),
|
|
131
|
+
'arn': response.get('Arn')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
logger.info(f"Credentials validated for account: {self._account_info['account_id']}")
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
except (NoCredentialsError, PartialCredentialsError) as e:
|
|
138
|
+
logger.error(f"Invalid credentials: {e}")
|
|
139
|
+
return False
|
|
140
|
+
except ClientError as e:
|
|
141
|
+
logger.error(f"AWS API error during credential validation: {e}")
|
|
142
|
+
return False
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.error(f"Unexpected error during credential validation: {e}")
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
def get_account_info(self) -> Dict[str, str]:
|
|
148
|
+
"""Get AWS account ID and caller identity information.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Dictionary with account_id, user_id, and arn
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
RuntimeError: If credentials haven't been validated yet
|
|
155
|
+
"""
|
|
156
|
+
if self._account_info is None:
|
|
157
|
+
if not self.validate_credentials():
|
|
158
|
+
raise RuntimeError("Failed to validate credentials")
|
|
159
|
+
|
|
160
|
+
return self._account_info.copy()
|
|
161
|
+
|
|
162
|
+
def test_service_access(self, service_name: str, region: Optional[str] = None) -> bool:
|
|
163
|
+
"""Test access to a specific AWS service.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
service_name: AWS service name to test
|
|
167
|
+
region: AWS region to test. If None, uses first region.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if service is accessible, False otherwise
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
client = self.get_client(service_name, region)
|
|
174
|
+
|
|
175
|
+
# Test with a simple, low-cost API call for each service
|
|
176
|
+
test_calls = {
|
|
177
|
+
'ec2': lambda c: c.describe_regions(),
|
|
178
|
+
'iam': lambda c: c.get_account_summary(),
|
|
179
|
+
's3': lambda c: c.list_buckets(),
|
|
180
|
+
'rds': lambda c: c.describe_db_instances(MaxRecords=1),
|
|
181
|
+
'cloudtrail': lambda c: c.describe_trails(),
|
|
182
|
+
'config': lambda c: c.describe_configuration_recorders(),
|
|
183
|
+
'guardduty': lambda c: c.list_detectors(MaxResults=1),
|
|
184
|
+
'organizations': lambda c: c.describe_organization(),
|
|
185
|
+
'ssm': lambda c: c.describe_instance_information(MaxResults=1),
|
|
186
|
+
'elb': lambda c: c.describe_load_balancers(PageSize=1),
|
|
187
|
+
'elbv2': lambda c: c.describe_load_balancers(PageSize=1),
|
|
188
|
+
'apigateway': lambda c: c.get_rest_apis(limit=1),
|
|
189
|
+
'apigatewayv2': lambda c: c.get_apis(MaxResults='1'),
|
|
190
|
+
'kms': lambda c: c.list_keys(Limit=1),
|
|
191
|
+
'backup': lambda c: c.list_backup_vaults(MaxResults=1),
|
|
192
|
+
'dynamodb': lambda c: c.list_tables(Limit=1)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if service_name in test_calls:
|
|
196
|
+
test_calls[service_name](client)
|
|
197
|
+
else:
|
|
198
|
+
# Generic test - try to get service's waiter names
|
|
199
|
+
client.waiter_names
|
|
200
|
+
|
|
201
|
+
logger.debug(f"Service {service_name} is accessible in region {region}")
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
except ClientError as e:
|
|
205
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
206
|
+
if error_code in ['AccessDenied', 'UnauthorizedOperation']:
|
|
207
|
+
logger.debug(f"Access denied for {service_name} in region {region}")
|
|
208
|
+
else:
|
|
209
|
+
logger.debug(f"Service {service_name} test failed in region {region}: {error_code}")
|
|
210
|
+
return False
|
|
211
|
+
except Exception as e:
|
|
212
|
+
# Don't log parameter validation errors as warnings - they're expected for some services
|
|
213
|
+
if "Parameter validation failed" in str(e):
|
|
214
|
+
logger.debug(f"Parameter validation issue for {service_name} in region {region}: {e}")
|
|
215
|
+
else:
|
|
216
|
+
logger.warning(f"Unexpected error testing {service_name} in region {region}: {e}")
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
def get_available_regions(self, service_name: str) -> List[str]:
|
|
220
|
+
"""Get list of regions where a service is available.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
service_name: AWS service name
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
List of region names where service is available
|
|
227
|
+
"""
|
|
228
|
+
available_regions = []
|
|
229
|
+
|
|
230
|
+
for region in self.regions:
|
|
231
|
+
if self.test_service_access(service_name, region):
|
|
232
|
+
available_regions.append(region)
|
|
233
|
+
|
|
234
|
+
return available_regions
|
|
235
|
+
|
|
236
|
+
def aws_api_call_with_retry(self, func, max_retries: int = 3, base_delay: float = 1.0) -> Any:
|
|
237
|
+
"""Execute AWS API call with exponential backoff retry logic.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
func: Function to execute (should be a lambda or callable)
|
|
241
|
+
max_retries: Maximum number of retry attempts
|
|
242
|
+
base_delay: Base delay in seconds for exponential backoff
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Result of the function call
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
ClientError: If all retries are exhausted
|
|
249
|
+
"""
|
|
250
|
+
for attempt in range(max_retries + 1):
|
|
251
|
+
try:
|
|
252
|
+
return func()
|
|
253
|
+
|
|
254
|
+
except ClientError as e:
|
|
255
|
+
error_code = e.response.get('Error', {}).get('Code', '')
|
|
256
|
+
|
|
257
|
+
# Retry on throttling errors
|
|
258
|
+
if error_code in ['Throttling', 'RequestLimitExceeded', 'TooManyRequestsException']:
|
|
259
|
+
if attempt < max_retries:
|
|
260
|
+
# Exponential backoff with jitter
|
|
261
|
+
delay = base_delay * (2 ** attempt) + random.uniform(0, 1)
|
|
262
|
+
logger.warning(f"API throttled, retrying in {delay:.2f} seconds (attempt {attempt + 1}/{max_retries + 1})")
|
|
263
|
+
time.sleep(delay)
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Don't retry on other errors
|
|
267
|
+
raise
|
|
268
|
+
|
|
269
|
+
except Exception as e:
|
|
270
|
+
# Don't retry on non-AWS errors
|
|
271
|
+
logger.error(f"Non-retryable error in AWS API call: {e}")
|
|
272
|
+
raise
|
|
273
|
+
|
|
274
|
+
# This should never be reached due to the raise in the loop
|
|
275
|
+
raise ClientError(
|
|
276
|
+
error_response={'Error': {'Code': 'MaxRetriesExceeded', 'Message': 'Maximum retries exceeded'}},
|
|
277
|
+
operation_name='aws_api_call_with_retry'
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def get_supported_services(self) -> List[str]:
|
|
281
|
+
"""Get list of AWS services supported by this client factory.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
List of supported AWS service names
|
|
285
|
+
"""
|
|
286
|
+
return [
|
|
287
|
+
'ec2', 'iam', 's3', 'rds', 'cloudtrail', 'cloudwatch', 'logs',
|
|
288
|
+
'config', 'guardduty', 'inspector', 'kms', 'organizations',
|
|
289
|
+
'ssm', 'securityhub', 'macie2', 'backup', 'dynamodb',
|
|
290
|
+
'elb', 'elbv2', 'apigateway',
|
|
291
|
+
'apigatewayv2', 'redshift', 'efs', 'sns', 'sqs', 'lambda',
|
|
292
|
+
'ecs', 'ecr', 'codebuild', 'elasticsearch', 'opensearch'
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
def get_enabled_regions(self) -> List[str]:
|
|
296
|
+
"""Get list of enabled AWS regions for the current account.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
List of enabled AWS region names
|
|
300
|
+
"""
|
|
301
|
+
try:
|
|
302
|
+
ec2_client = self.get_client('ec2', 'us-east-1')
|
|
303
|
+
response = ec2_client.describe_regions()
|
|
304
|
+
return [region['RegionName'] for region in response['Regions']]
|
|
305
|
+
except Exception as e:
|
|
306
|
+
logger.warning(f"Could not retrieve enabled regions: {e}")
|
|
307
|
+
# Return default region as fallback
|
|
308
|
+
return ['us-east-1']
|
|
309
|
+
|
|
310
|
+
def cleanup(self):
|
|
311
|
+
"""Clean up resources and close connections."""
|
|
312
|
+
self._clients.clear()
|
|
313
|
+
logger.info("AWS client factory cleaned up")
|