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,356 @@
|
|
|
1
|
+
"""Utility functions for the CLI module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Dict, List, Optional, Any
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import boto3
|
|
9
|
+
from botocore.exceptions import ClientError, NoCredentialsError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_default_regions() -> List[str]:
|
|
13
|
+
"""Get default AWS regions for assessment.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
List of default AWS region names (us-east-1 only)
|
|
17
|
+
"""
|
|
18
|
+
# Default to us-east-1 only for focused assessment
|
|
19
|
+
return ['us-east-1']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_all_enabled_regions() -> List[str]:
|
|
23
|
+
"""Get all enabled AWS regions for the current account.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of all enabled AWS region names
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
ec2 = boto3.client('ec2', region_name='us-east-1')
|
|
30
|
+
response = ec2.describe_regions()
|
|
31
|
+
return [region['RegionName'] for region in response['Regions']]
|
|
32
|
+
except Exception:
|
|
33
|
+
# Fallback to default regions if unable to query
|
|
34
|
+
return get_default_regions()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_enabled_regions_for_profile(profile_name: str) -> List[str]:
|
|
38
|
+
"""Get enabled regions for a specific AWS profile.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
profile_name: Name of the AWS profile
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of enabled region names
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
session = boto3.Session(profile_name=profile_name)
|
|
48
|
+
ec2 = session.client('ec2', region_name='us-east-1')
|
|
49
|
+
response = ec2.describe_regions()
|
|
50
|
+
return [region['RegionName'] for region in response['Regions']]
|
|
51
|
+
except Exception:
|
|
52
|
+
return get_default_regions()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_aws_profile(profile_name: str) -> bool:
|
|
56
|
+
"""Validate that an AWS profile exists and is accessible.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
profile_name: Name of the AWS profile to validate
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if profile is valid, False otherwise
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
session = boto3.Session(profile_name=profile_name)
|
|
66
|
+
sts = session.client('sts')
|
|
67
|
+
sts.get_caller_identity()
|
|
68
|
+
return True
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_aws_config_path() -> Optional[Path]:
|
|
74
|
+
"""Get the path to AWS configuration directory.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Path to AWS config directory or None if not found
|
|
78
|
+
"""
|
|
79
|
+
aws_config_dir = Path.home() / '.aws'
|
|
80
|
+
if aws_config_dir.exists():
|
|
81
|
+
return aws_config_dir
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_aws_profiles() -> List[str]:
|
|
86
|
+
"""List available AWS profiles.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of AWS profile names
|
|
90
|
+
"""
|
|
91
|
+
profiles = []
|
|
92
|
+
aws_config_path = get_aws_config_path()
|
|
93
|
+
|
|
94
|
+
if aws_config_path:
|
|
95
|
+
credentials_file = aws_config_path / 'credentials'
|
|
96
|
+
config_file = aws_config_path / 'config'
|
|
97
|
+
|
|
98
|
+
# Parse credentials file
|
|
99
|
+
if credentials_file.exists():
|
|
100
|
+
profiles.extend(_parse_aws_config_file(credentials_file))
|
|
101
|
+
|
|
102
|
+
# Parse config file
|
|
103
|
+
if config_file.exists():
|
|
104
|
+
config_profiles = _parse_aws_config_file(config_file, prefix='profile ')
|
|
105
|
+
profiles.extend(config_profiles)
|
|
106
|
+
|
|
107
|
+
# Remove duplicates and sort
|
|
108
|
+
return sorted(list(set(profiles)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_aws_config_file(file_path: Path, prefix: str = '') -> List[str]:
|
|
112
|
+
"""Parse AWS configuration file to extract profile names.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
file_path: Path to the configuration file
|
|
116
|
+
prefix: Prefix to remove from profile names (e.g., 'profile ')
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of profile names
|
|
120
|
+
"""
|
|
121
|
+
profiles = []
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
with open(file_path, 'r') as f:
|
|
125
|
+
for line in f:
|
|
126
|
+
line = line.strip()
|
|
127
|
+
if line.startswith('[') and line.endswith(']'):
|
|
128
|
+
profile_name = line[1:-1] # Remove brackets
|
|
129
|
+
if prefix and profile_name.startswith(prefix):
|
|
130
|
+
profile_name = profile_name[len(prefix):]
|
|
131
|
+
if profile_name and profile_name != 'default':
|
|
132
|
+
profiles.append(profile_name)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass # Ignore parsing errors
|
|
135
|
+
|
|
136
|
+
return profiles
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def format_duration(duration) -> str:
|
|
140
|
+
"""Format a timedelta duration for display.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
duration: timedelta object or None
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Formatted duration string
|
|
147
|
+
"""
|
|
148
|
+
if duration is None:
|
|
149
|
+
return "Unknown"
|
|
150
|
+
|
|
151
|
+
total_seconds = int(duration.total_seconds())
|
|
152
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
153
|
+
minutes, seconds = divmod(remainder, 60)
|
|
154
|
+
|
|
155
|
+
if hours > 0:
|
|
156
|
+
return f"{hours}h {minutes}m {seconds}s"
|
|
157
|
+
elif minutes > 0:
|
|
158
|
+
return f"{minutes}m {seconds}s"
|
|
159
|
+
else:
|
|
160
|
+
return f"{seconds}s"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def format_percentage(value: float, precision: int = 1) -> str:
|
|
164
|
+
"""Format a percentage value for display.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
value: Percentage value (0-100)
|
|
168
|
+
precision: Number of decimal places
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Formatted percentage string
|
|
172
|
+
"""
|
|
173
|
+
return f"{value:.{precision}f}%"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def format_file_size(size_bytes: int) -> str:
|
|
177
|
+
"""Format file size in human-readable format.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
size_bytes: Size in bytes
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Formatted size string
|
|
184
|
+
"""
|
|
185
|
+
if size_bytes == 0:
|
|
186
|
+
return "0 B"
|
|
187
|
+
|
|
188
|
+
size_names = ["B", "KB", "MB", "GB", "TB"]
|
|
189
|
+
i = 0
|
|
190
|
+
while size_bytes >= 1024 and i < len(size_names) - 1:
|
|
191
|
+
size_bytes /= 1024.0
|
|
192
|
+
i += 1
|
|
193
|
+
|
|
194
|
+
return f"{size_bytes:.1f} {size_names[i]}"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def ensure_output_directory(output_path: str) -> Path:
|
|
198
|
+
"""Ensure output directory exists and return Path object.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
output_path: Output file or directory path
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Path object with directory created
|
|
205
|
+
"""
|
|
206
|
+
path = Path(output_path)
|
|
207
|
+
|
|
208
|
+
# If it's a file path, get the directory
|
|
209
|
+
if path.suffix:
|
|
210
|
+
directory = path.parent
|
|
211
|
+
else:
|
|
212
|
+
directory = path
|
|
213
|
+
|
|
214
|
+
# Create directory if it doesn't exist
|
|
215
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
|
|
217
|
+
return path
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_terminal_width() -> int:
|
|
221
|
+
"""Get terminal width for formatting output.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Terminal width in characters
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
return os.get_terminal_size().columns
|
|
228
|
+
except OSError:
|
|
229
|
+
return 80 # Default width
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def truncate_string(text: str, max_length: int, suffix: str = "...") -> str:
|
|
233
|
+
"""Truncate string to maximum length with suffix.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
text: String to truncate
|
|
237
|
+
max_length: Maximum length including suffix
|
|
238
|
+
suffix: Suffix to add when truncating
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Truncated string
|
|
242
|
+
"""
|
|
243
|
+
if len(text) <= max_length:
|
|
244
|
+
return text
|
|
245
|
+
|
|
246
|
+
return text[:max_length - len(suffix)] + suffix
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def colorize_compliance_status(status: str, percentage: Optional[float] = None) -> str:
|
|
250
|
+
"""Add color codes to compliance status for terminal display.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
status: Compliance status string
|
|
254
|
+
percentage: Optional percentage value for color coding
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Colorized status string
|
|
258
|
+
"""
|
|
259
|
+
# ANSI color codes
|
|
260
|
+
RED = '\033[91m'
|
|
261
|
+
YELLOW = '\033[93m'
|
|
262
|
+
GREEN = '\033[92m'
|
|
263
|
+
RESET = '\033[0m'
|
|
264
|
+
|
|
265
|
+
if percentage is not None:
|
|
266
|
+
if percentage >= 90:
|
|
267
|
+
return f"{GREEN}{status}{RESET}"
|
|
268
|
+
elif percentage >= 70:
|
|
269
|
+
return f"{YELLOW}{status}{RESET}"
|
|
270
|
+
else:
|
|
271
|
+
return f"{RED}{status}{RESET}"
|
|
272
|
+
|
|
273
|
+
# Status-based coloring
|
|
274
|
+
status_lower = status.lower()
|
|
275
|
+
if 'compliant' in status_lower and 'non' not in status_lower:
|
|
276
|
+
return f"{GREEN}{status}{RESET}"
|
|
277
|
+
elif 'non_compliant' in status_lower or 'failed' in status_lower:
|
|
278
|
+
return f"{RED}{status}{RESET}"
|
|
279
|
+
elif 'error' in status_lower or 'warning' in status_lower:
|
|
280
|
+
return f"{YELLOW}{status}{RESET}"
|
|
281
|
+
|
|
282
|
+
return status
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def is_tty() -> bool:
|
|
286
|
+
"""Check if output is going to a terminal (TTY).
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
True if output is to a terminal, False otherwise
|
|
290
|
+
"""
|
|
291
|
+
return sys.stdout.isatty()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def get_config_file_path(config_path: Optional[str], filename: str) -> Path:
|
|
295
|
+
"""Get full path to a configuration file.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
config_path: Base configuration directory path
|
|
299
|
+
filename: Configuration filename
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Full path to configuration file
|
|
303
|
+
"""
|
|
304
|
+
if config_path:
|
|
305
|
+
base_path = Path(config_path)
|
|
306
|
+
else:
|
|
307
|
+
# Use package default
|
|
308
|
+
from aws_cis_assessment.config import config_loader
|
|
309
|
+
base_path = Path(config_loader.__file__).parent / 'rules'
|
|
310
|
+
|
|
311
|
+
return base_path / filename
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def validate_output_format(formats: List[str]) -> List[str]:
|
|
315
|
+
"""Validate and normalize output format list.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
formats: List of output format strings
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
List of validated format strings
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
ValueError: If any format is invalid
|
|
325
|
+
"""
|
|
326
|
+
valid_formats = ['json', 'html', 'csv']
|
|
327
|
+
normalized_formats = []
|
|
328
|
+
|
|
329
|
+
for fmt in formats:
|
|
330
|
+
fmt_lower = fmt.lower()
|
|
331
|
+
if fmt_lower not in valid_formats:
|
|
332
|
+
raise ValueError(f"Invalid output format: {fmt}. Valid formats: {', '.join(valid_formats)}")
|
|
333
|
+
normalized_formats.append(fmt_lower)
|
|
334
|
+
|
|
335
|
+
return normalized_formats
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def create_progress_bar(current: int, total: int, width: int = 50,
|
|
339
|
+
fill_char: str = '█', empty_char: str = '░') -> str:
|
|
340
|
+
"""Create a text-based progress bar.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
current: Current progress value
|
|
344
|
+
total: Total progress value
|
|
345
|
+
width: Width of progress bar in characters
|
|
346
|
+
fill_char: Character for filled portion
|
|
347
|
+
empty_char: Character for empty portion
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Progress bar string
|
|
351
|
+
"""
|
|
352
|
+
if total == 0:
|
|
353
|
+
return empty_char * width
|
|
354
|
+
|
|
355
|
+
filled_width = int(width * current / total)
|
|
356
|
+
return fill_char * filled_width + empty_char * (width - filled_width)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration management for CIS Controls and AWS Config rule mappings."""
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Configuration loader for CIS Controls and AWS Config rule mappings."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import yaml
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from aws_cis_assessment.core.models import ConfigRule, CISControl, ImplementationGroup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigRuleLoader:
|
|
12
|
+
"""Load and parse AWS Config rule specifications for CIS Controls."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config_path: Optional[str] = None):
|
|
15
|
+
"""Initialize with path to CIS Controls configuration files.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
config_path: Path to configuration directory. If None, uses package default.
|
|
19
|
+
"""
|
|
20
|
+
if config_path is None:
|
|
21
|
+
# Use package default configuration path
|
|
22
|
+
package_dir = Path(__file__).parent
|
|
23
|
+
self.config_path = package_dir / "rules"
|
|
24
|
+
else:
|
|
25
|
+
self.config_path = Path(config_path)
|
|
26
|
+
|
|
27
|
+
self._config_cache = {}
|
|
28
|
+
self._rules_cache = {}
|
|
29
|
+
|
|
30
|
+
def load_rules_for_ig(self, implementation_group: str) -> Dict[str, List[ConfigRule]]:
|
|
31
|
+
"""Load all Config rules for specified Implementation Group.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
implementation_group: IG1, IG2, or IG3
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary mapping control IDs to lists of ConfigRule objects
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If implementation group is invalid
|
|
41
|
+
FileNotFoundError: If configuration file not found
|
|
42
|
+
"""
|
|
43
|
+
if implementation_group not in [ig.value for ig in ImplementationGroup]:
|
|
44
|
+
raise ValueError(f"Invalid implementation group: {implementation_group}")
|
|
45
|
+
|
|
46
|
+
# Check cache first
|
|
47
|
+
cache_key = f"rules_{implementation_group}"
|
|
48
|
+
if cache_key in self._rules_cache:
|
|
49
|
+
return self._rules_cache[cache_key]
|
|
50
|
+
|
|
51
|
+
# Load configuration file
|
|
52
|
+
config_file = self.config_path / f"cis_controls_{implementation_group.lower()}.yaml"
|
|
53
|
+
if not config_file.exists():
|
|
54
|
+
raise FileNotFoundError(f"Configuration file not found: {config_file}")
|
|
55
|
+
|
|
56
|
+
with open(config_file, 'r', encoding='utf-8') as f:
|
|
57
|
+
config_data = yaml.safe_load(f)
|
|
58
|
+
|
|
59
|
+
# Parse configuration into ConfigRule objects
|
|
60
|
+
rules_by_control = {}
|
|
61
|
+
|
|
62
|
+
if 'controls' in config_data:
|
|
63
|
+
for control_id, control_data in config_data['controls'].items():
|
|
64
|
+
config_rules = []
|
|
65
|
+
|
|
66
|
+
if 'config_rules' in control_data:
|
|
67
|
+
for rule_data in control_data['config_rules']:
|
|
68
|
+
config_rule = ConfigRule(
|
|
69
|
+
name=rule_data['name'],
|
|
70
|
+
control_id=control_id,
|
|
71
|
+
resource_types=rule_data.get('resource_types', []),
|
|
72
|
+
parameters=rule_data.get('parameters', {}),
|
|
73
|
+
implementation_group=implementation_group,
|
|
74
|
+
description=rule_data.get('description', ''),
|
|
75
|
+
remediation_guidance=rule_data.get('remediation_guidance', '')
|
|
76
|
+
)
|
|
77
|
+
config_rules.append(config_rule)
|
|
78
|
+
|
|
79
|
+
if config_rules:
|
|
80
|
+
rules_by_control[control_id] = config_rules
|
|
81
|
+
|
|
82
|
+
# Cache results
|
|
83
|
+
self._rules_cache[cache_key] = rules_by_control
|
|
84
|
+
return rules_by_control
|
|
85
|
+
|
|
86
|
+
def get_rule_by_name(self, rule_name: str) -> Optional[ConfigRule]:
|
|
87
|
+
"""Get specific Config rule definition by name.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
rule_name: Name of the AWS Config rule
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
ConfigRule object if found, None otherwise
|
|
94
|
+
"""
|
|
95
|
+
# Search through all implementation groups
|
|
96
|
+
for ig in ImplementationGroup:
|
|
97
|
+
rules_by_control = self.load_rules_for_ig(ig.value)
|
|
98
|
+
for control_rules in rules_by_control.values():
|
|
99
|
+
for rule in control_rules:
|
|
100
|
+
if rule.name == rule_name:
|
|
101
|
+
return rule
|
|
102
|
+
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def get_all_controls(self) -> Dict[str, CISControl]:
|
|
106
|
+
"""Get all CIS Controls across all Implementation Groups.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Dictionary mapping control IDs to CISControl objects
|
|
110
|
+
"""
|
|
111
|
+
all_controls = {}
|
|
112
|
+
|
|
113
|
+
for ig in ImplementationGroup:
|
|
114
|
+
rules_by_control = self.load_rules_for_ig(ig.value)
|
|
115
|
+
|
|
116
|
+
# Load control metadata
|
|
117
|
+
config_file = self.config_path / f"cis_controls_{ig.value.lower()}.yaml"
|
|
118
|
+
with open(config_file, 'r', encoding='utf-8') as f:
|
|
119
|
+
config_data = yaml.safe_load(f)
|
|
120
|
+
|
|
121
|
+
if 'controls' in config_data:
|
|
122
|
+
for control_id, control_data in config_data['controls'].items():
|
|
123
|
+
# Create unique key for each IG-control combination
|
|
124
|
+
unique_key = f"{ig.value}_{control_id}"
|
|
125
|
+
|
|
126
|
+
control = CISControl(
|
|
127
|
+
control_id=control_id,
|
|
128
|
+
title=control_data.get('title', ''),
|
|
129
|
+
implementation_group=ig.value,
|
|
130
|
+
config_rules=rules_by_control.get(control_id, []),
|
|
131
|
+
weight=control_data.get('weight', 1.0)
|
|
132
|
+
)
|
|
133
|
+
all_controls[unique_key] = control
|
|
134
|
+
|
|
135
|
+
return all_controls
|
|
136
|
+
|
|
137
|
+
def validate_configuration(self) -> List[str]:
|
|
138
|
+
"""Validate configuration files and return any errors.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of validation error messages
|
|
142
|
+
"""
|
|
143
|
+
errors = []
|
|
144
|
+
|
|
145
|
+
for ig in ImplementationGroup:
|
|
146
|
+
config_file = self.config_path / f"cis_controls_{ig.value.lower()}.yaml"
|
|
147
|
+
|
|
148
|
+
if not config_file.exists():
|
|
149
|
+
errors.append(f"Missing configuration file: {config_file}")
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
with open(config_file, 'r', encoding='utf-8') as f:
|
|
154
|
+
config_data = yaml.safe_load(f)
|
|
155
|
+
|
|
156
|
+
# Validate structure
|
|
157
|
+
if not isinstance(config_data, dict):
|
|
158
|
+
errors.append(f"Invalid YAML structure in {config_file}")
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if 'controls' not in config_data:
|
|
162
|
+
errors.append(f"Missing 'controls' section in {config_file}")
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Validate each control
|
|
166
|
+
for control_id, control_data in config_data['controls'].items():
|
|
167
|
+
if not isinstance(control_data, dict):
|
|
168
|
+
errors.append(f"Invalid control data for {control_id} in {config_file}")
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
if 'title' not in control_data:
|
|
172
|
+
errors.append(f"Missing title for control {control_id} in {config_file}")
|
|
173
|
+
|
|
174
|
+
if 'config_rules' in control_data:
|
|
175
|
+
for i, rule_data in enumerate(control_data['config_rules']):
|
|
176
|
+
if not isinstance(rule_data, dict):
|
|
177
|
+
errors.append(f"Invalid rule data at index {i} for control {control_id}")
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if 'name' not in rule_data:
|
|
181
|
+
errors.append(f"Missing rule name at index {i} for control {control_id}")
|
|
182
|
+
|
|
183
|
+
if 'resource_types' not in rule_data:
|
|
184
|
+
errors.append(f"Missing resource_types for rule at index {i} for control {control_id}")
|
|
185
|
+
|
|
186
|
+
except yaml.YAMLError as e:
|
|
187
|
+
errors.append(f"YAML parsing error in {config_file}: {e}")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
errors.append(f"Error validating {config_file}: {e}")
|
|
190
|
+
|
|
191
|
+
return errors
|
|
192
|
+
|
|
193
|
+
def get_rules_count_by_ig(self) -> Dict[str, int]:
|
|
194
|
+
"""Get count of Config rules by Implementation Group.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Dictionary mapping IG names to rule counts
|
|
198
|
+
"""
|
|
199
|
+
counts = {}
|
|
200
|
+
|
|
201
|
+
for ig in ImplementationGroup:
|
|
202
|
+
try:
|
|
203
|
+
rules_by_control = self.load_rules_for_ig(ig.value)
|
|
204
|
+
total_rules = sum(len(rules) for rules in rules_by_control.values())
|
|
205
|
+
counts[ig.value] = total_rules
|
|
206
|
+
except (FileNotFoundError, ValueError):
|
|
207
|
+
counts[ig.value] = 0
|
|
208
|
+
|
|
209
|
+
return counts
|
|
210
|
+
|
|
211
|
+
def get_assessment_statistics(self, implementation_groups: Optional[List[str]] = None,
|
|
212
|
+
controls: Optional[List[str]] = None,
|
|
213
|
+
regions: Optional[List[str]] = None) -> Dict[str, any]:
|
|
214
|
+
"""Get assessment statistics based on specified criteria.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
implementation_groups: List of IGs to include (default: all)
|
|
218
|
+
controls: List of specific control IDs to include
|
|
219
|
+
regions: List of regions to include (for estimation)
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Dictionary containing assessment statistics
|
|
223
|
+
"""
|
|
224
|
+
# Default to all IGs if none specified
|
|
225
|
+
if implementation_groups is None:
|
|
226
|
+
implementation_groups = [ig.value for ig in ImplementationGroup]
|
|
227
|
+
|
|
228
|
+
# Default regions for estimation
|
|
229
|
+
if regions is None:
|
|
230
|
+
from aws_cis_assessment.cli.utils import get_default_regions
|
|
231
|
+
regions = get_default_regions()
|
|
232
|
+
|
|
233
|
+
stats = {
|
|
234
|
+
'total_controls': 0,
|
|
235
|
+
'total_config_rules': 0,
|
|
236
|
+
'total_regions': len(regions),
|
|
237
|
+
'estimated_assessments': 0,
|
|
238
|
+
'by_implementation_group': {},
|
|
239
|
+
'by_service': {},
|
|
240
|
+
'resource_types': set()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Get all controls
|
|
244
|
+
all_controls = self.get_all_controls()
|
|
245
|
+
|
|
246
|
+
# Filter controls based on criteria
|
|
247
|
+
filtered_controls = {}
|
|
248
|
+
for control_id, control in all_controls.items():
|
|
249
|
+
# Filter by implementation group
|
|
250
|
+
if control.implementation_group not in implementation_groups:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Filter by specific controls
|
|
254
|
+
if controls and control_id not in controls:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
filtered_controls[control_id] = control
|
|
258
|
+
|
|
259
|
+
# Calculate statistics
|
|
260
|
+
stats['total_controls'] = len(filtered_controls)
|
|
261
|
+
|
|
262
|
+
for control_id, control in filtered_controls.items():
|
|
263
|
+
ig = control.implementation_group
|
|
264
|
+
|
|
265
|
+
# Initialize IG stats if needed
|
|
266
|
+
if ig not in stats['by_implementation_group']:
|
|
267
|
+
stats['by_implementation_group'][ig] = {
|
|
268
|
+
'controls': 0,
|
|
269
|
+
'config_rules': 0
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# Count controls and rules
|
|
273
|
+
stats['by_implementation_group'][ig]['controls'] += 1
|
|
274
|
+
stats['by_implementation_group'][ig]['config_rules'] += len(control.config_rules)
|
|
275
|
+
stats['total_config_rules'] += len(control.config_rules)
|
|
276
|
+
|
|
277
|
+
# Count by service and resource types
|
|
278
|
+
for rule in control.config_rules:
|
|
279
|
+
# Extract service from rule name (heuristic)
|
|
280
|
+
service = self._extract_service_from_rule(rule.name)
|
|
281
|
+
if service:
|
|
282
|
+
stats['by_service'][service] = stats['by_service'].get(service, 0) + 1
|
|
283
|
+
|
|
284
|
+
# Add resource types
|
|
285
|
+
stats['resource_types'].update(rule.resource_types)
|
|
286
|
+
|
|
287
|
+
# Estimate total assessments (rules * regions)
|
|
288
|
+
stats['estimated_assessments'] = stats['total_config_rules'] * len(regions)
|
|
289
|
+
|
|
290
|
+
# Convert set to list for JSON serialization
|
|
291
|
+
stats['resource_types'] = sorted(list(stats['resource_types']))
|
|
292
|
+
|
|
293
|
+
return stats
|
|
294
|
+
|
|
295
|
+
def _extract_service_from_rule(self, rule_name: str) -> Optional[str]:
|
|
296
|
+
"""Extract AWS service name from Config rule name.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
rule_name: AWS Config rule name
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Service name if identifiable, None otherwise
|
|
303
|
+
"""
|
|
304
|
+
# Common service prefixes in Config rule names
|
|
305
|
+
service_prefixes = {
|
|
306
|
+
'ec2-': 'EC2',
|
|
307
|
+
'iam-': 'IAM',
|
|
308
|
+
's3-': 'S3',
|
|
309
|
+
'rds-': 'RDS',
|
|
310
|
+
'vpc-': 'VPC',
|
|
311
|
+
'elb-': 'ELB',
|
|
312
|
+
'alb-': 'ALB',
|
|
313
|
+
'api-gw-': 'API Gateway',
|
|
314
|
+
'cloudtrail-': 'CloudTrail',
|
|
315
|
+
'guardduty-': 'GuardDuty',
|
|
316
|
+
'dynamodb-': 'DynamoDB',
|
|
317
|
+
'redshift-': 'Redshift',
|
|
318
|
+
'secretsmanager-': 'Secrets Manager',
|
|
319
|
+
'backup-': 'Backup',
|
|
320
|
+
'ecr-': 'ECR'
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
rule_lower = rule_name.lower()
|
|
324
|
+
for prefix, service in service_prefixes.items():
|
|
325
|
+
if rule_lower.startswith(prefix):
|
|
326
|
+
return service
|
|
327
|
+
|
|
328
|
+
return 'Other'
|