iam-policy-validator 1.15.5__py3-none-any.whl → 1.16.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iam-policy-validator
3
- Version: 1.15.5
3
+ Version: 1.16.0
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://boogy.github.io/iam-policy-validator
@@ -1,13 +1,14 @@
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=U-wVS3NvN8FNHihwmPm5Li3BYK5Ld0cc3hv7gIhvzM0,374
4
- iam_validator/checks/__init__.py,sha256=wFU5Lz-ZIQBcn2y1u0Kl88B--vEO3btOOaTGPPSjJ74,2106
3
+ iam_validator/__version__.py,sha256=SNgWrnwHrJ7-BbIPlpeXBPHcfB0-l1X47rnMf2CYA6I,374
4
+ iam_validator/checks/__init__.py,sha256=ybmPohOLXWUpPULY1b_DY0ZfazA4YzB9xRJGjUvsK3c,2217
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=gy9_mujYbojBboLk0WwuJYJjggrY2sRwHAFvJnoLTYE,4823
8
- iam_validator/checks/condition_key_validation.py,sha256=5i8LqqV78SjWK6pLrbttWmeMAD4pDC12_FjTjx5dFSU,4024
9
- iam_validator/checks/condition_type_mismatch.py,sha256=KJp7zQHDd8VeTcfjcD-ur3S4070cXEDTWkFtxfp7CuE,10652
8
+ iam_validator/checks/condition_key_validation.py,sha256=X0fVQ_6ZZgyUmaT87m0aMusk-gb98rnh3UFeVhfKXH8,5714
9
+ iam_validator/checks/condition_type_mismatch.py,sha256=KmlFHOHenmFAPacJjxq4jd-H8kibvkw_Wh_aoNPUNuI,11635
10
10
  iam_validator/checks/full_wildcard.py,sha256=0TkkHtV0MZ6nZtJRtGdn3wwOMM96TRyGO7l7mmdHNUo,2325
11
+ iam_validator/checks/ifexists_condition_check.py,sha256=9Q32bA0sy2sQxMZQee4l_DMLzQ5zjyJWjOb7Fm60K-E,11166
11
12
  iam_validator/checks/mfa_condition_check.py,sha256=EYOzESzsZVlxW9Pp6hSqPZkH0UT1h21SoZibBX7k_OU,7705
12
13
  iam_validator/checks/not_action_not_resource.py,sha256=18wB1NKaCj4X7o8xcTLmtFjsXFJLXTukzp1FhZzvMa8,9779
13
14
  iam_validator/checks/policy_size.py,sha256=eJd36Nj4gqWLIkQ5imhHR1hGtQ6T-iJsC22Wd1VSUf0,4681
@@ -17,7 +18,7 @@ iam_validator/checks/principal_validation.py,sha256=oXSooVQIgUfbTRulgpiLACSJ2LZX
17
18
  iam_validator/checks/resource_validation.py,sha256=k7qHIwX7IDf4MCWIvl9G17aINzTuZLOHDHRWrujbCaM,7787
18
19
  iam_validator/checks/sensitive_action.py,sha256=LHicl6dd5E1JV19cLKvFAMGzLzYr5h_Y7QvS8s57kvA,18952
19
20
  iam_validator/checks/service_wildcard.py,sha256=1epcET5oDclAmzxhtmQfKBg3Q5Rl4VXBMoZouxCJLpM,4114
20
- iam_validator/checks/set_operator_validation.py,sha256=GMZ1OWqySptYWV7565-K4R5ODs1eYgWXDozwtU-3sgY,7422
21
+ iam_validator/checks/set_operator_validation.py,sha256=lV6y4PZ_qaRePfIezEoSf21oHAAPLsEdJD3aD8f6T6o,8285
21
22
  iam_validator/checks/sid_uniqueness.py,sha256=MPQwALBjcvbY4Q55mpxDrGMqmVCMb13YSg621YbQYF8,6048
22
23
  iam_validator/checks/trust_policy_validation.py,sha256=r3fM2hU7W0yCFS6hbd4ZSlD8y2tm0zk99mUVlIt0LsI,17956
23
24
  iam_validator/checks/wildcard_action.py,sha256=VhWezb1JXmFnwV9WKgVu-48ythScGfZasg8fFd6tAG4,2001
