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.
Files changed (77) hide show
  1. aws_cis_assessment/__init__.py +11 -0
  2. aws_cis_assessment/cli/__init__.py +3 -0
  3. aws_cis_assessment/cli/examples.py +274 -0
  4. aws_cis_assessment/cli/main.py +1259 -0
  5. aws_cis_assessment/cli/utils.py +356 -0
  6. aws_cis_assessment/config/__init__.py +1 -0
  7. aws_cis_assessment/config/config_loader.py +328 -0
  8. aws_cis_assessment/config/rules/cis_controls_ig1.yaml +590 -0
  9. aws_cis_assessment/config/rules/cis_controls_ig2.yaml +412 -0
  10. aws_cis_assessment/config/rules/cis_controls_ig3.yaml +100 -0
  11. aws_cis_assessment/controls/__init__.py +1 -0
  12. aws_cis_assessment/controls/base_control.py +400 -0
  13. aws_cis_assessment/controls/ig1/__init__.py +239 -0
  14. aws_cis_assessment/controls/ig1/control_1_1.py +586 -0
  15. aws_cis_assessment/controls/ig1/control_2_2.py +231 -0
  16. aws_cis_assessment/controls/ig1/control_3_3.py +718 -0
  17. aws_cis_assessment/controls/ig1/control_3_4.py +235 -0
  18. aws_cis_assessment/controls/ig1/control_4_1.py +461 -0
  19. aws_cis_assessment/controls/ig1/control_access_keys.py +310 -0
  20. aws_cis_assessment/controls/ig1/control_advanced_security.py +512 -0
  21. aws_cis_assessment/controls/ig1/control_backup_recovery.py +510 -0
  22. aws_cis_assessment/controls/ig1/control_cloudtrail_logging.py +197 -0
  23. aws_cis_assessment/controls/ig1/control_critical_security.py +422 -0
  24. aws_cis_assessment/controls/ig1/control_data_protection.py +898 -0
  25. aws_cis_assessment/controls/ig1/control_iam_advanced.py +573 -0
  26. aws_cis_assessment/controls/ig1/control_iam_governance.py +493 -0
  27. aws_cis_assessment/controls/ig1/control_iam_policies.py +383 -0
  28. aws_cis_assessment/controls/ig1/control_instance_optimization.py +100 -0
  29. aws_cis_assessment/controls/ig1/control_network_enhancements.py +203 -0
  30. aws_cis_assessment/controls/ig1/control_network_security.py +672 -0
  31. aws_cis_assessment/controls/ig1/control_s3_enhancements.py +173 -0
  32. aws_cis_assessment/controls/ig1/control_s3_security.py +422 -0
  33. aws_cis_assessment/controls/ig1/control_vpc_security.py +235 -0
  34. aws_cis_assessment/controls/ig2/__init__.py +172 -0
  35. aws_cis_assessment/controls/ig2/control_3_10.py +698 -0
  36. aws_cis_assessment/controls/ig2/control_3_11.py +1330 -0
  37. aws_cis_assessment/controls/ig2/control_5_2.py +393 -0
  38. aws_cis_assessment/controls/ig2/control_advanced_encryption.py +355 -0
  39. aws_cis_assessment/controls/ig2/control_codebuild_security.py +263 -0
  40. aws_cis_assessment/controls/ig2/control_encryption_rest.py +382 -0
  41. aws_cis_assessment/controls/ig2/control_encryption_transit.py +382 -0
  42. aws_cis_assessment/controls/ig2/control_network_ha.py +467 -0
  43. aws_cis_assessment/controls/ig2/control_remaining_encryption.py +426 -0
  44. aws_cis_assessment/controls/ig2/control_remaining_rules.py +363 -0
  45. aws_cis_assessment/controls/ig2/control_service_logging.py +402 -0
  46. aws_cis_assessment/controls/ig3/__init__.py +49 -0
  47. aws_cis_assessment/controls/ig3/control_12_8.py +395 -0
  48. aws_cis_assessment/controls/ig3/control_13_1.py +467 -0
  49. aws_cis_assessment/controls/ig3/control_3_14.py +523 -0
  50. aws_cis_assessment/controls/ig3/control_7_1.py +359 -0
  51. aws_cis_assessment/core/__init__.py +1 -0
  52. aws_cis_assessment/core/accuracy_validator.py +425 -0
  53. aws_cis_assessment/core/assessment_engine.py +1266 -0
  54. aws_cis_assessment/core/audit_trail.py +491 -0
  55. aws_cis_assessment/core/aws_client_factory.py +313 -0
  56. aws_cis_assessment/core/error_handler.py +607 -0
  57. aws_cis_assessment/core/models.py +166 -0
  58. aws_cis_assessment/core/scoring_engine.py +459 -0
  59. aws_cis_assessment/reporters/__init__.py +8 -0
  60. aws_cis_assessment/reporters/base_reporter.py +454 -0
  61. aws_cis_assessment/reporters/csv_reporter.py +835 -0
  62. aws_cis_assessment/reporters/html_reporter.py +2162 -0
  63. aws_cis_assessment/reporters/json_reporter.py +561 -0
  64. aws_cis_controls_assessment-1.0.3.dist-info/METADATA +248 -0
  65. aws_cis_controls_assessment-1.0.3.dist-info/RECORD +77 -0
  66. aws_cis_controls_assessment-1.0.3.dist-info/WHEEL +5 -0
  67. aws_cis_controls_assessment-1.0.3.dist-info/entry_points.txt +2 -0
  68. aws_cis_controls_assessment-1.0.3.dist-info/licenses/LICENSE +21 -0
  69. aws_cis_controls_assessment-1.0.3.dist-info/top_level.txt +2 -0
  70. docs/README.md +94 -0
  71. docs/assessment-logic.md +766 -0
  72. docs/cli-reference.md +698 -0
  73. docs/config-rule-mappings.md +393 -0
  74. docs/developer-guide.md +858 -0
  75. docs/installation.md +299 -0
  76. docs/troubleshooting.md +634 -0
  77. 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'