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,640 @@
|
|
|
1
|
+
"""Report generation for IAM Access Analyzer validation results."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from iam_validator.__version__ import __version__
|
|
12
|
+
from iam_validator.core.access_analyzer import (
|
|
13
|
+
AccessAnalyzerFinding,
|
|
14
|
+
AccessAnalyzerReport,
|
|
15
|
+
AccessAnalyzerResult,
|
|
16
|
+
CustomCheckResult,
|
|
17
|
+
FindingType,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AccessAnalyzerReportFormatter:
|
|
22
|
+
"""Formats Access Analyzer validation results for various outputs."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
"""Initialize the report formatter."""
|
|
26
|
+
self.console = Console()
|
|
27
|
+
|
|
28
|
+
def print_console_report(self, report: AccessAnalyzerReport) -> None:
|
|
29
|
+
"""Print a formatted console report using Rich.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
report: Access Analyzer validation report
|
|
33
|
+
"""
|
|
34
|
+
# Print summary
|
|
35
|
+
self._print_summary(report)
|
|
36
|
+
|
|
37
|
+
# Print detailed results for each policy
|
|
38
|
+
for result in report.results:
|
|
39
|
+
self._print_policy_result(result)
|
|
40
|
+
|
|
41
|
+
# Print final statistics
|
|
42
|
+
self._print_statistics(report)
|
|
43
|
+
|
|
44
|
+
def _print_summary(self, report: AccessAnalyzerReport) -> None:
|
|
45
|
+
"""Print summary panel."""
|
|
46
|
+
summary_text = Text()
|
|
47
|
+
summary_text.append(f"Total Policies: {report.total_policies}\n")
|
|
48
|
+
summary_text.append("Valid Policies: ", style="bold")
|
|
49
|
+
summary_text.append(f"{report.valid_policies}\n", style="bold green")
|
|
50
|
+
summary_text.append("Invalid Policies: ", style="bold")
|
|
51
|
+
summary_text.append(f"{report.invalid_policies}\n", style="bold red")
|
|
52
|
+
summary_text.append(f"\nTotal Findings: {report.total_findings}\n")
|
|
53
|
+
summary_text.append(" Errors: ", style="bold")
|
|
54
|
+
summary_text.append(f"{report.total_errors}\n", style="bold red")
|
|
55
|
+
summary_text.append(" Warnings: ", style="bold")
|
|
56
|
+
summary_text.append(f"{report.total_warnings}\n", style="bold yellow")
|
|
57
|
+
summary_text.append(" Suggestions: ", style="bold")
|
|
58
|
+
summary_text.append(f"{report.total_suggestions}", style="bold blue")
|
|
59
|
+
|
|
60
|
+
# Add custom checks summary if present
|
|
61
|
+
total_custom_checks = sum(len(r.custom_checks) for r in report.results if r.custom_checks)
|
|
62
|
+
failed_custom_checks = sum(r.failed_custom_checks for r in report.results)
|
|
63
|
+
|
|
64
|
+
if total_custom_checks > 0:
|
|
65
|
+
summary_text.append(f"\n\nCustom Policy Checks: {total_custom_checks}\n")
|
|
66
|
+
summary_text.append(" Failed Checks: ", style="bold")
|
|
67
|
+
if failed_custom_checks > 0:
|
|
68
|
+
summary_text.append(f"{failed_custom_checks}", style="bold red")
|
|
69
|
+
else:
|
|
70
|
+
summary_text.append(f"{failed_custom_checks}", style="bold green")
|
|
71
|
+
|
|
72
|
+
panel = Panel(
|
|
73
|
+
summary_text,
|
|
74
|
+
title=f"[bold]Access Analyzer Validation Summary (iam-validator v{__version__})[/bold]",
|
|
75
|
+
border_style="blue",
|
|
76
|
+
)
|
|
77
|
+
self.console.print(panel)
|
|
78
|
+
self.console.print()
|
|
79
|
+
|
|
80
|
+
def _print_policy_result(self, result: AccessAnalyzerResult) -> None:
|
|
81
|
+
"""Print results for a single policy."""
|
|
82
|
+
# Policy header
|
|
83
|
+
status_emoji = "✅" if result.is_valid else "❌"
|
|
84
|
+
status_text = "VALID" if result.is_valid else "INVALID"
|
|
85
|
+
status_style = "bold green" if result.is_valid else "bold red"
|
|
86
|
+
|
|
87
|
+
self.console.print(
|
|
88
|
+
f"\n{status_emoji} [bold]{result.policy_file}[/bold] - "
|
|
89
|
+
f"[{status_style}]{status_text}[/{status_style}]"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Handle errors
|
|
93
|
+
if result.error:
|
|
94
|
+
self.console.print(f" [red]Error: {result.error}[/red]")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
# Print custom check results
|
|
98
|
+
if result.custom_checks:
|
|
99
|
+
self.console.print("\n [bold cyan]Custom Policy Checks:[/bold cyan]")
|
|
100
|
+
for check in result.custom_checks:
|
|
101
|
+
self._print_custom_check(check)
|
|
102
|
+
|
|
103
|
+
# Print findings
|
|
104
|
+
if not result.findings:
|
|
105
|
+
self.console.print(" [green]No findings[/green]")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Group findings by type
|
|
109
|
+
for finding in result.findings:
|
|
110
|
+
self._print_finding(finding)
|
|
111
|
+
|
|
112
|
+
def _print_finding(self, finding: AccessAnalyzerFinding) -> None:
|
|
113
|
+
"""Print a single finding."""
|
|
114
|
+
# Icon and color based on finding type
|
|
115
|
+
icons = {
|
|
116
|
+
FindingType.ERROR: ("❌", "red"),
|
|
117
|
+
FindingType.SECURITY_WARNING: ("⚠️", "yellow"),
|
|
118
|
+
FindingType.WARNING: ("⚠️", "yellow"),
|
|
119
|
+
FindingType.SUGGESTION: ("💡", "blue"),
|
|
120
|
+
}
|
|
121
|
+
icon, color = icons.get(finding.finding_type, ("ℹ️", "white"))
|
|
122
|
+
|
|
123
|
+
self.console.print(f"\n {icon} [{color}]{finding.finding_type.value}[/{color}]")
|
|
124
|
+
self.console.print(f" Code: [bold]{finding.issue_code}[/bold]")
|
|
125
|
+
self.console.print(f" {finding.message}")
|
|
126
|
+
|
|
127
|
+
# Print locations if available
|
|
128
|
+
if finding.locations:
|
|
129
|
+
self.console.print(" Locations:")
|
|
130
|
+
for loc in finding.locations:
|
|
131
|
+
path = loc.get("path", [])
|
|
132
|
+
span = loc.get("span", {})
|
|
133
|
+
if path:
|
|
134
|
+
path_str = " → ".join(str(p.get("value", p)) for p in path)
|
|
135
|
+
self.console.print(f" • {path_str}")
|
|
136
|
+
if span:
|
|
137
|
+
start = span.get("start", {})
|
|
138
|
+
line_info = f"Line {start.get('line', '?')}"
|
|
139
|
+
if start.get("column"):
|
|
140
|
+
line_info += f", Column {start.get('column')}"
|
|
141
|
+
self.console.print(f" {line_info}")
|
|
142
|
+
|
|
143
|
+
self.console.print(f" [dim]Learn more: {finding.learn_more_link}[/dim]")
|
|
144
|
+
|
|
145
|
+
def _print_custom_check(self, check: CustomCheckResult) -> None:
|
|
146
|
+
"""Print a custom policy check result."""
|
|
147
|
+
# Icon and color based on result
|
|
148
|
+
if check.passed:
|
|
149
|
+
icon, color = ("✅", "green")
|
|
150
|
+
result_text = "PASS"
|
|
151
|
+
else:
|
|
152
|
+
icon, color = ("❌", "red")
|
|
153
|
+
result_text = "FAIL"
|
|
154
|
+
|
|
155
|
+
self.console.print(f"\n {icon} [{color}]{check.check_type}: {result_text}[/{color}]")
|
|
156
|
+
if check.message:
|
|
157
|
+
self.console.print(f" {check.message}")
|
|
158
|
+
|
|
159
|
+
# Print reasons if failed
|
|
160
|
+
if check.reasons and not check.passed:
|
|
161
|
+
self.console.print(" [yellow]Reasons:[/yellow]")
|
|
162
|
+
for reason in check.reasons:
|
|
163
|
+
self.console.print(f" • {reason.description}")
|
|
164
|
+
if reason.statement_id:
|
|
165
|
+
self.console.print(f" Statement ID: {reason.statement_id}")
|
|
166
|
+
if reason.statement_index is not None:
|
|
167
|
+
self.console.print(f" Statement Index: {reason.statement_index}")
|
|
168
|
+
|
|
169
|
+
def _print_statistics(self, report: AccessAnalyzerReport) -> None:
|
|
170
|
+
"""Print final statistics table."""
|
|
171
|
+
self.console.print("\n")
|
|
172
|
+
|
|
173
|
+
table = Table(title="Finding Statistics", show_header=True, header_style="bold magenta")
|
|
174
|
+
table.add_column("Finding Type", style="cyan", no_wrap=True)
|
|
175
|
+
table.add_column("Count", justify="right", style="green")
|
|
176
|
+
|
|
177
|
+
table.add_row("Errors", str(report.total_errors), style="red")
|
|
178
|
+
table.add_row("Warnings", str(report.total_warnings), style="yellow")
|
|
179
|
+
table.add_row("Suggestions", str(report.total_suggestions), style="blue")
|
|
180
|
+
table.add_row("Total", str(report.total_findings), style="bold")
|
|
181
|
+
|
|
182
|
+
self.console.print(table)
|
|
183
|
+
|
|
184
|
+
def generate_json_report(self, report: AccessAnalyzerReport) -> str:
|
|
185
|
+
"""Generate JSON report.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
report: Access Analyzer validation report
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
JSON string
|
|
192
|
+
"""
|
|
193
|
+
data: dict[str, Any] = {
|
|
194
|
+
"summary": {
|
|
195
|
+
"total_policies": report.total_policies,
|
|
196
|
+
"valid_policies": report.valid_policies,
|
|
197
|
+
"invalid_policies": report.invalid_policies,
|
|
198
|
+
"total_findings": report.total_findings,
|
|
199
|
+
"total_errors": report.total_errors,
|
|
200
|
+
"total_warnings": report.total_warnings,
|
|
201
|
+
"total_suggestions": report.total_suggestions,
|
|
202
|
+
},
|
|
203
|
+
"results": [],
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for result in report.results:
|
|
207
|
+
result_data: dict[str, Any] = {
|
|
208
|
+
"policy_file": result.policy_file,
|
|
209
|
+
"is_valid": result.is_valid,
|
|
210
|
+
"error_count": result.error_count,
|
|
211
|
+
"warning_count": result.warning_count,
|
|
212
|
+
"suggestion_count": result.suggestion_count,
|
|
213
|
+
"findings": [],
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if result.error:
|
|
217
|
+
result_data["error"] = result.error
|
|
218
|
+
|
|
219
|
+
# Add custom checks if present
|
|
220
|
+
if result.custom_checks:
|
|
221
|
+
result_data["custom_checks"] = []
|
|
222
|
+
for check in result.custom_checks:
|
|
223
|
+
check_data = {
|
|
224
|
+
"check_type": check.check_type,
|
|
225
|
+
"result": check.result.value,
|
|
226
|
+
"passed": check.passed,
|
|
227
|
+
"message": check.message,
|
|
228
|
+
"reasons": [
|
|
229
|
+
{
|
|
230
|
+
"description": r.description,
|
|
231
|
+
"statement_id": r.statement_id,
|
|
232
|
+
"statement_index": r.statement_index,
|
|
233
|
+
}
|
|
234
|
+
for r in check.reasons
|
|
235
|
+
],
|
|
236
|
+
}
|
|
237
|
+
result_data["custom_checks"].append(check_data)
|
|
238
|
+
|
|
239
|
+
for finding in result.findings:
|
|
240
|
+
finding_data = {
|
|
241
|
+
"finding_type": finding.finding_type.value,
|
|
242
|
+
"severity": finding.severity,
|
|
243
|
+
"issue_code": finding.issue_code,
|
|
244
|
+
"message": finding.message,
|
|
245
|
+
"learn_more_link": finding.learn_more_link,
|
|
246
|
+
"locations": finding.locations,
|
|
247
|
+
}
|
|
248
|
+
result_data["findings"].append(finding_data)
|
|
249
|
+
|
|
250
|
+
data["results"].append(result_data)
|
|
251
|
+
|
|
252
|
+
return json.dumps(data, indent=2)
|
|
253
|
+
|
|
254
|
+
def save_json_report(self, report: AccessAnalyzerReport, file_path: str) -> None:
|
|
255
|
+
"""Save JSON report to file.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
report: Access Analyzer validation report
|
|
259
|
+
file_path: Path to save JSON report
|
|
260
|
+
"""
|
|
261
|
+
json_content = self.generate_json_report(report)
|
|
262
|
+
with open(file_path, "w") as f:
|
|
263
|
+
f.write(json_content)
|
|
264
|
+
|
|
265
|
+
def generate_markdown_report(
|
|
266
|
+
self, report: AccessAnalyzerReport, max_length: int = 65000
|
|
267
|
+
) -> str:
|
|
268
|
+
"""Generate Markdown report.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
report: Access Analyzer validation report
|
|
272
|
+
max_length: Maximum character length (GitHub limit is 65536, we use 65000 for safety)
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Markdown string
|
|
276
|
+
"""
|
|
277
|
+
lines = []
|
|
278
|
+
|
|
279
|
+
# Title with emoji and status badge
|
|
280
|
+
if report.invalid_policies == 0:
|
|
281
|
+
lines.append("# 🛡️ IAM Access Analyzer Validation Passed!")
|
|
282
|
+
status_badge = ""
|
|
283
|
+
else:
|
|
284
|
+
lines.append("# 🔍 IAM Access Analyzer Validation Results")
|
|
285
|
+
status_badge = ""
|
|
286
|
+
|
|
287
|
+
lines.append("")
|
|
288
|
+
lines.append(status_badge)
|
|
289
|
+
lines.append("")
|
|
290
|
+
|
|
291
|
+
# Summary section with enhanced table
|
|
292
|
+
lines.append("## 📊 Summary")
|
|
293
|
+
lines.append("")
|
|
294
|
+
lines.append("| Metric | Count | Status |")
|
|
295
|
+
lines.append("|--------|------:|:------:|")
|
|
296
|
+
lines.append(f"| **Total Policies Analyzed** | {report.total_policies} | 📋 |")
|
|
297
|
+
lines.append(f"| **Valid Policies** | {report.valid_policies} | ✅ |")
|
|
298
|
+
lines.append(f"| **Invalid Policies** | {report.invalid_policies} | ❌ |")
|
|
299
|
+
lines.append(
|
|
300
|
+
f"| **Total Findings** | {report.total_findings} | {'⚠️' if report.total_findings > 0 else '✨'} |"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Add custom checks summary if present
|
|
304
|
+
total_custom_checks = sum(len(r.custom_checks) for r in report.results if r.custom_checks)
|
|
305
|
+
failed_custom_checks = sum(r.failed_custom_checks for r in report.results)
|
|
306
|
+
|
|
307
|
+
if total_custom_checks > 0:
|
|
308
|
+
lines.append(f"| **Custom Policy Checks** | {total_custom_checks} | 🔍 |")
|
|
309
|
+
if failed_custom_checks > 0:
|
|
310
|
+
lines.append(f"| **Failed Custom Checks** | {failed_custom_checks} | ❌ |")
|
|
311
|
+
else:
|
|
312
|
+
lines.append(f"| **Failed Custom Checks** | {failed_custom_checks} | ✅ |")
|
|
313
|
+
|
|
314
|
+
lines.append("")
|
|
315
|
+
|
|
316
|
+
# Findings breakdown
|
|
317
|
+
if report.total_findings > 0:
|
|
318
|
+
lines.append("<details>")
|
|
319
|
+
lines.append("<summary><b>🔍 Findings Breakdown</b></summary>")
|
|
320
|
+
lines.append("")
|
|
321
|
+
lines.append("| Severity | Count |")
|
|
322
|
+
lines.append("|----------|------:|")
|
|
323
|
+
if report.total_errors > 0:
|
|
324
|
+
lines.append(f"| 🔴 **Errors** | {report.total_errors} |")
|
|
325
|
+
if report.total_warnings > 0:
|
|
326
|
+
lines.append(f"| 🟡 **Warnings** | {report.total_warnings} |")
|
|
327
|
+
if report.total_suggestions > 0:
|
|
328
|
+
lines.append(f"| 🔵 **Suggestions** | {report.total_suggestions} |")
|
|
329
|
+
lines.append("")
|
|
330
|
+
lines.append("</details>")
|
|
331
|
+
lines.append("")
|
|
332
|
+
|
|
333
|
+
# Store header for later (we always include this)
|
|
334
|
+
header_content = "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
# Footer (we always include this)
|
|
337
|
+
footer_lines = [
|
|
338
|
+
"---",
|
|
339
|
+
"",
|
|
340
|
+
"<div align='center'>",
|
|
341
|
+
"🤖 <em>Generated by <strong>IAM Policy Validator</strong></em><br>",
|
|
342
|
+
"<sub>Powered by AWS IAM Access Analyzer</sub>",
|
|
343
|
+
"</div>",
|
|
344
|
+
]
|
|
345
|
+
footer_content = "\n".join(footer_lines)
|
|
346
|
+
|
|
347
|
+
# Calculate remaining space for details
|
|
348
|
+
base_length = len(header_content) + len(footer_content) + 100 # 100 for safety
|
|
349
|
+
available_length = max_length - base_length
|
|
350
|
+
|
|
351
|
+
# Detailed results
|
|
352
|
+
details_lines = []
|
|
353
|
+
details_lines.append("## 📝 Detailed Results")
|
|
354
|
+
details_lines.append("")
|
|
355
|
+
|
|
356
|
+
truncated = False
|
|
357
|
+
policies_shown = 0
|
|
358
|
+
findings_shown = 0
|
|
359
|
+
|
|
360
|
+
# Sort results to prioritize errors
|
|
361
|
+
sorted_results = sorted(
|
|
362
|
+
[(idx, r) for idx, r in enumerate(report.results, 1)],
|
|
363
|
+
key=lambda x: (
|
|
364
|
+
-x[1].error_count if x[1].error_count else 0,
|
|
365
|
+
-(x[1].error_count + x[1].warning_count + x[1].suggestion_count),
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
for idx, result in sorted_results:
|
|
370
|
+
status_emoji = "✅" if result.is_valid else "❌"
|
|
371
|
+
|
|
372
|
+
policy_lines = []
|
|
373
|
+
# File header with collapsible section
|
|
374
|
+
policy_lines.append(f"<details {'open' if result.findings else ''}>")
|
|
375
|
+
policy_lines.append(
|
|
376
|
+
f"<summary><b>{idx}. {status_emoji} <code>{result.policy_file}</code></b>"
|
|
377
|
+
)
|
|
378
|
+
if result.findings:
|
|
379
|
+
policy_lines.append(f" - {len(result.findings)} finding(s)")
|
|
380
|
+
policy_lines.append("</summary>")
|
|
381
|
+
policy_lines.append("")
|
|
382
|
+
|
|
383
|
+
if result.error:
|
|
384
|
+
policy_lines.append(f"> ❌ **Error**: {result.error}")
|
|
385
|
+
policy_lines.append("")
|
|
386
|
+
policy_lines.append("</details>")
|
|
387
|
+
policy_lines.append("")
|
|
388
|
+
|
|
389
|
+
# Check if we can fit this
|
|
390
|
+
test_length = len("\n".join(details_lines + policy_lines))
|
|
391
|
+
if test_length > available_length:
|
|
392
|
+
truncated = True
|
|
393
|
+
break
|
|
394
|
+
|
|
395
|
+
details_lines.extend(policy_lines)
|
|
396
|
+
policies_shown += 1
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
# Add custom check results if present
|
|
400
|
+
if result.custom_checks:
|
|
401
|
+
policy_lines.append("### 🔍 Custom Policy Checks")
|
|
402
|
+
policy_lines.append("")
|
|
403
|
+
for check in result.custom_checks:
|
|
404
|
+
check_lines = self._format_custom_check_markdown(check)
|
|
405
|
+
policy_lines.extend(check_lines)
|
|
406
|
+
policy_lines.append("")
|
|
407
|
+
|
|
408
|
+
if not result.findings:
|
|
409
|
+
policy_lines.append("> ✨ **No findings** - Policy is valid!")
|
|
410
|
+
policy_lines.append("")
|
|
411
|
+
policy_lines.append("</details>")
|
|
412
|
+
policy_lines.append("")
|
|
413
|
+
|
|
414
|
+
# Check if we can fit this
|
|
415
|
+
test_length = len("\n".join(details_lines + policy_lines))
|
|
416
|
+
if test_length > available_length:
|
|
417
|
+
truncated = True
|
|
418
|
+
break
|
|
419
|
+
|
|
420
|
+
details_lines.extend(policy_lines)
|
|
421
|
+
policies_shown += 1
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
# Group findings by type
|
|
425
|
+
errors = [f for f in result.findings if f.finding_type == FindingType.ERROR]
|
|
426
|
+
warnings = [
|
|
427
|
+
f
|
|
428
|
+
for f in result.findings
|
|
429
|
+
if f.finding_type in (FindingType.WARNING, FindingType.SECURITY_WARNING)
|
|
430
|
+
]
|
|
431
|
+
suggestions = [f for f in result.findings if f.finding_type == FindingType.SUGGESTION]
|
|
432
|
+
|
|
433
|
+
# Add errors (prioritized)
|
|
434
|
+
if errors:
|
|
435
|
+
policy_lines.append("### 🔴 Errors")
|
|
436
|
+
policy_lines.append("")
|
|
437
|
+
for finding in errors:
|
|
438
|
+
finding_content = self._format_finding_markdown(finding)
|
|
439
|
+
test_length = len("\n".join(details_lines + policy_lines)) + len(
|
|
440
|
+
"\n".join(finding_content)
|
|
441
|
+
)
|
|
442
|
+
if test_length > available_length:
|
|
443
|
+
truncated = True
|
|
444
|
+
break
|
|
445
|
+
policy_lines.extend(finding_content)
|
|
446
|
+
findings_shown += 1
|
|
447
|
+
policy_lines.append("")
|
|
448
|
+
|
|
449
|
+
if truncated:
|
|
450
|
+
break
|
|
451
|
+
|
|
452
|
+
# Add warnings
|
|
453
|
+
if warnings:
|
|
454
|
+
policy_lines.append("### 🟡 Warnings")
|
|
455
|
+
policy_lines.append("")
|
|
456
|
+
for finding in warnings:
|
|
457
|
+
finding_content = self._format_finding_markdown(finding)
|
|
458
|
+
test_length = len("\n".join(details_lines + policy_lines)) + len(
|
|
459
|
+
"\n".join(finding_content)
|
|
460
|
+
)
|
|
461
|
+
if test_length > available_length:
|
|
462
|
+
truncated = True
|
|
463
|
+
break
|
|
464
|
+
policy_lines.extend(finding_content)
|
|
465
|
+
findings_shown += 1
|
|
466
|
+
policy_lines.append("")
|
|
467
|
+
|
|
468
|
+
if truncated:
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
# Add suggestions
|
|
472
|
+
if suggestions:
|
|
473
|
+
policy_lines.append("### 🔵 Suggestions")
|
|
474
|
+
policy_lines.append("")
|
|
475
|
+
for finding in suggestions:
|
|
476
|
+
finding_content = self._format_finding_markdown(finding)
|
|
477
|
+
test_length = len("\n".join(details_lines + policy_lines)) + len(
|
|
478
|
+
"\n".join(finding_content)
|
|
479
|
+
)
|
|
480
|
+
if test_length > available_length:
|
|
481
|
+
truncated = True
|
|
482
|
+
break
|
|
483
|
+
policy_lines.extend(finding_content)
|
|
484
|
+
findings_shown += 1
|
|
485
|
+
policy_lines.append("")
|
|
486
|
+
|
|
487
|
+
if truncated:
|
|
488
|
+
break
|
|
489
|
+
|
|
490
|
+
policy_lines.append("</details>")
|
|
491
|
+
policy_lines.append("")
|
|
492
|
+
|
|
493
|
+
# Check if adding this policy would exceed limit
|
|
494
|
+
test_length = len("\n".join(details_lines + policy_lines))
|
|
495
|
+
if test_length > available_length:
|
|
496
|
+
truncated = True
|
|
497
|
+
break
|
|
498
|
+
|
|
499
|
+
details_lines.extend(policy_lines)
|
|
500
|
+
policies_shown += 1
|
|
501
|
+
|
|
502
|
+
# Add truncation warning if needed
|
|
503
|
+
if truncated:
|
|
504
|
+
remaining_policies = report.total_policies - policies_shown
|
|
505
|
+
remaining_findings = report.total_findings - findings_shown
|
|
506
|
+
|
|
507
|
+
details_lines.append("")
|
|
508
|
+
details_lines.append("> ⚠️ **Output Truncated**")
|
|
509
|
+
details_lines.append(">")
|
|
510
|
+
details_lines.append(
|
|
511
|
+
"> The report was truncated to fit within GitHub's comment size limit."
|
|
512
|
+
)
|
|
513
|
+
details_lines.append(
|
|
514
|
+
f"> **Showing:** {policies_shown} policies with {findings_shown} findings"
|
|
515
|
+
)
|
|
516
|
+
details_lines.append(
|
|
517
|
+
f"> **Remaining:** {remaining_policies} policies with {remaining_findings} findings"
|
|
518
|
+
)
|
|
519
|
+
details_lines.append(">")
|
|
520
|
+
details_lines.append(
|
|
521
|
+
"> 💡 **Tip:** Download the full report using `--output report.json` or `--format markdown --output report.md`"
|
|
522
|
+
)
|
|
523
|
+
details_lines.append("")
|
|
524
|
+
|
|
525
|
+
lines.extend(details_lines)
|
|
526
|
+
|
|
527
|
+
# Add footer
|
|
528
|
+
lines.extend(footer_lines)
|
|
529
|
+
|
|
530
|
+
return "\n".join(lines)
|
|
531
|
+
|
|
532
|
+
def _format_custom_check_markdown(self, check: CustomCheckResult) -> list[str]:
|
|
533
|
+
"""Format a custom check result as markdown lines.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
check: The custom check result to format
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
List of markdown lines
|
|
540
|
+
"""
|
|
541
|
+
lines = []
|
|
542
|
+
|
|
543
|
+
# Check header with result
|
|
544
|
+
if check.passed:
|
|
545
|
+
icon = "✅"
|
|
546
|
+
badge_color = "green"
|
|
547
|
+
else:
|
|
548
|
+
icon = "❌"
|
|
549
|
+
badge_color = "red"
|
|
550
|
+
|
|
551
|
+
lines.append(
|
|
552
|
+
f"{icon} **{check.check_type}**: "
|
|
553
|
+
f""
|
|
554
|
+
)
|
|
555
|
+
lines.append("")
|
|
556
|
+
|
|
557
|
+
# Message if available
|
|
558
|
+
if check.message:
|
|
559
|
+
lines.append(f"> {check.message}")
|
|
560
|
+
lines.append("")
|
|
561
|
+
|
|
562
|
+
# Reasons if failed
|
|
563
|
+
if check.reasons and not check.passed:
|
|
564
|
+
lines.append("<table>")
|
|
565
|
+
lines.append("<tr><td>")
|
|
566
|
+
lines.append("")
|
|
567
|
+
lines.append("**Reasons:**")
|
|
568
|
+
lines.append("")
|
|
569
|
+
for reason in check.reasons:
|
|
570
|
+
lines.append(f"- {reason.description}")
|
|
571
|
+
if reason.statement_id:
|
|
572
|
+
lines.append(f" - Statement ID: `{reason.statement_id}`")
|
|
573
|
+
if reason.statement_index is not None:
|
|
574
|
+
lines.append(f" - Statement Index: `{reason.statement_index}`")
|
|
575
|
+
lines.append("")
|
|
576
|
+
lines.append("</td></tr>")
|
|
577
|
+
lines.append("</table>")
|
|
578
|
+
lines.append("")
|
|
579
|
+
|
|
580
|
+
return lines
|
|
581
|
+
|
|
582
|
+
def _format_finding_markdown(self, finding: AccessAnalyzerFinding) -> list[str]:
|
|
583
|
+
"""Format a single finding as markdown lines.
|
|
584
|
+
|
|
585
|
+
Args:
|
|
586
|
+
finding: The finding to format
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
List of markdown lines
|
|
590
|
+
"""
|
|
591
|
+
lines = []
|
|
592
|
+
|
|
593
|
+
# Finding header with code
|
|
594
|
+
lines.append(f"**📍 `{finding.issue_code}`**")
|
|
595
|
+
lines.append("")
|
|
596
|
+
|
|
597
|
+
# Message in blockquote
|
|
598
|
+
lines.append(f"> {finding.message}")
|
|
599
|
+
lines.append("")
|
|
600
|
+
|
|
601
|
+
# Locations if available
|
|
602
|
+
if finding.locations:
|
|
603
|
+
lines.append("<table>")
|
|
604
|
+
lines.append("<tr><td>")
|
|
605
|
+
lines.append("")
|
|
606
|
+
lines.append("**Locations:**")
|
|
607
|
+
lines.append("")
|
|
608
|
+
for loc in finding.locations:
|
|
609
|
+
path = loc.get("path", [])
|
|
610
|
+
span = loc.get("span", {})
|
|
611
|
+
if path:
|
|
612
|
+
path_str = " → ".join(str(p.get("value", p)) for p in path)
|
|
613
|
+
lines.append(f"- 📂 {path_str}")
|
|
614
|
+
if span:
|
|
615
|
+
start = span.get("start", {})
|
|
616
|
+
line_info = f"Line {start.get('line', '?')}"
|
|
617
|
+
if start.get("column"):
|
|
618
|
+
line_info += f", Column {start.get('column')}"
|
|
619
|
+
lines.append(f" - 📍 {line_info}")
|
|
620
|
+
lines.append("")
|
|
621
|
+
lines.append("</td></tr>")
|
|
622
|
+
lines.append("</table>")
|
|
623
|
+
lines.append("")
|
|
624
|
+
|
|
625
|
+
# Learn more link as button-style
|
|
626
|
+
lines.append(f"[📚 Learn more]({finding.learn_more_link})")
|
|
627
|
+
lines.append("")
|
|
628
|
+
|
|
629
|
+
return lines
|
|
630
|
+
|
|
631
|
+
def save_markdown_report(self, report: AccessAnalyzerReport, file_path: str) -> None:
|
|
632
|
+
"""Save Markdown report to file.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
report: Access Analyzer validation report
|
|
636
|
+
file_path: Path to save Markdown report
|
|
637
|
+
"""
|
|
638
|
+
markdown_content = self.generate_markdown_report(report)
|
|
639
|
+
with open(file_path, "w") as f:
|
|
640
|
+
f.write(markdown_content)
|