@@ -41,10 +42,10 @@ iam_validator/core/__init__.py,sha256=hYXkSbxplKzhM6dqrVzV4M3k7GKLsZbgExypxKq74g
41
42
  iam_validator/core/access_analyzer.py,sha256=mtMaY-FnKjKEVITky_9ywZe1FaCAm61ElRv5Z_ZeC7E,24562
42
43
  iam_validator/core/access_analyzer_report.py,sha256=UMm2RNGj2rAKav1zsCw_htQZZRwRC0jjayd2zvKma1A,24896
43
44
  iam_validator/core/aws_fetcher.py,sha256=op93QvtGmeLF9dHobl2IuoPDeunn33pBLb8h7XjtmoQ,920
44
- iam_validator/core/check_registry.py,sha256=cRvFko_cTrip94VgVqwkxgrjR6oz3JfSiwertESicRc,28567
45
+ iam_validator/core/check_registry.py,sha256=iSFly0rloQ71NW-2dbmTx1gaAXwsmKwkZ_3GNdnwTfA,28655
45
46
  iam_validator/core/cli.py,sha256=PkXiZjlgrQ21QustBbspefYsdbxst4gxoClyG2_HQR8,3843
46
47
  iam_validator/core/codeowners.py,sha256=dfRjYTpcTVmc-h95i4EoPXCXlcblD8yryeJBaTKQfjM,7530
47
- iam_validator/core/condition_validators.py,sha256=5IdCZG0w8qQL37_wYv8TPQD3rIsmrB-845Ff4N-WXDU,27357
48
+ iam_validator/core/condition_validators.py,sha256=E7TJ46LJ1NDCDG061mtEiJJZF23_E3GdnhBdIjK1Gt0,30631
48
49
  iam_validator/core/constants.py,sha256=O80dQ6AtgsCJPempRtNlKaSIVE61rg6YDPV5vgaSnAY,7771
49
50
  iam_validator/core/diff_parser.py,sha256=5Jxa6WvQZtG5grblZeUH2OQ2R46tFLK-h8tvkHOSfLk,12110
50
51
  iam_validator/core/finding_fingerprint.py,sha256=NJIlu8NhdenWbLS7ww8LyWFasJgpKWN63-DprrNW7Zs,4353
@@ -91,7 +92,7 @@ iam_validator/integrations/github_integration.py,sha256=0aeQ_RPTZf5ij7dsBjmtIDz4
91
92
  iam_validator/integrations/ms_teams.py,sha256=t2PlWuTDb6GGH-eDU1jnOKd8D1w4FCB68bahGA7MJcE,14475
92
93
  iam_validator/mcp/__init__.py,sha256=dHCPuxLNKG3EMDYTBum8FTdskoebWJN6TaC03rkcnYQ,4927
93
94
  iam_validator/mcp/models.py,sha256=EJaDHaGdVvNzSauxYH3L4hmfbtBLLyzuHX_CbXOZr8Q,4473
94
- iam_validator/mcp/server.py,sha256=V_d4SmNSNMsPG6nul6II0G9GyvWiAvfnMX5B9_U3RQw,108422
95
+ iam_validator/mcp/server.py,sha256=7XOTn5RoOlPXHGQ9UzLIpR_a4o8T7kBZ2nwjLYW3aXg,82782
95
96
  iam_validator/mcp/session_config.py,sha256=dtzPqHVycpMUQH7qG68LW7ZikUz6APJrzttyFZkXwak,10336
96
97
  iam_validator/mcp/templates/__init__.py,sha256=OY1C7Af9O46W5-A2TEuZb3jB7gwrG6N-dZ-LQS6FHpw,2408
97
98
  iam_validator/mcp/templates/builtin.py,sha256=3cSPOqAzSQSWY2rBEwQ_sTHHKxur64NVASe0kVErbS8,31032
@@ -112,8 +113,8 @@ iam_validator/utils/__init__.py,sha256=NveA2F3G1E6-ANZzFr7J6Q6u5mogvMp862iFokmYu
112
113
  iam_validator/utils/cache.py,sha256=wOQKOBeoG6QqC5f0oXcHz63Cjtu_-SsSS-0pTSwyAiM,3254
113
114
  iam_validator/utils/regex.py,sha256=xHoMECttb7qaMhts-c9b0GIxdhHNZTt-UBr7wNhWfzg,6219
114
115
  iam_validator/utils/terminal.py,sha256=FsRaRMH_JAyDgXWBCOgOEhbS89cs17HCmKYoughq5io,724
