iam-policy-validator 1.10.3__py3-none-any.whl → 1.11.1__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.
@@ -61,7 +61,6 @@ class EnhancedFormatter(OutputFormatter):
61
61
  )
62
62
 
63
63
  # Header with title
64
- console.print()
65
64
  title = Text(
66
65
  f"IAM Policy Validation Report (v{__version__})",
67
66
  style="bold cyan",
@@ -75,7 +74,6 @@ class EnhancedFormatter(OutputFormatter):
75
74
  width=constants.CONSOLE_PANEL_WIDTH,
76
75
  )
77
76
  )
78
- console.print()
79
77
 
80
78
  # Executive Summary with progress bars (optional)
81
79
  if show_summary:
@@ -83,12 +81,15 @@ class EnhancedFormatter(OutputFormatter):
83
81
 
84
82
  # Severity breakdown if there are issues (optional)
85
83
  if show_severity_breakdown and report.total_issues > 0:
86
- console.print()
87
84
  self._print_severity_breakdown(console, report)
88
85
 
89
- console.print()
90
- console.print(Rule(title="[bold]Detailed Results", style=constants.CONSOLE_HEADER_COLOR))
91
- console.print()
86
+ console.print(
87
+ Rule(
88
+ title="[bold]Detailed Results",
89
+ style=constants.CONSOLE_HEADER_COLOR,
90
+ ),
91
+ width=constants.CONSOLE_PANEL_WIDTH,
92
+ )
92
93
 
93
94
  # Detailed results using tree structure
94
95
  for idx, result in enumerate(report.results, 1):
@@ -99,7 +100,7 @@ class EnhancedFormatter(OutputFormatter):
99
100
  self._print_final_status(console, report)
100
101
 
101
102
  # Get the formatted output
102
- output = string_buffer.getvalue()
103
+ output = string_buffer.getvalue().rstrip("\n")
103
104
  string_buffer.close()
104
105
 
105
106
  return output
@@ -305,13 +306,10 @@ class EnhancedFormatter(OutputFormatter):
305
306
  if not result.issues:
306
307
  console.print(header)
307
308
  console.print(" [dim italic]No issues detected[/dim italic]")
308
- console.print()
309
309
  return
310
310
 
311
311
  console.print(header)
312
312
  console.print(f" [dim]{len(result.issues)} issue(s) found[/dim]")
313
- console.print()
314
-
315
313
  # Create tree structure for issues
316
314
  tree = Tree(f"[bold]Issues ({len(result.issues)})[/bold]", guide_style="bright_black")
317
315
 
@@ -372,10 +370,14 @@ class EnhancedFormatter(OutputFormatter):
372
370
  def _add_issue_to_tree(self, branch: Tree, issue, color: str) -> None:
373
371
  """Add an issue to a tree branch."""
374
372
  # Build location string (use 1-indexed statement numbers for user-facing output)
375
- statement_num = issue.statement_index + 1
376
- location = f"Statement {statement_num}"
377
- if issue.statement_sid:
378
- location = f"{issue.statement_sid} (#{statement_num})"
373
+ # Handle policy-level issues (statement_index = -1)
374
+ if issue.statement_index == -1:
375
+ location = "Policy-level"
376
+ else:
377
+ statement_num = issue.statement_index + 1
378
+ location = f"Statement {statement_num}"
379
+ if issue.statement_sid:
380
+ location = f"{issue.statement_sid} (#{statement_num})"
379
381
  if issue.line_number is not None:
380
382
  location += f" @L{issue.line_number}"
381
383
 
@@ -399,19 +401,24 @@ class EnhancedFormatter(OutputFormatter):
399
401
  details.append(f"Condition: {issue.condition_key}")
400
402
  msg_node.add(Text(" • ".join(details), style="dim cyan"))
401
403
 
402
- # Suggestion
403
- if issue.suggestion:
404
- suggestion_text = Text()
405
- suggestion_text.append("💡 ", style="yellow")
406
- suggestion_text.append(issue.suggestion, style="italic yellow")
407
- msg_node.add(suggestion_text)
404
+ # Suggestion and Example - combine into single node to reduce spacing
405
+ if issue.suggestion or issue.example:
406
+ combined_text = Text()
407
+
408
+ # Add suggestion
409
+ if issue.suggestion:
410
+ combined_text.append("💡 ", style="yellow")
411
+ combined_text.append(issue.suggestion, style="italic yellow")
412
+
413
+ # Add example on same node (reduces vertical spacing)
414
+ if issue.example:
415
+ if issue.suggestion:
416
+ combined_text.append("\n", style="yellow") # Single newline separator
417
+ combined_text.append("Example:", style="bold cyan")
418
+ combined_text.append("\n")
419
+ combined_text.append(issue.example, style="dim")
408
420
 
