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.
Files changed (51) hide show
  1. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/METADATA +22 -7
  2. iam_policy_validator-1.8.0.dist-info/RECORD +87 -0
  3. iam_validator/__version__.py +4 -2
  4. iam_validator/checks/__init__.py +5 -3
  5. iam_validator/checks/action_condition_enforcement.py +81 -36
  6. iam_validator/checks/action_resource_matching.py +75 -37
  7. iam_validator/checks/action_validation.py +1 -1
  8. iam_validator/checks/condition_key_validation.py +7 -7
  9. iam_validator/checks/condition_type_mismatch.py +10 -8
  10. iam_validator/checks/full_wildcard.py +2 -8
  11. iam_validator/checks/mfa_condition_check.py +8 -8
  12. iam_validator/checks/policy_structure.py +577 -0
  13. iam_validator/checks/policy_type_validation.py +48 -32
  14. iam_validator/checks/principal_validation.py +86 -150
  15. iam_validator/checks/resource_validation.py +8 -8
  16. iam_validator/checks/sensitive_action.py +9 -11
  17. iam_validator/checks/service_wildcard.py +4 -10
  18. iam_validator/checks/set_operator_validation.py +11 -11
  19. iam_validator/checks/sid_uniqueness.py +8 -4
  20. iam_validator/checks/trust_policy_validation.py +512 -0
  21. iam_validator/checks/utils/sensitive_action_matcher.py +26 -26
  22. iam_validator/checks/utils/wildcard_expansion.py +1 -1
  23. iam_validator/checks/wildcard_action.py +5 -9
  24. iam_validator/checks/wildcard_resource.py +5 -9
  25. iam_validator/commands/validate.py +8 -14
  26. iam_validator/core/__init__.py +1 -2
  27. iam_validator/core/access_analyzer.py +1 -1
  28. iam_validator/core/access_analyzer_report.py +2 -2
  29. iam_validator/core/aws_fetcher.py +159 -64
  30. iam_validator/core/check_registry.py +83 -79
  31. iam_validator/core/config/condition_requirements.py +69 -17
  32. iam_validator/core/config/config_loader.py +1 -2
  33. iam_validator/core/config/defaults.py +74 -59
  34. iam_validator/core/config/service_principals.py +40 -3
  35. iam_validator/core/constants.py +57 -0
  36. iam_validator/core/formatters/console.py +10 -1
  37. iam_validator/core/formatters/csv.py +2 -1
  38. iam_validator/core/formatters/enhanced.py +42 -8
  39. iam_validator/core/formatters/markdown.py +2 -1
  40. iam_validator/core/ignore_patterns.py +297 -0
  41. iam_validator/core/models.py +35 -10
  42. iam_validator/core/policy_checks.py +34 -474
  43. iam_validator/core/policy_loader.py +98 -18
  44. iam_validator/core/report.py +65 -24
  45. iam_validator/integrations/github_integration.py +4 -5
  46. iam_validator/utils/__init__.py +4 -0
  47. iam_validator/utils/terminal.py +22 -0
  48. iam_policy_validator-1.7.1.dist-info/RECORD +0 -83
  49. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/WHEEL +0 -0
  50. {iam_policy_validator-1.7.1.dist-info → iam_policy_validator-1.8.0.dist-info}/entry_points.txt +0 -0
  51. {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("message", "Statement applies to all resources (*)")
66
- suggestion_text = config.config.get(
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
- "Enables policy-type-specific validation (e.g., requiring Principal for resource policies, "
123
- "strict RCP requirements for resource control policies)",
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
  )
@@ -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 PolicyValidator, validate_policies
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
- - _match_wildcard_action: Match wildcard patterns
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 = 30.0,
221
+ timeout: float = constants.DEFAULT_HTTP_TIMEOUT_SECONDS,
203
222
  retries: int = 3,
204
223
  enable_cache: bool = True,
205
- cache_ttl: int = 604800,
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=30.0, # Keep connections alive for 30 seconds
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(f"Invalid JSON response from {url}: {json_error}")
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(f"Service '{service_name}' not found in {self.aws_services_dir}")
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 '{service_name}' not found")
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
- elif isinstance(result, tuple):
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 _match_wildcard_action(self, pattern: str, actions: list[str]) -> tuple[bool, list[str]]:
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
- else:
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._match_wildcard_action(
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
- else:
784
- # Wildcard doesn't match any actions
785
- return (
786
- False,
787
- f"Action pattern '{action_name}' does not match any actions in service '{service_prefix}'",
788
- True,
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
- suggestion = f" Did you mean: {', '.join(similar)}?" if similar else ""
801
- return (
802
- False,
803
- f"Action '{action_name}' not found in service '{service_prefix}'.{suggestion}",
804
- False,
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
- ) -> tuple[bool, str | None, str | None]:
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
- Tuple of (is_valid, error_message, warning_message)
857
+ ConditionKeyValidationResult with:
837
858
  - is_valid: True if key is valid (even with warning)
838
- - error_message: Error message if invalid (is_valid=False)
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: '{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, None, None
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, None, None
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, None, None
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 '{condition_key}' is used with action '{action}'. "
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 '{condition_key}' is available for this specific action's request context. "
899
- f"Consider using '*IfExists' operators (e.g., StringEqualsIfExists) if the key might be missing."
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, None, warning_msg
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, None, None
926
+ return ConditionKeyValidationResult(is_valid=True)
906
927
 
907
- return (
908
- False,
909
- f"Condition key '{condition_key}' is not valid for action '{action}'",
910
- None,
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 False, f"Failed to validate condition key: {str(e)}", None
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")