115
- iam_policy_validator-1.15.5.dist-info/METADATA,sha256=sDFNSXMKvzmHQTDPBTdfBDpV0fVJ7xjGa094bBlzGbU,34939
116
- iam_policy_validator-1.15.5.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
117
- iam_policy_validator-1.15.5.dist-info/entry_points.txt,sha256=VXAcx1evo9fuxX0Gtj3J2HnzWcBHSXugiZwBtQ1BXE0,162
118
- iam_policy_validator-1.15.5.dist-info/licenses/LICENSE,sha256=AMnbFTBDcK4_MITe2wiQBkj0vg-jjBBhsc43ydC7tt4,1098
119
- iam_policy_validator-1.15.5.dist-info/RECORD,,
116
+ iam_policy_validator-1.16.0.dist-info/METADATA,sha256=AgKsjkJqDBzL6zzDJeetMwSQIowHGPAYgCHHhJqVA9s,34939
117
+ iam_policy_validator-1.16.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
118
+ iam_policy_validator-1.16.0.dist-info/entry_points.txt,sha256=VXAcx1evo9fuxX0Gtj3J2HnzWcBHSXugiZwBtQ1BXE0,162
119
+ iam_policy_validator-1.16.0.dist-info/licenses/LICENSE,sha256=AMnbFTBDcK4_MITe2wiQBkj0vg-jjBBhsc43ydC7tt4,1098
120
+ iam_policy_validator-1.16.0.dist-info/RECORD,,
@@ -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.15.5"
6
+ __version__ = "1.16.0"
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("."))
@@ -10,6 +10,7 @@ from iam_validator.checks.action_validation import ActionValidationCheck
10
10
  from iam_validator.checks.condition_key_validation import ConditionKeyValidationCheck
11
11
  from iam_validator.checks.condition_type_mismatch import ConditionTypeMismatchCheck
12
12
  from iam_validator.checks.full_wildcard import FullWildcardCheck
13
+ from iam_validator.checks.ifexists_condition_check import IfExistsConditionCheck
13
14
  from iam_validator.checks.mfa_condition_check import MFAConditionCheck
14
15
  from iam_validator.checks.not_action_not_resource import NotActionNotResourceCheck
15
16
  from iam_validator.checks.policy_size import PolicySizeCheck
