iam-policy-validator 1.10.2__py3-none-any.whl → 1.11.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 (27) hide show
  1. iam_policy_validator-1.11.0.dist-info/METADATA +782 -0
  2. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/RECORD +26 -22
  3. iam_validator/__version__.py +1 -1
  4. iam_validator/checks/action_condition_enforcement.py +27 -14
  5. iam_validator/checks/sensitive_action.py +123 -11
  6. iam_validator/checks/utils/policy_level_checks.py +47 -10
  7. iam_validator/checks/wildcard_resource.py +29 -7
  8. iam_validator/commands/__init__.py +6 -0
  9. iam_validator/commands/completion.py +420 -0
  10. iam_validator/commands/query.py +485 -0
  11. iam_validator/commands/validate.py +21 -26
  12. iam_validator/core/config/category_suggestions.py +77 -0
  13. iam_validator/core/config/condition_requirements.py +105 -54
  14. iam_validator/core/config/defaults.py +110 -6
  15. iam_validator/core/config/wildcards.py +3 -0
  16. iam_validator/core/diff_parser.py +321 -0
  17. iam_validator/core/formatters/enhanced.py +34 -27
  18. iam_validator/core/models.py +2 -0
  19. iam_validator/core/pr_commenter.py +179 -51
  20. iam_validator/core/report.py +19 -17
  21. iam_validator/integrations/github_integration.py +250 -1
  22. iam_validator/sdk/__init__.py +33 -0
  23. iam_validator/sdk/query_utils.py +454 -0
  24. iam_policy_validator-1.10.2.dist-info/METADATA +0 -549
  25. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/WHEEL +0 -0
  26. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/entry_points.txt +0 -0
  27. {iam_policy_validator-1.10.2.dist-info → iam_policy_validator-1.11.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,321 @@
1
+ """Diff Parser Module.
2
+
3
+ This module parses GitHub PR diff information to extract changed line numbers.
4
+ It supports GitHub's unified diff format and provides utilities for determining
5
+ which lines and statements were modified in a PR.
6
+ """
7
+
8
+ import logging
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class ParsedDiff:
18
+ """Parsed GitHub PR diff information for a single file.
19
+
20
+ Attributes:
21
+ file_path: Relative path to the file from repository root
22
+ changed_lines: Set of all line numbers that were added or modified (new side)
23
+ added_lines: Set of line numbers that were added (new side)
24
+ deleted_lines: Set of line numbers that were deleted (old side)
25
+ status: File status (added, modified, removed, renamed)
26
+ """
27
+
28
+ file_path: str
29
+ changed_lines: set[int]
30
+ added_lines: set[int]
31
+ deleted_lines: set[int]
32
+ status: str
33
+
34
+
35
+ @dataclass
36
+ class StatementLocation:
37
+ """Location information for a statement in a policy file.
38
+
39
+ Attributes:
40
+ statement_index: Zero-based index of the statement
41
+ start_line: First line number of the statement (1-indexed)
42
+ end_line: Last line number of the statement (1-indexed)
43
+ has_changes: True if any line in this range was modified
44
+ """
45
+
46
+ statement_index: int
47
+ start_line: int
48
+ end_line: int
49
+ has_changes: bool
50
+
51
+
52
+ class DiffParser:
53
+ """Parser for GitHub PR diff information."""
54
+
55
+ @staticmethod
56
+ def parse_pr_files(pr_files: list[dict[str, Any]]) -> dict[str, ParsedDiff]:
57
+ """Parse GitHub PR files response to extract changed line information.
58
+
59
+ Args:
60
+ pr_files: List of file dicts from GitHub API's get_pr_files() call.
61
+ Each dict contains: filename, status, patch, additions, deletions
62
+
63
+ Returns:
64
+ Dict mapping file paths to ParsedDiff objects
65
+
66
+ Example:
67
+ >>> pr_files = [{
68
+ ... "filename": "policies/policy.json",
69
+ ... "status": "modified",
70
+ ... "patch": "@@ -5,3 +5,4 @@\\n context\\n-old\\n+new\\n+added"
71
+ ... }]
72
+ >>> result = DiffParser.parse_pr_files(pr_files)
73
+ >>> result["policies/policy.json"].changed_lines
74
+ {6, 7}
75
+ """
76
+ parsed: dict[str, ParsedDiff] = {}
77
+
78
+ for file_info in pr_files:
79
+ if not isinstance(file_info, dict):
80
+ continue
81
+
82
+ filename = file_info.get("filename")
83
+ if not filename or not isinstance(filename, str):
84
+ continue
85
+
86
+ status = file_info.get("status", "modified")
87
+ patch = file_info.get("patch")
88
+
89
+ # Files without patches (e.g., binary files, very large files)
90
+ if not patch or not isinstance(patch, str):
91
+ logger.debug(f"No patch available for {filename}, skipping diff parsing")
92
+ # Still track the file with empty change sets
93
+ parsed[filename] = ParsedDiff(
94
+ file_path=filename,
95
+ changed_lines=set(),
96
+ added_lines=set(),
97
+ deleted_lines=set(),
98
+ status=status,
99
+ )
100
+ continue
101
+
102
+ try:
103
+ diff = DiffParser.parse_unified_diff(patch)
104
+ parsed[filename] = ParsedDiff(
105
+ file_path=filename,
106
+ changed_lines=diff["changed_lines"],
107
+ added_lines=diff["added_lines"],
108
+ deleted_lines=diff["deleted_lines"],
109
+ status=status,
110
+ )
111
+ logger.debug(
112
+ f"Parsed diff for {filename}: {len(diff['changed_lines'])} changed lines"
113
+ )
114
+ except Exception as e: # pylint: disable=broad-exception-caught
115
+ logger.warning(f"Failed to parse diff for {filename}: {e}")
116
+ # Track file with empty change sets on parse error
117
+ parsed[filename] = ParsedDiff(
118
+ file_path=filename,
119
+ changed_lines=set(),
120
+ added_lines=set(),
121
+ deleted_lines=set(),
122
+ status=status,
123
+ )
124
+
125
+ return parsed
126
+
127
+ @staticmethod
128
+ def parse_unified_diff(patch: str) -> dict[str, set[int]]:
129
+ """Parse a unified diff patch to extract changed line numbers.
130
+
131
+ Unified diff format uses @@ headers to indicate line ranges:
132
+ @@ -old_start,old_count +new_start,new_count @@
133
+
134
+ Lines starting with:
135
+ - '-' are deletions (old side line numbers)
136
+ - '+' are additions (new side line numbers)
137
+ - ' ' are context (both sides)
138
+
139
+ Args:
140
+ patch: Unified diff string from GitHub API
141
+
142
+ Returns:
143
+ Dict with keys:
144
+ - changed_lines: All added/modified lines (new side)
145
+ - added_lines: Only added lines (new side)
146
+ - deleted_lines: Only deleted lines (old side)
147
+
148
+ Example:
149
+ >>> patch = '''@@ -5,3 +5,4 @@
150
+ ... context line
151
+ ... -deleted line
152
+ ... +added line
153
+ ... +another added line
154
+ ... context line'''
155
+ >>> result = DiffParser.parse_unified_diff(patch)
156
+ >>> result['added_lines']
157
+ {6, 7}
158
+ """
159
+ changed_lines: set[int] = set()
160
+ added_lines: set[int] = set()
161
+ deleted_lines: set[int] = set()
162
+
163
+ # Pattern to match @@ -old_start,old_count +new_start,new_count @@ headers
164
+ # Handles variations: @@ -5,3 +5,4 @@, @@ -5 +5,2 @@, etc.
165
+ hunk_header_pattern = re.compile(r"^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@")
166
+
167
+ lines = patch.split("\n")
168
+ current_new_line = 0
169
+ current_old_line = 0
170
+
171
+ for line in lines:
172
+ # Check for hunk header
173
+ match = hunk_header_pattern.match(line)
174
+ if match:
175
+ old_start = int(match.group(1))
176
+ new_start = int(match.group(3))
177
+ current_old_line = old_start
178
+ current_new_line = new_start
179
+ continue
180
+
181
+ # Process diff lines
182
+ if not line:
183
+ continue
184
+
185
+ first_char = line[0]
186
+
187
+ if first_char == "+":
188
+ # Addition (new side only)
189
+ added_lines.add(current_new_line)
190
+ changed_lines.add(current_new_line)
191
+ current_new_line += 1
192
+ elif first_char == "-":
193
+ # Deletion (old side only)
194
+ deleted_lines.add(current_old_line)
195
+ current_old_line += 1
196
+ elif first_char == " ":
197
+ # Context line (both sides)
198
+ current_new_line += 1
199
+ current_old_line += 1
200
+ # Ignore lines that don't start with +, -, or space (e.g., \ No newline)
201
+
202
+ return {
203
+ "changed_lines": changed_lines,
204
+ "added_lines": added_lines,
205
+ "deleted_lines": deleted_lines,
206
+ }
207
+
208
+ @staticmethod
209
+ def get_modified_statements(
210
+ line_mapping: dict[int, int],
211
+ changed_lines: set[int],
212
+ policy_file: str,
213
+ ) -> dict[int, StatementLocation]:
214
+ """Determine which statements were modified based on changed lines.
215
+
216
+ A statement is considered modified if ANY line within its range appears
217
+ in the changed_lines set.
218
+
219
+ Args:
220
+ line_mapping: Dict mapping statement index to statement start line
221
+ (from PRCommenter._get_line_mapping())
222
+ changed_lines: Set of line numbers that were changed in the PR
223
+ policy_file: Path to the policy file (to determine statement end lines)
224
+
225
+ Returns:
226
+ Dict mapping statement indices to StatementLocation objects
227
+ Only includes statements that were modified.
228
+
229
+ Example:
230
+ >>> line_mapping = {0: 3, 1: 10, 2: 20} # Statement starts
231
+ >>> changed_lines = {5, 6} # Lines changed in statement 0
232
+ >>> result = get_modified_statements(line_mapping, changed_lines, "policy.json")
233
+ >>> result[0].has_changes
234
+ True
235
+ >>> 1 in result # Statement 1 not modified
236
+ False
237
+ """
238
+ if not line_mapping or not changed_lines:
239
+ return {}
240
+
241
+ # Determine end line for each statement
242
+ statement_ranges: dict[int, tuple[int, int]] = {}
243
+ sorted_indices = sorted(line_mapping.keys())
244
+
245
+ for i, stmt_idx in enumerate(sorted_indices):
246
+ start_line = line_mapping[stmt_idx]
247
+
248
+ # End line is either:
249
+ # 1. One line before next statement starts, OR
250
+ # 2. EOF for the last statement
251
+ if i < len(sorted_indices) - 1:
252
+ next_start = line_mapping[sorted_indices[i + 1]]
253
+ end_line = next_start - 1
254
+ else:
255
+ # For last statement, try to read file to get actual end
256
+ end_line = DiffParser.get_statement_end_line(policy_file, start_line)
257
+
258
+ statement_ranges[stmt_idx] = (start_line, end_line)
259
+
260
+ # Check which statements have changes
261
+ modified_statements: dict[int, StatementLocation] = {}
262
+
263
+ for stmt_idx, (start_line, end_line) in statement_ranges.items():
264
+ # Check if any line in this statement's range was changed
265
+ statement_lines = set(range(start_line, end_line + 1))
266
+ has_changes = bool(statement_lines & changed_lines)
267
+
268
+ if has_changes:
269
+ modified_statements[stmt_idx] = StatementLocation(
270
+ statement_index=stmt_idx,
271
+ start_line=start_line,
272
+ end_line=end_line,
273
+ has_changes=True,
274
+ )
275
+ logger.debug(f"Statement {stmt_idx} (lines {start_line}-{end_line}) was modified")
276
+
277
+ return modified_statements
278
+
279
+ @staticmethod
280
+ def get_statement_end_line(policy_file: str, start_line: int) -> int:
281
+ """Find the end line of a statement block starting at start_line.
282
+
283
+ Tracks brace depth to find where the statement object closes.
284
+
285
+ Args:
286
+ policy_file: Path to policy file
287
+ start_line: Line number where statement starts (1-indexed)
288
+
289
+ Returns:
290
+ Line number where statement ends (1-indexed)
291
+ """
292
+ try:
293
+ with open(policy_file, encoding="utf-8") as f:
294
+ lines = f.readlines()
295
+
296
+ # Start counting from the statement's opening brace
297
+ brace_depth = 0
298
+ in_statement = False
299
+
300
+ for line_num in range(start_line - 1, len(lines)): # Convert to 0-indexed
301
+ line = lines[line_num]
302
+
303
+ # Track braces
304
+ for char in line:
305
+ if char == "{":
306
+ brace_depth += 1
307
+ in_statement = True
308
+ elif char == "}":
309
+ brace_depth -= 1
310
+
311
+ # Found the closing brace for this statement
312
+ if in_statement and brace_depth == 0:
313
+ return line_num + 1 # Convert back to 1-indexed
314
+
315
+ # If we couldn't find the end, return a reasonable default
316
+ # (start_line + 20 or end of file)
317
+ return min(start_line + 20, len(lines))
318
+
319
+ except Exception as e: # pylint: disable=broad-exception-caught
320
+ logger.debug(f"Could not determine statement end line: {e}")
321
+ return start_line + 10 # Reasonable default
@@ -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}]"