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,830 @@
1
+ """Validate command for IAM Policy Validator."""
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ from typing import cast
7
+
8
+ from iam_validator.commands.base import Command
9
+ from iam_validator.core.models import PolicyType, ValidationReport
10
+ from iam_validator.core.policy_checks import validate_policies
11
+ from iam_validator.core.policy_loader import PolicyLoader
12
+ from iam_validator.core.report import ReportGenerator
13
+ from iam_validator.integrations.github_integration import GitHubIntegration
14
+
15
+
16
+ class ValidateCommand(Command):
17
+ """Command to validate IAM policies."""
18
+
19
+ @property
20
+ def name(self) -> str:
21
+ return "validate"
22
+
23
+ @property
24
+ def help(self) -> str:
25
+ return "Validate IAM policies"
26
+
27
+ @property
28
+ def epilog(self) -> str:
29
+ return """
30
+ Examples:
31
+ # Validate a single policy file
32
+ iam-validator validate --path policy.json
33
+
34
+ # Validate all policies in a directory
35
+ iam-validator validate --path ./policies/
36
+
37
+ # Validate multiple paths (files and directories)
38
+ iam-validator validate --path policy1.json --path ./policies/ --path ./more-policies/
39
+
40
+ # Read policy from stdin
41
+ cat policy.json | iam-validator validate --stdin
42
+ echo '{"Version":"2012-10-17","Statement":[...]}' | iam-validator validate --stdin
43
+
44
+ # Use custom checks from a directory
45
+ iam-validator validate --path ./policies/ --custom-checks-dir ./my-checks
46
+
47
+ # Use offline mode with pre-downloaded AWS service definitions
48
+ iam-validator validate --path ./policies/ --aws-services-dir ./aws_services
49
+
50
+ # Generate JSON output
51
+ iam-validator validate --path ./policies/ --format json --output report.json
52
+
53
+ # Validate resource policies (S3 bucket policies, SNS topics, etc.)
54
+ iam-validator validate --path ./bucket-policies/ --policy-type RESOURCE_POLICY
55
+
56
+ # GitHub integration - all options (PR comment + review comments + job summary)
57
+ iam-validator validate --path ./policies/ --github-comment --github-review --github-summary
58
+
59
+ # Only line-specific review comments (clean, minimal)
60
+ iam-validator validate --path ./policies/ --github-review
61
+
62
+ # Only PR summary comment
63
+ iam-validator validate --path ./policies/ --github-comment
64
+
65
+ # Only GitHub Actions job summary
66
+ iam-validator validate --path ./policies/ --github-summary
67
+
68
+ # CI mode: show enhanced output in logs, save JSON to file
69
+ iam-validator validate --path ./policies/ --ci --github-review
70
+ iam-validator validate --path ./policies/ --ci --ci-output results.json
71
+ """
72
+
73
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
74
+ """Add validate command arguments."""
75
+ # Create mutually exclusive group for input sources
76
+ input_group = parser.add_mutually_exclusive_group(required=True)
77
+
78
+ input_group.add_argument(
79
+ "--path",
80
+ "-p",
81
+ action="append",
82
+ dest="paths",
83
+ help="Path to IAM policy file or directory (can be specified multiple times)",
84
+ )
85
+
86
+ input_group.add_argument(
87
+ "--stdin",
88
+ action="store_true",
89
+ help="Read policy from stdin (JSON format)",
90
+ )
91
+
92
+ parser.add_argument(
93
+ "--format",
94
+ "-f",
95
+ choices=["console", "enhanced", "json", "markdown", "html", "csv", "sarif"],
96
+ default="console",
97
+ help="Output format (default: console). Use 'enhanced' for modern visual output with Rich library",
98
+ )
99
+
100
+ parser.add_argument(
101
+ "--output",
102
+ "-o",
103
+ help="Output file path (for json/markdown/html/csv/sarif formats)",
104
+ )
105
+
106
+ parser.add_argument(
107
+ "--no-recursive",
108
+ action="store_true",
109
+ help="Don't recursively search directories",
110
+ )
111
+
112
+ parser.add_argument(
113
+ "--fail-on-warnings",
114
+ action="store_true",
115
+ help="Fail validation if warnings are found (default: only fail on errors)",
116
+ )
117
+
118
+ parser.add_argument(
119
+ "--policy-type",
120
+ "-t",
121
+ choices=[
122
+ "IDENTITY_POLICY",
123
+ "RESOURCE_POLICY",
124
+ "TRUST_POLICY",
125
+ "SERVICE_CONTROL_POLICY",
126
+ "RESOURCE_CONTROL_POLICY",
127
+ ],
128
+ default="IDENTITY_POLICY",
129
+ help="Type of IAM policy being validated (default: IDENTITY_POLICY). "
130
+ "IDENTITY_POLICY: Attached to users/groups/roles | "
131
+ "RESOURCE_POLICY: S3/SNS/SQS policies | "
132
+ "TRUST_POLICY: Role assumption policies | "
133
+ "SERVICE_CONTROL_POLICY: AWS Orgs SCPs | "
134
+ "RESOURCE_CONTROL_POLICY: AWS Orgs RCPs",
135
+ )
136
+
137
+ parser.add_argument(
138
+ "--github-comment",
139
+ action="store_true",
140
+ help="Post summary comment to PR conversation",
141
+ )
142
+
143
+ parser.add_argument(
144
+ "--github-review",
145
+ action="store_true",
146
+ help="Create line-specific review comments on PR files",
147
+ )
148
+
149
+ parser.add_argument(
150
+ "--github-summary",
151
+ action="store_true",
152
+ help="Write summary to GitHub Actions job summary (visible in Actions tab)",
153
+ )
154
+
155
+ parser.add_argument(
156
+ "--verbose",
157
+ "-v",
158
+ action="store_true",
159
+ help="Enable verbose logging",
160
+ )
161
+
162
+ parser.add_argument(
163
+ "--config",
164
+ "-c",
165
+ help="Path to configuration file (default: auto-discover iam-validator.yaml)",
166
+ )
167
+
168
+ parser.add_argument(
169
+ "--custom-checks-dir",
170
+ help="Path to directory containing custom checks for auto-discovery",
171
+ )
172
+
173
+ parser.add_argument(
174
+ "--aws-services-dir",
175
+ help="Path to directory containing pre-downloaded AWS service definitions "
176
+ "(enables offline mode, avoids API rate limiting). "
177
+ "Use 'iam-validator download-services' to create this directory.",
178
+ )
179
+
180
+ parser.add_argument(
181
+ "--stream",
182
+ action="store_true",
183
+ help="Process files one-by-one (memory efficient, progressive feedback)",
184
+ )
185
+
186
+ parser.add_argument(
187
+ "--batch-size",
188
+ type=int,
189
+ default=10,
190
+ help="Number of policies to process per batch (default: 10, only with --stream)",
191
+ )
192
+
193
+ parser.add_argument(
194
+ "--summary",
195
+ action="store_true",
196
+ help="Show Executive Summary section in enhanced format output",
197
+ )
198
+
199
+ parser.add_argument(
200
+ "--severity-breakdown",
201
+ action="store_true",
202
+ help="Show Issue Severity Breakdown section in enhanced format output",
203
+ )
204
+
205
+ parser.add_argument(
206
+ "--allow-owner-ignore",
207
+ action="store_true",
208
+ default=True,
209
+ help="Allow CODEOWNERS to ignore findings by replying 'ignore' to review comments (default: enabled)",
210
+ )
211
+
212
+ parser.add_argument(
213
+ "--no-owner-ignore",
214
+ action="store_true",
215
+ help="Disable CODEOWNERS ignore feature",
216
+ )
217
+
218
+ parser.add_argument(
219
+ "--ci",
220
+ action="store_true",
221
+ help="CI mode: print enhanced console output for visibility in job logs, "
222
+ "and write JSON report to file (use --ci-output to specify filename, "
223
+ "defaults to 'validation-report.json').",
224
+ )
225
+
226
+ parser.add_argument(
227
+ "--ci-output",
228
+ default="validation-report.json",
229
+ help="Output file for JSON report in CI mode (default: validation-report.json)",
230
+ )
231
+
232
+ async def execute(self, args: argparse.Namespace) -> int:
233
+ """Execute the validate command."""
234
+ # Check if streaming mode is enabled
235
+ use_stream = getattr(args, "stream", False)
236
+
237
+ # Auto-enable streaming for CI environments or large policy sets
238
+ # to provide progressive feedback
239
+ if not use_stream and os.getenv("CI"):
240
+ logging.info(
241
+ "CI environment detected, enabling streaming mode for progressive feedback"
242
+ )
243
+ use_stream = True
244
+
245
+ if use_stream:
246
+ return await self._execute_streaming(args)
247
+ else:
248
+ return await self._execute_batch(args)
249
+
250
+ async def _execute_batch(self, args: argparse.Namespace) -> int:
251
+ """Execute validation by loading all policies at once (original behavior)."""
252
+ # Load policies from all specified paths or stdin
253
+ loader = PolicyLoader()
254
+
255
+ if args.stdin:
256
+ # Read from stdin
257
+ import json
258
+ import sys
259
+
260
+ stdin_content = sys.stdin.read()
261
+ if not stdin_content.strip():
262
+ logging.error("No policy data provided on stdin")
263
+ return 1
264
+
265
+ try:
266
+ policy_data = json.loads(stdin_content)
267
+ # Create a synthetic policy entry
268
+ policies = [("stdin", policy_data)]
269
+ logging.info("Loaded policy from stdin")
270
+ except json.JSONDecodeError as e:
271
+ logging.error(f"Invalid JSON from stdin: {e}")
272
+ return 1
273
+ else:
274
+ # Load from paths
275
+ policies = loader.load_from_paths(args.paths, recursive=not args.no_recursive)
276
+
277
+ if not policies:
278
+ logging.error(f"No valid IAM policies found in: {', '.join(args.paths)}")
279
+ return 1
280
+
281
+ logging.info(f"Loaded {len(policies)} policies from {len(args.paths)} path(s)")
282
+
283
+ # Validate policies
284
+ config_path = getattr(args, "config", None)
285
+ custom_checks_dir = getattr(args, "custom_checks_dir", None)
286
+ aws_services_dir = getattr(args, "aws_services_dir", None)
287
+ policy_type = cast(PolicyType, getattr(args, "policy_type", "IDENTITY_POLICY"))
288
+ results = await validate_policies(
289
+ policies,
290
+ config_path=config_path,
291
+ custom_checks_dir=custom_checks_dir,
292
+ policy_type=policy_type,
293
+ aws_services_dir=aws_services_dir,
294
+ )
295
+
296
+ # Generate report (include parsing errors if any)
297
+ generator = ReportGenerator()
298
+ report = generator.generate_report(results, parsing_errors=loader.parsing_errors)
299
+
300
+ # Handle --ci flag: show enhanced output in console, write JSON to file
301
+ ci_mode = getattr(args, "ci", False)
302
+ if ci_mode:
303
+ # CI mode: enhanced output to console, JSON to file
304
+ self._print_ci_console_output(report, generator)
305
+ ci_output_file = getattr(args, "ci_output", "validation-report.json")
306
+ generator.save_json_report(report, ci_output_file)
307
+ logging.info(f"Saved JSON report to {ci_output_file}")
308
+ elif args.format is None:
309
+ # Default: use classic console output (direct Rich printing)
310
+ generator.print_console_report(report)
311
+ elif args.format == "json":
312
+ if args.output:
313
+ generator.save_json_report(report, args.output)
314
+ else:
315
+ print(generator.generate_json_report(report))
316
+ elif args.format == "markdown":
317
+ if args.output:
318
+ generator.save_markdown_report(report, args.output)
319
+ else:
320
+ print(generator.generate_github_comment(report))
321
+ else:
322
+ # Use formatter registry for other formats (enhanced, html, csv, sarif)
323
+ # Pass options for enhanced format
324
+ format_options = {}
325
+ if args.format == "enhanced":
326
+ format_options["show_summary"] = getattr(args, "summary", False)
327
+ format_options["show_severity_breakdown"] = getattr(
328
+ args, "severity_breakdown", False
329
+ )
330
+ output_content = generator.format_report(report, args.format, **format_options)
331
+ if args.output:
332
+ with open(args.output, "w", encoding="utf-8") as f:
333
+ f.write(output_content)
334
+ logging.info(f"Saved {args.format.upper()} report to {args.output}")
335
+ else:
336
+ print(output_content)
337
+
338
+ # Post to GitHub if configured
339
+ if args.github_comment or getattr(args, "github_review", False):
340
+ from iam_validator.core.config.config_loader import ConfigLoader
341
+ from iam_validator.core.pr_commenter import PRCommenter
342
+
343
+ # Load config to get fail_on_severity, severity_labels, and ignore settings
344
+ config = ConfigLoader.load_config(config_path)
345
+ fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
346
+ severity_labels = config.get_setting("severity_labels", {})
347
+
348
+ # Get ignore settings from config, but CLI flag can override
349
+ ignore_settings = config.get_setting("ignore_settings", {})
350
+ enable_ignore = ignore_settings.get("enabled", True)
351
+ # CLI --no-owner-ignore takes precedence
352
+ if getattr(args, "no_owner_ignore", False):
353
+ enable_ignore = False
354
+ allowed_users = ignore_settings.get("allowed_users", [])
355
+
356
+ async with GitHubIntegration() as github:
357
+ commenter = PRCommenter(
358
+ github,
359
+ fail_on_severities=fail_on_severities,
360
+ severity_labels=severity_labels,
361
+ enable_codeowners_ignore=enable_ignore,
362
+ allowed_ignore_users=allowed_users,
363
+ )
364
+ success = await commenter.post_findings_to_pr(
365
+ report,
366
+ create_review=getattr(args, "github_review", False),
367
+ add_summary_comment=args.github_comment,
368
+ )
369
+ if not success:
370
+ logging.error("Failed to post to GitHub PR")
371
+
372
+ # Write to GitHub Actions job summary if configured
373
+ if getattr(args, "github_summary", False):
374
+ self._write_github_actions_summary(report)
375
+
376
+ # Return exit code based on validation results
377
+ if args.fail_on_warnings:
378
+ return 0 if report.total_issues == 0 else 1
379
+ else:
380
+ return 0 if report.invalid_policies == 0 else 1
381
+
382
+ async def _execute_streaming(self, args: argparse.Namespace) -> int:
383
+ """Execute validation by streaming policies one-by-one.
384
+
385
+ This provides:
386
+ - Lower memory usage
387
+ - Progressive feedback (see results as they come)
388
+ - Partial results if errors occur
389
+ - Better for CI/CD pipelines
390
+ """
391
+ loader = PolicyLoader()
392
+ generator = ReportGenerator()
393
+ config_path = getattr(args, "config", None)
394
+ custom_checks_dir = getattr(args, "custom_checks_dir", None)
395
+ policy_type = cast(PolicyType, getattr(args, "policy_type", "IDENTITY_POLICY"))
396
+
397
+ all_results = []
398
+ total_processed = 0
399
+ # Track all validated files across the streaming session for final cleanup
400
+ all_validated_files: set[str] = set()
401
+
402
+ logging.info(f"Starting streaming validation from {len(args.paths)} path(s)")
403
+
404
+ # Process policies one at a time
405
+ for file_path, policy in loader.stream_from_paths(
406
+ args.paths, recursive=not args.no_recursive
407
+ ):
408
+ total_processed += 1
409
+ logging.info(f"[{total_processed}] Processing: {file_path}")
410
+
411
+ # Validate single policy
412
+ results = await validate_policies(
413
+ [(file_path, policy)],
414
+ config_path=config_path,
415
+ custom_checks_dir=custom_checks_dir,
416
+ policy_type=policy_type,
417
+ )
418
+
419
+ if results:
420
+ result = results[0]
421
+ all_results.append(result)
422
+
423
+ # Track validated file (convert to relative path for cleanup)
424
+ relative_path = self._make_relative_path(file_path)
425
+ if relative_path:
426
+ all_validated_files.add(relative_path)
427
+
428
+ # Print immediate feedback for this file
429
+ if args.format == "console":
430
+ if result.is_valid:
431
+ logging.info(f" ✓ {file_path}: Valid")
432
+ else:
433
+ logging.warning(f" ✗ {file_path}: {len(result.issues)} issue(s) found")
434
+ # Note: validation_success tracks overall status
435
+
436
+ # Post to GitHub immediately for this file (progressive PR comments)
437
+ # skip_cleanup=True because we process files one at a time and don't want
438
+ # to delete comments from files processed earlier. Cleanup runs at the end.
439
+ if getattr(args, "github_review", False):
440
+ await self._post_file_review(result, args)
441
+
442
+ if total_processed == 0:
443
+ logging.error(f"No valid IAM policies found in: {', '.join(args.paths)}")
444
+ return 1
445
+
446
+ logging.info(f"\nCompleted validation of {total_processed} policies")
447
+
448
+ # Run final cleanup after all files are processed
449
+ # This uses the full report to know all current findings and deletes stale comments
450
+ if getattr(args, "github_review", False):
451
+ await self._run_final_review_cleanup(args, all_results, all_validated_files)
452
+
453
+ # Generate final summary report
454
+ report = generator.generate_report(all_results)
455
+
456
+ # Handle --ci flag: show enhanced output in console, write JSON to file
457
+ ci_mode = getattr(args, "ci", False)
458
+ if ci_mode:
459
+ # CI mode: enhanced output to console, JSON to file
460
+ self._print_ci_console_output(report, generator)
461
+ ci_output_file = getattr(args, "ci_output", "validation-report.json")
462
+ generator.save_json_report(report, ci_output_file)
463
+ logging.info(f"Saved JSON report to {ci_output_file}")
464
+ elif args.format == "console":
465
+ # Classic console output (direct Rich printing from report.py)
466
+ generator.print_console_report(report)
467
+ elif args.format == "json":
468
+ if args.output:
469
+ generator.save_json_report(report, args.output)
470
+ else:
471
+ print(generator.generate_json_report(report))
472
+ elif args.format == "markdown":
473
+ if args.output:
474
+ generator.save_markdown_report(report, args.output)
475
+ else:
476
+ print(generator.generate_github_comment(report))
477
+ else:
478
+ # Use formatter registry for other formats (enhanced, html, csv, sarif)
479
+ # Pass options for enhanced format
480
+ format_options = {}
481
+ if args.format == "enhanced":
482
+ format_options["show_summary"] = getattr(args, "summary", False)
483
+ format_options["show_severity_breakdown"] = getattr(
484
+ args, "severity_breakdown", False
485
+ )
486
+ output_content = generator.format_report(report, args.format, **format_options)
487
+ if args.output:
488
+ with open(args.output, "w", encoding="utf-8") as f:
489
+ f.write(output_content)
490
+ logging.info(f"Saved {args.format.upper()} report to {args.output}")
491
+ else:
492
+ print(output_content)
493
+
494
+ # Post summary comment to GitHub (if requested and not already posted per-file reviews)
495
+ if args.github_comment:
496
+ from iam_validator.core.config.config_loader import ConfigLoader
497
+ from iam_validator.core.pr_commenter import PRCommenter
498
+
499
+ # Load config to get fail_on_severity, severity_labels, and ignore settings
500
+ config = ConfigLoader.load_config(config_path)
501
+ fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
502
+ severity_labels = config.get_setting("severity_labels", {})
503
+
504
+ # Get ignore settings from config, but CLI flag can override
505
+ ignore_settings = config.get_setting("ignore_settings", {})
506
+ enable_ignore = ignore_settings.get("enabled", True)
507
+ # CLI --no-owner-ignore takes precedence
508
+ if getattr(args, "no_owner_ignore", False):
509
+ enable_ignore = False
510
+ allowed_users = ignore_settings.get("allowed_users", [])
511
+
512
+ async with GitHubIntegration() as github:
513
+ commenter = PRCommenter(
514
+ github,
515
+ fail_on_severities=fail_on_severities,
516
+ severity_labels=severity_labels,
517
+ enable_codeowners_ignore=enable_ignore,
518
+ allowed_ignore_users=allowed_users,
519
+ )
520
+ success = await commenter.post_findings_to_pr(
521
+ report,
522
+ create_review=False, # Already posted per-file reviews in streaming mode
523
+ add_summary_comment=True,
524
+ )
525
+ if not success:
526
+ logging.error("Failed to post summary to GitHub PR")
527
+
528
+ # Write to GitHub Actions job summary if configured
529
+ if getattr(args, "github_summary", False):
530
+ self._write_github_actions_summary(report)
531
+
532
+ # Return exit code based on validation results
533
+ if args.fail_on_warnings:
534
+ return 0 if report.total_issues == 0 else 1
535
+ else:
536
+ return 0 if report.invalid_policies == 0 else 1
537
+
538
+ async def _cleanup_old_comments(self, args: argparse.Namespace) -> None:
539
+ """Clean up old bot review comments from previous validation runs.
540
+
541
+ Note: This method is kept for backward compatibility but cleanup is now handled
542
+ automatically by update_or_create_review_comments(). It will update existing
543
+ comments, create new ones, and delete resolved ones smartly.
544
+
545
+ Args:
546
+ args: Command-line arguments (kept for compatibility)
547
+ """
548
+ # Cleanup is now handled automatically by update_or_create_review_comments()
549
+ # No action needed here
550
+ logging.debug("Comment cleanup will be handled automatically during review posting")
551
+
552
+ async def _post_file_review(self, result, args: argparse.Namespace) -> None:
553
+ """Post review comments for a single file immediately.
554
+
555
+ This provides progressive feedback in PRs as files are processed.
556
+ """
557
+ try:
558
+ from iam_validator.core.config.config_loader import ConfigLoader
559
+ from iam_validator.core.pr_commenter import PRCommenter
560
+
561
+ async with GitHubIntegration() as github:
562
+ if not github.is_configured():
563
+ return
564
+
565
+ # Load config to get fail_on_severity and ignore settings
566
+ config_path = getattr(args, "config", None)
567
+ config = ConfigLoader.load_config(config_path)
568
+ fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
569
+
570
+ # Get ignore settings from config, but CLI flag can override
571
+ ignore_settings = config.get_setting("ignore_settings", {})
572
+ enable_ignore = ignore_settings.get("enabled", True)
573
+ # CLI --no-owner-ignore takes precedence
574
+ if getattr(args, "no_owner_ignore", False):
575
+ enable_ignore = False
576
+ allowed_users = ignore_settings.get("allowed_users", [])
577
+
578
+ # In streaming mode, don't cleanup comments (we want to keep earlier files)
579
+ # Cleanup will happen once at the end
580
+ commenter = PRCommenter(
581
+ github,
582
+ cleanup_old_comments=False,
583
+ fail_on_severities=fail_on_severities,
584
+ enable_codeowners_ignore=enable_ignore,
585
+ allowed_ignore_users=allowed_users,
586
+ )
587
+
588
+ # Create a mini-report for just this file
589
+ generator = ReportGenerator()
590
+ mini_report = generator.generate_report([result])
591
+
592
+ # Post line-specific comments (skip cleanup - runs at end of streaming)
593
+ await commenter.post_findings_to_pr(
594
+ mini_report,
595
+ create_review=True,
596
+ add_summary_comment=False, # Summary comes later
597
+ )
598
+ except Exception as e:
599
+ logging.warning(f"Failed to post review for {result.policy_file}: {e}")
600
+
601
+ def _make_relative_path(self, file_path: str) -> str | None:
602
+ """Convert absolute path to relative path for GitHub.
603
+
604
+ Args:
605
+ file_path: Absolute or relative path to file
606
+
607
+ Returns:
608
+ Relative path from repository root, or None if cannot be determined
609
+ """
610
+ from pathlib import Path
611
+
612
+ # If already relative, use as-is
613
+ if not os.path.isabs(file_path):
614
+ return file_path
615
+
616
+ # Try to get workspace path from environment
617
+ workspace = os.getenv("GITHUB_WORKSPACE")
618
+ if workspace:
619
+ try:
620
+ abs_file_path = Path(file_path).resolve()
621
+ workspace_path = Path(workspace).resolve()
622
+
623
+ if abs_file_path.is_relative_to(workspace_path):
624
+ relative = abs_file_path.relative_to(workspace_path)
625
+ return str(relative).replace("\\", "/")
626
+ except (ValueError, OSError) as exc:
627
+ logging.debug(f"Could not make path relative to GitHub workspace: {exc}")
628
+
629
+ # Fallback: try current working directory
630
+ try:
631
+ cwd = Path.cwd()
632
+ abs_file_path = Path(file_path).resolve()
633
+ if abs_file_path.is_relative_to(cwd):
634
+ relative = abs_file_path.relative_to(cwd)
635
+ return str(relative).replace("\\", "/")
636
+ except (ValueError, OSError) as exc:
637
+ logging.debug(f"Could not make path relative to cwd: {exc}")
638
+
639
+ return None
640
+
641
+ async def _run_final_review_cleanup(
642
+ self,
643
+ args: argparse.Namespace,
644
+ all_results: list,
645
+ all_validated_files: set[str],
646
+ ) -> None:
647
+ """Run final cleanup after all files are processed in streaming mode.
648
+
649
+ This deletes stale comments for findings that are no longer present,
650
+ using the complete set of validated files and current findings.
651
+
652
+ Args:
653
+ args: Command-line arguments
654
+ all_results: All validation results from the streaming session
655
+ all_validated_files: Set of all validated file paths (relative)
656
+ """
657
+ try:
658
+ from iam_validator.core.config.config_loader import ConfigLoader
659
+ from iam_validator.core.pr_commenter import PRCommenter
660
+
661
+ async with GitHubIntegration() as github:
662
+ if not github.is_configured():
663
+ return
664
+
665
+ # Load config
666
+ config_path = getattr(args, "config", None)
667
+ config = ConfigLoader.load_config(config_path)
668
+ fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
669
+
670
+ # Get ignore settings
671
+ ignore_settings = config.get_setting("ignore_settings", {})
672
+ enable_ignore = ignore_settings.get("enabled", True)
673
+ if getattr(args, "no_owner_ignore", False):
674
+ enable_ignore = False
675
+ allowed_users = ignore_settings.get("allowed_users", [])
676
+
677
+ # Create commenter WITH cleanup enabled for the final pass
678
+ commenter = PRCommenter(
679
+ github,
680
+ cleanup_old_comments=True, # Enable cleanup for final pass
681
+ fail_on_severities=fail_on_severities,
682
+ enable_codeowners_ignore=enable_ignore,
683
+ allowed_ignore_users=allowed_users,
684
+ )
685
+
686
+ # Create a full report with all results
687
+ generator = ReportGenerator()
688
+ full_report = generator.generate_report(all_results)
689
+
690
+ # Post with create_review=True to run the full update/create/delete logic
691
+ # but pass all_validated_files so cleanup knows the full scope
692
+ logging.info("Running final comment cleanup...")
693
+ await commenter.post_findings_to_pr(
694
+ full_report,
695
+ create_review=True,
696
+ add_summary_comment=False,
697
+ manage_labels=False, # Labels are managed separately
698
+ process_ignores=False, # Already processed per-file
699
+ )
700
+
701
+ except Exception as e:
702
+ logging.warning(f"Failed to run final review cleanup: {e}")
703
+
704
+ def _write_github_actions_summary(self, report: ValidationReport) -> None:
705
+ """Write a high-level summary to GitHub Actions job summary.
706
+
707
+ This appears in the Actions tab and provides a quick overview without all details.
708
+ Uses GITHUB_STEP_SUMMARY environment variable.
709
+
710
+ Args:
711
+ report: Validation report to summarize
712
+ """
713
+ summary_file = os.getenv("GITHUB_STEP_SUMMARY")
714
+ if not summary_file:
715
+ logging.warning(
716
+ "--github-summary specified but GITHUB_STEP_SUMMARY env var not found. "
717
+ "This feature only works in GitHub Actions."
718
+ )
719
+ return
720
+
721
+ try:
722
+ # Generate high-level summary (no detailed issue list)
723
+ summary_parts = []
724
+
725
+ # Header with status
726
+ if report.total_issues == 0:
727
+ summary_parts.append("# ✅ IAM Policy Validation - Passed")
728
+ elif report.invalid_policies > 0:
729
+ summary_parts.append("# ❌ IAM Policy Validation - Failed")
730
+ else:
731
+ summary_parts.append("# âš ī¸ IAM Policy Validation - Security Issues Found")
732
+
733
+ summary_parts.append("")
734
+
735
+ # Summary table
736
+ summary_parts.append("## Summary")
737
+ summary_parts.append("")
738
+ summary_parts.append("| Metric | Count |")
739
+ summary_parts.append("|--------|-------|")
740
+ summary_parts.append(f"| Total Policies | {report.total_policies} |")
741
+ summary_parts.append(f"| Valid Policies | {report.valid_policies} |")
742
+ summary_parts.append(f"| Invalid Policies | {report.invalid_policies} |")
743
+ summary_parts.append(
744
+ f"| Policies with Security Issues | {report.policies_with_security_issues} |"
745
+ )
746
+ summary_parts.append(f"| **Total Issues** | **{report.total_issues}** |")
747
+
748
+ # Issue breakdown by severity if there are issues
749
+ if report.total_issues > 0:
750
+ summary_parts.append("")
751
+ summary_parts.append("## 📊 Issues by Severity")
752
+ summary_parts.append("")
753
+
754
+ # Count issues by severity
755
+ severity_counts: dict[str, int] = {}
756
+ for result in report.results:
757
+ for issue in result.issues:
758
+ severity_counts[issue.severity] = severity_counts.get(issue.severity, 0) + 1
759
+
760
+ # Sort by severity rank (highest first)
761
+ from iam_validator.core.models import ValidationIssue
762
+
763
+ sorted_severities = sorted(
764
+ severity_counts.items(),
765
+ key=lambda x: ValidationIssue.SEVERITY_RANK.get(x[0], 0),
766
+ reverse=True,
767
+ )
768
+
769
+ summary_parts.append("| Severity | Count |")
770
+ summary_parts.append("|----------|-------|")
771
+ for severity, count in sorted_severities:
772
+ emoji = {
773
+ "error": "❌",
774
+ "critical": "🔴",
775
+ "high": "🟠",
776
+ "warning": "âš ī¸",
777
+ "medium": "🟡",
778
+ "low": "đŸ”ĩ",
779
+ "info": "â„šī¸",
780
+ }.get(severity, "â€ĸ")
781
+ summary_parts.append(f"| {emoji} {severity.upper()} | {count} |")
782
+
783
+ # Add footer with links
784
+ summary_parts.append("")
785
+ summary_parts.append("---")
786
+ summary_parts.append("")
787
+ summary_parts.append(
788
+ "📝 For detailed findings, check the PR comments or review the workflow logs."
789
+ )
790
+
791
+ # Write to summary file (append mode)
792
+ with open(summary_file, "a", encoding="utf-8") as f:
793
+ f.write("\n".join(summary_parts))
794
+ f.write("\n")
795
+
796
+ logging.info("Wrote summary to GitHub Actions job summary")
797
+
798
+ except Exception as e:
799
+ logging.warning(f"Failed to write GitHub Actions summary: {e}")
800
+
801
+ def _print_ci_console_output(
802
+ self, report: ValidationReport, generator: ReportGenerator
803
+ ) -> None:
804
+ """Print enhanced console output for CI visibility.
805
+
806
+ This shows validation results in the CI job logs in a human-readable format.
807
+ JSON output is written to a separate file (specified by --ci-output).
808
+
809
+ Args:
810
+ report: Validation report to print
811
+ generator: ReportGenerator instance
812
+ """
813
+ # Generate enhanced format output with summary and severity breakdown
814
+ try:
815
+ enhanced_output = generator.format_report(
816
+ report,
817
+ "enhanced",
818
+ show_summary=True,
819
+ show_severity_breakdown=True,
820
+ )
821
+ print(enhanced_output)
822
+
823
+ except Exception as e:
824
+ # Fallback to basic summary if enhanced format fails
825
+ logging.warning(f"Failed to generate enhanced output: {e}")
826
+ print("\nValidation Summary:")
827
+ print(f" Total policies: {report.total_policies}")
828
+ print(f" Valid: {report.valid_policies}")
829
+ print(f" Invalid: {report.invalid_policies}")
830
+ print(f" Total issues: {report.total_issues}\n")