iam-policy-validator 1.4.0__py3-none-any.whl → 1.6.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.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/METADATA +106 -78
- iam_policy_validator-1.6.0.dist-info/RECORD +82 -0
- iam_validator/__version__.py +1 -1
- iam_validator/checks/__init__.py +20 -4
- iam_validator/checks/action_condition_enforcement.py +165 -8
- iam_validator/checks/action_resource_matching.py +424 -0
- iam_validator/checks/condition_key_validation.py +24 -2
- iam_validator/checks/condition_type_mismatch.py +259 -0
- iam_validator/checks/full_wildcard.py +67 -0
- iam_validator/checks/mfa_condition_check.py +112 -0
- iam_validator/checks/principal_validation.py +497 -3
- iam_validator/checks/sensitive_action.py +250 -0
- iam_validator/checks/service_wildcard.py +105 -0
- iam_validator/checks/set_operator_validation.py +157 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +74 -32
- iam_validator/checks/wildcard_action.py +62 -0
- iam_validator/checks/wildcard_resource.py +131 -0
- iam_validator/commands/cache.py +1 -1
- iam_validator/commands/download_services.py +3 -8
- iam_validator/commands/validate.py +72 -13
- iam_validator/core/aws_fetcher.py +114 -64
- iam_validator/core/check_registry.py +167 -29
- 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 +104 -0
- iam_validator/core/config/condition_requirements.py +155 -0
- iam_validator/core/{config_loader.py → config/config_loader.py} +32 -9
- iam_validator/core/config/defaults.py +523 -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 +95 -0
- iam_validator/core/config/wildcards.py +124 -0
- iam_validator/core/formatters/enhanced.py +11 -5
- iam_validator/core/formatters/sarif.py +78 -14
- iam_validator/core/models.py +14 -1
- iam_validator/core/policy_checks.py +4 -4
- iam_validator/core/pr_commenter.py +1 -1
- iam_validator/sdk/__init__.py +187 -0
- iam_validator/sdk/arn_matching.py +274 -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 +425 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +31 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +206 -0
- iam_policy_validator-1.4.0.dist-info/RECORD +0 -56
- iam_validator/checks/action_resource_constraint.py +0 -151
- iam_validator/checks/security_best_practices.py +0 -536
- iam_validator/core/aws_global_conditions.py +0 -137
- iam_validator/core/defaults.py +0 -393
- {iam_policy_validator-1.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/entry_points.txt +0 -0
- {iam_policy_validator-1.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Wildcard action check - detects Action: '*' in IAM policies."""
|
|
2
|
+
|
|
3
|
+
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
4
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
5
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WildcardActionCheck(PolicyCheck):
|
|
9
|
+
"""Checks for wildcard actions (Action: '*') which grant all permissions."""
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def check_id(self) -> str:
|
|
13
|
+
return "wildcard_action"
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def description(self) -> str:
|
|
17
|
+
return "Checks for wildcard actions (*)"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def default_severity(self) -> str:
|
|
21
|
+
return "medium"
|
|
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 wildcard action check on a statement."""
|
|
31
|
+
issues = []
|
|
32
|
+
|
|
33
|
+
# Only check Allow statements
|
|
34
|
+
if statement.effect != "Allow":
|
|
35
|
+
return issues
|
|
36
|
+
|
|
37
|
+
actions = statement.get_actions()
|
|
38
|
+
|
|
39
|
+
# Check for wildcard action (Action: "*")
|
|
40
|
+
if "*" in actions:
|
|
41
|
+
message = config.config.get("message", "Statement allows all actions (*)")
|
|
42
|
+
suggestion_text = config.config.get(
|
|
43
|
+
"suggestion", "Replace wildcard with specific actions needed for your use case"
|
|
44
|
+
)
|
|
45
|
+
example = config.config.get("example", "")
|
|
46
|
+
|
|
47
|
+
# Combine suggestion + example
|
|
48
|
+
suggestion = f"{suggestion_text}\nExample:\n{example}" if example else suggestion_text
|
|
49
|
+
|
|
50
|
+
issues.append(
|
|
51
|
+
ValidationIssue(
|
|
52
|
+
severity=self.get_severity(config),
|
|
53
|
+
statement_sid=statement.sid,
|
|
54
|
+
statement_index=statement_idx,
|
|
55
|
+
issue_type="overly_permissive",
|
|
56
|
+
message=message,
|
|
57
|
+
suggestion=suggestion,
|
|
58
|
+
line_number=statement.line_number,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return issues
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Wildcard resource check - detects Resource: '*' in IAM policies."""
|
|
2
|
+
|
|
3
|
+
from iam_validator.checks.utils.wildcard_expansion import expand_wildcard_actions
|
|
4
|
+
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
5
|
+
from iam_validator.core.check_registry import CheckConfig, PolicyCheck
|
|
6
|
+
from iam_validator.core.models import Statement, ValidationIssue
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WildcardResourceCheck(PolicyCheck):
|
|
10
|
+
"""Checks for wildcard resources (Resource: '*') which grant access to all resources."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def check_id(self) -> str:
|
|
14
|
+
return "wildcard_resource"
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def description(self) -> str:
|
|
18
|
+
return "Checks for wildcard resources (*)"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def default_severity(self) -> str:
|
|
22
|
+
return "medium"
|
|
23
|
+
|
|
24
|
+
async def execute(
|
|
25
|
+
self,
|
|
26
|
+
statement: Statement,
|
|
27
|
+
statement_idx: int,
|
|
28
|
+
fetcher: AWSServiceFetcher,
|
|
29
|
+
config: CheckConfig,
|
|
30
|
+
) -> list[ValidationIssue]:
|
|
31
|
+
"""Execute wildcard resource check on a statement."""
|
|
32
|
+
issues = []
|
|
33
|
+
|
|
34
|
+
# Only check Allow statements
|
|
35
|
+
if statement.effect != "Allow":
|
|
36
|
+
return issues
|
|
37
|
+
|
|
38
|
+
actions = statement.get_actions()
|
|
39
|
+
resources = statement.get_resources()
|
|
40
|
+
|
|
41
|
+
# Check for wildcard resource (Resource: "*")
|
|
42
|
+
if "*" in resources:
|
|
43
|
+
# Check if all actions are in the allowed_wildcards list
|
|
44
|
+
# allowed_wildcards works by expanding wildcard patterns (like "ec2:Describe*")
|
|
45
|
+
# to all matching AWS actions using the AWS API, then checking if the policy's
|
|
46
|
+
# actions are in that expanded list. This ensures only validated AWS actions
|
|
47
|
+
# are allowed with Resource: "*".
|
|
48
|
+
allowed_wildcards_expanded = await self._get_expanded_allowed_wildcards(config, fetcher)
|
|
49
|
+
|
|
50
|
+
# Check if ALL actions (excluding full wildcard "*") are in the expanded list
|
|
51
|
+
non_wildcard_actions = [a for a in actions if a != "*"]
|
|
52
|
+
|
|
53
|
+
if allowed_wildcards_expanded and non_wildcard_actions:
|
|
54
|
+
# Check if all actions are in the expanded allowed list (exact match)
|
|
55
|
+
all_actions_allowed = all(
|
|
56
|
+
action in allowed_wildcards_expanded for action in non_wildcard_actions
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# If all actions are in the expanded list, skip the wildcard resource warning
|
|
60
|
+
if all_actions_allowed:
|
|
61
|
+
# All actions are safe, Resource: "*" is acceptable
|
|
62
|
+
return issues
|
|
63
|
+
|
|
64
|
+
# Flag the issue if actions are not all allowed or no allowed_wildcards configured
|
|
65
|
+
message = config.config.get("message", "Statement applies to all resources (*)")
|
|
66
|
+
suggestion_text = config.config.get(
|
|
67
|
+
"suggestion", "Replace wildcard with specific resource ARNs"
|
|
68
|
+
)
|
|
69
|
+
example = config.config.get("example", "")
|
|
70
|
+
|
|
71
|
+
# Combine suggestion + example
|
|
72
|
+
suggestion = f"{suggestion_text}\nExample:\n{example}" if example else suggestion_text
|
|
73
|
+
|
|
74
|
+
issues.append(
|
|
75
|
+
ValidationIssue(
|
|
76
|
+
severity=self.get_severity(config),
|
|
77
|
+
statement_sid=statement.sid,
|
|
78
|
+
statement_index=statement_idx,
|
|
79
|
+
issue_type="overly_permissive",
|
|
80
|
+
message=message,
|
|
81
|
+
suggestion=suggestion,
|
|
82
|
+
line_number=statement.line_number,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return issues
|
|
87
|
+
|
|
88
|
+
async def _get_expanded_allowed_wildcards(
|
|
89
|
+
self, config: CheckConfig, fetcher: AWSServiceFetcher
|
|
90
|
+
) -> frozenset[str]:
|
|
91
|
+
"""Get and expand allowed_wildcards configuration.
|
|
92
|
+
|
|
93
|
+
This method retrieves wildcard patterns from the allowed_wildcards config
|
|
94
|
+
and expands them using the AWS API to get all matching actual AWS actions.
|
|
95
|
+
|
|
96
|
+
How it works:
|
|
97
|
+
1. Retrieves patterns from config (e.g., ["ec2:Describe*", "s3:List*"])
|
|
98
|
+
2. Expands each pattern using AWS API:
|
|
99
|
+
- "ec2:Describe*" → ["ec2:DescribeInstances", "ec2:DescribeImages", ...]
|
|
100
|
+
- "s3:List*" → ["s3:ListBucket", "s3:ListObjects", ...]
|
|
101
|
+
3. Returns a set of all expanded actions
|
|
102
|
+
|
|
103
|
+
This allows you to:
|
|
104
|
+
- Specify patterns like "ec2:Describe*" in config
|
|
105
|
+
- Have the validator allow specific actions like "ec2:DescribeInstances" with Resource: "*"
|
|
106
|
+
- Ensure only real AWS actions (validated via API) are allowed
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
Config: allowed_wildcards: ["ec2:Describe*"]
|
|
110
|
+
Expands to: ["ec2:DescribeInstances", "ec2:DescribeImages", ...]
|
|
111
|
+
Policy: "Action": ["ec2:DescribeInstances"], "Resource": "*"
|
|
112
|
+
Result: ✅ Allowed (ec2:DescribeInstances is in expanded list)
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
config: The check configuration
|
|
116
|
+
fetcher: AWS service fetcher for expanding wildcards via AWS API
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A frozenset of all expanded action names from the configured patterns
|
|
120
|
+
"""
|
|
121
|
+
patterns_to_expand = config.config.get("allowed_wildcards", [])
|
|
122
|
+
|
|
123
|
+
# If no patterns configured, return empty set
|
|
124
|
+
if not patterns_to_expand or not isinstance(patterns_to_expand, list):
|
|
125
|
+
return frozenset()
|
|
126
|
+
|
|
127
|
+
# Expand the wildcard patterns using the AWS API
|
|
128
|
+
# This converts patterns like "ec2:Describe*" to actual AWS actions
|
|
129
|
+
expanded_actions = await expand_wildcard_actions(patterns_to_expand, fetcher)
|
|
130
|
+
|
|
131
|
+
return frozenset(expanded_actions)
|
iam_validator/commands/cache.py
CHANGED
|
@@ -9,7 +9,7 @@ from rich.table import Table
|
|
|
9
9
|
|
|
10
10
|
from iam_validator.commands.base import Command
|
|
11
11
|
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
12
|
-
from iam_validator.core.config_loader import ConfigLoader
|
|
12
|
+
from iam_validator.core.config.config_loader import ConfigLoader
|
|
13
13
|
|
|
14
14
|
logger = logging.getLogger(__name__)
|
|
15
15
|
console = Console()
|
|
@@ -9,20 +9,15 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
11
11
|
from rich.console import Console
|
|
12
|
-
from rich.progress import
|
|
13
|
-
BarColumn,
|
|
14
|
-
Progress,
|
|
15
|
-
TaskID,
|
|
16
|
-
TextColumn,
|
|
17
|
-
TimeRemainingColumn,
|
|
18
|
-
)
|
|
12
|
+
from rich.progress import BarColumn, Progress, TaskID, TextColumn, TimeRemainingColumn
|
|
19
13
|
|
|
20
14
|
from iam_validator.commands.base import Command
|
|
15
|
+
from iam_validator.core.config import AWS_SERVICE_REFERENCE_BASE_URL
|
|
21
16
|
|
|
22
17
|
logger = logging.getLogger(__name__)
|
|
23
18
|
console = Console()
|
|
24
19
|
|
|
25
|
-
BASE_URL =
|
|
20
|
+
BASE_URL = AWS_SERVICE_REFERENCE_BASE_URL
|
|
26
21
|
DEFAULT_OUTPUT_DIR = Path("aws_services")
|
|
27
22
|
|
|
28
23
|
|
|
@@ -37,6 +37,10 @@ Examples:
|
|
|
37
37
|
# Validate multiple paths (files and directories)
|
|
38
38
|
iam-validator validate --path policy1.json --path ./policies/ --path ./more-policies/
|
|
39
39
|
|
|
40
|
+
# Read policy from stdin
|
|
41
|
+
cat policy.json | iam-validator validate --stdin
|
|
42
|
+
echo '{"Version":"2012-10-17","Statement":[...]}' | iam-validator validate --stdin
|
|
43
|
+
|
|
40
44
|
# Use custom checks from a directory
|
|
41
45
|
iam-validator validate --path ./policies/ --custom-checks-dir ./my-checks
|
|
42
46
|
|
|
@@ -61,15 +65,23 @@ Examples:
|
|
|
61
65
|
|
|
62
66
|
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
63
67
|
"""Add validate command arguments."""
|
|
64
|
-
|
|
68
|
+
# Create mutually exclusive group for input sources
|
|
69
|
+
input_group = parser.add_mutually_exclusive_group(required=True)
|
|
70
|
+
|
|
71
|
+
input_group.add_argument(
|
|
65
72
|
"--path",
|
|
66
73
|
"-p",
|
|
67
|
-
required=True,
|
|
68
74
|
action="append",
|
|
69
75
|
dest="paths",
|
|
70
76
|
help="Path to IAM policy file or directory (can be specified multiple times)",
|
|
71
77
|
)
|
|
72
78
|
|
|
79
|
+
input_group.add_argument(
|
|
80
|
+
"--stdin",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Read policy from stdin (JSON format)",
|
|
83
|
+
)
|
|
84
|
+
|
|
73
85
|
parser.add_argument(
|
|
74
86
|
"--format",
|
|
75
87
|
"-f",
|
|
@@ -166,6 +178,18 @@ Examples:
|
|
|
166
178
|
help="Number of policies to process per batch (default: 10, only with --stream)",
|
|
167
179
|
)
|
|
168
180
|
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--no-summary",
|
|
183
|
+
action="store_true",
|
|
184
|
+
help="Hide Executive Summary section in enhanced format output",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--no-severity-breakdown",
|
|
189
|
+
action="store_true",
|
|
190
|
+
help="Hide Issue Severity Breakdown section in enhanced format output",
|
|
191
|
+
)
|
|
192
|
+
|
|
169
193
|
async def execute(self, args: argparse.Namespace) -> int:
|
|
170
194
|
"""Execute the validate command."""
|
|
171
195
|
# Check if streaming mode is enabled
|
|
@@ -186,15 +210,36 @@ Examples:
|
|
|
186
210
|
|
|
187
211
|
async def _execute_batch(self, args: argparse.Namespace) -> int:
|
|
188
212
|
"""Execute validation by loading all policies at once (original behavior)."""
|
|
189
|
-
# Load policies from all specified paths
|
|
213
|
+
# Load policies from all specified paths or stdin
|
|
190
214
|
loader = PolicyLoader()
|
|
191
|
-
policies = loader.load_from_paths(args.paths, recursive=not args.no_recursive)
|
|
192
215
|
|
|
193
|
-
if
|
|
194
|
-
|
|
195
|
-
|
|
216
|
+
if args.stdin:
|
|
217
|
+
# Read from stdin
|
|
218
|
+
import json
|
|
219
|
+
import sys
|
|
220
|
+
|
|
221
|
+
stdin_content = sys.stdin.read()
|
|
222
|
+
if not stdin_content.strip():
|
|
223
|
+
logging.error("No policy data provided on stdin")
|
|
224
|
+
return 1
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
policy_data = json.loads(stdin_content)
|
|
228
|
+
# Create a synthetic policy entry
|
|
229
|
+
policies = [("stdin", policy_data)]
|
|
230
|
+
logging.info("Loaded policy from stdin")
|
|
231
|
+
except json.JSONDecodeError as e:
|
|
232
|
+
logging.error(f"Invalid JSON from stdin: {e}")
|
|
233
|
+
return 1
|
|
234
|
+
else:
|
|
235
|
+
# Load from paths
|
|
236
|
+
policies = loader.load_from_paths(args.paths, recursive=not args.no_recursive)
|
|
237
|
+
|
|
238
|
+
if not policies:
|
|
239
|
+
logging.error(f"No valid IAM policies found in: {', '.join(args.paths)}")
|
|
240
|
+
return 1
|
|
196
241
|
|
|
197
|
-
|
|
242
|
+
logging.info(f"Loaded {len(policies)} policies from {len(args.paths)} path(s)")
|
|
198
243
|
|
|
199
244
|
# Validate policies
|
|
200
245
|
use_registry = not getattr(args, "no_registry", False)
|
|
@@ -229,7 +274,14 @@ Examples:
|
|
|
229
274
|
print(generator.generate_github_comment(report))
|
|
230
275
|
else:
|
|
231
276
|
# Use formatter registry for other formats (enhanced, html, csv, sarif)
|
|
232
|
-
|
|
277
|
+
# Pass options for enhanced format
|
|
278
|
+
format_options = {}
|
|
279
|
+
if args.format == "enhanced":
|
|
280
|
+
format_options["show_summary"] = not getattr(args, "no_summary", False)
|
|
281
|
+
format_options["show_severity_breakdown"] = not getattr(
|
|
282
|
+
args, "no_severity_breakdown", False
|
|
283
|
+
)
|
|
284
|
+
output_content = generator.format_report(report, args.format, **format_options)
|
|
233
285
|
if args.output:
|
|
234
286
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
235
287
|
f.write(output_content)
|
|
@@ -239,7 +291,7 @@ Examples:
|
|
|
239
291
|
|
|
240
292
|
# Post to GitHub if configured
|
|
241
293
|
if args.github_comment or getattr(args, "github_review", False):
|
|
242
|
-
from iam_validator.core.config_loader import ConfigLoader
|
|
294
|
+
from iam_validator.core.config.config_loader import ConfigLoader
|
|
243
295
|
from iam_validator.core.pr_commenter import PRCommenter
|
|
244
296
|
|
|
245
297
|
# Load config to get fail_on_severity setting
|
|
@@ -348,7 +400,14 @@ Examples:
|
|
|
348
400
|
print(generator.generate_github_comment(report))
|
|
349
401
|
else:
|
|
350
402
|
# Use formatter registry for other formats (enhanced, html, csv, sarif)
|
|
351
|
-
|
|
403
|
+
# Pass options for enhanced format
|
|
404
|
+
format_options = {}
|
|
405
|
+
if args.format == "enhanced":
|
|
406
|
+
format_options["show_summary"] = not getattr(args, "no_summary", False)
|
|
407
|
+
format_options["show_severity_breakdown"] = not getattr(
|
|
408
|
+
args, "no_severity_breakdown", False
|
|
409
|
+
)
|
|
410
|
+
output_content = generator.format_report(report, args.format, **format_options)
|
|
352
411
|
if args.output:
|
|
353
412
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
354
413
|
f.write(output_content)
|
|
@@ -358,7 +417,7 @@ Examples:
|
|
|
358
417
|
|
|
359
418
|
# Post summary comment to GitHub (if requested and not already posted per-file reviews)
|
|
360
419
|
if args.github_comment:
|
|
361
|
-
from iam_validator.core.config_loader import ConfigLoader
|
|
420
|
+
from iam_validator.core.config.config_loader import ConfigLoader
|
|
362
421
|
from iam_validator.core.pr_commenter import PRCommenter
|
|
363
422
|
|
|
364
423
|
# Load config to get fail_on_severity setting
|
|
@@ -410,7 +469,7 @@ Examples:
|
|
|
410
469
|
This provides progressive feedback in PRs as files are processed.
|
|
411
470
|
"""
|
|
412
471
|
try:
|
|
413
|
-
from iam_validator.core.config_loader import ConfigLoader
|
|
472
|
+
from iam_validator.core.config.config_loader import ConfigLoader
|
|
414
473
|
from iam_validator.core.pr_commenter import PRCommenter
|
|
415
474
|
|
|
416
475
|
async with GitHubIntegration() as github:
|
|
@@ -27,64 +27,18 @@ import os
|
|
|
27
27
|
import re
|
|
28
28
|
import sys
|
|
29
29
|
import time
|
|
30
|
-
from collections import OrderedDict
|
|
31
30
|
from pathlib import Path
|
|
32
31
|
from typing import Any
|
|
33
32
|
|
|
34
33
|
import httpx
|
|
35
34
|
|
|
35
|
+
from iam_validator.core.config import AWS_SERVICE_REFERENCE_BASE_URL
|
|
36
36
|
from iam_validator.core.models import ServiceDetail, ServiceInfo
|
|
37
|
+
from iam_validator.utils.cache import LRUCache
|
|
37
38
|
|
|
38
39
|
logger = logging.getLogger(__name__)
|
|
39
40
|
|
|
40
41
|
|
|
41
|
-
class LRUCache:
|
|
42
|
-
"""Thread-safe LRU cache implementation with TTL support."""
|
|
43
|
-
|
|
44
|
-
def __init__(self, maxsize: int = 128, ttl: int = 3600):
|
|
45
|
-
"""Initialize LRU cache.
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
maxsize: Maximum number of items in cache
|
|
49
|
-
ttl: Time to live in seconds (default: 1 hour)
|
|
50
|
-
"""
|
|
51
|
-
self.cache: OrderedDict[str, tuple[Any, float]] = OrderedDict()
|
|
52
|
-
self.maxsize = maxsize
|
|
53
|
-
self.ttl = ttl
|
|
54
|
-
self._lock = asyncio.Lock()
|
|
55
|
-
|
|
56
|
-
async def get(self, key: str) -> Any | None:
|
|
57
|
-
"""Get item from cache if not expired."""
|
|
58
|
-
async with self._lock:
|
|
59
|
-
if key in self.cache:
|
|
60
|
-
value, timestamp = self.cache[key]
|
|
61
|
-
if time.time() - timestamp < self.ttl:
|
|
62
|
-
# Move to end (most recently used)
|
|
63
|
-
self.cache.move_to_end(key)
|
|
64
|
-
return value
|
|
65
|
-
else:
|
|
66
|
-
# Expired, remove it
|
|
67
|
-
del self.cache[key]
|
|
68
|
-
return None
|
|
69
|
-
|
|
70
|
-
async def set(self, key: str, value: Any) -> None:
|
|
71
|
-
"""Set item in cache with current timestamp."""
|
|
72
|
-
async with self._lock:
|
|
73
|
-
if key in self.cache:
|
|
74
|
-
# Move to end if exists
|
|
75
|
-
self.cache.move_to_end(key)
|
|
76
|
-
elif len(self.cache) >= self.maxsize:
|
|
77
|
-
# Remove least recently used
|
|
78
|
-
self.cache.popitem(last=False)
|
|
79
|
-
|
|
80
|
-
self.cache[key] = (value, time.time())
|
|
81
|
-
|
|
82
|
-
async def clear(self) -> None:
|
|
83
|
-
"""Clear the cache."""
|
|
84
|
-
async with self._lock:
|
|
85
|
-
self.cache.clear()
|
|
86
|
-
|
|
87
|
-
|
|
88
42
|
class CompiledPatterns:
|
|
89
43
|
"""Pre-compiled regex patterns for validation."""
|
|
90
44
|
|
|
@@ -119,9 +73,71 @@ class CompiledPatterns:
|
|
|
119
73
|
|
|
120
74
|
|
|
121
75
|
class AWSServiceFetcher:
|
|
122
|
-
"""Fetches AWS service information from the AWS service reference API with enhanced performance features.
|
|
123
|
-
|
|
124
|
-
|
|
76
|
+
"""Fetches AWS service information from the AWS service reference API with enhanced performance features.
|
|
77
|
+
|
|
78
|
+
This class provides a comprehensive interface for retrieving AWS service metadata,
|
|
79
|
+
including actions, resources, and condition keys. It includes multiple layers of
|
|
80
|
+
caching and optimization for high-performance policy validation.
|
|
81
|
+
|
|
82
|
+
Features:
|
|
83
|
+
- Multi-layer caching (memory LRU + disk with TTL)
|
|
84
|
+
- Service pre-fetching for common AWS services
|
|
85
|
+
- Request batching and coalescing
|
|
86
|
+
- Offline mode support with local AWS service files
|
|
87
|
+
- HTTP/2 connection pooling
|
|
88
|
+
- Automatic retry with exponential backoff
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
92
|
+
... # Fetch service list
|
|
93
|
+
... services = await fetcher.fetch_services()
|
|
94
|
+
...
|
|
95
|
+
... # Fetch specific service details
|
|
96
|
+
... s3_service = await fetcher.fetch_service_by_name("s3")
|
|
97
|
+
...
|
|
98
|
+
... # Validate actions
|
|
99
|
+
... is_valid = await fetcher.validate_action("s3:GetObject", s3_service)
|
|
100
|
+
|
|
101
|
+
Method Organization:
|
|
102
|
+
Lifecycle Management:
|
|
103
|
+
- __init__: Initialize fetcher with configuration
|
|
104
|
+
- __aenter__, __aexit__: Context manager support
|
|
105
|
+
|
|
106
|
+
Caching (Private):
|
|
107
|
+
- _get_cache_directory: Determine cache location
|
|
108
|
+
- _get_cache_path: Generate cache file path
|
|
109
|
+
- _read_from_cache: Read from disk cache
|
|
110
|
+
- _write_to_cache: Write to disk cache
|
|
111
|
+
- clear_caches: Clear all caches
|
|
112
|
+
|
|
113
|
+
HTTP Operations (Private):
|
|
114
|
+
- _make_request: Core HTTP request handler
|
|
115
|
+
- _make_request_with_batching: Request coalescing
|
|
116
|
+
- _prefetch_common_services: Pre-load common services
|
|
117
|
+
|
|
118
|
+
File I/O (Private):
|
|
119
|
+
- _load_services_from_file: Load service list from local file
|
|
120
|
+
- _load_service_from_file: Load service details from local file
|
|
121
|
+
|
|
122
|
+
Public API - Fetching:
|
|
123
|
+
- fetch_services: Get list of all AWS services
|
|
124
|
+
- fetch_service_by_name: Get details for one service
|
|
125
|
+
- fetch_multiple_services: Batch fetch multiple services
|
|
126
|
+
|
|
127
|
+
Public API - Validation:
|
|
128
|
+
- validate_action: Check if action exists in service
|
|
129
|
+
- validate_arn: Validate ARN format
|
|
130
|
+
- validate_condition_key: Check condition key validity
|
|
131
|
+
|
|
132
|
+
Public API - Parsing:
|
|
133
|
+
- parse_action: Split action into service and name
|
|
134
|
+
- _match_wildcard_action: Match wildcard patterns
|
|
135
|
+
|
|
136
|
+
Utilities:
|
|
137
|
+
- get_stats: Get cache statistics
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
BASE_URL = AWS_SERVICE_REFERENCE_BASE_URL
|
|
125
141
|
|
|
126
142
|
# Common AWS services to pre-fetch
|
|
127
143
|
# All other services will be fetched on-demand (lazy loading if found in policies)
|
|
@@ -796,11 +812,26 @@ class AWSServiceFetcher:
|
|
|
796
812
|
return True, None
|
|
797
813
|
|
|
798
814
|
async def validate_condition_key(
|
|
799
|
-
self, action: str, condition_key: str
|
|
800
|
-
) -> tuple[bool, str | None]:
|
|
801
|
-
"""
|
|
815
|
+
self, action: str, condition_key: str, resources: list[str] | None = None
|
|
816
|
+
) -> tuple[bool, str | None, str | None]:
|
|
817
|
+
"""
|
|
818
|
+
Validate condition key against action and optionally resource types.
|
|
819
|
+
|
|
820
|
+
Args:
|
|
821
|
+
action: IAM action (e.g., "s3:GetObject")
|
|
822
|
+
condition_key: Condition key to validate (e.g., "s3:prefix")
|
|
823
|
+
resources: Optional list of resource ARNs to validate against
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
Tuple of (is_valid, error_message, warning_message)
|
|
827
|
+
- is_valid: True if key is valid (even with warning)
|
|
828
|
+
- error_message: Error message if invalid (is_valid=False)
|
|
829
|
+
- warning_message: Warning message if valid but not recommended
|
|
830
|
+
"""
|
|
802
831
|
try:
|
|
803
|
-
from iam_validator.core.aws_global_conditions import
|
|
832
|
+
from iam_validator.core.config.aws_global_conditions import (
|
|
833
|
+
get_global_conditions,
|
|
834
|
+
)
|
|
804
835
|
|
|
805
836
|
service_prefix, action_name = self.parse_action(action)
|
|
806
837
|
|
|
@@ -814,6 +845,7 @@ class AWSServiceFetcher:
|
|
|
814
845
|
return (
|
|
815
846
|
False,
|
|
816
847
|
f"Invalid AWS global condition key: '{condition_key}'.",
|
|
848
|
+
None,
|
|
817
849
|
)
|
|
818
850
|
|
|
819
851
|
# Fetch service detail (cached)
|
|
@@ -821,7 +853,7 @@ class AWSServiceFetcher:
|
|
|
821
853
|
|
|
822
854
|
# Check service-specific condition keys
|
|
823
855
|
if condition_key in service_detail.condition_keys:
|
|
824
|
-
return True, None
|
|
856
|
+
return True, None, None
|
|
825
857
|
|
|
826
858
|
# Check action-specific condition keys
|
|
827
859
|
if action_name in service_detail.actions:
|
|
@@ -830,29 +862,47 @@ class AWSServiceFetcher:
|
|
|
830
862
|
action_detail.action_condition_keys
|
|
831
863
|
and condition_key in action_detail.action_condition_keys
|
|
832
864
|
):
|
|
833
|
-
return True, None
|
|
865
|
+
return True, None, None
|
|
866
|
+
|
|
867
|
+
# Check resource-specific condition keys
|
|
868
|
+
# Get resource types required by this action
|
|
869
|
+
if resources and action_detail.resources:
|
|
870
|
+
for res_req in action_detail.resources:
|
|
871
|
+
resource_name = res_req.get("Name", "")
|
|
872
|
+
if not resource_name:
|
|
873
|
+
continue
|
|
874
|
+
|
|
875
|
+
# Look up resource type definition
|
|
876
|
+
resource_type = service_detail.resources.get(resource_name)
|
|
877
|
+
if resource_type and resource_type.condition_keys:
|
|
878
|
+
if condition_key in resource_type.condition_keys:
|
|
879
|
+
return True, None, None
|
|
834
880
|
|
|
835
881
|
# If it's a global key but the action has specific condition keys defined,
|
|
836
|
-
#
|
|
882
|
+
# AWS allows it but the key may not be available in every request context
|
|
837
883
|
if is_global_key and action_detail.action_condition_keys is not None:
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
f"
|
|
841
|
-
f"
|
|
884
|
+
warning_msg = (
|
|
885
|
+
f"Global condition key '{condition_key}' is used with action '{action}'. "
|
|
886
|
+
f"While global condition keys can be used across all AWS services, "
|
|
887
|
+
f"the key may not be available in every request context. "
|
|
888
|
+
f"Verify that '{condition_key}' is available for this specific action's request context. "
|
|
889
|
+
f"Consider using '*IfExists' operators (e.g., StringEqualsIfExists) if the key might be missing."
|
|
842
890
|
)
|
|
891
|
+
return True, None, warning_msg
|
|
843
892
|
|
|
844
893
|
# If it's a global key and action doesn't define specific keys, allow it
|
|
845
894
|
if is_global_key:
|
|
846
|
-
return True, None
|
|
895
|
+
return True, None, None
|
|
847
896
|
|
|
848
897
|
return (
|
|
849
898
|
False,
|
|
850
899
|
f"Condition key '{condition_key}' is not valid for action '{action}'",
|
|
900
|
+
None,
|
|
851
901
|
)
|
|
852
902
|
|
|
853
903
|
except Exception as e:
|
|
854
904
|
logger.error(f"Error validating condition key {condition_key} for {action}: {e}")
|
|
855
|
-
return False, f"Failed to validate condition key: {str(e)}"
|
|
905
|
+
return False, f"Failed to validate condition key: {str(e)}", None
|
|
856
906
|
|
|
857
907
|
async def clear_caches(self) -> None:
|
|
858
908
|
"""Clear all caches (memory and disk)."""
|