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,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
+ "![Status](https://img.shields.io/badge/status-passed-success?style=flat-square)"
382
+ )
383
+ else:
384
+ lines.append("# ๐Ÿšจ IAM Policy Validation Failed")
385
+ status_badge = (
386
+ "![Status](https://img.shields.io/badge/status-failed-critical?style=flat-square)"
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
+ "![Status](https://img.shields.io/badge/status-passed-success?style=flat-square)"
530
+ )
531
+ else:
532
+ lines.append("# ๐Ÿšจ IAM Policy Validation Failed")
533
+ status_badge = (
534
+ "![Status](https://img.shields.io/badge/status-failed-critical?style=flat-square)"
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