iam-policy-validator 1.7.0__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.
Potentially problematic release.
This version of iam-policy-validator might be problematic. Click here for more details.
- iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
- iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
- iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +7 -0
- iam_validator/checks/__init__.py +43 -0
- iam_validator/checks/action_condition_enforcement.py +884 -0
- iam_validator/checks/action_resource_matching.py +441 -0
- iam_validator/checks/action_validation.py +72 -0
- iam_validator/checks/condition_key_validation.py +92 -0
- iam_validator/checks/condition_type_mismatch.py +259 -0
- iam_validator/checks/full_wildcard.py +71 -0
- iam_validator/checks/mfa_condition_check.py +112 -0
- iam_validator/checks/policy_size.py +147 -0
- iam_validator/checks/policy_type_validation.py +305 -0
- iam_validator/checks/principal_validation.py +776 -0
- iam_validator/checks/resource_validation.py +138 -0
- iam_validator/checks/sensitive_action.py +254 -0
- iam_validator/checks/service_wildcard.py +107 -0
- iam_validator/checks/set_operator_validation.py +157 -0
- iam_validator/checks/sid_uniqueness.py +170 -0
- iam_validator/checks/utils/__init__.py +1 -0
- iam_validator/checks/utils/policy_level_checks.py +143 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
- iam_validator/checks/utils/wildcard_expansion.py +87 -0
- iam_validator/checks/wildcard_action.py +67 -0
- iam_validator/checks/wildcard_resource.py +135 -0
- iam_validator/commands/__init__.py +25 -0
- iam_validator/commands/analyze.py +531 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +392 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/validate.py +600 -0
- iam_validator/core/__init__.py +14 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +940 -0
- iam_validator/core/check_registry.py +607 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +104 -0
- iam_validator/core/config/condition_requirements.py +155 -0
- iam_validator/core/config/config_loader.py +472 -0
- iam_validator/core/config/defaults.py +523 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +95 -0
- iam_validator/core/config/wildcards.py +124 -0
- iam_validator/core/constants.py +74 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +59 -0
- iam_validator/core/formatters/csv.py +170 -0
- iam_validator/core/formatters/enhanced.py +440 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +63 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/models.py +327 -0
- iam_validator/core/policy_checks.py +656 -0
- iam_validator/core/policy_loader.py +396 -0
- iam_validator/core/pr_commenter.py +424 -0
- iam_validator/core/report.py +872 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +815 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +187 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +425 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +31 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +206 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
"""Report Generation Module.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to generate validation reports in various formats
|
|
4
|
+
including console output, JSON, and GitHub-flavored markdown for PR comments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from iam_validator.__version__ import __version__
|
|
15
|
+
from iam_validator.core.formatters import (
|
|
16
|
+
ConsoleFormatter,
|
|
17
|
+
CSVFormatter,
|
|
18
|
+
EnhancedFormatter,
|
|
19
|
+
HTMLFormatter,
|
|
20
|
+
JSONFormatter,
|
|
21
|
+
MarkdownFormatter,
|
|
22
|
+
SARIFFormatter,
|
|
23
|
+
get_global_registry,
|
|
24
|
+
)
|
|
25
|
+
from iam_validator.core.models import (
|
|
26
|
+
PolicyValidationResult,
|
|
27
|
+
ValidationIssue,
|
|
28
|
+
ValidationReport,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReportGenerator:
|
|
35
|
+
"""Generates validation reports in various formats."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
"""Initialize the report generator."""
|
|
39
|
+
self.console = Console()
|
|
40
|
+
self.formatter_registry = get_global_registry()
|
|
41
|
+
self._register_default_formatters()
|
|
42
|
+
|
|
43
|
+
def _register_default_formatters(self) -> None:
|
|
44
|
+
"""Register default formatters if not already registered."""
|
|
45
|
+
# Register all built-in formatters
|
|
46
|
+
if not self.formatter_registry.get_formatter("console"):
|
|
47
|
+
self.formatter_registry.register(ConsoleFormatter())
|
|
48
|
+
if not self.formatter_registry.get_formatter("enhanced"):
|
|
49
|
+
self.formatter_registry.register(EnhancedFormatter())
|
|
50
|
+
if not self.formatter_registry.get_formatter("json"):
|
|
51
|
+
self.formatter_registry.register(JSONFormatter())
|
|
52
|
+
if not self.formatter_registry.get_formatter("markdown"):
|
|
53
|
+
self.formatter_registry.register(MarkdownFormatter())
|
|
54
|
+
if not self.formatter_registry.get_formatter("sarif"):
|
|
55
|
+
self.formatter_registry.register(SARIFFormatter())
|
|
56
|
+
if not self.formatter_registry.get_formatter("csv"):
|
|
57
|
+
self.formatter_registry.register(CSVFormatter())
|
|
58
|
+
if not self.formatter_registry.get_formatter("html"):
|
|
59
|
+
self.formatter_registry.register(HTMLFormatter())
|
|
60
|
+
|
|
61
|
+
def format_report(self, report: ValidationReport, format_id: str, **kwargs) -> str:
|
|
62
|
+
"""Format a report using the specified formatter.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
report: Validation report to format
|
|
66
|
+
format_id: ID of the formatter to use
|
|
67
|
+
**kwargs: Additional formatter-specific options
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Formatted string representation
|
|
71
|
+
"""
|
|
72
|
+
return self.formatter_registry.format_report(report, format_id, **kwargs)
|
|
73
|
+
|
|
74
|
+
def generate_report(self, results: list[PolicyValidationResult]) -> ValidationReport:
|
|
75
|
+
"""Generate a validation report from results.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
results: List of policy validation results
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
ValidationReport
|
|
82
|
+
"""
|
|
83
|
+
valid_count = sum(1 for r in results if r.is_valid)
|
|
84
|
+
invalid_count = len(results) - valid_count
|
|
85
|
+
total_issues = sum(len(r.issues) for r in results)
|
|
86
|
+
|
|
87
|
+
# Count policies with security issues (separate from validity issues)
|
|
88
|
+
policies_with_security_issues = sum(
|
|
89
|
+
1 for r in results if any(issue.is_security_severity() for issue in r.issues)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Count validity vs security issues
|
|
93
|
+
validity_issues = sum(
|
|
94
|
+
sum(1 for issue in r.issues if issue.is_validity_severity()) for r in results
|
|
95
|
+
)
|
|
96
|
+
security_issues = sum(
|
|
97
|
+
sum(1 for issue in r.issues if issue.is_security_severity()) for r in results
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return ValidationReport(
|
|
101
|
+
total_policies=len(results),
|
|
102
|
+
valid_policies=valid_count,
|
|
103
|
+
invalid_policies=invalid_count,
|
|
104
|
+
policies_with_security_issues=policies_with_security_issues,
|
|
105
|
+
total_issues=total_issues,
|
|
106
|
+
validity_issues=validity_issues,
|
|
107
|
+
security_issues=security_issues,
|
|
108
|
+
results=results,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def print_console_report(self, report: ValidationReport) -> None:
|
|
112
|
+
"""Print a formatted console report using Rich.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
report: Validation report to display
|
|
116
|
+
"""
|
|
117
|
+
# Summary panel
|
|
118
|
+
summary_text = Text()
|
|
119
|
+
summary_text.append(f"Total Policies: {report.total_policies}\n")
|
|
120
|
+
summary_text.append(f"Valid: {report.valid_policies} ", style="green")
|
|
121
|
+
|
|
122
|
+
# Show invalid policies (IAM validity issues)
|
|
123
|
+
if report.invalid_policies > 0:
|
|
124
|
+
summary_text.append(f"Invalid: {report.invalid_policies} ", style="red")
|
|
125
|
+
|
|
126
|
+
# Show policies with security findings (separate from validity)
|
|
127
|
+
if report.policies_with_security_issues > 0:
|
|
128
|
+
summary_text.append(
|
|
129
|
+
f"Security Findings: {report.policies_with_security_issues} ",
|
|
130
|
+
style="yellow",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
summary_text.append("\n")
|
|
134
|
+
|
|
135
|
+
# Breakdown of issue types
|
|
136
|
+
summary_text.append(f"Total Issues: {report.total_issues}")
|
|
137
|
+
if report.validity_issues > 0 or report.security_issues > 0:
|
|
138
|
+
summary_text.append(" (")
|
|
139
|
+
if report.validity_issues > 0:
|
|
140
|
+
summary_text.append(f"{report.validity_issues} validity", style="red")
|
|
141
|
+
if report.validity_issues > 0 and report.security_issues > 0:
|
|
142
|
+
summary_text.append(", ")
|
|
143
|
+
if report.security_issues > 0:
|
|
144
|
+
summary_text.append(f"{report.security_issues} security", style="yellow")
|
|
145
|
+
summary_text.append(")")
|
|
146
|
+
summary_text.append("\n")
|
|
147
|
+
|
|
148
|
+
self.console.print(
|
|
149
|
+
Panel(
|
|
150
|
+
summary_text,
|
|
151
|
+
title=f"Validation Summary (iam-validator v{__version__})",
|
|
152
|
+
border_style="blue",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Detailed results
|
|
157
|
+
for result in report.results:
|
|
158
|
+
self._print_policy_result(result)
|
|
159
|
+
|
|
160
|
+
# Final status
|
|
161
|
+
if report.invalid_policies == 0:
|
|
162
|
+
self.console.print(
|
|
163
|
+
f"\n[green]โ All {report.valid_policies} policies are valid![/green]"
|
|
164
|
+
f"\n[yellow]โ Issues found: {report.total_issues}[/yellow]"
|
|
165
|
+
if report.total_issues > 0
|
|
166
|
+
else ""
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
self.console.print(f"\n[red]โ {report.invalid_policies} policies have issues[/red]")
|
|
170
|
+
|
|
171
|
+
def _print_policy_result(self, result: PolicyValidationResult) -> None:
|
|
172
|
+
"""Print results for a single policy."""
|
|
173
|
+
status = "[green]โ[/green]" if result.is_valid else "[red]โ[/red]"
|
|
174
|
+
self.console.print(f"\n{status} {result.policy_file}")
|
|
175
|
+
|
|
176
|
+
if not result.issues:
|
|
177
|
+
self.console.print(" [dim]No issues found[/dim]")
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# Create issues table with adjusted column widths for better readability
|
|
181
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
|
|
182
|
+
table.add_column("Severity", style="cyan", width=12, no_wrap=False)
|
|
183
|
+
table.add_column("Type", style="magenta", width=25, no_wrap=False)
|
|
184
|
+
table.add_column("Message", style="white", no_wrap=False)
|
|
185
|
+
|
|
186
|
+
for issue in result.issues:
|
|
187
|
+
severity_style = {
|
|
188
|
+
# IAM validity severities
|
|
189
|
+
"error": "[red]ERROR[/red]",
|
|
190
|
+
"warning": "[yellow]WARNING[/yellow]",
|
|
191
|
+
"info": "[blue]INFO[/blue]",
|
|
192
|
+
# Security severities
|
|
193
|
+
"critical": "[bold red]CRITICAL[/bold red]",
|
|
194
|
+
"high": "[red]HIGH[/red]",
|
|
195
|
+
"medium": "[yellow]MEDIUM[/yellow]",
|
|
196
|
+
"low": "[cyan]LOW[/cyan]",
|
|
197
|
+
}.get(issue.severity, issue.severity.upper())
|
|
198
|
+
|
|
199
|
+
# Use 1-indexed statement numbers for user-facing output
|
|
200
|
+
statement_num = issue.statement_index + 1
|
|
201
|
+
location = f"Statement {statement_num}"
|
|
202
|
+
if issue.statement_sid:
|
|
203
|
+
location += f" ({issue.statement_sid})"
|
|
204
|
+
if issue.line_number is not None:
|
|
205
|
+
location += f" @L{issue.line_number}"
|
|
206
|
+
|
|
207
|
+
message = f"{location}: {issue.message}"
|
|
208
|
+
if issue.suggestion:
|
|
209
|
+
message += f"\n โ {issue.suggestion}"
|
|
210
|
+
|
|
211
|
+
table.add_row(severity_style, issue.issue_type, message)
|
|
212
|
+
|
|
213
|
+
self.console.print(table)
|
|
214
|
+
|
|
215
|
+
def generate_json_report(self, report: ValidationReport) -> str:
|
|
216
|
+
"""Generate a JSON report.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
report: Validation report
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
JSON string
|
|
223
|
+
"""
|
|
224
|
+
return report.model_dump_json(indent=2)
|
|
225
|
+
|
|
226
|
+
def generate_github_comment_parts(
|
|
227
|
+
self, report: ValidationReport, max_length_per_part: int = 60000
|
|
228
|
+
) -> list[str]:
|
|
229
|
+
"""Generate GitHub PR comment(s), splitting into multiple parts if needed.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
report: Validation report
|
|
233
|
+
max_length_per_part: Maximum character length per comment part (default 60000)
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
List of comment parts (each under max_length_per_part)
|
|
237
|
+
"""
|
|
238
|
+
# Estimate the size needed - if it's likely to fit, generate single comment
|
|
239
|
+
# Otherwise, go straight to multi-part generation
|
|
240
|
+
estimated_size = self._estimate_report_size(report)
|
|
241
|
+
|
|
242
|
+
if estimated_size <= max_length_per_part:
|
|
243
|
+
# Try single comment
|
|
244
|
+
single_comment = self.generate_github_comment(
|
|
245
|
+
report, max_length=max_length_per_part * 2
|
|
246
|
+
)
|
|
247
|
+
if len(single_comment) <= max_length_per_part:
|
|
248
|
+
return [single_comment]
|
|
249
|
+
|
|
250
|
+
# Need to split into multiple parts
|
|
251
|
+
return self._generate_split_comments(report, max_length_per_part)
|
|
252
|
+
|
|
253
|
+
def _estimate_report_size(self, report: ValidationReport) -> int:
|
|
254
|
+
"""Estimate the size of the report in characters.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
report: Validation report
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Estimated character count
|
|
261
|
+
"""
|
|
262
|
+
# Rough estimate: ~500 chars per issue + overhead
|
|
263
|
+
base_overhead = 2000 # Header + footer
|
|
264
|
+
chars_per_issue = 500
|
|
265
|
+
return base_overhead + (report.total_issues * chars_per_issue)
|
|
266
|
+
|
|
267
|
+
def _generate_split_comments(self, report: ValidationReport, max_length: int) -> list[str]:
|
|
268
|
+
"""Split a large report into multiple comment parts.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
report: Validation report
|
|
272
|
+
max_length: Maximum length per part
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of comment parts
|
|
276
|
+
"""
|
|
277
|
+
parts: list[str] = []
|
|
278
|
+
|
|
279
|
+
# Generate header (will be in first part only)
|
|
280
|
+
header_lines = self._generate_header(report)
|
|
281
|
+
header_content = "\n".join(header_lines)
|
|
282
|
+
|
|
283
|
+
# Generate footer (will be in all parts)
|
|
284
|
+
footer_content = self._generate_footer()
|
|
285
|
+
|
|
286
|
+
# Calculate space available for policy details in each part
|
|
287
|
+
# Reserve space for:
|
|
288
|
+
# - "Continued from previous comment" / "Continued in next comment" messages
|
|
289
|
+
# - Part indicator: "**(Part N/M)**\n\n" (estimated ~20 chars)
|
|
290
|
+
# - HTML comment identifier: "<!-- iam-policy-validator -->\n" (~35 chars)
|
|
291
|
+
# - Safety buffer for formatting
|
|
292
|
+
continuation_overhead = 200
|
|
293
|
+
|
|
294
|
+
# Sort results to prioritize errors - support both IAM validity and security severities
|
|
295
|
+
sorted_results = sorted(
|
|
296
|
+
[(idx, r) for idx, r in enumerate(report.results, 1) if r.issues],
|
|
297
|
+
key=lambda x: (
|
|
298
|
+
-sum(1 for i in x[1].issues if i.severity in ("error", "critical", "high")),
|
|
299
|
+
-len(x[1].issues),
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
current_part_lines: list[str] = []
|
|
304
|
+
current_length = 0
|
|
305
|
+
is_first_part = True
|
|
306
|
+
|
|
307
|
+
for idx, result in sorted_results:
|
|
308
|
+
if not result.issues:
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
# Generate this policy's content
|
|
312
|
+
policy_content = self._format_policy_for_comment(idx, result)
|
|
313
|
+
policy_length = len(policy_content)
|
|
314
|
+
|
|
315
|
+
# Add policy to current part if needed (initialize)
|
|
316
|
+
if is_first_part and not current_part_lines:
|
|
317
|
+
current_part_lines.append(header_content)
|
|
318
|
+
current_part_lines.append("")
|
|
319
|
+
current_part_lines.append("## ๐ Detailed Findings")
|
|
320
|
+
current_part_lines.append("")
|
|
321
|
+
current_length = len("\n".join(current_part_lines))
|
|
322
|
+
elif not current_part_lines:
|
|
323
|
+
# Continuation part
|
|
324
|
+
current_part_lines.append("> โฌ๏ธ **Continued from previous comment**")
|
|
325
|
+
current_part_lines.append("")
|
|
326
|
+
current_part_lines.append("## ๐ Detailed Findings (continued)")
|
|
327
|
+
current_part_lines.append("")
|
|
328
|
+
current_length = len("\n".join(current_part_lines))
|
|
329
|
+
|
|
330
|
+
# Check if adding this policy would exceed the limit
|
|
331
|
+
test_length = (
|
|
332
|
+
current_length + policy_length + len(footer_content) + continuation_overhead
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if test_length > max_length and len(current_part_lines) > 4: # 4 = header lines
|
|
336
|
+
# Finalize current part without this policy
|
|
337
|
+
part_content = self._finalize_part(
|
|
338
|
+
current_part_lines,
|
|
339
|
+
None, # Header already added
|
|
340
|
+
footer_content,
|
|
341
|
+
continued_in_next=True,
|
|
342
|
+
)
|
|
343
|
+
parts.append(part_content)
|
|
344
|
+
|
|
345
|
+
# Start new part
|
|
346
|
+
current_part_lines = []
|
|
347
|
+
current_length = 0
|
|
348
|
+
is_first_part = False
|
|
349
|
+
|
|
350
|
+
# Add continuation header
|
|
351
|
+
current_part_lines.append("> โฌ๏ธ **Continued from previous comment**")
|
|
352
|
+
current_part_lines.append("")
|
|
353
|
+
current_part_lines.append("## ๐ Detailed Findings (continued)")
|
|
354
|
+
current_part_lines.append("")
|
|
355
|
+
current_length = len("\n".join(current_part_lines))
|
|
356
|
+
|
|
357
|
+
# Add policy to current part
|
|
358
|
+
current_part_lines.append(policy_content)
|
|
359
|
+
current_length += policy_length
|
|
360
|
+
|
|
361
|
+
# Finalize last part
|
|
362
|
+
if current_part_lines:
|
|
363
|
+
part_content = self._finalize_part(
|
|
364
|
+
current_part_lines,
|
|
365
|
+
header_content if is_first_part else None,
|
|
366
|
+
footer_content,
|
|
367
|
+
continued_in_next=False,
|
|
368
|
+
)
|
|
369
|
+
parts.append(part_content)
|
|
370
|
+
|
|
371
|
+
return parts
|
|
372
|
+
|
|
373
|
+
def _generate_header(self, report: ValidationReport) -> list[str]:
|
|
374
|
+
"""Generate the comment header with summary."""
|
|
375
|
+
lines = []
|
|
376
|
+
|
|
377
|
+
# Title with emoji and status badge
|
|
378
|
+
if report.invalid_policies == 0:
|
|
379
|
+
lines.append("# ๐ IAM Policy Validation Passed!")
|
|
380
|
+
status_badge = (
|
|
381
|
+
""
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
lines.append("# ๐จ IAM Policy Validation Failed")
|
|
385
|
+
status_badge = (
|
|
386
|
+
""
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
lines.append("")
|
|
390
|
+
lines.append(status_badge)
|
|
391
|
+
lines.append("")
|
|
392
|
+
|
|
393
|
+
# Summary section
|
|
394
|
+
lines.append("## ๐ Summary")
|
|
395
|
+
lines.append("")
|
|
396
|
+
lines.append("| Metric | Count | Status |")
|
|
397
|
+
lines.append("|--------|------:|:------:|")
|
|
398
|
+
lines.append(f"| **Total Policies Analyzed** | {report.total_policies} | ๐ |")
|
|
399
|
+
lines.append(f"| **Valid Policies** | {report.valid_policies} | โ
|")
|
|
400
|
+
lines.append(f"| **Invalid Policies** | {report.invalid_policies} | โ |")
|
|
401
|
+
lines.append(
|
|
402
|
+
f"| **Total Issues Found** | {report.total_issues} | {'โ ๏ธ' if report.total_issues > 0 else 'โจ'} |"
|
|
403
|
+
)
|
|
404
|
+
lines.append("")
|
|
405
|
+
|
|
406
|
+
# Issue breakdown
|
|
407
|
+
if report.total_issues > 0:
|
|
408
|
+
# Count issues - support both IAM validity and security severities
|
|
409
|
+
errors = sum(
|
|
410
|
+
1
|
|
411
|
+
for r in report.results
|
|
412
|
+
for i in r.issues
|
|
413
|
+
if i.severity in ("error", "critical", "high")
|
|
414
|
+
)
|
|
415
|
+
warnings = sum(
|
|
416
|
+
1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
|
|
417
|
+
)
|
|
418
|
+
infos = sum(
|
|
419
|
+
1 for r in report.results for i in r.issues if i.severity in ("info", "low")
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
lines.append("### ๐ Issue Breakdown")
|
|
423
|
+
lines.append("")
|
|
424
|
+
lines.append("| Severity | Count |")
|
|
425
|
+
lines.append("|----------|------:|")
|
|
426
|
+
if errors > 0:
|
|
427
|
+
lines.append(f"| ๐ด **Errors** | {errors} |")
|
|
428
|
+
if warnings > 0:
|
|
429
|
+
lines.append(f"| ๐ก **Warnings** | {warnings} |")
|
|
430
|
+
if infos > 0:
|
|
431
|
+
lines.append(f"| ๐ต **Info** | {infos} |")
|
|
432
|
+
lines.append("")
|
|
433
|
+
|
|
434
|
+
return lines
|
|
435
|
+
|
|
436
|
+
def _generate_footer(self) -> str:
|
|
437
|
+
"""Generate the comment footer."""
|
|
438
|
+
return "\n".join(
|
|
439
|
+
[
|
|
440
|
+
"---",
|
|
441
|
+
"",
|
|
442
|
+
"<div align='center'>",
|
|
443
|
+
"๐ค <em>Generated by <strong>IAM Policy Validator</strong></em><br>",
|
|
444
|
+
"</div>",
|
|
445
|
+
]
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _format_policy_for_comment(self, idx: int, result: PolicyValidationResult) -> str:
|
|
449
|
+
"""Format a single policy's issues for the comment."""
|
|
450
|
+
lines = []
|
|
451
|
+
|
|
452
|
+
lines.append("<details>")
|
|
453
|
+
lines.append(
|
|
454
|
+
f"<summary>๐ <b>{idx}. <code>{result.policy_file}</code></b> - {len(result.issues)} issue(s) found</summary>"
|
|
455
|
+
)
|
|
456
|
+
lines.append("")
|
|
457
|
+
|
|
458
|
+
# Group issues by severity - support both IAM validity and security severities
|
|
459
|
+
errors = [i for i in result.issues if i.severity in ("error", "critical", "high")]
|
|
460
|
+
warnings = [i for i in result.issues if i.severity in ("warning", "medium")]
|
|
461
|
+
infos = [i for i in result.issues if i.severity in ("info", "low")]
|
|
462
|
+
|
|
463
|
+
if errors:
|
|
464
|
+
lines.append("### ๐ด Errors")
|
|
465
|
+
lines.append("")
|
|
466
|
+
for issue in errors:
|
|
467
|
+
lines.append(self._format_issue_markdown(issue, result.policy_file))
|
|
468
|
+
lines.append("")
|
|
469
|
+
|
|
470
|
+
if warnings:
|
|
471
|
+
lines.append("### ๐ก Warnings")
|
|
472
|
+
lines.append("")
|
|
473
|
+
for issue in warnings:
|
|
474
|
+
lines.append(self._format_issue_markdown(issue, result.policy_file))
|
|
475
|
+
lines.append("")
|
|
476
|
+
|
|
477
|
+
if infos:
|
|
478
|
+
lines.append("### ๐ต Info")
|
|
479
|
+
lines.append("")
|
|
480
|
+
for issue in infos:
|
|
481
|
+
lines.append(self._format_issue_markdown(issue, result.policy_file))
|
|
482
|
+
lines.append("")
|
|
483
|
+
|
|
484
|
+
lines.append("</details>")
|
|
485
|
+
lines.append("")
|
|
486
|
+
|
|
487
|
+
return "\n".join(lines)
|
|
488
|
+
|
|
489
|
+
def _finalize_part(
|
|
490
|
+
self,
|
|
491
|
+
lines: list[str],
|
|
492
|
+
header: str | None,
|
|
493
|
+
footer: str,
|
|
494
|
+
continued_in_next: bool,
|
|
495
|
+
) -> str:
|
|
496
|
+
"""Finalize a comment part with header, footer, and continuation messages."""
|
|
497
|
+
parts = []
|
|
498
|
+
|
|
499
|
+
if header:
|
|
500
|
+
parts.append(header)
|
|
501
|
+
|
|
502
|
+
parts.extend(lines)
|
|
503
|
+
|
|
504
|
+
if continued_in_next:
|
|
505
|
+
parts.append("")
|
|
506
|
+
parts.append("> โฌ๏ธ **Continued in next comment...**")
|
|
507
|
+
parts.append("")
|
|
508
|
+
|
|
509
|
+
parts.append(footer)
|
|
510
|
+
|
|
511
|
+
return "\n".join(parts)
|
|
512
|
+
|
|
513
|
+
def generate_github_comment(self, report: ValidationReport, max_length: int = 65000) -> str:
|
|
514
|
+
"""Generate a GitHub-flavored markdown comment for PR reviews.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
report: Validation report
|
|
518
|
+
max_length: Maximum character length (GitHub limit is 65536, we use 65000 for safety)
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Markdown formatted string
|
|
522
|
+
"""
|
|
523
|
+
lines = []
|
|
524
|
+
|
|
525
|
+
# Header with emoji and status badge
|
|
526
|
+
if report.invalid_policies == 0:
|
|
527
|
+
lines.append("# ๐ IAM Policy Validation Passed!")
|
|
528
|
+
status_badge = (
|
|
529
|
+
""
|
|
530
|
+
)
|
|
531
|
+
else:
|
|
532
|
+
lines.append("# ๐จ IAM Policy Validation Failed")
|
|
533
|
+
status_badge = (
|
|
534
|
+
""
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
lines.append("")
|
|
538
|
+
lines.append(status_badge)
|
|
539
|
+
lines.append("")
|
|
540
|
+
|
|
541
|
+
# Summary section with enhanced table
|
|
542
|
+
lines.append("## ๐ Summary")
|
|
543
|
+
lines.append("")
|
|
544
|
+
lines.append("| Metric | Count | Status |")
|
|
545
|
+
lines.append("|--------|------:|:------:|")
|
|
546
|
+
lines.append(f"| **Total Policies Analyzed** | {report.total_policies} | ๐ |")
|
|
547
|
+
lines.append(f"| **Valid Policies** | {report.valid_policies} | โ
|")
|
|
548
|
+
lines.append(f"| **Invalid Policies** | {report.invalid_policies} | โ |")
|
|
549
|
+
lines.append(
|
|
550
|
+
f"| **Total Issues Found** | {report.total_issues} | {'โ ๏ธ' if report.total_issues > 0 else 'โจ'} |"
|
|
551
|
+
)
|
|
552
|
+
lines.append("")
|
|
553
|
+
|
|
554
|
+
# Issue breakdown
|
|
555
|
+
if report.total_issues > 0:
|
|
556
|
+
# Count issues - support both IAM validity and security severities
|
|
557
|
+
errors = sum(
|
|
558
|
+
1
|
|
559
|
+
for r in report.results
|
|
560
|
+
for i in r.issues
|
|
561
|
+
if i.severity in ("error", "critical", "high")
|
|
562
|
+
)
|
|
563
|
+
warnings = sum(
|
|
564
|
+
1 for r in report.results for i in r.issues if i.severity in ("warning", "medium")
|
|
565
|
+
)
|
|
566
|
+
infos = sum(
|
|
567
|
+
1 for r in report.results for i in r.issues if i.severity in ("info", "low")
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
lines.append("### ๐ Issue Breakdown")
|
|
571
|
+
lines.append("")
|
|
572
|
+
lines.append("| Severity | Count |")
|
|
573
|
+
lines.append("|----------|------:|")
|
|
574
|
+
if errors > 0:
|
|
575
|
+
lines.append(f"| ๐ด **Errors** | {errors} |")
|
|
576
|
+
if warnings > 0:
|
|
577
|
+
lines.append(f"| ๐ก **Warnings** | {warnings} |")
|
|
578
|
+
if infos > 0:
|
|
579
|
+
lines.append(f"| ๐ต **Info** | {infos} |")
|
|
580
|
+
lines.append("")
|
|
581
|
+
|
|
582
|
+
# Store header for later (we always include this)
|
|
583
|
+
header_content = "\n".join(lines)
|
|
584
|
+
|
|
585
|
+
# Footer (we always include this)
|
|
586
|
+
footer_lines = [
|
|
587
|
+
"",
|
|
588
|
+
"---",
|
|
589
|
+
"",
|
|
590
|
+
"<div align='center'>",
|
|
591
|
+
"๐ค <em>Generated by <strong>IAM Policy Validator</strong></em><br>",
|
|
592
|
+
"</div>",
|
|
593
|
+
]
|
|
594
|
+
footer_content = "\n".join(footer_lines)
|
|
595
|
+
|
|
596
|
+
# Calculate remaining space for details
|
|
597
|
+
base_length = len(header_content) + len(footer_content) + 100 # 100 for safety
|
|
598
|
+
available_length = max_length - base_length
|
|
599
|
+
|
|
600
|
+
# Detailed findings
|
|
601
|
+
if report.invalid_policies > 0:
|
|
602
|
+
details_lines = []
|
|
603
|
+
details_lines.append("## ๐ Detailed Findings")
|
|
604
|
+
details_lines.append("")
|
|
605
|
+
|
|
606
|
+
truncated = False
|
|
607
|
+
policies_shown = 0
|
|
608
|
+
issues_shown = 0
|
|
609
|
+
|
|
610
|
+
# Sort results to prioritize errors - support both IAM validity and security severities
|
|
611
|
+
sorted_results = sorted(
|
|
612
|
+
[(idx, r) for idx, r in enumerate(report.results, 1) if r.issues],
|
|
613
|
+
key=lambda x: (
|
|
614
|
+
-sum(1 for i in x[1].issues if i.severity in ("error", "critical", "high")),
|
|
615
|
+
-len(x[1].issues),
|
|
616
|
+
),
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
for idx, result in sorted_results:
|
|
620
|
+
if not result.issues:
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
policy_lines = []
|
|
624
|
+
|
|
625
|
+
# Group issues by severity - support both IAM validity and security severities
|
|
626
|
+
errors = [i for i in result.issues if i.severity in ("error", "critical", "high")]
|
|
627
|
+
warnings = [i for i in result.issues if i.severity in ("warning", "medium")]
|
|
628
|
+
infos = [i for i in result.issues if i.severity in ("info", "low")]
|
|
629
|
+
|
|
630
|
+
# Build severity summary for header
|
|
631
|
+
severity_parts = []
|
|
632
|
+
if errors:
|
|
633
|
+
severity_parts.append(f"๐ด {len(errors)}")
|
|
634
|
+
if warnings:
|
|
635
|
+
severity_parts.append(f"๐ก {len(warnings)}")
|
|
636
|
+
if infos:
|
|
637
|
+
severity_parts.append(f"๐ต {len(infos)}")
|
|
638
|
+
severity_summary = " ยท ".join(severity_parts)
|
|
639
|
+
|
|
640
|
+
# Only open first 3 policy details by default to avoid wall of text
|
|
641
|
+
# is_open = " open" if policies_shown < 3 else ""
|
|
642
|
+
policy_lines.append("<details>")
|
|
643
|
+
policy_lines.append(
|
|
644
|
+
f"<summary><b>{idx}. <code>{result.policy_file}</code></b> - {severity_summary}</summary>"
|
|
645
|
+
)
|
|
646
|
+
policy_lines.append("")
|
|
647
|
+
|
|
648
|
+
# Add errors (prioritized)
|
|
649
|
+
if errors:
|
|
650
|
+
policy_lines.append("### ๐ด Errors")
|
|
651
|
+
policy_lines.append("")
|
|
652
|
+
for i, issue in enumerate(errors):
|
|
653
|
+
issue_content = self._format_issue_markdown(issue, result.policy_file)
|
|
654
|
+
test_length = len("\n".join(details_lines + policy_lines)) + len(
|
|
655
|
+
issue_content
|
|
656
|
+
)
|
|
657
|
+
if test_length > available_length:
|
|
658
|
+
truncated = True
|
|
659
|
+
break
|
|
660
|
+
policy_lines.append(issue_content)
|
|
661
|
+
issues_shown += 1
|
|
662
|
+
# Add separator between issues within same severity
|
|
663
|
+
if i < len(errors) - 1:
|
|
664
|
+
policy_lines.append("---")
|
|
665
|
+
policy_lines.append("")
|
|
666
|
+
policy_lines.append("")
|
|
667
|
+
|
|
668
|
+
if truncated:
|
|
669
|
+
break
|
|
670
|
+
|
|
671
|
+
# Add warnings
|
|
672
|
+
if warnings:
|
|
673
|
+
policy_lines.append("### ๐ก Warnings")
|
|
674
|
+
policy_lines.append("")
|
|
675
|
+
for i, issue in enumerate(warnings):
|
|
676
|
+
issue_content = self._format_issue_markdown(issue, result.policy_file)
|
|
677
|
+
test_length = len("\n".join(details_lines + policy_lines)) + len(
|
|
678
|
+
issue_content
|
|
679
|
+
)
|
|
680
|
+
if test_length > available_length:
|
|
681
|
+
truncated = True
|
|
682
|
+
break
|
|
683
|
+
policy_lines.append(issue_content)
|
|
684
|
+
issues_shown += 1
|
|
685
|
+
# Add separator between issues within same severity
|
|
686
|
+
if i < len(warnings) - 1:
|
|
687
|
+
policy_lines.append("---")
|
|
688
|
+
policy_lines.append("")
|
|
689
|
+
policy_lines.append("")
|
|
690
|
+
|
|
691
|
+
if truncated:
|
|
692
|
+
break
|
|
693
|
+
|
|
694
|
+
# Add infos
|
|
695
|
+
if infos:
|
|
696
|
+
policy_lines.append("### ๐ต Info")
|
|
697
|
+
policy_lines.append("")
|
|
698
|
+
for i, issue in enumerate(infos):
|
|
699
|
+
issue_content = self._format_issue_markdown(issue, result.policy_file)
|
|
700
|
+
test_length = len("\n".join(details_lines + policy_lines)) + len(
|
|
701
|
+
issue_content
|
|
702
|
+
)
|
|
703
|
+
if test_length > available_length:
|
|
704
|
+
truncated = True
|
|
705
|
+
break
|
|
706
|
+
policy_lines.append(issue_content)
|
|
707
|
+
issues_shown += 1
|
|
708
|
+
# Add separator between issues within same severity
|
|
709
|
+
if i < len(infos) - 1:
|
|
710
|
+
policy_lines.append("---")
|
|
711
|
+
policy_lines.append("")
|
|
712
|
+
policy_lines.append("")
|
|
713
|
+
|
|
714
|
+
if truncated:
|
|
715
|
+
break
|
|
716
|
+
|
|
717
|
+
policy_lines.append("</details>")
|
|
718
|
+
policy_lines.append("")
|
|
719
|
+
|
|
720
|
+
# Check if adding this policy would exceed limit
|
|
721
|
+
test_length = len("\n".join(details_lines + policy_lines))
|
|
722
|
+
if test_length > available_length:
|
|
723
|
+
truncated = True
|
|
724
|
+
break
|
|
725
|
+
|
|
726
|
+
details_lines.extend(policy_lines)
|
|
727
|
+
policies_shown += 1
|
|
728
|
+
|
|
729
|
+
# Add separator between policies (but not after the last one)
|
|
730
|
+
# The footer will add its own separator
|
|
731
|
+
if (
|
|
732
|
+
policies_shown < len([r for r in sorted_results if r[1].issues])
|
|
733
|
+
and not truncated
|
|
734
|
+
):
|
|
735
|
+
details_lines.append("---")
|
|
736
|
+
details_lines.append("")
|
|
737
|
+
|
|
738
|
+
# Add truncation warning if needed
|
|
739
|
+
if truncated:
|
|
740
|
+
remaining_policies = len([r for r in report.results if r.issues]) - policies_shown
|
|
741
|
+
remaining_issues = report.total_issues - issues_shown
|
|
742
|
+
|
|
743
|
+
details_lines.append("")
|
|
744
|
+
details_lines.append("> โ ๏ธ **Output Truncated**")
|
|
745
|
+
details_lines.append(">")
|
|
746
|
+
details_lines.append(
|
|
747
|
+
"> The report was truncated to fit within GitHub's comment size limit."
|
|
748
|
+
)
|
|
749
|
+
details_lines.append(
|
|
750
|
+
f"> **Showing:** {policies_shown} policies with {issues_shown} issues"
|
|
751
|
+
)
|
|
752
|
+
details_lines.append(
|
|
753
|
+
f"> **Remaining:** {remaining_policies} policies with {remaining_issues} issues"
|
|
754
|
+
)
|
|
755
|
+
details_lines.append(">")
|
|
756
|
+
details_lines.append(
|
|
757
|
+
"> ๐ก **Tip:** Download the full report using `--output report.json` or `--format markdown --output report.md`"
|
|
758
|
+
)
|
|
759
|
+
details_lines.append("")
|
|
760
|
+
|
|
761
|
+
lines.extend(details_lines)
|
|
762
|
+
else:
|
|
763
|
+
# Success message when no issues
|
|
764
|
+
lines.append("## โจ All Policies Valid")
|
|
765
|
+
lines.append("")
|
|
766
|
+
lines.append("> ๐ฏ Great job! All IAM policies passed validation with no issues found.")
|
|
767
|
+
lines.append("")
|
|
768
|
+
|
|
769
|
+
# Add footer
|
|
770
|
+
lines.extend(footer_lines)
|
|
771
|
+
|
|
772
|
+
return "\n".join(lines)
|
|
773
|
+
|
|
774
|
+
def _format_issue_markdown(self, issue: ValidationIssue, policy_file: str | None = None) -> str:
|
|
775
|
+
"""Format a single issue as markdown.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
issue: The validation issue to format
|
|
779
|
+
policy_file: Optional policy file path (currently unused, kept for compatibility)
|
|
780
|
+
"""
|
|
781
|
+
# Use 1-indexed statement numbers for user-facing output
|
|
782
|
+
statement_num = issue.statement_index + 1
|
|
783
|
+
|
|
784
|
+
# Build statement location reference
|
|
785
|
+
# Note: We show plain text here instead of links because:
|
|
786
|
+
# 1. GitHub's diff anchor format only works for files in the PR diff
|
|
787
|
+
# 2. Inline review comments (posted separately) already provide perfect navigation
|
|
788
|
+
# 3. Summary comment is for overview, not detailed navigation
|
|
789
|
+
if issue.line_number:
|
|
790
|
+
location = f"Statement {statement_num} (Line {issue.line_number})"
|
|
791
|
+
if issue.statement_sid:
|
|
792
|
+
location = (
|
|
793
|
+
f"`{issue.statement_sid}` (statement {statement_num}, line {issue.line_number})"
|
|
794
|
+
)
|
|
795
|
+
else:
|
|
796
|
+
location = f"Statement {statement_num}"
|
|
797
|
+
if issue.statement_sid:
|
|
798
|
+
location = f"`{issue.statement_sid}` (statement {statement_num})"
|
|
799
|
+
|
|
800
|
+
parts = []
|
|
801
|
+
|
|
802
|
+
# Issue header with type badge
|
|
803
|
+
parts.append(f"**๐ {location}** ยท `{issue.issue_type}`")
|
|
804
|
+
parts.append("")
|
|
805
|
+
|
|
806
|
+
# Message in blockquote for emphasis
|
|
807
|
+
parts.append(f"> {issue.message}")
|
|
808
|
+
parts.append("")
|
|
809
|
+
|
|
810
|
+
# Details section - inline format
|
|
811
|
+
details = []
|
|
812
|
+
if issue.action:
|
|
813
|
+
details.append(f"**Action:** `{issue.action}`")
|
|
814
|
+
if issue.resource:
|
|
815
|
+
details.append(f"**Resource:** `{issue.resource}`")
|
|
816
|
+
if issue.condition_key:
|
|
817
|
+
details.append(f"**Condition Key:** `{issue.condition_key}`")
|
|
818
|
+
|
|
819
|
+
if details:
|
|
820
|
+
parts.append(" ยท ".join(details))
|
|
821
|
+
parts.append("")
|
|
822
|
+
|
|
823
|
+
# Suggestion in highlighted box with code examples
|
|
824
|
+
if issue.suggestion:
|
|
825
|
+
# Check if suggestion contains "Example:" section
|
|
826
|
+
if "\nExample:\n" in issue.suggestion:
|
|
827
|
+
text_part, code_part = issue.suggestion.split("\nExample:\n", 1)
|
|
828
|
+
parts.append(f"> ๐ก **Suggestion:** {text_part}")
|
|
829
|
+
parts.append("")
|
|
830
|
+
parts.append("<details>")
|
|
831
|
+
parts.append("<summary>๐ View Example</summary>")
|
|
832
|
+
parts.append("")
|
|
833
|
+
parts.append("```json")
|
|
834
|
+
parts.append(code_part)
|
|
835
|
+
parts.append("```")
|
|
836
|
+
parts.append("</details>")
|
|
837
|
+
parts.append("")
|
|
838
|
+
else:
|
|
839
|
+
parts.append(f"> ๐ก **Suggestion:** {issue.suggestion}")
|
|
840
|
+
parts.append("")
|
|
841
|
+
|
|
842
|
+
return "\n".join(parts)
|
|
843
|
+
|
|
844
|
+
def save_json_report(self, report: ValidationReport, file_path: str) -> None:
|
|
845
|
+
"""Save report to a JSON file.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
report: Validation report
|
|
849
|
+
file_path: Path to save the JSON file
|
|
850
|
+
"""
|
|
851
|
+
try:
|
|
852
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
853
|
+
f.write(self.generate_json_report(report))
|
|
854
|
+
logger.info(f"Saved JSON report to {file_path}")
|
|
855
|
+
except Exception as e:
|
|
856
|
+
logger.error(f"Failed to save JSON report: {e}")
|
|
857
|
+
raise
|
|
858
|
+
|
|
859
|
+
def save_markdown_report(self, report: ValidationReport, file_path: str) -> None:
|
|
860
|
+
"""Save GitHub markdown report to a file.
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
report: Validation report
|
|
864
|
+
file_path: Path to save the markdown file
|
|
865
|
+
"""
|
|
866
|
+
try:
|
|
867
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
868
|
+
f.write(self.generate_github_comment(report))
|
|
869
|
+
logger.info(f"Saved markdown report to {file_path}")
|
|
870
|
+
except Exception as e:
|
|
871
|
+
logger.error(f"Failed to save markdown report: {e}")
|
|
872
|
+
raise
|