iam-policy-validator 1.14.2__py3-none-any.whl → 1.14.4__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.2.dist-info → iam_policy_validator-1.14.4.dist-info}/METADATA +1 -1
- {iam_policy_validator-1.14.2.dist-info → iam_policy_validator-1.14.4.dist-info}/RECORD +13 -13
- iam_validator/__version__.py +1 -1
- iam_validator/checks/condition_key_validation.py +1 -1
- iam_validator/core/aws_service/validators.py +99 -8
- iam_validator/core/config/aws_global_conditions.py +8 -4
- iam_validator/core/constants.py +29 -0
- iam_validator/core/pr_commenter.py +58 -3
- iam_validator/core/report.py +117 -7
- iam_validator/integrations/github_integration.py +1 -1
- {iam_policy_validator-1.14.2.dist-info → iam_policy_validator-1.14.4.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.14.2.dist-info → iam_policy_validator-1.14.4.dist-info}/entry_points.txt +0 -0
- {iam_policy_validator-1.14.2.dist-info → iam_policy_validator-1.14.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iam-policy-validator
|
|
3
|
-
Version: 1.14.
|
|
3
|
+
Version: 1.14.4
|
|
4
4
|
Summary: Validate AWS IAM policies for correctness and security using AWS Service Reference API
|
|
5
5
|
Project-URL: Homepage, https://github.com/boogy/iam-policy-validator
|
|
6
6
|
Project-URL: Documentation, https://github.com/boogy/iam-policy-validator/tree/main/docs
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
iam_validator/__init__.py,sha256=xHdUASOxFHwEXfT_GSr_KrkLlnxZ-pAAr1wW1PwAGko,693
|
|
2
2
|
iam_validator/__main__.py,sha256=to_nz3n_IerJpVVZZ6WSFlFR5s_06J0csfPOTfQZG8g,197
|
|
3
|
-
iam_validator/__version__.py,sha256=
|
|
3
|
+
iam_validator/__version__.py,sha256=K_xnaUWCmwON5ETAsNuzottXYU-HSz_ojv0wjnlbS0U,374
|
|
4
4
|
iam_validator/checks/__init__.py,sha256=OTkPnmlelu4YjMO8krjhu2wXiTV72RzopA5u1SfPQA0,1990
|
|
5
5
|
iam_validator/checks/action_condition_enforcement.py,sha256=2-XUMbof9tQ7SHZNmAHMkR1DgbOIzY2eFWlp9S9dwLk,60625
|
|
6
6
|
iam_validator/checks/action_resource_matching.py,sha256=qND0hfDgNoxFEdLWwrxOPVDfdj3k50nzedT2qF7nK7o,19428
|
|
7
7
|
iam_validator/checks/action_validation.py,sha256=glA2F3iQBU-d8rruiyB9IdzVOESC2Kb-91SnwRCdQF0,2562
|
|
8
|
-
iam_validator/checks/condition_key_validation.py,sha256=
|
|
8
|
+
iam_validator/checks/condition_key_validation.py,sha256=5i8LqqV78SjWK6pLrbttWmeMAD4pDC12_FjTjx5dFSU,4024
|
|
9
9
|
iam_validator/checks/condition_type_mismatch.py,sha256=KJp7zQHDd8VeTcfjcD-ur3S4070cXEDTWkFtxfp7CuE,10652
|
|
10
10
|
iam_validator/checks/full_wildcard.py,sha256=0TkkHtV0MZ6nZtJRtGdn3wwOMM96TRyGO7l7mmdHNUo,2325
|
|
11
11
|
iam_validator/checks/mfa_condition_check.py,sha256=y1LbqcvQ_fL2BPTNaKRQoBYM5hM7JET9cDPUOWKFEVs,4814
|
|
@@ -43,7 +43,7 @@ iam_validator/core/check_registry.py,sha256=oRCdWoCGQ8VZERVYd821u9r5NdKQ9FMC54e6
|
|
|
43
43
|
iam_validator/core/cli.py,sha256=PkXiZjlgrQ21QustBbspefYsdbxst4gxoClyG2_HQR8,3843
|
|
44
44
|
iam_validator/core/codeowners.py,sha256=dfRjYTpcTVmc-h95i4EoPXCXlcblD8yryeJBaTKQfjM,7530
|
|
45
45
|
iam_validator/core/condition_validators.py,sha256=7zBjlcf2xGFKGbcFrXSLvWT5tFhWxoqwzhsJqS2E8uY,21524
|
|
46
|
-
iam_validator/core/constants.py,sha256=
|
|
46
|
+
iam_validator/core/constants.py,sha256=YRT_uOUs1Dnt9J1hE-zG6Ja0numsH4Mf7RNNXNJWZIY,7447
|
|
47
47
|
iam_validator/core/diff_parser.py,sha256=5Jxa6WvQZtG5grblZeUH2OQ2R46tFLK-h8tvkHOSfLk,12110
|
|
48
48
|
iam_validator/core/finding_fingerprint.py,sha256=NJIlu8NhdenWbLS7ww8LyWFasJgpKWN63-DprrNW7Zs,4353
|
|
49
49
|
iam_validator/core/ignore_patterns.py,sha256=pZqDJBtkbck-85QK5eFPM5ZOPEKs3McRh3avqiCT5z0,10398
|
|
@@ -53,8 +53,8 @@ iam_validator/core/label_manager.py,sha256=48CRASWg98wyjfVF_1pUzj6dm9itzmG7SeIWf
|
|
|
53
53
|
iam_validator/core/models.py,sha256=lXUadIsTpp_j0Vt89Ez7aJkTKs2GD2ty3Ukl2NeY9Zo,15680
|
|
54
54
|
iam_validator/core/policy_checks.py,sha256=FNVuS2GTffwCjjrlupVIazC172gSxKYAAT_ObV6Apbo,8803
|
|
55
55
|
iam_validator/core/policy_loader.py,sha256=iid3mGfDzSXASzKDqbLnrqJHBdVQvvebofVqNImsGKM,29201
|
|
56
|
-
iam_validator/core/pr_commenter.py,sha256=
|
|
57
|
-
iam_validator/core/report.py,sha256=
|
|
56
|
+
iam_validator/core/pr_commenter.py,sha256=F5ql60E-etGYOIDUSacvlhjsx5E-2hgGqhPbXmYfHqE,35021
|
|
57
|
+
iam_validator/core/report.py,sha256=IEHjNe6v_9nvcGA8_FNbXdG0AoV-yHVjiP1KQKnpEys,41376
|
|
58
58
|
iam_validator/core/aws_service/__init__.py,sha256=UqMh4HUdGlx2QF5OoueJJ2UlCnhX4QW_x3KeE_bxRQc,735
|
|
59
59
|
iam_validator/core/aws_service/cache.py,sha256=DPuOOPPJC867KAYgV1e0RyQs_k3mtefMdYli3jPaN64,3589
|
|
60
60
|
iam_validator/core/aws_service/client.py,sha256=Zv7rIpEFdUCDXKGp3migPDkj8L5eZltgrGe64M2t2Ko,7336
|
|
@@ -62,10 +62,10 @@ iam_validator/core/aws_service/fetcher.py,sha256=TD9qQ4tQF4xdEGhVVADGgF8QlXe15R3
|
|
|
62
62
|
iam_validator/core/aws_service/parsers.py,sha256=gJzR7HCD8ItCWCCbguTQIZpPEdj2rdMwC7LPhu7ve14,5174
|
|
63
63
|
iam_validator/core/aws_service/patterns.py,sha256=gGc55Tn-EJ3cmcWtmYAZROUajKYz7DaMchYWGEhHpC0,1726
|
|
64
64
|
iam_validator/core/aws_service/storage.py,sha256=PrfKdvF60IL7E_8xYs_XwFoAJPRcVYw57FVLHCoqwVk,10429
|
|
65
|
-
iam_validator/core/aws_service/validators.py,sha256=
|
|
65
|
+
iam_validator/core/aws_service/validators.py,sha256=7izAvL92TsWpQePU7g9qvegT_F2xz8CqpbrnSgE40Xg,19890
|
|
66
66
|
iam_validator/core/config/__init__.py,sha256=CWSyIA7kEyzrskEenjYbs9Iih10BXRpiY9H2dHg61rU,2671
|
|
67
67
|
iam_validator/core/config/aws_api.py,sha256=HLIzOItQ0A37wxHcgWck6ZFO0wmNY8JNTiWMMK6JKYU,1248
|
|
68
|
-
iam_validator/core/config/aws_global_conditions.py,sha256=
|
|
68
|
+
iam_validator/core/config/aws_global_conditions.py,sha256=PO3zMdzM_QWZduxL3lV2nrmpcMEEwKASCUYHcqX0LBg,7363
|
|
69
69
|
iam_validator/core/config/category_suggestions.py,sha256=fopaZ9kXDrsLgi_r0pERrLwgdPPJl5VIiKvXtQK9tj0,8583
|
|
70
70
|
iam_validator/core/config/check_documentation.py,sha256=Adyt3Q4JSRlVNPWulXVlRIEorxXG_nt0mkPz_ySa8y4,15931
|
|
71
71
|
iam_validator/core/config/condition_requirements.py,sha256=1CeQJfWV-Y2ImW0Mq9YdrgvH-hj9IXe0gVOm3B36Rc8,10655
|
|
@@ -85,7 +85,7 @@ iam_validator/core/formatters/json.py,sha256=A7gZ8P32GEdbDvrSn6v56yQ4fOP_kyMaoFV
|
|
|
85
85
|
iam_validator/core/formatters/markdown.py,sha256=dk4STeY-tOEZsVrlmolIEqZvWYP9JhRtygxxNA49DEE,2293
|
|
86
86
|
iam_validator/core/formatters/sarif.py,sha256=03MHSyuZm9FlzaPeWg7wH-UTzzCDhSy6vMPrFpFNkS8,18884
|
|
87
87
|
iam_validator/integrations/__init__.py,sha256=7Hlor_X9j0NZaEjFuSvoXAAuSKQ-zgY19Rk-Dz3JpKo,616
|
|
88
|
-
iam_validator/integrations/github_integration.py,sha256=
|
|
88
|
+
iam_validator/integrations/github_integration.py,sha256=OZjVFkeEK0PYerqHFOuc0tFtTMmo78JhbqZgFduzq-8,67949
|
|
89
89
|
iam_validator/integrations/ms_teams.py,sha256=t2PlWuTDb6GGH-eDU1jnOKd8D1w4FCB68bahGA7MJcE,14475
|
|
90
90
|
iam_validator/sdk/__init__.py,sha256=AZLnfdn3A9AWb0pMhsbu3GAOAzt6rV7Fi3E3d9_3ZdI,6388
|
|
91
91
|
iam_validator/sdk/arn_matching.py,sha256=HSDpLltOYISq-SoPebAlM89mKOaUaghq_04urchEFDA,12778
|
|
@@ -99,8 +99,8 @@ iam_validator/utils/__init__.py,sha256=NveA2F3G1E6-ANZzFr7J6Q6u5mogvMp862iFokmYu
|
|
|
99
99
|
iam_validator/utils/cache.py,sha256=wOQKOBeoG6QqC5f0oXcHz63Cjtu_-SsSS-0pTSwyAiM,3254
|
|
100
100
|
iam_validator/utils/regex.py,sha256=xHoMECttb7qaMhts-c9b0GIxdhHNZTt-UBr7wNhWfzg,6219
|
|
101
101
|
iam_validator/utils/terminal.py,sha256=FsRaRMH_JAyDgXWBCOgOEhbS89cs17HCmKYoughq5io,724
|
|
102
|
-
iam_policy_validator-1.14.
|
|
103
|
-
iam_policy_validator-1.14.
|
|
104
|
-
iam_policy_validator-1.14.
|
|
105
|
-
iam_policy_validator-1.14.
|
|
106
|
-
iam_policy_validator-1.14.
|
|
102
|
+
iam_policy_validator-1.14.4.dist-info/METADATA,sha256=YHZ8MSw6dCEyDQmQm5mdbRNcf75MT-zL3n13ipE-zOQ,34456
|
|
103
|
+
iam_policy_validator-1.14.4.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
104
|
+
iam_policy_validator-1.14.4.dist-info/entry_points.txt,sha256=8HtWd8O7mvPiPdZR5YbzY8or_qcqLM4-pKaFdhtFT8M,62
|
|
105
|
+
iam_policy_validator-1.14.4.dist-info/licenses/LICENSE,sha256=AMnbFTBDcK4_MITe2wiQBkj0vg-jjBBhsc43ydC7tt4,1098
|
|
106
|
+
iam_policy_validator-1.14.4.dist-info/RECORD,,
|
iam_validator/__version__.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This file is the single source of truth for the package version.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
__version__ = "1.14.
|
|
6
|
+
__version__ = "1.14.4"
|
|
7
7
|
# Parse version, handling pre-release suffixes like -rc, -alpha, -beta
|
|
8
8
|
_version_base = __version__.split("-", maxsplit=1)[0] # Remove pre-release suffix if present
|
|
9
9
|
__version_info__ = tuple(int(part) for part in _version_base.split("."))
|
|
@@ -37,7 +37,7 @@ class ConditionKeyValidationCheck(PolicyCheck):
|
|
|
37
37
|
resources = statement.get_resources()
|
|
38
38
|
|
|
39
39
|
# Extract all condition keys from all condition operators
|
|
40
|
-
for
|
|
40
|
+
for _, conditions in statement.condition.items():
|
|
41
41
|
for condition_key in conditions.keys():
|
|
42
42
|
# Validate this condition key against each action in the statement
|
|
43
43
|
for action in actions:
|
|
@@ -5,14 +5,104 @@ including actions, condition keys, and ARN formats.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
+
import re
|
|
8
9
|
from dataclasses import dataclass
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
11
12
|
from iam_validator.core.aws_service.parsers import ServiceParser
|
|
13
|
+
from iam_validator.core.constants import (
|
|
14
|
+
AWS_TAG_KEY_ALLOWED_CHARS,
|
|
15
|
+
AWS_TAG_KEY_MAX_LENGTH,
|
|
16
|
+
AWS_TAG_KEY_PLACEHOLDERS,
|
|
17
|
+
)
|
|
12
18
|
from iam_validator.core.models import ServiceDetail
|
|
13
19
|
|
|
14
20
|
logger = logging.getLogger(__name__)
|
|
15
21
|
|
|
22
|
+
# Pre-compiled regex for AWS tag key validation
|
|
23
|
+
# Uses centralized constants from iam_validator.core.constants
|
|
24
|
+
_TAG_KEY_PATTERN = re.compile(rf"^[{AWS_TAG_KEY_ALLOWED_CHARS}]{{1,{AWS_TAG_KEY_MAX_LENGTH}}}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_valid_tag_key(tag_key: str) -> bool:
|
|
28
|
+
"""Validate an AWS tag key format.
|
|
29
|
+
|
|
30
|
+
AWS tag keys must:
|
|
31
|
+
- Be 1-128 characters long
|
|
32
|
+
- Contain only: letters, numbers, spaces, and + - = . _ : / @
|
|
33
|
+
- Not be empty
|
|
34
|
+
|
|
35
|
+
Note: The 'aws:' prefix check is not done here as it's for the condition key prefix,
|
|
36
|
+
not the tag key portion (e.g., in 'ssm:resourceTag/owner', 'owner' is the tag key).
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
tag_key: The tag key portion to validate
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
True if valid AWS tag key format
|
|
43
|
+
"""
|
|
44
|
+
if not tag_key or len(tag_key) > AWS_TAG_KEY_MAX_LENGTH:
|
|
45
|
+
return False
|
|
46
|
+
return bool(_TAG_KEY_PATTERN.match(tag_key))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _matches_condition_key_pattern(condition_key: str, pattern: str) -> bool:
|
|
50
|
+
"""Check if a condition key matches a pattern with tag-key placeholders.
|
|
51
|
+
|
|
52
|
+
AWS service definitions use patterns like:
|
|
53
|
+
- `ssm:resourceTag/tag-key` or `ssm:resourceTag/${TagKey}` to match `ssm:resourceTag/owner`
|
|
54
|
+
- `aws:ResourceTag/${TagKey}` to match `aws:ResourceTag/Environment`
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
condition_key: The actual condition key from the policy (e.g., "ssm:resourceTag/owner")
|
|
58
|
+
pattern: The pattern from AWS service definition (e.g., "ssm:resourceTag/tag-key")
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if condition_key matches the pattern
|
|
62
|
+
"""
|
|
63
|
+
# Exact match (fast path)
|
|
64
|
+
if condition_key == pattern:
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
# Check for tag-key placeholder patterns
|
|
68
|
+
for tag_placeholder in AWS_TAG_KEY_PLACEHOLDERS:
|
|
69
|
+
if tag_placeholder in pattern:
|
|
70
|
+
# Extract the prefix before the placeholder
|
|
71
|
+
prefix = pattern.split(tag_placeholder, 1)[0]
|
|
72
|
+
prefix_with_slash = prefix + "/"
|
|
73
|
+
# Check if condition_key starts with prefix and has a tag key after it
|
|
74
|
+
if condition_key.startswith(prefix_with_slash):
|
|
75
|
+
# Validate tag key format per AWS constraints
|
|
76
|
+
tag_key = condition_key[len(prefix_with_slash) :]
|
|
77
|
+
if _is_valid_tag_key(tag_key):
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _condition_key_in_list(condition_key: str, condition_keys: list[str]) -> bool:
|
|
84
|
+
"""Check if a condition key matches any key in the list, supporting patterns.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
condition_key: The condition key to check
|
|
88
|
+
condition_keys: List of condition keys (may include patterns)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if condition_key matches any entry in the list
|
|
92
|
+
"""
|
|
93
|
+
# Fast path: check for exact match first (most common case)
|
|
94
|
+
if condition_key in condition_keys:
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
# Slower path: check patterns only if no exact match
|
|
98
|
+
for pattern in condition_keys:
|
|
99
|
+
# Skip exact matches (already checked above)
|
|
100
|
+
if pattern == condition_key:
|
|
101
|
+
continue
|
|
102
|
+
if _matches_condition_key_pattern(condition_key, pattern):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
16
106
|
|
|
17
107
|
@dataclass
|
|
18
108
|
class ConditionKeyValidationResult:
|
|
@@ -134,7 +224,7 @@ class ServiceValidator:
|
|
|
134
224
|
action: str,
|
|
135
225
|
condition_key: str,
|
|
136
226
|
service_detail: ServiceDetail,
|
|
137
|
-
resources: list[str] | None = None,
|
|
227
|
+
resources: list[str] | None = None, # pylint: disable=unused-argument - kept for API compatibility
|
|
138
228
|
) -> ConditionKeyValidationResult:
|
|
139
229
|
"""Validate condition key against action and optionally resource types.
|
|
140
230
|
|
|
@@ -173,22 +263,23 @@ class ServiceValidator:
|
|
|
173
263
|
error_message=f"Invalid AWS global condition key: `{condition_key}`.",
|
|
174
264
|
)
|
|
175
265
|
|
|
176
|
-
# Check service-specific condition keys
|
|
177
|
-
if
|
|
266
|
+
# Check service-specific condition keys (with pattern matching for tag keys)
|
|
267
|
+
if service_detail.condition_keys and _condition_key_in_list(
|
|
268
|
+
condition_key, list(service_detail.condition_keys.keys())
|
|
269
|
+
):
|
|
178
270
|
return ConditionKeyValidationResult(is_valid=True)
|
|
179
271
|
|
|
180
272
|
# Check action-specific condition keys
|
|
181
273
|
if action_name in service_detail.actions:
|
|
182
274
|
action_detail = service_detail.actions[action_name]
|
|
183
|
-
if (
|
|
184
|
-
action_detail.action_condition_keys
|
|
185
|
-
and condition_key in action_detail.action_condition_keys
|
|
275
|
+
if action_detail.action_condition_keys and _condition_key_in_list(
|
|
276
|
+
condition_key, action_detail.action_condition_keys
|
|
186
277
|
):
|
|
187
278
|
return ConditionKeyValidationResult(is_valid=True)
|
|
188
279
|
|
|
189
280
|
# Check resource-specific condition keys
|
|
190
281
|
# Get resource types required by this action
|
|
191
|
-
if
|
|
282
|
+
if action_detail.resources:
|
|
192
283
|
for res_req in action_detail.resources:
|
|
193
284
|
resource_name = res_req.get("Name", "")
|
|
194
285
|
if not resource_name:
|
|
@@ -197,7 +288,7 @@ class ServiceValidator:
|
|
|
197
288
|
# Look up resource type definition
|
|
198
289
|
resource_type = service_detail.resources.get(resource_name)
|
|
199
290
|
if resource_type and resource_type.condition_keys:
|
|
200
|
-
if condition_key
|
|
291
|
+
if _condition_key_in_list(condition_key, resource_type.condition_keys):
|
|
201
292
|
return ConditionKeyValidationResult(is_valid=True)
|
|
202
293
|
|
|
203
294
|
# If it's a global key but the action has specific condition keys defined,
|
|
@@ -11,6 +11,8 @@ Last updated: 2025-01-17
|
|
|
11
11
|
import re
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from iam_validator.core.constants import AWS_TAG_KEY_ALLOWED_CHARS
|
|
15
|
+
|
|
14
16
|
# AWS Global Condition Keys with Type Information
|
|
15
17
|
# These condition keys are available for use in IAM policies across all AWS services
|
|
16
18
|
# Format: {key: type} where type is one of: String, ARN, Bool, Date, IPAddress, Numeric
|
|
@@ -71,17 +73,18 @@ AWS_GLOBAL_CONDITION_KEYS = {
|
|
|
71
73
|
|
|
72
74
|
# Patterns that should be recognized (wildcards and tag-based keys)
|
|
73
75
|
# These allow things like aws:RequestTag/Department or aws:PrincipalTag/Environment
|
|
76
|
+
# Uses centralized tag key character class from constants
|
|
74
77
|
AWS_CONDITION_KEY_PATTERNS = [
|
|
75
78
|
{
|
|
76
|
-
"pattern":
|
|
79
|
+
"pattern": rf"^aws:RequestTag/[{AWS_TAG_KEY_ALLOWED_CHARS}]+$",
|
|
77
80
|
"description": "Tag keys in the request (for tag-based access control)",
|
|
78
81
|
},
|
|
79
82
|
{
|
|
80
|
-
"pattern":
|
|
83
|
+
"pattern": rf"^aws:ResourceTag/[{AWS_TAG_KEY_ALLOWED_CHARS}]+$",
|
|
81
84
|
"description": "Tags on the resource being accessed",
|
|
82
85
|
},
|
|
83
86
|
{
|
|
84
|
-
"pattern":
|
|
87
|
+
"pattern": rf"^aws:PrincipalTag/[{AWS_TAG_KEY_ALLOWED_CHARS}]+$",
|
|
85
88
|
"description": "Tags attached to the principal making the request",
|
|
86
89
|
},
|
|
87
90
|
]
|
|
@@ -154,7 +157,8 @@ _global_conditions_instance = None
|
|
|
154
157
|
|
|
155
158
|
def get_global_conditions() -> AWSGlobalConditions:
|
|
156
159
|
"""Get singleton instance of AWSGlobalConditions."""
|
|
157
|
-
global _global_conditions_instance
|
|
160
|
+
global _global_conditions_instance # pylint: disable=global-statement
|
|
161
|
+
|
|
158
162
|
if _global_conditions_instance is None:
|
|
159
163
|
_global_conditions_instance = AWSGlobalConditions()
|
|
160
164
|
return _global_conditions_instance
|
iam_validator/core/constants.py
CHANGED
|
@@ -147,3 +147,32 @@ RCP_SUPPORTED_SERVICES = frozenset(
|
|
|
147
147
|
|
|
148
148
|
# AWS Service Authorization Reference (for finding valid actions, resources, and condition keys)
|
|
149
149
|
AWS_SERVICE_AUTH_REF_URL = "https://docs.aws.amazon.com/service-authorization/latest/reference/reference_policies_actions-resources-contextkeys.html"
|
|
150
|
+
|
|
151
|
+
# ============================================================================
|
|
152
|
+
# AWS Tag Constraints
|
|
153
|
+
# ============================================================================
|
|
154
|
+
# Reference: https://docs.aws.amazon.com/tag-editor/latest/userguide/best-practices-and-strats.html
|
|
155
|
+
|
|
156
|
+
# --- Tag Key Constraints ---
|
|
157
|
+
# Allowed characters in AWS tag keys: letters, numbers, spaces, and + - = . _ : / @
|
|
158
|
+
# This is the character class for use in regex patterns
|
|
159
|
+
AWS_TAG_KEY_ALLOWED_CHARS = r"a-zA-Z0-9 +\-=._:/@"
|
|
160
|
+
|
|
161
|
+
# Maximum length for AWS tag keys (per AWS documentation)
|
|
162
|
+
AWS_TAG_KEY_MAX_LENGTH = 128
|
|
163
|
+
|
|
164
|
+
# Tag-key placeholder patterns used in AWS service definitions
|
|
165
|
+
# These patterns indicate where a tag key should be substituted
|
|
166
|
+
AWS_TAG_KEY_PLACEHOLDERS = ("/tag-key", "/${TagKey}", "/${tag-key}")
|
|
167
|
+
|
|
168
|
+
# --- Tag Value Constraints ---
|
|
169
|
+
# Allowed characters in AWS tag values: letters, numbers, spaces, and + - = . _ : / @
|
|
170
|
+
# Same character set as tag keys
|
|
171
|
+
AWS_TAG_VALUE_ALLOWED_CHARS = r"a-zA-Z0-9 +\-=._:/@"
|
|
172
|
+
|
|
173
|
+
# Maximum length for AWS tag values (per AWS documentation)
|
|
174
|
+
# Note: Tag values can be empty (minimum 0), unlike keys which must have at least 1 char
|
|
175
|
+
AWS_TAG_VALUE_MAX_LENGTH = 256
|
|
176
|
+
|
|
177
|
+
# Minimum length for AWS tag values (can be empty)
|
|
178
|
+
AWS_TAG_VALUE_MIN_LENGTH = 0
|
|
@@ -17,7 +17,7 @@ from iam_validator.core.diff_parser import DiffParser
|
|
|
17
17
|
from iam_validator.core.label_manager import LabelManager
|
|
18
18
|
from iam_validator.core.models import ValidationIssue, ValidationReport
|
|
19
19
|
from iam_validator.core.policy_loader import PolicyLineMap, PolicyLoader
|
|
20
|
-
from iam_validator.core.report import ReportGenerator
|
|
20
|
+
from iam_validator.core.report import IgnoredFindingInfo, ReportGenerator
|
|
21
21
|
from iam_validator.integrations.github_integration import GitHubIntegration, ReviewEvent
|
|
22
22
|
|
|
23
23
|
logger = logging.getLogger(__name__)
|
|
@@ -96,6 +96,8 @@ class PRCommenter:
|
|
|
96
96
|
self._context_issues: list[ContextIssue] = []
|
|
97
97
|
# Track ignored finding IDs for the current run
|
|
98
98
|
self._ignored_finding_ids: frozenset[str] = frozenset()
|
|
99
|
+
# Store full ignored findings for display in summary
|
|
100
|
+
self._ignored_findings: dict[str, Any] = {}
|
|
99
101
|
# Cache for PolicyLineMap per file (for field-level line detection)
|
|
100
102
|
self._policy_line_maps: dict[str, PolicyLineMap] = {}
|
|
101
103
|
|
|
@@ -155,8 +157,28 @@ class PRCommenter:
|
|
|
155
157
|
generator = ReportGenerator()
|
|
156
158
|
# Pass ignored count to show in summary
|
|
157
159
|
ignored_count = len(self._ignored_finding_ids) if self._ignored_finding_ids else 0
|
|
160
|
+
|
|
161
|
+
# Convert ignored findings to IgnoredFindingInfo for display
|
|
162
|
+
ignored_findings_info: list[IgnoredFindingInfo] = []
|
|
163
|
+
if self._ignored_findings:
|
|
164
|
+
for finding in self._ignored_findings.values():
|
|
165
|
+
ignored_findings_info.append(
|
|
166
|
+
IgnoredFindingInfo(
|
|
167
|
+
file_path=finding.file_path,
|
|
168
|
+
issue_type=finding.issue_type,
|
|
169
|
+
ignored_by=finding.ignored_by,
|
|
170
|
+
reason=finding.reason,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Determine if all blocking issues are ignored
|
|
175
|
+
all_blocking_ignored = self._are_all_blocking_issues_ignored(report)
|
|
176
|
+
|
|
158
177
|
comment_parts = generator.generate_github_comment_parts(
|
|
159
|
-
report,
|
|
178
|
+
report,
|
|
179
|
+
ignored_count=ignored_count,
|
|
180
|
+
ignored_findings=ignored_findings_info if ignored_findings_info else None,
|
|
181
|
+
all_blocking_ignored=all_blocking_ignored,
|
|
160
182
|
)
|
|
161
183
|
|
|
162
184
|
# Post all parts using the multipart method
|
|
@@ -694,7 +716,10 @@ class PRCommenter:
|
|
|
694
716
|
)
|
|
695
717
|
|
|
696
718
|
store = IgnoredFindingsStore(self.github)
|
|
697
|
-
|
|
719
|
+
# Load full ignored findings for display in summary
|
|
720
|
+
self._ignored_findings = await store.load()
|
|
721
|
+
# Also get just the IDs for fast lookup
|
|
722
|
+
self._ignored_finding_ids = frozenset(self._ignored_findings.keys())
|
|
698
723
|
if self._ignored_finding_ids:
|
|
699
724
|
logger.debug(f"Loaded {len(self._ignored_finding_ids)} ignored finding(s)")
|
|
700
725
|
|
|
@@ -718,6 +743,36 @@ class PRCommenter:
|
|
|
718
743
|
fingerprint = FindingFingerprint.from_issue(issue, file_path)
|
|
719
744
|
return fingerprint.to_hash() in self._ignored_finding_ids
|
|
720
745
|
|
|
746
|
+
def _are_all_blocking_issues_ignored(self, report: ValidationReport) -> bool:
|
|
747
|
+
"""Check if all blocking issues (based on fail_on_severities) are ignored.
|
|
748
|
+
|
|
749
|
+
Args:
|
|
750
|
+
report: The validation report
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
True if there are no unignored blocking issues (i.e., all blocking
|
|
754
|
+
issues have been ignored, or there were no blocking issues to begin with)
|
|
755
|
+
"""
|
|
756
|
+
if not self._ignored_finding_ids:
|
|
757
|
+
# No ignored findings - check if there are any blocking issues at all
|
|
758
|
+
for result in report.results:
|
|
759
|
+
for issue in result.issues:
|
|
760
|
+
if issue.severity in self.fail_on_severities:
|
|
761
|
+
return False
|
|
762
|
+
return True
|
|
763
|
+
|
|
764
|
+
# Check each blocking issue to see if it's ignored
|
|
765
|
+
for result in report.results:
|
|
766
|
+
relative_path = self._make_relative_path(result.policy_file)
|
|
767
|
+
if not relative_path:
|
|
768
|
+
continue
|
|
769
|
+
for issue in result.issues:
|
|
770
|
+
if issue.severity in self.fail_on_severities:
|
|
771
|
+
if not self._is_issue_ignored(issue, relative_path):
|
|
772
|
+
return False
|
|
773
|
+
|
|
774
|
+
return True
|
|
775
|
+
|
|
721
776
|
|
|
722
777
|
async def post_report_to_pr(
|
|
723
778
|
report_file: str,
|
iam_validator/core/report.py
CHANGED
|
@@ -5,6 +5,7 @@ including console output, JSON, and GitHub-flavored markdown for PR comments.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
+
from dataclasses import dataclass
|
|
8
9
|
|
|
9
10
|
from rich.console import Console
|
|
10
11
|
from rich.panel import Panel
|
|
@@ -29,6 +30,24 @@ from iam_validator.core.models import (
|
|
|
29
30
|
ValidationReport,
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class IgnoredFindingInfo:
|
|
36
|
+
"""Information about an ignored finding for display in summary.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
file_path: Path to the policy file
|
|
40
|
+
issue_type: Type of issue (e.g., "invalid_action")
|
|
41
|
+
ignored_by: Username who ignored the finding
|
|
42
|
+
reason: Optional reason provided by the user
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
file_path: str
|
|
46
|
+
issue_type: str
|
|
47
|
+
ignored_by: str
|
|
48
|
+
reason: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
32
51
|
logger = logging.getLogger(__name__)
|
|
33
52
|
|
|
34
53
|
|
|
@@ -239,6 +258,8 @@ class ReportGenerator:
|
|
|
239
258
|
report: ValidationReport,
|
|
240
259
|
max_length_per_part: int = constants.GITHUB_COMMENT_SPLIT_LIMIT,
|
|
241
260
|
ignored_count: int = 0,
|
|
261
|
+
ignored_findings: list[IgnoredFindingInfo] | None = None,
|
|
262
|
+
all_blocking_ignored: bool = False,
|
|
242
263
|
) -> list[str]:
|
|
243
264
|
"""Generate GitHub PR comment(s), splitting into multiple parts if needed.
|
|
244
265
|
|
|
@@ -246,6 +267,8 @@ class ReportGenerator:
|
|
|
246
267
|
report: Validation report
|
|
247
268
|
max_length_per_part: Maximum character length per comment part (default from GITHUB_COMMENT_SPLIT_LIMIT)
|
|
248
269
|
ignored_count: Number of findings that were ignored (will be shown in summary)
|
|
270
|
+
ignored_findings: List of ignored finding details for display in summary
|
|
271
|
+
all_blocking_ignored: True if all blocking issues were ignored (shows "Passed" status)
|
|
249
272
|
|
|
250
273
|
Returns:
|
|
251
274
|
List of comment parts (each under max_length_per_part)
|
|
@@ -257,13 +280,19 @@ class ReportGenerator:
|
|
|
257
280
|
if estimated_size <= max_length_per_part:
|
|
258
281
|
# Try single comment
|
|
259
282
|
single_comment = self.generate_github_comment(
|
|
260
|
-
report,
|
|
283
|
+
report,
|
|
284
|
+
max_length=max_length_per_part * 2,
|
|
285
|
+
ignored_count=ignored_count,
|
|
286
|
+
ignored_findings=ignored_findings,
|
|
287
|
+
all_blocking_ignored=all_blocking_ignored,
|
|
261
288
|
)
|
|
262
289
|
if len(single_comment) <= max_length_per_part:
|
|
263
290
|
return [single_comment]
|
|
264
291
|
|
|
265
292
|
# Need to split into multiple parts
|
|
266
|
-
return self._generate_split_comments(
|
|
293
|
+
return self._generate_split_comments(
|
|
294
|
+
report, max_length_per_part, ignored_count, ignored_findings, all_blocking_ignored
|
|
295
|
+
)
|
|
267
296
|
|
|
268
297
|
def _estimate_report_size(self, report: ValidationReport) -> int:
|
|
269
298
|
"""Estimate the size of the report in characters.
|
|
@@ -280,7 +309,12 @@ class ReportGenerator:
|
|
|
280
309
|
)
|
|
281
310
|
|
|
282
311
|
def _generate_split_comments(
|
|
283
|
-
self,
|
|
312
|
+
self,
|
|
313
|
+
report: ValidationReport,
|
|
314
|
+
max_length: int,
|
|
315
|
+
ignored_count: int = 0,
|
|
316
|
+
ignored_findings: list[IgnoredFindingInfo] | None = None,
|
|
317
|
+
all_blocking_ignored: bool = False,
|
|
284
318
|
) -> list[str]:
|
|
285
319
|
"""Split a large report into multiple comment parts.
|
|
286
320
|
|
|
@@ -288,6 +322,8 @@ class ReportGenerator:
|
|
|
288
322
|
report: Validation report
|
|
289
323
|
max_length: Maximum length per part
|
|
290
324
|
ignored_count: Number of ignored findings to show in summary
|
|
325
|
+
ignored_findings: List of ignored finding details for display
|
|
326
|
+
all_blocking_ignored: True if all blocking issues were ignored
|
|
291
327
|
|
|
292
328
|
Returns:
|
|
293
329
|
List of comment parts
|
|
@@ -295,7 +331,9 @@ class ReportGenerator:
|
|
|
295
331
|
parts: list[str] = []
|
|
296
332
|
|
|
297
333
|
# Generate header (will be in first part only)
|
|
298
|
-
header_lines = self._generate_header(
|
|
334
|
+
header_lines = self._generate_header(
|
|
335
|
+
report, ignored_count, ignored_findings, all_blocking_ignored
|
|
336
|
+
)
|
|
299
337
|
header_content = "\n".join(header_lines)
|
|
300
338
|
|
|
301
339
|
# Generate footer (will be in all parts)
|
|
@@ -388,17 +426,27 @@ class ReportGenerator:
|
|
|
388
426
|
|
|
389
427
|
return parts
|
|
390
428
|
|
|
391
|
-
def _generate_header(
|
|
429
|
+
def _generate_header(
|
|
430
|
+
self,
|
|
431
|
+
report: ValidationReport,
|
|
432
|
+
ignored_count: int = 0,
|
|
433
|
+
ignored_findings: list[IgnoredFindingInfo] | None = None,
|
|
434
|
+
all_blocking_ignored: bool = False,
|
|
435
|
+
) -> list[str]:
|
|
392
436
|
"""Generate the comment header with summary.
|
|
393
437
|
|
|
394
438
|
Args:
|
|
395
439
|
report: Validation report
|
|
396
440
|
ignored_count: Number of findings that were ignored
|
|
441
|
+
ignored_findings: List of ignored finding details for display
|
|
442
|
+
all_blocking_ignored: True if all blocking issues were ignored (shows "Passed" status)
|
|
397
443
|
"""
|
|
398
444
|
lines = []
|
|
399
445
|
|
|
400
446
|
# Title with emoji and status badge
|
|
401
|
-
if
|
|
447
|
+
# Pass if: no invalid policies, OR all blocking issues were ignored
|
|
448
|
+
is_passing = report.invalid_policies == 0 or all_blocking_ignored
|
|
449
|
+
if is_passing:
|
|
402
450
|
lines.append("# 🎉 IAM Policy Validation Passed!")
|
|
403
451
|
status_badge = (
|
|
404
452
|
""
|
|
@@ -456,6 +504,56 @@ class ReportGenerator:
|
|
|
456
504
|
lines.append(f"| 🔵 **Info** | {infos} |")
|
|
457
505
|
lines.append("")
|
|
458
506
|
|
|
507
|
+
# Ignored findings section
|
|
508
|
+
if ignored_findings:
|
|
509
|
+
lines.extend(self._generate_ignored_findings_section(ignored_findings))
|
|
510
|
+
|
|
511
|
+
return lines
|
|
512
|
+
|
|
513
|
+
def _generate_ignored_findings_section(
|
|
514
|
+
self, ignored_findings: list[IgnoredFindingInfo]
|
|
515
|
+
) -> list[str]:
|
|
516
|
+
"""Generate the ignored findings section for the summary comment.
|
|
517
|
+
|
|
518
|
+
Args:
|
|
519
|
+
ignored_findings: List of ignored finding details
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
List of markdown lines for the section
|
|
523
|
+
"""
|
|
524
|
+
lines = []
|
|
525
|
+
lines.append("### 🔕 Ignored Findings")
|
|
526
|
+
lines.append("")
|
|
527
|
+
lines.append(
|
|
528
|
+
"> The following findings were ignored by authorized users and are excluded from validation:"
|
|
529
|
+
)
|
|
530
|
+
lines.append("")
|
|
531
|
+
|
|
532
|
+
lines.append("<details>")
|
|
533
|
+
lines.append(f"<summary>View {len(ignored_findings)} ignored finding(s)</summary>")
|
|
534
|
+
lines.append("")
|
|
535
|
+
|
|
536
|
+
lines.append("| File | Issue Type | Ignored By | Reason |")
|
|
537
|
+
lines.append("|------|------------|------------|--------|")
|
|
538
|
+
|
|
539
|
+
for finding in ignored_findings:
|
|
540
|
+
# Truncate file path if too long
|
|
541
|
+
file_display = finding.file_path
|
|
542
|
+
if len(file_display) > 50:
|
|
543
|
+
file_display = "..." + file_display[-47:]
|
|
544
|
+
|
|
545
|
+
reason_display = finding.reason if finding.reason else "-"
|
|
546
|
+
if len(reason_display) > 30:
|
|
547
|
+
reason_display = reason_display[:27] + "..."
|
|
548
|
+
|
|
549
|
+
lines.append(
|
|
550
|
+
f"| `{file_display}` | `{finding.issue_type}` | @{finding.ignored_by} | {reason_display} |"
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
lines.append("")
|
|
554
|
+
lines.append("</details>")
|
|
555
|
+
lines.append("")
|
|
556
|
+
|
|
459
557
|
return lines
|
|
460
558
|
|
|
461
559
|
def _generate_footer(self) -> str:
|
|
@@ -540,6 +638,8 @@ class ReportGenerator:
|
|
|
540
638
|
report: ValidationReport,
|
|
541
639
|
max_length: int = constants.GITHUB_MAX_COMMENT_LENGTH,
|
|
542
640
|
ignored_count: int = 0,
|
|
641
|
+
ignored_findings: list[IgnoredFindingInfo] | None = None,
|
|
642
|
+
all_blocking_ignored: bool = False,
|
|
543
643
|
) -> str:
|
|
544
644
|
"""Generate a GitHub-flavored markdown comment for PR reviews.
|
|
545
645
|
|
|
@@ -547,6 +647,8 @@ class ReportGenerator:
|
|
|
547
647
|
report: Validation report
|
|
548
648
|
max_length: Maximum character length (default from GITHUB_MAX_COMMENT_LENGTH constant)
|
|
549
649
|
ignored_count: Number of findings that were ignored (will be shown in summary)
|
|
650
|
+
ignored_findings: List of ignored finding details for display in summary
|
|
651
|
+
all_blocking_ignored: True if all blocking issues were ignored (shows "Passed" status)
|
|
550
652
|
|
|
551
653
|
Returns:
|
|
552
654
|
Markdown formatted string
|
|
@@ -554,8 +656,12 @@ class ReportGenerator:
|
|
|
554
656
|
lines = []
|
|
555
657
|
|
|
556
658
|
# Header with emoji and status badge
|
|
659
|
+
# Pass if: no invalid policies, OR all blocking issues were ignored
|
|
557
660
|
has_parsing_errors = len(report.parsing_errors) > 0
|
|
558
|
-
|
|
661
|
+
is_passing = (
|
|
662
|
+
report.invalid_policies == 0 or all_blocking_ignored
|
|
663
|
+
) and not has_parsing_errors
|
|
664
|
+
if is_passing:
|
|
559
665
|
lines.append("# 🎉 IAM Policy Validation Passed!")
|
|
560
666
|
status_badge = (
|
|
561
667
|
""
|
|
@@ -613,6 +719,10 @@ class ReportGenerator:
|
|
|
613
719
|
lines.append(f"| 🔵 **Info** | {infos} |")
|
|
614
720
|
lines.append("")
|
|
615
721
|
|
|
722
|
+
# Ignored findings section
|
|
723
|
+
if ignored_findings:
|
|
724
|
+
lines.extend(self._generate_ignored_findings_section(ignored_findings))
|
|
725
|
+
|
|
616
726
|
# Parsing errors section (if any)
|
|
617
727
|
if report.parsing_errors:
|
|
618
728
|
lines.append("### ⚠️ Parsing Errors")
|
|
File without changes
|
{iam_policy_validator-1.14.2.dist-info → iam_policy_validator-1.14.4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{iam_policy_validator-1.14.2.dist-info → iam_policy_validator-1.14.4.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|