iam-policy-validator 1.14.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 (106) hide show
  1. iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
  2. iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
  3. iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
  4. iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
  5. iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
  6. iam_validator/__init__.py +27 -0
  7. iam_validator/__main__.py +11 -0
  8. iam_validator/__version__.py +9 -0
  9. iam_validator/checks/__init__.py +45 -0
  10. iam_validator/checks/action_condition_enforcement.py +1442 -0
  11. iam_validator/checks/action_resource_matching.py +472 -0
  12. iam_validator/checks/action_validation.py +67 -0
  13. iam_validator/checks/condition_key_validation.py +88 -0
  14. iam_validator/checks/condition_type_mismatch.py +257 -0
  15. iam_validator/checks/full_wildcard.py +62 -0
  16. iam_validator/checks/mfa_condition_check.py +105 -0
  17. iam_validator/checks/policy_size.py +114 -0
  18. iam_validator/checks/policy_structure.py +556 -0
  19. iam_validator/checks/policy_type_validation.py +331 -0
  20. iam_validator/checks/principal_validation.py +708 -0
  21. iam_validator/checks/resource_validation.py +135 -0
  22. iam_validator/checks/sensitive_action.py +438 -0
  23. iam_validator/checks/service_wildcard.py +98 -0
  24. iam_validator/checks/set_operator_validation.py +153 -0
  25. iam_validator/checks/sid_uniqueness.py +146 -0
  26. iam_validator/checks/trust_policy_validation.py +509 -0
  27. iam_validator/checks/utils/__init__.py +17 -0
  28. iam_validator/checks/utils/action_parser.py +149 -0
  29. iam_validator/checks/utils/policy_level_checks.py +190 -0
  30. iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
  31. iam_validator/checks/utils/wildcard_expansion.py +86 -0
  32. iam_validator/checks/wildcard_action.py +58 -0
  33. iam_validator/checks/wildcard_resource.py +374 -0
  34. iam_validator/commands/__init__.py +31 -0
  35. iam_validator/commands/analyze.py +549 -0
  36. iam_validator/commands/base.py +48 -0
  37. iam_validator/commands/cache.py +393 -0
  38. iam_validator/commands/completion.py +471 -0
  39. iam_validator/commands/download_services.py +255 -0
  40. iam_validator/commands/post_to_pr.py +86 -0
  41. iam_validator/commands/query.py +485 -0
  42. iam_validator/commands/validate.py +830 -0
  43. iam_validator/core/__init__.py +13 -0
  44. iam_validator/core/access_analyzer.py +671 -0
  45. iam_validator/core/access_analyzer_report.py +640 -0
  46. iam_validator/core/aws_fetcher.py +29 -0
  47. iam_validator/core/aws_service/__init__.py +21 -0
  48. iam_validator/core/aws_service/cache.py +108 -0
  49. iam_validator/core/aws_service/client.py +205 -0
  50. iam_validator/core/aws_service/fetcher.py +641 -0
  51. iam_validator/core/aws_service/parsers.py +149 -0
  52. iam_validator/core/aws_service/patterns.py +51 -0
  53. iam_validator/core/aws_service/storage.py +291 -0
  54. iam_validator/core/aws_service/validators.py +380 -0
  55. iam_validator/core/check_registry.py +679 -0
  56. iam_validator/core/cli.py +134 -0
  57. iam_validator/core/codeowners.py +245 -0
  58. iam_validator/core/condition_validators.py +626 -0
  59. iam_validator/core/config/__init__.py +81 -0
  60. iam_validator/core/config/aws_api.py +35 -0
  61. iam_validator/core/config/aws_global_conditions.py +160 -0
  62. iam_validator/core/config/category_suggestions.py +181 -0
  63. iam_validator/core/config/check_documentation.py +390 -0
  64. iam_validator/core/config/condition_requirements.py +258 -0
  65. iam_validator/core/config/config_loader.py +670 -0
  66. iam_validator/core/config/defaults.py +739 -0
  67. iam_validator/core/config/principal_requirements.py +421 -0
  68. iam_validator/core/config/sensitive_actions.py +672 -0
  69. iam_validator/core/config/service_principals.py +132 -0
  70. iam_validator/core/config/wildcards.py +127 -0
  71. iam_validator/core/constants.py +149 -0
  72. iam_validator/core/diff_parser.py +325 -0
  73. iam_validator/core/finding_fingerprint.py +131 -0
  74. iam_validator/core/formatters/__init__.py +27 -0
  75. iam_validator/core/formatters/base.py +147 -0
  76. iam_validator/core/formatters/console.py +68 -0
  77. iam_validator/core/formatters/csv.py +171 -0
  78. iam_validator/core/formatters/enhanced.py +481 -0
  79. iam_validator/core/formatters/html.py +672 -0
  80. iam_validator/core/formatters/json.py +33 -0
  81. iam_validator/core/formatters/markdown.py +64 -0
  82. iam_validator/core/formatters/sarif.py +251 -0
  83. iam_validator/core/ignore_patterns.py +297 -0
  84. iam_validator/core/ignore_processor.py +309 -0
  85. iam_validator/core/ignored_findings.py +400 -0
  86. iam_validator/core/label_manager.py +197 -0
  87. iam_validator/core/models.py +404 -0
  88. iam_validator/core/policy_checks.py +220 -0
  89. iam_validator/core/policy_loader.py +785 -0
  90. iam_validator/core/pr_commenter.py +780 -0
  91. iam_validator/core/report.py +942 -0
  92. iam_validator/integrations/__init__.py +28 -0
  93. iam_validator/integrations/github_integration.py +1821 -0
  94. iam_validator/integrations/ms_teams.py +442 -0
  95. iam_validator/sdk/__init__.py +220 -0
  96. iam_validator/sdk/arn_matching.py +382 -0
  97. iam_validator/sdk/context.py +222 -0
  98. iam_validator/sdk/exceptions.py +48 -0
  99. iam_validator/sdk/helpers.py +177 -0
  100. iam_validator/sdk/policy_utils.py +451 -0
  101. iam_validator/sdk/query_utils.py +454 -0
  102. iam_validator/sdk/shortcuts.py +283 -0
  103. iam_validator/utils/__init__.py +35 -0
  104. iam_validator/utils/cache.py +105 -0
  105. iam_validator/utils/regex.py +205 -0
  106. iam_validator/utils/terminal.py +22 -0
