iam-policy-validator 1.4.0__py3-none-any.whl → 1.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {iam_policy_validator-1.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/METADATA +106 -78
  2. iam_policy_validator-1.6.0.dist-info/RECORD +82 -0
  3. iam_validator/__version__.py +1 -1
  4. iam_validator/checks/__init__.py +20 -4
  5. iam_validator/checks/action_condition_enforcement.py +165 -8
  6. iam_validator/checks/action_resource_matching.py +424 -0
  7. iam_validator/checks/condition_key_validation.py +24 -2
  8. iam_validator/checks/condition_type_mismatch.py +259 -0
  9. iam_validator/checks/full_wildcard.py +67 -0
  10. iam_validator/checks/mfa_condition_check.py +112 -0
  11. iam_validator/checks/principal_validation.py +497 -3
  12. iam_validator/checks/sensitive_action.py +250 -0
  13. iam_validator/checks/service_wildcard.py +105 -0
  14. iam_validator/checks/set_operator_validation.py +157 -0
  15. iam_validator/checks/utils/sensitive_action_matcher.py +74 -32
  16. iam_validator/checks/wildcard_action.py +62 -0
  17. iam_validator/checks/wildcard_resource.py +131 -0
  18. iam_validator/commands/cache.py +1 -1
  19. iam_validator/commands/download_services.py +3 -8
  20. iam_validator/commands/validate.py +72 -13
  21. iam_validator/core/aws_fetcher.py +114 -64
  22. iam_validator/core/check_registry.py +167 -29
  23. iam_validator/core/condition_validators.py +626 -0
  24. iam_validator/core/config/__init__.py +81 -0
  25. iam_validator/core/config/aws_api.py +35 -0
  26. iam_validator/core/config/aws_global_conditions.py +160 -0
  27. iam_validator/core/config/category_suggestions.py +104 -0
  28. iam_validator/core/config/condition_requirements.py +155 -0
  29. iam_validator/core/{config_loader.py → config/config_loader.py} +32 -9
  30. iam_validator/core/config/defaults.py +523 -0
  31. iam_validator/core/config/principal_requirements.py +421 -0
  32. iam_validator/core/config/sensitive_actions.py +672 -0
  33. iam_validator/core/config/service_principals.py +95 -0
  34. iam_validator/core/config/wildcards.py +124 -0
  35. iam_validator/core/formatters/enhanced.py +11 -5
  36. iam_validator/core/formatters/sarif.py +78 -14
  37. iam_validator/core/models.py +14 -1
  38. iam_validator/core/policy_checks.py +4 -4
  39. iam_validator/core/pr_commenter.py +1 -1
  40. iam_validator/sdk/__init__.py +187 -0
  41. iam_validator/sdk/arn_matching.py +274 -0
  42. iam_validator/sdk/context.py +222 -0
  43. iam_validator/sdk/exceptions.py +48 -0
  44. iam_validator/sdk/helpers.py +177 -0
  45. iam_validator/sdk/policy_utils.py +425 -0
  46. iam_validator/sdk/shortcuts.py +283 -0
  47. iam_validator/utils/__init__.py +31 -0
  48. iam_validator/utils/cache.py +105 -0
  49. iam_validator/utils/regex.py +206 -0
  50. iam_policy_validator-1.4.0.dist-info/RECORD +0 -56
  51. iam_validator/checks/action_resource_constraint.py +0 -151
  52. iam_validator/checks/security_best_practices.py +0 -536
  53. iam_validator/core/aws_global_conditions.py +0 -137
  54. iam_validator/core/defaults.py +0 -393
  55. {iam_policy_validator-1.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/WHEEL +0 -0
  56. {iam_policy_validator-1.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/entry_points.txt +0 -0
  57. {iam_policy_validator-1.4.0.dist-info → iam_policy_validator-1.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -4,13 +4,40 @@ Validates Principal elements in resource-based policies for security best practi
4
4
  This check enforces:
5
5
  - Blocked principals (e.g., public access via "*")
6
6
  - Allowed principals whitelist (optional)
7
- - Required conditions for specific principals
7
+ - Required conditions for specific principals (simple format)
8
+ - Rich condition requirements for principals (advanced format with all_of/any_of)
8
9
  - Service principal validation
9
10
 
10
11
  Only runs for RESOURCE_POLICY type policies.
12
+
13
+ Configuration supports TWO formats:
14
+
15
+ 1. Simple format (backward compatible):
16
+ require_conditions_for:
17
+ "*": ["aws:SourceArn", "aws:SourceAccount"]
18
+ "arn:aws:iam::*:root": ["aws:PrincipalOrgID"]
19
+
20
+ 2. Advanced format with rich condition requirements:
21
+ principal_condition_requirements:
22
+ - principals:
23
+ - "*"
24
+ severity: critical
25
+ required_conditions:
26
+ all_of:
27
+ - condition_key: "aws:SourceArn"
28
+ description: "Limit by source ARN"
29
+ - condition_key: "aws:SourceAccount"
30
+
31
+ - principals:
32
+ - "arn:aws:iam::*:root"
33
+ required_conditions:
34
+ - condition_key: "aws:PrincipalOrgID"
35
+ expected_value: "o-xxxxx"
36
+ operator: "StringEquals"
11
37
  """
12
38
 
13
39
  import fnmatch
40
+ from typing import Any
14
41
 
15
42
  from iam_validator.core.aws_fetcher import AWSServiceFetcher
16
43
  from iam_validator.core.check_registry import CheckConfig, PolicyCheck
@@ -22,7 +49,7 @@ class PrincipalValidationCheck(PolicyCheck):
22
49
 
23
50
  @property
24
51
  def check_id(self) -> str:
25
- return "principal_validation_check"
52
+ return "principal_validation"
26
53
 
27
54
  @property
28
55
  def description(self) -> str:
@@ -60,6 +87,7 @@ class PrincipalValidationCheck(PolicyCheck):
60
87
  blocked_principals = config.config.get("blocked_principals", ["*"])
61
88
  allowed_principals = config.config.get("allowed_principals", [])
62
89
  require_conditions_for = config.config.get("require_conditions_for", {})
90
+ principal_condition_requirements = config.config.get("principal_condition_requirements", [])
63
91
  allowed_service_principals = config.config.get(
64
92
  "allowed_service_principals",
65
93
  [
@@ -114,7 +142,7 @@ class PrincipalValidationCheck(PolicyCheck):
114
142
  )
115
143
  continue
116
144
 
117
- # Check if principal requires conditions
145
+ # Check simple format: require_conditions_for (backward compatible)
118
146
  required_conditions = self._get_required_conditions(principal, require_conditions_for)
119
147
  if required_conditions:
120
148
  missing_conditions = self._check_required_conditions(statement, required_conditions)
@@ -138,6 +166,13 @@ class PrincipalValidationCheck(PolicyCheck):
138
166
  )
139
167
  )
140
168
 
169
+ # Check advanced format: principal_condition_requirements
170
+ if principal_condition_requirements:
171
+ condition_issues = self._validate_principal_condition_requirements(
172
+ statement, statement_idx, principals, principal_condition_requirements, config
173
+ )
174
+ issues.extend(condition_issues)
175
+
141
176
  return issues
142
177
 
143
178
  def _extract_principals(self, statement: Statement) -> list[str]:
@@ -280,3 +315,462 @@ class PrincipalValidationCheck(PolicyCheck):
280
315
  # Find missing keys
281
316
  missing = [key for key in required_keys if key not in present_keys]
282
317
  return missing
318
+
319
+ def _validate_principal_condition_requirements(
320
+ self,
321
+ statement: Statement,
322
+ statement_idx: int,
323
+ principals: list[str],
324
+ requirements: list[dict[str, Any]],
325
+ config: CheckConfig,
326
+ ) -> list[ValidationIssue]:
327
+ """Validate advanced principal condition requirements.
328
+
329
+ Args:
330
+ statement: The statement to validate
331
+ statement_idx: Index of the statement
332
+ principals: List of principals from the statement
333
+ requirements: List of principal condition requirements
334
+ config: Check configuration
335
+
336
+ Returns:
337
+ List of validation issues
338
+ """
339
+ issues: list[ValidationIssue] = []
340
+
341
+ # Check each requirement rule
342
+ for requirement in requirements:
343
+ # Check if any principal matches this requirement
344
+ matching_principals = self._get_matching_principals(principals, requirement)
345
+
346
+ if not matching_principals:
347
+ continue
348
+
349
+ # Get required conditions from the requirement
350
+ required_conditions_config = requirement.get("required_conditions", [])
351
+ if not required_conditions_config:
352
+ continue
353
+
354
+ # Validate conditions using the same logic as action_condition_enforcement
355
+ condition_issues = self._validate_conditions(
356
+ statement,
357
+ statement_idx,
358
+ required_conditions_config,
359
+ matching_principals,
360
+ config,
361
+ requirement,
362
+ )
363
+
364
+ issues.extend(condition_issues)
365
+
366
+ return issues
367
+
368
+ def _get_matching_principals(
369
+ self, principals: list[str], requirement: dict[str, Any]
370
+ ) -> list[str]:
371
+ """Get principals that match the requirement pattern.
372
+
373
+ Args:
374
+ principals: List of principals from the statement
375
+ requirement: Principal condition requirement config
376
+
377
+ Returns:
378
+ List of matching principals
379
+ """
380
+ principal_patterns = requirement.get("principals", [])
381
+ if not principal_patterns:
382
+ return []
383
+
384
+ matching: list[str] = []
385
+
386
+ for principal in principals:
387
+ for pattern in principal_patterns:
388
+ # Special case: "*" pattern should only match literal "*"
389
+ if pattern == "*":
390
+ if principal == "*":
391
+ matching.append(principal)
392
+ elif fnmatch.fnmatch(principal, pattern):
393
+ matching.append(principal)
394
+
395
+ return matching
396
+
397
+ def _validate_conditions(
398
+ self,
399
+ statement: Statement,
400
+ statement_idx: int,
401
+ required_conditions_config: Any,
402
+ matching_principals: list[str],
403
+ config: CheckConfig,
404
+ requirement: dict[str, Any],
405
+ ) -> list[ValidationIssue]:
406
+ """Validate that required conditions are present.
407
+
408
+ Supports: simple list, all_of, any_of, none_of formats.
409
+ Similar to action_condition_enforcement logic.
410
+
411
+ Args:
412
+ statement: The statement to validate
413
+ statement_idx: Index of the statement
414
+ required_conditions_config: Condition requirements config
415
+ matching_principals: Principals that matched
416
+ config: Check configuration
417
+ requirement: Parent requirement for severity override
418
+
419
+ Returns:
420
+ List of validation issues
421
+ """
422
+ issues: list[ValidationIssue] = []
423
+
424
+ # Handle simple list format (backward compatibility)
425
+ if isinstance(required_conditions_config, list):
426
+ for condition_requirement in required_conditions_config:
427
+ if not self._has_condition_requirement(statement, condition_requirement):
428
+ issues.append(
429
+ self._create_condition_issue(
430
+ statement,
431
+ statement_idx,
432
+ condition_requirement,
433
+ matching_principals,
434
+ config,
435
+ requirement,
436
+ )
437
+ )
438
+ return issues
439
+
440
+ # Handle all_of/any_of/none_of format
441
+ if isinstance(required_conditions_config, dict):
442
+ all_of = required_conditions_config.get("all_of", [])
443
+ any_of = required_conditions_config.get("any_of", [])
444
+ none_of = required_conditions_config.get("none_of", [])
445
+
446
+ # Validate all_of: ALL conditions must be present
447
+ if all_of:
448
+ for condition_requirement in all_of:
449
+ if not self._has_condition_requirement(statement, condition_requirement):
450
+ issues.append(
451
+ self._create_condition_issue(
452
+ statement,
453
+ statement_idx,
454
+ condition_requirement,
455
+ matching_principals,
456
+ config,
457
+ requirement,
458
+ requirement_type="all_of",
459
+ )
460
+ )
461
+
462
+ # Validate any_of: At least ONE condition must be present
463
+ if any_of:
464
+ any_present = any(
465
+ self._has_condition_requirement(statement, cond_req) for cond_req in any_of
466
+ )
467
+
468
+ if not any_present:
469
+ # Create a combined error for any_of
470
+ condition_keys = [cond.get("condition_key", "unknown") for cond in any_of]
471
+ severity = requirement.get("severity", self.get_severity(config))
472
+ issues.append(
473
+ ValidationIssue(
474
+ severity=severity,
475
+ statement_sid=statement.sid,
476
+ statement_index=statement_idx,
477
+ issue_type="missing_principal_condition_any_of",
478
+ message=(
479
+ f"Principals {matching_principals} require at least ONE of these conditions: "
480
+ f"{', '.join(condition_keys)}"
481
+ ),
482
+ suggestion=self._build_any_of_suggestion(any_of),
483
+ line_number=statement.line_number,
484
+ )
485
+ )
486
+
487
+ # Validate none_of: NONE of these conditions should be present
488
+ if none_of:
489
+ for condition_requirement in none_of:
490
+ if self._has_condition_requirement(statement, condition_requirement):
491
+ issues.append(
492
+ self._create_none_of_condition_issue(
493
+ statement,
494
+ statement_idx,
495
+ condition_requirement,
496
+ matching_principals,
497
+ config,
498
+ requirement,
499
+ )
500
+ )
501
+
502
+ return issues
503
+
504
+ def _has_condition_requirement(
505
+ self, statement: Statement, condition_requirement: dict[str, Any]
506
+ ) -> bool:
507
+ """Check if statement has the required condition.
508
+
509
+ Args:
510
+ statement: The statement to check
511
+ condition_requirement: Condition requirement config
512
+
513
+ Returns:
514
+ True if condition is present and matches requirements
515
+ """
516
+ condition_key = condition_requirement.get("condition_key")
517
+ if not condition_key:
518
+ return True # No condition key specified, skip
519
+
520
+ operator = condition_requirement.get("operator")
521
+ expected_value = condition_requirement.get("expected_value")
522
+
523
+ return self._has_condition(statement, condition_key, operator, expected_value)
524
+
525
+ def _has_condition(
526
+ self,
527
+ statement: Statement,
528
+ condition_key: str,
529
+ operator: str | None = None,
530
+ expected_value: Any = None,
531
+ ) -> bool:
532
+ """Check if statement has the specified condition key.
533
+
534
+ Args:
535
+ statement: The IAM policy statement
536
+ condition_key: The condition key to look for
537
+ operator: Optional specific operator (e.g., "StringEquals")
538
+ expected_value: Optional expected value for the condition
539
+
540
+ Returns:
541
+ True if condition is present (and matches expected value if specified)
542
+ """
543
+ if not statement.condition:
544
+ return False
545
+
546
+ # If operator specified, only check that operator
547
+ operators_to_check = [operator] if operator else list(statement.condition.keys())
548
+
549
+ # Look through specified condition operators
550
+ for op in operators_to_check:
551
+ if op not in statement.condition:
552
+ continue
553
+
554
+ conditions = statement.condition[op]
555
+ if isinstance(conditions, dict):
556
+ if condition_key in conditions:
557
+ # If no expected value specified, just presence is enough
558
+ if expected_value is None:
559
+ return True
560
+
561
+ # Check if the value matches
562
+ actual_value = conditions[condition_key]
563
+
564
+ # Handle boolean values
565
+ if isinstance(expected_value, bool):
566
+ if isinstance(actual_value, bool):
567
+ return actual_value == expected_value
568
+ if isinstance(actual_value, str):
569
+ return actual_value.lower() == str(expected_value).lower()
570
+
571
+ # Handle exact matches
572
+ if actual_value == expected_value:
573
+ return True
574
+
575
+ # Handle list values (actual can be string or list)
576
+ if isinstance(expected_value, list):
577
+ if isinstance(actual_value, list):
578
+ return set(expected_value) == set(actual_value)
579
+ if actual_value in expected_value:
580
+ return True
581
+
582
+ # Handle string matches for variable references like ${aws:PrincipalTag/owner}
583
+ if str(actual_value) == str(expected_value):
584
+ return True
585
+
586
+ return False
587
+
588
+ def _create_condition_issue(
589
+ self,
590
+ statement: Statement,
591
+ statement_idx: int,
592
+ condition_requirement: dict[str, Any],
593
+ matching_principals: list[str],
594
+ config: CheckConfig,
595
+ requirement: dict[str, Any],
596
+ requirement_type: str = "required",
597
+ ) -> ValidationIssue:
598
+ """Create a validation issue for a missing condition.
599
+
600
+ Severity precedence:
601
+ 1. Individual condition requirement's severity (condition_requirement['severity'])
602
+ 2. Parent requirement's severity (requirement['severity'])
603
+ 3. Global check severity (config.severity)
604
+
605
+ Args:
606
+ statement: The statement being validated
607
+ statement_idx: Index of the statement
608
+ condition_requirement: The condition requirement config
609
+ matching_principals: Principals that matched
610
+ config: Check configuration
611
+ requirement: Parent requirement config
612
+ requirement_type: Type of requirement (required, all_of)
613
+
614
+ Returns:
615
+ ValidationIssue
616
+ """
617
+ condition_key = condition_requirement.get("condition_key", "unknown")
618
+ description = condition_requirement.get("description", "")
619
+ expected_value = condition_requirement.get("expected_value")
620
+ example = condition_requirement.get("example", "")
621
+ operator = condition_requirement.get("operator", "StringEquals")
622
+
623
+ message_prefix = "ALL required:" if requirement_type == "all_of" else "Required:"
624
+
625
+ # Determine severity with precedence: condition > requirement > global
626
+ severity = (
627
+ condition_requirement.get("severity")
628
+ or requirement.get("severity")
629
+ or self.get_severity(config)
630
+ )
631
+
632
+ return ValidationIssue(
633
+ severity=severity,
634
+ statement_sid=statement.sid,
635
+ statement_index=statement_idx,
636
+ issue_type="missing_principal_condition",
637
+ message=f"{message_prefix} Principal(s) {matching_principals} require condition '{condition_key}'",
638
+ suggestion=self._build_condition_suggestion(
639
+ condition_key, description, example, expected_value, operator
640
+ ),
641
+ line_number=statement.line_number,
642
+ )
643
+
644
+ def _build_condition_suggestion(
645
+ self,
646
+ condition_key: str,
647
+ description: str,
648
+ example: str,
649
+ expected_value: Any = None,
650
+ operator: str = "StringEquals",
651
+ ) -> str:
652
+ """Build a helpful suggestion for adding the missing condition.
653
+
654
+ Args:
655
+ condition_key: The condition key
656
+ description: Description of the condition
657
+ example: Example usage
658
+ expected_value: Expected value for the condition
659
+ operator: Condition operator
660
+
661
+ Returns:
662
+ Suggestion string
663
+ """
664
+ parts = []
665
+
666
+ if description:
667
+ parts.append(description)
668
+
669
+ # Build example based on condition key type
670
+ if example:
671
+ parts.append(f"Example:\n{example}")
672
+ else:
673
+ # Auto-generate example
674
+ example_lines = ['Add to "Condition" block:', f' "{operator}": {{']
675
+
676
+ if isinstance(expected_value, list):
677
+ value_str = (
678
+ "["
679
+ + ", ".join(
680
+ [
681
+ f'"{v}"' if not str(v).startswith("${") else f'"{v}"'
682
+ for v in expected_value
683
+ ]
684
+ )
685
+ + "]"
686
+ )
687
+ elif expected_value is not None:
688
+ # Don't quote if it's a variable reference like ${aws:PrincipalTag/owner}
689
+ if str(expected_value).startswith("${"):
690
+ value_str = f'"{expected_value}"'
691
+ elif isinstance(expected_value, bool):
692
+ value_str = str(expected_value).lower()
693
+ else:
694
+ value_str = f'"{expected_value}"'
695
+ else:
696
+ value_str = '"<value>"'
697
+
698
+ example_lines.append(f' "{condition_key}": {value_str}')
699
+ example_lines.append(" }")
700
+
701
+ parts.append("\n".join(example_lines))
702
+
703
+ return ". ".join(parts) if parts else f"Add condition: {condition_key}"
704
+
705
+ def _build_any_of_suggestion(self, any_of_conditions: list[dict[str, Any]]) -> str:
706
+ """Build suggestion for any_of conditions.
707
+
708
+ Args:
709
+ any_of_conditions: List of condition requirements
710
+
711
+ Returns:
712
+ Suggestion string
713
+ """
714
+ suggestions = []
715
+ suggestions.append("Add at least ONE of these conditions:")
716
+
717
+ for i, cond in enumerate(any_of_conditions, 1):
718
+ condition_key = cond.get("condition_key", "unknown")
719
+ description = cond.get("description", "")
720
+ expected_value = cond.get("expected_value")
721
+
722
+ option = f"\nOption {i}: {condition_key}"
723
+ if description:
724
+ option += f" - {description}"
725
+ if expected_value is not None:
726
+ option += f" (value: {expected_value})"
727
+
728
+ suggestions.append(option)
729
+
730
+ return "".join(suggestions)
731
+
732
+ def _create_none_of_condition_issue(
733
+ self,
734
+ statement: Statement,
735
+ statement_idx: int,
736
+ condition_requirement: dict[str, Any],
737
+ matching_principals: list[str],
738
+ config: CheckConfig,
739
+ requirement: dict[str, Any],
740
+ ) -> ValidationIssue:
741
+ """Create a validation issue for a forbidden condition that is present.
742
+
743
+ Args:
744
+ statement: The statement being validated
745
+ statement_idx: Index of the statement
746
+ condition_requirement: The condition requirement config
747
+ matching_principals: Principals that matched
748
+ config: Check configuration
749
+ requirement: Parent requirement config
750
+
751
+ Returns:
752
+ ValidationIssue
753
+ """
754
+ condition_key = condition_requirement.get("condition_key", "unknown")
755
+ description = condition_requirement.get("description", "")
756
+ expected_value = condition_requirement.get("expected_value")
757
+
758
+ message = f"FORBIDDEN: Principal(s) {matching_principals} must NOT have condition '{condition_key}'"
759
+ if expected_value is not None:
760
+ message += f" with value '{expected_value}'"
761
+
762
+ suggestion = f"Remove the '{condition_key}' condition from the statement"
763
+ if description:
764
+ suggestion += f". {description}"
765
+
766
+ severity = requirement.get("severity", self.get_severity(config))
767
+
768
+ return ValidationIssue(
769
+ severity=severity,
770
+ statement_sid=statement.sid,
771
+ statement_index=statement_idx,
772
+ issue_type="forbidden_principal_condition",
773
+ message=message,
774
+ suggestion=suggestion,
775
+ line_number=statement.line_number,
776
+ )