complio 0.1.1__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.
- CHANGELOG.md +208 -0
- README.md +343 -0
- complio/__init__.py +48 -0
- complio/cli/__init__.py +0 -0
- complio/cli/banner.py +87 -0
- complio/cli/commands/__init__.py +0 -0
- complio/cli/commands/history.py +439 -0
- complio/cli/commands/scan.py +700 -0
- complio/cli/main.py +115 -0
- complio/cli/output.py +338 -0
- complio/config/__init__.py +17 -0
- complio/config/settings.py +333 -0
- complio/connectors/__init__.py +9 -0
- complio/connectors/aws/__init__.py +0 -0
- complio/connectors/aws/client.py +342 -0
- complio/connectors/base.py +135 -0
- complio/core/__init__.py +10 -0
- complio/core/registry.py +228 -0
- complio/core/runner.py +351 -0
- complio/py.typed +0 -0
- complio/reporters/__init__.py +7 -0
- complio/reporters/generator.py +417 -0
- complio/tests_library/__init__.py +0 -0
- complio/tests_library/base.py +492 -0
- complio/tests_library/identity/__init__.py +0 -0
- complio/tests_library/identity/access_key_rotation.py +302 -0
- complio/tests_library/identity/mfa_enforcement.py +327 -0
- complio/tests_library/identity/root_account_protection.py +470 -0
- complio/tests_library/infrastructure/__init__.py +0 -0
- complio/tests_library/infrastructure/cloudtrail_encryption.py +286 -0
- complio/tests_library/infrastructure/cloudtrail_log_validation.py +274 -0
- complio/tests_library/infrastructure/cloudtrail_logging.py +400 -0
- complio/tests_library/infrastructure/ebs_encryption.py +244 -0
- complio/tests_library/infrastructure/ec2_security_groups.py +321 -0
- complio/tests_library/infrastructure/iam_password_policy.py +460 -0
- complio/tests_library/infrastructure/nacl_security.py +356 -0
- complio/tests_library/infrastructure/rds_encryption.py +252 -0
- complio/tests_library/infrastructure/s3_encryption.py +301 -0
- complio/tests_library/infrastructure/s3_public_access.py +369 -0
- complio/tests_library/infrastructure/secrets_manager_encryption.py +248 -0
- complio/tests_library/infrastructure/vpc_flow_logs.py +287 -0
- complio/tests_library/logging/__init__.py +0 -0
- complio/tests_library/logging/cloudwatch_alarms.py +354 -0
- complio/tests_library/logging/cloudwatch_logs_encryption.py +281 -0
- complio/tests_library/logging/cloudwatch_retention.py +252 -0
- complio/tests_library/logging/config_enabled.py +393 -0
- complio/tests_library/logging/eventbridge_rules.py +460 -0
- complio/tests_library/logging/guardduty_enabled.py +436 -0
- complio/tests_library/logging/security_hub_enabled.py +416 -0
- complio/tests_library/logging/sns_encryption.py +273 -0
- complio/tests_library/network/__init__.py +0 -0
- complio/tests_library/network/alb_nlb_security.py +421 -0
- complio/tests_library/network/api_gateway_security.py +452 -0
- complio/tests_library/network/cloudfront_https.py +332 -0
- complio/tests_library/network/direct_connect_security.py +343 -0
- complio/tests_library/network/nacl_configuration.py +367 -0
- complio/tests_library/network/network_firewall.py +355 -0
- complio/tests_library/network/transit_gateway_security.py +318 -0
- complio/tests_library/network/vpc_endpoints_security.py +339 -0
- complio/tests_library/network/vpn_security.py +333 -0
- complio/tests_library/network/waf_configuration.py +428 -0
- complio/tests_library/security/__init__.py +0 -0
- complio/tests_library/security/kms_key_rotation.py +314 -0
- complio/tests_library/storage/__init__.py +0 -0
- complio/tests_library/storage/backup_encryption.py +288 -0
- complio/tests_library/storage/dynamodb_encryption.py +280 -0
- complio/tests_library/storage/efs_encryption.py +257 -0
- complio/tests_library/storage/elasticache_encryption.py +370 -0
- complio/tests_library/storage/redshift_encryption.py +252 -0
- complio/tests_library/storage/s3_versioning.py +264 -0
- complio/utils/__init__.py +26 -0
- complio/utils/errors.py +179 -0
- complio/utils/exceptions.py +151 -0
- complio/utils/history.py +243 -0
- complio/utils/logger.py +391 -0
- complio-0.1.1.dist-info/METADATA +385 -0
- complio-0.1.1.dist-info/RECORD +79 -0
- complio-0.1.1.dist-info/WHEEL +4 -0
- complio-0.1.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scan command for running compliance tests.
|
|
3
|
+
|
|
4
|
+
This module implements the 'complio scan' CLI command for executing
|
|
5
|
+
compliance tests and generating reports.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
$ complio scan
|
|
9
|
+
$ complio scan --test s3_encryption
|
|
10
|
+
$ complio scan --region us-west-2 --output report.json
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.progress import (
|
|
19
|
+
BarColumn,
|
|
20
|
+
Progress,
|
|
21
|
+
SpinnerColumn,
|
|
22
|
+
TextColumn,
|
|
23
|
+
TimeElapsedColumn,
|
|
24
|
+
)
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
|
|
27
|
+
from complio.cli.output import ComplianceOutput
|
|
28
|
+
from complio.connectors.aws.client import AWSConnector
|
|
29
|
+
from complio.core.registry import TestRegistry
|
|
30
|
+
from complio.core.runner import TestRunner
|
|
31
|
+
from complio.reporters.generator import ReportGenerator
|
|
32
|
+
from complio.utils.errors import handle_aws_error, validate_profile_exists, validate_region_format
|
|
33
|
+
from complio.utils.history import save_scan_to_history
|
|
34
|
+
from complio.utils.logger import get_logger
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ============================================================================
|
|
38
|
+
# ARGUMENT VALIDATION CALLBACKS
|
|
39
|
+
# ============================================================================
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def validate_region_param(ctx, param, value):
|
|
43
|
+
"""Validate region parameter format and emptiness."""
|
|
44
|
+
if value is None:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
# Check for empty string
|
|
48
|
+
if isinstance(value, str) and value.strip() == "":
|
|
49
|
+
raise click.BadParameter(
|
|
50
|
+
"Region cannot be empty. "
|
|
51
|
+
"Valid regions: us-east-1, eu-west-1, eu-west-3, etc."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Validate format
|
|
55
|
+
if not validate_region_format(value):
|
|
56
|
+
raise click.BadParameter(
|
|
57
|
+
f"'{value}' is not a valid AWS region format. "
|
|
58
|
+
"Expected format: us-east-1, eu-west-3, ap-southeast-2, etc."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_profile_param(ctx, param, value):
|
|
65
|
+
"""Validate profile parameter exists and is not empty."""
|
|
66
|
+
if value is None or value == "default":
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
# Check for empty string
|
|
70
|
+
if isinstance(value, str) and value.strip() == "":
|
|
71
|
+
raise click.BadParameter("Profile name cannot be empty")
|
|
72
|
+
|
|
73
|
+
# Verify profile exists (warning only, let boto3 handle actual failure)
|
|
74
|
+
if not validate_profile_exists(value):
|
|
75
|
+
import configparser
|
|
76
|
+
import os
|
|
77
|
+
try:
|
|
78
|
+
config = configparser.ConfigParser()
|
|
79
|
+
config.read(os.path.expanduser('~/.aws/credentials'))
|
|
80
|
+
available = ", ".join(config.sections()) if config.sections() else "none"
|
|
81
|
+
raise click.BadParameter(
|
|
82
|
+
f"Profile '{value}' not found in ~/.aws/credentials. "
|
|
83
|
+
f"Available profiles: {available}"
|
|
84
|
+
)
|
|
85
|
+
except:
|
|
86
|
+
pass # Let boto3 handle validation
|
|
87
|
+
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@click.command()
|
|
92
|
+
@click.option(
|
|
93
|
+
"--profile",
|
|
94
|
+
default="default",
|
|
95
|
+
callback=validate_profile_param,
|
|
96
|
+
help="AWS credentials profile to use",
|
|
97
|
+
show_default=True,
|
|
98
|
+
)
|
|
99
|
+
@click.option(
|
|
100
|
+
"--region",
|
|
101
|
+
default=None,
|
|
102
|
+
callback=validate_region_param,
|
|
103
|
+
help="AWS region to scan (uses profile's region if not specified)",
|
|
104
|
+
)
|
|
105
|
+
@click.option(
|
|
106
|
+
"--regions",
|
|
107
|
+
default=None,
|
|
108
|
+
help="Comma-separated list of regions to scan (e.g., 'us-east-1,eu-west-1')",
|
|
109
|
+
)
|
|
110
|
+
@click.option(
|
|
111
|
+
"--all-regions",
|
|
112
|
+
is_flag=True,
|
|
113
|
+
default=False,
|
|
114
|
+
help="Scan all available AWS regions (takes longer)",
|
|
115
|
+
)
|
|
116
|
+
@click.option(
|
|
117
|
+
"--test",
|
|
118
|
+
"test_id",
|
|
119
|
+
default=None,
|
|
120
|
+
help="Run specific test only (e.g., 's3_encryption')",
|
|
121
|
+
)
|
|
122
|
+
@click.option(
|
|
123
|
+
"--output",
|
|
124
|
+
"-o",
|
|
125
|
+
type=click.Path(path_type=Path),
|
|
126
|
+
default=None,
|
|
127
|
+
help="Save report to file (auto-detects format from extension: .json, .md)",
|
|
128
|
+
)
|
|
129
|
+
@click.option(
|
|
130
|
+
"--format",
|
|
131
|
+
"output_format",
|
|
132
|
+
type=click.Choice(["json", "markdown"], case_sensitive=False),
|
|
133
|
+
default=None,
|
|
134
|
+
help="Report format (auto-detected from --output if not specified)",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--parallel",
|
|
138
|
+
is_flag=True,
|
|
139
|
+
default=False,
|
|
140
|
+
help="Run tests in parallel (faster but uses more resources)",
|
|
141
|
+
)
|
|
142
|
+
@click.option(
|
|
143
|
+
"--list-tests",
|
|
144
|
+
is_flag=True,
|
|
145
|
+
default=False,
|
|
146
|
+
help="List all available tests and exit",
|
|
147
|
+
)
|
|
148
|
+
@click.pass_context
|
|
149
|
+
def scan(
|
|
150
|
+
ctx: click.Context,
|
|
151
|
+
profile: str,
|
|
152
|
+
region: Optional[str],
|
|
153
|
+
regions: Optional[str],
|
|
154
|
+
all_regions: bool,
|
|
155
|
+
test_id: Optional[str],
|
|
156
|
+
output: Optional[Path],
|
|
157
|
+
output_format: Optional[str],
|
|
158
|
+
parallel: bool,
|
|
159
|
+
list_tests: bool,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Run compliance tests on AWS infrastructure.
|
|
162
|
+
|
|
163
|
+
Executes ISO 27001 compliance tests against your AWS environment
|
|
164
|
+
and generates detailed reports with findings and remediation steps.
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
|
|
168
|
+
# Run all tests on default profile
|
|
169
|
+
$ complio scan
|
|
170
|
+
|
|
171
|
+
# Run specific test
|
|
172
|
+
$ complio scan --test s3_encryption
|
|
173
|
+
|
|
174
|
+
# Scan specific region
|
|
175
|
+
$ complio scan --region us-west-2
|
|
176
|
+
|
|
177
|
+
# Scan multiple regions
|
|
178
|
+
$ complio scan --regions us-east-1,eu-west-1,ap-southeast-2
|
|
179
|
+
|
|
180
|
+
# Scan all AWS regions
|
|
181
|
+
$ complio scan --all-regions
|
|
182
|
+
|
|
183
|
+
# Save report to file
|
|
184
|
+
$ complio scan --output report.json
|
|
185
|
+
$ complio scan --output report.md --format markdown
|
|
186
|
+
|
|
187
|
+
# Run tests in parallel (faster)
|
|
188
|
+
$ complio scan --parallel
|
|
189
|
+
|
|
190
|
+
# List all available tests
|
|
191
|
+
$ complio scan --list-tests
|
|
192
|
+
"""
|
|
193
|
+
console = Console()
|
|
194
|
+
output_helper = ComplianceOutput()
|
|
195
|
+
logger = get_logger(__name__)
|
|
196
|
+
|
|
197
|
+
# List tests and exit if requested
|
|
198
|
+
if list_tests:
|
|
199
|
+
_list_available_tests(console)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# ========================================================================
|
|
203
|
+
# SCAN EXECUTION
|
|
204
|
+
# ========================================================================
|
|
205
|
+
|
|
206
|
+
# Validate mutually exclusive region options
|
|
207
|
+
region_options_count = sum([
|
|
208
|
+
region is not None,
|
|
209
|
+
regions is not None,
|
|
210
|
+
all_regions,
|
|
211
|
+
])
|
|
212
|
+
|
|
213
|
+
if region_options_count > 1:
|
|
214
|
+
output_helper.error("Options --region, --regions, and --all-regions are mutually exclusive")
|
|
215
|
+
output_helper.info("Use only one of these options at a time")
|
|
216
|
+
raise click.Abort()
|
|
217
|
+
|
|
218
|
+
# Determine regions to scan
|
|
219
|
+
scan_regions = []
|
|
220
|
+
|
|
221
|
+
if all_regions:
|
|
222
|
+
# Get all AWS regions from the validation function
|
|
223
|
+
scan_regions = [
|
|
224
|
+
"us-east-1", "us-east-2", "us-west-1", "us-west-2",
|
|
225
|
+
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-north-1",
|
|
226
|
+
"ap-south-1", "ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2",
|
|
227
|
+
"ca-central-1", "sa-east-1",
|
|
228
|
+
]
|
|
229
|
+
output_helper.info(f"Scanning all {len(scan_regions)} AWS regions (this may take a while)")
|
|
230
|
+
elif regions:
|
|
231
|
+
# Parse comma-separated list
|
|
232
|
+
scan_regions = [r.strip() for r in regions.split(",")]
|
|
233
|
+
# Validate each region
|
|
234
|
+
for r in scan_regions:
|
|
235
|
+
if not validate_region_format(r):
|
|
236
|
+
output_helper.error(f"Invalid region format: '{r}'")
|
|
237
|
+
output_helper.info("Valid format: us-east-1, eu-west-3, ap-southeast-2, etc.")
|
|
238
|
+
raise click.Abort()
|
|
239
|
+
if not _validate_aws_region(r):
|
|
240
|
+
output_helper.error(f"Unknown AWS region: '{r}'")
|
|
241
|
+
output_helper.info("Valid regions: us-east-1, us-west-2, eu-west-1, eu-west-3, etc.")
|
|
242
|
+
raise click.Abort()
|
|
243
|
+
output_helper.info(f"Scanning {len(scan_regions)} regions: {', '.join(scan_regions)}")
|
|
244
|
+
else:
|
|
245
|
+
# Single region mode (existing behavior)
|
|
246
|
+
scan_region = region
|
|
247
|
+
if not scan_region:
|
|
248
|
+
# Try to get region from AWS config/profile
|
|
249
|
+
try:
|
|
250
|
+
import boto3
|
|
251
|
+
session = boto3.Session(profile_name=profile)
|
|
252
|
+
scan_region = session.region_name
|
|
253
|
+
if scan_region:
|
|
254
|
+
output_helper.info(f"Using region from AWS config: {scan_region}")
|
|
255
|
+
else:
|
|
256
|
+
# Fall back to us-east-1 if no region in config
|
|
257
|
+
from complio.config.settings import get_settings
|
|
258
|
+
settings = get_settings()
|
|
259
|
+
scan_region = settings.default_region
|
|
260
|
+
output_helper.info(f"No region in AWS config, using default: {scan_region}")
|
|
261
|
+
except Exception:
|
|
262
|
+
# If boto3 fails, use default
|
|
263
|
+
from complio.config.settings import get_settings
|
|
264
|
+
settings = get_settings()
|
|
265
|
+
scan_region = settings.default_region
|
|
266
|
+
output_helper.info(f"Using default region: {scan_region}")
|
|
267
|
+
|
|
268
|
+
# Validate region
|
|
269
|
+
if not _validate_aws_region(scan_region):
|
|
270
|
+
output_helper.error(f"Invalid AWS region: {scan_region}")
|
|
271
|
+
output_helper.info("Valid regions include: us-east-1, us-west-2, eu-west-1, eu-west-3, eu-north-1, etc.")
|
|
272
|
+
raise click.Abort()
|
|
273
|
+
|
|
274
|
+
# Convert single region to list for unified processing
|
|
275
|
+
scan_regions = [scan_region]
|
|
276
|
+
|
|
277
|
+
# ========================================================================
|
|
278
|
+
# MULTI-REGION SCANNING LOOP
|
|
279
|
+
# ========================================================================
|
|
280
|
+
|
|
281
|
+
# Store results from all regions
|
|
282
|
+
all_regional_results = []
|
|
283
|
+
failed_regions = []
|
|
284
|
+
|
|
285
|
+
# Determine which tests to run (before region loop)
|
|
286
|
+
if test_id:
|
|
287
|
+
# Run single test
|
|
288
|
+
registry = TestRegistry()
|
|
289
|
+
if not registry.test_exists(test_id):
|
|
290
|
+
output_helper.error(f"Test '{test_id}' not found")
|
|
291
|
+
available_tests = registry.get_test_ids()
|
|
292
|
+
output_helper.info(f"Available tests: {', '.join(available_tests)}")
|
|
293
|
+
raise click.Abort()
|
|
294
|
+
|
|
295
|
+
test_ids = [test_id]
|
|
296
|
+
output_helper.info(f"Running single test: {test_id}")
|
|
297
|
+
else:
|
|
298
|
+
# Run all tests
|
|
299
|
+
registry = TestRegistry()
|
|
300
|
+
test_ids = registry.get_test_ids()
|
|
301
|
+
output_helper.info(f"Running {len(test_ids)} compliance tests")
|
|
302
|
+
|
|
303
|
+
# Loop through each region
|
|
304
|
+
for region_index, scan_region in enumerate(scan_regions, 1):
|
|
305
|
+
try:
|
|
306
|
+
# Show region progress for multi-region scans
|
|
307
|
+
if len(scan_regions) > 1:
|
|
308
|
+
console.print(f"\n[bold cyan]Region {region_index}/{len(scan_regions)}: {scan_region}[/bold cyan]\n")
|
|
309
|
+
|
|
310
|
+
# Initialize AWS connector with standard credentials
|
|
311
|
+
# Reads from ~/.aws/credentials automatically
|
|
312
|
+
connector = AWSConnector(
|
|
313
|
+
profile_name=profile,
|
|
314
|
+
region=scan_region
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
output_helper.success(f"Connecting to AWS region: {scan_region}")
|
|
318
|
+
|
|
319
|
+
if not connector.connect():
|
|
320
|
+
output_helper.error(f"Failed to connect to AWS region {scan_region}")
|
|
321
|
+
failed_regions.append(scan_region)
|
|
322
|
+
continue # Skip to next region
|
|
323
|
+
|
|
324
|
+
# Validate credentials (only once for first region)
|
|
325
|
+
if region_index == 1:
|
|
326
|
+
account_info = connector.validate_credentials()
|
|
327
|
+
account_id = account_info.get("account_id", "unknown")
|
|
328
|
+
output_helper.info(f"Connected to AWS Account: {account_id}")
|
|
329
|
+
|
|
330
|
+
# Execute tests with progress bar
|
|
331
|
+
with Progress(
|
|
332
|
+
SpinnerColumn(),
|
|
333
|
+
TextColumn("[progress.description]{task.description}"),
|
|
334
|
+
BarColumn(),
|
|
335
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
336
|
+
TimeElapsedColumn(),
|
|
337
|
+
console=console,
|
|
338
|
+
) as progress:
|
|
339
|
+
progress_task = progress.add_task(
|
|
340
|
+
"[cyan]Running compliance tests...",
|
|
341
|
+
total=len(test_ids),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Define progress callback to update Rich progress bar
|
|
345
|
+
def progress_callback(test_name: str, current: int, total: int, scope: str = "regional") -> None:
|
|
346
|
+
"""Update progress bar after each test completes with scope information."""
|
|
347
|
+
# Create scope label
|
|
348
|
+
if scope == "global":
|
|
349
|
+
scope_label = "[dim cyan](Global - All Regions)[/dim cyan]"
|
|
350
|
+
else:
|
|
351
|
+
scope_label = f"[dim cyan](Regional - {scan_region} only)[/dim cyan]"
|
|
352
|
+
|
|
353
|
+
description = f"[cyan]Test {current}/{total}:[/cyan] {test_name} {scope_label}"
|
|
354
|
+
progress.update(progress_task, completed=current, description=description)
|
|
355
|
+
|
|
356
|
+
# Initialize test runner with progress callback
|
|
357
|
+
runner = TestRunner(
|
|
358
|
+
connector=connector,
|
|
359
|
+
max_workers=4,
|
|
360
|
+
progress_callback=progress_callback,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Run tests
|
|
364
|
+
results = runner.run_tests(test_ids, parallel=parallel)
|
|
365
|
+
|
|
366
|
+
# Ensure progress is at 100%
|
|
367
|
+
progress.update(progress_task, completed=len(test_ids))
|
|
368
|
+
|
|
369
|
+
# Store results with region info
|
|
370
|
+
all_regional_results.append({
|
|
371
|
+
"region": scan_region,
|
|
372
|
+
"results": results,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
# For single region, display results immediately
|
|
376
|
+
if len(scan_regions) == 1:
|
|
377
|
+
# Generate and display scan ID for reference
|
|
378
|
+
from complio.reporters.generator import generate_scan_id
|
|
379
|
+
scan_id = generate_scan_id()
|
|
380
|
+
console.print(f"\n📋 Scan ID: [cyan]{scan_id}[/cyan]")
|
|
381
|
+
console.print(" [dim]Reference this ID when contacting support[/dim]\n")
|
|
382
|
+
|
|
383
|
+
# Save scan to history for later reference
|
|
384
|
+
try:
|
|
385
|
+
history_path = save_scan_to_history(scan_id, results, scan_region)
|
|
386
|
+
logger.info("scan_saved_to_history", scan_id=scan_id, path=str(history_path))
|
|
387
|
+
except Exception as e:
|
|
388
|
+
logger.warning("failed_to_save_history", scan_id=scan_id, error=str(e))
|
|
389
|
+
# Don't fail the scan if history saving fails
|
|
390
|
+
|
|
391
|
+
# Display results summary
|
|
392
|
+
_display_results_summary(console, results)
|
|
393
|
+
|
|
394
|
+
# Display detailed findings
|
|
395
|
+
_display_findings(console, results)
|
|
396
|
+
else:
|
|
397
|
+
# For multi-region, show brief summary per region
|
|
398
|
+
console.print(f"\n[bold]Results for {scan_region}:[/bold]")
|
|
399
|
+
console.print(f" Score: {results.overall_score}%")
|
|
400
|
+
console.print(f" Passed: {results.passed_tests}/{results.total_tests}")
|
|
401
|
+
console.print(f" Failed: {results.failed_tests}")
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
# Log error but continue with other regions
|
|
405
|
+
logger.error("region_scan_failed", region=scan_region, error=str(e))
|
|
406
|
+
output_helper.error(f"Scan failed for region {scan_region}: {str(e)}")
|
|
407
|
+
failed_regions.append(scan_region)
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
# ========================================================================
|
|
411
|
+
# MULTI-REGION RESULTS AGGREGATION
|
|
412
|
+
# ========================================================================
|
|
413
|
+
|
|
414
|
+
# If multiple regions, display aggregated results
|
|
415
|
+
if len(scan_regions) > 1:
|
|
416
|
+
console.print(f"\n[bold cyan]Multi-Region Scan Summary[/bold cyan]\n")
|
|
417
|
+
|
|
418
|
+
# Display summary table
|
|
419
|
+
from rich.table import Table
|
|
420
|
+
summary_table = Table(title="Regional Results", show_header=True)
|
|
421
|
+
summary_table.add_column("Region", style="cyan")
|
|
422
|
+
summary_table.add_column("Score", style="magenta", justify="right")
|
|
423
|
+
summary_table.add_column("Passed", style="green", justify="right")
|
|
424
|
+
summary_table.add_column("Failed", style="red", justify="right")
|
|
425
|
+
summary_table.add_column("Status", style="bold")
|
|
426
|
+
|
|
427
|
+
for regional_data in all_regional_results:
|
|
428
|
+
region = regional_data["region"]
|
|
429
|
+
results = regional_data["results"]
|
|
430
|
+
|
|
431
|
+
score_display = f"{results.overall_score}%"
|
|
432
|
+
if results.overall_score >= 90:
|
|
433
|
+
score_display = f"[green]{score_display}[/green]"
|
|
434
|
+
elif results.overall_score >= 70:
|
|
435
|
+
score_display = f"[yellow]{score_display}[/yellow]"
|
|
436
|
+
else:
|
|
437
|
+
score_display = f"[red]{score_display}[/red]"
|
|
438
|
+
|
|
439
|
+
status = "✅ COMPLIANT" if results.overall_score >= 90 else "❌ NON-COMPLIANT"
|
|
440
|
+
|
|
441
|
+
summary_table.add_row(
|
|
442
|
+
region,
|
|
443
|
+
score_display,
|
|
444
|
+
str(results.passed_tests),
|
|
445
|
+
str(results.failed_tests),
|
|
446
|
+
status,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
console.print(summary_table)
|
|
450
|
+
console.print()
|
|
451
|
+
|
|
452
|
+
if failed_regions:
|
|
453
|
+
console.print(f"[yellow]⚠️ Failed to scan {len(failed_regions)} region(s): {', '.join(failed_regions)}[/yellow]\n")
|
|
454
|
+
|
|
455
|
+
# Calculate aggregate statistics
|
|
456
|
+
total_regions_scanned = len(all_regional_results)
|
|
457
|
+
avg_score = sum(r["results"].overall_score for r in all_regional_results) / total_regions_scanned if total_regions_scanned > 0 else 0
|
|
458
|
+
worst_region = min(all_regional_results, key=lambda r: r["results"].overall_score) if total_regions_scanned > 0 else None
|
|
459
|
+
best_region = max(all_regional_results, key=lambda r: r["results"].overall_score) if total_regions_scanned > 0 else None
|
|
460
|
+
|
|
461
|
+
console.print(f"[bold]Aggregate Statistics:[/bold]")
|
|
462
|
+
console.print(f" Total Regions Scanned: {total_regions_scanned}")
|
|
463
|
+
console.print(f" Average Score: {avg_score:.1f}%")
|
|
464
|
+
if worst_region:
|
|
465
|
+
console.print(f" Worst Region: {worst_region['region']} ({worst_region['results'].overall_score}%)")
|
|
466
|
+
if best_region:
|
|
467
|
+
console.print(f" Best Region: {best_region['region']} ({best_region['results'].overall_score}%)")
|
|
468
|
+
console.print()
|
|
469
|
+
|
|
470
|
+
# Use the worst region's results for final status determination
|
|
471
|
+
if worst_region:
|
|
472
|
+
results = worst_region["results"]
|
|
473
|
+
else:
|
|
474
|
+
# Single region - results already displayed above
|
|
475
|
+
results = all_regional_results[0]["results"] if all_regional_results else None
|
|
476
|
+
|
|
477
|
+
if not results:
|
|
478
|
+
output_helper.error("All region scans failed")
|
|
479
|
+
raise click.Abort()
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
|
|
483
|
+
# Save or display report
|
|
484
|
+
if output:
|
|
485
|
+
# Auto-detect format from extension if not specified
|
|
486
|
+
if not output_format:
|
|
487
|
+
ext = output.suffix.lower()
|
|
488
|
+
if ext == ".json":
|
|
489
|
+
output_format = "json"
|
|
490
|
+
elif ext in [".md", ".markdown"]:
|
|
491
|
+
output_format = "markdown"
|
|
492
|
+
else:
|
|
493
|
+
output_format = "json" # default
|
|
494
|
+
|
|
495
|
+
generator = ReportGenerator()
|
|
496
|
+
generator.save_report(results, output, output_format)
|
|
497
|
+
output_helper.success(f"Report saved to: {output}")
|
|
498
|
+
else:
|
|
499
|
+
# Display markdown report to console
|
|
500
|
+
generator = ReportGenerator()
|
|
501
|
+
if output_format == "json":
|
|
502
|
+
console.print(generator.generate_json(results))
|
|
503
|
+
else:
|
|
504
|
+
console.print(generator.generate_markdown(results))
|
|
505
|
+
|
|
506
|
+
# Exit with appropriate code
|
|
507
|
+
if results.overall_score < 70:
|
|
508
|
+
output_helper.warning(
|
|
509
|
+
f"Compliance score ({results.overall_score}%) is below threshold"
|
|
510
|
+
)
|
|
511
|
+
raise click.exceptions.Exit(1)
|
|
512
|
+
elif results.overall_score < 90:
|
|
513
|
+
output_helper.warning(
|
|
514
|
+
f"Compliance score ({results.overall_score}%) needs improvement"
|
|
515
|
+
)
|
|
516
|
+
raise click.exceptions.Exit(0)
|
|
517
|
+
else:
|
|
518
|
+
output_helper.success(f"Compliance score: {results.overall_score}% - PASSED!")
|
|
519
|
+
raise click.exceptions.Exit(0)
|
|
520
|
+
|
|
521
|
+
except click.exceptions.Exit:
|
|
522
|
+
# Let Exit exceptions pass through to Click
|
|
523
|
+
raise
|
|
524
|
+
except click.exceptions.Abort:
|
|
525
|
+
# Let these exceptions pass through
|
|
526
|
+
raise
|
|
527
|
+
except Exception as e:
|
|
528
|
+
# Try to provide user-friendly error messages for AWS errors
|
|
529
|
+
from botocore.exceptions import BotoCoreError, ClientError
|
|
530
|
+
if isinstance(e, (BotoCoreError, ClientError)):
|
|
531
|
+
logger.error("scan_command_failed_aws_error", error=str(e))
|
|
532
|
+
handle_aws_error(e) # This will exit with user-friendly message
|
|
533
|
+
else:
|
|
534
|
+
# Other errors - show technical message with help
|
|
535
|
+
logger.error("scan_command_failed", error=str(e))
|
|
536
|
+
output_helper.error(f"Scan failed: {str(e)}")
|
|
537
|
+
click.echo("\nFor help: https://docs.complio.tech/troubleshooting", err=True)
|
|
538
|
+
click.echo("Support: support@complio.tech", err=True)
|
|
539
|
+
raise click.Abort()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _validate_aws_region(region: str) -> bool:
|
|
543
|
+
"""Validate that the provided region is a valid AWS region.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
region: AWS region name to validate
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
True if valid region, False otherwise
|
|
550
|
+
"""
|
|
551
|
+
# List of valid AWS regions (as of 2024)
|
|
552
|
+
# This is more reliable than trying to call AWS APIs which may fail due to credentials
|
|
553
|
+
VALID_REGIONS = {
|
|
554
|
+
# US regions
|
|
555
|
+
"us-east-1", "us-east-2", "us-west-1", "us-west-2",
|
|
556
|
+
# EU regions
|
|
557
|
+
"eu-west-1", "eu-west-2", "eu-west-3", "eu-central-1", "eu-central-2",
|
|
558
|
+
"eu-north-1", "eu-south-1", "eu-south-2",
|
|
559
|
+
# Asia Pacific regions
|
|
560
|
+
"ap-south-1", "ap-south-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3",
|
|
561
|
+
"ap-southeast-1", "ap-southeast-2", "ap-southeast-3", "ap-southeast-4",
|
|
562
|
+
"ap-east-1",
|
|
563
|
+
# Canada
|
|
564
|
+
"ca-central-1",
|
|
565
|
+
# South America
|
|
566
|
+
"sa-east-1",
|
|
567
|
+
# Middle East
|
|
568
|
+
"me-south-1", "me-central-1",
|
|
569
|
+
# Africa
|
|
570
|
+
"af-south-1",
|
|
571
|
+
# China (requires special account)
|
|
572
|
+
"cn-north-1", "cn-northwest-1",
|
|
573
|
+
# GovCloud
|
|
574
|
+
"us-gov-east-1", "us-gov-west-1",
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return region in VALID_REGIONS
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _list_available_tests(console: Console) -> None:
|
|
581
|
+
"""Display list of available tests."""
|
|
582
|
+
registry = TestRegistry()
|
|
583
|
+
|
|
584
|
+
table = Table(title="Available Compliance Tests", show_header=True)
|
|
585
|
+
table.add_column("Test ID", style="cyan")
|
|
586
|
+
table.add_column("Test Name", style="green")
|
|
587
|
+
table.add_column("ISO 27001 Control", style="yellow")
|
|
588
|
+
|
|
589
|
+
# Temporary connector for getting test names (won't be used)
|
|
590
|
+
from complio.connectors.aws.client import AWSConnector
|
|
591
|
+
temp_connector = AWSConnector("temp", "us-east-1")
|
|
592
|
+
|
|
593
|
+
test_info = {
|
|
594
|
+
"s3_encryption": "A.10.1.1",
|
|
595
|
+
"ec2_security_groups": "A.13.1.1",
|
|
596
|
+
"iam_password_policy": "A.9.4.3",
|
|
597
|
+
"cloudtrail_logging": "A.12.4.1",
|
|
598
|
+
# Phase 1: 8 New Tests
|
|
599
|
+
"ebs_encryption": "A.8.24",
|
|
600
|
+
"rds_encryption": "A.8.24",
|
|
601
|
+
"secrets_manager_encryption": "A.8.24",
|
|
602
|
+
"s3_public_access_block": "A.8.11",
|
|
603
|
+
"cloudtrail_log_validation": "A.8.15",
|
|
604
|
+
"cloudtrail_encryption": "A.8.15",
|
|
605
|
+
"vpc_flow_logs": "A.8.16",
|
|
606
|
+
"nacl_security": "A.8.20",
|
|
607
|
+
# Phase 2: 8 New Tests
|
|
608
|
+
"redshift_encryption": "A.8.24",
|
|
609
|
+
"efs_encryption": "A.8.24",
|
|
610
|
+
"dynamodb_encryption": "A.8.24",
|
|
611
|
+
"elasticache_encryption": "A.8.24",
|
|
612
|
+
"kms_key_rotation": "A.8.24",
|
|
613
|
+
"access_key_rotation": "A.8.5",
|
|
614
|
+
"mfa_enforcement": "A.8.5",
|
|
615
|
+
"root_account_protection": "A.8.2",
|
|
616
|
+
# Phase 3 Week 1: 6 Easy Tests
|
|
617
|
+
"s3_versioning": "A.8.13",
|
|
618
|
+
"backup_encryption": "A.8.24",
|
|
619
|
+
"cloudwatch_retention": "A.8.15",
|
|
620
|
+
"sns_encryption": "A.8.24",
|
|
621
|
+
"cloudwatch_logs_encryption": "A.8.24",
|
|
622
|
+
"vpn_security": "A.8.22",
|
|
623
|
+
# Phase 3 Week 2: 9 Medium Tests (complete)
|
|
624
|
+
"nacl_configuration": "A.8.20",
|
|
625
|
+
"alb_nlb_security": "A.8.22",
|
|
626
|
+
"cloudfront_https": "A.8.24",
|
|
627
|
+
"transit_gateway_security": "A.8.22",
|
|
628
|
+
"vpc_endpoints_security": "A.8.22",
|
|
629
|
+
"network_firewall": "A.8.20",
|
|
630
|
+
"direct_connect_security": "A.8.22",
|
|
631
|
+
"cloudwatch_alarms": "A.8.16",
|
|
632
|
+
"config_enabled": "A.8.16",
|
|
633
|
+
# Phase 3 Week 3: 5 Hard Tests (complete!)
|
|
634
|
+
"waf_configuration": "A.8.20",
|
|
635
|
+
"api_gateway_security": "A.8.22",
|
|
636
|
+
"guardduty_enabled": "A.8.16",
|
|
637
|
+
"security_hub_enabled": "A.8.16",
|
|
638
|
+
"eventbridge_rules": "A.8.16",
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for test_id in registry.get_test_ids():
|
|
642
|
+
test_class = registry.get_test(test_id)
|
|
643
|
+
test_instance = test_class(temp_connector)
|
|
644
|
+
control = test_info.get(test_id, "N/A")
|
|
645
|
+
|
|
646
|
+
table.add_row(test_id, test_instance.test_name, control)
|
|
647
|
+
|
|
648
|
+
console.print(table)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _display_results_summary(console: Console, results) -> None:
|
|
652
|
+
"""Display test results summary table."""
|
|
653
|
+
table = Table(title="Scan Results Summary", show_header=True)
|
|
654
|
+
table.add_column("Metric", style="cyan")
|
|
655
|
+
table.add_column("Value", style="green")
|
|
656
|
+
|
|
657
|
+
table.add_row("Total Tests", str(results.total_tests))
|
|
658
|
+
table.add_row("Passed", f"✅ {results.passed_tests}")
|
|
659
|
+
table.add_row("Failed", f"❌ {results.failed_tests}")
|
|
660
|
+
table.add_row("Errors", f"⚠️ {results.error_tests}")
|
|
661
|
+
table.add_row("Overall Score", f"{results.overall_score}%")
|
|
662
|
+
table.add_row("Execution Time", f"{results.execution_time:.2f}s")
|
|
663
|
+
|
|
664
|
+
console.print()
|
|
665
|
+
console.print(table)
|
|
666
|
+
console.print()
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _display_findings(console: Console, results) -> None:
|
|
670
|
+
"""Display findings by severity."""
|
|
671
|
+
# Count findings by severity (using strings since use_enum_values=True)
|
|
672
|
+
severity_counts = {
|
|
673
|
+
"critical": 0,
|
|
674
|
+
"high": 0,
|
|
675
|
+
"medium": 0,
|
|
676
|
+
"low": 0,
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for test_result in results.test_results:
|
|
680
|
+
for finding in test_result.findings:
|
|
681
|
+
if finding.severity in severity_counts:
|
|
682
|
+
severity_counts[finding.severity] += 1
|
|
683
|
+
|
|
684
|
+
# Display critical and high findings
|
|
685
|
+
critical_and_high = []
|
|
686
|
+
for test_result in results.test_results:
|
|
687
|
+
for finding in test_result.findings:
|
|
688
|
+
if finding.severity in ["critical", "high"]:
|
|
689
|
+
critical_and_high.append((test_result.test_name, finding))
|
|
690
|
+
|
|
691
|
+
if critical_and_high:
|
|
692
|
+
console.print("[bold red]🚨 Critical & High Severity Findings:[/bold red]")
|
|
693
|
+
console.print()
|
|
694
|
+
|
|
695
|
+
for test_name, finding in critical_and_high:
|
|
696
|
+
severity_color = "red" if finding.severity == "critical" else "orange1"
|
|
697
|
+
console.print(f"[{severity_color}]● {finding.severity}:[/{severity_color}] {finding.title}")
|
|
698
|
+
console.print(f" Test: {test_name}")
|
|
699
|
+
console.print(f" Resource: {finding.resource_id}")
|
|
700
|
+
console.print()
|