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,417 @@
1
+ """
2
+ Report generation for compliance scan results.
3
+
4
+ This module provides formatters for generating compliance reports
5
+ in various formats (JSON, Markdown, HTML, PDF).
6
+
7
+ Example:
8
+ >>> from complio.reporters.generator import ReportGenerator
9
+ >>> generator = ReportGenerator()
10
+ >>> json_report = generator.generate_json(scan_results)
11
+ >>> markdown_report = generator.generate_markdown(scan_results)
12
+ """
13
+
14
+ import json
15
+ import random
16
+ import string
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Any, Dict, List
20
+
21
+ from complio.core.runner import ScanResults
22
+ from complio.tests_library.base import Severity, TestStatus
23
+
24
+
25
+ def generate_scan_id() -> str:
26
+ """Generate a unique scan identifier.
27
+
28
+ Returns:
29
+ Unique scan ID in format: scan_YYYYMMDD_HHMMSS_abc123
30
+
31
+ Example:
32
+ >>> scan_id = generate_scan_id()
33
+ >>> print(scan_id)
34
+ 'scan_20240115_162335_abc123'
35
+ """
36
+ timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
37
+ random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
38
+ return f"scan_{timestamp}_{random_suffix}"
39
+
40
+
41
+ def get_complio_version() -> str:
42
+ """Get Complio version.
43
+
44
+ Returns:
45
+ Version string
46
+
47
+ Example:
48
+ >>> version = get_complio_version()
49
+ >>> print(version)
50
+ '0.1.0'
51
+ """
52
+ try:
53
+ from complio.config.settings import get_settings
54
+ settings = get_settings()
55
+ return settings.VERSION
56
+ except Exception:
57
+ return "unknown"
58
+
59
+
60
+ def sanitize_resource_name(name: str) -> str:
61
+ """Sanitize resource names to handle special characters safely.
62
+
63
+ Ensures resource names with Unicode characters, emojis, or other
64
+ special characters are safely displayable in reports without causing
65
+ encoding errors.
66
+
67
+ Args:
68
+ name: Resource name to sanitize
69
+
70
+ Returns:
71
+ Sanitized resource name that can be safely displayed
72
+
73
+ Example:
74
+ >>> sanitize_resource_name("bucket-Êmojis-🎉")
75
+ 'bucket-Êmojis-🎉'
76
+ >>> sanitize_resource_name("test-\udcff-invalid")
77
+ 'test-īŋŊ-invalid'
78
+ """
79
+ if not isinstance(name, str):
80
+ return str(name)
81
+
82
+ try:
83
+ # Try to encode/decode to catch issues early
84
+ name.encode('utf-8').decode('utf-8')
85
+ return name
86
+ except (UnicodeEncodeError, UnicodeDecodeError):
87
+ # Replace problematic characters with replacement character
88
+ return name.encode('utf-8', errors='replace').decode('utf-8')
89
+
90
+
91
+ class ReportGenerator:
92
+ """Generate compliance reports in various formats.
93
+
94
+ This class provides methods to format ScanResults into
95
+ different output formats suitable for humans and machines.
96
+
97
+ Example:
98
+ >>> generator = ReportGenerator()
99
+ >>> json_str = generator.generate_json(results)
100
+ >>> markdown_str = generator.generate_markdown(results)
101
+ >>> generator.save_report(results, Path("report.md"), "markdown")
102
+ """
103
+
104
+ def generate_json(self, results: ScanResults) -> str:
105
+ """Generate JSON format report.
106
+
107
+ Args:
108
+ results: Scan results to format
109
+
110
+ Returns:
111
+ JSON formatted string
112
+
113
+ Example:
114
+ >>> json_report = generator.generate_json(scan_results)
115
+ >>> with open("report.json", "w") as f:
116
+ ... f.write(json_report)
117
+ """
118
+ # Categorize tests by scope
119
+ global_tests = []
120
+ regional_tests = []
121
+ for test_result in results.test_results:
122
+ scope = test_result.metadata.get("scope", "regional")
123
+ if scope == "global":
124
+ global_tests.append(test_result.test_name)
125
+ else:
126
+ regional_tests.append(test_result.test_name)
127
+
128
+ # Generate unique scan ID
129
+ scan_id = generate_scan_id()
130
+
131
+ report_data = {
132
+ "scan_metadata": {
133
+ "scan_id": scan_id,
134
+ "timestamp": datetime.fromtimestamp(results.timestamp).isoformat(),
135
+ "complio_version": get_complio_version(),
136
+ "region": results.region,
137
+ "execution_time_seconds": round(results.execution_time, 2),
138
+ "scope_note": "S3 and IAM are global services. Results apply to entire account. EC2 and CloudTrail are regional.",
139
+ },
140
+ "scan_scope": {
141
+ "global_services": global_tests,
142
+ "regional_services": regional_tests,
143
+ "scanned_region": results.region,
144
+ },
145
+ "summary": {
146
+ "total_tests": results.total_tests,
147
+ "passed_tests": results.passed_tests,
148
+ "failed_tests": results.failed_tests,
149
+ "error_tests": results.error_tests,
150
+ "overall_score": results.overall_score,
151
+ "compliance_status": "COMPLIANT" if results.overall_score >= 90 else "NON_COMPLIANT",
152
+ },
153
+ "test_results": [],
154
+ }
155
+
156
+ # Add each test result
157
+ for test_result in results.test_results:
158
+ scope = test_result.metadata.get("scope", "regional")
159
+ scope_description = "Scans all regions across the account" if scope == "global" else f"Only scans {results.region}"
160
+
161
+ test_data = {
162
+ "test_id": test_result.test_id,
163
+ "test_name": test_result.test_name,
164
+ "scope": scope,
165
+ "scope_description": scope_description,
166
+ "status": test_result.status, # Already a string due to use_enum_values=True
167
+ "passed": test_result.passed,
168
+ "score": test_result.score,
169
+ "findings_count": len(test_result.findings),
170
+ "evidence_count": len(test_result.evidence),
171
+ "findings": [],
172
+ "evidence": [],
173
+ "metadata": test_result.metadata,
174
+ }
175
+
176
+ # Add findings
177
+ for finding in test_result.findings:
178
+ test_data["findings"].append({
179
+ "resource_id": sanitize_resource_name(finding.resource_id),
180
+ "resource_type": sanitize_resource_name(finding.resource_type),
181
+ "severity": finding.severity, # Already a string due to use_enum_values=True
182
+ "title": finding.title,
183
+ "description": finding.description,
184
+ "remediation": finding.remediation,
185
+ # Note: iso27001_control is in test_result.metadata, not in individual findings
186
+ })
187
+
188
+ # Add evidence
189
+ for evidence in test_result.evidence:
190
+ test_data["evidence"].append({
191
+ "resource_id": sanitize_resource_name(evidence.resource_id),
192
+ "resource_type": sanitize_resource_name(evidence.resource_type),
193
+ "region": evidence.region,
194
+ "timestamp": evidence.timestamp.isoformat() if evidence.timestamp else None,
195
+ "data": evidence.data,
196
+ "signature": evidence.signature,
197
+ })
198
+
199
+ report_data["test_results"].append(test_data)
200
+
201
+ return json.dumps(report_data, indent=2)
202
+
203
+ def generate_markdown(self, results: ScanResults) -> str:
204
+ """Generate Markdown format report.
205
+
206
+ Args:
207
+ results: Scan results to format
208
+
209
+ Returns:
210
+ Markdown formatted string
211
+
212
+ Example:
213
+ >>> md_report = generator.generate_markdown(scan_results)
214
+ >>> with open("report.md", "w") as f:
215
+ ... f.write(md_report)
216
+ """
217
+ lines: List[str] = []
218
+
219
+ # Header
220
+ lines.append("# Compliance Scan Report")
221
+ lines.append("")
222
+ lines.append(f"**Generated:** {datetime.fromtimestamp(results.timestamp).strftime('%Y-%m-%d %H:%M:%S')}")
223
+ lines.append(f"**Region:** {results.region}")
224
+ lines.append(f"**Execution Time:** {results.execution_time:.2f} seconds")
225
+ lines.append("")
226
+
227
+ # Executive Summary
228
+ lines.append("## Executive Summary")
229
+ lines.append("")
230
+
231
+ compliance_status = "✅ **COMPLIANT**" if results.overall_score >= 90 else "❌ **NON-COMPLIANT**"
232
+ lines.append(f"**Overall Status:** {compliance_status}")
233
+ lines.append(f"**Overall Score:** {results.overall_score}%")
234
+ lines.append("")
235
+
236
+ lines.append("### Test Statistics")
237
+ lines.append("")
238
+ lines.append(f"- **Total Tests:** {results.total_tests}")
239
+ lines.append(f"- **Passed:** ✅ {results.passed_tests}")
240
+ lines.append(f"- **Failed:** ❌ {results.failed_tests}")
241
+ lines.append(f"- **Errors:** âš ī¸ {results.error_tests}")
242
+ lines.append("")
243
+
244
+ # Scan Scope Section
245
+ lines.append("## Scan Scope")
246
+ lines.append("")
247
+
248
+ # Categorize tests by scope
249
+ global_tests = []
250
+ regional_tests = []
251
+ for test_result in results.test_results:
252
+ scope = test_result.metadata.get("scope", "regional")
253
+ if scope == "global":
254
+ global_tests.append(test_result.test_name)
255
+ else:
256
+ regional_tests.append(test_result.test_name)
257
+
258
+ if global_tests:
259
+ lines.append("**Global Services (Account-wide):**")
260
+ lines.append("")
261
+ lines.append("These services are evaluated once and apply to your entire AWS account:")
262
+ lines.append("")
263
+ for test_name in global_tests:
264
+ lines.append(f"- ✅ {test_name}")
265
+ lines.append("")
266
+
267
+ if regional_tests:
268
+ lines.append(f"**Regional Services ({results.region} only):**")
269
+ lines.append("")
270
+ lines.append("These services are specific to the scanned region:")
271
+ lines.append("")
272
+ for test_name in regional_tests:
273
+ lines.append(f"- ✅ {test_name}")
274
+ lines.append("")
275
+
276
+ if regional_tests:
277
+ lines.append("💡 **Tip:** Run scans in each region where you have infrastructure to check regional services.")
278
+ lines.append("")
279
+
280
+ # Test Results
281
+ lines.append("## Test Results")
282
+ lines.append("")
283
+
284
+ for test_result in results.test_results:
285
+ # Test header
286
+ # Use string keys since test_result.status is a string (due to use_enum_values=True)
287
+ status_emoji = {
288
+ "passed": "✅",
289
+ "warning": "âš ī¸",
290
+ "failed": "❌",
291
+ "error": "đŸšĢ",
292
+ }.get(test_result.status, "❓")
293
+
294
+ lines.append(f"### {status_emoji} {test_result.test_name}")
295
+ lines.append("")
296
+ lines.append(f"**Test ID:** `{test_result.test_id}`")
297
+ lines.append(f"**Status:** {test_result.status}") # Already a string
298
+ lines.append(f"**Score:** {test_result.score}%")
299
+ lines.append(f"**ISO 27001 Control:** {test_result.metadata.get('iso27001_control', 'N/A')}")
300
+ lines.append("")
301
+
302
+ # Findings
303
+ if test_result.findings:
304
+ lines.append(f"**Findings:** {len(test_result.findings)}")
305
+ lines.append("")
306
+
307
+ for finding in test_result.findings:
308
+ # Use string keys since finding.severity is a string (due to use_enum_values=True)
309
+ severity_emoji = {
310
+ "critical": "🔴",
311
+ "high": "🟠",
312
+ "medium": "🟡",
313
+ "low": "đŸ”ĩ",
314
+ "info": "â„šī¸",
315
+ }.get(finding.severity, "❓")
316
+
317
+ lines.append(f"#### {severity_emoji} {finding.severity}: {finding.title}") # severity is already a string
318
+ lines.append("")
319
+ safe_resource_id = sanitize_resource_name(finding.resource_id)
320
+ safe_resource_type = sanitize_resource_name(finding.resource_type)
321
+ lines.append(f"**Resource:** `{safe_resource_id}` ({safe_resource_type})")
322
+ lines.append("")
323
+ lines.append(f"**Description:**")
324
+ lines.append(f"{finding.description}")
325
+ lines.append("")
326
+ lines.append(f"**Remediation:**")
327
+ lines.append(f"{finding.remediation}")
328
+ lines.append("")
329
+ else:
330
+ lines.append("**Findings:** None - All checks passed ✅")
331
+ lines.append("")
332
+
333
+ lines.append("---")
334
+ lines.append("")
335
+
336
+ # Summary Statistics
337
+ lines.append("## Summary Statistics")
338
+ lines.append("")
339
+
340
+ # Count findings by severity
341
+ # Use string keys since finding.severity is a string (due to use_enum_values=True)
342
+ severity_counts: Dict[str, int] = {
343
+ "critical": 0,
344
+ "high": 0,
345
+ "medium": 0,
346
+ "low": 0,
347
+ "info": 0,
348
+ }
349
+
350
+ for test_result in results.test_results:
351
+ for finding in test_result.findings:
352
+ severity_counts[finding.severity] += 1
353
+
354
+ lines.append("### Findings by Severity")
355
+ lines.append("")
356
+ lines.append(f"- 🔴 **CRITICAL:** {severity_counts['critical']}")
357
+ lines.append(f"- 🟠 **HIGH:** {severity_counts['high']}")
358
+ lines.append(f"- 🟡 **MEDIUM:** {severity_counts['medium']}")
359
+ lines.append(f"- đŸ”ĩ **LOW:** {severity_counts['low']}")
360
+ lines.append(f"- â„šī¸ **INFO:** {severity_counts['info']}")
361
+ lines.append("")
362
+
363
+ # Recommendations
364
+ if severity_counts["critical"] > 0 or severity_counts["high"] > 0:
365
+ lines.append("## 🚨 Immediate Actions Required")
366
+ lines.append("")
367
+ lines.append("This scan identified CRITICAL or HIGH severity findings that require immediate attention:")
368
+ lines.append("")
369
+
370
+ for test_result in results.test_results:
371
+ for finding in test_result.findings:
372
+ # finding.severity is a string, so compare with strings
373
+ if finding.severity in ["critical", "high"]:
374
+ lines.append(f"- **{finding.title}** ({finding.severity})") # severity is already a string
375
+
376
+ lines.append("")
377
+
378
+ # Footer
379
+ lines.append("---")
380
+ lines.append("")
381
+ lines.append("*Report generated by Complio - Compliance-as-Code Platform*")
382
+ lines.append("")
383
+
384
+ return "\n".join(lines)
385
+
386
+ def save_report(
387
+ self,
388
+ results: ScanResults,
389
+ output_path: Path,
390
+ format: str = "json",
391
+ ) -> None:
392
+ """Save report to file.
393
+
394
+ Args:
395
+ results: Scan results to save
396
+ output_path: Path to output file
397
+ format: Report format ("json" or "markdown")
398
+
399
+ Raises:
400
+ ValueError: If format is not supported
401
+
402
+ Example:
403
+ >>> generator.save_report(results, Path("report.json"), "json")
404
+ >>> generator.save_report(results, Path("report.md"), "markdown")
405
+ """
406
+ if format == "json":
407
+ content = self.generate_json(results)
408
+ elif format == "markdown":
409
+ content = self.generate_markdown(results)
410
+ else:
411
+ raise ValueError(f"Unsupported format: {format}. Use 'json' or 'markdown'")
412
+
413
+ # Ensure parent directory exists
414
+ output_path.parent.mkdir(parents=True, exist_ok=True)
415
+
416
+ # Write report
417
+ output_path.write_text(content, encoding="utf-8")
File without changes