iam-policy-validator 1.4.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.

Potentially problematic release.


This version of iam-policy-validator might be problematic. Click here for more details.

Files changed (56) hide show
  1. iam_policy_validator-1.4.0.dist-info/METADATA +1022 -0
  2. iam_policy_validator-1.4.0.dist-info/RECORD +56 -0
  3. iam_policy_validator-1.4.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.4.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.4.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 +7 -0
  9. iam_validator/checks/__init__.py +27 -0
  10. iam_validator/checks/action_condition_enforcement.py +727 -0
  11. iam_validator/checks/action_resource_constraint.py +151 -0
  12. iam_validator/checks/action_validation.py +72 -0
  13. iam_validator/checks/condition_key_validation.py +70 -0
  14. iam_validator/checks/policy_size.py +151 -0
  15. iam_validator/checks/policy_type_validation.py +299 -0
  16. iam_validator/checks/principal_validation.py +282 -0
  17. iam_validator/checks/resource_validation.py +108 -0
  18. iam_validator/checks/security_best_practices.py +536 -0
  19. iam_validator/checks/sid_uniqueness.py +170 -0
  20. iam_validator/checks/utils/__init__.py +1 -0
  21. iam_validator/checks/utils/policy_level_checks.py +143 -0
  22. iam_validator/checks/utils/sensitive_action_matcher.py +252 -0
  23. iam_validator/checks/utils/wildcard_expansion.py +87 -0
  24. iam_validator/commands/__init__.py +25 -0
  25. iam_validator/commands/analyze.py +434 -0
  26. iam_validator/commands/base.py +48 -0
  27. iam_validator/commands/cache.py +392 -0
  28. iam_validator/commands/download_services.py +260 -0
  29. iam_validator/commands/post_to_pr.py +86 -0
  30. iam_validator/commands/validate.py +539 -0
  31. iam_validator/core/__init__.py +14 -0
  32. iam_validator/core/access_analyzer.py +666 -0
  33. iam_validator/core/access_analyzer_report.py +643 -0
  34. iam_validator/core/aws_fetcher.py +880 -0
  35. iam_validator/core/aws_global_conditions.py +137 -0
  36. iam_validator/core/check_registry.py +469 -0
  37. iam_validator/core/cli.py +134 -0
  38. iam_validator/core/config_loader.py +452 -0
  39. iam_validator/core/defaults.py +393 -0
  40. iam_validator/core/formatters/__init__.py +27 -0
  41. iam_validator/core/formatters/base.py +147 -0
  42. iam_validator/core/formatters/console.py +59 -0
  43. iam_validator/core/formatters/csv.py +170 -0
  44. iam_validator/core/formatters/enhanced.py +434 -0
  45. iam_validator/core/formatters/html.py +672 -0
  46. iam_validator/core/formatters/json.py +33 -0
  47. iam_validator/core/formatters/markdown.py +63 -0
  48. iam_validator/core/formatters/sarif.py +187 -0
  49. iam_validator/core/models.py +298 -0
  50. iam_validator/core/policy_checks.py +656 -0
  51. iam_validator/core/policy_loader.py +396 -0
  52. iam_validator/core/pr_commenter.py +338 -0
  53. iam_validator/core/report.py +859 -0
  54. iam_validator/integrations/__init__.py +28 -0
  55. iam_validator/integrations/github_integration.py +795 -0
  56. iam_validator/integrations/ms_teams.py +442 -0
