iam-policy-validator 1.7.1__py3-none-any.whl → 1.8.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.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/METADATA +22 -7
- iam_policy_validator-1.8.0.dist-info/RECORD +87 -0
- iam_validator/__version__.py +4 -2
- iam_validator/checks/__init__.py +5 -3
- iam_validator/checks/action_condition_enforcement.py +81 -36
- iam_validator/checks/action_resource_matching.py +75 -37
- iam_validator/checks/action_validation.py +1 -1
- iam_validator/checks/condition_key_validation.py +7 -7
- iam_validator/checks/condition_type_mismatch.py +10 -8
- iam_validator/checks/full_wildcard.py +2 -8
- iam_validator/checks/mfa_condition_check.py +8 -8
- iam_validator/checks/policy_structure.py +577 -0
- iam_validator/checks/policy_type_validation.py +48 -32
- iam_validator/checks/principal_validation.py +86 -150
- iam_validator/checks/resource_validation.py +8 -8
- iam_validator/checks/sensitive_action.py +9 -11
- iam_validator/checks/service_wildcard.py +4 -10
- iam_validator/checks/set_operator_validation.py +11 -11
- iam_validator/checks/sid_uniqueness.py +8 -4
- iam_validator/checks/trust_policy_validation.py +512 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +26 -26
- iam_validator/checks/utils/wildcard_expansion.py +1 -1
- iam_validator/checks/wildcard_action.py +5 -9
- iam_validator/checks/wildcard_resource.py +5 -9
- iam_validator/commands/validate.py +8 -14
- iam_validator/core/__init__.py +1 -2
- iam_validator/core/access_analyzer.py +1 -1
- iam_validator/core/access_analyzer_report.py +2 -2
- iam_validator/core/aws_fetcher.py +159 -64
- iam_validator/core/check_registry.py +83 -79
- iam_validator/core/config/condition_requirements.py +69 -17
- iam_validator/core/config/config_loader.py +1 -2
- iam_validator/core/config/defaults.py +74 -59
- iam_validator/core/config/service_principals.py +40 -3
- iam_validator/core/constants.py +57 -0
- iam_validator/core/formatters/console.py +10 -1
- iam_validator/core/formatters/csv.py +2 -1
- iam_validator/core/formatters/enhanced.py +42 -8
- iam_validator/core/formatters/markdown.py +2 -1
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/models.py +35 -10
- iam_validator/core/policy_checks.py +34 -474
- iam_validator/core/policy_loader.py +98 -18
- iam_validator/core/report.py +65 -24
- iam_validator/integrations/github_integration.py +4 -5
- iam_validator/utils/__init__.py +4 -0
- iam_validator/utils/terminal.py +22 -0
- iam_policy_validator-1.7.1.dist-info/RECORD +0 -83
- {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/entry_points.txt +0 -0
- {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -62,19 +62,14 @@ class WildcardResourceCheck(PolicyCheck):
|
|
|
62
62
|
return issues
|
|
63
63
|
|
|
64
64
|
# Flag the issue if actions are not all allowed or no allowed_wildcards configured
|
|
65
|
-
message = config.config.get(
|
|
66
|
-
|
|
65
|
+
message = config.config.get(
|
|
66
|
+
"message", 'Statement applies to all resources `"*"` (wildcard resource).'
|
|
67
|
+
)
|
|
68
|
+
suggestion = config.config.get(
|
|
67
69
|
"suggestion", "Replace wildcard with specific resource ARNs"
|
|
68
70
|
)
|
|
69
71
|
example = config.config.get("example", "")
|
|
70
72
|
|
|
71
|
-
# Combine suggestion + example
|
|
72
|
-
suggestion = (
|
|
73
|
-
f"{suggestion_text}\nExample:\n```json\n{example}\n```"
|
|
74
|
-
if example
|
|
75
|
-
else suggestion_text
|
|
76
|
-
)
|
|
77
|
-
|
|
78
73
|
issues.append(
|
|
79
74
|
ValidationIssue(
|
|
80
75
|
severity=self.get_severity(config),
|
|
@@ -83,6 +78,7 @@ class WildcardResourceCheck(PolicyCheck):
|
|
|
83
78
|
issue_type="overly_permissive",
|
|
84
79
|
message=message,
|
|
85
80
|
suggestion=suggestion,
|
|
81
|
+
example=example if example else None,
|
|
86
82
|
line_number=statement.line_number,
|
|
87
83
|
)
|
|
88
84
|
)
|
|
@@ -114,13 +114,17 @@ Examples:
|
|
|
114
114
|
choices=[
|
|
115
115
|
"IDENTITY_POLICY",
|
|
116
116
|
"RESOURCE_POLICY",
|
|
117
|
+
"TRUST_POLICY",
|
|
117
118
|
"SERVICE_CONTROL_POLICY",
|
|
118
119
|
"RESOURCE_CONTROL_POLICY",
|
|
119
120
|
],
|
|
120
121
|
default="IDENTITY_POLICY",
|
|
121
122
|
help="Type of IAM policy being validated (default: IDENTITY_POLICY). "
|
|
122
|
-
"
|
|
123
|
-
"
|
|
123
|
+
"IDENTITY_POLICY: Attached to users/groups/roles | "
|
|
124
|
+
"RESOURCE_POLICY: S3/SNS/SQS policies | "
|
|
125
|
+
"TRUST_POLICY: Role assumption policies | "
|
|
126
|
+
"SERVICE_CONTROL_POLICY: AWS Orgs SCPs | "
|
|
127
|
+
"RESOURCE_CONTROL_POLICY: AWS Orgs RCPs",
|
|
124
128
|
)
|
|
125
129
|
|
|
126
130
|
parser.add_argument(
|
|
@@ -159,12 +163,6 @@ Examples:
|
|
|
159
163
|
help="Path to directory containing custom checks for auto-discovery",
|
|
160
164
|
)
|
|
161
165
|
|
|
162
|
-
parser.add_argument(
|
|
163
|
-
"--no-registry",
|
|
164
|
-
action="store_true",
|
|
165
|
-
help="Use legacy validation (disable check registry system)",
|
|
166
|
-
)
|
|
167
|
-
|
|
168
166
|
parser.add_argument(
|
|
169
167
|
"--stream",
|
|
170
168
|
action="store_true",
|
|
@@ -242,21 +240,19 @@ Examples:
|
|
|
242
240
|
logging.info(f"Loaded {len(policies)} policies from {len(args.paths)} path(s)")
|
|
243
241
|
|
|
244
242
|
# Validate policies
|
|
245
|
-
use_registry = not getattr(args, "no_registry", False)
|
|
246
243
|
config_path = getattr(args, "config", None)
|
|
247
244
|
custom_checks_dir = getattr(args, "custom_checks_dir", None)
|
|
248
245
|
policy_type = cast(PolicyType, getattr(args, "policy_type", "IDENTITY_POLICY"))
|
|
249
246
|
results = await validate_policies(
|
|
250
247
|
policies,
|
|
251
248
|
config_path=config_path,
|
|
252
|
-
use_registry=use_registry,
|
|
253
249
|
custom_checks_dir=custom_checks_dir,
|
|
254
250
|
policy_type=policy_type,
|
|
255
251
|
)
|
|
256
252
|
|
|
257
|
-
# Generate report
|
|
253
|
+
# Generate report (include parsing errors if any)
|
|
258
254
|
generator = ReportGenerator()
|
|
259
|
-
report = generator.generate_report(results)
|
|
255
|
+
report = generator.generate_report(results, parsing_errors=loader.parsing_errors)
|
|
260
256
|
|
|
261
257
|
# Output results
|
|
262
258
|
if args.format is None:
|
|
@@ -329,7 +325,6 @@ Examples:
|
|
|
329
325
|
"""
|
|
330
326
|
loader = PolicyLoader()
|
|
331
327
|
generator = ReportGenerator()
|
|
332
|
-
use_registry = not getattr(args, "no_registry", False)
|
|
333
328
|
config_path = getattr(args, "config", None)
|
|
334
329
|
custom_checks_dir = getattr(args, "custom_checks_dir", None)
|
|
335
330
|
policy_type = cast(PolicyType, getattr(args, "policy_type", "IDENTITY_POLICY"))
|
|
@@ -354,7 +349,6 @@ Examples:
|
|
|
354
349
|
results = await validate_policies(
|
|
355
350
|
[(file_path, policy)],
|
|
356
351
|
config_path=config_path,
|
|
357
|
-
use_registry=use_registry,
|
|
358
352
|
custom_checks_dir=custom_checks_dir,
|
|
359
353
|
policy_type=policy_type,
|
|
360
354
|
)
|
iam_validator/core/__init__.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
"""Core validation modules."""
|
|
2
2
|
|
|
3
3
|
from iam_validator.core.aws_fetcher import AWSServiceFetcher
|
|
4
|
-
from iam_validator.core.policy_checks import
|
|
4
|
+
from iam_validator.core.policy_checks import validate_policies
|
|
5
5
|
from iam_validator.core.policy_loader import PolicyLoader
|
|
6
6
|
from iam_validator.core.report import ReportGenerator
|
|
7
7
|
|
|
8
8
|
__all__ = [
|
|
9
9
|
"AWSServiceFetcher",
|
|
10
|
-
"PolicyValidator",
|
|
11
10
|
"validate_policies",
|
|
12
11
|
"PolicyLoader",
|
|
13
12
|
"ReportGenerator",
|
|
@@ -577,7 +577,7 @@ class AccessAnalyzerValidator:
|
|
|
577
577
|
)
|
|
578
578
|
results.append(result)
|
|
579
579
|
|
|
580
|
-
except Exception as e:
|
|
580
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
581
581
|
self.logger.error(f"Failed to validate {policy_file}: {e}")
|
|
582
582
|
result = AccessAnalyzerResult(
|
|
583
583
|
policy_file=policy_file,
|
|
@@ -259,7 +259,7 @@ class AccessAnalyzerReportFormatter:
|
|
|
259
259
|
file_path: Path to save JSON report
|
|
260
260
|
"""
|
|
261
261
|
json_content = self.generate_json_report(report)
|
|
262
|
-
with open(file_path, "w") as f:
|
|
262
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
263
263
|
f.write(json_content)
|
|
264
264
|
|
|
265
265
|
def generate_markdown_report(
|
|
@@ -636,5 +636,5 @@ class AccessAnalyzerReportFormatter:
|
|
|
636
636
|
file_path: Path to save Markdown report
|
|
637
637
|
"""
|
|
638
638
|
markdown_content = self.generate_markdown_report(report)
|
|
639
|
-
with open(file_path, "w") as f:
|
|
639
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
640
640
|
f.write(markdown_content)
|
|
@@ -27,11 +27,13 @@ import os
|
|
|
27
27
|
import re
|
|
28
28
|
import sys
|
|
29
29
|
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
30
31
|
from pathlib import Path
|
|
31
32
|
from typing import Any
|
|
32
33
|
|
|
33
34
|
import httpx
|
|
34
35
|
|
|
36
|
+
from iam_validator.core import constants
|
|
35
37
|
from iam_validator.core.config import AWS_SERVICE_REFERENCE_BASE_URL
|
|
36
38
|
from iam_validator.core.models import ServiceDetail, ServiceInfo
|
|
37
39
|
from iam_validator.utils.cache import LRUCache
|
|
@@ -39,6 +41,23 @@ from iam_validator.utils.cache import LRUCache
|
|
|
39
41
|
logger = logging.getLogger(__name__)
|
|
40
42
|
|
|
41
43
|
|
|
44
|
+
@dataclass
|
|
45
|
+
class ConditionKeyValidationResult:
|
|
46
|
+
"""Result of condition key validation.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
is_valid: True if the condition key is valid for the action
|
|
50
|
+
error_message: Short error message if invalid (shown prominently)
|
|
51
|
+
warning_message: Warning message if valid but not recommended
|
|
52
|
+
suggestion: Detailed suggestion with valid keys (shown in collapsible section)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
is_valid: bool
|
|
56
|
+
error_message: str | None = None
|
|
57
|
+
warning_message: str | None = None
|
|
58
|
+
suggestion: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
42
61
|
class CompiledPatterns:
|
|
43
62
|
"""Pre-compiled regex patterns for validation.
|
|
44
63
|
|
|
@@ -141,7 +160,7 @@ class AWSServiceFetcher:
|
|
|
141
160
|
|
|
142
161
|
Public API - Parsing:
|
|
143
162
|
- parse_action: Split action into service and name
|
|
144
|
-
-
|
|
163
|
+
- match_wildcard_action: Match wildcard patterns
|
|
145
164
|
|
|
146
165
|
Utilities:
|
|
147
166
|
- get_stats: Get cache statistics
|
|
@@ -199,10 +218,10 @@ class AWSServiceFetcher:
|
|
|
199
218
|
|
|
200
219
|
def __init__(
|
|
201
220
|
self,
|
|
202
|
-
timeout: float =
|
|
221
|
+
timeout: float = constants.DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
203
222
|
retries: int = 3,
|
|
204
223
|
enable_cache: bool = True,
|
|
205
|
-
cache_ttl: int =
|
|
224
|
+
cache_ttl: int = constants.DEFAULT_CACHE_TTL_SECONDS,
|
|
206
225
|
memory_cache_size: int = 256,
|
|
207
226
|
connection_pool_size: int = 50,
|
|
208
227
|
keepalive_connections: int = 20,
|
|
@@ -303,7 +322,7 @@ class AWSServiceFetcher:
|
|
|
303
322
|
limits=httpx.Limits(
|
|
304
323
|
max_keepalive_connections=self.keepalive_connections,
|
|
305
324
|
max_connections=self.connection_pool_size,
|
|
306
|
-
keepalive_expiry=
|
|
325
|
+
keepalive_expiry=constants.DEFAULT_HTTP_TIMEOUT_SECONDS, # Keep connections alive
|
|
307
326
|
),
|
|
308
327
|
http2=True, # Enable HTTP/2 for multiplexing
|
|
309
328
|
)
|
|
@@ -333,7 +352,7 @@ class AWSServiceFetcher:
|
|
|
333
352
|
try:
|
|
334
353
|
await self.fetch_service_by_name(name)
|
|
335
354
|
self._prefetched_services.add(name)
|
|
336
|
-
except Exception as e:
|
|
355
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
337
356
|
logger.warning(f"Failed to prefetch service {name}: {e}")
|
|
338
357
|
|
|
339
358
|
# Fetch in batches to avoid overwhelming the API
|
|
@@ -381,7 +400,7 @@ class AWSServiceFetcher:
|
|
|
381
400
|
logger.debug(f"Disk cache hit for {url}")
|
|
382
401
|
return data
|
|
383
402
|
|
|
384
|
-
except Exception as e:
|
|
403
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
385
404
|
logger.warning(f"Failed to read cache for {url}: {e}")
|
|
386
405
|
return None
|
|
387
406
|
|
|
@@ -396,7 +415,7 @@ class AWSServiceFetcher:
|
|
|
396
415
|
with open(cache_path, "w", encoding="utf-8") as f:
|
|
397
416
|
json.dump(data, f, indent=2)
|
|
398
417
|
logger.debug(f"Written to disk cache: {url}")
|
|
399
|
-
except Exception as e:
|
|
418
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
400
419
|
logger.warning(f"Failed to write cache for {url}: {e}")
|
|
401
420
|
|
|
402
421
|
async def _make_request_with_batching(self, url: str) -> Any:
|
|
@@ -440,7 +459,7 @@ class AWSServiceFetcher:
|
|
|
440
459
|
if not future.done():
|
|
441
460
|
future.set_result(result)
|
|
442
461
|
return result
|
|
443
|
-
except Exception as e:
|
|
462
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
444
463
|
if not future.done():
|
|
445
464
|
future.set_exception(e)
|
|
446
465
|
raise
|
|
@@ -485,21 +504,23 @@ class AWSServiceFetcher:
|
|
|
485
504
|
|
|
486
505
|
return data
|
|
487
506
|
|
|
488
|
-
except Exception as json_error:
|
|
507
|
+
except Exception as json_error: # pylint: disable=broad-exception-caught
|
|
489
508
|
logger.error(f"Failed to parse response as JSON: {json_error}")
|
|
490
|
-
raise ValueError(
|
|
509
|
+
raise ValueError(
|
|
510
|
+
f"Invalid JSON response from {url}: {json_error}"
|
|
511
|
+
) from json_error
|
|
491
512
|
|
|
492
513
|
except httpx.HTTPStatusError as e:
|
|
493
514
|
logger.error(f"HTTP error {e.response.status_code} for {url}")
|
|
494
515
|
if e.response.status_code == 404:
|
|
495
|
-
raise ValueError(f"Service not found: {url}")
|
|
516
|
+
raise ValueError(f"Service not found: {url}") from e
|
|
496
517
|
last_exception = e
|
|
497
518
|
|
|
498
519
|
except httpx.RequestError as e:
|
|
499
520
|
logger.error(f"Request error for {url}: {e}")
|
|
500
521
|
last_exception = e
|
|
501
522
|
|
|
502
|
-
except Exception as e:
|
|
523
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
503
524
|
logger.error(f"Unexpected error for {url}: {e}")
|
|
504
525
|
last_exception = e
|
|
505
526
|
|
|
@@ -528,7 +549,7 @@ class AWSServiceFetcher:
|
|
|
528
549
|
raise FileNotFoundError(f"_services.json not found in {self.aws_services_dir}")
|
|
529
550
|
|
|
530
551
|
try:
|
|
531
|
-
with open(services_file) as f:
|
|
552
|
+
with open(services_file, encoding="utf-8") as f:
|
|
532
553
|
data = json.load(f)
|
|
533
554
|
|
|
534
555
|
if not isinstance(data, list):
|
|
@@ -546,7 +567,7 @@ class AWSServiceFetcher:
|
|
|
546
567
|
return services
|
|
547
568
|
|
|
548
569
|
except json.JSONDecodeError as e:
|
|
549
|
-
raise ValueError(f"Invalid JSON in services.json: {e}")
|
|
570
|
+
raise ValueError(f"Invalid JSON in services.json: {e}") from e
|
|
550
571
|
|
|
551
572
|
def _load_service_from_file(self, service_name: str) -> ServiceDetail:
|
|
552
573
|
"""Load service detail from local JSON file.
|
|
@@ -572,7 +593,7 @@ class AWSServiceFetcher:
|
|
|
572
593
|
raise FileNotFoundError(f"Service file not found: {service_file}")
|
|
573
594
|
|
|
574
595
|
try:
|
|
575
|
-
with open(service_file) as f:
|
|
596
|
+
with open(service_file, encoding="utf-8") as f:
|
|
576
597
|
data = json.load(f)
|
|
577
598
|
|
|
578
599
|
service_detail = ServiceDetail.model_validate(data)
|
|
@@ -580,7 +601,7 @@ class AWSServiceFetcher:
|
|
|
580
601
|
return service_detail
|
|
581
602
|
|
|
582
603
|
except json.JSONDecodeError as e:
|
|
583
|
-
raise ValueError(f"Invalid JSON in {service_file}: {e}")
|
|
604
|
+
raise ValueError(f"Invalid JSON in {service_file}: {e}") from e
|
|
584
605
|
|
|
585
606
|
async def fetch_services(self) -> list[ServiceInfo]:
|
|
586
607
|
"""Fetch list of AWS services with caching.
|
|
@@ -658,7 +679,9 @@ class AWSServiceFetcher:
|
|
|
658
679
|
return service_detail
|
|
659
680
|
except FileNotFoundError:
|
|
660
681
|
pass
|
|
661
|
-
raise ValueError(
|
|
682
|
+
raise ValueError(
|
|
683
|
+
f"Service `{service_name}` not found in {self.aws_services_dir}"
|
|
684
|
+
) from FileNotFoundError
|
|
662
685
|
|
|
663
686
|
# Fetch service list and find URL from API
|
|
664
687
|
services = await self.fetch_services()
|
|
@@ -676,7 +699,7 @@ class AWSServiceFetcher:
|
|
|
676
699
|
|
|
677
700
|
return service_detail
|
|
678
701
|
|
|
679
|
-
raise ValueError(f"Service
|
|
702
|
+
raise ValueError(f"Service `{service_name}` not found")
|
|
680
703
|
|
|
681
704
|
async def fetch_multiple_services(self, service_names: list[str]) -> dict[str, ServiceDetail]:
|
|
682
705
|
"""Fetch multiple services concurrently with optimized batching."""
|
|
@@ -685,7 +708,7 @@ class AWSServiceFetcher:
|
|
|
685
708
|
try:
|
|
686
709
|
detail = await self.fetch_service_by_name(name)
|
|
687
710
|
return name, detail
|
|
688
|
-
except Exception as e:
|
|
711
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
689
712
|
logger.error(f"Failed to fetch service {name}: {e}")
|
|
690
713
|
raise
|
|
691
714
|
|
|
@@ -698,7 +721,7 @@ class AWSServiceFetcher:
|
|
|
698
721
|
if isinstance(result, Exception):
|
|
699
722
|
logger.error(f"Failed to fetch service {service_names[i]}: {result}")
|
|
700
723
|
raise result
|
|
701
|
-
|
|
724
|
+
if isinstance(result, tuple):
|
|
702
725
|
name, detail = result
|
|
703
726
|
services[name] = detail
|
|
704
727
|
|
|
@@ -712,7 +735,7 @@ class AWSServiceFetcher:
|
|
|
712
735
|
|
|
713
736
|
return match.group("service").lower(), match.group("action")
|
|
714
737
|
|
|
715
|
-
def
|
|
738
|
+
def match_wildcard_action(self, pattern: str, actions: list[str]) -> tuple[bool, list[str]]:
|
|
716
739
|
"""Match wildcard pattern against list of actions.
|
|
717
740
|
|
|
718
741
|
Args:
|
|
@@ -755,8 +778,7 @@ class AWSServiceFetcher:
|
|
|
755
778
|
# Just verify service exists
|
|
756
779
|
await self.fetch_service_by_name(service_prefix)
|
|
757
780
|
return True, None, True
|
|
758
|
-
|
|
759
|
-
return False, "Wildcard actions are not allowed", True
|
|
781
|
+
return False, "Wildcard actions are not allowed", True
|
|
760
782
|
|
|
761
783
|
# Fetch service details (will use cache)
|
|
762
784
|
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
@@ -767,7 +789,7 @@ class AWSServiceFetcher:
|
|
|
767
789
|
if not allow_wildcards:
|
|
768
790
|
return False, "Wildcard actions are not allowed", True
|
|
769
791
|
|
|
770
|
-
has_matches, matched_actions = self.
|
|
792
|
+
has_matches, matched_actions = self.match_wildcard_action(
|
|
771
793
|
action_name, available_actions
|
|
772
794
|
)
|
|
773
795
|
|
|
@@ -780,33 +802,32 @@ class AWSServiceFetcher:
|
|
|
780
802
|
examples += f", ... ({match_count - 5} more)"
|
|
781
803
|
|
|
782
804
|
return True, None, True
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
)
|
|
805
|
+
# Wildcard doesn't match any actions
|
|
806
|
+
return (
|
|
807
|
+
False,
|
|
808
|
+
f"Action pattern `{action_name}` does not match any actions in service `{service_prefix}`",
|
|
809
|
+
True,
|
|
810
|
+
)
|
|
790
811
|
|
|
791
812
|
# Check if exact action exists (case-insensitive)
|
|
792
813
|
action_exists = any(a.lower() == action_name.lower() for a in available_actions)
|
|
793
814
|
|
|
794
815
|
if action_exists:
|
|
795
816
|
return True, None, False
|
|
796
|
-
else:
|
|
797
|
-
# Suggest similar actions
|
|
798
|
-
similar = [a for a in available_actions if action_name.lower() in a.lower()][:3]
|
|
799
817
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
818
|
+
# Suggest similar actions
|
|
819
|
+
similar = [f"`{a}`" for a in available_actions if action_name.lower() in a.lower()][:3]
|
|
820
|
+
|
|
821
|
+
suggestion = f" Did you mean: {', '.join(similar)}?" if similar else ""
|
|
822
|
+
return (
|
|
823
|
+
False,
|
|
824
|
+
f"Action `{action_name}` not found in service `{service_prefix}`.{suggestion}",
|
|
825
|
+
False,
|
|
826
|
+
)
|
|
806
827
|
|
|
807
828
|
except ValueError as e:
|
|
808
829
|
return False, str(e), False
|
|
809
|
-
except Exception as e:
|
|
830
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
810
831
|
logger.error(f"Error validating action {action}: {e}")
|
|
811
832
|
return False, f"Failed to validate action: {str(e)}", False
|
|
812
833
|
|
|
@@ -823,7 +844,7 @@ class AWSServiceFetcher:
|
|
|
823
844
|
|
|
824
845
|
async def validate_condition_key(
|
|
825
846
|
self, action: str, condition_key: str, resources: list[str] | None = None
|
|
826
|
-
) ->
|
|
847
|
+
) -> ConditionKeyValidationResult:
|
|
827
848
|
"""
|
|
828
849
|
Validate condition key against action and optionally resource types.
|
|
829
850
|
|
|
@@ -833,13 +854,14 @@ class AWSServiceFetcher:
|
|
|
833
854
|
resources: Optional list of resource ARNs to validate against
|
|
834
855
|
|
|
835
856
|
Returns:
|
|
836
|
-
|
|
857
|
+
ConditionKeyValidationResult with:
|
|
837
858
|
- is_valid: True if key is valid (even with warning)
|
|
838
|
-
- error_message:
|
|
859
|
+
- error_message: Short error message if invalid (shown prominently)
|
|
839
860
|
- warning_message: Warning message if valid but not recommended
|
|
861
|
+
- suggestion: Detailed suggestion with valid keys (shown in collapsible section)
|
|
840
862
|
"""
|
|
841
863
|
try:
|
|
842
|
-
from iam_validator.core.config.aws_global_conditions import (
|
|
864
|
+
from iam_validator.core.config.aws_global_conditions import ( # pylint: disable=import-outside-toplevel
|
|
843
865
|
get_global_conditions,
|
|
844
866
|
)
|
|
845
867
|
|
|
@@ -852,10 +874,9 @@ class AWSServiceFetcher:
|
|
|
852
874
|
if global_conditions.is_valid_global_key(condition_key):
|
|
853
875
|
is_global_key = True
|
|
854
876
|
else:
|
|
855
|
-
return (
|
|
856
|
-
False,
|
|
857
|
-
f"Invalid AWS global condition key:
|
|
858
|
-
None,
|
|
877
|
+
return ConditionKeyValidationResult(
|
|
878
|
+
is_valid=False,
|
|
879
|
+
error_message=f"Invalid AWS global condition key: `{condition_key}`.",
|
|
859
880
|
)
|
|
860
881
|
|
|
861
882
|
# Fetch service detail (cached)
|
|
@@ -863,7 +884,7 @@ class AWSServiceFetcher:
|
|
|
863
884
|
|
|
864
885
|
# Check service-specific condition keys
|
|
865
886
|
if condition_key in service_detail.condition_keys:
|
|
866
|
-
return True
|
|
887
|
+
return ConditionKeyValidationResult(is_valid=True)
|
|
867
888
|
|
|
868
889
|
# Check action-specific condition keys
|
|
869
890
|
if action_name in service_detail.actions:
|
|
@@ -872,7 +893,7 @@ class AWSServiceFetcher:
|
|
|
872
893
|
action_detail.action_condition_keys
|
|
873
894
|
and condition_key in action_detail.action_condition_keys
|
|
874
895
|
):
|
|
875
|
-
return True
|
|
896
|
+
return ConditionKeyValidationResult(is_valid=True)
|
|
876
897
|
|
|
877
898
|
# Check resource-specific condition keys
|
|
878
899
|
# Get resource types required by this action
|
|
@@ -886,33 +907,107 @@ class AWSServiceFetcher:
|
|
|
886
907
|
resource_type = service_detail.resources.get(resource_name)
|
|
887
908
|
if resource_type and resource_type.condition_keys:
|
|
888
909
|
if condition_key in resource_type.condition_keys:
|
|
889
|
-
return True
|
|
910
|
+
return ConditionKeyValidationResult(is_valid=True)
|
|
890
911
|
|
|
891
912
|
# If it's a global key but the action has specific condition keys defined,
|
|
892
913
|
# AWS allows it but the key may not be available in every request context
|
|
893
914
|
if is_global_key and action_detail.action_condition_keys is not None:
|
|
894
915
|
warning_msg = (
|
|
895
|
-
f"Global condition key
|
|
916
|
+
f"Global condition key `{condition_key}` is used with action `{action}`. "
|
|
896
917
|
f"While global condition keys can be used across all AWS services, "
|
|
897
918
|
f"the key may not be available in every request context. "
|
|
898
|
-
f"Verify that
|
|
899
|
-
f"Consider using
|
|
919
|
+
f"Verify that `{condition_key}` is available for this specific action's request context. "
|
|
920
|
+
f"Consider using `*IfExists` operators (e.g., `StringEqualsIfExists`) if the key might be missing."
|
|
900
921
|
)
|
|
901
|
-
return True,
|
|
922
|
+
return ConditionKeyValidationResult(is_valid=True, warning_message=warning_msg)
|
|
902
923
|
|
|
903
924
|
# If it's a global key and action doesn't define specific keys, allow it
|
|
904
925
|
if is_global_key:
|
|
905
|
-
return True
|
|
926
|
+
return ConditionKeyValidationResult(is_valid=True)
|
|
906
927
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
928
|
+
# Short error message
|
|
929
|
+
error_msg = f"Condition key `{condition_key}` is not valid for action `{action}`"
|
|
930
|
+
|
|
931
|
+
# Collect valid condition keys for this action
|
|
932
|
+
valid_keys = set()
|
|
933
|
+
|
|
934
|
+
# Add service-level condition keys
|
|
935
|
+
if service_detail.condition_keys:
|
|
936
|
+
if isinstance(service_detail.condition_keys, dict):
|
|
937
|
+
valid_keys.update(service_detail.condition_keys.keys())
|
|
938
|
+
elif isinstance(service_detail.condition_keys, list):
|
|
939
|
+
valid_keys.update(service_detail.condition_keys)
|
|
940
|
+
|
|
941
|
+
# Add action-specific condition keys
|
|
942
|
+
if action_name in service_detail.actions:
|
|
943
|
+
action_detail = service_detail.actions[action_name]
|
|
944
|
+
if action_detail.action_condition_keys:
|
|
945
|
+
if isinstance(action_detail.action_condition_keys, dict):
|
|
946
|
+
valid_keys.update(action_detail.action_condition_keys.keys())
|
|
947
|
+
elif isinstance(action_detail.action_condition_keys, list):
|
|
948
|
+
valid_keys.update(action_detail.action_condition_keys)
|
|
949
|
+
|
|
950
|
+
# Add resource-specific condition keys
|
|
951
|
+
if action_detail.resources:
|
|
952
|
+
for res_req in action_detail.resources:
|
|
953
|
+
resource_name = res_req.get("Name", "")
|
|
954
|
+
if resource_name:
|
|
955
|
+
resource_type = service_detail.resources.get(resource_name)
|
|
956
|
+
if resource_type and resource_type.condition_keys:
|
|
957
|
+
if isinstance(resource_type.condition_keys, dict):
|
|
958
|
+
valid_keys.update(resource_type.condition_keys.keys())
|
|
959
|
+
elif isinstance(resource_type.condition_keys, list):
|
|
960
|
+
valid_keys.update(resource_type.condition_keys)
|
|
961
|
+
|
|
962
|
+
# Build detailed suggestion with valid keys (goes in collapsible section)
|
|
963
|
+
suggestion_parts = []
|
|
964
|
+
|
|
965
|
+
if valid_keys:
|
|
966
|
+
# Sort and limit to first 10 keys for readability
|
|
967
|
+
sorted_keys = sorted(valid_keys)
|
|
968
|
+
suggestion_parts.append("**Valid condition keys for this action:**")
|
|
969
|
+
if len(sorted_keys) <= 10:
|
|
970
|
+
for key in sorted_keys:
|
|
971
|
+
suggestion_parts.append(f"- `{key}`")
|
|
972
|
+
else:
|
|
973
|
+
for key in sorted_keys[:10]:
|
|
974
|
+
suggestion_parts.append(f"- `{key}`")
|
|
975
|
+
suggestion_parts.append(f"- ... and {len(sorted_keys) - 10} more")
|
|
976
|
+
|
|
977
|
+
suggestion_parts.append("")
|
|
978
|
+
suggestion_parts.append(
|
|
979
|
+
"**Global condition keys** (e.g., `aws:ResourceOrgID`, `aws:RequestedRegion`, `aws:SourceIp`, `aws:SourceVpce`) "
|
|
980
|
+
"can also be used with any AWS action"
|
|
981
|
+
)
|
|
982
|
+
else:
|
|
983
|
+
# No action-specific keys - mention global keys
|
|
984
|
+
suggestion_parts.append(
|
|
985
|
+
"This action does not have specific condition keys defined.\n\n"
|
|
986
|
+
"However, you can use **global condition keys** such as:\n"
|
|
987
|
+
"- `aws:RequestedRegion`\n"
|
|
988
|
+
"- `aws:SourceIp`\n"
|
|
989
|
+
"- `aws:SourceVpce`\n"
|
|
990
|
+
"- `aws:UserAgent`\n"
|
|
991
|
+
"- `aws:CurrentTime`\n"
|
|
992
|
+
"- `aws:SecureTransport`\n"
|
|
993
|
+
"- `aws:PrincipalArn`\n"
|
|
994
|
+
"- And many others"
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
suggestion = "\n".join(suggestion_parts)
|
|
998
|
+
|
|
999
|
+
return ConditionKeyValidationResult(
|
|
1000
|
+
is_valid=False,
|
|
1001
|
+
error_message=error_msg,
|
|
1002
|
+
suggestion=suggestion,
|
|
911
1003
|
)
|
|
912
1004
|
|
|
913
|
-
except Exception as e:
|
|
1005
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
914
1006
|
logger.error(f"Error validating condition key {condition_key} for {action}: {e}")
|
|
915
|
-
return
|
|
1007
|
+
return ConditionKeyValidationResult(
|
|
1008
|
+
is_valid=False,
|
|
1009
|
+
error_message=f"Failed to validate condition key: {str(e)}",
|
|
1010
|
+
)
|
|
916
1011
|
|
|
917
1012
|
async def clear_caches(self) -> None:
|
|
918
1013
|
"""Clear all caches (memory and disk)."""
|
|
@@ -924,7 +1019,7 @@ class AWSServiceFetcher:
|
|
|
924
1019
|
for cache_file in self._cache_dir.glob("*.json"):
|
|
925
1020
|
try:
|
|
926
1021
|
cache_file.unlink()
|
|
927
|
-
except Exception as e:
|
|
1022
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
928
1023
|
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
|
|
929
1024
|
|
|
930
1025
|
logger.info("Cleared all caches")
|