iam-policy-validator 1.14.6__py3-none-any.whl → 1.15.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/METADATA +34 -23
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/RECORD +42 -29
- iam_policy_validator-1.15.0.dist-info/entry_points.txt +4 -0
- iam_validator/__version__.py +1 -1
- iam_validator/checks/__init__.py +2 -0
- iam_validator/checks/action_validation.py +91 -27
- iam_validator/checks/not_action_not_resource.py +163 -0
- iam_validator/checks/resource_validation.py +132 -81
- iam_validator/checks/wildcard_resource.py +136 -6
- iam_validator/commands/__init__.py +3 -0
- iam_validator/commands/cache.py +66 -24
- iam_validator/commands/completion.py +94 -15
- iam_validator/commands/mcp.py +210 -0
- iam_validator/commands/query.py +489 -65
- iam_validator/core/aws_service/__init__.py +5 -1
- iam_validator/core/aws_service/cache.py +20 -0
- iam_validator/core/aws_service/fetcher.py +180 -11
- iam_validator/core/aws_service/storage.py +14 -6
- iam_validator/core/aws_service/validators.py +32 -41
- iam_validator/core/check_registry.py +100 -35
- iam_validator/core/config/aws_global_conditions.py +13 -0
- iam_validator/core/config/check_documentation.py +104 -51
- iam_validator/core/config/config_loader.py +39 -3
- iam_validator/core/config/defaults.py +6 -0
- iam_validator/core/constants.py +11 -4
- iam_validator/core/models.py +39 -14
- iam_validator/mcp/__init__.py +162 -0
- iam_validator/mcp/models.py +118 -0
- iam_validator/mcp/server.py +2928 -0
- iam_validator/mcp/session_config.py +319 -0
- iam_validator/mcp/templates/__init__.py +79 -0
- iam_validator/mcp/templates/builtin.py +856 -0
- iam_validator/mcp/tools/__init__.py +72 -0
- iam_validator/mcp/tools/generation.py +888 -0
- iam_validator/mcp/tools/org_config_tools.py +263 -0
- iam_validator/mcp/tools/query.py +395 -0
- iam_validator/mcp/tools/validation.py +376 -0
- iam_validator/sdk/__init__.py +64 -63
- iam_validator/sdk/context.py +3 -2
- iam_validator/sdk/policy_utils.py +31 -5
- iam_policy_validator-1.14.6.dist-info/entry_points.txt +0 -2
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/WHEEL +0 -0
- {iam_policy_validator-1.14.6.dist-info → iam_policy_validator-1.15.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
"""Policy generation tools for MCP server.
|
|
2
|
+
|
|
3
|
+
This module provides MCP tools for generating IAM policies from templates,
|
|
4
|
+
explicit actions, and natural language descriptions. All generated policies
|
|
5
|
+
are validated and optionally enriched with security conditions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import functools
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from iam_validator.core.aws_service import AWSServiceFetcher
|
|
14
|
+
from iam_validator.core.config.category_suggestions import DEFAULT_CATEGORY_SUGGESTIONS
|
|
15
|
+
from iam_validator.core.config.check_documentation import CheckDocumentationRegistry
|
|
16
|
+
from iam_validator.core.config.sensitive_actions import SENSITIVE_ACTION_CATEGORIES
|
|
17
|
+
from iam_validator.mcp.models import GenerationResult, ValidationResult
|
|
18
|
+
from iam_validator.sdk import query_actions
|
|
19
|
+
|
|
20
|
+
# Pre-build action→category index at module load time for O(1) lookups
|
|
21
|
+
_ACTION_CATEGORY_INDEX: dict[str, str] = {}
|
|
22
|
+
for _category_id, _category_data in SENSITIVE_ACTION_CATEGORIES.items():
|
|
23
|
+
for _action in _category_data["actions"]:
|
|
24
|
+
_ACTION_CATEGORY_INDEX[_action] = _category_id
|
|
25
|
+
|
|
26
|
+
# Pre-compiled regex pattern cache for condition requirement matching
|
|
27
|
+
_COMPILED_PATTERNS: dict[str, re.Pattern[str]] = {}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_category_for_action(action: str) -> str | None:
|
|
31
|
+
"""Get the category for a sensitive action using pre-built index.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
action: AWS action name to check
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Category name if action is sensitive, None otherwise
|
|
38
|
+
"""
|
|
39
|
+
return _ACTION_CATEGORY_INDEX.get(action)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_compiled_pattern(pattern: str) -> re.Pattern[str]:
|
|
43
|
+
"""Get a compiled regex pattern, using cache for efficiency."""
|
|
44
|
+
if pattern not in _COMPILED_PATTERNS:
|
|
45
|
+
_COMPILED_PATTERNS[pattern] = re.compile(pattern)
|
|
46
|
+
return _COMPILED_PATTERNS[pattern]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_auto_conditions(actions: list[str]) -> tuple[dict[str, Any], list[str]]:
|
|
50
|
+
"""Get auto-applied conditions based on action requirements.
|
|
51
|
+
|
|
52
|
+
Analyzes the actions list against CONDITION_REQUIREMENTS and returns
|
|
53
|
+
conditions that should be automatically applied along with explanatory notes.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
actions: List of AWS actions to analyze
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Tuple of (conditions_dict, notes_list) where:
|
|
60
|
+
- conditions_dict: Dictionary of conditions to apply
|
|
61
|
+
- notes_list: List of strings explaining what was auto-added
|
|
62
|
+
"""
|
|
63
|
+
from iam_validator.core.config.condition_requirements import CONDITION_REQUIREMENTS
|
|
64
|
+
|
|
65
|
+
auto_conditions: dict[str, Any] = {}
|
|
66
|
+
notes: list[str] = []
|
|
67
|
+
|
|
68
|
+
for action in actions:
|
|
69
|
+
for requirement in CONDITION_REQUIREMENTS:
|
|
70
|
+
# Check if this requirement applies to this action
|
|
71
|
+
action_matches = False
|
|
72
|
+
|
|
73
|
+
# Check direct action match
|
|
74
|
+
if "actions" in requirement and action in requirement["actions"]:
|
|
75
|
+
action_matches = True
|
|
76
|
+
|
|
77
|
+
# Check pattern match using pre-compiled regex
|
|
78
|
+
if not action_matches and "action_patterns" in requirement:
|
|
79
|
+
for pattern in requirement["action_patterns"]:
|
|
80
|
+
if _get_compiled_pattern(pattern).match(action):
|
|
81
|
+
action_matches = True
|
|
82
|
+
break
|
|
83
|
+
|
|
84
|
+
if not action_matches:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# This requirement applies - extract conditions
|
|
88
|
+
required_conditions = requirement.get("required_conditions", [])
|
|
89
|
+
|
|
90
|
+
# Handle list of conditions (simple case)
|
|
91
|
+
if isinstance(required_conditions, list):
|
|
92
|
+
for cond in required_conditions:
|
|
93
|
+
condition_key = cond.get("condition_key")
|
|
94
|
+
expected_value = cond.get("expected_value")
|
|
95
|
+
description = cond.get("description", "")
|
|
96
|
+
|
|
97
|
+
if condition_key:
|
|
98
|
+
if expected_value is not None:
|
|
99
|
+
# We have a specific value - auto-add the condition
|
|
100
|
+
# Determine the operator based on value type
|
|
101
|
+
if isinstance(expected_value, bool):
|
|
102
|
+
operator = "Bool"
|
|
103
|
+
value = "true" if expected_value else "false"
|
|
104
|
+
elif isinstance(expected_value, str):
|
|
105
|
+
if expected_value.startswith("${"):
|
|
106
|
+
# Policy variable - use StringEquals
|
|
107
|
+
operator = "StringEquals"
|
|
108
|
+
value = expected_value
|
|
109
|
+
else:
|
|
110
|
+
operator = "StringEquals"
|
|
111
|
+
value = expected_value
|
|
112
|
+
else:
|
|
113
|
+
# Default to StringEquals for other types
|
|
114
|
+
operator = "StringEquals"
|
|
115
|
+
value = str(expected_value)
|
|
116
|
+
|
|
117
|
+
# Add to auto_conditions
|
|
118
|
+
if operator not in auto_conditions:
|
|
119
|
+
auto_conditions[operator] = {}
|
|
120
|
+
auto_conditions[operator][condition_key] = value
|
|
121
|
+
|
|
122
|
+
# Add note
|
|
123
|
+
note_desc = (
|
|
124
|
+
description if description else f"Required for {condition_key}"
|
|
125
|
+
)
|
|
126
|
+
notes.append(f"Auto-added {condition_key} for {action}: {note_desc}")
|
|
127
|
+
else:
|
|
128
|
+
# No expected_value - add recommendation note only
|
|
129
|
+
note_desc = (
|
|
130
|
+
description if description else f"Consider adding {condition_key}"
|
|
131
|
+
)
|
|
132
|
+
notes.append(f"Recommendation for {action}: {note_desc}")
|
|
133
|
+
|
|
134
|
+
# Handle complex conditions with any_of/none_of
|
|
135
|
+
elif isinstance(required_conditions, dict):
|
|
136
|
+
# For any_of, we apply the first option (most common pattern)
|
|
137
|
+
if "any_of" in required_conditions:
|
|
138
|
+
options = required_conditions["any_of"]
|
|
139
|
+
if options:
|
|
140
|
+
# Apply the first option (typically the strongest control)
|
|
141
|
+
first_option = options[0]
|
|
142
|
+
condition_key = first_option.get("condition_key")
|
|
143
|
+
expected_value = first_option.get("expected_value")
|
|
144
|
+
description = first_option.get("description", "")
|
|
145
|
+
|
|
146
|
+
if condition_key and expected_value is not None:
|
|
147
|
+
# Determine operator
|
|
148
|
+
if isinstance(expected_value, bool):
|
|
149
|
+
operator = "Bool"
|
|
150
|
+
value = "true" if expected_value else "false"
|
|
151
|
+
elif isinstance(expected_value, str):
|
|
152
|
+
operator = "StringEquals"
|
|
153
|
+
value = expected_value
|
|
154
|
+
else:
|
|
155
|
+
operator = "StringEquals"
|
|
156
|
+
value = str(expected_value)
|
|
157
|
+
|
|
158
|
+
# Add to auto_conditions
|
|
159
|
+
if operator not in auto_conditions:
|
|
160
|
+
auto_conditions[operator] = {}
|
|
161
|
+
auto_conditions[operator][condition_key] = value
|
|
162
|
+
|
|
163
|
+
# Add note with "one of" context
|
|
164
|
+
notes.append(
|
|
165
|
+
f"Auto-added {condition_key} for {action} (one of {len(options)} options): {description}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# For none_of, we skip auto-adding (validation will catch violations)
|
|
169
|
+
# These are negative conditions that prevent bad configs
|
|
170
|
+
|
|
171
|
+
return auto_conditions, notes
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def generate_policy_from_template(
|
|
175
|
+
template_name: str,
|
|
176
|
+
variables: dict[str, str],
|
|
177
|
+
config_path: str | None = None,
|
|
178
|
+
) -> GenerationResult:
|
|
179
|
+
"""Generate an IAM policy from a built-in template.
|
|
180
|
+
|
|
181
|
+
This tool loads a pre-defined policy template, substitutes the provided
|
|
182
|
+
variables, and validates the generated policy using the IAM validator's
|
|
183
|
+
built-in checks. Any security issues are reported through validation results.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
template_name: Name of the template to use. Available templates:
|
|
187
|
+
- s3-read-only: S3 bucket read-only access
|
|
188
|
+
- s3-read-write: S3 bucket read-write access
|
|
189
|
+
- lambda-basic-execution: Basic Lambda execution role
|
|
190
|
+
- lambda-s3-trigger: Lambda with S3 event trigger permissions
|
|
191
|
+
- dynamodb-crud: DynamoDB table CRUD operations
|
|
192
|
+
- cloudwatch-logs: CloudWatch Logs write permissions
|
|
193
|
+
- secrets-manager-read: Secrets Manager read access
|
|
194
|
+
- kms-encrypt-decrypt: KMS key encryption/decryption
|
|
195
|
+
- ec2-describe: EC2 describe-only permissions
|
|
196
|
+
- ecs-task-execution: ECS task execution role
|
|
197
|
+
variables: Dictionary of variable values to substitute in the template.
|
|
198
|
+
Required variables depend on the template (see list_templates).
|
|
199
|
+
config_path: Optional path to YAML configuration file for validation.
|
|
200
|
+
Uses the same config format as the CLI validator.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
GenerationResult with:
|
|
204
|
+
- policy: The generated IAM policy
|
|
205
|
+
- validation: Validation results from built-in checks
|
|
206
|
+
- security_notes: Security warnings from validation
|
|
207
|
+
- template_used: Name of the template used
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: If template not found or required variables missing
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
>>> result = await generate_policy_from_template(
|
|
214
|
+
... template_name="s3-read-only",
|
|
215
|
+
... variables={"bucket_name": "my-data", "prefix": "logs/"}
|
|
216
|
+
... )
|
|
217
|
+
>>> print(result.policy)
|
|
218
|
+
"""
|
|
219
|
+
from iam_validator.mcp.templates import load_template
|
|
220
|
+
from iam_validator.mcp.tools.validation import validate_policy
|
|
221
|
+
|
|
222
|
+
# Load and substitute template
|
|
223
|
+
policy = load_template(template_name, variables)
|
|
224
|
+
|
|
225
|
+
# Validate the generated policy using the validator's built-in checks
|
|
226
|
+
validation_result = await validate_policy(
|
|
227
|
+
policy=policy,
|
|
228
|
+
policy_type="identity",
|
|
229
|
+
config_path=config_path,
|
|
230
|
+
use_org_config=False, # Config is passed explicitly via config_path
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Extract security notes from validation issues
|
|
234
|
+
security_notes: list[str] = []
|
|
235
|
+
for issue in validation_result.issues:
|
|
236
|
+
if issue.severity in ("high", "critical", "error"):
|
|
237
|
+
security_notes.append(f"{issue.severity.upper()}: {issue.message}")
|
|
238
|
+
|
|
239
|
+
return GenerationResult(
|
|
240
|
+
policy=policy,
|
|
241
|
+
validation=ValidationResult(
|
|
242
|
+
is_valid=validation_result.is_valid,
|
|
243
|
+
issues=validation_result.issues,
|
|
244
|
+
policy_file=validation_result.policy_file,
|
|
245
|
+
),
|
|
246
|
+
security_notes=security_notes,
|
|
247
|
+
template_used=template_name,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def build_minimal_policy(
|
|
252
|
+
actions: list[str],
|
|
253
|
+
resources: list[str],
|
|
254
|
+
conditions: dict[str, Any] | None = None,
|
|
255
|
+
config_path: str | None = None,
|
|
256
|
+
fetcher: AWSServiceFetcher | None = None,
|
|
257
|
+
) -> GenerationResult:
|
|
258
|
+
"""Build a minimal IAM policy from explicit actions and resources.
|
|
259
|
+
|
|
260
|
+
This tool constructs a policy statement from the provided actions and resources.
|
|
261
|
+
It validates that actions exist in AWS, checks for sensitive actions, and
|
|
262
|
+
validates the generated policy using the validator's built-in checks.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
actions: List of AWS actions to allow (e.g., ["s3:GetObject", "s3:ListBucket"])
|
|
266
|
+
resources: List of resource ARNs (e.g., ["arn:aws:s3:::my-bucket/*"])
|
|
267
|
+
conditions: Optional conditions to add to the statement
|
|
268
|
+
config_path: Optional path to YAML configuration file for validation.
|
|
269
|
+
Uses the same config format as the CLI validator.
|
|
270
|
+
fetcher: Optional shared AWSServiceFetcher instance. If None, creates a new one.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
GenerationResult with:
|
|
274
|
+
- policy: The generated IAM policy
|
|
275
|
+
- validation: Validation results from built-in checks
|
|
276
|
+
- security_notes: Security warnings from validation
|
|
277
|
+
|
|
278
|
+
Example:
|
|
279
|
+
>>> result = await build_minimal_policy(
|
|
280
|
+
... actions=["s3:GetObject", "s3:ListBucket"],
|
|
281
|
+
... resources=["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"]
|
|
282
|
+
... )
|
|
283
|
+
>>> print(result.policy)
|
|
284
|
+
"""
|
|
285
|
+
from iam_validator.core.models import ValidationIssue
|
|
286
|
+
|
|
287
|
+
security_notes: list[str] = []
|
|
288
|
+
effective_conditions = conditions.copy() if conditions else {}
|
|
289
|
+
|
|
290
|
+
# Use provided fetcher or create a new one
|
|
291
|
+
if fetcher is not None:
|
|
292
|
+
# Use shared fetcher directly (no context manager)
|
|
293
|
+
_fetcher = fetcher
|
|
294
|
+
should_close = False
|
|
295
|
+
else:
|
|
296
|
+
# Create new fetcher with context manager
|
|
297
|
+
_fetcher = AWSServiceFetcher()
|
|
298
|
+
await _fetcher.__aenter__()
|
|
299
|
+
should_close = True
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
# Separate wildcard and exact actions for validation
|
|
303
|
+
wildcard_actions = []
|
|
304
|
+
exact_actions = []
|
|
305
|
+
for action in actions:
|
|
306
|
+
if "*" in action:
|
|
307
|
+
if action == "*":
|
|
308
|
+
# Block bare wildcard
|
|
309
|
+
return GenerationResult(
|
|
310
|
+
policy={},
|
|
311
|
+
validation=ValidationResult(
|
|
312
|
+
is_valid=False,
|
|
313
|
+
issues=[
|
|
314
|
+
ValidationIssue(
|
|
315
|
+
severity="error",
|
|
316
|
+
statement_index=0,
|
|
317
|
+
issue_type="bare_wildcard_not_allowed",
|
|
318
|
+
message="Action: '*' is not allowed in generated policies",
|
|
319
|
+
suggestion="Specify explicit actions instead of using wildcard",
|
|
320
|
+
check_id="policy_generation",
|
|
321
|
+
)
|
|
322
|
+
],
|
|
323
|
+
policy_file="generated-policy",
|
|
324
|
+
),
|
|
325
|
+
security_notes=["Policy generation blocked: bare wildcard action detected"],
|
|
326
|
+
)
|
|
327
|
+
wildcard_actions.append(action)
|
|
328
|
+
else:
|
|
329
|
+
exact_actions.append(action)
|
|
330
|
+
|
|
331
|
+
# Validate wildcard actions (must be done individually - expand each)
|
|
332
|
+
for action in wildcard_actions:
|
|
333
|
+
try:
|
|
334
|
+
await _fetcher.expand_wildcard_action(action)
|
|
335
|
+
except Exception:
|
|
336
|
+
# Invalid wildcard
|
|
337
|
+
return GenerationResult(
|
|
338
|
+
policy={},
|
|
339
|
+
validation=ValidationResult(
|
|
340
|
+
is_valid=False,
|
|
341
|
+
issues=[
|
|
342
|
+
ValidationIssue(
|
|
343
|
+
severity="error",
|
|
344
|
+
statement_index=0,
|
|
345
|
+
issue_type="invalid_wildcard_action",
|
|
346
|
+
message=f"Wildcard action '{action}' cannot be expanded to valid actions",
|
|
347
|
+
suggestion="Verify the action pattern is correct",
|
|
348
|
+
check_id="policy_generation",
|
|
349
|
+
)
|
|
350
|
+
],
|
|
351
|
+
policy_file="generated-policy",
|
|
352
|
+
),
|
|
353
|
+
security_notes=[],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Batch validate exact actions (more efficient - fetches each service once)
|
|
357
|
+
if exact_actions:
|
|
358
|
+
validation_results = await _fetcher.validate_actions_batch(exact_actions)
|
|
359
|
+
for action, (is_valid, error, _) in validation_results.items():
|
|
360
|
+
if not is_valid:
|
|
361
|
+
return GenerationResult(
|
|
362
|
+
policy={},
|
|
363
|
+
validation=ValidationResult(
|
|
364
|
+
is_valid=False,
|
|
365
|
+
issues=[
|
|
366
|
+
ValidationIssue(
|
|
367
|
+
severity="error",
|
|
368
|
+
statement_index=0,
|
|
369
|
+
issue_type="invalid_action",
|
|
370
|
+
message=f"Action '{action}' is not valid: {error}",
|
|
371
|
+
suggestion="Verify the action name is correct",
|
|
372
|
+
check_id="policy_generation",
|
|
373
|
+
)
|
|
374
|
+
],
|
|
375
|
+
policy_file="generated-policy",
|
|
376
|
+
),
|
|
377
|
+
security_notes=[],
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Check for bare Resource: "*" with write actions
|
|
381
|
+
if "*" in resources:
|
|
382
|
+
# Check if any actions are write-level
|
|
383
|
+
has_write_actions = False
|
|
384
|
+
for action in actions:
|
|
385
|
+
if "*" not in action:
|
|
386
|
+
try:
|
|
387
|
+
# Check access level
|
|
388
|
+
service = action.split(":")[0]
|
|
389
|
+
action_list = await query_actions(_fetcher, service, access_level="write")
|
|
390
|
+
if any(a["action"] == action for a in action_list):
|
|
391
|
+
has_write_actions = True
|
|
392
|
+
break
|
|
393
|
+
except Exception:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
if has_write_actions:
|
|
397
|
+
return GenerationResult(
|
|
398
|
+
policy={},
|
|
399
|
+
validation=ValidationResult(
|
|
400
|
+
is_valid=False,
|
|
401
|
+
issues=[
|
|
402
|
+
ValidationIssue(
|
|
403
|
+
severity="error",
|
|
404
|
+
statement_index=0,
|
|
405
|
+
issue_type="bare_wildcard_resource_not_allowed",
|
|
406
|
+
message="Resource: '*' with write actions is not allowed",
|
|
407
|
+
suggestion="Specify explicit resource ARNs instead of using wildcard",
|
|
408
|
+
check_id="policy_generation",
|
|
409
|
+
)
|
|
410
|
+
],
|
|
411
|
+
policy_file="generated-policy",
|
|
412
|
+
),
|
|
413
|
+
security_notes=[
|
|
414
|
+
"Policy generation blocked: bare wildcard resource with write actions"
|
|
415
|
+
],
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Check for sensitive actions and add warnings
|
|
419
|
+
sensitive_action_notes: list[dict[str, Any]] = []
|
|
420
|
+
for action in actions:
|
|
421
|
+
if "*" not in action:
|
|
422
|
+
category = _get_category_for_action(action)
|
|
423
|
+
if category:
|
|
424
|
+
category_data = SENSITIVE_ACTION_CATEGORIES[category]
|
|
425
|
+
sensitive_action_notes.append(
|
|
426
|
+
{
|
|
427
|
+
"action": action,
|
|
428
|
+
"category": category,
|
|
429
|
+
"severity": category_data["severity"],
|
|
430
|
+
"description": category_data["description"],
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
security_notes.append(
|
|
434
|
+
f"Warning: '{action}' is a sensitive action ({category_data['name']})"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
# Auto-add required conditions based on CONDITION_REQUIREMENTS
|
|
438
|
+
auto_conditions, auto_notes = _get_auto_conditions(actions)
|
|
439
|
+
if auto_conditions:
|
|
440
|
+
# Merge auto-conditions into effective_conditions
|
|
441
|
+
from iam_validator.mcp.session_config import merge_conditions
|
|
442
|
+
|
|
443
|
+
effective_conditions = merge_conditions(effective_conditions, auto_conditions)
|
|
444
|
+
# Add notes about what was auto-added
|
|
445
|
+
security_notes.extend(auto_notes)
|
|
446
|
+
|
|
447
|
+
# Group actions by service for cleaner policy structure
|
|
448
|
+
actions_by_service: dict[str, list[str]] = {}
|
|
449
|
+
for action in actions:
|
|
450
|
+
service = action.split(":")[0]
|
|
451
|
+
if service not in actions_by_service:
|
|
452
|
+
actions_by_service[service] = []
|
|
453
|
+
actions_by_service[service].append(action)
|
|
454
|
+
|
|
455
|
+
# Build the policy
|
|
456
|
+
statement: dict[str, Any] = {
|
|
457
|
+
"Sid": "GeneratedPolicy",
|
|
458
|
+
"Effect": "Allow",
|
|
459
|
+
"Action": sorted(actions), # Keep all actions in one statement for now
|
|
460
|
+
"Resource": resources if isinstance(resources, list) else [resources],
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
# Add conditions if provided or auto-generated
|
|
464
|
+
if effective_conditions:
|
|
465
|
+
statement["Condition"] = effective_conditions
|
|
466
|
+
|
|
467
|
+
policy: dict[str, Any] = {
|
|
468
|
+
"Version": "2012-10-17",
|
|
469
|
+
"Statement": [statement],
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
# Validate the generated policy using the validator's built-in checks
|
|
473
|
+
from iam_validator.mcp.tools.validation import validate_policy
|
|
474
|
+
|
|
475
|
+
validation_result = await validate_policy(
|
|
476
|
+
policy=policy,
|
|
477
|
+
policy_type="identity",
|
|
478
|
+
config_path=config_path,
|
|
479
|
+
use_org_config=False, # Config is passed explicitly via config_path
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Add high-severity issues to security notes
|
|
483
|
+
for issue in validation_result.issues:
|
|
484
|
+
if issue.severity in ("high", "critical", "error"):
|
|
485
|
+
security_notes.append(f"{issue.severity.upper()}: {issue.message}")
|
|
486
|
+
|
|
487
|
+
return GenerationResult(
|
|
488
|
+
policy=policy,
|
|
489
|
+
validation=ValidationResult(
|
|
490
|
+
is_valid=validation_result.is_valid,
|
|
491
|
+
issues=validation_result.issues,
|
|
492
|
+
policy_file=validation_result.policy_file,
|
|
493
|
+
),
|
|
494
|
+
security_notes=security_notes,
|
|
495
|
+
)
|
|
496
|
+
finally:
|
|
497
|
+
# Clean up fetcher if we created it
|
|
498
|
+
if should_close:
|
|
499
|
+
await _fetcher.__aexit__(None, None, None)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@functools.lru_cache(maxsize=1)
|
|
503
|
+
def _get_cached_templates() -> tuple[dict[str, Any], ...]:
|
|
504
|
+
"""Build template list once, return tuple for immutability.
|
|
505
|
+
|
|
506
|
+
This helper is cached with lru_cache to avoid rebuilding
|
|
507
|
+
the template list on every call to list_templates().
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Tuple of template dictionaries (immutable for caching)
|
|
511
|
+
"""
|
|
512
|
+
from iam_validator.mcp.templates.builtin import (
|
|
513
|
+
list_templates as get_templates_metadata,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
templates_metadata = get_templates_metadata()
|
|
517
|
+
return tuple(
|
|
518
|
+
{
|
|
519
|
+
"name": tmpl["name"],
|
|
520
|
+
"description": tmpl["description"],
|
|
521
|
+
"variables": [
|
|
522
|
+
{
|
|
523
|
+
"name": var["name"],
|
|
524
|
+
"description": var["description"],
|
|
525
|
+
"required": var.get("required", True),
|
|
526
|
+
}
|
|
527
|
+
for var in tmpl["variables"]
|
|
528
|
+
],
|
|
529
|
+
}
|
|
530
|
+
for tmpl in templates_metadata
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
async def list_templates() -> list[dict[str, Any]]:
|
|
535
|
+
"""List all available policy templates with their metadata.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
List of template dictionaries, each containing:
|
|
539
|
+
- name: Template identifier (use with generate_policy_from_template)
|
|
540
|
+
- description: Human-readable description
|
|
541
|
+
- variables: List of variable objects with:
|
|
542
|
+
- name: Variable name to use in the variables dict
|
|
543
|
+
- description: What value to provide
|
|
544
|
+
- required: Whether the variable is required
|
|
545
|
+
|
|
546
|
+
Example:
|
|
547
|
+
>>> templates = await list_templates()
|
|
548
|
+
>>> for tmpl in templates:
|
|
549
|
+
... print(f"{tmpl['name']}: {tmpl['description']}")
|
|
550
|
+
... for var in tmpl['variables']:
|
|
551
|
+
... print(f" - {var['name']}: {var['description']}")
|
|
552
|
+
"""
|
|
553
|
+
return list(_get_cached_templates())
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
async def suggest_actions(
|
|
557
|
+
description: str, service: str | None = None, fetcher: AWSServiceFetcher | None = None
|
|
558
|
+
) -> list[str]:
|
|
559
|
+
"""Suggest AWS actions based on a natural language description.
|
|
560
|
+
|
|
561
|
+
This tool uses keyword pattern matching to suggest appropriate AWS actions
|
|
562
|
+
based on the task description. It's useful for discovering actions when
|
|
563
|
+
building policies from scratch.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
description: Natural language description of the desired permissions
|
|
567
|
+
(e.g., "read files from S3", "invoke Lambda functions")
|
|
568
|
+
service: Optional AWS service to limit suggestions to (e.g., "s3", "lambda")
|
|
569
|
+
fetcher: Optional shared AWSServiceFetcher instance. If None, creates a new one.
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
List of suggested action names
|
|
573
|
+
|
|
574
|
+
Example:
|
|
575
|
+
>>> actions = await suggest_actions("read and write DynamoDB tables", "dynamodb")
|
|
576
|
+
>>> print(actions)
|
|
577
|
+
['dynamodb:GetItem', 'dynamodb:PutItem', 'dynamodb:UpdateItem', ...]
|
|
578
|
+
"""
|
|
579
|
+
description_lower = description.lower()
|
|
580
|
+
|
|
581
|
+
# Keyword mapping for access levels
|
|
582
|
+
access_level_keywords = {
|
|
583
|
+
"read": ["read", "get", "describe", "view", "download", "retrieve", "fetch"],
|
|
584
|
+
"write": [
|
|
585
|
+
"write",
|
|
586
|
+
"put",
|
|
587
|
+
"create",
|
|
588
|
+
"update",
|
|
589
|
+
"modify",
|
|
590
|
+
"upload",
|
|
591
|
+
"edit",
|
|
592
|
+
"change",
|
|
593
|
+
],
|
|
594
|
+
"list": ["list", "enumerate", "browse", "search", "query", "scan"],
|
|
595
|
+
"tagging": ["tag", "untag", "label"],
|
|
596
|
+
"permissions-management": [
|
|
597
|
+
"permission",
|
|
598
|
+
"policy",
|
|
599
|
+
"grant",
|
|
600
|
+
"revoke",
|
|
601
|
+
"attach",
|
|
602
|
+
"detach",
|
|
603
|
+
],
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
# Determine which access levels match the description
|
|
607
|
+
matched_access_levels = []
|
|
608
|
+
for access_level, keywords in access_level_keywords.items():
|
|
609
|
+
if any(keyword in description_lower for keyword in keywords):
|
|
610
|
+
matched_access_levels.append(access_level)
|
|
611
|
+
|
|
612
|
+
# If no specific access level matched, default to read + list
|
|
613
|
+
if not matched_access_levels:
|
|
614
|
+
matched_access_levels = ["read", "list"]
|
|
615
|
+
|
|
616
|
+
# Service detection from description if not provided
|
|
617
|
+
if service is None:
|
|
618
|
+
service_keywords = {
|
|
619
|
+
"s3": ["s3", "bucket", "object", "file", "storage"],
|
|
620
|
+
"lambda": ["lambda", "function", "invoke"],
|
|
621
|
+
"dynamodb": ["dynamodb", "table", "item", "nosql"],
|
|
622
|
+
"ec2": ["ec2", "instance", "vm", "virtual machine"],
|
|
623
|
+
"iam": ["iam", "user", "role", "permission", "policy"],
|
|
624
|
+
"cloudwatch": ["cloudwatch", "log", "metric", "monitoring"],
|
|
625
|
+
"secretsmanager": ["secret", "credential", "password"],
|
|
626
|
+
"kms": ["kms", "encrypt", "decrypt", "key"],
|
|
627
|
+
"rds": ["rds", "database", "db", "sql"],
|
|
628
|
+
"sns": ["sns", "notification", "topic", "publish"],
|
|
629
|
+
"sqs": ["sqs", "queue", "message"],
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for svc, keywords in service_keywords.items():
|
|
633
|
+
if any(keyword in description_lower for keyword in keywords):
|
|
634
|
+
service = svc
|
|
635
|
+
break
|
|
636
|
+
|
|
637
|
+
if service is None:
|
|
638
|
+
# No service detected, return empty list
|
|
639
|
+
return []
|
|
640
|
+
|
|
641
|
+
# Use provided fetcher or create a new one
|
|
642
|
+
if fetcher is not None:
|
|
643
|
+
# Use shared fetcher directly
|
|
644
|
+
_fetcher = fetcher
|
|
645
|
+
should_close = False
|
|
646
|
+
else:
|
|
647
|
+
# Create new fetcher with context manager
|
|
648
|
+
_fetcher = AWSServiceFetcher()
|
|
649
|
+
await _fetcher.__aenter__()
|
|
650
|
+
should_close = True
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
# Query all access levels in parallel for better performance
|
|
654
|
+
async def query_level(level: str) -> list[str]:
|
|
655
|
+
try:
|
|
656
|
+
actions = await query_actions(
|
|
657
|
+
_fetcher,
|
|
658
|
+
service,
|
|
659
|
+
access_level=level, # type: ignore
|
|
660
|
+
)
|
|
661
|
+
return [a["action"] for a in actions]
|
|
662
|
+
except Exception:
|
|
663
|
+
# Service might not exist or other error
|
|
664
|
+
return []
|
|
665
|
+
|
|
666
|
+
results = await asyncio.gather(*[query_level(level) for level in matched_access_levels])
|
|
667
|
+
# Flatten results and deduplicate using a set
|
|
668
|
+
suggested_actions: set[str] = set()
|
|
669
|
+
for result in results:
|
|
670
|
+
suggested_actions.update(result)
|
|
671
|
+
finally:
|
|
672
|
+
# Clean up fetcher if we created it
|
|
673
|
+
if should_close:
|
|
674
|
+
await _fetcher.__aexit__(None, None, None)
|
|
675
|
+
|
|
676
|
+
# Return sorted list
|
|
677
|
+
return sorted(suggested_actions)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
async def get_required_conditions(actions: list[str]) -> dict[str, Any]:
|
|
681
|
+
"""Get the conditions required for a list of actions.
|
|
682
|
+
|
|
683
|
+
This tool looks up condition requirements from the IAM Policy Validator's
|
|
684
|
+
configuration and returns the requirements for the given actions.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
actions: List of AWS actions to analyze
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
Dictionary with:
|
|
691
|
+
- requirements: List of condition requirements from the validator config
|
|
692
|
+
- actions_matched: Which actions matched requirements
|
|
693
|
+
- summary: Human-readable summary of what conditions are needed
|
|
694
|
+
|
|
695
|
+
Example:
|
|
696
|
+
>>> conditions = await get_required_conditions(["iam:PassRole", "s3:GetObject"])
|
|
697
|
+
>>> print(conditions["summary"])
|
|
698
|
+
"""
|
|
699
|
+
from iam_validator.core.config.condition_requirements import CONDITION_REQUIREMENTS
|
|
700
|
+
|
|
701
|
+
matched_requirements: list[dict[str, Any]] = []
|
|
702
|
+
actions_matched: list[str] = []
|
|
703
|
+
|
|
704
|
+
for action in actions:
|
|
705
|
+
for requirement in CONDITION_REQUIREMENTS:
|
|
706
|
+
# Check direct action match
|
|
707
|
+
if "actions" in requirement and action in requirement["actions"]:
|
|
708
|
+
matched_requirements.append(requirement)
|
|
709
|
+
actions_matched.append(action)
|
|
710
|
+
break
|
|
711
|
+
|
|
712
|
+
# Check pattern match
|
|
713
|
+
if "action_patterns" in requirement:
|
|
714
|
+
for pattern in requirement["action_patterns"]:
|
|
715
|
+
if re.match(pattern, action):
|
|
716
|
+
matched_requirements.append(requirement)
|
|
717
|
+
actions_matched.append(action)
|
|
718
|
+
break
|
|
719
|
+
|
|
720
|
+
# Build a summary
|
|
721
|
+
summary_parts = []
|
|
722
|
+
for req in matched_requirements:
|
|
723
|
+
if "suggestion_text" in req:
|
|
724
|
+
summary_parts.append(req["suggestion_text"])
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
"requirements": matched_requirements,
|
|
728
|
+
"actions_matched": list(set(actions_matched)),
|
|
729
|
+
"summary": "\n\n".join(summary_parts)
|
|
730
|
+
if summary_parts
|
|
731
|
+
else "No specific condition requirements found for these actions.",
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _get_remediation_from_validator(action: str, category: str) -> dict[str, Any]:
|
|
736
|
+
"""Get remediation guidance for a sensitive action from the validator's data.
|
|
737
|
+
|
|
738
|
+
Uses the IAM validator's category_suggestions module for ABAC-focused
|
|
739
|
+
remediation guidance, and CheckDocumentationRegistry for general check documentation.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
action: AWS action name
|
|
743
|
+
category: Risk category (credential_exposure, data_access, priv_esc, resource_exposure)
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
Dictionary with remediation guidance from the validator
|
|
747
|
+
"""
|
|
748
|
+
# Get category suggestions from validator (ABAC-focused guidance)
|
|
749
|
+
category_suggestions = DEFAULT_CATEGORY_SUGGESTIONS.get(category, {})
|
|
750
|
+
|
|
751
|
+
# Check for action-specific override first
|
|
752
|
+
action_overrides = category_suggestions.get("action_overrides", {})
|
|
753
|
+
if action in action_overrides:
|
|
754
|
+
override = action_overrides[action]
|
|
755
|
+
suggestion = override.get("suggestion", "")
|
|
756
|
+
example = override.get("example", "")
|
|
757
|
+
else:
|
|
758
|
+
# Fall back to category-level guidance
|
|
759
|
+
suggestion = category_suggestions.get("suggestion", "")
|
|
760
|
+
example = category_suggestions.get("example", "")
|
|
761
|
+
|
|
762
|
+
# Get check documentation from the validator's registry
|
|
763
|
+
check_doc = CheckDocumentationRegistry.get("sensitive_action")
|
|
764
|
+
remediation_steps = check_doc.remediation_steps if check_doc else []
|
|
765
|
+
documentation_url = check_doc.documentation_url if check_doc else None
|
|
766
|
+
risk_explanation = check_doc.risk_explanation if check_doc else None
|
|
767
|
+
|
|
768
|
+
# Determine risk level from category severity
|
|
769
|
+
category_data = SENSITIVE_ACTION_CATEGORIES.get(category, {})
|
|
770
|
+
risk_level = "CRITICAL" if category_data.get("severity") == "critical" else "HIGH"
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
"risk_level": risk_level,
|
|
774
|
+
"suggestion": suggestion,
|
|
775
|
+
"condition_example": example,
|
|
776
|
+
"remediation_steps": remediation_steps,
|
|
777
|
+
"documentation_url": documentation_url,
|
|
778
|
+
"risk_explanation": risk_explanation,
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
async def check_sensitive_actions(actions: list[str]) -> dict[str, Any]:
|
|
783
|
+
"""Check if any actions in the list are sensitive and get remediation guidance.
|
|
784
|
+
|
|
785
|
+
This tool analyzes actions against the IAM Policy Validator's sensitive
|
|
786
|
+
actions catalog and returns remediation guidance sourced directly from
|
|
787
|
+
the validator's configuration.
|
|
788
|
+
|
|
789
|
+
The sensitive actions catalog contains 490+ actions across 4 categories,
|
|
790
|
+
sourced from https://github.com/primeharbor/sensitive_iam_actions
|
|
791
|
+
|
|
792
|
+
IMPORTANT FOR AI CLIENTS: To fix sensitive action findings:
|
|
793
|
+
1. Add the suggested IAM conditions to your policy statement
|
|
794
|
+
2. The condition_example field contains ready-to-use JSON
|
|
795
|
+
3. After adding conditions, re-validate to confirm the fix
|
|
796
|
+
4. If issues persist, the action may need additional restrictions
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
actions: List of AWS actions to check
|
|
800
|
+
|
|
801
|
+
Returns:
|
|
802
|
+
Dictionary containing:
|
|
803
|
+
- sensitive_actions: List of sensitive actions with remediation
|
|
804
|
+
- action: The action name
|
|
805
|
+
- category: Risk category
|
|
806
|
+
- severity: critical or high
|
|
807
|
+
- description: Category description
|
|
808
|
+
- remediation: Guidance from the IAM validator including:
|
|
809
|
+
- risk_level: CRITICAL or HIGH
|
|
810
|
+
- suggestion: ABAC-focused guidance on what conditions to add
|
|
811
|
+
- condition_example: Ready-to-use JSON condition block
|
|
812
|
+
- remediation_steps: Step-by-step fix guidance
|
|
813
|
+
- documentation_url: AWS documentation link
|
|
814
|
+
- total_checked: Number of actions checked
|
|
815
|
+
- sensitive_count: Number of sensitive actions found
|
|
816
|
+
- categories_found: List of unique risk categories
|
|
817
|
+
- has_critical: Whether any CRITICAL actions were found
|
|
818
|
+
- summary: Quick summary with key recommendations
|
|
819
|
+
- fix_guidance: Clear instructions for AI clients on how to resolve
|
|
820
|
+
|
|
821
|
+
Example:
|
|
822
|
+
>>> result = await check_sensitive_actions(["iam:PassRole", "s3:GetObject"])
|
|
823
|
+
>>> for item in result["sensitive_actions"]:
|
|
824
|
+
... print(f"Action: {item['action']}")
|
|
825
|
+
... print(f"Fix: Add this condition block:")
|
|
826
|
+
... print(item['remediation']['condition_example'])
|
|
827
|
+
"""
|
|
828
|
+
sensitive_actions_found: list[dict[str, Any]] = []
|
|
829
|
+
categories_found: set[str] = set()
|
|
830
|
+
has_critical = False
|
|
831
|
+
|
|
832
|
+
for action in actions:
|
|
833
|
+
category = _get_category_for_action(action)
|
|
834
|
+
if category:
|
|
835
|
+
category_data = SENSITIVE_ACTION_CATEGORIES[category]
|
|
836
|
+
categories_found.add(category)
|
|
837
|
+
|
|
838
|
+
if category_data["severity"] == "critical":
|
|
839
|
+
has_critical = True
|
|
840
|
+
|
|
841
|
+
# Get remediation from validator's data (not duplicated logic)
|
|
842
|
+
remediation = _get_remediation_from_validator(action, category)
|
|
843
|
+
|
|
844
|
+
sensitive_actions_found.append(
|
|
845
|
+
{
|
|
846
|
+
"action": action,
|
|
847
|
+
"category": category,
|
|
848
|
+
"severity": category_data["severity"],
|
|
849
|
+
"description": category_data["description"],
|
|
850
|
+
"remediation": remediation,
|
|
851
|
+
}
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
# Generate summary with actionable guidance
|
|
855
|
+
summary = ""
|
|
856
|
+
fix_guidance = ""
|
|
857
|
+
if sensitive_actions_found:
|
|
858
|
+
summary = f"Found {len(sensitive_actions_found)} sensitive action(s). "
|
|
859
|
+
if has_critical:
|
|
860
|
+
summary += "CRITICAL actions detected - require MFA and strict conditions. "
|
|
861
|
+
if "credential_exposure" in categories_found:
|
|
862
|
+
summary += "Credential exposure risk present. "
|
|
863
|
+
if "priv_esc" in categories_found:
|
|
864
|
+
summary += "Privilege escalation risk present. "
|
|
865
|
+
|
|
866
|
+
# Clear fix guidance for AI clients to prevent loops
|
|
867
|
+
fix_guidance = (
|
|
868
|
+
"To resolve these findings:\n"
|
|
869
|
+
"1. Add IAM conditions to each statement containing sensitive actions\n"
|
|
870
|
+
"2. Use the condition_example from each finding as a starting point\n"
|
|
871
|
+
"3. Customize placeholder values (e.g., replace IP ranges, tag values)\n"
|
|
872
|
+
"4. Re-validate the policy after adding conditions\n"
|
|
873
|
+
"5. If the same action is still flagged, the validator's sensitive_action "
|
|
874
|
+
"check may require specific conditions - see the suggestion field for details"
|
|
875
|
+
)
|
|
876
|
+
else:
|
|
877
|
+
summary = "No sensitive actions detected."
|
|
878
|
+
fix_guidance = "No action required - no sensitive actions found in the provided list."
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
"sensitive_actions": sensitive_actions_found,
|
|
882
|
+
"total_checked": len(actions),
|
|
883
|
+
"sensitive_count": len(sensitive_actions_found),
|
|
884
|
+
"categories_found": sorted(categories_found),
|
|
885
|
+
"has_critical": has_critical,
|
|
886
|
+
"summary": summary,
|
|
887
|
+
"fix_guidance": fix_guidance,
|
|
888
|
+
}
|