iam-policy-validator 1.4.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 (56) hide show
  1. iam_policy_validator-1.4.0.dist-info/METADATA +1022 -0
  2. iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
  3. iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.4.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 +27 -0
  10. iam_validator/checks/action_condition_enforcement.py +727 -0
  11. iam_validator/checks/action_resource_constraint.py +151 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +70 -0
  14. iam_validator/checks/policy_size.py +151 -0
  15. iam_validator/checks/policy_type_validation.py +299 -0
  16. iam_validator/checks/principal_validation.py +282 -0
  17. iam_validator/checks/resource_validation.py +108 -0
  18. iam_validator/checks/security_best_practices.py +536 -0
  19. iam_validator/checks/sid_uniqueness.py +170 -0
  20. iam_validator/checks/utils/__init__.py +1 -0
  21. iam_validator/checks/utils/policy_level_checks.py +143 -0
  22. iam_validator/checks/utils/sensitive_action_matcher.py +252 -0
  23. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  24. iam_validator/commands/__init__.py +25 -0
  25. iam_validator/commands/analyze.py +434 -0
  26. iam_validator/commands/base.py +48 -0
  27. iam_validator/commands/cache.py +392 -0
  28. iam_validator/commands/download_services.py +260 -0
  29. iam_validator/commands/post_to_pr.py +86 -0
  30. iam_validator/commands/validate.py +539 -0
  31. iam_validator/core/__init__.py +14 -0
  32. iam_validator/core/access_analyzer.py +666 -0
  33. iam_validator/core/access_analyzer_report.py +643 -0
  34. iam_validator/core/aws_fetcher.py +880 -0
  35. iam_validator/core/aws_global_conditions.py +137 -0
  36. iam_validator/core/check_registry.py +469 -0
  37. iam_validator/core/cli.py +134 -0
  38. iam_validator/core/config_loader.py +452 -0
  39. iam_validator/core/defaults.py +393 -0
  40. iam_validator/core/formatters/__init__.py +27 -0
  41. iam_validator/core/formatters/base.py +147 -0
  42. iam_validator/core/formatters/console.py +59 -0
  43. iam_validator/core/formatters/csv.py +170 -0
  44. iam_validator/core/formatters/enhanced.py +434 -0
  45. iam_validator/core/formatters/html.py +672 -0
  46. iam_validator/core/formatters/json.py +33 -0
  47. iam_validator/core/formatters/markdown.py +63 -0
  48. iam_validator/core/formatters/sarif.py +187 -0
  49. iam_validator/core/models.py +298 -0
  50. iam_validator/core/policy_checks.py +656 -0
  51. iam_validator/core/policy_loader.py +396 -0
  52. iam_validator/core/pr_commenter.py +338 -0
  53. iam_validator/core/report.py +859 -0
  54. iam_validator/integrations/__init__.py +28 -0
  55. iam_validator/integrations/github_integration.py +795 -0
  56. iam_validator/integrations/ms_teams.py +442 -0
