iam-policy-validator 1.10.3__py3-none-any.whl → 1.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iam_policy_validator-1.11.0.dist-info/METADATA +782 -0
- {iam_policy_validator-1.10.3.dist-info → iam_policy_validator-1.11.0.dist-info}/RECORD +25 -21
- iam_validator/__version__.py +1 -1
- iam_validator/checks/action_condition_enforcement.py +27 -14
- iam_validator/checks/sensitive_action.py +123 -11
- iam_validator/checks/utils/policy_level_checks.py +47 -10
- iam_validator/commands/__init__.py +6 -0
- iam_validator/commands/completion.py +420 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +21 -26
- iam_validator/core/config/category_suggestions.py +77 -0
- iam_validator/core/config/condition_requirements.py +105 -54
- iam_validator/core/config/defaults.py +82 -6
- iam_validator/core/config/wildcards.py +3 -0
- iam_validator/core/diff_parser.py +321 -0
- iam_validator/core/formatters/enhanced.py +34 -27
- iam_validator/core/models.py +2 -0
- iam_validator/core/pr_commenter.py +179 -51
- iam_validator/core/report.py +19 -17
- iam_validator/integrations/github_integration.py +250 -1
- iam_validator/sdk/__init__.py +33 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_policy_validator-1.10.3.dist-info/METADATA +0 -549
- {iam_policy_validator-1.10.3.dist-info → iam_policy_validator-1.11.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.10.3.dist-info → iam_policy_validator-1.11.0.dist-info}/entry_points.txt +0 -0
- {iam_policy_validator-1.10.3.dist-info → iam_policy_validator-1.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -291,7 +291,7 @@ class GitHubIntegration:
|
|
|
291
291
|
except httpx.HTTPStatusError as e:
|
|
292
292
|
logger.error(f"HTTP error: {e.response.status_code} - {e.response.text}")
|
|
293
293
|
return None
|
|
294
|
-
except Exception as e:
|
|
294
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
295
295
|
logger.error(f"Request failed: {e}")
|
|
296
296
|
return None
|
|
297
297
|
|
|
@@ -486,6 +486,57 @@ class GitHubIntegration:
|
|
|
486
486
|
return result
|
|
487
487
|
return []
|
|
488
488
|
|
|
489
|
+
async def get_bot_review_comments_with_location(
|
|
490
|
+
self, identifier: str = constants.BOT_IDENTIFIER
|
|
491
|
+
) -> dict[tuple[str, int, str], dict[str, Any]]:
|
|
492
|
+
"""Get bot review comments indexed by file path, line number, and issue type.
|
|
493
|
+
|
|
494
|
+
This enables efficient lookup to update existing comments.
|
|
495
|
+
Uses (path, line, issue_type) as key to support multiple issues at the same line.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
identifier: String to identify bot comments
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
Dict mapping (file_path, line_number, issue_type) to comment metadata dict
|
|
502
|
+
Comment dict contains: id, body, path, line, issue_type, commit_id
|
|
503
|
+
"""
|
|
504
|
+
comments = await self.get_review_comments()
|
|
505
|
+
bot_comments_map: dict[tuple[str, int, str], dict[str, Any]] = {}
|
|
506
|
+
|
|
507
|
+
for comment in comments:
|
|
508
|
+
if not isinstance(comment, dict):
|
|
509
|
+
continue
|
|
510
|
+
|
|
511
|
+
body = comment.get("body", "")
|
|
512
|
+
comment_id = comment.get("id")
|
|
513
|
+
path = comment.get("path")
|
|
514
|
+
line = comment.get("line") or comment.get("original_line")
|
|
515
|
+
|
|
516
|
+
# Check if this is a bot comment with valid location
|
|
517
|
+
if (
|
|
518
|
+
identifier in str(body)
|
|
519
|
+
and isinstance(comment_id, int)
|
|
520
|
+
and isinstance(path, str)
|
|
521
|
+
and isinstance(line, int)
|
|
522
|
+
):
|
|
523
|
+
# Extract issue type from HTML comment
|
|
524
|
+
issue_type_match = re.search(r"<!-- issue-type: (\w+) -->", body)
|
|
525
|
+
issue_type = issue_type_match.group(1) if issue_type_match else "unknown"
|
|
526
|
+
|
|
527
|
+
key = (path, line, issue_type)
|
|
528
|
+
bot_comments_map[key] = {
|
|
529
|
+
"id": comment_id,
|
|
530
|
+
"body": body,
|
|
531
|
+
"path": path,
|
|
532
|
+
"line": line,
|
|
533
|
+
"issue_type": issue_type,
|
|
534
|
+
"commit_id": comment.get("commit_id"),
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
logger.debug(f"Found {len(bot_comments_map)} bot review comments at specific locations")
|
|
538
|
+
return bot_comments_map
|
|
539
|
+
|
|
489
540
|
async def delete_review_comment(self, comment_id: int) -> bool:
|
|
490
541
|
"""Delete a specific review comment.
|
|
491
542
|
|
|
@@ -505,6 +556,47 @@ class GitHubIntegration:
|
|
|
505
556
|
return True
|
|
506
557
|
return False
|
|
507
558
|
|
|
559
|
+
async def resolve_review_comment(self, comment_id: int) -> bool:
|
|
560
|
+
"""Resolve a specific review comment.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
comment_id: ID of the comment to resolve
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
True if successful, False otherwise
|
|
567
|
+
"""
|
|
568
|
+
result = await self._make_request(
|
|
569
|
+
"PATCH",
|
|
570
|
+
f"pulls/comments/{comment_id}",
|
|
571
|
+
json={"state": "resolved"},
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
if result is not None:
|
|
575
|
+
logger.info(f"Successfully resolved review comment {comment_id}")
|
|
576
|
+
return True
|
|
577
|
+
return False
|
|
578
|
+
|
|
579
|
+
async def update_review_comment(self, comment_id: int, new_body: str) -> bool:
|
|
580
|
+
"""Update the body text of an existing review comment.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
comment_id: ID of the comment to update
|
|
584
|
+
new_body: New comment text (markdown supported)
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
True if successful, False otherwise
|
|
588
|
+
"""
|
|
589
|
+
result = await self._make_request(
|
|
590
|
+
"PATCH",
|
|
591
|
+
f"pulls/comments/{comment_id}",
|
|
592
|
+
json={"body": new_body},
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if result is not None:
|
|
596
|
+
logger.debug(f"Successfully updated review comment {comment_id}")
|
|
597
|
+
return True
|
|
598
|
+
return False
|
|
599
|
+
|
|
508
600
|
async def cleanup_bot_review_comments(self, identifier: str = constants.BOT_IDENTIFIER) -> int:
|
|
509
601
|
"""Delete all review comments from the bot (from previous runs).
|
|
510
602
|
|
|
@@ -536,6 +628,40 @@ class GitHubIntegration:
|
|
|
536
628
|
|
|
537
629
|
return deleted_count
|
|
538
630
|
|
|
631
|
+
async def cleanup_bot_review_comments_by_resolving(
|
|
632
|
+
self, identifier: str = constants.BOT_IDENTIFIER
|
|
633
|
+
) -> int:
|
|
634
|
+
"""Resolve all review comments from the bot (from previous runs).
|
|
635
|
+
|
|
636
|
+
This marks old/outdated comments as resolved instead of deleting them,
|
|
637
|
+
preserving them in the PR for audit trail purposes.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
identifier: String to identify bot comments
|
|
641
|
+
|
|
642
|
+
Returns:
|
|
643
|
+
Number of comments resolved
|
|
644
|
+
"""
|
|
645
|
+
comments = await self.get_review_comments()
|
|
646
|
+
resolved_count = 0
|
|
647
|
+
|
|
648
|
+
for comment in comments:
|
|
649
|
+
if not isinstance(comment, dict):
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
body = comment.get("body", "")
|
|
653
|
+
comment_id = comment.get("id")
|
|
654
|
+
|
|
655
|
+
# Check if this is a bot comment
|
|
656
|
+
if identifier in str(body) and isinstance(comment_id, int):
|
|
657
|
+
if await self.resolve_review_comment(comment_id):
|
|
658
|
+
resolved_count += 1
|
|
659
|
+
|
|
660
|
+
if resolved_count > 0:
|
|
661
|
+
logger.info(f"Resolved {resolved_count} old review comments")
|
|
662
|
+
|
|
663
|
+
return resolved_count
|
|
664
|
+
|
|
539
665
|
async def create_review_comment(
|
|
540
666
|
self,
|
|
541
667
|
commit_id: str,
|
|
@@ -646,6 +772,129 @@ class GitHubIntegration:
|
|
|
646
772
|
return True
|
|
647
773
|
return False
|
|
648
774
|
|
|
775
|
+
async def update_or_create_review_comments(
|
|
776
|
+
self,
|
|
777
|
+
comments: list[dict[str, Any]],
|
|
778
|
+
body: str = "",
|
|
779
|
+
event: ReviewEvent = ReviewEvent.COMMENT,
|
|
780
|
+
identifier: str = constants.REVIEW_IDENTIFIER,
|
|
781
|
+
) -> bool:
|
|
782
|
+
"""Smart comment management: update existing, create new, delete resolved.
|
|
783
|
+
|
|
784
|
+
This method implements a three-step process:
|
|
785
|
+
1. Fetch existing bot comments at each location
|
|
786
|
+
2. For each new comment: update if exists, create if new
|
|
787
|
+
3. Delete old comments where issues have been resolved
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
comments: List of comment dicts with keys: path, line, body, (optional) side
|
|
791
|
+
body: The overall review body text
|
|
792
|
+
event: The review event type (APPROVE, REQUEST_CHANGES, COMMENT)
|
|
793
|
+
identifier: String to identify bot comments (for matching existing)
|
|
794
|
+
|
|
795
|
+
Returns:
|
|
796
|
+
True if successful, False otherwise
|
|
797
|
+
|
|
798
|
+
Example:
|
|
799
|
+
# First run: Creates 3 comments
|
|
800
|
+
comments = [
|
|
801
|
+
{"path": "policy.json", "line": 5, "body": "Issue A"},
|
|
802
|
+
{"path": "policy.json", "line": 10, "body": "Issue B"},
|
|
803
|
+
{"path": "policy.json", "line": 15, "body": "Issue C"},
|
|
804
|
+
]
|
|
805
|
+
|
|
806
|
+
# Second run: Updates Issue A, keeps B, deletes C (resolved), adds D
|
|
807
|
+
comments = [
|
|
808
|
+
{"path": "policy.json", "line": 5, "body": "Issue A (updated)"},
|
|
809
|
+
{"path": "policy.json", "line": 10, "body": "Issue B"}, # Same = no update
|
|
810
|
+
{"path": "policy.json", "line": 20, "body": "Issue D"}, # New
|
|
811
|
+
]
|
|
812
|
+
# Result: line 15 comment deleted (resolved), line 5 updated, line 20 created
|
|
813
|
+
"""
|
|
814
|
+
# Step 1: Get existing bot comments mapped by location
|
|
815
|
+
existing_comments = await self.get_bot_review_comments_with_location(identifier)
|
|
816
|
+
logger.debug(f"Found {len(existing_comments)} existing bot comments")
|
|
817
|
+
|
|
818
|
+
# Track which existing comments we've seen (to know what to delete later)
|
|
819
|
+
seen_locations: set[tuple[str, int, str]] = set()
|
|
820
|
+
updated_count = 0
|
|
821
|
+
created_count = 0
|
|
822
|
+
|
|
823
|
+
# Step 2: Update or create each new comment
|
|
824
|
+
new_comments_for_review: list[dict[str, Any]] = []
|
|
825
|
+
|
|
826
|
+
for comment in comments:
|
|
827
|
+
path = comment["path"]
|
|
828
|
+
line = comment["line"]
|
|
829
|
+
new_body = comment["body"]
|
|
830
|
+
|
|
831
|
+
# Extract issue type from comment body HTML comment
|
|
832
|
+
issue_type_match = re.search(r"<!-- issue-type: (\w+) -->", new_body)
|
|
833
|
+
issue_type = issue_type_match.group(1) if issue_type_match else "unknown"
|
|
834
|
+
|
|
835
|
+
location = (path, line, issue_type)
|
|
836
|
+
seen_locations.add(location)
|
|
837
|
+
|
|
838
|
+
existing = existing_comments.get(location)
|
|
839
|
+
|
|
840
|
+
if existing:
|
|
841
|
+
# Comment exists at this location - check if body changed
|
|
842
|
+
if existing["body"] != new_body:
|
|
843
|
+
# Update the existing comment
|
|
844
|
+
success = await self.update_review_comment(existing["id"], new_body)
|
|
845
|
+
if success:
|
|
846
|
+
updated_count += 1
|
|
847
|
+
logger.debug(f"Updated comment at {path}:{line}")
|
|
848
|
+
else:
|
|
849
|
+
logger.warning(f"Failed to update comment at {path}:{line}")
|
|
850
|
+
else:
|
|
851
|
+
# Body unchanged, skip update
|
|
852
|
+
logger.debug(f"Comment at {path}:{line} unchanged, skipping update")
|
|
853
|
+
else:
|
|
854
|
+
# New comment - collect for batch creation
|
|
855
|
+
new_comments_for_review.append(comment)
|
|
856
|
+
|
|
857
|
+
# Step 3: Create new comments via review API (if any)
|
|
858
|
+
if new_comments_for_review:
|
|
859
|
+
success = await self.create_review_with_comments(
|
|
860
|
+
new_comments_for_review,
|
|
861
|
+
body=body,
|
|
862
|
+
event=event,
|
|
863
|
+
)
|
|
864
|
+
if success:
|
|
865
|
+
created_count = len(new_comments_for_review)
|
|
866
|
+
logger.info(f"Created {created_count} new review comments")
|
|
867
|
+
else:
|
|
868
|
+
logger.error("Failed to create new review comments")
|
|
869
|
+
return False
|
|
870
|
+
|
|
871
|
+
# Step 4: Delete comments for resolved issues (not in new comment set)
|
|
872
|
+
# IMPORTANT: Only delete comments for files that are in the current batch
|
|
873
|
+
# to avoid deleting comments from other files processed in the same run
|
|
874
|
+
deleted_count = 0
|
|
875
|
+
files_in_batch = {comment["path"] for comment in comments}
|
|
876
|
+
|
|
877
|
+
for location, existing in existing_comments.items():
|
|
878
|
+
# Only delete if:
|
|
879
|
+
# 1. This location is not in the new comment set (resolved issue)
|
|
880
|
+
# 2. AND this file is in the current batch (don't touch other files' comments)
|
|
881
|
+
if location not in seen_locations and existing["path"] in files_in_batch:
|
|
882
|
+
# This comment location is no longer in the new issues - delete it
|
|
883
|
+
success = await self.delete_review_comment(existing["id"])
|
|
884
|
+
if success:
|
|
885
|
+
deleted_count += 1
|
|
886
|
+
logger.debug(
|
|
887
|
+
f"Deleted resolved comment at {existing['path']}:{existing['line']}"
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Summary
|
|
891
|
+
logger.info(
|
|
892
|
+
f"Review comment management: {updated_count} updated, "
|
|
893
|
+
f"{created_count} created, {deleted_count} deleted (resolved)"
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
return True
|
|
897
|
+
|
|
649
898
|
# ==================== PR Labels ====================
|
|
650
899
|
|
|
651
900
|
async def add_labels(self, labels: list[str]) -> bool:
|
iam_validator/sdk/__init__.py
CHANGED
|
@@ -23,6 +23,14 @@ Quick Start:
|
|
|
23
23
|
>>> summary = get_policy_summary(policy)
|
|
24
24
|
>>> print(f"Actions: {summary['action_count']}")
|
|
25
25
|
|
|
26
|
+
Query AWS service definitions:
|
|
27
|
+
>>> from iam_validator.sdk import AWSServiceFetcher, query_actions, query_arn_formats
|
|
28
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
29
|
+
... # Query all S3 write actions
|
|
30
|
+
... write_actions = await query_actions(fetcher, "s3", access_level="write")
|
|
31
|
+
... # Get ARN formats for S3
|
|
32
|
+
... arns = await query_arn_formats(fetcher, "s3")
|
|
33
|
+
|
|
26
34
|
Custom check development:
|
|
27
35
|
>>> from iam_validator.sdk import PolicyCheck, CheckHelper
|
|
28
36
|
>>> class MyCheck(PolicyCheck):
|
|
@@ -108,6 +116,20 @@ from iam_validator.sdk.policy_utils import (
|
|
|
108
116
|
policy_to_dict,
|
|
109
117
|
policy_to_json,
|
|
110
118
|
)
|
|
119
|
+
|
|
120
|
+
# === Query utilities (AWS service definition queries) ===
|
|
121
|
+
from iam_validator.sdk.query_utils import (
|
|
122
|
+
get_actions_by_access_level,
|
|
123
|
+
get_actions_supporting_condition,
|
|
124
|
+
get_wildcard_only_actions,
|
|
125
|
+
query_action_details,
|
|
126
|
+
query_actions,
|
|
127
|
+
query_arn_format,
|
|
128
|
+
query_arn_formats,
|
|
129
|
+
query_arn_types,
|
|
130
|
+
query_condition_key,
|
|
131
|
+
query_condition_keys,
|
|
132
|
+
)
|
|
111
133
|
from iam_validator.sdk.shortcuts import (
|
|
112
134
|
count_issues_by_severity,
|
|
113
135
|
get_issues,
|
|
@@ -143,6 +165,17 @@ __all__ = [
|
|
|
143
165
|
"policy_to_dict",
|
|
144
166
|
"is_resource_policy",
|
|
145
167
|
"has_public_access",
|
|
168
|
+
# === Query utilities ===
|
|
169
|
+
"query_actions",
|
|
170
|
+
"query_action_details",
|
|
171
|
+
"query_arn_formats",
|
|
172
|
+
"query_arn_types",
|
|
173
|
+
"query_arn_format",
|
|
174
|
+
"query_condition_keys",
|
|
175
|
+
"query_condition_key",
|
|
176
|
+
"get_actions_by_access_level",
|
|
177
|
+
"get_wildcard_only_actions",
|
|
178
|
+
"get_actions_supporting_condition",
|
|
146
179
|
# === ARN utilities ===
|
|
147
180
|
"arn_matches",
|
|
148
181
|
"arn_strictly_valid",
|