@@ -0,0 +1,338 @@
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.models import ValidationIssue, ValidationReport
12
+ from iam_validator.integrations.github_integration import GitHubIntegration, ReviewEvent
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class PRCommenter:
18
+ """Posts validation findings as PR comments."""
19
+
20
+ # Identifier for bot comments (used for cleanup/updates)
21
+ BOT_IDENTIFIER = "🤖 IAM Policy Validator"
22
+ SUMMARY_IDENTIFIER = "<!-- iam-policy-validator-summary -->"
23
+ REVIEW_IDENTIFIER = "<!-- iam-policy-validator-review -->"
24
+
25
+ def __init__(
26
+ self,
27
+ github: GitHubIntegration | None = None,
28
+ cleanup_old_comments: bool = True,
29
+ fail_on_severities: list[str] | None = None,
30
+ ):
31
+ """Initialize PR commenter.
32
+
33
+ Args:
34
+ github: GitHubIntegration instance (will create one if None)
35
+ cleanup_old_comments: Whether to clean up old bot comments before posting new ones
36
+ fail_on_severities: List of severity levels that should trigger REQUEST_CHANGES
37
+ (e.g., ["error", "critical", "high"])
38
+ """
39
+ self.github = github
40
+ self.cleanup_old_comments = cleanup_old_comments
41
+ self.fail_on_severities = fail_on_severities or ["error", "critical"]
42
+
43
+ async def post_findings_to_pr(
44
+ self,
45
+ report: ValidationReport,
46
+ create_review: bool = True,
47
+ add_summary_comment: bool = True,
48
+ ) -> bool:
49
+ """Post validation findings to a PR.
50
+
51
+ Args:
52
+ report: Validation report with findings
53
+ create_review: Whether to create a PR review with line comments
54
+ add_summary_comment: Whether to add a summary comment
55
+
56
+ Returns:
57
+ True if successful, False otherwise
58
+ """
59
+ if self.github is None:
60
+ self.github = GitHubIntegration()
61
+
62
+ if not self.github.is_configured():
63
+ logger.error("GitHub integration not configured")
64
+ return False
65
+
66
+ success = True
67
+
68
+ # Clean up old bot comments if enabled
69
+ if self.cleanup_old_comments and create_review:
70
+ logger.info("Cleaning up old review comments from previous runs...")
71
+ await self.github.cleanup_bot_review_comments(self.REVIEW_IDENTIFIER)
72
+
73
+ # Post summary comment (potentially as multiple parts)
74
+ if add_summary_comment:
75
+ from iam_validator.core.report import ReportGenerator
76
+
77
+ generator = ReportGenerator()
78
+ comment_parts = generator.generate_github_comment_parts(report)
79
+
80
+ # Post all parts using the multipart method
81
+ if not await self.github.post_multipart_comments(
82
+ comment_parts, self.SUMMARY_IDENTIFIER
83
+ ):
84
+ logger.error("Failed to post summary comment(s)")
85
+ success = False
86
+ else:
87
+ if len(comment_parts) > 1:
88
+ logger.info(f"Posted summary in {len(comment_parts)} parts")
89
+ else:
90
+ logger.info("Posted summary comment")
91
+
92
+ # Post line-specific review comments
93
+ if create_review:
94
+ if not await self._post_review_comments(report):
95
+ logger.error("Failed to post review comments")
96
+ success = False
97
+
98
+ return success
99
+
100
+ async def _post_review_comments(self, report: ValidationReport) -> bool:
101
+ """Post line-specific review comments.
102
+
103
+ Args:
104
+ report: Validation report
105
+
106
+ Returns:
107
+ True if successful, False otherwise
108
+ """
109
+ if not self.github:
110
+ return False
111
+
112
+ # Group issues by file
113
+ comments_by_file: dict[str, list[dict[str, Any]]] = {}
114
+
115
+ for result in report.results:
116
+ if not result.issues:
117
+ continue
118
+
119
+ # Try to determine line numbers from the policy file
120
+ line_mapping = self._get_line_mapping(result.policy_file)
121
+
122
+ for issue in result.issues:
123
+ # Determine the line number for this issue
124
+ line_number = self._find_issue_line(issue, result.policy_file, line_mapping)
125
+
126
+ if line_number:
127
+ comment = {
128
+ "path": result.policy_file,
129
+ "line": line_number,
130
+ "body": issue.to_pr_comment(),
131
+ }
132
+
133
+ if result.policy_file not in comments_by_file:
134
+ comments_by_file[result.policy_file] = []
135
+ comments_by_file[result.policy_file].append(comment)
136
+
137
+ # If no line-specific comments, skip
138
+ if not comments_by_file:
139
+ logger.info("No line-specific comments to post")
140
+ return True
141
+
142
+ # Flatten comments list
143
+ all_comments = []
144
+ for file_comments in comments_by_file.values():
145
+ all_comments.extend(file_comments)
146
+
147
+ # Determine review event based on fail_on_severities config
148
+ # Check if any issue has a severity that should trigger REQUEST_CHANGES
149
+ has_blocking_issues = any(
150
+ issue.severity in self.fail_on_severities
151
+ for result in report.results
152
+ for issue in result.issues
153
+ )
154
+
155
+ # Set review event: request changes if any blocking issues, else comment
156
+ event = ReviewEvent.REQUEST_CHANGES if has_blocking_issues else ReviewEvent.COMMENT
157
+
158
+ # Post review with comments (include identifier in review body for cleanup)
159
+ review_body = (
160
+ f"{self.REVIEW_IDENTIFIER}\n\n"
161
+ f"🤖 **IAM Policy Validator**\n\n"
162
+ f"## Validation Results\n\n"
163
+ f"Found {report.total_issues} issues across {report.total_policies} policies.\n"
164
+ f"See inline comments for details."
165
+ )
166
+
167
+ return await self.github.create_review_with_comments(
168
+ comments=all_comments,
169
+ body=review_body,
170
+ event=event,
171
+ )
172
+
173
+ def _get_line_mapping(self, policy_file: str) -> dict[int, int]:
174
+ """Get mapping of statement indices to line numbers.
175
+
176
+ Args:
177
+ policy_file: Path to policy file
178
+
179
+ Returns:
180
+ Dict mapping statement index to line number
181
+ """
182
+ try:
183
+ with open(policy_file, encoding="utf-8") as f:
184
+ lines = f.readlines()
185
+
186
+ mapping: dict[int, int] = {}
187
+ statement_count = 0
188
+ in_statement_array = False
189
+
190
+ for line_num, line in enumerate(lines, start=1):
191
+ stripped = line.strip()
192
+
193
+ # Detect "Statement": [ or "Statement" : [
194
+ if '"Statement"' in stripped or "'Statement'" in stripped:
195
+ in_statement_array = True
196
+ continue
197
+
198
+ # Detect statement object start
199
+ if in_statement_array and stripped.startswith("{"):
200
+ mapping[statement_count] = line_num
201
+ statement_count += 1
202
+
203
+ return mapping
204
+
205
+ except Exception as e:
206
+ logger.warning(f"Could not parse {policy_file} for line mapping: {e}")
207
+ return {}
208
+
209
+ def _find_issue_line(
210
+ self,
211
+ issue: ValidationIssue,
212
+ policy_file: str,
213
+ line_mapping: dict[int, int],
214
+ ) -> int | None:
215
+ """Find the line number for an issue.
216
+
217
+ Args:
218
+ issue: Validation issue
219
+ policy_file: Path to policy file
220
+ line_mapping: Statement index to line number mapping
221
+
222
+ Returns:
223
+ Line number or None
224
+ """
225
+ # If issue has explicit line number, use it
226
+ if issue.line_number:
227
+ return issue.line_number
228
+
229
+ # Otherwise, use statement mapping
230
+ if issue.statement_index in line_mapping:
231
+ return line_mapping[issue.statement_index]
232
+
233
+ # Fallback: try to find specific field in file
234
+ search_term = issue.action or issue.resource or issue.condition_key
235
+ if search_term:
236
+ return self._search_for_field_line(policy_file, issue.statement_index, search_term)
237
+
238
+ return None
239
+
240
+ def _search_for_field_line(
241
+ self, policy_file: str, statement_idx: int, search_term: str
242
+ ) -> int | None:
243
+ """Search for a specific field within a statement.
244
+
245
+ Args:
246
+ policy_file: Path to policy file
247
+ statement_idx: Statement index
248
+ search_term: Term to search for
249
+
250
+ Returns:
251
+ Line number or None
252
+ """
253
+ try:
254
+ with open(policy_file, encoding="utf-8") as f:
255
+ lines = f.readlines()
256
+
257
+ # Find the statement block
258
+ statement_count = 0
259
+ in_statement = False
260
+ brace_depth = 0
261
+
262
+ for line_num, line in enumerate(lines, start=1):
263
+ stripped = line.strip()
264
+
265
+ # Track braces
266
+ brace_depth += stripped.count("{") - stripped.count("}")
267
+
268
+ # Detect statement start
269
+ if not in_statement and stripped.startswith("{") and brace_depth > 0:
270
+ if statement_count == statement_idx:
271
+ in_statement = True
272
+ continue
273
+ statement_count += 1
274
+
275
+ # Search within the statement
276
+ if in_statement:
277
+ if search_term in line:
278
+ return line_num
279
+
280
+ # Exit statement when braces balance
281
+ if brace_depth == 0:
282
+ in_statement = False
283
+
284
+ return None
285
+
286
+ except Exception as e:
287
+ logger.debug(f"Could not search {policy_file}: {e}")
288
+ return None
289
+
290
+
291
+ async def post_report_to_pr(
292
+ report_file: str,
293
+ create_review: bool = True,
294
+ add_summary: bool = True,
295
+ config_path: str | None = None,
296
+ ) -> bool:
297
+ """Post a JSON report to a PR.
298
+
299
+ Args:
300
+ report_file: Path to JSON report file
301
+ create_review: Whether to create line-specific review
302
+ add_summary: Whether to add summary comment
303
+ config_path: Optional path to config file (to get fail_on_severity)
304
+
305
+ Returns:
306
+ True if successful, False otherwise
307
+ """
308
+ try:
309
+ # Load report from JSON
310
+ with open(report_file, encoding="utf-8") as f:
311
+ report_data = json.load(f)
312
+
313
+ report = ValidationReport.model_validate(report_data)
314
+
315
+ # Load config to get fail_on_severity setting
316
+ from iam_validator.core.config_loader import ConfigLoader
317
+
318
+ config = ConfigLoader.load_config(config_path)
319
+ fail_on_severities = config.get_setting("fail_on_severity", ["error", "critical"])
320
+
321
+ # Post to PR
322
+ async with GitHubIntegration() as github:
323
+ commenter = PRCommenter(github, fail_on_severities=fail_on_severities)
324
+ return await commenter.post_findings_to_pr(
325
+ report,
326
+ create_review=create_review,
327
+ add_summary_comment=add_summary,
328
+ )
329
+
330
+ except FileNotFoundError:
331
+ logger.error(f"Report file not found: {report_file}")
332
+ return False
333
+ except json.JSONDecodeError as e:
334
+ logger.error(f"Invalid JSON in report file: {e}")
335
+ return False
336
+ except Exception as e:
337
+ logger.error(f"Failed to post report to PR: {e}")
338
+ return False