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.
Files changed (43) hide show
  1. {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/METADATA +34 -23
  2. {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/RECORD +42 -29
  3. iam_policy_validator-1.15.0.dist-info/entry_points.txt +4 -0
  4. iam_validator/__version__.py +1 -1
  5. iam_validator/checks/__init__.py +2 -0
  6. iam_validator/checks/action_validation.py +91 -27
  7. iam_validator/checks/not_action_not_resource.py +163 -0
  8. iam_validator/checks/resource_validation.py +132 -81
  9. iam_validator/checks/wildcard_resource.py +136 -6
  10. iam_validator/commands/__init__.py +3 -0
  11. iam_validator/commands/cache.py +66 -24
  12. iam_validator/commands/completion.py +94 -15
  13. iam_validator/commands/mcp.py +210 -0
  14. iam_validator/commands/query.py +489 -65
  15. iam_validator/core/aws_service/__init__.py +5 -1
  16. iam_validator/core/aws_service/cache.py +20 -0
  17. iam_validator/core/aws_service/fetcher.py +180 -11
  18. iam_validator/core/aws_service/storage.py +14 -6
  19. iam_validator/core/aws_service/validators.py +32 -41
  20. iam_validator/core/check_registry.py +100 -35
  21. iam_validator/core/config/aws_global_conditions.py +13 -0
  22. iam_validator/core/config/check_documentation.py +104 -51
  23. iam_validator/core/config/config_loader.py +39 -3
  24. iam_validator/core/config/defaults.py +6 -0
  25. iam_validator/core/constants.py +11 -4
  26. iam_validator/core/models.py +39 -14
  27. iam_validator/mcp/__init__.py +162 -0
  28. iam_validator/mcp/models.py +118 -0
  29. iam_validator/mcp/server.py +2928 -0
  30. iam_validator/mcp/session_config.py +319 -0
  31. iam_validator/mcp/templates/__init__.py +79 -0
  32. iam_validator/mcp/templates/builtin.py +856 -0
  33. iam_validator/mcp/tools/__init__.py +72 -0
  34. iam_validator/mcp/tools/generation.py +888 -0
  35. iam_validator/mcp/tools/org_config_tools.py +263 -0
  36. iam_validator/mcp/tools/query.py +395 -0
  37. iam_validator/mcp/tools/validation.py +376 -0
  38. iam_validator/sdk/__init__.py +64 -63
  39. iam_validator/sdk/context.py +3 -2
  40. iam_validator/sdk/policy_utils.py +31 -5
  41. iam_policy_validator-1.14.6.dist-info/entry_points.txt +0 -2
  42. {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/WHEEL +0 -0
  43. {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 ConditionKeyValidationResult
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
- data = await self._client.fetch(self.BASE_URL)
249
- # Cache the raw data
250
- await self._cache.set(
251
- f"raw:{self.BASE_URL}", data, url=self.BASE_URL, base_url=self.BASE_URL
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
- # Fetch service detail from API
336
- data = await self._client.fetch(service.url)
337
- # Cache the raw data
338
- await self._cache.set(
339
- f"raw:{service.url}", data, url=service.url, base_url=self.BASE_URL
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
- if time.time() - mtime > self.cache_ttl:
175
- logger.debug(f"Cache expired for {url}")
176
- cache_path.unlink() # Remove expired cache
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
- logger.debug(f"Disk cache hit for {url}")
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 _matches_condition_key_pattern(condition_key: str, pattern: str) -> bool:
50
- """Check if a condition key matches a pattern with tag-key placeholders.
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` or `ssm:resourceTag/${TagKey}` to match `ssm:resourceTag/owner`
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
- Args:
57
- condition_key: The actual condition key from the policy (e.g., "ssm:resourceTag/owner")
58
- pattern: The pattern from AWS service definition (e.g., "ssm:resourceTag/tag-key")
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
- # Slower path: check patterns only if no exact match
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
- # Skip exact matches (already checked above)
100
- if pattern == condition_key:
89
+ if "/" not in pattern:
101
90
  continue
102
- if _matches_condition_key_pattern(condition_key, pattern):
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 _condition_key_in_list(
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 _condition_key_in_list(
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 _condition_key_in_list(condition_key, resource_type.condition_keys):
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) # NEW: Ignore patterns
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
- List of patterns to ignore findings.
38
-
39
- Each pattern is a dict with optional fields:
40
- - filepath: Regex to match file path
41
- - action: Regex to match action name
42
- - resource: Regex to match resource
43
- - sid: Exact SID to match (or regex if ends with .*)
44
- - condition_key: Regex to match condition key
45
-
46
- Multiple fields in one pattern = AND logic
47
- Multiple patterns = OR logic (any pattern matches ignore)
48
-
49
- Example:
50
- ignore_patterns:
51
- - filepath: "test/.*|examples/.*"
52
- - filepath: "policies/readonly-.*"
53
- action: ".*:(Get|List|Describe).*"
54
- - sid: "AllowReadOnlyAccess"
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
- # Filter issues based on ignore_patterns
480
+ _inject_documentation(issue, check.check_id)
481
+ # Filter issues based on ignore_patterns and hide_severities
433
482
  filtered_issues = [
434
- issue for issue in issues if not config.should_ignore(issue, filepath)
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 ignore_patterns
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
- # Filter issues based on ignore_patterns
518
+ _inject_documentation(issue, check.check_id)
519
+ # Filter issues based on ignore_patterns and hide_severities
467
520
  filtered_issues = [
468
- issue for issue in result if not config.should_ignore(issue, filepath)
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
- # Filter issues based on ignore_patterns
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 ignore_patterns
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
- # Filter issues based on ignore_patterns
655
+ _inject_documentation(issue, check.check_id)
656
+ # Filter issues based on ignore_patterns and hide_severities
598
657
  filtered_issues = [
599
- issue for issue in result if not config.should_ignore(issue, policy_file)
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(