@@ -31,6 +32,7 @@ __all__ = [
31
32
  "ConditionKeyValidationCheck",
32
33
  "ConditionTypeMismatchCheck",
33
34
  "FullWildcardCheck",
35
+ "IfExistsConditionCheck",
34
36
  "MFAConditionCheck",
35
37
  "NotActionNotResourceCheck",
36
38
  "PolicySizeCheck",
@@ -4,6 +4,7 @@ from typing import ClassVar
4
4
 
5
5
  from iam_validator.core.aws_service import AWSServiceFetcher
6
6
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
7
+ from iam_validator.core.condition_validators import has_if_exists_suffix
7
8
  from iam_validator.core.models import Statement, ValidationIssue
8
9
 
9
10
 
@@ -37,8 +38,14 @@ class ConditionKeyValidationCheck(PolicyCheck):
37
38
  resources = statement.get_resources()
38
39
 
39
40
  # Extract all condition keys from all condition operators
40
- for _, conditions in statement.condition.items():
41
+ for operator, conditions in statement.condition.items():
42
+ operator_has_ifexists = has_if_exists_suffix(operator)
43
+
41
44
  for condition_key in conditions.keys():
45
+ key_valid_for_any_action = False
46
+ key_invalid_actions: list[tuple] = []
47
+ first_warning_result = None
48
+
42
49
  # Validate this condition key against each action in the statement
43
50
  for action in actions:
44
51
  # Skip wildcard actions
@@ -48,41 +55,71 @@ class ConditionKeyValidationCheck(PolicyCheck):
48
55
  # Validate against action and resource types
49
56
  result = await fetcher.validate_condition_key(action, condition_key, resources)
50
57
 
51
- if not result.is_valid:
52
- issues.append(
53
- ValidationIssue(
54
- severity=self.get_severity(config),
55
- statement_sid=statement_sid,
56
- statement_index=statement_idx,
57
- issue_type="invalid_condition_key",
58
- message=result.error_message
59
- or f"Invalid condition key: `{condition_key}`",
60
- action=action,
61
- condition_key=condition_key,
62
- line_number=line_number,
63
- suggestion=result.suggestion,
64
- field_name="condition",
65
- )
66
- )
67
- # Only report once per condition key (not per action)
68
- break
69
- elif result.warning_message and warn_on_global_keys:
70
- # Add warning for global condition keys with action-specific keys
71
- # Only if warn_on_global_condition_keys is enabled
58
+ if result.is_valid:
59
+ key_valid_for_any_action = True
60
+ if result.warning_message and first_warning_result is None:
61
+ first_warning_result = (action, result)
62
+ else:
63
+ key_invalid_actions.append((action, result))
64
+
65
+ # If IfExists is used and the key is valid for at least one action,
66
+ # suppress errors for actions that don't support it.
67
+ # Warnings (global key usage) are still reported.
68
+ if operator_has_ifexists and key_valid_for_any_action:
69
+ if first_warning_result and warn_on_global_keys:
70
+ action, result = first_warning_result
71
+ warning_msg = result.warning_message or ""
72
72
  issues.append(
73
73
  ValidationIssue(
74
74
  severity="warning",
75
75
  statement_sid=statement_sid,
76
76
  statement_index=statement_idx,
77
77
  issue_type="global_condition_key_with_action_specific",
78
- message=result.warning_message,
78
+ message=warning_msg,
79
79
  action=action,
80
80
  condition_key=condition_key,
81
81
  line_number=line_number,
82
82
  field_name="condition",
83
83
  )
84
84
  )
85
- # Only report once per condition key (not per action)
86
- break
85
+ continue # Skip error reporting
86
+
87
+ # Report errors (first invalid action)
88
+ if key_invalid_actions:
89
+ action, result = key_invalid_actions[0]
90
+ issues.append(
91
+ ValidationIssue(
92
+ severity=self.get_severity(config),
93
+ statement_sid=statement_sid,
94
+ statement_index=statement_idx,
95
+ issue_type="invalid_condition_key",
96
+ message=result.error_message
97
+ or f"Invalid condition key: `{condition_key}`",
98
+ action=action,
99
+ condition_key=condition_key,
100
+ line_number=line_number,
101
+ suggestion=result.suggestion,
102
+ field_name="condition",
103
+ )
104
+ )
105
+ continue
106
+
107
+ # Report warnings for valid keys (no IfExists suppression path)
108
+ if first_warning_result and warn_on_global_keys:
109
+ action, result = first_warning_result
110
+ warning_msg = result.warning_message or ""
111
+ issues.append(
112
+ ValidationIssue(
113
+ severity="warning",
114
+ statement_sid=statement_sid,
115
+ statement_index=statement_idx,
116
+ issue_type="global_condition_key_with_action_specific",
117
+ message=warning_msg,
118
+ action=action,
119
+ condition_key=condition_key,
120
+ line_number=line_number,
121
+ field_name="condition",
122
+ )
123
+ )
87
124
 
88
125
  return issues
@@ -8,6 +8,7 @@ from typing import ClassVar
8
8
  from iam_validator.core.aws_service import AWSServiceFetcher
9
9
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
10
10
  from iam_validator.core.condition_validators import (
11
+ has_if_exists_suffix,
11
12
  normalize_operator,
12
13
  translate_type,
13
14
  validate_value_for_type,
@@ -71,6 +72,25 @@ class ConditionTypeMismatchCheck(PolicyCheck):
71
72
  # Unknown operator - this will be caught by another check
72
73
  continue
73
74
 
75
+ # Detect NullIfExists as invalid syntax (must be BEFORE skip_operators guard)
76
+ if base_operator == "Null" and has_if_exists_suffix(operator):
77
+ issues.append(
78
+ ValidationIssue(
79
+ severity=self.get_severity(config),
80
+ message=(
81
+ f"Invalid operator `{operator}`. The `Null` condition operator "
82
+ f"does not support the `IfExists` suffix. The `Null` operator "
83
+ f"already checks for key existence \u2014 use `Null` directly."
84
+ ),
85
+ statement_sid=statement_sid,
86
+ statement_index=statement_idx,
87
+ issue_type="invalid_operator",
88
+ line_number=line_number,
89
+ field_name="condition",
90
+ )
91
+ )
92
+ continue
93
+
74
94
  if base_operator in skip_operators:
75
95
  continue
76
96
 
@@ -0,0 +1,210 @@
1
+ """IfExists Condition Usage Check.
2
+
3
+ Validates proper usage of the IfExists suffix on IAM condition operators.
4
+
5
+ Detects:
6
+ - IfExists on security-sensitive keys in Allow statements (may bypass controls)
7
+ - Non-negated IfExists in Deny statements (weakens the Deny)
8
+ - IfExists on always-present keys (redundant)
9
+ - Suggests IfExists for negated operators in Deny without it
10
+ """
11
+
12
+ from typing import ClassVar
13
+
14
+ from iam_validator.core.aws_service import AWSServiceFetcher
15
+ from iam_validator.core.check_registry import CheckConfig, PolicyCheck
16
+ from iam_validator.core.condition_validators import (
17
+ ALWAYS_PRESENT_CONDITION_KEYS,
18
+ SECURITY_SENSITIVE_CONDITION_KEYS,
19
+ has_if_exists_suffix,
20
+ is_negated_operator,
21
+ normalize_operator,
22
+ )
23
+ from iam_validator.core.models import Statement, ValidationIssue
24
+
25
+
26
+ class IfExistsConditionCheck(PolicyCheck):
27
+ """Check for improper or risky usage of IfExists condition operators."""
28
+
29
+ check_id: ClassVar[str] = "ifexists_condition_usage"
30
+ description: ClassVar[str] = "Validates proper usage of IfExists suffix on condition operators"
31
+ default_severity: ClassVar[str] = "warning"
32
+
33
+ async def execute(
34
+ self,
35
+ statement: Statement,
36
+ statement_idx: int,
37
+ fetcher: AWSServiceFetcher,
38
+ config: CheckConfig,
39
+ ) -> list[ValidationIssue]:
40
+ """Execute IfExists condition usage checks."""
41
+ issues: list[ValidationIssue] = []
42
+
43
+ if not statement.condition:
44
+ return issues
45
+
46
+ statement_sid = statement.sid
47
+ line_number = statement.line_number
48
+ effect = statement.effect
49
+
50
+ # Config options
51
+ warn_security_sensitive_allow = config.config.get("warn_security_sensitive_allow", True)
52
+ suggest_deny_ifexists = config.config.get("suggest_deny_ifexists", False)
53
+ warn_always_present_keys = config.config.get("warn_always_present_keys", True)
54
+ additional_security_keys = config.config.get("additional_security_sensitive_keys", [])
55
+
56
+ # Build the full set of security-sensitive keys
57
+ security_keys = SECURITY_SENSITIVE_CONDITION_KEYS | frozenset(additional_security_keys)
58
+
59
+ # Collect Null-checked keys for suppression
60
+ null_checked_keys: set[str] = set()
61
+ for op, conds in statement.condition.items():
62
+ base_op, _, _ = normalize_operator(op)
63
+ if base_op == "Null":
64
+ for key, value in conds.items():
65
+ # Only suppress if Null checks for key presence (value = "false")
66
+ values = value if isinstance(value, list) else [value]
67
+ if any(str(v).lower() == "false" for v in values):
68
+ null_checked_keys.add(key)
69
+
70
+ for operator, conditions in statement.condition.items():
71
+ has_ifexists = has_if_exists_suffix(operator)
72
+ is_negated = is_negated_operator(operator)
73
+ base_op, _, _ = normalize_operator(operator)
74
+
75
+ for condition_key in conditions:
76
+ # Normalize condition key for case-insensitive comparison
77
+ key_lower = condition_key.lower()
78
+
79
+ # Skip MFA key - handled by mfa_condition_antipattern check
80
+ if key_lower == "aws:multifactorauthpresent":
81
+ continue
82
+
83
+ if has_ifexists:
84
+ # IfExists on always-present keys is redundant
85
+ if warn_always_present_keys:
86
+ always_present_match = any(
87
+ k.lower() == key_lower for k in ALWAYS_PRESENT_CONDITION_KEYS
88
+ )
89
+ if always_present_match:
90
+ # Reconstruct the operator without IfExists for the message
91
+ raw_base = operator
92
+ if ":" in operator:
93
+ parts = operator.split(":", 1)
94
+ if parts[0] in ("ForAllValues", "ForAnyValue"):
95
+ raw_base = parts[1]
96
+ base_without_ifexists = (
97
+ raw_base[:-8] if raw_base.endswith("IfExists") else raw_base
98
+ )
99
+ if ":" in operator:
100
+ parts = operator.split(":", 1)
101
+ if parts[0] in ("ForAllValues", "ForAnyValue"):
102
+ base_without_ifexists = f"{parts[0]}:{base_without_ifexists}"
103
+
104
+ issues.append(
105
+ ValidationIssue(
106
+ severity="info",
107
+ message=(
108
+ f"Redundant `IfExists`: The condition key "
109
+ f"`{condition_key}` is always present in the "
110
+ f"request context. Using `{operator}` has the "
111
+ f"same effect as `{base_without_ifexists}` for "
112
+ f"this key."
113
+ ),
114
+ statement_sid=statement_sid,
115
+ statement_index=statement_idx,
116
+ issue_type="ifexists_on_always_present_key",
117
+ condition_key=condition_key,
118
+ line_number=line_number,
119
+ field_name="condition",
120
+ )
121
+ )
122
+
123
+ # Check if key is security-sensitive (case-insensitive)
124
+ is_security_key = any(k.lower() == key_lower for k in security_keys)
125
+
126
+ if effect == "Allow" and warn_security_sensitive_allow:
127
+ # IfExists on security-sensitive keys in Allow may bypass controls
128
+ if is_security_key:
129
+ # Check for complementary Null check
130
+ if condition_key not in null_checked_keys:
131
+ issues.append(
132
+ ValidationIssue(
133
+ severity=self.get_severity(config),
134
+ message=(
135
+ f"Security control may be bypassed: "
136
+ f"`{operator}` with `{condition_key}` in "
137
+ f"an Allow statement means the restriction "
138
+ f"is not enforced when the key is missing "
139
+ f"from the request context. Not all API "
140
+ f"calls include `{condition_key}` (e.g., "
141
+ f"calls made by AWS services on your "
142
+ f"behalf). Consider using the operator "
143
+ f"without `IfExists` or adding a `Null` "
144
+ f"condition check."
145
+ ),
146
+ statement_sid=statement_sid,
147
+ statement_index=statement_idx,
148
+ issue_type="ifexists_weakens_security_condition",
149
+ condition_key=condition_key,
150
+ line_number=line_number,
151
+ field_name="condition",
152
+ )
153
+ )
154
+
155
+ elif effect == "Deny":
156
+ # Non-negated IfExists in Deny weakens the restriction
157
+ if not is_negated and is_security_key:
158
+ if condition_key not in null_checked_keys:
159
+ issues.append(
160
+ ValidationIssue(
161
+ severity=self.get_severity(config),
162
+ message=(
163
+ f"Weakened Deny: `{operator}` with "
164
+ f"`{condition_key}` in a Deny statement "
165
+ f"means the Deny does not apply when "
166
+ f"`{condition_key}` is missing from the "
167
+ f"request context. Consider removing "
168
+ f"`IfExists` or adding a `Null` condition "
169
+ f"check."
170
+ ),
171
+ statement_sid=statement_sid,
172
+ statement_index=statement_idx,
173
+ issue_type="ifexists_weakens_deny",
174
+ condition_key=condition_key,
175
+ line_number=line_number,
176
+ field_name="condition",
177
+ )
178
+ )
179
+
180
+ elif not has_ifexists and effect == "Deny" and suggest_deny_ifexists:
181
+ # Suggest IfExists for negated operator in Deny without it
182
+ if is_negated:
183
+ # Only suggest for keys that may be absent
184
+ is_always_present = any(
185
+ k.lower() == key_lower for k in ALWAYS_PRESENT_CONDITION_KEYS
186
+ )
187
+ is_security_key = any(k.lower() == key_lower for k in security_keys)
188
+ if not is_always_present and is_security_key:
189
+ issues.append(
190
+ ValidationIssue(
191
+ severity="info",
192
+ message=(
193
+ f"Consider using `{base_op}IfExists` instead "
194
+ f"of `{base_op}` in this Deny statement. "
195
+ f"Without `IfExists`, the Deny does not apply "
196
+ f"when `{condition_key}` is missing from the "
197
+ f"request context. With `{base_op}IfExists`, "
198
+ f"the Deny still applies even when the key is "
199
+ f"absent."
200
+ ),
201
+ statement_sid=statement_sid,
202
+ statement_index=statement_idx,
203
+ issue_type="ifexists_deny_suggestion",
204
+ condition_key=condition_key,
205
+ line_number=line_number,
206
+ field_name="condition",
207
+ )
208
+ )
209
+
210
+ return issues
@@ -11,6 +11,7 @@ from typing import ClassVar
11
11
  from iam_validator.core.aws_service import AWSServiceFetcher
12
12
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
13
13
  from iam_validator.core.condition_validators import (
14
+ has_if_exists_suffix,
14
15
  is_multivalued_context_key,
15
16
  normalize_operator,
16
17
  )
@@ -81,6 +82,7 @@ class SetOperatorValidationCheck(PolicyCheck):
81
82
  # Second pass: Validate set operator usage
82
83
  for operator, conditions in statement.condition.items():
83
84
  base_operator, _operator_type, set_prefix = normalize_operator(operator)
85
+ has_ifexists = has_if_exists_suffix(operator)
84
86
 
85
87
  if not set_prefix:
86
88
  continue
@@ -110,15 +112,27 @@ class SetOperatorValidationCheck(PolicyCheck):
110
112
  # Issue 2: ForAllValues with Allow effect without Null check (security risk)
111
113
  if set_prefix == "ForAllValues" and effect == "Allow":
112
114
  if condition_key not in null_checked_keys:
115
+ if has_ifexists:
116
+ message = (
117
+ f"Compounded security risk: `{operator}` with `Allow` effect "
118
+ f"on `{condition_key}` is doubly permissive. `ForAllValues` "
119
+ f"passes when the value set is empty, and `IfExists` passes "
120
+ f"when the key is missing entirely. Both mechanisms "
121
+ f"independently allow access without matching any values. "
122
+ f'Add: `"Null": {{"{condition_key}": "false"}}` and consider '
123
+ f"removing `IfExists`."
124
+ )
125
+ else:
126
+ message = (
127
+ f"Security risk: `ForAllValues` with `Allow` effect on `{condition_key}` "
128
+ f"should include a `Null` condition check. Without it, requests with missing "
129
+ f'`{condition_key}` will be granted access. Add: `"Null": {{"{condition_key}": "false"}}`. '
130
+ f"See: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-single-vs-multi-valued-context-keys.html"
131
+ )
113
132
  issues.append(
114
133
  ValidationIssue(
115
134
  severity="warning",
116
- message=(
117
- f"Security risk: `ForAllValues` with `Allow` effect on `{condition_key}` "
118
- f"should include a `Null` condition check. Without it, requests with missing "
119
- f'`{condition_key}` will be granted access. Add: `"Null": {{"{condition_key}": "false"}}`. '
120
- f"See: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-single-vs-multi-valued-context-keys.html"
121
- ),
135
+ message=message,
122
136
  statement_sid=statement_sid,
123
137
  statement_index=statement_idx,
124
138
  issue_type="forallvalues_allow_without_null_check",
@@ -706,6 +706,7 @@ def create_default_registry(
706
706
  # 3. TYPE VALIDATION (Condition operator type checking)
707
707
  registry.register(checks.ConditionTypeMismatchCheck()) # Operator-value type compatibility
708
708
  registry.register(checks.SetOperatorValidationCheck()) # ForAllValues/ForAnyValue usage
709
+ registry.register(checks.IfExistsConditionCheck()) # IfExists usage validation
709
710
 
710
711
  # 4. RESOURCE MATCHING (Action-resource relationship validation)
711
712
  registry.register(
@@ -3,7 +3,8 @@ Condition Key and Value Validators.
3
3
 
4
4
  This module provides validators for IAM policy condition operators, keys, and values.
5
5
  Based on AWS IAM Policy Elements Reference:
6
- https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition_operators.html
6
+ - https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition_operators.html
7
+ - https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html
7
8
 
8
9
  Supports:
9
10
  - All standard IAM condition operators (String, Numeric, Date, Bool, Binary, IP, ARN)
@@ -69,6 +70,49 @@ CONDITION_OPERATORS = {
69
70
  # Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_multi-value-conditions.html
70
71
  SET_OPERATOR_PREFIXES = ["ForAllValues", "ForAnyValue"]
71
72
 
73
+ # Condition keys that are sometimes absent from the request context AND are
74
+ # security-sensitive. Using IfExists with these keys in Allow statements can
75
+ # bypass security controls when the key is missing.
76
+ # Note: aws:MultiFactorAuthPresent is excluded — handled by mfa_condition_antipattern check.
77
+ # Note: Always-present keys are in ALWAYS_PRESENT_CONDITION_KEYS.
78
+ # Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html
79
+ SECURITY_SENSITIVE_CONDITION_KEYS = frozenset(
80
+ {
81
+ "aws:SourceIp", # Absent when requester uses VPC endpoint
82
+ "aws:VpcSourceIp", # Related to VPC context
83
+ "aws:SourceVpc", # Only present with VPC endpoint
84
+ "aws:SourceVpce", # Only present with VPC endpoint
85
+ "aws:SourceArn", # Only for direct service principal calls
86
+ "aws:SourceAccount", # Only for direct service principal calls
87
+ "aws:SourceOrgID", # Only for service principal calls + org membership
88
+ "aws:SourceOrgPaths", # Only for service principal calls + org membership
89
+ "aws:PrincipalOrgID", # Only if principal is a member of an organization
90
+ "aws:PrincipalOrgPaths", # Only if principal is a member of an organization
91
+ "aws:PrincipalArn", # All signed requests only; absent for anonymous requests
92
+ "aws:ResourceAccount", # Always included for most actions (has documented exceptions)
93
+ "aws:ResourceOrgID", # Only if resource account is in an organization
94
+ "aws:ResourceOrgPaths", # Only if resource account is in an organization
95
+ }
96
+ )
97
+
98
+ # Condition keys that are always present in every AWS request context.
99
+ # Using IfExists with these keys is redundant since the key is never absent.
100
+ # Reference: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html
101
+ ALWAYS_PRESENT_CONDITION_KEYS = frozenset(
102
+ {
103
+ # "always included in the request context" per AWS docs
104
+ "aws:CurrentTime",
105
+ "aws:EpochTime",
106
+ "aws:SecureTransport",
107
+ "aws:ViaAWSService",
108
+ "aws:RequestedRegion",
109
+ # "included in the request context for all requests, including anonymous requests"
110
+ "aws:PrincipalAccount",
111
+ "aws:PrincipalType",
112
+ "aws:userid",
113
+ }
114
+ )
115
+
72
116
 
73
117
  def normalize_operator(operator: str) -> tuple[str, str | None, str | None]:
74
118
  """
@@ -122,6 +166,37 @@ def normalize_operator(operator: str) -> tuple[str, str | None, str | None]:
122
166
  return operator, None, set_prefix
123
167
 
124
168
 
169
+ def has_if_exists_suffix(operator: str) -> bool:
170
+ """
171
+ Check if a condition operator has the IfExists suffix.
172
+
173
+ Handles set operator prefixes (ForAllValues/ForAnyValue).
174
+ Case-sensitive for the IfExists suffix, matching AWS IAM behavior.
175
+
176
+ Args:
177
+ operator: Raw operator string (e.g., "StringEqualsIfExists", "ForAllValues:StringLikeIfExists")
178
+
179
+ Returns:
180
+ True if the operator has the IfExists suffix
181
+
182
+ Examples:
183
+ >>> has_if_exists_suffix("StringEqualsIfExists")
184
+ True
185
+ >>> has_if_exists_suffix("StringEquals")
186
+ False
187
+ >>> has_if_exists_suffix("ForAllValues:StringLikeIfExists")
188
+ True
189
+ >>> has_if_exists_suffix("BoolIfExists")
190
+ True
191
+ """
192
+ cleaned = operator
193
+ if ":" in operator:
194
+ parts = operator.split(":", 1)
195
+ if parts[0] in SET_OPERATOR_PREFIXES:
196
+ cleaned = parts[1]
197
+ return cleaned.endswith("IfExists")
198
+
199
+
125
200
  def translate_type(doc_type: str) -> str:
126
201
  """
127
202
  Translate documentation type names to normalized types.
@@ -264,8 +339,7 @@ def _validate_date_value(value_str: str) -> tuple[bool, str | None]:
264
339
  if epoch > 32503680000: # Year 3000 in seconds
265
340
  return (
266
341
  False,
267
- f"UNIX epoch timestamp appears unreasonably large: {value_str}. "
268
- "Expected seconds since 1970-01-01.",
342
+ f"UNIX epoch timestamp appears unreasonably large: {value_str}. Expected seconds since 1970-01-01.",
269
343
  )
270
344
  return True, None
271
345
  except (ValueError, OverflowError):