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,780 @@
1
+ """PR Comment Module.
2
+
3
+ This module handles posting validation findings as PR comments.
4
+ It reads a JSON report and posts line-specific comments to GitHub PRs.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from typing import Any
10
+
11
+ from iam_validator.core.constants import (
12
+ BOT_IDENTIFIER,
13
+ REVIEW_IDENTIFIER,
14
+ SUMMARY_IDENTIFIER,
15
+ )
16
+ from iam_validator.core.diff_parser import DiffParser
17
+ from iam_validator.core.label_manager import LabelManager
18
+ from iam_validator.core.models import ValidationIssue, ValidationReport
19
+ from iam_validator.core.policy_loader import PolicyLineMap, PolicyLoader
20
+ from iam_validator.core.report import ReportGenerator
21
+ from iam_validator.integrations.github_integration import GitHubIntegration, ReviewEvent
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class ContextIssue:
27
+ """Represents an issue in a modified statement but on an unchanged line.
28
+
29
+ These issues are shown in the summary comment rather than as inline comments,
30
+ since GitHub only allows comments on lines that appear in the PR diff.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ file_path: str,
36
+ statement_index: int,
37
+ line_number: int,
38
+ issue: ValidationIssue,
39
+ ):
40
+ """Initialize context issue.
41
+
42
+ Args:
43
+ file_path: Relative path to the file
44
+ statement_index: Zero-based statement index
45
+ line_number: Line number where the issue exists
46
+ issue: The validation issue
47
+ """
48
+ self.file_path = file_path
49
+ self.statement_index = statement_index
50
+ self.line_number = line_number
51
+ self.issue = issue
52
+
53
+
54
+ class PRCommenter:
55
+ """Posts validation findings as PR comments."""
56
+
57
+ # Load identifiers from constants module for consistency
58
+ BOT_IDENTIFIER = BOT_IDENTIFIER
59
+ SUMMARY_IDENTIFIER = SUMMARY_IDENTIFIER
60
+ REVIEW_IDENTIFIER = REVIEW_IDENTIFIER
61
+
62
+ def __init__(
63
+ self,
64
+ github: GitHubIntegration | None = None,
65
+ cleanup_old_comments: bool = True,
66
+ fail_on_severities: list[str] | None = None,
67
+ severity_labels: dict[str, str | list[str]] | None = None,
68
+ enable_codeowners_ignore: bool = True,
69
+ allowed_ignore_users: list[str] | None = None,
70
+ ):
71
+ """Initialize PR commenter.
72
+
73
+ Args:
74
+ github: GitHubIntegration instance (will create one if None)
75
+ cleanup_old_comments: Whether to clean up old bot comments after posting new ones.
76
+ Set to False in streaming mode where files are processed one at a time
77
+ to avoid deleting comments from files processed earlier.
78
+ fail_on_severities: List of severity levels that should trigger REQUEST_CHANGES
79
+ (e.g., ["error", "critical", "high"])
80
+ severity_labels: Mapping of severity levels to label name(s) for automatic label management
81
+ Supports both single labels and lists of labels per severity.
82
+ Examples:
83
+ - Single: {"error": "iam-validity-error", "critical": "security-critical"}
84
+ - Multiple: {"error": ["iam-error", "needs-fix"], "critical": ["security-critical", "needs-review"]}
85
+ - Mixed: {"error": "iam-validity-error", "critical": ["security-critical", "needs-review"]}
86
+ enable_codeowners_ignore: Whether to enable CODEOWNERS-based ignore feature
87
+ allowed_ignore_users: Fallback users who can ignore findings when no CODEOWNERS
88
+ """
89
+ self.github = github
90
+ self.cleanup_old_comments = cleanup_old_comments
91
+ self.fail_on_severities = fail_on_severities or ["error", "critical"]
92
+ self.severity_labels = severity_labels or {}
93
+ self.enable_codeowners_ignore = enable_codeowners_ignore
94
+ self.allowed_ignore_users = allowed_ignore_users or []
95
+ # Track issues in modified statements that are on unchanged lines
96
+ self._context_issues: list[ContextIssue] = []
97
+ # Track ignored finding IDs for the current run
98
+ self._ignored_finding_ids: frozenset[str] = frozenset()
99
+ # Cache for PolicyLineMap per file (for field-level line detection)
100
+ self._policy_line_maps: dict[str, PolicyLineMap] = {}
101
+
102
+ async def post_findings_to_pr(
103
+ self,
104
+ report: ValidationReport,
105
+ create_review: bool = True,
106
+ add_summary_comment: bool = True,
107
+ manage_labels: bool = True,
108
+ process_ignores: bool = True,
109
+ ) -> bool:
110
+ """Post validation findings to a PR.
111
+
112
+ Args:
113
+ report: Validation report with findings
114
+ create_review: Whether to create a PR review with line comments
115
+ add_summary_comment: Whether to add a summary comment
116
+ manage_labels: Whether to manage PR labels based on severity findings
117
+ process_ignores: Whether to process pending ignore commands
118
+
119
+ Returns:
120
+ True if successful, False otherwise
121
+ """
122
+ if self.github is None:
123
+ self.github = GitHubIntegration()
124
+
125
+ if not self.github.is_configured():
126
+ logger.error(
127
+ "GitHub integration not configured. "
128
+ "Required: GITHUB_TOKEN, GITHUB_REPOSITORY, and GITHUB_PR_NUMBER environment variables. "
129
+ "Ensure your workflow is triggered by a pull_request event."
130
+ )
131
+ return False
132
+
133
+ success = True
134
+
135
+ # Process pending ignore commands first (if enabled)
136
+ if process_ignores and self.enable_codeowners_ignore:
137
+ await self._process_ignore_commands()
138
+
139
+ # Load ignored findings for filtering
140
+ if self.enable_codeowners_ignore:
141
+ await self._load_ignored_findings()
142
+
143
+ # Note: Cleanup is now handled smartly by update_or_create_review_comments()
144
+ # It will update existing comments, create new ones, and delete resolved ones
145
+
146
+ # Post line-specific review comments FIRST
147
+ # (This populates self._context_issues)
148
+ if create_review:
149
+ if not await self._post_review_comments(report):
150
+ logger.error("Failed to post review comments")
151
+ success = False
152
+
153
+ # Post summary comment (potentially as multiple parts)
154
+ if add_summary_comment:
155
+ generator = ReportGenerator()
156
+ # Pass ignored count to show in summary
157
+ ignored_count = len(self._ignored_finding_ids) if self._ignored_finding_ids else 0
158
+ comment_parts = generator.generate_github_comment_parts(
159
+ report, ignored_count=ignored_count
160
+ )
161
+
162
+ # Post all parts using the multipart method
163
+ if not await self.github.post_multipart_comments(
164
+ comment_parts, self.SUMMARY_IDENTIFIER
165
+ ):
166
+ logger.error("Failed to post summary comment(s)")
167
+ success = False
168
+ else:
169
+ if len(comment_parts) > 1:
170
+ logger.info(f"Posted summary in {len(comment_parts)} parts")
171
+ else:
172
+ logger.info("Posted summary comment")
173
+
174
+ # Manage PR labels based on severity findings
175
+ if manage_labels and self.severity_labels:
176
+ label_manager = LabelManager(self.github, self.severity_labels)
177
+ label_success, added, removed = await label_manager.manage_labels_from_report(report)
178
+
179
+ if not label_success:
180
+ logger.error("Failed to manage PR labels")
181
+ success = False
182
+ else:
183
+ if added > 0 or removed > 0:
184
+ logger.info(f"Label management: added {added}, removed {removed}")
185
+
186
+ return success
187
+
188
+ async def _post_review_comments(self, report: ValidationReport) -> bool:
189
+ """Post line-specific review comments with strict diff filtering.
190
+
191
+ Only posts comments on lines that were actually changed in the PR.
192
+ Issues in modified statements but on unchanged lines are tracked in
193
+ self._context_issues for inclusion in the summary comment.
194
+
195
+ Args:
196
+ report: Validation report
197
+
198
+ Returns:
199
+ True if successful, False otherwise
200
+ """
201
+ if not self.github:
202
+ return False
203
+
204
+ # Clear context issues from previous runs
205
+ self._context_issues = []
206
+
207
+ # Fetch PR diff information
208
+ logger.info("Fetching PR diff information for strict filtering...")
209
+ pr_files = await self.github.get_pr_files()
210
+ if not pr_files:
211
+ logger.warning(
212
+ "Could not fetch PR diff information. "
213
+ "Falling back to unfiltered commenting (may fail if lines not in diff)."
214
+ )
215
+ parsed_diffs = {}
216
+ else:
217
+ parsed_diffs = DiffParser.parse_pr_files(pr_files)
218
+ # Use warning level for diagnostics to ensure visibility
219
+ logger.warning(
220
+ f"[DIFF] Parsed diffs for {len(parsed_diffs)} file(s): {list(parsed_diffs.keys())}"
221
+ )
222
+
223
+ # Collect ALL validated files (for cleanup of resolved findings)
224
+ # This includes files with no issues - we need to track them so stale comments get deleted
225
+ validated_files: set[str] = set()
226
+ for result in report.results:
227
+ relative_path = self._make_relative_path(result.policy_file)
228
+ if relative_path:
229
+ validated_files.add(relative_path)
230
+
231
+ logger.debug(f"Tracking {len(validated_files)} validated files for comment cleanup")
232
+
233
+ # Group issues by file
234
+ inline_comments: list[dict[str, Any]] = []
235
+ context_issue_count = 0
236
+
237
+ for result in report.results:
238
+ if not result.issues:
239
+ continue
240
+
241
+ # Convert absolute path to relative path for GitHub
242
+ relative_path = self._make_relative_path(result.policy_file)
243
+ if not relative_path:
244
+ logger.warning(
245
+ f"Could not determine relative path for {result.policy_file}, skipping review comments"
246
+ )
247
+ continue
248
+
249
+ # Use warning level for path diagnostics to ensure visibility
250
+ logger.warning(f"[PATH] Processing: {result.policy_file} -> '{relative_path}'")
251
+
252
+ # Get diff info for this file
253
+ diff_info = parsed_diffs.get(relative_path)
254
+ if not diff_info:
255
+ # Log ALL available paths to help diagnose path mismatches
256
+ all_paths = list(parsed_diffs.keys())
257
+ logger.warning(
258
+ f"'{relative_path}' not found in PR diff. "
259
+ f"Available paths ({len(all_paths)}): {all_paths}"
260
+ )
261
+ # Check for partial matches to help diagnose
262
+ for avail_path in all_paths:
263
+ if relative_path.endswith(avail_path.split("/")[-1]):
264
+ logger.warning(
265
+ f" Possible match by filename: '{avail_path}' "
266
+ f"(basename matches '{relative_path.split('/')[-1]}')"
267
+ )
268
+ # Still process issues for summary (excluding ignored)
269
+ for issue in result.issues:
270
+ # Skip ignored issues
271
+ if self._is_issue_ignored(issue, relative_path):
272
+ continue
273
+ if issue.statement_index is not None:
274
+ line_num = self._find_issue_line(
275
+ issue, result.policy_file, self._get_line_mapping(result.policy_file)
276
+ )
277
+ if line_num:
278
+ self._context_issues.append(
279
+ ContextIssue(relative_path, issue.statement_index, line_num, issue)
280
+ )
281
+ context_issue_count += 1
282
+ continue
283
+
284
+ # Get line mapping and modified statements for this file
285
+ line_mapping = self._get_line_mapping(result.policy_file)
286
+ modified_statements = DiffParser.get_modified_statements(
287
+ line_mapping, diff_info.changed_lines, result.policy_file
288
+ )
289
+
290
+ # Check if this file has no patch (large file or GitHub truncated the diff)
291
+ # In this case, we allow inline comments on any line since the file is in the PR
292
+ allow_all_lines = diff_info.status.endswith("_no_patch")
293
+ if allow_all_lines:
294
+ logger.warning(
295
+ f"[MATCH] {relative_path}: No patch available (status={diff_info.status}), "
296
+ "allowing inline comments on any line"
297
+ )
298
+ else:
299
+ logger.warning(
300
+ f"[MATCH] {relative_path}: FOUND in diff with {len(diff_info.changed_lines)} changed lines, "
301
+ f"{len(modified_statements)} modified statements, status={diff_info.status}"
302
+ )
303
+
304
+ # Process each issue with filtering (relaxed for no_patch files)
305
+ for issue in result.issues:
306
+ # Skip ignored issues
307
+ if self._is_issue_ignored(issue, relative_path):
308
+ logger.debug(f"Skipped ignored issue in {relative_path}: {issue.issue_type}")
309
+ continue
310
+
311
+ line_number = self._find_issue_line(issue, result.policy_file, line_mapping)
312
+
313
+ if not line_number:
314
+ logger.debug(
315
+ f"Could not determine line number for issue in {relative_path}: {issue.issue_type}"
316
+ )
317
+ continue
318
+
319
+ # SPECIAL CASE: Policy-level issues (privilege escalation, etc.)
320
+ # Post to first available line in diff, preferring line 1 if available
321
+ if issue.statement_index == -1:
322
+ # Try to find the best line to post the comment
323
+ comment_line = None
324
+
325
+ if allow_all_lines:
326
+ # No patch - post at the actual line
327
+ comment_line = line_number
328
+ elif line_number in diff_info.changed_lines:
329
+ # Best case: line 1 is in the diff
330
+ comment_line = line_number
331
+ elif diff_info.changed_lines:
332
+ # Fallback: use the first changed line in the file
333
+ # This ensures policy-level issues always appear as inline comments
334
+ comment_line = min(diff_info.changed_lines)
335
+ logger.debug(
336
+ f"Policy-level issue at line {line_number}, posting to first changed line {comment_line}"
337
+ )
338
+
339
+ if comment_line:
340
+ # Post as inline comment at the determined line
341
+ inline_comments.append(
342
+ {
343
+ "path": relative_path,
344
+ "line": comment_line,
345
+ "body": issue.to_pr_comment(file_path=relative_path),
346
+ }
347
+ )
348
+ logger.debug(
349
+ f"Policy-level inline comment: {relative_path}:{comment_line} - {issue.issue_type}"
350
+ )
351
+ else:
352
+ # No changed lines in file - add to summary comment
353
+ self._context_issues.append(
354
+ ContextIssue(relative_path, issue.statement_index, line_number, issue)
355
+ )
356
+ context_issue_count += 1
357
+ logger.debug(
358
+ f"Policy-level issue (no diff lines): {relative_path} - {issue.issue_type}"
359
+ )
360
+ # RELAXED FILTERING for no_patch files, STRICT for others
361
+ elif allow_all_lines or line_number in diff_info.changed_lines:
362
+ # No patch: allow all lines, or exact match with changed lines
363
+ inline_comments.append(
364
+ {
365
+ "path": relative_path,
366
+ "line": line_number,
367
+ "body": issue.to_pr_comment(file_path=relative_path),
368
+ }
369
+ )
370
+ logger.debug(
371
+ f"Inline comment: {relative_path}:{line_number} - {issue.issue_type}"
372
+ f"{' (no_patch)' if allow_all_lines else ''}"
373
+ )
374
+ elif issue.statement_index in modified_statements:
375
+ # Issue in modified statement but on unchanged line - save for summary
376
+ self._context_issues.append(
377
+ ContextIssue(relative_path, issue.statement_index, line_number, issue)
378
+ )
379
+ context_issue_count += 1
380
+ logger.debug(
381
+ f"Context issue: {relative_path}:{line_number} (statement {issue.statement_index} modified) - {issue.issue_type}"
382
+ )
383
+ else:
384
+ # Issue in completely unchanged statement - ignore for inline and summary
385
+ logger.debug(
386
+ f"Skipped issue in unchanged statement: {relative_path}:{line_number} - {issue.issue_type}"
387
+ )
388
+
389
+ # Log filtering results
390
+ logger.info(
391
+ f"Diff filtering results: {len(inline_comments)} inline comments, "
392
+ f"{context_issue_count} context issues for summary"
393
+ )
394
+
395
+ # Even if no inline comments, we still need to run cleanup to delete stale comments
396
+ # from previous runs where findings have been resolved (unless cleanup is disabled)
397
+ if not inline_comments:
398
+ logger.info("No inline comments to post (after diff filtering)")
399
+ # Still run cleanup to delete any stale comments from resolved findings
400
+ # (unless skip_cleanup is set for streaming mode)
401
+ if validated_files and self.cleanup_old_comments:
402
+ logger.debug("Running cleanup for stale comments from resolved findings...")
403
+ await self.github.update_or_create_review_comments(
404
+ comments=[],
405
+ body="",
406
+ event=ReviewEvent.COMMENT,
407
+ identifier=self.REVIEW_IDENTIFIER,
408
+ validated_files=validated_files,
409
+ skip_cleanup=False, # Explicitly run cleanup
410
+ )
411
+ return True
412
+
413
+ # Determine review event based on fail_on_severities config
414
+ # Exclude ignored findings from blocking issues
415
+ has_blocking_issues = any(
416
+ issue.severity in self.fail_on_severities
417
+ and not self._is_issue_ignored(
418
+ issue, self._make_relative_path(result.policy_file) or ""
419
+ )
420
+ for result in report.results
421
+ for issue in result.issues
422
+ )
423
+
424
+ event = ReviewEvent.REQUEST_CHANGES if has_blocking_issues else ReviewEvent.COMMENT
425
+ logger.info(
426
+ f"Creating PR review with {len(inline_comments)} comments, event: {event.value}"
427
+ )
428
+
429
+ # Post review with smart update-or-create logic
430
+ # Pass validated_files to ensure stale comments are deleted even for files
431
+ # that no longer have any findings (issues were resolved)
432
+ # Use skip_cleanup based on cleanup_old_comments flag (False in streaming mode)
433
+ review_body = f"{self.REVIEW_IDENTIFIER}"
434
+
435
+ success = await self.github.update_or_create_review_comments(
436
+ comments=inline_comments,
437
+ body=review_body,
438
+ event=event,
439
+ identifier=self.REVIEW_IDENTIFIER,
440
+ validated_files=validated_files,
441
+ skip_cleanup=not self.cleanup_old_comments, # Skip cleanup in streaming mode
442
+ )
443
+
444
+ if success:
445
+ logger.info("Successfully managed PR review comments (update/create/delete)")
446
+ else:
447
+ logger.error("Failed to manage PR review comments")
448
+
449
+ return success
450
+
451
+ def _make_relative_path(self, policy_file: str) -> str | None:
452
+ """Convert absolute path to relative path for GitHub.
453
+
454
+ GitHub PR review comments require paths relative to the repository root.
455
+
456
+ Args:
457
+ policy_file: Absolute or relative path to policy file
458
+
459
+ Returns:
460
+ Relative path from repository root, or None if cannot be determined
461
+ """
462
+ import os # pylint: disable=import-outside-toplevel
463
+ from pathlib import Path # pylint: disable=import-outside-toplevel
464
+
465
+ # If already relative, use as-is
466
+ if not os.path.isabs(policy_file):
467
+ logger.debug(f"Path already relative: {policy_file}")
468
+ return policy_file
469
+
470
+ # Try to get workspace path from environment
471
+ workspace = os.getenv("GITHUB_WORKSPACE")
472
+ # Log first call only to avoid spam
473
+ if not hasattr(self, "_logged_workspace"):
474
+ self._logged_workspace = True
475
+ logger.warning(f"[ENV] GITHUB_WORKSPACE={workspace}")
476
+ if workspace:
477
+ try:
478
+ # Convert to Path objects for proper path handling
479
+ abs_file_path = Path(policy_file).resolve()
480
+ workspace_path = Path(workspace).resolve()
481
+
482
+ # Check if file is within workspace
483
+ if abs_file_path.is_relative_to(workspace_path):
484
+ relative = abs_file_path.relative_to(workspace_path)
485
+ # Use forward slashes for GitHub (works on all platforms)
486
+ result = str(relative).replace("\\", "/")
487
+ return result
488
+ else:
489
+ logger.warning(
490
+ f"[PATH] File not within workspace: {abs_file_path} not in {workspace_path}"
491
+ )
492
+ except (ValueError, OSError) as e:
493
+ logger.debug(f"Could not compute relative path for {policy_file}: {e}")
494
+
495
+ # Fallback: try current working directory
496
+ try:
497
+ cwd = Path.cwd()
498
+ abs_file_path = Path(policy_file).resolve()
499
+ if abs_file_path.is_relative_to(cwd):
500
+ relative = abs_file_path.relative_to(cwd)
501
+ return str(relative).replace("\\", "/")
502
+ except (ValueError, OSError) as e:
503
+ logger.debug(f"Could not compute relative path from CWD for {policy_file}: {e}")
504
+
505
+ # If all else fails, return None
506
+ logger.warning(
507
+ f"Could not determine relative path for {policy_file}. "
508
+ "Ensure GITHUB_WORKSPACE is set or file is in current directory."
509
+ )
510
+ return None
511
+
512
+ def _get_line_mapping(self, policy_file: str) -> dict[int, int]:
513
+ """Get mapping of statement indices to line numbers.
514
+
515
+ Args:
516
+ policy_file: Path to policy file
517
+
518
+ Returns:
519
+ Dict mapping statement index to line number
520
+ """
521
+ try:
522
+ with open(policy_file, encoding="utf-8") as f:
523
+ lines = f.readlines()
524
+
525
+ mapping: dict[int, int] = {}
526
+ statement_count = 0
527
+ in_statement_array = False
528
+
529
+ for line_num, line in enumerate(lines, start=1):
530
+ stripped = line.strip()
531
+
532
+ # Detect "Statement": [ or "Statement" : [
533
+ if '"Statement"' in stripped or "'Statement'" in stripped:
534
+ in_statement_array = True
535
+ continue
536
+
537
+ # Detect statement object start
538
+ if in_statement_array and stripped.startswith("{"):
539
+ mapping[statement_count] = line_num
540
+ statement_count += 1
541
+
542
+ return mapping
543
+
544
+ except Exception as e: # pylint: disable=broad-exception-caught
545
+ logger.warning(f"Could not parse {policy_file} for line mapping: {e}")
546
+ return {}
547
+
548
+ def _find_issue_line(
549
+ self,
550
+ issue: ValidationIssue,
551
+ policy_file: str,
552
+ line_mapping: dict[int, int],
553
+ ) -> int | None:
554
+ """Find the line number for an issue.
555
+
556
+ Uses field-level line detection when available for precise comment placement.
557
+ For example, an issue about an invalid Action will point to the exact
558
+ Action line, not just the statement start.
559
+
560
+ Args:
561
+ issue: Validation issue
562
+ policy_file: Path to policy file
563
+ line_mapping: Statement index to line number mapping
564
+
565
+ Returns:
566
+ Line number or None
567
+ """
568
+ # If issue has explicit line number, use it
569
+ if issue.line_number:
570
+ return issue.line_number
571
+
572
+ # Try field-level line detection first (most precise)
573
+ if issue.field_name and issue.statement_index >= 0:
574
+ policy_line_map = self._get_policy_line_map(policy_file)
575
+ if policy_line_map:
576
+ field_line = policy_line_map.get_line_for_field(
577
+ issue.statement_index, issue.field_name
578
+ )
579
+ if field_line:
580
+ return field_line
581
+
582
+ # Fallback: use statement mapping
583
+ if issue.statement_index in line_mapping:
584
+ return line_mapping[issue.statement_index]
585
+
586
+ # Fallback: try to find specific field in file by searching
587
+ search_term = issue.action or issue.resource or issue.condition_key
588
+ if search_term:
589
+ return self._search_for_field_line(policy_file, issue.statement_index, search_term)
590
+
591
+ return None
592
+
593
+ def _get_policy_line_map(self, policy_file: str) -> PolicyLineMap | None:
594
+ """Get cached PolicyLineMap for field-level line detection.
595
+
596
+ Args:
597
+ policy_file: Path to policy file
598
+
599
+ Returns:
600
+ PolicyLineMap or None if parsing failed
601
+ """
602
+ if policy_file in self._policy_line_maps:
603
+ return self._policy_line_maps[policy_file]
604
+
605
+ try:
606
+ with open(policy_file, encoding="utf-8") as f:
607
+ content = f.read()
608
+
609
+ policy_map = PolicyLoader.parse_statement_field_lines(content)
610
+ self._policy_line_maps[policy_file] = policy_map
611
+ return policy_map
612
+
613
+ except Exception as e: # pylint: disable=broad-exception-caught
614
+ logger.debug(f"Could not parse field lines for {policy_file}: {e}")
615
+ return None
616
+
617
+ def _search_for_field_line(
618
+ self, policy_file: str, statement_idx: int, search_term: str
619
+ ) -> int | None:
620
+ """Search for a specific field within a statement.
621
+
622
+ Args:
623
+ policy_file: Path to policy file
624
+ statement_idx: Statement index
625
+ search_term: Term to search for
626
+
627
+ Returns:
628
+ Line number or None
629
+ """
630
+ try:
631
+ with open(policy_file, encoding="utf-8") as f:
632
+ lines = f.readlines()
633
+
634
+ # Find the statement block
635
+ statement_count = 0
636
+ in_statement = False
637
+ brace_depth = 0
638
+
639
+ for line_num, line in enumerate(lines, start=1):
640
+ stripped = line.strip()
641
+
642
+ # Track braces
643
+ brace_depth += stripped.count("{") - stripped.count("}")
644
+
645
+ # Detect statement start
646
+ if not in_statement and stripped.startswith("{") and brace_depth > 0:
647
+ if statement_count == statement_idx:
648
+ in_statement = True
649
+ continue
650
+ statement_count += 1
651
+
652
+ # Search within the statement
653
+ if in_statement:
654
+ if search_term in line:
655
+ return line_num
656
+
657
+ # Exit statement when braces balance
658
+ if brace_depth == 0:
659
+ in_statement = False
660
+
661
+ return None
662
+
663
+ except Exception as e: # pylint: disable=broad-exception-caught
664
+ logger.debug(f"Could not search {policy_file}: {e}")
665
+ return None
666
+
667
+ async def _process_ignore_commands(self) -> None:
668
+ """Process pending ignore commands from PR comments."""
669
+ if not self.github:
670
+ return
671
+
672
+ from iam_validator.core.ignore_processor import ( # pylint: disable=import-outside-toplevel
673
+ IgnoreCommandProcessor,
674
+ )
675
+
676
+ processor = IgnoreCommandProcessor(
677
+ github=self.github,
678
+ allowed_users=self.allowed_ignore_users,
679
+ )
680
+ ignored_count = await processor.process_pending_ignores()
681
+ if ignored_count > 0:
682
+ logger.info(f"Processed {ignored_count} ignore command(s)")
683
+
684
+ async def _load_ignored_findings(self) -> None:
685
+ """Load ignored findings for the current PR."""
686
+ if not self.github:
687
+ return
688
+
689
+ from iam_validator.core.ignored_findings import ( # pylint: disable=import-outside-toplevel
690
+ IgnoredFindingsStore,
691
+ )
692
+
693
+ store = IgnoredFindingsStore(self.github)
694
+ self._ignored_finding_ids = await store.get_ignored_ids()
695
+ if self._ignored_finding_ids:
696
+ logger.debug(f"Loaded {len(self._ignored_finding_ids)} ignored finding(s)")
697
+
698
+ def _is_issue_ignored(self, issue: ValidationIssue, file_path: str) -> bool:
699
+ """Check if an issue should be ignored.
700
+
701
+ Args:
702
+ issue: The validation issue
703
+ file_path: Relative path to the policy file
704
+
705
+ Returns:
706
+ True if the issue is ignored
707
+ """
708
+ if not self._ignored_finding_ids:
709
+ return False
710
+
711
+ from iam_validator.core.finding_fingerprint import ( # pylint: disable=import-outside-toplevel
712
+ FindingFingerprint,
713
+ )
714
+
715
+ fingerprint = FindingFingerprint.from_issue(issue, file_path)
716
+ return fingerprint.to_hash() in self._ignored_finding_ids
717
+
718
+
719
+ async def post_report_to_pr(
720
+ report_file: str,
721
+ create_review: bool = True,
722
+ add_summary: bool = True,
723
+ config_path: str | None = None,
724
+ ) -> bool:
725
+ """Post a JSON report to a PR.
726
+
727
+ Args:
728
+ report_file: Path to JSON report file
729
+ create_review: Whether to create line-specific review
730
+ add_summary: Whether to add summary comment
731
+ config_path: Optional path to config file (to get fail_on_severity)
732
+
733
+ Returns:
734
+ True if successful, False otherwise
735
+ """
736
+ try:
737
+ # Load report from JSON
738
+ with open(report_file, encoding="utf-8") as f:
739
+ report_data = json.load(f)
740
+
741
+ report = ValidationReport.model_validate(report_data)
742
+
743
+ # Load config to get fail_on_severity and severity_labels settings
744
+ from iam_validator.core.config.config_loader import ( # pylint: disable=import-outside-toplevel
745
+ ConfigLoader,
746
+ )
747
+
748
+ config = ConfigLoader.load_config(config_path)
749
+ fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
750
+ severity_labels = config.get_setting("severity_labels", {})
751
+
752
+ # Get ignore settings
753
+ ignore_settings = config.get_setting("ignore_settings", {})
754
+ enable_codeowners_ignore = ignore_settings.get("enabled", True)
755
+ allowed_ignore_users = ignore_settings.get("allowed_users", [])
756
+
757
+ # Post to PR
758
+ async with GitHubIntegration() as github:
759
+ commenter = PRCommenter(
760
+ github,
761
+ fail_on_severities=fail_on_severities,
762
+ severity_labels=severity_labels,
763
+ enable_codeowners_ignore=enable_codeowners_ignore,
764
+ allowed_ignore_users=allowed_ignore_users,
765
+ )
766
+ return await commenter.post_findings_to_pr(
767
+ report,
768
+ create_review=create_review,
769
+ add_summary_comment=add_summary,
770
+ )
771
+
772
+ except FileNotFoundError:
773
+ logger.error(f"Report file not found: {report_file}")
774
+ return False
775
+ except json.JSONDecodeError as e:
776
+ logger.error(f"Invalid JSON in report file: {e}")
777
+ return False
778
+ except Exception as e: # pylint: disable=broad-exception-caught
779
+ logger.error(f"Failed to post report to PR: {e}")
780
+ return False