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,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
|