@@ -0,0 +1,643 @@
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
+ "",
342
+ "**🤖 Generated by IAM Policy Validator**",
343
+ "",
344
+ "_Powered by AWS IAM Access Analyzer_",
345
+ "",
346
+ "</div>",
347
+ ]
348
+ footer_content = "\n".join(footer_lines)
349
+
350
+ # Calculate remaining space for details
351
+ base_length = len(header_content) + len(footer_content) + 100 # 100 for safety
352
+ available_length = max_length - base_length
353
+
354
+ # Detailed results
355
+ details_lines = []
356
+ details_lines.append("## 📝 Detailed Results")
357
+ details_lines.append("")
358
+
359
+ truncated = False
360
+ policies_shown = 0
361
+ findings_shown = 0
362
+
363
+ # Sort results to prioritize errors
364
+ sorted_results = sorted(
365
+ [(idx, r) for idx, r in enumerate(report.results, 1)],
366
+ key=lambda x: (
367
+ -x[1].error_count if x[1].error_count else 0,
368
+ -(x[1].error_count + x[1].warning_count + x[1].suggestion_count),
369
+ ),
370
+ )
371
+
372
+ for idx, result in sorted_results:
373
+ status_emoji = "✅" if result.is_valid else "❌"
374
+
375
+ policy_lines = []
376
+ # File header with collapsible section
377
+ policy_lines.append(f"<details {'open' if result.findings else ''}>")
378
+ policy_lines.append(
379
+ f"<summary><b>{idx}. {status_emoji} <code>{result.policy_file}</code></b>"
380
+ )
381
+ if result.findings:
382
+ policy_lines.append(f" - {len(result.findings)} finding(s)")
383
+ policy_lines.append("</summary>")
384
+ policy_lines.append("")
385
+
386
+ if result.error:
387
+ policy_lines.append(f"> ❌ **Error**: {result.error}")
388
+ policy_lines.append("")
389
+ policy_lines.append("</details>")
390
+ policy_lines.append("")
391
+
392
+ # Check if we can fit this
393
+ test_length = len("\n".join(details_lines + policy_lines))
394
+ if test_length > available_length:
395
+ truncated = True
396
+ break
397
+
398
+ details_lines.extend(policy_lines)
399
+ policies_shown += 1
400
+ continue
401
+
402
+ # Add custom check results if present
403
+ if result.custom_checks:
404
+ policy_lines.append("### 🔍 Custom Policy Checks")
405
+ policy_lines.append("")
406
+ for check in result.custom_checks:
407
+ check_lines = self._format_custom_check_markdown(check)
408
+ policy_lines.extend(check_lines)
409
+ policy_lines.append("")
410
+
411
+ if not result.findings:
412
+ policy_lines.append("> ✨ **No findings** - Policy is valid!")
413
+ policy_lines.append("")
414
+ policy_lines.append("</details>")
415
+ policy_lines.append("")
416
+
417
+ # Check if we can fit this
418
+ test_length = len("\n".join(details_lines + policy_lines))
419
+ if test_length > available_length:
420
+ truncated = True
421
+ break
422
+
423
+ details_lines.extend(policy_lines)
424
+ policies_shown += 1
425
+ continue
426
+
427
+ # Group findings by type
428
+ errors = [f for f in result.findings if f.finding_type == FindingType.ERROR]
429
+ warnings = [
430
+ f
431
+ for f in result.findings
432
+ if f.finding_type in (FindingType.WARNING, FindingType.SECURITY_WARNING)
433
+ ]
434
+ suggestions = [f for f in result.findings if f.finding_type == FindingType.SUGGESTION]
435
+
436
+ # Add errors (prioritized)
437
+ if errors:
438
+ policy_lines.append("### 🔴 Errors")
439
+ policy_lines.append("")
440
+ for finding in errors:
441
+ finding_content = self._format_finding_markdown(finding)
442
+ test_length = len("\n".join(details_lines + policy_lines)) + len(
443
+ "\n".join(finding_content)
444
+ )
445
+ if test_length > available_length:
446
+ truncated = True
447
+ break
448
+ policy_lines.extend(finding_content)
449
+ findings_shown += 1
450
+ policy_lines.append("")
451
+
452
+ if truncated:
453
+ break
454
+
455
+ # Add warnings
456
+ if warnings:
457
+ policy_lines.append("### 🟡 Warnings")
458
+ policy_lines.append("")
459
+ for finding in warnings:
460
+ finding_content = self._format_finding_markdown(finding)
461
+ test_length = len("\n".join(details_lines + policy_lines)) + len(
462
+ "\n".join(finding_content)
463
+ )
464
+ if test_length > available_length:
465
+ truncated = True
466
+ break
467
+ policy_lines.extend(finding_content)
468
+ findings_shown += 1
469
+ policy_lines.append("")
470
+
471
+ if truncated:
472
+ break
473
+
474
+ # Add suggestions
475
+ if suggestions:
476
+ policy_lines.append("### 🔵 Suggestions")
477
+ policy_lines.append("")
478
+ for finding in suggestions:
479
+ finding_content = self._format_finding_markdown(finding)
480
+ test_length = len("\n".join(details_lines + policy_lines)) + len(
481
+ "\n".join(finding_content)
482
+ )
483
+ if test_length > available_length:
484
+ truncated = True
485
+ break
486
+ policy_lines.extend(finding_content)
487
+ findings_shown += 1
488
+ policy_lines.append("")
489
+
490
+ if truncated:
491
+ break
492
+
493
+ policy_lines.append("</details>")
494
+ policy_lines.append("")
495
+
496
+ # Check if adding this policy would exceed limit
497
+ test_length = len("\n".join(details_lines + policy_lines))
498
+ if test_length > available_length:
499
+ truncated = True
500
+ break
501
+
502
+ details_lines.extend(policy_lines)
503
+ policies_shown += 1
504
+
505
+ # Add truncation warning if needed
506
+ if truncated:
507
+ remaining_policies = report.total_policies - policies_shown
508
+ remaining_findings = report.total_findings - findings_shown
509
+
510
+ details_lines.append("")
511
+ details_lines.append("> ⚠️ **Output Truncated**")
512
+ details_lines.append(">")
513
+ details_lines.append(
514
+ "> The report was truncated to fit within GitHub's comment size limit."
515
+ )
516
+ details_lines.append(
517
+ f"> **Showing:** {policies_shown} policies with {findings_shown} findings"
518
+ )
519
+ details_lines.append(
520
+ f"> **Remaining:** {remaining_policies} policies with {remaining_findings} findings"
521
+ )
522
+ details_lines.append(">")
523
+ details_lines.append(
524
+ "> 💡 **Tip:** Download the full report using `--output report.json` or `--format markdown --output report.md`"
525
+ )
526
+ details_lines.append("")
527
+
528
+ lines.extend(details_lines)
529
+
530
+ # Add footer
531
+ lines.extend(footer_lines)
532
+
533
+ return "\n".join(lines)
534
+
535
+ def _format_custom_check_markdown(self, check: CustomCheckResult) -> list[str]:
536
+ """Format a custom check result as markdown lines.
537
+
538
+ Args:
539
+ check: The custom check result to format
540
+
541
+ Returns:
542
+ List of markdown lines
543
+ """
544
+ lines = []
545
+
546
+ # Check header with result
547
+ if check.passed:
548
+ icon = "✅"
549
+ badge_color = "green"
550
+ else:
551
+ icon = "❌"
552
+ badge_color = "red"
553
+
554
+ lines.append(
555
+ f"{icon} **{check.check_type}**: "
556
+ f"![{check.result.value}](https://img.shields.io/badge/{check.result.value}-{badge_color})"
557
+ )
558
+ lines.append("")
559
+
560
+ # Message if available
561
+ if check.message:
562
+ lines.append(f"> {check.message}")
563
+ lines.append("")
564
+
565
+ # Reasons if failed
566
+ if check.reasons and not check.passed:
567
+ lines.append("<table>")
568
+ lines.append("<tr><td>")
569
+ lines.append("")
570
+ lines.append("**Reasons:**")
571
+ lines.append("")
572
+ for reason in check.reasons:
573
+ lines.append(f"- {reason.description}")
574
+ if reason.statement_id:
575
+ lines.append(f" - Statement ID: `{reason.statement_id}`")
576
+ if reason.statement_index is not None:
577
+ lines.append(f" - Statement Index: `{reason.statement_index}`")
578
+ lines.append("")
579
+ lines.append("</td></tr>")
580
+ lines.append("</table>")
581
+ lines.append("")
582
+
583
+ return lines
584
+
585
+ def _format_finding_markdown(self, finding: AccessAnalyzerFinding) -> list[str]:
586
+ """Format a single finding as markdown lines.
587
+
588
+ Args:
589
+ finding: The finding to format
590
+
591
+ Returns:
592
+ List of markdown lines
593
+ """
594
+ lines = []
595
+
596
+ # Finding header with code
597
+ lines.append(f"**📍 `{finding.issue_code}`**")
598
+ lines.append("")
599
+
600
+ # Message in blockquote
601
+ lines.append(f"> {finding.message}")
602
+ lines.append("")
603
+
604
+ # Locations if available
605
+ if finding.locations:
606
+ lines.append("<table>")
607
+ lines.append("<tr><td>")
608
+ lines.append("")
609
+ lines.append("**Locations:**")
610
+ lines.append("")
611
+ for loc in finding.locations:
612
+ path = loc.get("path", [])
613
+ span = loc.get("span", {})
614
+ if path:
615
+ path_str = " → ".join(str(p.get("value", p)) for p in path)
616
+ lines.append(f"- 📂 {path_str}")
617
+ if span:
618
+ start = span.get("start", {})
619
+ line_info = f"Line {start.get('line', '?')}"
620
+ if start.get("column"):
621
+ line_info += f", Column {start.get('column')}"
622
+ lines.append(f" - 📍 {line_info}")
623
+ lines.append("")
624
+ lines.append("</td></tr>")
625
+ lines.append("</table>")
626
+ lines.append("")
627
+
628
+ # Learn more link as button-style
629
+ lines.append(f"[📚 Learn more]({finding.learn_more_link})")
630
+ lines.append("")
631
+
632
+ return lines
633
+
634
+ def save_markdown_report(self, report: AccessAnalyzerReport, file_path: str) -> None:
635
+ """Save Markdown report to file.
636
+
637
+ Args:
638
+ report: Access Analyzer validation report
639
+ file_path: Path to save Markdown report
640
+ """
641
+ markdown_content = self.generate_markdown_report(report)
642
+ with open(file_path, "w") as f:
643
+ f.write(markdown_content)