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,309 @@
|
|
|
1
|
+
"""Ignore command processor for CODEOWNERS-based finding suppression.
|
|
2
|
+
|
|
3
|
+
This module processes ignore commands from PR comments, validates
|
|
4
|
+
authorization via CODEOWNERS, and manages the ignored findings store.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from iam_validator.core.codeowners import CodeOwnersParser
|
|
15
|
+
from iam_validator.core.ignored_findings import IgnoredFinding, IgnoredFindingsStore
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from iam_validator.integrations.github_integration import GitHubIntegration
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IgnoreCommandProcessor:
|
|
24
|
+
"""Processes ignore commands from PR comments.
|
|
25
|
+
|
|
26
|
+
This processor:
|
|
27
|
+
1. Scans for replies to bot comments containing "ignore"
|
|
28
|
+
2. Verifies the replier is a CODEOWNER for the affected file
|
|
29
|
+
3. Adds authorized ignores to the ignored findings store
|
|
30
|
+
4. Posts denial replies for unauthorized attempts
|
|
31
|
+
|
|
32
|
+
Authorization flow:
|
|
33
|
+
1. Check if user is in allowed_users config (always applies)
|
|
34
|
+
2. Check if user is directly listed in CODEOWNERS for the file
|
|
35
|
+
3. Check if user is a member of a team listed in CODEOWNERS
|
|
36
|
+
|
|
37
|
+
If no CODEOWNERS file exists and allowed_users is empty, all
|
|
38
|
+
ignore requests are denied (fail secure).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
github: GitHubIntegration,
|
|
44
|
+
allowed_users: list[str] | None = None,
|
|
45
|
+
post_denial_feedback: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Initialize the processor.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
github: GitHub integration instance
|
|
51
|
+
allowed_users: Fallback list of users who can ignore findings
|
|
52
|
+
(used when CODEOWNERS is not available)
|
|
53
|
+
post_denial_feedback: Whether to post visible replies on denied ignores
|
|
54
|
+
"""
|
|
55
|
+
self.github = github
|
|
56
|
+
self.allowed_users = allowed_users or []
|
|
57
|
+
self.post_denial_feedback = post_denial_feedback
|
|
58
|
+
self.store = IgnoredFindingsStore(github)
|
|
59
|
+
self._codeowners_parser: CodeOwnersParser | None = None
|
|
60
|
+
self._codeowners_loaded = False
|
|
61
|
+
# Cache for authorization results: (username, file_path) -> bool
|
|
62
|
+
self._auth_cache: dict[tuple[str, str], bool] = {}
|
|
63
|
+
# Cache for team memberships: (org, team) -> list[str]
|
|
64
|
+
self._team_cache: dict[tuple[str, str], list[str]] = {}
|
|
65
|
+
|
|
66
|
+
async def process_pending_ignores(self) -> int:
|
|
67
|
+
"""Process all pending ignore commands.
|
|
68
|
+
|
|
69
|
+
Scans PR comments for ignore commands, validates authorization,
|
|
70
|
+
and adds to the ignored findings store.
|
|
71
|
+
|
|
72
|
+
Performance: Uses batch mode to save all findings in a single
|
|
73
|
+
GitHub API call instead of one per finding.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Number of findings newly ignored
|
|
77
|
+
"""
|
|
78
|
+
# Early exit: scan for ignore commands first
|
|
79
|
+
ignore_commands = await self.github.scan_for_ignore_commands()
|
|
80
|
+
|
|
81
|
+
if not ignore_commands:
|
|
82
|
+
logger.debug("No ignore commands found")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
logger.info(f"Processing {len(ignore_commands)} ignore command(s)")
|
|
86
|
+
|
|
87
|
+
# Load CODEOWNERS lazily (only if we have commands to process)
|
|
88
|
+
await self._load_codeowners()
|
|
89
|
+
|
|
90
|
+
# Collect all valid findings first, then save in batch
|
|
91
|
+
findings_to_add: list[IgnoredFinding] = []
|
|
92
|
+
|
|
93
|
+
for bot_comment, reply in ignore_commands:
|
|
94
|
+
finding = await self._process_single_ignore(bot_comment, reply)
|
|
95
|
+
if finding:
|
|
96
|
+
findings_to_add.append(finding)
|
|
97
|
+
|
|
98
|
+
# Batch save all findings in one API call
|
|
99
|
+
if findings_to_add:
|
|
100
|
+
success = await self.store.add_ignored_batch(findings_to_add)
|
|
101
|
+
if success:
|
|
102
|
+
logger.info(f"Successfully ignored {len(findings_to_add)} finding(s)")
|
|
103
|
+
else:
|
|
104
|
+
logger.warning("Failed to save ignored findings")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
return len(findings_to_add)
|
|
108
|
+
|
|
109
|
+
async def _process_single_ignore(
|
|
110
|
+
self,
|
|
111
|
+
bot_comment: dict[str, Any],
|
|
112
|
+
reply: dict[str, Any],
|
|
113
|
+
) -> IgnoredFinding | None:
|
|
114
|
+
"""Process a single ignore command.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
bot_comment: The bot comment being replied to
|
|
118
|
+
reply: The reply comment with ignore command
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
IgnoredFinding if valid and authorized, None otherwise
|
|
122
|
+
"""
|
|
123
|
+
# Extract required information
|
|
124
|
+
finding_id = self.github.extract_finding_id(bot_comment.get("body", ""))
|
|
125
|
+
file_path = bot_comment.get("path", "")
|
|
126
|
+
replier = reply.get("user", {}).get("login", "")
|
|
127
|
+
reply_body = reply.get("body", "")
|
|
128
|
+
|
|
129
|
+
if not finding_id:
|
|
130
|
+
logger.debug("Ignore command reply missing finding ID in bot comment")
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
if not file_path:
|
|
134
|
+
logger.debug("Ignore command reply missing file path")
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
if not replier:
|
|
138
|
+
logger.debug("Ignore command reply missing user")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
# Check if already ignored
|
|
142
|
+
if await self.store.is_ignored(finding_id):
|
|
143
|
+
logger.debug(f"Finding {finding_id} already ignored")
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
# Check authorization (with caching)
|
|
147
|
+
is_authorized = await self._is_authorized(replier, file_path)
|
|
148
|
+
|
|
149
|
+
if not is_authorized:
|
|
150
|
+
logger.info(f"Denied ignore from {replier} for {file_path} - not authorized")
|
|
151
|
+
await self._post_denial(reply, replier, file_path)
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
# Extract metadata from bot comment
|
|
155
|
+
check_id = self._extract_check_id(bot_comment.get("body", ""))
|
|
156
|
+
issue_type = self._extract_issue_type(bot_comment.get("body", ""))
|
|
157
|
+
reason = self.github.extract_ignore_reason(reply_body)
|
|
158
|
+
|
|
159
|
+
# Create the ignored finding (will be saved in batch)
|
|
160
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
161
|
+
finding = IgnoredFinding(
|
|
162
|
+
finding_id=finding_id,
|
|
163
|
+
file_path=file_path,
|
|
164
|
+
check_id=check_id,
|
|
165
|
+
issue_type=issue_type,
|
|
166
|
+
ignored_by=replier,
|
|
167
|
+
ignored_at=now,
|
|
168
|
+
reason=reason,
|
|
169
|
+
reply_comment_id=reply.get("id"), # Store for tamper verification
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
logger.debug(f"Prepared ignored finding {finding_id} by {replier}")
|
|
173
|
+
return finding
|
|
174
|
+
|
|
175
|
+
async def _load_codeowners(self) -> None:
|
|
176
|
+
"""Load and cache CODEOWNERS file."""
|
|
177
|
+
if self._codeowners_loaded:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
content = await self.github.get_codeowners_content()
|
|
181
|
+
if content:
|
|
182
|
+
self._codeowners_parser = CodeOwnersParser(content)
|
|
183
|
+
logger.debug("Loaded CODEOWNERS file")
|
|
184
|
+
else:
|
|
185
|
+
logger.debug("No CODEOWNERS file found")
|
|
186
|
+
|
|
187
|
+
self._codeowners_loaded = True
|
|
188
|
+
|
|
189
|
+
async def _is_authorized(self, username: str, file_path: str) -> bool:
|
|
190
|
+
"""Check if user is authorized to ignore findings for a file.
|
|
191
|
+
|
|
192
|
+
Uses caching to avoid repeated API calls.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
username: GitHub username
|
|
196
|
+
file_path: Path to the file
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
True if authorized
|
|
200
|
+
"""
|
|
201
|
+
cache_key = (username.lower(), file_path)
|
|
202
|
+
if cache_key in self._auth_cache:
|
|
203
|
+
return self._auth_cache[cache_key]
|
|
204
|
+
|
|
205
|
+
result = await self.github.is_user_codeowner(
|
|
206
|
+
username=username,
|
|
207
|
+
file_path=file_path,
|
|
208
|
+
codeowners_parser=self._codeowners_parser,
|
|
209
|
+
allowed_users=self.allowed_users,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
self._auth_cache[cache_key] = result
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
async def _post_denial(
|
|
216
|
+
self,
|
|
217
|
+
reply: dict[str, Any],
|
|
218
|
+
username: str,
|
|
219
|
+
file_path: str,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Post a reply explaining why the ignore was denied.
|
|
222
|
+
|
|
223
|
+
Posts a visible reply to the user if post_denial_feedback is enabled,
|
|
224
|
+
otherwise only logs the denial.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
reply: The reply comment that was denied
|
|
228
|
+
username: User who tried to ignore
|
|
229
|
+
file_path: File path they tried to ignore
|
|
230
|
+
"""
|
|
231
|
+
if self.post_denial_feedback:
|
|
232
|
+
message = (
|
|
233
|
+
f"@{username} You are not authorized to ignore findings for `{file_path}`. "
|
|
234
|
+
f"Only CODEOWNERS or users in `allowed_users` can ignore findings."
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
await self.github.post_reply_to_review_comment(reply["id"], message)
|
|
238
|
+
except Exception as e:
|
|
239
|
+
logger.warning(f"Failed to post denial feedback: {e}")
|
|
240
|
+
|
|
241
|
+
logger.warning(
|
|
242
|
+
f"Ignore request denied: @{username} is not authorized "
|
|
243
|
+
f"for {file_path} (not in CODEOWNERS or allowed_users)"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def _extract_check_id(self, body: str) -> str:
|
|
247
|
+
"""Extract check ID from bot comment body.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
body: Comment body text
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Check ID or empty string
|
|
254
|
+
"""
|
|
255
|
+
match = re.search(r"\*Check: `([^`]+)`\*", body)
|
|
256
|
+
return match.group(1) if match else ""
|
|
257
|
+
|
|
258
|
+
def _extract_issue_type(self, body: str) -> str:
|
|
259
|
+
"""Extract issue type from bot comment body.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
body: Comment body text
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Issue type or empty string
|
|
266
|
+
"""
|
|
267
|
+
match = re.search(r"<!-- issue-type: (\w+) -->", body)
|
|
268
|
+
return match.group(1) if match else ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def filter_ignored_findings(
|
|
272
|
+
github: GitHubIntegration,
|
|
273
|
+
findings: list[tuple[str, Any]], # List of (file_path, ValidationIssue)
|
|
274
|
+
) -> tuple[list[tuple[str, Any]], frozenset[str]]:
|
|
275
|
+
"""Filter out ignored findings from a list.
|
|
276
|
+
|
|
277
|
+
This is a convenience function for filtering findings before
|
|
278
|
+
determining exit code or posting comments.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
github: GitHub integration instance
|
|
282
|
+
findings: List of (file_path, issue) tuples
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Tuple of (filtered_findings, ignored_ids)
|
|
286
|
+
"""
|
|
287
|
+
from iam_validator.core.finding_fingerprint import FindingFingerprint
|
|
288
|
+
|
|
289
|
+
store = IgnoredFindingsStore(github)
|
|
290
|
+
ignored_ids = await store.get_ignored_ids()
|
|
291
|
+
|
|
292
|
+
if not ignored_ids:
|
|
293
|
+
return findings, frozenset()
|
|
294
|
+
|
|
295
|
+
filtered: list[tuple[str, Any]] = []
|
|
296
|
+
for file_path, issue in findings:
|
|
297
|
+
fingerprint = FindingFingerprint.from_issue(issue, file_path)
|
|
298
|
+
finding_id = fingerprint.to_hash()
|
|
299
|
+
|
|
300
|
+
if finding_id not in ignored_ids:
|
|
301
|
+
filtered.append((file_path, issue))
|
|
302
|
+
else:
|
|
303
|
+
logger.debug(f"Filtered out ignored finding {finding_id}")
|
|
304
|
+
|
|
305
|
+
filtered_count = len(findings) - len(filtered)
|
|
306
|
+
if filtered_count > 0:
|
|
307
|
+
logger.info(f"Filtered out {filtered_count} ignored finding(s)")
|
|
308
|
+
|
|
309
|
+
return filtered, ignored_ids
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Ignored findings storage for PR-scoped ignores.
|
|
2
|
+
|
|
3
|
+
This module manages persistent storage of ignored findings via a hidden
|
|
4
|
+
PR comment. Ignored findings are stored as JSON in a comment that
|
|
5
|
+
persists with the PR lifecycle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from iam_validator.core import constants
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from iam_validator.integrations.github_integration import GitHubIntegration
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# Storage format version for future compatibility
|
|
25
|
+
STORAGE_VERSION = 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class IgnoredFinding:
|
|
30
|
+
"""Record of an ignored finding.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
finding_id: Unique hash identifying the finding
|
|
34
|
+
file_path: Path to the policy file
|
|
35
|
+
check_id: Check that generated the finding
|
|
36
|
+
issue_type: Type of issue
|
|
37
|
+
ignored_by: Username who ignored the finding
|
|
38
|
+
ignored_at: ISO timestamp when ignored
|
|
39
|
+
reason: Optional reason provided by the user
|
|
40
|
+
reply_comment_id: ID of the reply comment for tamper verification
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
finding_id: str
|
|
44
|
+
file_path: str
|
|
45
|
+
check_id: str
|
|
46
|
+
issue_type: str
|
|
47
|
+
ignored_by: str
|
|
48
|
+
ignored_at: str
|
|
49
|
+
reason: str | None = None
|
|
50
|
+
reply_comment_id: int | None = None
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: dict[str, Any]) -> IgnoredFinding:
|
|
54
|
+
"""Create IgnoredFinding from dictionary.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: Dictionary with finding data
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
IgnoredFinding instance
|
|
61
|
+
"""
|
|
62
|
+
return cls(
|
|
63
|
+
finding_id=data.get("finding_id", ""),
|
|
64
|
+
file_path=data.get("file_path", ""),
|
|
65
|
+
check_id=data.get("check_id", ""),
|
|
66
|
+
issue_type=data.get("issue_type", ""),
|
|
67
|
+
ignored_by=data.get("ignored_by", ""),
|
|
68
|
+
ignored_at=data.get("ignored_at", ""),
|
|
69
|
+
reason=data.get("reason"),
|
|
70
|
+
reply_comment_id=data.get("reply_comment_id"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
"""Convert to dictionary for JSON serialization.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Dictionary representation
|
|
78
|
+
"""
|
|
79
|
+
return asdict(self)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class IgnoredFindingsStore:
|
|
83
|
+
"""Manages persistent storage of ignored findings via PR comment.
|
|
84
|
+
|
|
85
|
+
Storage is implemented as a hidden PR comment with JSON payload.
|
|
86
|
+
The comment is automatically created, updated, and managed.
|
|
87
|
+
|
|
88
|
+
Example storage format:
|
|
89
|
+
<!-- iam-policy-validator-ignored-findings -->
|
|
90
|
+
<!-- DO NOT EDIT: This comment tracks ignored validation findings -->
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"version": 1,
|
|
95
|
+
"ignored_findings": [
|
|
96
|
+
{
|
|
97
|
+
"finding_id": "abc123...",
|
|
98
|
+
"file_path": "policies/admin.json",
|
|
99
|
+
"check_id": "sensitive_action",
|
|
100
|
+
"ignored_by": "username",
|
|
101
|
+
"ignored_at": "2024-01-15T10:30:00Z",
|
|
102
|
+
"reason": "Approved by security team"
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, github: GitHubIntegration) -> None:
|
|
110
|
+
"""Initialize the store.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
github: GitHub integration instance
|
|
114
|
+
"""
|
|
115
|
+
self.github = github
|
|
116
|
+
self._cache: dict[str, IgnoredFinding] | None = None
|
|
117
|
+
self._comment_id: int | None = None
|
|
118
|
+
|
|
119
|
+
async def load(self) -> dict[str, IgnoredFinding]:
|
|
120
|
+
"""Load ignored findings from PR comment.
|
|
121
|
+
|
|
122
|
+
Returns cached data if available, otherwise fetches from GitHub.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dictionary mapping finding_id to IgnoredFinding
|
|
126
|
+
"""
|
|
127
|
+
if self._cache is not None:
|
|
128
|
+
return self._cache
|
|
129
|
+
|
|
130
|
+
comment = await self._find_storage_comment()
|
|
131
|
+
if not comment:
|
|
132
|
+
self._cache = {}
|
|
133
|
+
return self._cache
|
|
134
|
+
|
|
135
|
+
self._comment_id = comment.get("id")
|
|
136
|
+
body = comment.get("body", "")
|
|
137
|
+
data = self._parse_comment(body)
|
|
138
|
+
|
|
139
|
+
self._cache = {}
|
|
140
|
+
for finding_data in data.get("ignored_findings", []):
|
|
141
|
+
try:
|
|
142
|
+
finding = IgnoredFinding.from_dict(finding_data)
|
|
143
|
+
self._cache[finding.finding_id] = finding
|
|
144
|
+
except (KeyError, TypeError) as e:
|
|
145
|
+
logger.warning(f"Failed to parse ignored finding: {e}")
|
|
146
|
+
|
|
147
|
+
logger.debug(f"Loaded {len(self._cache)} ignored finding(s) from storage")
|
|
148
|
+
return self._cache
|
|
149
|
+
|
|
150
|
+
async def save(self) -> bool:
|
|
151
|
+
"""Save current ignored findings to PR comment.
|
|
152
|
+
|
|
153
|
+
Creates a new comment if none exists, or updates the existing one.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if save was successful
|
|
157
|
+
"""
|
|
158
|
+
if self._cache is None:
|
|
159
|
+
self._cache = {}
|
|
160
|
+
|
|
161
|
+
body = self._format_comment(self._cache)
|
|
162
|
+
|
|
163
|
+
if self._comment_id:
|
|
164
|
+
# Update existing comment
|
|
165
|
+
result = await self.github._update_comment(self._comment_id, body)
|
|
166
|
+
if result:
|
|
167
|
+
logger.debug("Updated ignored findings storage comment")
|
|
168
|
+
return True
|
|
169
|
+
logger.warning("Failed to update ignored findings storage comment")
|
|
170
|
+
return False
|
|
171
|
+
else:
|
|
172
|
+
# Create new comment
|
|
173
|
+
result = await self.github.post_comment(body)
|
|
174
|
+
if result:
|
|
175
|
+
# Find the comment ID for future updates
|
|
176
|
+
comment = await self._find_storage_comment()
|
|
177
|
+
if comment:
|
|
178
|
+
self._comment_id = comment.get("id")
|
|
179
|
+
logger.debug("Created ignored findings storage comment")
|
|
180
|
+
return True
|
|
181
|
+
logger.warning("Failed to create ignored findings storage comment")
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
async def add_ignored(self, finding: IgnoredFinding) -> bool:
|
|
185
|
+
"""Add a finding to the ignored list.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
finding: The finding to ignore
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if successfully added and saved
|
|
192
|
+
"""
|
|
193
|
+
findings = await self.load()
|
|
194
|
+
findings[finding.finding_id] = finding
|
|
195
|
+
self._cache = findings
|
|
196
|
+
return await self.save()
|
|
197
|
+
|
|
198
|
+
async def add_ignored_batch(self, new_findings: list[IgnoredFinding]) -> bool:
|
|
199
|
+
"""Add multiple findings to the ignored list in a single save.
|
|
200
|
+
|
|
201
|
+
More efficient than calling add_ignored() multiple times as it
|
|
202
|
+
only makes one GitHub API call to update the storage comment.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
new_findings: List of findings to ignore
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
True if successfully added and saved
|
|
209
|
+
"""
|
|
210
|
+
if not new_findings:
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
findings = await self.load()
|
|
214
|
+
for finding in new_findings:
|
|
215
|
+
findings[finding.finding_id] = finding
|
|
216
|
+
self._cache = findings
|
|
217
|
+
return await self.save()
|
|
218
|
+
|
|
219
|
+
async def is_ignored(self, finding_id: str) -> bool:
|
|
220
|
+
"""Check if a finding is ignored.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
finding_id: The finding ID hash to check
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if the finding is in the ignored list
|
|
227
|
+
"""
|
|
228
|
+
findings = await self.load()
|
|
229
|
+
return finding_id in findings
|
|
230
|
+
|
|
231
|
+
async def get_ignored_ids(self) -> frozenset[str]:
|
|
232
|
+
"""Get all ignored finding IDs as a frozenset.
|
|
233
|
+
|
|
234
|
+
Returns a frozenset for O(1) membership checking and immutability.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Frozenset of ignored finding IDs
|
|
238
|
+
"""
|
|
239
|
+
findings = await self.load()
|
|
240
|
+
return frozenset(findings.keys())
|
|
241
|
+
|
|
242
|
+
async def remove_ignored(self, finding_id: str) -> bool:
|
|
243
|
+
"""Remove a finding from the ignored list.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
finding_id: The finding ID hash to remove
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
True if successfully removed and saved
|
|
250
|
+
"""
|
|
251
|
+
findings = await self.load()
|
|
252
|
+
if finding_id in findings:
|
|
253
|
+
del findings[finding_id]
|
|
254
|
+
self._cache = findings
|
|
255
|
+
return await self.save()
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
async def _find_storage_comment(self) -> dict[str, Any] | None:
|
|
259
|
+
"""Find the storage comment on the PR.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Comment dict if found, None otherwise
|
|
263
|
+
"""
|
|
264
|
+
comments = await self.github.get_issue_comments()
|
|
265
|
+
|
|
266
|
+
for comment in comments:
|
|
267
|
+
if not isinstance(comment, dict):
|
|
268
|
+
continue
|
|
269
|
+
body = comment.get("body", "")
|
|
270
|
+
if constants.IGNORED_FINDINGS_IDENTIFIER in str(body):
|
|
271
|
+
return comment
|
|
272
|
+
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
def _parse_comment(self, body: str) -> dict[str, Any]:
|
|
276
|
+
"""Extract JSON data from comment body.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
body: Comment body text
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Parsed JSON data or empty default structure
|
|
283
|
+
"""
|
|
284
|
+
# Look for JSON code block
|
|
285
|
+
match = re.search(r"```json\n(.*?)\n```", body, re.DOTALL)
|
|
286
|
+
if match:
|
|
287
|
+
try:
|
|
288
|
+
data = json.loads(match.group(1))
|
|
289
|
+
# Validate version
|
|
290
|
+
if data.get("version", 0) > STORAGE_VERSION:
|
|
291
|
+
logger.warning(
|
|
292
|
+
f"Storage version {data.get('version')} is newer than "
|
|
293
|
+
f"supported version {STORAGE_VERSION}"
|
|
294
|
+
)
|
|
295
|
+
return data
|
|
296
|
+
except json.JSONDecodeError as e:
|
|
297
|
+
logger.warning(f"Failed to parse ignored findings JSON: {e}")
|
|
298
|
+
|
|
299
|
+
return {"version": STORAGE_VERSION, "ignored_findings": []}
|
|
300
|
+
|
|
301
|
+
def _format_comment(self, findings: dict[str, IgnoredFinding]) -> str:
|
|
302
|
+
"""Format findings as a storage comment.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
findings: Dictionary of ignored findings
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Formatted comment body
|
|
309
|
+
"""
|
|
310
|
+
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
311
|
+
|
|
312
|
+
data = {
|
|
313
|
+
"version": STORAGE_VERSION,
|
|
314
|
+
"ignored_findings": [f.to_dict() for f in findings.values()],
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
json_str = json.dumps(data, indent=2, sort_keys=True)
|
|
318
|
+
|
|
319
|
+
return f"""{constants.IGNORED_FINDINGS_IDENTIFIER}
|
|
320
|
+
<!-- DO NOT EDIT: This comment tracks ignored validation findings -->
|
|
321
|
+
<!-- Last updated: {now} -->
|
|
322
|
+
<!-- Findings can be ignored by CODEOWNERS replying "ignore" to validation comments -->
|
|
323
|
+
|
|
324
|
+
<details>
|
|
325
|
+
<summary>📋 Ignored Findings ({len(findings)})</summary>
|
|
326
|
+
|
|
327
|
+
```json
|
|
328
|
+
{json_str}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
</details>
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def invalidate_cache(self) -> None:
|
|
335
|
+
"""Invalidate the cache to force reload on next access."""
|
|
336
|
+
self._cache = None
|
|
337
|
+
self._comment_id = None
|
|
338
|
+
|
|
339
|
+
async def verify_ignored_findings(self) -> list[str]:
|
|
340
|
+
"""Verify all ignored findings have valid reply comments.
|
|
341
|
+
|
|
342
|
+
Checks that the original reply comment still exists and was authored
|
|
343
|
+
by the user recorded in ignored_by. This prevents tampering with the
|
|
344
|
+
JSON storage by manually editing the comment.
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
List of finding_ids that are no longer valid (should be removed).
|
|
348
|
+
"""
|
|
349
|
+
findings = await self.load()
|
|
350
|
+
invalid_ids: list[str] = []
|
|
351
|
+
|
|
352
|
+
for finding_id, finding in findings.items():
|
|
353
|
+
if not finding.reply_comment_id:
|
|
354
|
+
# Legacy finding without ID - skip verification
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# Try to fetch the comment
|
|
358
|
+
comment = await self.github.get_comment_by_id(finding.reply_comment_id)
|
|
359
|
+
if not comment:
|
|
360
|
+
# Comment was deleted - ignore is invalid
|
|
361
|
+
logger.warning(
|
|
362
|
+
f"Reply comment {finding.reply_comment_id} for finding {finding_id} was deleted"
|
|
363
|
+
)
|
|
364
|
+
invalid_ids.append(finding_id)
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
# Verify author matches stored ignored_by
|
|
368
|
+
author = comment.get("user", {}).get("login", "").lower()
|
|
369
|
+
if author != finding.ignored_by.lower():
|
|
370
|
+
logger.warning(
|
|
371
|
+
f"Author mismatch for finding {finding_id}: "
|
|
372
|
+
f"stored={finding.ignored_by}, actual={author}"
|
|
373
|
+
)
|
|
374
|
+
invalid_ids.append(finding_id)
|
|
375
|
+
|
|
376
|
+
if invalid_ids:
|
|
377
|
+
logger.info(f"Found {len(invalid_ids)} invalid ignored finding(s)")
|
|
378
|
+
|
|
379
|
+
return invalid_ids
|
|
380
|
+
|
|
381
|
+
async def remove_invalid_findings(self) -> int:
|
|
382
|
+
"""Verify and remove invalid ignored findings.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Number of findings removed.
|
|
386
|
+
"""
|
|
387
|
+
invalid_ids = await self.verify_ignored_findings()
|
|
388
|
+
if not invalid_ids:
|
|
389
|
+
return 0
|
|
390
|
+
|
|
391
|
+
findings = await self.load()
|
|
392
|
+
for finding_id in invalid_ids:
|
|
393
|
+
if finding_id in findings:
|
|
394
|
+
del findings[finding_id]
|
|
395
|
+
|
|
396
|
+
self._cache = findings
|
|
397
|
+
await self.save()
|
|
398
|
+
|
|
399
|
+
logger.info(f"Removed {len(invalid_ids)} invalid ignored finding(s)")
|
|
400
|
+
return len(invalid_ids)
|