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