iam-policy-validator 1.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
- iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
- iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +9 -0
- iam_validator/checks/__init__.py +45 -0
- iam_validator/checks/action_condition_enforcement.py +1442 -0
- iam_validator/checks/action_resource_matching.py +472 -0
- iam_validator/checks/action_validation.py +67 -0
- iam_validator/checks/condition_key_validation.py +88 -0
- iam_validator/checks/condition_type_mismatch.py +257 -0
- iam_validator/checks/full_wildcard.py +62 -0
- iam_validator/checks/mfa_condition_check.py +105 -0
- iam_validator/checks/policy_size.py +114 -0
- iam_validator/checks/policy_structure.py +556 -0
- iam_validator/checks/policy_type_validation.py +331 -0
- iam_validator/checks/principal_validation.py +708 -0
- iam_validator/checks/resource_validation.py +135 -0
- iam_validator/checks/sensitive_action.py +438 -0
- iam_validator/checks/service_wildcard.py +98 -0
- iam_validator/checks/set_operator_validation.py +153 -0
- iam_validator/checks/sid_uniqueness.py +146 -0
- iam_validator/checks/trust_policy_validation.py +509 -0
- iam_validator/checks/utils/__init__.py +17 -0
- iam_validator/checks/utils/action_parser.py +149 -0
- iam_validator/checks/utils/policy_level_checks.py +190 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
- iam_validator/checks/utils/wildcard_expansion.py +86 -0
- iam_validator/checks/wildcard_action.py +58 -0
- iam_validator/checks/wildcard_resource.py +374 -0
- iam_validator/commands/__init__.py +31 -0
- iam_validator/commands/analyze.py +549 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +393 -0
- iam_validator/commands/completion.py +471 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +830 -0
- iam_validator/core/__init__.py +13 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +29 -0
- iam_validator/core/aws_service/__init__.py +21 -0
- iam_validator/core/aws_service/cache.py +108 -0
- iam_validator/core/aws_service/client.py +205 -0
- iam_validator/core/aws_service/fetcher.py +641 -0
- iam_validator/core/aws_service/parsers.py +149 -0
- iam_validator/core/aws_service/patterns.py +51 -0
- iam_validator/core/aws_service/storage.py +291 -0
- iam_validator/core/aws_service/validators.py +380 -0
- iam_validator/core/check_registry.py +679 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/codeowners.py +245 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +181 -0
- iam_validator/core/config/check_documentation.py +390 -0
- iam_validator/core/config/condition_requirements.py +258 -0
- iam_validator/core/config/config_loader.py +670 -0
- iam_validator/core/config/defaults.py +739 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +132 -0
- iam_validator/core/config/wildcards.py +127 -0
- iam_validator/core/constants.py +149 -0
- iam_validator/core/diff_parser.py +325 -0
- iam_validator/core/finding_fingerprint.py +131 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +68 -0
- iam_validator/core/formatters/csv.py +171 -0
- iam_validator/core/formatters/enhanced.py +481 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +64 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/ignore_processor.py +309 -0
- iam_validator/core/ignored_findings.py +400 -0
- iam_validator/core/label_manager.py +197 -0
- iam_validator/core/models.py +404 -0
- iam_validator/core/policy_checks.py +220 -0
- iam_validator/core/policy_loader.py +785 -0
- iam_validator/core/pr_commenter.py +780 -0
- iam_validator/core/report.py +942 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +1821 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +220 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +451 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +35 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +205 -0
- iam_validator/utils/terminal.py +22 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Resource validation check - validates ARN formats."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from iam_validator.core.aws_service import AWSServiceFetcher
|
|
7
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
8
|
+
from iam_validator.core.constants import DEFAULT_ARN_VALIDATION_PATTERN, MAX_ARN_LENGTH
|
|
9
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
10
|
+
from iam_validator.sdk.arn_matching import (
|
|
11
|
+
has_template_variables,
|
|
12
|
+
normalize_template_variables,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ResourceValidationCheck(PolicyCheck):
|
|
17
|
+
"""Validates ARN format for resources."""
|
|
18
|
+
|
|
19
|
+
check_id: ClassVar[str] = "resource_validation"
|
|
20
|
+
description: ClassVar[str] = "Validates ARN format for resources"
|
|
21
|
+
default_severity: ClassVar[str] = "error"
|
|
22
|
+
|
|
23
|
+
async def execute(
|
|
24
|
+
self,
|
|
25
|
+
statement: Statement,
|
|
26
|
+
statement_idx: int,
|
|
27
|
+
fetcher: AWSServiceFetcher,
|
|
28
|
+
config: CheckConfig,
|
|
29
|
+
) -> list[ValidationIssue]:
|
|
30
|
+
"""Execute resource ARN validation on a statement."""
|
|
31
|
+
issues = []
|
|
32
|
+
|
|
33
|
+
# Get resources from statement
|
|
34
|
+
resources = statement.get_resources()
|
|
35
|
+
statement_sid = statement.sid
|
|
36
|
+
line_number = statement.line_number
|
|
37
|
+
|
|
38
|
+
# Get ARN pattern from config, or use default
|
|
39
|
+
# Pattern allows wildcards (*) in region and account fields
|
|
40
|
+
arn_pattern_str = config.config.get("arn_pattern", DEFAULT_ARN_VALIDATION_PATTERN)
|
|
41
|
+
|
|
42
|
+
# Compile pattern
|
|
43
|
+
try:
|
|
44
|
+
arn_pattern = re.compile(arn_pattern_str)
|
|
45
|
+
except re.error:
|
|
46
|
+
# Fallback to default pattern if custom pattern is invalid
|
|
47
|
+
arn_pattern = re.compile(DEFAULT_ARN_VALIDATION_PATTERN)
|
|
48
|
+
|
|
49
|
+
# Check if template variable support is enabled (default: true)
|
|
50
|
+
# Try global settings first, then check-specific config
|
|
51
|
+
allow_template_variables = config.root_config.get("settings", {}).get(
|
|
52
|
+
"allow_template_variables",
|
|
53
|
+
config.config.get("allow_template_variables", True),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for resource in resources:
|
|
57
|
+
# Skip wildcard resources (handled by security checks)
|
|
58
|
+
if resource == "*":
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
# Validate ARN length to prevent ReDoS attacks
|
|
62
|
+
if len(resource) > MAX_ARN_LENGTH:
|
|
63
|
+
issues.append(
|
|
64
|
+
ValidationIssue(
|
|
65
|
+
severity=self.get_severity(config),
|
|
66
|
+
statement_sid=statement_sid,
|
|
67
|
+
statement_index=statement_idx,
|
|
68
|
+
issue_type="invalid_resource",
|
|
69
|
+
message=f"Resource ARN exceeds maximum length ({len(resource)} > {MAX_ARN_LENGTH}): {resource[:100]}...",
|
|
70
|
+
resource=resource[:100] + "...",
|
|
71
|
+
suggestion="`ARN` is too long and may be invalid",
|
|
72
|
+
line_number=line_number,
|
|
73
|
+
field_name="resource",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Check if resource contains template variables
|
|
79
|
+
has_templates = has_template_variables(resource)
|
|
80
|
+
|
|
81
|
+
# If template variables are found and allowed, normalize them for validation
|
|
82
|
+
validation_resource = resource
|
|
83
|
+
if has_templates and allow_template_variables:
|
|
84
|
+
validation_resource = normalize_template_variables(resource)
|
|
85
|
+
|
|
86
|
+
# Validate ARN format
|
|
87
|
+
try:
|
|
88
|
+
if not arn_pattern.match(validation_resource):
|
|
89
|
+
# If original resource had templates and normalization didn't help,
|
|
90
|
+
# provide a more informative message
|
|
91
|
+
if has_templates and allow_template_variables:
|
|
92
|
+
issues.append(
|
|
93
|
+
ValidationIssue(
|
|
94
|
+
severity=self.get_severity(config),
|
|
95
|
+
statement_sid=statement_sid,
|
|
96
|
+
statement_index=statement_idx,
|
|
97
|
+
issue_type="invalid_resource",
|
|
98
|
+
message=f"Invalid `ARN` format even after normalizing template variables: `{resource}`",
|
|
99
|
+
resource=resource,
|
|
100
|
+
suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource` (template variables like `${aws_account_id}` are supported)",
|
|
101
|
+
line_number=line_number,
|
|
102
|
+
field_name="resource",
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
issues.append(
|
|
107
|
+
ValidationIssue(
|
|
108
|
+
severity=self.get_severity(config),
|
|
109
|
+
statement_sid=statement_sid,
|
|
110
|
+
statement_index=statement_idx,
|
|
111
|
+
issue_type="invalid_resource",
|
|
112
|
+
message=f"Invalid `ARN` format: `{resource}`",
|
|
113
|
+
resource=resource,
|
|
114
|
+
suggestion="`ARN` should follow format: `arn:partition:service:region:account-id:resource`",
|
|
115
|
+
line_number=line_number,
|
|
116
|
+
field_name="resource",
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
120
|
+
# If regex matching fails (shouldn't happen with length check), treat as invalid
|
|
121
|
+
issues.append(
|
|
122
|
+
ValidationIssue(
|
|
123
|
+
severity=self.get_severity(config),
|
|
124
|
+
statement_sid=statement_sid,
|
|
125
|
+
statement_index=statement_idx,
|
|
126
|
+
issue_type="invalid_resource",
|
|
127
|
+
message=f"Could not validate `ARN` format: `{resource}`",
|
|
128
|
+
resource=resource,
|
|
129
|
+
suggestion="`ARN` validation failed - may contain unexpected characters",
|
|
130
|
+
line_number=line_number,
|
|
131
|
+
field_name="resource",
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return issues
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Sensitive action check - detects sensitive actions without IAM conditions."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from iam_validator.checks.utils.policy_level_checks import check_policy_level_actions
|
|
6
|
+
from iam_validator.checks.utils.sensitive_action_matcher import (
|
|
7
|
+
DEFAULT_SENSITIVE_ACTIONS,
|
|
8
|
+
check_sensitive_actions,
|
|
9
|
+
)
|
|
10
|
+
from iam_validator.checks.utils.wildcard_expansion import expand_wildcard_actions
|
|
11
|
+
from iam_validator.core.aws_service import AWSServiceFetcher
|
|
12
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
13
|
+
from iam_validator.core.config.sensitive_actions import get_category_for_action
|
|
14
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from iam_validator.core.models import IAMPolicy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_suggestion_from_requirement(requirement: dict[str, Any]) -> tuple[str, str] | None:
|
|
21
|
+
"""
|
|
22
|
+
Extract suggestion and example from a condition requirement.
|
|
23
|
+
|
|
24
|
+
This is a public utility function that can be used by custom checks
|
|
25
|
+
to extract user-friendly suggestions from condition requirement structures.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
requirement: Condition requirement dictionary containing:
|
|
29
|
+
- suggestion_text: Human-readable guidance text
|
|
30
|
+
- required_conditions: Conditions structure (list or dict with any_of/all_of/none_of)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Tuple of (suggestion_text, example) if available, None otherwise
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> from iam_validator.core.config.condition_requirements import IAM_PASS_ROLE_REQUIREMENT
|
|
37
|
+
>>> suggestion, example = get_suggestion_from_requirement(IAM_PASS_ROLE_REQUIREMENT)
|
|
38
|
+
>>> print(suggestion)
|
|
39
|
+
This action allows passing IAM roles to AWS services...
|
|
40
|
+
"""
|
|
41
|
+
# Check if requirement has suggestion_text
|
|
42
|
+
if "suggestion_text" not in requirement:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
suggestion_text = requirement["suggestion_text"]
|
|
46
|
+
|
|
47
|
+
# Extract example from required_conditions
|
|
48
|
+
example = ""
|
|
49
|
+
required_conditions = requirement.get("required_conditions", [])
|
|
50
|
+
|
|
51
|
+
# Handle different condition structures (list, dict with any_of/all_of/none_of)
|
|
52
|
+
if isinstance(required_conditions, list) and required_conditions:
|
|
53
|
+
# Get first condition's example
|
|
54
|
+
first_condition = required_conditions[0]
|
|
55
|
+
example = first_condition.get("example", "")
|
|
56
|
+
elif isinstance(required_conditions, dict):
|
|
57
|
+
# Handle any_of, all_of, none_of structures
|
|
58
|
+
for logic_key in ["any_of", "all_of", "none_of"]:
|
|
59
|
+
if logic_key in required_conditions:
|
|
60
|
+
conditions = required_conditions[logic_key]
|
|
61
|
+
if isinstance(conditions, list) and conditions:
|
|
62
|
+
# Get first option's example
|
|
63
|
+
first_option = conditions[0]
|
|
64
|
+
if isinstance(first_option, dict):
|
|
65
|
+
if "example" in first_option:
|
|
66
|
+
example = first_option["example"]
|
|
67
|
+
break
|
|
68
|
+
# Handle nested all_of/any_of/none_of structures
|
|
69
|
+
for nested_key in ["all_of", "any_of", "none_of"]:
|
|
70
|
+
if nested_key in first_option and isinstance(
|
|
71
|
+
first_option[nested_key], list
|
|
72
|
+
):
|
|
73
|
+
for nested in first_option[nested_key]:
|
|
74
|
+
if "example" in nested:
|
|
75
|
+
example = nested["example"]
|
|
76
|
+
break
|
|
77
|
+
if example:
|
|
78
|
+
break
|
|
79
|
+
if example:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
return (suggestion_text, example)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SensitiveActionCheck(PolicyCheck):
|
|
86
|
+
"""Checks for sensitive actions without IAM conditions to limit their use."""
|
|
87
|
+
|
|
88
|
+
check_id: ClassVar[str] = "sensitive_action"
|
|
89
|
+
description: ClassVar[str] = "Checks for sensitive actions without conditions"
|
|
90
|
+
default_severity: ClassVar[str] = "medium"
|
|
91
|
+
|
|
92
|
+
def _get_severity_for_action(self, action: str, config: CheckConfig) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Get severity for a specific action, considering category-based overrides.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
action: The AWS action to check
|
|
98
|
+
config: Check configuration
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Severity level for the action (considers category overrides)
|
|
102
|
+
"""
|
|
103
|
+
# Check if category severities are configured
|
|
104
|
+
category_severities = config.config.get("category_severities", {})
|
|
105
|
+
if not category_severities:
|
|
106
|
+
return self.get_severity(config)
|
|
107
|
+
|
|
108
|
+
# Get the category for this action
|
|
109
|
+
category = get_category_for_action(action)
|
|
110
|
+
if category and category in category_severities:
|
|
111
|
+
return category_severities[category]
|
|
112
|
+
|
|
113
|
+
# Fall back to default severity
|
|
114
|
+
return self.get_severity(config)
|
|
115
|
+
|
|
116
|
+
def _get_actions_covered_by_condition_enforcement(self, config: CheckConfig) -> set[str]:
|
|
117
|
+
"""
|
|
118
|
+
Get set of actions that are covered by action_condition_enforcement requirements.
|
|
119
|
+
|
|
120
|
+
This prevents duplicate warnings when an action is already validated by
|
|
121
|
+
formal condition requirements.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
config: Check configuration with root_config access
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Set of action strings that are covered by condition requirements
|
|
128
|
+
"""
|
|
129
|
+
covered_actions: set[str] = set()
|
|
130
|
+
|
|
131
|
+
# Access action_condition_enforcement config from root_config
|
|
132
|
+
ace_config = config.root_config.get("action_condition_enforcement", {})
|
|
133
|
+
requirements = ace_config.get("requirements", [])
|
|
134
|
+
|
|
135
|
+
for requirement in requirements:
|
|
136
|
+
# Get actions from requirement
|
|
137
|
+
actions_config = requirement.get("actions", [])
|
|
138
|
+
if isinstance(actions_config, list):
|
|
139
|
+
covered_actions.update(actions_config)
|
|
140
|
+
|
|
141
|
+
return covered_actions
|
|
142
|
+
|
|
143
|
+
def _get_category_specific_suggestion(
|
|
144
|
+
self, action: str, config: CheckConfig
|
|
145
|
+
) -> tuple[str, str]:
|
|
146
|
+
"""
|
|
147
|
+
Get category-specific suggestion and example for an action using two-tier lookup.
|
|
148
|
+
|
|
149
|
+
This method provides suggestions for the sensitive_action check, which flags
|
|
150
|
+
actions that have NO conditions. It does NOT validate specific conditions
|
|
151
|
+
(that's handled by the action_condition_enforcement check).
|
|
152
|
+
|
|
153
|
+
Tier 1: Check action_overrides in category suggestions for important actions
|
|
154
|
+
Tier 2: Fall back to category-level default suggestions
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
action: The AWS action to check
|
|
158
|
+
config: Check configuration
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Tuple of (suggestion_text, example_text) tailored to the action's category
|
|
162
|
+
"""
|
|
163
|
+
# TIER 1: Check action-specific overrides in category suggestions
|
|
164
|
+
category = get_category_for_action(action)
|
|
165
|
+
category_suggestions = config.config.get("category_suggestions", {})
|
|
166
|
+
|
|
167
|
+
if category and category in category_suggestions:
|
|
168
|
+
category_data = category_suggestions[category]
|
|
169
|
+
|
|
170
|
+
# Check if there's an action-specific override
|
|
171
|
+
action_overrides = category_data.get("action_overrides", {})
|
|
172
|
+
if action in action_overrides:
|
|
173
|
+
override = action_overrides[action]
|
|
174
|
+
return (override["suggestion"], override["example"])
|
|
175
|
+
|
|
176
|
+
# TIER 2: Fall back to category-level defaults
|
|
177
|
+
return (category_data["suggestion"], category_data["example"])
|
|
178
|
+
|
|
179
|
+
# Ultimate fallback: Generic ABAC guidance for uncategorized actions
|
|
180
|
+
return (
|
|
181
|
+
"Add IAM conditions to limit when this action can be used. Use ABAC for scalability:\n"
|
|
182
|
+
"• Match principal tags to resource tags (`aws:PrincipalTag/<tag-name>` = `aws:ResourceTag/<tag-name>`)\n"
|
|
183
|
+
"• Match organization principal tags to resource tags (`aws:PrincipalOrgID` = `aws:ResourceOrgID`)\n"
|
|
184
|
+
"• Require MFA (`aws:MultiFactorAuthPresent` = `true`)\n"
|
|
185
|
+
"• Restrict by IP (`aws:SourceIp`) or VPC (`aws:SourceVpc`)",
|
|
186
|
+
'"Condition": {\n'
|
|
187
|
+
' "StringEquals": {\n'
|
|
188
|
+
' "aws:PrincipalTag/owner": "${aws:ResourceTag/owner}"\n'
|
|
189
|
+
" }\n"
|
|
190
|
+
"}",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
async def execute(
|
|
194
|
+
self,
|
|
195
|
+
statement: Statement,
|
|
196
|
+
statement_idx: int,
|
|
197
|
+
fetcher: AWSServiceFetcher,
|
|
198
|
+
config: CheckConfig,
|
|
199
|
+
) -> list[ValidationIssue]:
|
|
200
|
+
"""Execute sensitive action check on a statement."""
|
|
201
|
+
issues = []
|
|
202
|
+
|
|
203
|
+
# Only check Allow statements
|
|
204
|
+
if statement.effect != "Allow":
|
|
205
|
+
return issues
|
|
206
|
+
|
|
207
|
+
actions = statement.get_actions()
|
|
208
|
+
has_conditions = statement.condition is not None and len(statement.condition) > 0
|
|
209
|
+
|
|
210
|
+
# Expand wildcards to actual actions using AWS API
|
|
211
|
+
expanded_actions = await expand_wildcard_actions(actions, fetcher)
|
|
212
|
+
|
|
213
|
+
# Check if sensitive actions match using any_of/all_of logic
|
|
214
|
+
is_sensitive, matched_actions = check_sensitive_actions(
|
|
215
|
+
expanded_actions, config, DEFAULT_SENSITIVE_ACTIONS
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if is_sensitive and not has_conditions:
|
|
219
|
+
# Filter out actions already covered by action_condition_enforcement
|
|
220
|
+
# This prevents duplicate warnings with different messages
|
|
221
|
+
covered_actions = self._get_actions_covered_by_condition_enforcement(config)
|
|
222
|
+
matched_actions = [
|
|
223
|
+
action for action in matched_actions if action not in covered_actions
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
# If all matched actions are covered elsewhere, skip this check
|
|
227
|
+
if not matched_actions:
|
|
228
|
+
return issues
|
|
229
|
+
# Create appropriate message based on matched actions using configurable templates
|
|
230
|
+
if len(matched_actions) == 1:
|
|
231
|
+
message_template = config.config.get(
|
|
232
|
+
"message_single",
|
|
233
|
+
"Sensitive action `{action}` should have conditions to limit when it can be used",
|
|
234
|
+
)
|
|
235
|
+
message = message_template.format(action=matched_actions[0])
|
|
236
|
+
else:
|
|
237
|
+
action_list = "', '".join(matched_actions)
|
|
238
|
+
message_template = config.config.get(
|
|
239
|
+
"message_multiple",
|
|
240
|
+
"Sensitive actions `{actions}` should have conditions to limit when they can be used",
|
|
241
|
+
)
|
|
242
|
+
message = message_template.format(actions=action_list)
|
|
243
|
+
|
|
244
|
+
# Get category-specific suggestion and example (or use config defaults)
|
|
245
|
+
# Use the first matched action to determine the category
|
|
246
|
+
suggestion_text, example = self._get_category_specific_suggestion(
|
|
247
|
+
matched_actions[0], config
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Determine severity based on the highest severity action in the list
|
|
251
|
+
# If single action, use its category severity
|
|
252
|
+
# If multiple actions, use the highest severity among them
|
|
253
|
+
severity = self.get_severity(config) # Default
|
|
254
|
+
if matched_actions:
|
|
255
|
+
# Get severity for first action (or highest if we want to be more sophisticated)
|
|
256
|
+
severity = self._get_severity_for_action(matched_actions[0], config)
|
|
257
|
+
|
|
258
|
+
issues.append(
|
|
259
|
+
ValidationIssue(
|
|
260
|
+
severity=severity,
|
|
261
|
+
statement_sid=statement.sid,
|
|
262
|
+
statement_index=statement_idx,
|
|
263
|
+
issue_type="missing_condition",
|
|
264
|
+
message=message,
|
|
265
|
+
action=(matched_actions[0] if len(matched_actions) == 1 else None),
|
|
266
|
+
suggestion=suggestion_text,
|
|
267
|
+
example=example if example else None,
|
|
268
|
+
line_number=statement.line_number,
|
|
269
|
+
field_name="action",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return issues
|
|
274
|
+
|
|
275
|
+
def _apply_merge_strategy(
|
|
276
|
+
self,
|
|
277
|
+
merge_strategy: str,
|
|
278
|
+
user_config: list[dict] | None,
|
|
279
|
+
default_config: list[dict] | None,
|
|
280
|
+
) -> list[dict] | None:
|
|
281
|
+
"""
|
|
282
|
+
Apply merge strategy to combine user and default sensitive action patterns.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
merge_strategy: One of "per_action_override", "user_only", "append",
|
|
286
|
+
"replace_all", or "defaults_only"
|
|
287
|
+
user_config: User-provided sensitive action patterns (or None)
|
|
288
|
+
default_config: Default sensitive action patterns (or None)
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Merged list of patterns based on strategy, or None if no patterns
|
|
292
|
+
"""
|
|
293
|
+
if merge_strategy == "user_only":
|
|
294
|
+
# Use ONLY user patterns, completely ignore defaults
|
|
295
|
+
return user_config
|
|
296
|
+
|
|
297
|
+
elif merge_strategy == "defaults_only":
|
|
298
|
+
# Use ONLY defaults, ignore user patterns
|
|
299
|
+
return default_config
|
|
300
|
+
|
|
301
|
+
elif merge_strategy == "append":
|
|
302
|
+
# Combine both (defaults first, then user)
|
|
303
|
+
result = []
|
|
304
|
+
if default_config:
|
|
305
|
+
result.extend(default_config)
|
|
306
|
+
if user_config:
|
|
307
|
+
result.extend(user_config)
|
|
308
|
+
return result if result else None
|
|
309
|
+
|
|
310
|
+
elif merge_strategy == "replace_all":
|
|
311
|
+
# User replaces all if provided, otherwise use defaults
|
|
312
|
+
return user_config if user_config else default_config
|
|
313
|
+
|
|
314
|
+
else: # "per_action_override" (default)
|
|
315
|
+
# If user provides patterns, use them; otherwise use defaults
|
|
316
|
+
# This is the legacy behavior
|
|
317
|
+
return user_config if user_config else default_config
|
|
318
|
+
|
|
319
|
+
async def execute_policy(
|
|
320
|
+
self,
|
|
321
|
+
policy: "IAMPolicy",
|
|
322
|
+
policy_file: str,
|
|
323
|
+
fetcher: AWSServiceFetcher,
|
|
324
|
+
config: CheckConfig,
|
|
325
|
+
**kwargs,
|
|
326
|
+
) -> list[ValidationIssue]:
|
|
327
|
+
"""
|
|
328
|
+
Execute policy-level sensitive action checks.
|
|
329
|
+
|
|
330
|
+
This method examines the entire policy to detect privilege escalation patterns
|
|
331
|
+
and other security issues that span multiple statements.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
policy: The complete IAM policy to check
|
|
335
|
+
policy_file: Path to the policy file (for context/reporting)
|
|
336
|
+
fetcher: AWS service fetcher for validation against AWS APIs
|
|
337
|
+
config: Configuration for this check instance
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
List of ValidationIssue objects found by this check
|
|
341
|
+
"""
|
|
342
|
+
del policy_file, fetcher # Not used in current implementation
|
|
343
|
+
issues = []
|
|
344
|
+
|
|
345
|
+
# Handle policies with no statements
|
|
346
|
+
if not policy.statement:
|
|
347
|
+
return []
|
|
348
|
+
|
|
349
|
+
# Collect all actions from all Allow statements across the entire policy
|
|
350
|
+
all_actions: set[str] = set()
|
|
351
|
+
statement_map: dict[
|
|
352
|
+
str, list[tuple[int, str | None]]
|
|
353
|
+
] = {} # action -> [(stmt_idx, sid), ...]
|
|
354
|
+
|
|
355
|
+
for idx, statement in enumerate(policy.statement):
|
|
356
|
+
if statement.effect == "Allow":
|
|
357
|
+
actions = statement.get_actions()
|
|
358
|
+
# Filter out wildcards for privilege escalation detection
|
|
359
|
+
filtered_actions = [a for a in actions if a != "*"]
|
|
360
|
+
|
|
361
|
+
for action in filtered_actions:
|
|
362
|
+
all_actions.add(action)
|
|
363
|
+
if action not in statement_map:
|
|
364
|
+
statement_map[action] = []
|
|
365
|
+
statement_map[action].append((idx, statement.sid))
|
|
366
|
+
|
|
367
|
+
# Get configuration for sensitive actions with merge_strategy support
|
|
368
|
+
# merge_strategy options:
|
|
369
|
+
# - "append": Add user patterns ON TOP OF defaults (both apply) - DEFAULT
|
|
370
|
+
# - "user_only": Use ONLY user patterns, disable ALL default privilege escalation patterns
|
|
371
|
+
# - "defaults_only": Ignore user patterns, use only defaults
|
|
372
|
+
# - "replace_all": User patterns completely replace ALL defaults (if provided)
|
|
373
|
+
# - "per_action_override": User patterns replace defaults for matching action combos
|
|
374
|
+
merge_strategy = config.config.get("merge_strategy", "append")
|
|
375
|
+
|
|
376
|
+
# Determine which sensitive_actions patterns to use based on merge_strategy
|
|
377
|
+
# Note: The config.config already contains deep-merged values from defaults + user config
|
|
378
|
+
# For lists like sensitive_actions, user config REPLACES defaults (not merges)
|
|
379
|
+
# So if user provided sensitive_actions, it's already the only value in config.config
|
|
380
|
+
sensitive_actions_config: list[dict] | None = None
|
|
381
|
+
sensitive_patterns_config: list[dict] | None = None
|
|
382
|
+
|
|
383
|
+
if merge_strategy == "user_only":
|
|
384
|
+
# user_only: Disable ALL default patterns
|
|
385
|
+
# If user set merge_strategy: "user_only", they want NO defaults
|
|
386
|
+
# They must explicitly provide sensitive_actions if they want any checks
|
|
387
|
+
# Since we can't distinguish user-provided from defaults after merge,
|
|
388
|
+
# we assume user_only means "no patterns" unless user explicitly provided them
|
|
389
|
+
# (which would have replaced defaults anyway)
|
|
390
|
+
sensitive_actions_config = None
|
|
391
|
+
sensitive_patterns_config = None
|
|
392
|
+
|
|
393
|
+
elif merge_strategy == "defaults_only":
|
|
394
|
+
# Use only defaults - but since config is merged, we use what's there
|
|
395
|
+
# (user would need to NOT provide sensitive_actions to get defaults)
|
|
396
|
+
sensitive_actions_config = config.config.get("sensitive_actions")
|
|
397
|
+
sensitive_patterns_config = config.config.get("sensitive_action_patterns")
|
|
398
|
+
|
|
399
|
+
else:
|
|
400
|
+
# append, replace_all, per_action_override all use the merged config
|
|
401
|
+
# The deep_merge already handled the merging:
|
|
402
|
+
# - If user provided sensitive_actions, it replaced defaults
|
|
403
|
+
# - If user didn't provide it, defaults are in config
|
|
404
|
+
sensitive_actions_config = config.config.get("sensitive_actions")
|
|
405
|
+
sensitive_patterns_config = config.config.get("sensitive_action_patterns")
|
|
406
|
+
|
|
407
|
+
# Check for privilege escalation patterns using all_of logic
|
|
408
|
+
# We need to check both exact actions and patterns
|
|
409
|
+
policy_issues = []
|
|
410
|
+
|
|
411
|
+
# Check sensitive_actions configuration
|
|
412
|
+
if sensitive_actions_config:
|
|
413
|
+
policy_issues.extend(
|
|
414
|
+
check_policy_level_actions(
|
|
415
|
+
list(all_actions),
|
|
416
|
+
statement_map,
|
|
417
|
+
sensitive_actions_config,
|
|
418
|
+
config,
|
|
419
|
+
"actions",
|
|
420
|
+
self.get_severity,
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Check sensitive_action_patterns configuration
|
|
425
|
+
if sensitive_patterns_config:
|
|
426
|
+
policy_issues.extend(
|
|
427
|
+
check_policy_level_actions(
|
|
428
|
+
list(all_actions),
|
|
429
|
+
statement_map,
|
|
430
|
+
sensitive_patterns_config,
|
|
431
|
+
config,
|
|
432
|
+
"patterns",
|
|
433
|
+
self.get_severity,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
issues.extend(policy_issues)
|
|
438
|
+
return issues
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Service wildcard check - detects service-level wildcards like 'iam:*', 's3:*'."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from iam_validator.checks.utils.action_parser import parse_action
|
|
6
|
+
from iam_validator.core.aws_service import AWSServiceFetcher
|
|
7
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
8
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ServiceWildcardCheck(PolicyCheck):
|
|
12
|
+
"""Checks for service-level wildcards (e.g., 'iam:*', 's3:*') which grant all permissions for a service."""
|
|
13
|
+
|
|
14
|
+
check_id: ClassVar[str] = "service_wildcard"
|
|
15
|
+
description: ClassVar[str] = "Checks for service-level wildcards (e.g., 'iam:*', 's3:*')"
|
|
16
|
+
default_severity: ClassVar[str] = "high"
|
|
17
|
+
|
|
18
|
+
async def execute(
|
|
19
|
+
self,
|
|
20
|
+
statement: Statement,
|
|
21
|
+
statement_idx: int,
|
|
22
|
+
fetcher: AWSServiceFetcher,
|
|
23
|
+
config: CheckConfig,
|
|
24
|
+
) -> list[ValidationIssue]:
|
|
25
|
+
"""Execute service wildcard check on a statement."""
|
|
26
|
+
issues = []
|
|
27
|
+
|
|
28
|
+
# Only check Allow statements
|
|
29
|
+
if statement.effect != "Allow":
|
|
30
|
+
return issues
|
|
31
|
+
|
|
32
|
+
actions = statement.get_actions()
|
|
33
|
+
allowed_services = self._get_allowed_service_wildcards(config)
|
|
34
|
+
|
|
35
|
+
for action in actions:
|
|
36
|
+
# Skip full wildcard (covered by wildcard_action check)
|
|
37
|
+
if action == "*":
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# Parse action and check if it's a service-level wildcard (e.g., "iam:*", "s3:*")
|
|
41
|
+
parsed = parse_action(action)
|
|
42
|
+
if parsed and parsed.action_name == "*":
|
|
43
|
+
service = parsed.service
|
|
44
|
+
|
|
45
|
+
# Check if this service is in the allowed list
|
|
46
|
+
if service not in allowed_services:
|
|
47
|
+
# Get message template and replace placeholders
|
|
48
|
+
message_template = config.config.get(
|
|
49
|
+
"message",
|
|
50
|
+
"Service-level wildcard `{action}` grants all permissions for `{service}` service",
|
|
51
|
+
)
|
|
52
|
+
suggestion_template = config.config.get(
|
|
53
|
+
"suggestion",
|
|
54
|
+
"Consider specifying explicit actions instead of `{action}`. If you need multiple actions, list them individually or use more specific wildcards like `{service}:Get*` or `{service}:List*`.",
|
|
55
|
+
)
|
|
56
|
+
example_template = config.config.get("example", "")
|
|
57
|
+
|
|
58
|
+
message = message_template.format(action=action, service=service)
|
|
59
|
+
suggestion = suggestion_template.format(action=action, service=service)
|
|
60
|
+
example = (
|
|
61
|
+
example_template.format(action=action, service=service)
|
|
62
|
+
if example_template
|
|
63
|
+
else ""
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
issues.append(
|
|
67
|
+
ValidationIssue(
|
|
68
|
+
severity=self.get_severity(config),
|
|
69
|
+
statement_sid=statement.sid,
|
|
70
|
+
statement_index=statement_idx,
|
|
71
|
+
issue_type="overly_permissive",
|
|
72
|
+
message=message,
|
|
73
|
+
action=action,
|
|
74
|
+
suggestion=suggestion,
|
|
75
|
+
example=example if example else None,
|
|
76
|
+
line_number=statement.line_number,
|
|
77
|
+
field_name="action",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return issues
|
|
82
|
+
|
|
83
|
+
def _get_allowed_service_wildcards(self, config: CheckConfig) -> set[str]:
|
|
84
|
+
"""
|
|
85
|
+
Get list of services that are allowed to use service-level wildcards.
|
|
86
|
+
|
|
87
|
+
This allows configuration like:
|
|
88
|
+
service_wildcard:
|
|
89
|
+
allowed_services:
|
|
90
|
+
- "logs" # Allow "logs:*"
|
|
91
|
+
- "cloudwatch" # Allow "cloudwatch:*"
|
|
92
|
+
|
|
93
|
+
Returns empty set if no exceptions are configured.
|
|
94
|
+
"""
|
|
95
|
+
allowed = config.config.get("allowed_services", [])
|
|
96
|
+
if allowed and isinstance(allowed, list):
|
|
97
|
+
return set(allowed)
|
|
98
|
+
return set()
|