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.
Files changed (106) hide show
  1. iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
  2. iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
  3. iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.14.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 +9 -0
  9. iam_validator/checks/__init__.py +45 -0
  10. iam_validator/checks/action_condition_enforcement.py +1442 -0
  11. iam_validator/checks/action_resource_matching.py +472 -0
  12. iam_validator/checks/action_validation.py +67 -0
  13. iam_validator/checks/condition_key_validation.py +88 -0
  14. iam_validator/checks/condition_type_mismatch.py +257 -0
  15. iam_validator/checks/full_wildcard.py +62 -0
  16. iam_validator/checks/mfa_condition_check.py +105 -0
  17. iam_validator/checks/policy_size.py +114 -0
  18. iam_validator/checks/policy_structure.py +556 -0
  19. iam_validator/checks/policy_type_validation.py +331 -0
  20. iam_validator/checks/principal_validation.py +708 -0
  21. iam_validator/checks/resource_validation.py +135 -0
  22. iam_validator/checks/sensitive_action.py +438 -0
  23. iam_validator/checks/service_wildcard.py +98 -0
  24. iam_validator/checks/set_operator_validation.py +153 -0
  25. iam_validator/checks/sid_uniqueness.py +146 -0
  26. iam_validator/checks/trust_policy_validation.py +509 -0
  27. iam_validator/checks/utils/__init__.py +17 -0
  28. iam_validator/checks/utils/action_parser.py +149 -0
  29. iam_validator/checks/utils/policy_level_checks.py +190 -0
  30. iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
  31. iam_validator/checks/utils/wildcard_expansion.py +86 -0
  32. iam_validator/checks/wildcard_action.py +58 -0
  33. iam_validator/checks/wildcard_resource.py +374 -0
  34. iam_validator/commands/__init__.py +31 -0
  35. iam_validator/commands/analyze.py +549 -0
  36. iam_validator/commands/base.py +48 -0
  37. iam_validator/commands/cache.py +393 -0
  38. iam_validator/commands/completion.py +471 -0
  39. iam_validator/commands/download_services.py +255 -0
  40. iam_validator/commands/post_to_pr.py +86 -0
  41. iam_validator/commands/query.py +485 -0
  42. iam_validator/commands/validate.py +830 -0
  43. iam_validator/core/__init__.py +13 -0
  44. iam_validator/core/access_analyzer.py +671 -0
  45. iam_validator/core/access_analyzer_report.py +640 -0
  46. iam_validator/core/aws_fetcher.py +29 -0
  47. iam_validator/core/aws_service/__init__.py +21 -0
  48. iam_validator/core/aws_service/cache.py +108 -0
  49. iam_validator/core/aws_service/client.py +205 -0
  50. iam_validator/core/aws_service/fetcher.py +641 -0
  51. iam_validator/core/aws_service/parsers.py +149 -0
  52. iam_validator/core/aws_service/patterns.py +51 -0
  53. iam_validator/core/aws_service/storage.py +291 -0
  54. iam_validator/core/aws_service/validators.py +380 -0
  55. iam_validator/core/check_registry.py +679 -0
  56. iam_validator/core/cli.py +134 -0
  57. iam_validator/core/codeowners.py +245 -0
  58. iam_validator/core/condition_validators.py +626 -0
  59. iam_validator/core/config/__init__.py +81 -0
  60. iam_validator/core/config/aws_api.py +35 -0
  61. iam_validator/core/config/aws_global_conditions.py +160 -0
  62. iam_validator/core/config/category_suggestions.py +181 -0
  63. iam_validator/core/config/check_documentation.py +390 -0
  64. iam_validator/core/config/condition_requirements.py +258 -0
  65. iam_validator/core/config/config_loader.py +670 -0
  66. iam_validator/core/config/defaults.py +739 -0
  67. iam_validator/core/config/principal_requirements.py +421 -0
  68. iam_validator/core/config/sensitive_actions.py +672 -0
  69. iam_validator/core/config/service_principals.py +132 -0
  70. iam_validator/core/config/wildcards.py +127 -0
  71. iam_validator/core/constants.py +149 -0
  72. iam_validator/core/diff_parser.py +325 -0
  73. iam_validator/core/finding_fingerprint.py +131 -0
  74. iam_validator/core/formatters/__init__.py +27 -0
  75. iam_validator/core/formatters/base.py +147 -0
  76. iam_validator/core/formatters/console.py +68 -0
  77. iam_validator/core/formatters/csv.py +171 -0
  78. iam_validator/core/formatters/enhanced.py +481 -0
  79. iam_validator/core/formatters/html.py +672 -0
  80. iam_validator/core/formatters/json.py +33 -0
  81. iam_validator/core/formatters/markdown.py +64 -0
  82. iam_validator/core/formatters/sarif.py +251 -0
  83. iam_validator/core/ignore_patterns.py +297 -0
  84. iam_validator/core/ignore_processor.py +309 -0
  85. iam_validator/core/ignored_findings.py +400 -0
  86. iam_validator/core/label_manager.py +197 -0
  87. iam_validator/core/models.py +404 -0
  88. iam_validator/core/policy_checks.py +220 -0
  89. iam_validator/core/policy_loader.py +785 -0
  90. iam_validator/core/pr_commenter.py +780 -0
  91. iam_validator/core/report.py +942 -0
  92. iam_validator/integrations/__init__.py +28 -0
  93. iam_validator/integrations/github_integration.py +1821 -0
  94. iam_validator/integrations/ms_teams.py +442 -0
  95. iam_validator/sdk/__init__.py +220 -0
  96. iam_validator/sdk/arn_matching.py +382 -0
  97. iam_validator/sdk/context.py +222 -0
  98. iam_validator/sdk/exceptions.py +48 -0
  99. iam_validator/sdk/helpers.py +177 -0
  100. iam_validator/sdk/policy_utils.py +451 -0
  101. iam_validator/sdk/query_utils.py +454 -0
  102. iam_validator/sdk/shortcuts.py +283 -0
  103. iam_validator/utils/__init__.py +35 -0
  104. iam_validator/utils/cache.py +105 -0
  105. iam_validator/utils/regex.py +205 -0
  106. 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)