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.
Files changed (79) hide show
  1. CHANGELOG.md +208 -0
  2. README.md +343 -0
  3. complio/__init__.py +48 -0
  4. complio/cli/__init__.py +0 -0
  5. complio/cli/banner.py +87 -0
  6. complio/cli/commands/__init__.py +0 -0
  7. complio/cli/commands/history.py +439 -0
  8. complio/cli/commands/scan.py +700 -0
  9. complio/cli/main.py +115 -0
  10. complio/cli/output.py +338 -0
  11. complio/config/__init__.py +17 -0
  12. complio/config/settings.py +333 -0
  13. complio/connectors/__init__.py +9 -0
  14. complio/connectors/aws/__init__.py +0 -0
  15. complio/connectors/aws/client.py +342 -0
  16. complio/connectors/base.py +135 -0
  17. complio/core/__init__.py +10 -0
  18. complio/core/registry.py +228 -0
  19. complio/core/runner.py +351 -0
  20. complio/py.typed +0 -0
  21. complio/reporters/__init__.py +7 -0
  22. complio/reporters/generator.py +417 -0
  23. complio/tests_library/__init__.py +0 -0
  24. complio/tests_library/base.py +492 -0
  25. complio/tests_library/identity/__init__.py +0 -0
  26. complio/tests_library/identity/access_key_rotation.py +302 -0
  27. complio/tests_library/identity/mfa_enforcement.py +327 -0
  28. complio/tests_library/identity/root_account_protection.py +470 -0
  29. complio/tests_library/infrastructure/__init__.py +0 -0
  30. complio/tests_library/infrastructure/cloudtrail_encryption.py +286 -0
  31. complio/tests_library/infrastructure/cloudtrail_log_validation.py +274 -0
  32. complio/tests_library/infrastructure/cloudtrail_logging.py +400 -0
  33. complio/tests_library/infrastructure/ebs_encryption.py +244 -0
  34. complio/tests_library/infrastructure/ec2_security_groups.py +321 -0
  35. complio/tests_library/infrastructure/iam_password_policy.py +460 -0
  36. complio/tests_library/infrastructure/nacl_security.py +356 -0
  37. complio/tests_library/infrastructure/rds_encryption.py +252 -0
  38. complio/tests_library/infrastructure/s3_encryption.py +301 -0
  39. complio/tests_library/infrastructure/s3_public_access.py +369 -0
  40. complio/tests_library/infrastructure/secrets_manager_encryption.py +248 -0
  41. complio/tests_library/infrastructure/vpc_flow_logs.py +287 -0
  42. complio/tests_library/logging/__init__.py +0 -0
  43. complio/tests_library/logging/cloudwatch_alarms.py +354 -0
  44. complio/tests_library/logging/cloudwatch_logs_encryption.py +281 -0
  45. complio/tests_library/logging/cloudwatch_retention.py +252 -0
  46. complio/tests_library/logging/config_enabled.py +393 -0
  47. complio/tests_library/logging/eventbridge_rules.py +460 -0
  48. complio/tests_library/logging/guardduty_enabled.py +436 -0
  49. complio/tests_library/logging/security_hub_enabled.py +416 -0
  50. complio/tests_library/logging/sns_encryption.py +273 -0
  51. complio/tests_library/network/__init__.py +0 -0
  52. complio/tests_library/network/alb_nlb_security.py +421 -0
  53. complio/tests_library/network/api_gateway_security.py +452 -0
  54. complio/tests_library/network/cloudfront_https.py +332 -0
  55. complio/tests_library/network/direct_connect_security.py +343 -0
  56. complio/tests_library/network/nacl_configuration.py +367 -0
  57. complio/tests_library/network/network_firewall.py +355 -0
  58. complio/tests_library/network/transit_gateway_security.py +318 -0
  59. complio/tests_library/network/vpc_endpoints_security.py +339 -0
  60. complio/tests_library/network/vpn_security.py +333 -0
  61. complio/tests_library/network/waf_configuration.py +428 -0
  62. complio/tests_library/security/__init__.py +0 -0
  63. complio/tests_library/security/kms_key_rotation.py +314 -0
  64. complio/tests_library/storage/__init__.py +0 -0
  65. complio/tests_library/storage/backup_encryption.py +288 -0
  66. complio/tests_library/storage/dynamodb_encryption.py +280 -0
  67. complio/tests_library/storage/efs_encryption.py +257 -0
  68. complio/tests_library/storage/elasticache_encryption.py +370 -0
  69. complio/tests_library/storage/redshift_encryption.py +252 -0
  70. complio/tests_library/storage/s3_versioning.py +264 -0
  71. complio/utils/__init__.py +26 -0
  72. complio/utils/errors.py +179 -0
  73. complio/utils/exceptions.py +151 -0
  74. complio/utils/history.py +243 -0
  75. complio/utils/logger.py +391 -0
  76. complio-0.1.1.dist-info/METADATA +385 -0
  77. complio-0.1.1.dist-info/RECORD +79 -0
  78. complio-0.1.1.dist-info/WHEEL +4 -0
  79. 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()