iam-policy-validator 1.14.6__py3-none-any.whl → 1.15.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.6.dist-info → iam_policy_validator-1.15.0.dist-info}/METADATA +34 -23
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/RECORD +42 -29
- iam_policy_validator-1.15.0.dist-info/entry_points.txt +4 -0
- iam_validator/__version__.py +1 -1
- iam_validator/checks/__init__.py +2 -0
- iam_validator/checks/action_validation.py +91 -27
- iam_validator/checks/not_action_not_resource.py +163 -0
- iam_validator/checks/resource_validation.py +132 -81
- iam_validator/checks/wildcard_resource.py +136 -6
- iam_validator/commands/__init__.py +3 -0
- iam_validator/commands/cache.py +66 -24
- iam_validator/commands/completion.py +94 -15
- iam_validator/commands/mcp.py +210 -0
- iam_validator/commands/query.py +489 -65
- iam_validator/core/aws_service/__init__.py +5 -1
- iam_validator/core/aws_service/cache.py +20 -0
- iam_validator/core/aws_service/fetcher.py +180 -11
- iam_validator/core/aws_service/storage.py +14 -6
- iam_validator/core/aws_service/validators.py +32 -41
- iam_validator/core/check_registry.py +100 -35
- iam_validator/core/config/aws_global_conditions.py +13 -0
- iam_validator/core/config/check_documentation.py +104 -51
- iam_validator/core/config/config_loader.py +39 -3
- iam_validator/core/config/defaults.py +6 -0
- iam_validator/core/constants.py +11 -4
- iam_validator/core/models.py +39 -14
- iam_validator/mcp/__init__.py +162 -0
- iam_validator/mcp/models.py +118 -0
- iam_validator/mcp/server.py +2928 -0
- iam_validator/mcp/session_config.py +319 -0
- iam_validator/mcp/templates/__init__.py +79 -0
- iam_validator/mcp/templates/builtin.py +856 -0
- iam_validator/mcp/tools/__init__.py +72 -0
- iam_validator/mcp/tools/generation.py +888 -0
- iam_validator/mcp/tools/org_config_tools.py +263 -0
- iam_validator/mcp/tools/query.py +395 -0
- iam_validator/mcp/tools/validation.py +376 -0
- iam_validator/sdk/__init__.py +64 -63
- iam_validator/sdk/context.py +3 -2
- iam_validator/sdk/policy_utils.py +31 -5
- iam_policy_validator-1.14.6.dist-info/entry_points.txt +0 -2
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -12,10 +12,14 @@ Example usage:
|
|
|
12
12
|
# Re-export main classes for public API
|
|
13
13
|
from iam_validator.core.aws_service.fetcher import AWSServiceFetcher
|
|
14
14
|
from iam_validator.core.aws_service.patterns import CompiledPatterns
|
|
15
|
-
from iam_validator.core.aws_service.validators import
|
|
15
|
+
from iam_validator.core.aws_service.validators import (
|
|
16
|
+
ConditionKeyValidationResult,
|
|
17
|
+
condition_key_in_list,
|
|
18
|
+
)
|
|
16
19
|
|
|
17
20
|
__all__ = [
|
|
18
21
|
"AWSServiceFetcher",
|
|
19
22
|
"ConditionKeyValidationResult",
|
|
20
23
|
"CompiledPatterns",
|
|
24
|
+
"condition_key_in_list",
|
|
21
25
|
]
|
|
@@ -70,6 +70,26 @@ class ServiceCacheManager:
|
|
|
70
70
|
|
|
71
71
|
return None
|
|
72
72
|
|
|
73
|
+
async def get_stale(self, url: str | None = None, base_url: str = "") -> Any | None:
|
|
74
|
+
"""Get from disk cache even if expired (stale data fallback).
|
|
75
|
+
|
|
76
|
+
Use this method to retrieve stale cache data when a fresh fetch fails.
|
|
77
|
+
The stale data can serve as a fallback to avoid complete failure.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
url: URL for disk cache lookup
|
|
81
|
+
base_url: Base URL for service reference API (used for disk cache path)
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Cached data if found (regardless of TTL), None otherwise
|
|
85
|
+
"""
|
|
86
|
+
if url and self._storage:
|
|
87
|
+
cached = self._storage.read_from_cache(url, base_url, allow_stale=True)
|
|
88
|
+
if cached is not None:
|
|
89
|
+
logger.info(f"Using stale cache fallback for URL: {url}")
|
|
90
|
+
return cached
|
|
91
|
+
return None
|
|
92
|
+
|
|
73
93
|
async def set(
|
|
74
94
|
self, cache_key: str, value: Any, url: str | None = None, base_url: str = ""
|
|
75
95
|
) -> None:
|
|
@@ -245,11 +245,21 @@ class AWSServiceFetcher:
|
|
|
245
245
|
f"raw:{self.BASE_URL}", url=self.BASE_URL, base_url=self.BASE_URL
|
|
246
246
|
)
|
|
247
247
|
if data is None:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
248
|
+
try:
|
|
249
|
+
data = await self._client.fetch(self.BASE_URL)
|
|
250
|
+
# Cache the raw data (this refreshes the disk cache file)
|
|
251
|
+
await self._cache.set(
|
|
252
|
+
f"raw:{self.BASE_URL}", data, url=self.BASE_URL, base_url=self.BASE_URL
|
|
253
|
+
)
|
|
254
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
255
|
+
# API fetch failed - try stale cache as fallback
|
|
256
|
+
logger.warning(f"API fetch failed for services list: {e}")
|
|
257
|
+
stale_data = await self._cache.get_stale(url=self.BASE_URL, base_url=self.BASE_URL)
|
|
258
|
+
if stale_data is not None:
|
|
259
|
+
logger.info("Using stale cache data for services list due to API failure")
|
|
260
|
+
data = stale_data
|
|
261
|
+
else:
|
|
262
|
+
raise
|
|
253
263
|
|
|
254
264
|
if not isinstance(data, list):
|
|
255
265
|
raise ValueError("Expected list of services from root endpoint")
|
|
@@ -332,12 +342,26 @@ class AWSServiceFetcher:
|
|
|
332
342
|
f"raw:{service.url}", url=service.url, base_url=self.BASE_URL
|
|
333
343
|
)
|
|
334
344
|
if data is None:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
345
|
+
try:
|
|
346
|
+
# Fetch service detail from API
|
|
347
|
+
data = await self._client.fetch(service.url)
|
|
348
|
+
# Cache the raw data (this refreshes the disk cache file)
|
|
349
|
+
await self._cache.set(
|
|
350
|
+
f"raw:{service.url}", data, url=service.url, base_url=self.BASE_URL
|
|
351
|
+
)
|
|
352
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
353
|
+
# API fetch failed - try stale cache as fallback
|
|
354
|
+
logger.warning(f"API fetch failed for {service_name}: {e}")
|
|
355
|
+
stale_data = await self._cache.get_stale(
|
|
356
|
+
url=service.url, base_url=self.BASE_URL
|
|
357
|
+
)
|
|
358
|
+
if stale_data is not None:
|
|
359
|
+
logger.info(
|
|
360
|
+
f"Using stale cache data for {service_name} due to API failure"
|
|
361
|
+
)
|
|
362
|
+
data = stale_data
|
|
363
|
+
else:
|
|
364
|
+
raise
|
|
341
365
|
|
|
342
366
|
# Validate and parse
|
|
343
367
|
service_detail = ServiceDetail.model_validate(data)
|
|
@@ -421,6 +445,73 @@ class AWSServiceFetcher:
|
|
|
421
445
|
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
422
446
|
return await self._validator.validate_action(action, service_detail, allow_wildcards)
|
|
423
447
|
|
|
448
|
+
async def validate_actions_batch(
|
|
449
|
+
self,
|
|
450
|
+
actions: list[str],
|
|
451
|
+
allow_wildcards: bool = True,
|
|
452
|
+
) -> dict[str, tuple[bool, str | None, bool]]:
|
|
453
|
+
"""Validate multiple IAM actions efficiently in batch.
|
|
454
|
+
|
|
455
|
+
Groups actions by service prefix and fetches each service definition once,
|
|
456
|
+
reducing network overhead when validating multiple actions.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
actions: List of full action strings (e.g., ["s3:GetObject", "iam:CreateUser"])
|
|
460
|
+
allow_wildcards: Whether to allow wildcard actions
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Dictionary mapping action -> (is_valid, error_message, is_wildcard)
|
|
464
|
+
|
|
465
|
+
Example:
|
|
466
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
467
|
+
... results = await fetcher.validate_actions_batch([
|
|
468
|
+
... "s3:GetObject",
|
|
469
|
+
... "s3:PutObject",
|
|
470
|
+
... "iam:CreateUser"
|
|
471
|
+
... ])
|
|
472
|
+
... for action, (is_valid, error, is_wildcard) in results.items():
|
|
473
|
+
... if not is_valid:
|
|
474
|
+
... print(f"Invalid: {action} - {error}")
|
|
475
|
+
"""
|
|
476
|
+
if not actions:
|
|
477
|
+
return {}
|
|
478
|
+
|
|
479
|
+
# Group actions by service prefix
|
|
480
|
+
service_actions: dict[str, list[str]] = {}
|
|
481
|
+
for action in actions:
|
|
482
|
+
service_prefix, _ = self._parser.parse_action(action)
|
|
483
|
+
if service_prefix not in service_actions:
|
|
484
|
+
service_actions[service_prefix] = []
|
|
485
|
+
service_actions[service_prefix].append(action)
|
|
486
|
+
|
|
487
|
+
# Fetch all service definitions in parallel
|
|
488
|
+
service_details: dict[str, ServiceDetail] = {}
|
|
489
|
+
fetch_tasks = [self.fetch_service_by_name(service) for service in service_actions.keys()]
|
|
490
|
+
fetched = await asyncio.gather(*fetch_tasks, return_exceptions=True)
|
|
491
|
+
|
|
492
|
+
for service, result in zip(service_actions.keys(), fetched):
|
|
493
|
+
if isinstance(result, BaseException):
|
|
494
|
+
# Store None to indicate fetch failure
|
|
495
|
+
service_details[service] = None # type: ignore
|
|
496
|
+
else:
|
|
497
|
+
service_details[service] = result
|
|
498
|
+
|
|
499
|
+
# Validate all actions using cached service details
|
|
500
|
+
results: dict[str, tuple[bool, str | None, bool]] = {}
|
|
501
|
+
for action in actions:
|
|
502
|
+
service_prefix, _ = self._parser.parse_action(action)
|
|
503
|
+
service_detail = service_details.get(service_prefix)
|
|
504
|
+
|
|
505
|
+
if service_detail is None:
|
|
506
|
+
# Service fetch failed
|
|
507
|
+
results[action] = (False, f"Failed to fetch service '{service_prefix}'", False)
|
|
508
|
+
else:
|
|
509
|
+
results[action] = await self._validator.validate_action(
|
|
510
|
+
action, service_detail, allow_wildcards
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
return results
|
|
514
|
+
|
|
424
515
|
def validate_arn(self, arn: str) -> tuple[bool, str | None]:
|
|
425
516
|
"""Validate ARN format.
|
|
426
517
|
|
|
@@ -466,6 +557,84 @@ class AWSServiceFetcher:
|
|
|
466
557
|
action, condition_key, service_detail, resources
|
|
467
558
|
)
|
|
468
559
|
|
|
560
|
+
async def is_condition_key_supported(
|
|
561
|
+
self,
|
|
562
|
+
action: str,
|
|
563
|
+
condition_key: str,
|
|
564
|
+
) -> bool:
|
|
565
|
+
"""Check if a condition key is supported for a specific action.
|
|
566
|
+
|
|
567
|
+
This checks two locations for the condition key:
|
|
568
|
+
1. Action-level condition keys (ActionConditionKeys)
|
|
569
|
+
2. Resource-level condition keys (for each resource the action operates on)
|
|
570
|
+
|
|
571
|
+
Returns True if the condition key is found in either location.
|
|
572
|
+
|
|
573
|
+
This is useful for determining if a condition provides meaningful
|
|
574
|
+
restrictions for an action, particularly for resource-scoping conditions
|
|
575
|
+
like aws:ResourceTag/*.
|
|
576
|
+
|
|
577
|
+
Args:
|
|
578
|
+
action: IAM action (e.g., "s3:GetObject", "ssm:StartSession")
|
|
579
|
+
condition_key: Condition key to check (e.g., "aws:ResourceTag/Env")
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
True if the condition key is supported for this action
|
|
583
|
+
|
|
584
|
+
Example:
|
|
585
|
+
>>> async with AWSServiceFetcher() as fetcher:
|
|
586
|
+
... # SSM StartSession has aws:ResourceTag in ActionConditionKeys
|
|
587
|
+
... supported = await fetcher.is_condition_key_supported(
|
|
588
|
+
... "ssm:StartSession", "aws:ResourceTag/Component"
|
|
589
|
+
... )
|
|
590
|
+
... print(f"Tag support: {supported}") # True
|
|
591
|
+
"""
|
|
592
|
+
from iam_validator.core.aws_service.validators import ( # pylint: disable=import-outside-toplevel
|
|
593
|
+
condition_key_in_list,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
service_prefix, action_name = self._parser.parse_action(action)
|
|
598
|
+
except ValueError:
|
|
599
|
+
return False # Invalid action format
|
|
600
|
+
|
|
601
|
+
# Can't verify wildcard actions
|
|
602
|
+
if "*" in action_name or "?" in action_name:
|
|
603
|
+
return False
|
|
604
|
+
|
|
605
|
+
service_detail = await self.fetch_service_by_name(service_prefix)
|
|
606
|
+
if not service_detail:
|
|
607
|
+
return False
|
|
608
|
+
|
|
609
|
+
# Case-insensitive action lookup
|
|
610
|
+
action_detail = None
|
|
611
|
+
action_name_lower = action_name.lower()
|
|
612
|
+
for name, detail in service_detail.actions.items():
|
|
613
|
+
if name.lower() == action_name_lower:
|
|
614
|
+
action_detail = detail
|
|
615
|
+
break
|
|
616
|
+
|
|
617
|
+
if not action_detail:
|
|
618
|
+
return False
|
|
619
|
+
|
|
620
|
+
# Check 1: Action-level condition keys
|
|
621
|
+
if action_detail.action_condition_keys:
|
|
622
|
+
if condition_key_in_list(condition_key, action_detail.action_condition_keys):
|
|
623
|
+
return True
|
|
624
|
+
|
|
625
|
+
# Check 2: Resource-level condition keys
|
|
626
|
+
if action_detail.resources:
|
|
627
|
+
for res_ref in action_detail.resources:
|
|
628
|
+
resource_name = res_ref.get("Name")
|
|
629
|
+
if not resource_name:
|
|
630
|
+
continue
|
|
631
|
+
resource_type = service_detail.resources.get(resource_name)
|
|
632
|
+
if resource_type and resource_type.condition_keys:
|
|
633
|
+
if condition_key_in_list(condition_key, resource_type.condition_keys):
|
|
634
|
+
return True
|
|
635
|
+
|
|
636
|
+
return False
|
|
637
|
+
|
|
469
638
|
# --- Parsing Methods (delegate to parser) ---
|
|
470
639
|
|
|
471
640
|
def parse_action(self, action: str) -> tuple[str, str]:
|
|
@@ -150,15 +150,16 @@ class ServiceFileStorage:
|
|
|
150
150
|
|
|
151
151
|
return self._cache_dir / filename
|
|
152
152
|
|
|
153
|
-
def read_from_cache(self, url: str, base_url: str) -> Any | None:
|
|
153
|
+
def read_from_cache(self, url: str, base_url: str, allow_stale: bool = False) -> Any | None:
|
|
154
154
|
"""Read from disk cache with TTL checking.
|
|
155
155
|
|
|
156
156
|
Args:
|
|
157
157
|
url: URL to read from cache
|
|
158
158
|
base_url: Base URL for service reference API
|
|
159
|
+
allow_stale: If True, return stale cache data even if expired
|
|
159
160
|
|
|
160
161
|
Returns:
|
|
161
|
-
Cached data if valid, None otherwise
|
|
162
|
+
Cached data if valid (or if allow_stale and data exists), None otherwise
|
|
162
163
|
"""
|
|
163
164
|
if not self.enable_cache:
|
|
164
165
|
return None
|
|
@@ -171,14 +172,21 @@ class ServiceFileStorage:
|
|
|
171
172
|
try:
|
|
172
173
|
# Check file modification time for TTL
|
|
173
174
|
mtime = cache_path.stat().st_mtime
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
is_expired = time.time() - mtime > self.cache_ttl
|
|
176
|
+
|
|
177
|
+
if is_expired and not allow_stale:
|
|
178
|
+
# Cache expired - don't delete the file, just return None
|
|
179
|
+
# The file will be refreshed (overwritten) on next successful fetch
|
|
180
|
+
logger.debug(f"Cache expired for {url} (keeping file for refresh)")
|
|
177
181
|
return None
|
|
178
182
|
|
|
179
183
|
with open(cache_path, encoding="utf-8") as f:
|
|
180
184
|
data = json.load(f)
|
|
181
|
-
|
|
185
|
+
|
|
186
|
+
if is_expired:
|
|
187
|
+
logger.debug(f"Disk cache stale hit for {url} (allow_stale=True)")
|
|
188
|
+
else:
|
|
189
|
+
logger.debug(f"Disk cache hit for {url}")
|
|
182
190
|
return data
|
|
183
191
|
|
|
184
192
|
except Exception as e: # pylint: disable=broad-exception-caught
|
|
@@ -13,7 +13,6 @@ from iam_validator.core.aws_service.parsers import ServiceParser
|
|
|
13
13
|
from iam_validator.core.constants import (
|
|
14
14
|
AWS_TAG_KEY_ALLOWED_CHARS,
|
|
15
15
|
AWS_TAG_KEY_MAX_LENGTH,
|
|
16
|
-
AWS_TAG_KEY_PLACEHOLDERS,
|
|
17
16
|
)
|
|
18
17
|
from iam_validator.core.models import ServiceDetail
|
|
19
18
|
|
|
@@ -46,42 +45,17 @@ def _is_valid_tag_key(tag_key: str) -> bool:
|
|
|
46
45
|
return bool(_TAG_KEY_PATTERN.match(tag_key))
|
|
47
46
|
|
|
48
47
|
|
|
49
|
-
def
|
|
50
|
-
"""Check if a condition key matches
|
|
48
|
+
def condition_key_in_list(condition_key: str, condition_keys: list[str]) -> bool:
|
|
49
|
+
"""Check if a condition key matches any key in the list, supporting patterns.
|
|
51
50
|
|
|
52
|
-
AWS service definitions use patterns like:
|
|
53
|
-
- `ssm:resourceTag/tag-key`
|
|
51
|
+
AWS service definitions use patterns with tag-key placeholders like:
|
|
52
|
+
- `ssm:resourceTag/tag-key` to match `ssm:resourceTag/owner`
|
|
54
53
|
- `aws:ResourceTag/${TagKey}` to match `aws:ResourceTag/Environment`
|
|
54
|
+
- `s3:RequestObjectTag/<key>` to match `s3:RequestObjectTag/Environment`
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
Returns:
|
|
61
|
-
True if condition_key matches the pattern
|
|
62
|
-
"""
|
|
63
|
-
# Exact match (fast path)
|
|
64
|
-
if condition_key == pattern:
|
|
65
|
-
return True
|
|
66
|
-
|
|
67
|
-
# Check for tag-key placeholder patterns
|
|
68
|
-
for tag_placeholder in AWS_TAG_KEY_PLACEHOLDERS:
|
|
69
|
-
if tag_placeholder in pattern:
|
|
70
|
-
# Extract the prefix before the placeholder
|
|
71
|
-
prefix = pattern.split(tag_placeholder, 1)[0]
|
|
72
|
-
prefix_with_slash = prefix + "/"
|
|
73
|
-
# Check if condition_key starts with prefix and has a tag key after it
|
|
74
|
-
if condition_key.startswith(prefix_with_slash):
|
|
75
|
-
# Validate tag key format per AWS constraints
|
|
76
|
-
tag_key = condition_key[len(prefix_with_slash) :]
|
|
77
|
-
if _is_valid_tag_key(tag_key):
|
|
78
|
-
return True
|
|
79
|
-
|
|
80
|
-
return False
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _condition_key_in_list(condition_key: str, condition_keys: list[str]) -> bool:
|
|
84
|
-
"""Check if a condition key matches any key in the list, supporting patterns.
|
|
56
|
+
Any pattern containing "/" is treated as a potential tag-key pattern where
|
|
57
|
+
the prefix before "/" must match exactly and the suffix after "/" in the
|
|
58
|
+
condition_key must be a valid AWS tag key.
|
|
85
59
|
|
|
86
60
|
Args:
|
|
87
61
|
condition_key: The condition key to check
|
|
@@ -94,13 +68,30 @@ def _condition_key_in_list(condition_key: str, condition_keys: list[str]) -> boo
|
|
|
94
68
|
if condition_key in condition_keys:
|
|
95
69
|
return True
|
|
96
70
|
|
|
97
|
-
#
|
|
71
|
+
# Check if condition_key could match a pattern (must contain "/")
|
|
72
|
+
if "/" not in condition_key:
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
# Extract prefix and tag key from condition_key
|
|
76
|
+
cond_slash_idx = condition_key.rfind("/")
|
|
77
|
+
if cond_slash_idx <= 0:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
cond_prefix = condition_key[:cond_slash_idx]
|
|
81
|
+
tag_key = condition_key[cond_slash_idx + 1 :]
|
|
82
|
+
|
|
83
|
+
# Validate tag key format
|
|
84
|
+
if not _is_valid_tag_key(tag_key):
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
# Check if any pattern has a matching prefix
|
|
98
88
|
for pattern in condition_keys:
|
|
99
|
-
|
|
100
|
-
if pattern == condition_key:
|
|
89
|
+
if "/" not in pattern:
|
|
101
90
|
continue
|
|
102
|
-
|
|
91
|
+
pattern_prefix = pattern[: pattern.rfind("/")]
|
|
92
|
+
if pattern_prefix == cond_prefix:
|
|
103
93
|
return True
|
|
94
|
+
|
|
104
95
|
return False
|
|
105
96
|
|
|
106
97
|
|
|
@@ -264,7 +255,7 @@ class ServiceValidator:
|
|
|
264
255
|
)
|
|
265
256
|
|
|
266
257
|
# Check service-specific condition keys (with pattern matching for tag keys)
|
|
267
|
-
if service_detail.condition_keys and
|
|
258
|
+
if service_detail.condition_keys and condition_key_in_list(
|
|
268
259
|
condition_key, list(service_detail.condition_keys.keys())
|
|
269
260
|
):
|
|
270
261
|
return ConditionKeyValidationResult(is_valid=True)
|
|
@@ -272,7 +263,7 @@ class ServiceValidator:
|
|
|
272
263
|
# Check action-specific condition keys
|
|
273
264
|
if action_name in service_detail.actions:
|
|
274
265
|
action_detail = service_detail.actions[action_name]
|
|
275
|
-
if action_detail.action_condition_keys and
|
|
266
|
+
if action_detail.action_condition_keys and condition_key_in_list(
|
|
276
267
|
condition_key, action_detail.action_condition_keys
|
|
277
268
|
):
|
|
278
269
|
return ConditionKeyValidationResult(is_valid=True)
|
|
@@ -288,7 +279,7 @@ class ServiceValidator:
|
|
|
288
279
|
# Look up resource type definition
|
|
289
280
|
resource_type = service_detail.resources.get(resource_name)
|
|
290
281
|
if resource_type and resource_type.condition_keys:
|
|
291
|
-
if
|
|
282
|
+
if condition_key_in_list(condition_key, resource_type.condition_keys):
|
|
292
283
|
return ConditionKeyValidationResult(is_valid=True)
|
|
293
284
|
|
|
294
285
|
# If it's a global key but the action has specific condition keys defined,
|
|
@@ -15,6 +15,7 @@ from dataclasses import dataclass, field
|
|
|
15
15
|
from typing import TYPE_CHECKING, Any
|
|
16
16
|
|
|
17
17
|
from iam_validator.core.aws_service import AWSServiceFetcher
|
|
18
|
+
from iam_validator.core.config.check_documentation import CheckDocumentationRegistry
|
|
18
19
|
from iam_validator.core.ignore_patterns import IgnorePatternMatcher
|
|
19
20
|
from iam_validator.core.models import Statement, ValidationIssue
|
|
20
21
|
|
|
@@ -22,6 +23,28 @@ if TYPE_CHECKING:
|
|
|
22
23
|
from iam_validator.core.models import IAMPolicy
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
def _inject_documentation(issue: ValidationIssue, check_id: str) -> None:
|
|
27
|
+
"""Inject documentation fields from CheckDocumentationRegistry into an issue.
|
|
28
|
+
|
|
29
|
+
This populates risk_explanation, documentation_url, remediation_steps, and
|
|
30
|
+
risk_category from the centralized documentation registry if not already set.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
issue: The validation issue to enhance
|
|
34
|
+
check_id: The check ID to look up documentation for
|
|
35
|
+
"""
|
|
36
|
+
doc = CheckDocumentationRegistry.get(check_id)
|
|
37
|
+
if doc:
|
|
38
|
+
if issue.risk_explanation is None:
|
|
39
|
+
issue.risk_explanation = doc.risk_explanation
|
|
40
|
+
if issue.documentation_url is None:
|
|
41
|
+
issue.documentation_url = doc.documentation_url
|
|
42
|
+
if issue.remediation_steps is None:
|
|
43
|
+
issue.remediation_steps = doc.remediation_steps
|
|
44
|
+
if issue.risk_category is None:
|
|
45
|
+
issue.risk_category = doc.risk_category
|
|
46
|
+
|
|
47
|
+
|
|
25
48
|
@dataclass
|
|
26
49
|
class CheckConfig:
|
|
27
50
|
"""Configuration for a single check."""
|
|
@@ -32,28 +55,53 @@ class CheckConfig:
|
|
|
32
55
|
config: dict[str, Any] = field(default_factory=dict) # Check-specific config
|
|
33
56
|
description: str = ""
|
|
34
57
|
root_config: dict[str, Any] = field(default_factory=dict) # Full config for cross-check access
|
|
35
|
-
ignore_patterns: list[dict[str, Any]] = field(default_factory=list) #
|
|
58
|
+
ignore_patterns: list[dict[str, Any]] = field(default_factory=list) # Ignore patterns
|
|
59
|
+
hide_severities: frozenset[str] | None = None # Severities to hide from output
|
|
36
60
|
"""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
Configuration fields:
|
|
62
|
+
|
|
63
|
+
ignore_patterns: List of patterns to ignore findings.
|
|
64
|
+
Each pattern is a dict with optional fields:
|
|
65
|
+
- filepath: Regex to match file path
|
|
66
|
+
- action: Regex to match action name
|
|
67
|
+
- resource: Regex to match resource
|
|
68
|
+
- sid: Exact SID to match (or regex if ends with .*)
|
|
69
|
+
- condition_key: Regex to match condition key
|
|
70
|
+
|
|
71
|
+
Multiple fields in one pattern = AND logic
|
|
72
|
+
Multiple patterns = OR logic (any pattern matches → ignore)
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
ignore_patterns:
|
|
76
|
+
- filepath: "test/.*|examples/.*"
|
|
77
|
+
- filepath: "policies/readonly-.*"
|
|
78
|
+
action: ".*:(Get|List|Describe).*"
|
|
79
|
+
- sid: "AllowReadOnlyAccess"
|
|
80
|
+
|
|
81
|
+
hide_severities: Set of severity levels to hide from output.
|
|
82
|
+
Issues with these severities will be filtered out and not shown
|
|
83
|
+
in any output (console, JSON, SARIF, GitHub PR comments, etc.).
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
hide_severities: frozenset(["low", "info"])
|
|
55
87
|
"""
|
|
56
88
|
|
|
89
|
+
def should_show_severity(self, severity: str) -> bool:
|
|
90
|
+
"""Check if a severity level should be shown in output.
|
|
91
|
+
|
|
92
|
+
Returns False if severity is in hide_severities, True otherwise.
|
|
93
|
+
This is used to filter out low-priority findings to reduce noise.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
severity: The severity level to check
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if the severity should be shown, False if it should be hidden
|
|
100
|
+
"""
|
|
101
|
+
if self.hide_severities and severity in self.hide_severities:
|
|
102
|
+
return False
|
|
103
|
+
return True
|
|
104
|
+
|
|
57
105
|
def should_ignore(self, issue: ValidationIssue, filepath: str = "") -> bool:
|
|
58
106
|
"""
|
|
59
107
|
Check if issue should be ignored based on ignore patterns.
|
|
@@ -425,13 +473,17 @@ class CheckRegistry:
|
|
|
425
473
|
config = self.get_config(check.check_id)
|
|
426
474
|
if config:
|
|
427
475
|
issues = await check.execute(statement, statement_idx, fetcher, config)
|
|
428
|
-
# Inject check_id into each issue
|
|
476
|
+
# Inject check_id and documentation into each issue
|
|
429
477
|
for issue in issues:
|
|
430
478
|
if issue.check_id is None:
|
|
431
479
|
issue.check_id = check.check_id
|
|
432
|
-
|
|
480
|
+
_inject_documentation(issue, check.check_id)
|
|
481
|
+
# Filter issues based on ignore_patterns and hide_severities
|
|
433
482
|
filtered_issues = [
|
|
434
|
-
issue
|
|
483
|
+
issue
|
|
484
|
+
for issue in issues
|
|
485
|
+
if not config.should_ignore(issue, filepath)
|
|
486
|
+
and config.should_show_severity(issue.severity)
|
|
435
487
|
]
|
|
436
488
|
all_issues.extend(filtered_issues)
|
|
437
489
|
return all_issues
|
|
@@ -449,7 +501,7 @@ class CheckRegistry:
|
|
|
449
501
|
# Wait for all checks to complete
|
|
450
502
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
451
503
|
|
|
452
|
-
# Collect all issues, handling any exceptions and applying
|
|
504
|
+
# Collect all issues, handling any exceptions and applying filters
|
|
453
505
|
all_issues = []
|
|
454
506
|
for idx, result in enumerate(results):
|
|
455
507
|
if isinstance(result, Exception):
|
|
@@ -459,13 +511,17 @@ class CheckRegistry:
|
|
|
459
511
|
elif isinstance(result, list):
|
|
460
512
|
check = enabled_checks[idx]
|
|
461
513
|
config = configs[idx]
|
|
462
|
-
# Inject check_id into each issue
|
|
514
|
+
# Inject check_id and documentation into each issue
|
|
463
515
|
for issue in result:
|
|
464
516
|
if issue.check_id is None:
|
|
465
517
|
issue.check_id = check.check_id
|
|
466
|
-
|
|
518
|
+
_inject_documentation(issue, check.check_id)
|
|
519
|
+
# Filter issues based on ignore_patterns and hide_severities
|
|
467
520
|
filtered_issues = [
|
|
468
|
-
issue
|
|
521
|
+
issue
|
|
522
|
+
for issue in result
|
|
523
|
+
if not config.should_ignore(issue, filepath)
|
|
524
|
+
and config.should_show_severity(issue.severity)
|
|
469
525
|
]
|
|
470
526
|
all_issues.extend(filtered_issues)
|
|
471
527
|
|
|
@@ -499,7 +555,7 @@ class CheckRegistry:
|
|
|
499
555
|
try:
|
|
500
556
|
issues = await check.execute(statement, statement_idx, fetcher, config)
|
|
501
557
|
all_issues.extend(issues)
|
|
502
|
-
except Exception as e:
|
|
558
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
503
559
|
print(f"Warning: Check '{check.check_id}' failed: {e}")
|
|
504
560
|
|
|
505
561
|
return all_issues
|
|
@@ -551,18 +607,20 @@ class CheckRegistry:
|
|
|
551
607
|
policy_type=policy_type,
|
|
552
608
|
**kwargs,
|
|
553
609
|
)
|
|
554
|
-
# Inject check_id into each issue
|
|
610
|
+
# Inject check_id and documentation into each issue
|
|
555
611
|
for issue in issues:
|
|
556
612
|
if issue.check_id is None:
|
|
557
613
|
issue.check_id = check.check_id
|
|
558
|
-
|
|
614
|
+
_inject_documentation(issue, check.check_id)
|
|
615
|
+
# Filter issues based on ignore_patterns and hide_severities
|
|
559
616
|
filtered_issues = [
|
|
560
617
|
issue
|
|
561
618
|
for issue in issues
|
|
562
619
|
if not config.should_ignore(issue, policy_file)
|
|
620
|
+
and config.should_show_severity(issue.severity)
|
|
563
621
|
]
|
|
564
622
|
all_issues.extend(filtered_issues)
|
|
565
|
-
except Exception as e:
|
|
623
|
+
except Exception as e: # pylint: disable=broad-exception-caught
|
|
566
624
|
print(f"Warning: Check '{check.check_id}' failed: {e}")
|
|
567
625
|
return all_issues
|
|
568
626
|
|
|
@@ -581,7 +639,7 @@ class CheckRegistry:
|
|
|
581
639
|
# Wait for all checks to complete
|
|
582
640
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
583
641
|
|
|
584
|
-
# Collect all issues, handling any exceptions and applying
|
|
642
|
+
# Collect all issues, handling any exceptions and applying filters
|
|
585
643
|
for idx, result in enumerate(results):
|
|
586
644
|
if isinstance(result, Exception):
|
|
587
645
|
# Log error but continue with other checks
|
|
@@ -590,13 +648,17 @@ class CheckRegistry:
|
|
|
590
648
|
elif isinstance(result, list):
|
|
591
649
|
check = policy_level_checks[idx]
|
|
592
650
|
config = configs[idx]
|
|
593
|
-
# Inject check_id into each issue
|
|
651
|
+
# Inject check_id and documentation into each issue
|
|
594
652
|
for issue in result:
|
|
595
653
|
if issue.check_id is None:
|
|
596
654
|
issue.check_id = check.check_id
|
|
597
|
-
|
|
655
|
+
_inject_documentation(issue, check.check_id)
|
|
656
|
+
# Filter issues based on ignore_patterns and hide_severities
|
|
598
657
|
filtered_issues = [
|
|
599
|
-
issue
|
|
658
|
+
issue
|
|
659
|
+
for issue in result
|
|
660
|
+
if not config.should_ignore(issue, policy_file)
|
|
661
|
+
and config.should_show_severity(issue.severity)
|
|
600
662
|
]
|
|
601
663
|
all_issues.extend(filtered_issues)
|
|
602
664
|
|
|
@@ -623,7 +685,7 @@ def create_default_registry(
|
|
|
623
685
|
|
|
624
686
|
if include_builtin_checks:
|
|
625
687
|
# Import and register built-in checks
|
|
626
|
-
from iam_validator import checks
|
|
688
|
+
from iam_validator import checks # pylint: disable=import-outside-toplevel
|
|
627
689
|
|
|
628
690
|
# 0. FUNDAMENTAL STRUCTURE (Must run FIRST - validates basic policy structure)
|
|
629
691
|
registry.register(
|
|
@@ -655,6 +717,9 @@ def create_default_registry(
|
|
|
655
717
|
registry.register(checks.WildcardResourceCheck()) # Wildcard resource detection
|
|
656
718
|
registry.register(checks.FullWildcardCheck()) # Full wildcard (*) detection
|
|
657
719
|
registry.register(checks.ServiceWildcardCheck()) # Service-level wildcard detection
|
|
720
|
+
registry.register(
|
|
721
|
+
checks.NotActionNotResourceCheck()
|
|
722
|
+
) # NotAction/NotResource pattern detection
|
|
658
723
|
|
|
659
724
|
# 6. SECURITY - ADVANCED (Sensitive actions and condition enforcement)
|
|
660
725
|
registry.register(
|