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.
- iam_policy_validator-1.14.0.dist-info/METADATA +782 -0
- iam_policy_validator-1.14.0.dist-info/RECORD +106 -0
- iam_policy_validator-1.14.0.dist-info/WHEEL +4 -0
- iam_policy_validator-1.14.0.dist-info/entry_points.txt +2 -0
- iam_policy_validator-1.14.0.dist-info/licenses/LICENSE +21 -0
- iam_validator/__init__.py +27 -0
- iam_validator/__main__.py +11 -0
- iam_validator/__version__.py +9 -0
- iam_validator/checks/__init__.py +45 -0
- iam_validator/checks/action_condition_enforcement.py +1442 -0
- iam_validator/checks/action_resource_matching.py +472 -0
- iam_validator/checks/action_validation.py +67 -0
- iam_validator/checks/condition_key_validation.py +88 -0
- iam_validator/checks/condition_type_mismatch.py +257 -0
- iam_validator/checks/full_wildcard.py +62 -0
- iam_validator/checks/mfa_condition_check.py +105 -0
- iam_validator/checks/policy_size.py +114 -0
- iam_validator/checks/policy_structure.py +556 -0
- iam_validator/checks/policy_type_validation.py +331 -0
- iam_validator/checks/principal_validation.py +708 -0
- iam_validator/checks/resource_validation.py +135 -0
- iam_validator/checks/sensitive_action.py +438 -0
- iam_validator/checks/service_wildcard.py +98 -0
- iam_validator/checks/set_operator_validation.py +153 -0
- iam_validator/checks/sid_uniqueness.py +146 -0
- iam_validator/checks/trust_policy_validation.py +509 -0
- iam_validator/checks/utils/__init__.py +17 -0
- iam_validator/checks/utils/action_parser.py +149 -0
- iam_validator/checks/utils/policy_level_checks.py +190 -0
- iam_validator/checks/utils/sensitive_action_matcher.py +293 -0
- iam_validator/checks/utils/wildcard_expansion.py +86 -0
- iam_validator/checks/wildcard_action.py +58 -0
- iam_validator/checks/wildcard_resource.py +374 -0
- iam_validator/commands/__init__.py +31 -0
- iam_validator/commands/analyze.py +549 -0
- iam_validator/commands/base.py +48 -0
- iam_validator/commands/cache.py +393 -0
- iam_validator/commands/completion.py +471 -0
- iam_validator/commands/download_services.py +255 -0
- iam_validator/commands/post_to_pr.py +86 -0
- iam_validator/commands/query.py +485 -0
- iam_validator/commands/validate.py +830 -0
- iam_validator/core/__init__.py +13 -0
- iam_validator/core/access_analyzer.py +671 -0
- iam_validator/core/access_analyzer_report.py +640 -0
- iam_validator/core/aws_fetcher.py +29 -0
- iam_validator/core/aws_service/__init__.py +21 -0
- iam_validator/core/aws_service/cache.py +108 -0
- iam_validator/core/aws_service/client.py +205 -0
- iam_validator/core/aws_service/fetcher.py +641 -0
- iam_validator/core/aws_service/parsers.py +149 -0
- iam_validator/core/aws_service/patterns.py +51 -0
- iam_validator/core/aws_service/storage.py +291 -0
- iam_validator/core/aws_service/validators.py +380 -0
- iam_validator/core/check_registry.py +679 -0
- iam_validator/core/cli.py +134 -0
- iam_validator/core/codeowners.py +245 -0
- iam_validator/core/condition_validators.py +626 -0
- iam_validator/core/config/__init__.py +81 -0
- iam_validator/core/config/aws_api.py +35 -0
- iam_validator/core/config/aws_global_conditions.py +160 -0
- iam_validator/core/config/category_suggestions.py +181 -0
- iam_validator/core/config/check_documentation.py +390 -0
- iam_validator/core/config/condition_requirements.py +258 -0
- iam_validator/core/config/config_loader.py +670 -0
- iam_validator/core/config/defaults.py +739 -0
- iam_validator/core/config/principal_requirements.py +421 -0
- iam_validator/core/config/sensitive_actions.py +672 -0
- iam_validator/core/config/service_principals.py +132 -0
- iam_validator/core/config/wildcards.py +127 -0
- iam_validator/core/constants.py +149 -0
- iam_validator/core/diff_parser.py +325 -0
- iam_validator/core/finding_fingerprint.py +131 -0
- iam_validator/core/formatters/__init__.py +27 -0
- iam_validator/core/formatters/base.py +147 -0
- iam_validator/core/formatters/console.py +68 -0
- iam_validator/core/formatters/csv.py +171 -0
- iam_validator/core/formatters/enhanced.py +481 -0
- iam_validator/core/formatters/html.py +672 -0
- iam_validator/core/formatters/json.py +33 -0
- iam_validator/core/formatters/markdown.py +64 -0
- iam_validator/core/formatters/sarif.py +251 -0
- iam_validator/core/ignore_patterns.py +297 -0
- iam_validator/core/ignore_processor.py +309 -0
- iam_validator/core/ignored_findings.py +400 -0
- iam_validator/core/label_manager.py +197 -0
- iam_validator/core/models.py +404 -0
- iam_validator/core/policy_checks.py +220 -0
- iam_validator/core/policy_loader.py +785 -0
- iam_validator/core/pr_commenter.py +780 -0
- iam_validator/core/report.py +942 -0
- iam_validator/integrations/__init__.py +28 -0
- iam_validator/integrations/github_integration.py +1821 -0
- iam_validator/integrations/ms_teams.py +442 -0
- iam_validator/sdk/__init__.py +220 -0
- iam_validator/sdk/arn_matching.py +382 -0
- iam_validator/sdk/context.py +222 -0
- iam_validator/sdk/exceptions.py +48 -0
- iam_validator/sdk/helpers.py +177 -0
- iam_validator/sdk/policy_utils.py +451 -0
- iam_validator/sdk/query_utils.py +454 -0
- iam_validator/sdk/shortcuts.py +283 -0
- iam_validator/utils/__init__.py +35 -0
- iam_validator/utils/cache.py +105 -0
- iam_validator/utils/regex.py +205 -0
- 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)
|