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.
- iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
- iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
- iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +9 -0
- iam_validator/checks/__init__.py +45 -0
- iam_validator/checks/action_condition_enforcement.py +1442 -0
- iam_validator/checks/action_resource_matching.py +472 -0
- iam_validator/checks/action_validation.py +67 -0
- iam_validator/checks/condition_key_validation.py +88 -0
- iam_validator/checks/condition_type_mismatch.py +257 -0
- iam_validator/checks/full_wildcard.py +62 -0
- iam_validator/checks/mfa_condition_check.py +105 -0
- iam_validator/checks/policy_size.py +114 -0
- iam_validator/checks/policy_structure.py +556 -0
- iam_validator/checks/policy_type_validation.py +331 -0
- iam_validator/checks/principal_validation.py +708 -0
- iam_validator/checks/resource_validation.py +135 -0
- iam_validator/checks/sensitive_action.py +438 -0
- iam_validator/checks/service_wildcard.py +98 -0
- iam_validator/checks/set_operator_validation.py +153 -0
- iam_validator/checks/sid_uniqueness.py +146 -0
- iam_validator/checks/trust_policy_validation.py +509 -0
- iam_validator/checks/utils/__init__.py +17 -0
- iam_validator/checks/utils/action_parser.py +149 -0
- iam_validator/checks/utils/policy_level_checks.py +190 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
- iam_validator/checks/utils/wildcard_expansion.py +86 -0
- iam_validator/checks/wildcard_action.py +58 -0
- iam_validator/checks/wildcard_resource.py +374 -0
- iam_validator/commands/__init__.py +31 -0
- iam_validator/commands/analyze.py +549 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +393 -0
- iam_validator/commands/completion.py +471 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +830 -0
- iam_validator/core/__init__.py +13 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +29 -0
- iam_validator/core/aws_service/__init__.py +21 -0
- iam_validator/core/aws_service/cache.py +108 -0
- iam_validator/core/aws_service/client.py +205 -0
- iam_validator/core/aws_service/fetcher.py +641 -0
- iam_validator/core/aws_service/parsers.py +149 -0
- iam_validator/core/aws_service/patterns.py +51 -0
- iam_validator/core/aws_service/storage.py +291 -0
- iam_validator/core/aws_service/validators.py +380 -0
- iam_validator/core/check_registry.py +679 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/codeowners.py +245 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +181 -0
- iam_validator/core/config/check_documentation.py +390 -0
- iam_validator/core/config/condition_requirements.py +258 -0
- iam_validator/core/config/config_loader.py +670 -0
- iam_validator/core/config/defaults.py +739 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +132 -0
- iam_validator/core/config/wildcards.py +127 -0
- iam_validator/core/constants.py +149 -0
- iam_validator/core/diff_parser.py +325 -0
- iam_validator/core/finding_fingerprint.py +131 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +68 -0
- iam_validator/core/formatters/csv.py +171 -0
- iam_validator/core/formatters/enhanced.py +481 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +64 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/ignore_processor.py +309 -0
- iam_validator/core/ignored_findings.py +400 -0
- iam_validator/core/label_manager.py +197 -0
- iam_validator/core/models.py +404 -0
- iam_validator/core/policy_checks.py +220 -0
- iam_validator/core/policy_loader.py +785 -0
- iam_validator/core/pr_commenter.py +780 -0
- iam_validator/core/report.py +942 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +1821 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +220 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +451 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +35 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +205 -0
- 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
|