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.

Files changed (83) hide show
  1. iam_policy_validator-1.7.0.dist-info/METADATA +1057 -0
  2. iam_policy_validator-1.7.0.dist-info/RECORD +83 -0
  3. iam_policy_validator-1.7.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.7.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.7.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +7 -0
  9. iam_validator/checks/__init__.py +43 -0
  10. iam_validator/checks/action_condition_enforcement.py +884 -0
  11. iam_validator/checks/action_resource_matching.py +441 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +92 -0
  14. iam_validator/checks/condition_type_mismatch.py +259 -0
  15. iam_validator/checks/full_wildcard.py +71 -0
  16. iam_validator/checks/mfa_condition_check.py +112 -0
  17. iam_validator/checks/policy_size.py +147 -0
  18. iam_validator/checks/policy_type_validation.py +305 -0
  19. iam_validator/checks/principal_validation.py +776 -0
  20. iam_validator/checks/resource_validation.py +138 -0
  21. iam_validator/checks/sensitive_action.py +254 -0
  22. iam_validator/checks/service_wildcard.py +107 -0
  23. iam_validator/checks/set_operator_validation.py +157 -0
  24. iam_validator/checks/sid_uniqueness.py +170 -0
  25. iam_validator/checks/utils/__init__.py +1 -0
  26. iam_validator/checks/utils/policy_level_checks.py +143 -0
  27. iam_validator/checks/utils/sensitive_action_matcher.py +294 -0
  28. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  29. iam_validator/checks/wildcard_action.py +67 -0
  30. iam_validator/checks/wildcard_resource.py +135 -0
  31. iam_validator/commands/__init__.py +25 -0
  32. iam_validator/commands/analyze.py +531 -0
  33. iam_validator/commands/base.py +48 -0
  34. iam_validator/commands/cache.py +392 -0
  35. iam_validator/commands/download_services.py +255 -0
  36. iam_validator/commands/post_to_pr.py +86 -0
  37. iam_validator/commands/validate.py +600 -0
  38. iam_validator/core/__init__.py +14 -0
  39. iam_validator/core/access_analyzer.py +671 -0
  40. iam_validator/core/access_analyzer_report.py +640 -0
  41. iam_validator/core/aws_fetcher.py +940 -0
  42. iam_validator/core/check_registry.py +607 -0
  43. iam_validator/core/cli.py +134 -0
  44. iam_validator/core/condition_validators.py +626 -0
  45. iam_validator/core/config/__init__.py +81 -0
  46. iam_validator/core/config/aws_api.py +35 -0
  47. iam_validator/core/config/aws_global_conditions.py +160 -0
  48. iam_validator/core/config/category_suggestions.py +104 -0
  49. iam_validator/core/config/condition_requirements.py +155 -0
  50. iam_validator/core/config/config_loader.py +472 -0
  51. iam_validator/core/config/defaults.py +523 -0
  52. iam_validator/core/config/principal_requirements.py +421 -0
  53. iam_validator/core/config/sensitive_actions.py +672 -0
  54. iam_validator/core/config/service_principals.py +95 -0
  55. iam_validator/core/config/wildcards.py +124 -0
  56. iam_validator/core/constants.py +74 -0
  57. iam_validator/core/formatters/__init__.py +27 -0
  58. iam_validator/core/formatters/base.py +147 -0
  59. iam_validator/core/formatters/console.py +59 -0
  60. iam_validator/core/formatters/csv.py +170 -0
  61. iam_validator/core/formatters/enhanced.py +440 -0
  62. iam_validator/core/formatters/html.py +672 -0
  63. iam_validator/core/formatters/json.py +33 -0
  64. iam_validator/core/formatters/markdown.py +63 -0
  65. iam_validator/core/formatters/sarif.py +251 -0
  66. iam_validator/core/models.py +327 -0
  67. iam_validator/core/policy_checks.py +656 -0
  68. iam_validator/core/policy_loader.py +396 -0
  69. iam_validator/core/pr_commenter.py +424 -0
  70. iam_validator/core/report.py +872 -0
  71. iam_validator/integrations/__init__.py +28 -0
  72. iam_validator/integrations/github_integration.py +815 -0
  73. iam_validator/integrations/ms_teams.py +442 -0
  74. iam_validator/sdk/__init__.py +187 -0
  75. iam_validator/sdk/arn_matching.py +382 -0
  76. iam_validator/sdk/context.py +222 -0
  77. iam_validator/sdk/exceptions.py +48 -0
  78. iam_validator/sdk/helpers.py +177 -0
  79. iam_validator/sdk/policy_utils.py +425 -0
  80. iam_validator/sdk/shortcuts.py +283 -0
  81. iam_validator/utils/__init__.py +31 -0
  82. iam_validator/utils/cache.py +105 -0
  83. 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 = "![Status](https://img.shields.io/badge/AWS%20Access%20Analyzer-passed-success?style=flat-square&logo=amazon-aws)"
283
+ else:
284
+ lines.append("# 🔍 IAM Access Analyzer Validation Results")
285
+ status_badge = "![Status](https://img.shields.io/badge/AWS%20Access%20Analyzer-issues%20found-critical?style=flat-square&logo=amazon-aws)"
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"![{check.result.value}](https://img.shields.io/badge/{check.result.value}-{badge_color})"
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)