@@ -0,0 +1,197 @@
1
+ """Label Manager for GitHub PR Labels based on Severity Findings.
2
+
3
+ This module manages GitHub PR labels based on IAM policy validation severity findings.
4
+ When validation finds issues with specific severities, it applies corresponding labels.
5
+ When those severities are not found, it removes the labels if present.
6
+ """
7
+
8
+ import logging
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from iam_validator.core.models import PolicyValidationResult, ValidationReport
13
+ from iam_validator.integrations.github_integration import GitHubIntegration
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class LabelManager:
19
+ """Manages GitHub PR labels based on severity findings."""
20
+
21
+ def __init__(
22
+ self,
23
+ github: "GitHubIntegration",
24
+ severity_labels: dict[str, str | list[str]] | None = None,
25
+ ):
26
+ """Initialize label manager.
27
+
28
+ Args:
29
+ github: GitHubIntegration instance for API calls
30
+ severity_labels: Mapping of severity levels to label name(s)
31
+ Supports both single labels and lists of labels per severity.
32
+ Examples:
33
+ - Single label per severity:
34
+ {"error": "iam-validity-error", "critical": "security-critical"}
35
+ - Multiple labels per severity:
36
+ {"error": ["iam-error", "needs-fix"], "critical": ["security-critical", "needs-security-review"]}
37
+ - Mixed:
38
+ {"error": "iam-validity-error", "critical": ["security-critical", "needs-review"]}
39
+ """
40
+ self.github = github
41
+ self.severity_labels = severity_labels or {}
42
+
43
+ def is_enabled(self) -> bool:
44
+ """Check if label management is enabled.
45
+
46
+ Returns:
47
+ True if severity_labels is configured and GitHub is configured
48
+ """
49
+ return bool(self.severity_labels) and self.github.is_configured()
50
+
51
+ def _get_severities_in_results(self, results: list["PolicyValidationResult"]) -> set[str]:
52
+ """Extract all severity levels found in validation results.
53
+
54
+ Args:
55
+ results: List of PolicyValidationResult objects
56
+
57
+ Returns:
58
+ Set of severity levels found (e.g., {"error", "critical", "high"})
59
+ """
60
+ severities = set()
61
+ for result in results:
62
+ for issue in result.issues:
63
+ severities.add(issue.severity)
64
+ return severities
65
+
66
+ def _get_severities_in_report(self, report: "ValidationReport") -> set[str]:
67
+ """Extract all severity levels found in validation report.
68
+
69
+ Args:
70
+ report: ValidationReport object
71
+
72
+ Returns:
73
+ Set of severity levels found (e.g., {"error", "critical", "high"})
74
+ """
75
+ return self._get_severities_in_results(report.results)
76
+
77
+ def _determine_labels_to_apply(self, found_severities: set[str]) -> set[str]:
78
+ """Determine which labels should be applied based on found severities.
79
+
80
+ Args:
81
+ found_severities: Set of severity levels found in validation
82
+
83
+ Returns:
84
+ Set of label names to apply
85
+ """
86
+ labels_to_apply = set()
87
+ for severity, labels in self.severity_labels.items():
88
+ if severity in found_severities:
89
+ # Support both single labels and lists of labels
90
+ if isinstance(labels, list):
91
+ labels_to_apply.update(labels)
92
+ else:
93
+ labels_to_apply.add(labels)
94
+ return labels_to_apply
95
+
96
+ def _determine_labels_to_remove(self, found_severities: set[str]) -> set[str]:
97
+ """Determine which labels should be removed based on missing severities.
98
+
99
+ Args:
100
+ found_severities: Set of severity levels found in validation
101
+
102
+ Returns:
103
+ Set of label names to remove
104
+ """
105
+ labels_to_remove = set()
106
+ for severity, labels in self.severity_labels.items():
107
+ if severity not in found_severities:
108
+ # Support both single labels and lists of labels
109
+ if isinstance(labels, list):
110
+ labels_to_remove.update(labels)
111
+ else:
112
+ labels_to_remove.add(labels)
113
+ return labels_to_remove
114
+
115
+ async def manage_labels_from_results(
116
+ self, results: list["PolicyValidationResult"]
117
+ ) -> tuple[bool, int, int]:
118
+ """Manage PR labels based on validation results.
119
+
120
+ This method will:
121
+ 1. Determine which severity levels are present in the results
122
+ 2. Add labels for severities that are found
123
+ 3. Remove labels for severities that are not found
124
+
125
+ Args:
126
+ results: List of PolicyValidationResult objects
127
+
128
+ Returns:
129
+ Tuple of (success, labels_added, labels_removed)
130
+ """
131
+ if not self.is_enabled():
132
+ logger.debug("Label management not enabled (no severity_labels configured)")
133
+ return (True, 0, 0)
134
+
135
+ # Get all severities found in results
136
+ found_severities = self._get_severities_in_results(results)
137
+ logger.debug(f"Found severities in results: {found_severities}")
138
+
139
+ # Determine which labels to apply/remove
140
+ labels_to_apply = self._determine_labels_to_apply(found_severities)
141
+ labels_to_remove = self._determine_labels_to_remove(found_severities)
142
+
143
+ logger.debug(f"Labels to apply: {labels_to_apply}")
144
+ logger.debug(f"Labels to remove: {labels_to_remove}")
145
+
146
+ # Get current labels on PR
147
+ current_labels = set(await self.github.get_labels())
148
+ logger.debug(f"Current PR labels: {current_labels}")
149
+
150
+ # Filter: only add labels that aren't already present
151
+ labels_to_add = labels_to_apply - current_labels
152
+
153
+ # Filter: only remove labels that are currently present
154
+ labels_to_actually_remove = labels_to_remove & current_labels
155
+
156
+ success = True
157
+ added_count = 0
158
+ removed_count = 0
159
+
160
+ # Add new labels
161
+ if labels_to_add:
162
+ logger.info(f"Adding labels to PR: {labels_to_add}")
163
+ if await self.github.add_labels(list(labels_to_add)):
164
+ added_count = len(labels_to_add)
165
+ else:
166
+ logger.error("Failed to add labels to PR")
167
+ success = False
168
+
169
+ # Remove old labels
170
+ for label in labels_to_actually_remove:
171
+ logger.info(f"Removing label from PR: {label}")
172
+ if await self.github.remove_label(label):
173
+ removed_count += 1
174
+ else:
175
+ logger.error(f"Failed to remove label: {label}")
176
+ success = False
177
+
178
+ if added_count > 0 or removed_count > 0:
179
+ logger.info(f"Label management complete: added {added_count}, removed {removed_count}")
180
+ else:
181
+ logger.debug("No label changes needed")
182
+
183
+ return (success, added_count, removed_count)
184
+
185
+ async def manage_labels_from_report(self, report: "ValidationReport") -> tuple[bool, int, int]:
186
+ """Manage PR labels based on validation report.
187
+
188
+ This is a convenience method that extracts results from the report
189
+ and calls manage_labels_from_results().
190
+
191
+ Args:
192
+ report: ValidationReport object
193
+
194
+ Returns:
195
+ Tuple of (success, labels_added, labels_removed)
196
+ """
197
+ return await self.manage_labels_from_results(report.results)
@@ -0,0 +1,404 @@
1
+ """Data models for AWS IAM policy validation.
2
+
3
+ This module defines Pydantic models for AWS service information,
4
+ IAM policies, and validation results.
5
+ """
6
+
7
+ from typing import Any, ClassVar, Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ from iam_validator.core import constants
12
+
13
+ # Policy Type Constants
14
+ PolicyType = Literal[
15
+ "IDENTITY_POLICY",
16
+ "RESOURCE_POLICY",
17
+ "TRUST_POLICY", # Trust policies (role assumption policies - subtype of resource policies)
18
+ "SERVICE_CONTROL_POLICY",
19
+ "RESOURCE_CONTROL_POLICY",
20
+ ]
21
+
22
+
23
+ # AWS Service Reference Models
24
+ class ServiceInfo(BaseModel):
25
+ """Basic information about an AWS service."""
26
+
27
+ service: str
28
+ url: str
29
+
30
+
31
+ class ActionDetail(BaseModel):
32
+ """Details about an AWS IAM action."""
33
+
34
+ model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
35
+
36
+ name: str = Field(alias="Name")
37
+ action_condition_keys: list[str] | None = Field(
38
+ default_factory=list, alias="ActionConditionKeys"
39
+ )
40
+ resources: list[dict[str, Any]] | None = Field(default_factory=list, alias="Resources")
41
+ annotations: dict[str, Any] | None = Field(default=None, alias="Annotations")
42
+ supported_by: dict[str, Any] | None = Field(default=None, alias="SupportedBy")
43
+
44
+
45
+ class ResourceType(BaseModel):
46
+ """Details about an AWS resource type."""
47
+
48
+ model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
49
+
50
+ name: str = Field(alias="Name")
51
+ arn_formats: list[str] | None = Field(default=None, alias="ARNFormats")
52
+ condition_keys: list[str] | None = Field(default_factory=list, alias="ConditionKeys")
53
+
54
+ @property
55
+ def arn_pattern(self) -> str | None:
56
+ """
57
+ Get the first ARN format for backwards compatibility.
58
+
59
+ AWS provides ARN formats as an array (ARNFormats), but most code
60
+ just needs a single pattern. This property returns the first one.
61
+
62
+ Returns:
63
+ First ARN format string, or None if no formats are defined
64
+ """
65
+ return self.arn_formats[0] if self.arn_formats else None
66
+
67
+
68
+ class ConditionKey(BaseModel):
69
+ """Details about an AWS condition key."""
70
+
71
+ model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
72
+
73
+ name: str = Field(alias="Name")
74
+ description: str | None = Field(default=None, alias="Description")
75
+ types: list[str] | None = Field(default_factory=list, alias="Types")
76
+
77
+
78
+ class ServiceDetail(BaseModel):
79
+ """Detailed information about an AWS service."""
80
+
81
+ model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
82
+
83
+ name: str = Field(alias="Name")
84
+ prefix: str | None = None # Not always present in API response
85
+ actions: dict[str, ActionDetail] = Field(default_factory=dict)
86
+ resources: dict[str, ResourceType] = Field(default_factory=dict)
87
+ condition_keys: dict[str, ConditionKey] = Field(default_factory=dict)
88
+ version: str | None = Field(default=None, alias="Version")
89
+
90
+ # Raw API data
91
+ actions_list: list[ActionDetail] = Field(default_factory=list, alias="Actions")
92
+ resources_list: list[ResourceType] = Field(default_factory=list, alias="Resources")
93
+ condition_keys_list: list[ConditionKey] = Field(default_factory=list, alias="ConditionKeys")
94
+
95
+ def model_post_init(self, __context: Any, /) -> None:
96
+ """Convert lists to dictionaries for easier lookup."""
97
+ # Convert actions list to dict
98
+ self.actions = {action.name: action for action in self.actions_list}
99
+ # Convert resources list to dict
100
+ self.resources = {resource.name: resource for resource in self.resources_list}
101
+ # Convert condition keys list to dict
102
+ self.condition_keys = {ck.name: ck for ck in self.condition_keys_list}
103
+
104
+
105
+ # IAM Policy Models
106
+ class Statement(BaseModel):
107
+ """IAM policy statement."""
108
+
109
+ model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="allow")
110
+
111
+ sid: str | None = Field(default=None, alias="Sid")
112
+ effect: str | None = Field(default=None, alias="Effect")
113
+ action: list[str] | str | None = Field(default=None, alias="Action")
114
+ not_action: list[str] | str | None = Field(default=None, alias="NotAction")
115
+ resource: list[str] | str | None = Field(default=None, alias="Resource")
116
+ not_resource: list[str] | str | None = Field(default=None, alias="NotResource")
117
+ condition: dict[str, dict[str, Any]] | None = Field(default=None, alias="Condition")
118
+ principal: dict[str, Any] | str | None = Field(default=None, alias="Principal")
119
+ not_principal: dict[str, Any] | str | None = Field(default=None, alias="NotPrincipal")
120
+ # Line number metadata (populated during parsing)
121
+ line_number: int | None = Field(default=None, exclude=True)
122
+
123
+ def get_actions(self) -> list[str]:
124
+ """Get list of actions, handling both string and list formats."""
125
+ if self.action is None:
126
+ return []
127
+ return [self.action] if isinstance(self.action, str) else self.action
128
+
129
+ def get_resources(self) -> list[str]:
130
+ """Get list of resources, handling both string and list formats."""
131
+ if self.resource is None:
132
+ return []
133
+ return [self.resource] if isinstance(self.resource, str) else self.resource
134
+
135
+
136
+ class IAMPolicy(BaseModel):
137
+ """IAM policy document."""
138
+
139
+ model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, extra="allow")
140
+
141
+ version: str | None = Field(default=None, alias="Version")
142
+ statement: list[Statement] | None = Field(default=None, alias="Statement")
143
+ id: str | None = Field(default=None, alias="Id")
144
+
145
+
146
+ # Validation Result Models
147
+ class ValidationIssue(BaseModel):
148
+ """A single validation issue found in a policy.
149
+
150
+ Severity Levels:
151
+ - IAM Validity: "error", "warning", "info"
152
+ (for issues that make the policy invalid according to AWS IAM rules)
153
+ - Security: "critical", "high", "medium", "low"
154
+ (for security best practices and configuration issues)
155
+ """
156
+
157
+ severity: str # "error", "warning", "info" OR "critical", "high", "medium", "low"
158
+ statement_sid: str | None = None
159
+ statement_index: int
160
+ issue_type: str # "invalid_action", "invalid_condition_key", "invalid_resource", etc.
161
+ message: str
162
+ action: str | None = None
163
+ resource: str | None = None
164
+ condition_key: str | None = None
165
+ suggestion: str | None = None
166
+ example: str | None = None # Code example (JSON/YAML) - formatted separately for GitHub
167
+ line_number: int | None = None # Line number in the policy file (if available)
168
+ check_id: str | None = (
169
+ None # Check that triggered this issue (e.g., "policy_size", "sensitive_action")
170
+ )
171
+ # Field that caused the issue (for precise line detection in PR comments)
172
+ # Values: "action", "resource", "condition", "principal", "effect", "sid"
173
+ field_name: str | None = None
174
+
175
+ # Enhanced finding quality fields (Phase 3)
176
+ # Explains why this issue is a security risk or compliance concern
177
+ risk_explanation: str | None = None
178
+ # Link to relevant AWS documentation or org-specific runbook
179
+ documentation_url: str | None = None
180
+ # Step-by-step remediation guidance
181
+ remediation_steps: list[str] | None = None
182
+
183
+ # Severity level constants (ClassVar to avoid Pydantic treating them as fields)
184
+ VALID_SEVERITIES: ClassVar[frozenset[str]] = frozenset(
185
+ [
186
+ "error",
187
+ "warning",
188
+ "info", # IAM validity severities
189
+ "critical",
190
+ "high",
191
+ "medium",
192
+ "low", # Security severities
193
+ ]
194
+ )
195
+
196
+ # Severity ordering for fail_on_severity (higher value = more severe)
197
+ SEVERITY_RANK: ClassVar[dict[str, int]] = {
198
+ "error": 100, # IAM validity errors (highest)
199
+ "critical": 90, # Critical security issues
200
+ "high": 70, # High security issues
201
+ "warning": 50, # IAM validity warnings
202
+ "medium": 40, # Medium security issues
203
+ "low": 20, # Low security issues
204
+ "info": 10, # Informational (lowest)
205
+ }
206
+
207
+ def get_severity_rank(self) -> int:
208
+ """Get the numeric rank of this issue's severity (higher = more severe)."""
209
+ return self.SEVERITY_RANK.get(self.severity, 0)
210
+
211
+ def is_security_severity(self) -> bool:
212
+ """Check if this issue uses security severity levels (critical/high/medium/low)."""
213
+ return self.severity in {"critical", "high", "medium", "low"}
214
+
215
+ def is_validity_severity(self) -> bool:
216
+ """Check if this issue uses IAM validity severity levels (error/warning/info)."""
217
+ return self.severity in {"error", "warning", "info"}
218
+
219
+ def to_pr_comment(self, include_identifier: bool = True, file_path: str = "") -> str:
220
+ """Format issue as a PR comment.
221
+
222
+ Args:
223
+ include_identifier: Whether to include bot identifier (for cleanup)
224
+ file_path: Relative path to the policy file (for finding ID)
225
+
226
+ Returns:
227
+ Formatted comment string
228
+ """
229
+ severity_emoji = {
230
+ # IAM validity severities
231
+ "error": "❌",
232
+ "warning": "⚠️",
233
+ "info": "ℹ️",
234
+ # Security severities
235
+ "critical": "🔴",
236
+ "high": "🟠",
237
+ "medium": "🟡",
238
+ "low": "🔵",
239
+ }
240
+ emoji = severity_emoji.get(self.severity, "•")
241
+
242
+ parts = []
243
+
244
+ # Add identifier for bot comment cleanup (HTML comment - not visible to users)
245
+ if include_identifier:
246
+ parts.append(f"{constants.REVIEW_IDENTIFIER}\n")
247
+ parts.append(f"{constants.BOT_IDENTIFIER}\n")
248
+ # Add issue type identifier to allow multiple issues at same line
249
+ parts.append(f"<!-- issue-type: {self.issue_type} -->\n")
250
+ # Add finding ID for ignore tracking
251
+ if file_path:
252
+ from iam_validator.core.finding_fingerprint import compute_finding_hash
253
+
254
+ finding_hash = compute_finding_hash(
255
+ file_path=file_path,
256
+ check_id=self.check_id,
257
+ issue_type=self.issue_type,
258
+ statement_sid=self.statement_sid,
259
+ statement_index=self.statement_index,
260
+ action=self.action,
261
+ resource=self.resource,
262
+ condition_key=self.condition_key,
263
+ )
264
+ parts.append(f"<!-- finding-id: {finding_hash} -->\n")
265
+
266
+ # Build statement context for better navigation
267
+ statement_context = f"Statement[{self.statement_index}]"
268
+ if self.statement_sid:
269
+ statement_context = f"`{self.statement_sid}` ({statement_context})"
270
+
271
+ # Main issue header with statement context
272
+ parts.append(f"{emoji} **{self.severity.upper()}** in **{statement_context}**")
273
+ parts.append("")
274
+
275
+ # Show message immediately (not collapsed)
276
+ parts.append(self.message)
277
+
278
+ # Add risk explanation if present (shown prominently)
279
+ if self.risk_explanation:
280
+ parts.append("")
281
+ parts.append(f"> **Why this matters:** {self.risk_explanation}")
282
+
283
+ # Put additional details in collapsible section if there are any
284
+ has_details = bool(
285
+ self.action
286
+ or self.resource
287
+ or self.condition_key
288
+ or self.suggestion
289
+ or self.example
290
+ or self.remediation_steps
291
+ )
292
+
293
+ if has_details:
294
+ parts.append("")
295
+ parts.append("<details>")
296
+ parts.append("<summary>📋 <b>View Details</b></summary>")
297
+ parts.append("")
298
+ parts.append("") # Extra spacing after opening
299
+
300
+ # Add affected fields section if any are present
301
+ if self.action or self.resource or self.condition_key:
302
+ parts.append("**Affected Fields:**")
303
+ if self.action:
304
+ parts.append(f" - Action: `{self.action}`")
305
+ if self.resource:
306
+ parts.append(f" - Resource: `{self.resource}`")
307
+ if self.condition_key:
308
+ parts.append(f" - Condition Key: `{self.condition_key}`")
309
+ parts.append("")
310
+
311
+ # Add remediation steps if present
312
+ if self.remediation_steps:
313
+ parts.append("**🔧 How to Fix:**")
314
+ for i, step in enumerate(self.remediation_steps, 1):
315
+ parts.append(f" {i}. {step}")
316
+ parts.append("")
317
+
318
+ # Add suggestion if present
319
+ if self.suggestion:
320
+ parts.append("**💡 Suggested Fix:**")
321
+ parts.append("")
322
+ parts.append(self.suggestion)
323
+ parts.append("")
324
+
325
+ # Add example if present (formatted as JSON code block for GitHub)
326
+ if self.example:
327
+ parts.append("**Example:**")
328
+ parts.append("```json")
329
+ parts.append(self.example)
330
+ parts.append("```")
331
+
332
+ parts.append("")
333
+ parts.append("</details>")
334
+
335
+ # Add check ID and documentation link at the bottom
336
+ footer_parts = []
337
+ if self.check_id:
338
+ footer_parts.append(f"*Check: `{self.check_id}`*")
339
+ if self.documentation_url:
340
+ footer_parts.append(f"[📖 Documentation]({self.documentation_url})")
341
+
342
+ if footer_parts:
343
+ parts.append("")
344
+ parts.append("---")
345
+ parts.append(" | ".join(footer_parts))
346
+
347
+ return "\n".join(parts)
348
+
349
+
350
+ class PolicyValidationResult(BaseModel):
351
+ """Result of validating a single IAM policy."""
352
+
353
+ policy_file: str
354
+ is_valid: bool
355
+ policy_type: PolicyType = "IDENTITY_POLICY"
356
+ issues: list[ValidationIssue] = Field(default_factory=list)
357
+ actions_checked: int = 0
358
+ condition_keys_checked: int = 0
359
+ resources_checked: int = 0
360
+
361
+
362
+ class ValidationReport(BaseModel):
363
+ """Complete validation report for all policies."""
364
+
365
+ total_policies: int
366
+ valid_policies: int
367
+ invalid_policies: int # Policies with IAM validity issues (error/warning)
368
+ policies_with_security_issues: int = (
369
+ 0 # Policies with security findings (critical/high/medium/low)
370
+ )
371
+ total_issues: int
372
+ validity_issues: int = 0 # Count of IAM validity issues (error/warning/info)
373
+ security_issues: int = 0 # Count of security issues (critical/high/medium/low)
374
+ results: list[PolicyValidationResult] = Field(default_factory=list)
375
+ parsing_errors: list[tuple[str, str]] = Field(
376
+ default_factory=list
377
+ ) # (file_path, error_message)
378
+
379
+ def get_summary(self) -> str:
380
+ """Generate a human-readable summary."""
381
+ parts = []
382
+ parts.append(f"Validated {self.total_policies} policies:")
383
+
384
+ # Always show valid/invalid counts
385
+ parts.append(f"{self.valid_policies} valid")
386
+
387
+ if self.invalid_policies > 0:
388
+ parts.append(f"{self.invalid_policies} invalid (IAM validity)")
389
+
390
+ if self.policies_with_security_issues > 0:
391
+ parts.append(f"{self.policies_with_security_issues} with security findings")
392
+
393
+ parts.append(f"{self.total_issues} total issues")
394
+
395
+ # Show breakdown if there are issues
396
+ if self.total_issues > 0 and (self.validity_issues > 0 or self.security_issues > 0):
397
+ breakdown_parts = []
398
+ if self.validity_issues > 0:
399
+ breakdown_parts.append(f"{self.validity_issues} validity")
400
+ if self.security_issues > 0:
401
+ breakdown_parts.append(f"{self.security_issues} security")
402
+ parts.append(f"({', '.join(breakdown_parts)})")
403
+
404
+ return " ".join(parts)