409
- # Example (if present, show with indentation)
410
- if issue.example:
411
- msg_node.add(Text("Example:", style="bold cyan"))
412
- # Show example code with syntax highlighting
413
- example_text = Text(issue.example, style="dim")
414
- msg_node.add(example_text)
421
+ msg_node.add(combined_text)
415
422
 
416
423
  def _print_final_status(self, console: Console, report: ValidationReport) -> None:
417
424
  """Print final status panel."""
@@ -461,7 +468,7 @@ class EnhancedFormatter(OutputFormatter):
461
468
  # Combine status and message
462
469
  final_text = Text()
463
470
  final_text.append(status)
464
- final_text.append("\n\n")
471
+ final_text.append("\n") # Reduced from \n\n to single newline
465
472
  final_text.append(message)
466
473
 
467
474
  console.print(
@@ -233,6 +233,8 @@ class ValidationIssue(BaseModel):
233
233
  if include_identifier:
234
234
  parts.append(f"{constants.REVIEW_IDENTIFIER}\n")
235
235
  parts.append(f"{constants.BOT_IDENTIFIER}\n")
236
+ # Add issue type identifier to allow multiple issues at same line
237
+ parts.append(f"<!-- issue-type: {self.issue_type} -->\n")
236
238
 
237
239
  # Build statement context for better navigation
238
240
  statement_context = f"Statement[{self.statement_index}]"
@@ -13,6 +13,7 @@ from iam_validator.core.constants import (
13
13
  REVIEW_IDENTIFIER,
14
14
  SUMMARY_IDENTIFIER,
15
15
  )
16
+ from iam_validator.core.diff_parser import DiffParser
16
17
  from iam_validator.core.label_manager import LabelManager
17
18
  from iam_validator.core.models import ValidationIssue, ValidationReport
18
19
  from iam_validator.core.report import ReportGenerator
@@ -21,6 +22,34 @@ from iam_validator.integrations.github_integration import GitHubIntegration, Rev
21
22
  logger = logging.getLogger(__name__)
22
23
 
23
24
 
25
+ class ContextIssue:
26
+ """Represents an issue in a modified statement but on an unchanged line.
27
+
28
+ These issues are shown in the summary comment rather than as inline comments,
29
+ since GitHub only allows comments on lines that appear in the PR diff.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ file_path: str,
35
+ statement_index: int,
36
+ line_number: int,
37
+ issue: ValidationIssue,
38
+ ):
39
+ """Initialize context issue.
40
+
41
+ Args:
42
+ file_path: Relative path to the file
43
+ statement_index: Zero-based statement index
44
+ line_number: Line number where the issue exists
45
+ issue: The validation issue
46
+ """
47
+ self.file_path = file_path
48
+ self.statement_index = statement_index
49
+ self.line_number = line_number
50
+ self.issue = issue
51
+
52
+
24
53
  class PRCommenter:
25
54
  """Posts validation findings as PR comments."""
26
55
 
@@ -41,6 +70,7 @@ class PRCommenter:
41
70
  Args:
42
71
  github: GitHubIntegration instance (will create one if None)
43
72
  cleanup_old_comments: Whether to clean up old bot comments before posting new ones
73
+ (kept for backward compatibility but now handled automatically)
44
74
  fail_on_severities: List of severity levels that should trigger REQUEST_CHANGES
45
75
  (e.g., ["error", "critical", "high"])
46
76
  severity_labels: Mapping of severity levels to label name(s) for automatic label management
@@ -54,6 +84,8 @@ class PRCommenter:
54
84
  self.cleanup_old_comments = cleanup_old_comments
55
85
  self.fail_on_severities = fail_on_severities or ["error", "critical"]
56
86
  self.severity_labels = severity_labels or {}
87
+ # Track issues in modified statements that are on unchanged lines
88
+ self._context_issues: list[ContextIssue] = []
57
89
 
58
90
  async def post_findings_to_pr(
59
91
  self,
@@ -86,10 +118,15 @@ class PRCommenter:
86
118
 
87
119
  success = True
88
120
 
89
- # Clean up old bot comments if enabled
90
- if self.cleanup_old_comments and create_review:
91
- logger.info("Cleaning up old review comments from previous runs...")
92
- await self.github.cleanup_bot_review_comments(self.REVIEW_IDENTIFIER)
121
+ # Note: Cleanup is now handled smartly by update_or_create_review_comments()
122
+ # It will update existing comments, create new ones, and delete resolved ones
123
+
124
+ # Post line-specific review comments FIRST
125
+ # (This populates self._context_issues)
126
+ if create_review:
127
+ if not await self._post_review_comments(report):
128
+ logger.error("Failed to post review comments")
129
+ success = False
93
130
 
94
131
  # Post summary comment (potentially as multiple parts)
95
132
  if add_summary_comment:
@@ -108,12 +145,6 @@ class PRCommenter:
108
145
  else:
109
146
  logger.info("Posted summary comment")
110
147
 
111
- # Post line-specific review comments
112
- if create_review:
113
- if not await self._post_review_comments(report):
114
- logger.error("Failed to post review comments")
115
- success = False
116
-
117
148
  # Manage PR labels based on severity findings
118
149
  if manage_labels and self.severity_labels:
119
150
  label_manager = LabelManager(self.github, self.severity_labels)
@@ -129,7 +160,11 @@ class PRCommenter:
129
160
  return success
130
161
 
131
162
  async def _post_review_comments(self, report: ValidationReport) -> bool:
132
- """Post line-specific review comments.
163
+ """Post line-specific review comments with strict diff filtering.
164
+
165
+ Only posts comments on lines that were actually changed in the PR.
166
+ Issues in modified statements but on unchanged lines are tracked in
167
+ self._context_issues for inclusion in the summary comment.
133
168
 
134
169
  Args:
135
170
  report: Validation report
@@ -140,8 +175,25 @@ class PRCommenter:
140
175
  if not self.github:
141
176
  return False
142
177
 
178
+ # Clear context issues from previous runs
179
+ self._context_issues = []
180
+
181
+ # Fetch PR diff information
182
+ logger.info("Fetching PR diff information for strict filtering...")
183
+ pr_files = await self.github.get_pr_files()
184
+ if not pr_files:
185
+ logger.warning(
186
+ "Could not fetch PR diff information. "
187
+ "Falling back to unfiltered commenting (may fail if lines not in diff)."
188
+ )
189
+ parsed_diffs = {}
190
+ else:
191
+ parsed_diffs = DiffParser.parse_pr_files(pr_files)
192
+ logger.info(f"Parsed diffs for {len(parsed_diffs)} file(s)")
193
+
143
194
  # Group issues by file
144
- comments_by_file: dict[str, list[dict[str, Any]]] = {}
195
+ inline_comments: list[dict[str, Any]] = []
196
+ context_issue_count = 0
145
197
 
146
198
  for result in report.results:
147
199
  if not result.issues:
@@ -155,75 +207,149 @@ class PRCommenter:
155
207
  )
156
208
  continue
157
209
 
158
- # Try to determine line numbers from the policy file
210
+ # Get diff info for this file
211
+ diff_info = parsed_diffs.get(relative_path)
212
+ if not diff_info:
213
+ logger.debug(
214
+ f"{relative_path} not in PR diff or no changes, skipping inline comments"
215
+ )
216
+ # Still process issues for summary
217
+ for issue in result.issues:
218
+ if issue.statement_index is not None:
219
+ line_num = self._find_issue_line(
220
+ issue, result.policy_file, self._get_line_mapping(result.policy_file)
221
+ )
222
+ if line_num:
223
+ self._context_issues.append(
224
+ ContextIssue(relative_path, issue.statement_index, line_num, issue)
225
+ )
226
+ context_issue_count += 1
227
+ continue
228
+
229
+ # Get line mapping and modified statements for this file
159
230
  line_mapping = self._get_line_mapping(result.policy_file)
231
+ modified_statements = DiffParser.get_modified_statements(
232
+ line_mapping, diff_info.changed_lines, result.policy_file
233
+ )
160
234
 
235
+ logger.debug(
236
+ f"{relative_path}: {len(diff_info.changed_lines)} changed lines, "
237
+ f"{len(modified_statements)} modified statements"
238
+ )
239
+
240
+ # Process each issue with strict filtering
161
241
  for issue in result.issues:
162
- # Determine the line number for this issue
163
242
  line_number = self._find_issue_line(issue, result.policy_file, line_mapping)
164
243
 
165
- if line_number:
166
- comment = {
167
- "path": relative_path, # Use relative path for GitHub
168
- "line": line_number,
169
- "body": issue.to_pr_comment(),
170
- }
244
+ if not line_number:
245
+ logger.debug(
246
+ f"Could not determine line number for issue in {relative_path}: {issue.issue_type}"
247
+ )
248
+ continue
171
249
 
172
- if relative_path not in comments_by_file:
173
- comments_by_file[relative_path] = []
174
- comments_by_file[relative_path].append(comment)
250
+ # SPECIAL CASE: Policy-level issues (privilege escalation, etc.)
251
+ # Post to first available line in diff, preferring line 1 if available
252
+ if issue.statement_index == -1:
253
+ # Try to find the best line to post the comment
254
+ comment_line = None
255
+
256
+ if line_number in diff_info.changed_lines:
257
+ # Best case: line 1 is in the diff
258
+ comment_line = line_number
259
+ elif diff_info.changed_lines:
260
+ # Fallback: use the first changed line in the file
261
+ # This ensures policy-level issues always appear as inline comments
262
+ comment_line = min(diff_info.changed_lines)
263
+ logger.debug(
264
+ f"Policy-level issue at line {line_number}, posting to first changed line {comment_line}"
265
+ )
266
+
267
+ if comment_line:
268
+ # Post as inline comment at the determined line
269
+ inline_comments.append(
270
+ {
271
+ "path": relative_path,
272
+ "line": comment_line,
273
+ "body": issue.to_pr_comment(),
274
+ }
275
+ )
276
+ logger.debug(
277
+ f"Policy-level inline comment: {relative_path}:{comment_line} - {issue.issue_type}"
278
+ )
279
+ else:
280
+ # No changed lines in file - add to summary comment
281
+ self._context_issues.append(
282
+ ContextIssue(relative_path, issue.statement_index, line_number, issue)
283
+ )
284
+ context_issue_count += 1
285
+ logger.debug(
286
+ f"Policy-level issue (no diff lines): {relative_path} - {issue.issue_type}"
287
+ )
288
+ # STRICT FILTERING: Only comment if line is in the diff
289
+ elif line_number in diff_info.changed_lines:
290
+ # Exact match - post inline comment
291
+ inline_comments.append(
292
+ {
293
+ "path": relative_path,
294
+ "line": line_number,
295
+ "body": issue.to_pr_comment(),
296
+ }
297
+ )
298
+ logger.debug(
299
+ f"Inline comment: {relative_path}:{line_number} - {issue.issue_type}"
300
+ )
301
+ elif issue.statement_index in modified_statements:
302
+ # Issue in modified statement but on unchanged line - save for summary
303
+ self._context_issues.append(
304
+ ContextIssue(relative_path, issue.statement_index, line_number, issue)
305
+ )
306
+ context_issue_count += 1
175
307
  logger.debug(
176
- f"Prepared review comment for {relative_path}:{line_number} - {issue.issue_type}"
308
+ f"Context issue: {relative_path}:{line_number} (statement {issue.statement_index} modified) - {issue.issue_type}"
177
309
  )
178
310
  else:
311
+ # Issue in completely unchanged statement - ignore for inline and summary
179
312
  logger.debug(
180
- f"Could not determine line number for issue in {relative_path}: {issue.issue_type}"
313
+ f"Skipped issue in unchanged statement: {relative_path}:{line_number} - {issue.issue_type}"
181
314
  )
182
315
 
183
- # If no line-specific comments, skip
184
- if not comments_by_file:
185
- logger.info("No line-specific comments to post")
186
- return True
187
-
188
- # Flatten comments list
189
- all_comments = []
190
- for file_comments in comments_by_file.values():
191
- all_comments.extend(file_comments)
192
-
316
+ # Log filtering results
193
317
  logger.info(
194
- f"Posting {len(all_comments)} review comments across {len(comments_by_file)} file(s)"
318
+ f"Diff filtering results: {len(inline_comments)} inline comments, "
319
+ f"{context_issue_count} context issues for summary"
195
320
  )
196
321
 
197
- # Log files that will receive comments (for debugging)
198
- for file_path, file_comments in comments_by_file.items():
199
- logger.debug(f" {file_path}: {len(file_comments)} comment(s)")
322
+ # If no inline comments, skip review creation but still return success
323
+ if not inline_comments:
324
+ logger.info("No inline comments to post (after diff filtering)")
325
+ return True
200
326
 
201
327
  # Determine review event based on fail_on_severities config
202
- # Check if any issue has a severity that should trigger REQUEST_CHANGES
203
328
  has_blocking_issues = any(
204
329
  issue.severity in self.fail_on_severities
205
330
  for result in report.results
206
331
  for issue in result.issues
207
332
  )
208
333
 
209
- # Set review event: request changes if any blocking issues, else comment
210
334
  event = ReviewEvent.REQUEST_CHANGES if has_blocking_issues else ReviewEvent.COMMENT
211
- logger.info(f"Creating PR review with event: {event.value}")
335
+ logger.info(
336
+ f"Creating PR review with {len(inline_comments)} comments, event: {event.value}"
337
+ )
212
338
 
213
- # Post review with comments (use minimal body since summary comment has the details)
214
- # Only include the identifier for cleanup purposes
339
+ # Post review with smart update-or-create logic
215
340
  review_body = f"{self.REVIEW_IDENTIFIER}"
216
341
 
217
- success = await self.github.create_review_with_comments(
218
- comments=all_comments,
342
+ success = await self.github.update_or_create_review_comments(
343
+ comments=inline_comments,
219
344
  body=review_body,
220
345
  event=event,
346
+ identifier=self.REVIEW_IDENTIFIER,
221
347
  )
222
348
 
223
349
  if success:
224
- logger.info(f"Successfully created PR review with {len(all_comments)} comments")
350
+ logger.info("Successfully managed PR review comments (update/create/delete)")
225
351
  else:
226
- logger.error("Failed to create PR review")
352
+ logger.error("Failed to manage PR review comments")
227
353
 
228
354
  return success
229
355
 
@@ -238,8 +364,8 @@ class PRCommenter:
238
364
  Returns:
239
365
  Relative path from repository root, or None if cannot be determined
240
366
  """
241
- import os
242
- from pathlib import Path
367
+ import os # pylint: disable=import-outside-toplevel
368
+ from pathlib import Path # pylint: disable=import-outside-toplevel
243
369
 
244
370
  # If already relative, use as-is
245
371
  if not os.path.isabs(policy_file):
@@ -421,7 +547,9 @@ async def post_report_to_pr(
421
547
  report = ValidationReport.model_validate(report_data)
422
548
 
423
549
  # Load config to get fail_on_severity and severity_labels settings
424
- from iam_validator.core.config.config_loader import ConfigLoader
550
+ from iam_validator.core.config.config_loader import ( # pylint: disable=import-outside-toplevel
551
+ ConfigLoader,
552
+ )
425
553
 
426
554
  config = ConfigLoader.load_config(config_path)
427
555
  fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
@@ -819,24 +819,26 @@ class ReportGenerator:
819
819
  issue: The validation issue to format
820
820
  policy_file: Optional policy file path (currently unused, kept for compatibility)
821
821
  """
822
- # Use 1-indexed statement numbers for user-facing output
823
- statement_num = issue.statement_index + 1
824
-
825
- # Build statement location reference
826
- # Note: We show plain text here instead of links because:
827
- # 1. GitHub's diff anchor format only works for files in the PR diff
828
- # 2. Inline review comments (posted separately) already provide perfect navigation
829
- # 3. Summary comment is for overview, not detailed navigation
830
- if issue.line_number:
831
- location = f"Statement {statement_num} (Line {issue.line_number})"
832
- if issue.statement_sid:
833
- location = (
834
- f"`{issue.statement_sid}` (statement {statement_num}, line {issue.line_number})"
835
- )
822
+ # Handle policy-level issues (statement_index = -1)
823
+ if issue.statement_index == -1:
824
+ location = "Policy-level"
836
825
  else:
837
- location = f"Statement {statement_num}"
838
- if issue.statement_sid:
839
- location = f"`{issue.statement_sid}` (statement {statement_num})"
826
+ # Use 1-indexed statement numbers for user-facing output
827
+ statement_num = issue.statement_index + 1
828
+
829
+ # Build statement location reference
830
+ # Note: We show plain text here instead of links because:
831
+ # 1. GitHub's diff anchor format only works for files in the PR diff
832
+ # 2. Inline review comments (posted separately) already provide perfect navigation
833
+ # 3. Summary comment is for overview, not detailed navigation
834
+ if issue.line_number:
835
+ location = f"Statement {statement_num} (Line {issue.line_number})"
836
+ if issue.statement_sid:
837
+ location = f"`{issue.statement_sid}` (statement {statement_num}, line {issue.line_number})"
838
+ else:
839
+ location = f"Statement {statement_num}"
840
+ if issue.statement_sid:
841
+ location = f"`{issue.statement_sid}` (statement {statement_num})"
840
842
 
841
843
  parts = []
842
844