iam-policy-validator 1.10.3__py3-none-any.whl → 1.11.1__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.
@@ -0,0 +1,485 @@
1
+ """Query AWS service definitions - actions, ARNs, and condition keys.
2
+
3
+ This command allows querying AWS IAM service metadata similar to policy_sentry.
4
+ Implementation inspired by: https://github.com/salesforce/policy_sentry
5
+
6
+ Examples:
7
+ # Query all actions for a service
8
+ iam-validator query action --service s3
9
+
10
+ # Query write-level actions
11
+ iam-validator query action --service s3 --access-level write
12
+
13
+ # Query actions that support wildcard resource
14
+ iam-validator query action --service s3 --resource-type "*"
15
+
16
+ # Query action details
17
+ iam-validator query action --service s3 --name GetObject
18
+
19
+ # Query ARN formats for a service
20
+ iam-validator query arn --service s3
21
+
22
+ # Query specific ARN type
23
+ iam-validator query arn --service s3 --name bucket
24
+
25
+ # Query condition keys
26
+ iam-validator query condition --service s3
27
+
28
+ # Query specific condition key
29
+ iam-validator query condition --service s3 --name s3:prefix
30
+
31
+ # Text format for simple output (great for piping)
32
+ iam-validator query action --service s3 --output text | grep Delete
33
+ iam-validator query action --service iam --access-level write --output text
34
+ """
35
+
36
+ import argparse
37
+ import json
38
+ import logging
39
+ import sys
40
+ from typing import Any
41
+
42
+ import yaml
43
+
44
+ from iam_validator.commands.base import Command
45
+ from iam_validator.core.aws_service.fetcher import AWSServiceFetcher
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ class QueryCommand(Command):
51
+ """Query AWS service definitions."""
52
+
53
+ @property
54
+ def name(self) -> str:
55
+ """Command name."""
56
+ return "query"
57
+
58
+ @property
59
+ def help(self) -> str:
60
+ """Command help text."""
61
+ return "Query AWS service definitions (actions, ARNs, condition keys)"
62
+
63
+ @property
64
+ def epilog(self) -> str:
65
+ """Command epilog with examples."""
66
+ return """
67
+ examples:
68
+ # Query all actions for a service
69
+ iam-validator query action --service s3
70
+
71
+ # Query write-level actions
72
+ iam-validator query action --service s3 --access-level write
73
+
74
+ # Query actions that support wildcard resource
75
+ iam-validator query action --service s3 --resource-type "*"
76
+
77
+ # Query action details
78
+ iam-validator query action --service s3 --name GetObject
79
+
80
+ # Query ARN formats for a service
81
+ iam-validator query arn --service s3
82
+
83
+ # Query specific ARN type
84
+ iam-validator query arn --service s3 --name bucket
85
+
86
+ # Query condition keys
87
+ iam-validator query condition --service s3
88
+
89
+ # Query specific condition key
90
+ iam-validator query condition --service s3 --name s3:prefix
91
+
92
+ # Text format for simple output (great for piping)
93
+ iam-validator query action --service s3 --output text | grep Delete
94
+ iam-validator query action --service iam --access-level write --output text
95
+
96
+ note:
97
+ This feature is inspired by policy_sentry's query functionality.
98
+ See: https://github.com/salesforce/policy_sentry
99
+ """
100
+
101
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
102
+ """Add query command arguments."""
103
+ # Add subparsers for different query types
104
+ subparsers = parser.add_subparsers(
105
+ dest="query_type",
106
+ help="Type of query to perform",
107
+ required=True,
108
+ )
109
+
110
+ # Action query
111
+ action_parser = subparsers.add_parser(
112
+ "action",
113
+ help="Query IAM actions",
114
+ formatter_class=argparse.RawDescriptionHelpFormatter,
115
+ )
116
+ action_parser.add_argument(
117
+ "--service",
118
+ required=True,
119
+ help="AWS service prefix (e.g., s3, iam, ec2)",
120
+ )
121
+ action_parser.add_argument(
122
+ "--name",
123
+ help="Specific action name (e.g., GetObject, CreateUser)",
124
+ )
125
+ action_parser.add_argument(
126
+ "--access-level",
127
+ choices=["read", "write", "list", "tagging", "permissions-management"],
128
+ help="Filter by access level",
129
+ )
130
+ action_parser.add_argument(
131
+ "--resource-type",
132
+ help='Filter by resource type (use "*" for wildcard-only actions)',
133
+ )
134
+ action_parser.add_argument(
135
+ "--condition",
136
+ help="Filter actions that support specific condition key",
137
+ )
138
+ action_parser.add_argument(
139
+ "--output",
140
+ choices=["json", "yaml", "text"],
141
+ default="json",
142
+ help="Output format (default: json)",
143
+ )
144
+
145
+ # ARN query
146
+ arn_parser = subparsers.add_parser(
147
+ "arn",
148
+ help="Query ARN formats",
149
+ formatter_class=argparse.RawDescriptionHelpFormatter,
150
+ )
151
+ arn_parser.add_argument(
152
+ "--service",
153
+ required=True,
154
+ help="AWS service prefix (e.g., s3, iam, ec2)",
155
+ )
156
+ arn_parser.add_argument(
157
+ "--name",
158
+ help="Specific ARN resource type name (e.g., bucket, role)",
159
+ )
160
+ arn_parser.add_argument(
161
+ "--list-arn-types",
162
+ action="store_true",
163
+ help="List all ARN types with their formats",
164
+ )
165
+ arn_parser.add_argument(
166
+ "--output",
167
+ choices=["json", "yaml", "text"],
168
+ default="json",
169
+ help="Output format (default: json)",
170
+ )
171
+
172
+ # Condition query
173
+ condition_parser = subparsers.add_parser(
174
+ "condition",
175
+ help="Query condition keys",
176
+ formatter_class=argparse.RawDescriptionHelpFormatter,
177
+ )
178
+ condition_parser.add_argument(
179
+ "--service",
180
+ required=True,
181
+ help="AWS service prefix (e.g., s3, iam, ec2)",
182
+ )
183
+ condition_parser.add_argument(
184
+ "--name",
185
+ help="Specific condition key name (e.g., s3:prefix, iam:PolicyArn)",
186
+ )
187
+ condition_parser.add_argument(
188
+ "--output",
189
+ choices=["json", "yaml", "text"],
190
+ default="json",
191
+ help="Output format (default: json)",
192
+ )
193
+
194
+ async def execute(self, args: argparse.Namespace) -> int:
195
+ """Execute query command."""
196
+ try:
197
+ async with AWSServiceFetcher(prefetch_common=False) as fetcher:
198
+ if args.query_type == "action":
199
+ result = await self._query_action_table(fetcher, args)
200
+ elif args.query_type == "arn":
201
+ result = await self._query_arn_table(fetcher, args)
202
+ elif args.query_type == "condition":
203
+ result = await self._query_condition_table(fetcher, args)
204
+ else:
205
+ logger.error(f"Unknown query type: {args.query_type}")
206
+ return 1
207
+
208
+ # Output result
209
+ self._print_result(result, args.output)
210
+ return 0
211
+
212
+ except ValueError as e:
213
+ logger.error(f"Query failed: {e}")
214
+ return 1
215
+ except Exception as e: # pylint: disable=broad-exception-caught
216
+ logger.error(f"Unexpected error during query: {e}", exc_info=True)
217
+ return 1
218
+
219
+ def _get_access_level(self, action_detail: Any) -> str:
220
+ """Derive access level from action annotations.
221
+
222
+ AWS API provides Properties dict with boolean flags instead of AccessLevel string.
223
+ We derive the access level from these flags.
224
+ """
225
+ if not action_detail.annotations:
226
+ return "Unknown"
227
+
228
+ props = action_detail.annotations.get("Properties", {})
229
+ if not props:
230
+ return "Unknown"
231
+
232
+ # Check flags in priority order
233
+ if props.get("IsPermissionManagement"):
234
+ return "permissions-management"
235
+ if props.get("IsTaggingOnly"):
236
+ return "tagging"
237
+ if props.get("IsWrite"):
238
+ return "write"
239
+ if props.get("IsList"):
240
+ return "list"
241
+
242
+ # Default to read if none of the above
243
+ return "read"
244
+
245
+ async def _query_action_table(
246
+ self, fetcher: AWSServiceFetcher, args: argparse.Namespace
247
+ ) -> dict[str, Any] | list[dict[str, Any]]:
248
+ """Query action table."""
249
+ service_detail = await fetcher.fetch_service_by_name(args.service)
250
+
251
+ # If specific action requested, return its details
252
+ if args.name:
253
+ action_name = args.name
254
+ # Try case-insensitive lookup
255
+ action_detail = None
256
+ for key, detail in service_detail.actions.items():
257
+ if key.lower() == action_name.lower():
258
+ action_detail = detail
259
+ break
260
+
261
+ if not action_detail:
262
+ raise ValueError(f"Action '{args.name}' not found in service '{args.service}'")
263
+
264
+ access_level = self._get_access_level(action_detail)
265
+ description = (
266
+ action_detail.annotations.get("Description", "N/A")
267
+ if action_detail.annotations
268
+ else "N/A"
269
+ )
270
+
271
+ return {
272
+ "service": args.service,
273
+ "action": action_detail.name,
274
+ "description": description,
275
+ "access_level": access_level,
276
+ "resource_types": [r.get("Name", "*") for r in (action_detail.resources or [])],
277
+ "condition_keys": action_detail.action_condition_keys or [],
278
+ }
279
+
280
+ # Filter actions based on criteria
281
+ filtered_actions = []
282
+ for action_name, action_detail in service_detail.actions.items():
283
+ access_level = self._get_access_level(action_detail)
284
+
285
+ # Apply filters
286
+ if args.access_level:
287
+ if access_level.lower() != args.access_level.lower():
288
+ continue
289
+
290
+ if args.resource_type:
291
+ resources = action_detail.resources or []
292
+
293
+ # If filtering for wildcard-only actions (actions with no required resources)
294
+ if args.resource_type == "*":
295
+ # Actions with empty resources list are wildcard-only
296
+ if resources:
297
+ continue
298
+ else:
299
+ # Filter by specific resource type name
300
+ resource_names = [r.get("Name", "") for r in resources]
301
+ if args.resource_type not in resource_names:
302
+ continue
303
+
304
+ if args.condition:
305
+ condition_keys = action_detail.action_condition_keys or []
306
+ if args.condition not in condition_keys:
307
+ continue
308
+
309
+ description = (
310
+ action_detail.annotations.get("Description", "N/A")
311
+ if action_detail.annotations
312
+ else "N/A"
313
+ )
314
+
315
+ # Add to filtered list
316
+ filtered_actions.append(
317
+ {
318
+ "action": f"{args.service}:{action_name}",
319
+ "access_level": access_level,
320
+ "description": description,
321
+ }
322
+ )
323
+
324
+ return filtered_actions
325
+
326
+ async def _query_arn_table(
327
+ self, fetcher: AWSServiceFetcher, args: argparse.Namespace
328
+ ) -> dict[str, Any] | list[dict[str, Any]]:
329
+ """Query ARN table."""
330
+ service_detail = await fetcher.fetch_service_by_name(args.service)
331
+
332
+ # If specific ARN type requested
333
+ if args.name:
334
+ resource_type = None
335
+ for key, rt in service_detail.resources.items():
336
+ if key.lower() == args.name.lower():
337
+ resource_type = rt
338
+ break
339
+
340
+ if not resource_type:
341
+ raise ValueError(
342
+ f"ARN resource type '{args.name}' not found in service '{args.service}'"
343
+ )
344
+
345
+ return {
346
+ "service": args.service,
347
+ "resource_type": resource_type.name,
348
+ "arn_formats": resource_type.arn_formats or [],
349
+ "condition_keys": resource_type.condition_keys or [],
350
+ }
351
+
352
+ # List all ARN types
353
+ if args.list_arn_types:
354
+ return [
355
+ {
356
+ "resource_type": rt.name,
357
+ "arn_formats": rt.arn_formats or [],
358
+ }
359
+ for rt in service_detail.resources.values()
360
+ ]
361
+
362
+ # Return all raw ARN formats
363
+ all_arns = []
364
+ for resource_type in service_detail.resources.values():
365
+ if resource_type.arn_formats:
366
+ all_arns.extend(resource_type.arn_formats)
367
+
368
+ return list(set(all_arns)) # Remove duplicates
369
+
370
+ async def _query_condition_table(
371
+ self, fetcher: AWSServiceFetcher, args: argparse.Namespace
372
+ ) -> dict[str, Any] | list[dict[str, Any]]:
373
+ """Query condition table."""
374
+ service_detail = await fetcher.fetch_service_by_name(args.service)
375
+
376
+ # If specific condition key requested
377
+ if args.name:
378
+ condition_key = None
379
+ for key, ck in service_detail.condition_keys.items():
380
+ if key.lower() == args.name.lower():
381
+ condition_key = ck
382
+ break
383
+
384
+ if not condition_key:
385
+ raise ValueError(
386
+ f"Condition key '{args.name}' not found in service '{args.service}'"
387
+ )
388
+
389
+ return {
390
+ "service": args.service,
391
+ "condition_key": condition_key.name,
392
+ "description": condition_key.description or "N/A",
393
+ "types": condition_key.types or [],
394
+ }
395
+
396
+ # Return all condition keys
397
+ return [
398
+ {
399
+ "condition_key": ck.name,
400
+ "description": ck.description or "N/A",
401
+ "types": ck.types or [],
402
+ }
403
+ for ck in service_detail.condition_keys.values()
404
+ ]
405
+
406
+ def _print_result(self, result: Any, fmt: str) -> None:
407
+ """Print query result in specified format."""
408
+ if fmt == "yaml":
409
+ print(yaml.dump(result, default_flow_style=False, sort_keys=False))
410
+ elif fmt == "text":
411
+ self._print_text_format(result)
412
+ else: # json
413
+ print(json.dumps(result, indent=2))
414
+
415
+ def _print_text_format(self, result: Any) -> None:
416
+ """Print result in simple text format.
417
+
418
+ Text format outputs only the essential information:
419
+ - For lists of actions: one action per line (service:action format)
420
+ - For specific action: action name followed by key details
421
+ - For ARNs: one ARN format per line
422
+ - For condition keys: one condition key per line
423
+ """
424
+ if isinstance(result, list):
425
+ # List of items (actions, ARNs, or condition keys)
426
+ if not result:
427
+ return
428
+
429
+ first_item = result[0]
430
+ if "action" in first_item:
431
+ # Action list
432
+ for item in result:
433
+ print(item["action"])
434
+ elif "condition_key" in first_item:
435
+ # Condition key list
436
+ for item in result:
437
+ print(item["condition_key"])
438
+ elif "resource_type" in first_item:
439
+ # ARN type list
440
+ for item in result:
441
+ print(f"{item['resource_type']}: {', '.join(item['arn_formats'])}")
442
+ else:
443
+ # Generic list (e.g., plain ARN formats)
444
+ for item in result:
445
+ print(item)
446
+
447
+ elif isinstance(result, dict):
448
+ # Single item details
449
+ if "action" in result:
450
+ # Action details
451
+ print(result["action"])
452
+ if result.get("resource_types"):
453
+ print(f" Resource types: {', '.join(result['resource_types'])}")
454
+ if result.get("condition_keys"):
455
+ print(f" Condition keys: {', '.join(result['condition_keys'])}")
456
+ if result.get("access_level"):
457
+ print(f" Access level: {result['access_level']}")
458
+
459
+ elif "resource_type" in result:
460
+ # ARN details
461
+ print(result["resource_type"])
462
+ if result.get("arn_formats"):
463
+ for arn in result["arn_formats"]:
464
+ print(f" {arn}")
465
+ if result.get("condition_keys"):
466
+ print(f" Condition keys: {', '.join(result['condition_keys'])}")
467
+
468
+ elif "condition_key" in result:
469
+ # Condition key details
470
+ print(result["condition_key"])
471
+ if result.get("types"):
472
+ print(f" Types: {', '.join(result['types'])}")
473
+ if result.get("description") and result["description"] != "N/A":
474
+ print(f" Description: {result['description']}")
475
+
476
+
477
+ # For testing
478
+ if __name__ == "__main__":
479
+ import asyncio
480
+
481
+ cmd = QueryCommand()
482
+ arg_parser = argparse.ArgumentParser()
483
+ cmd.add_arguments(arg_parser)
484
+ parsed_args = arg_parser.parse_args()
485
+ sys.exit(asyncio.run(cmd.execute(parsed_args)))
@@ -187,15 +187,15 @@ Examples:
187
187
  )
188
188
 
189
189
  parser.add_argument(
190
- "--no-summary",
190
+ "--summary",
191
191
  action="store_true",
192
- help="Hide Executive Summary section in enhanced format output",
192
+ help="Show Executive Summary section in enhanced format output",
193
193
  )
194
194
 
195
195
  parser.add_argument(
196
- "--no-severity-breakdown",
196
+ "--severity-breakdown",
197
197
  action="store_true",
198
- help="Hide Issue Severity Breakdown section in enhanced format output",
198
+ help="Show Issue Severity Breakdown section in enhanced format output",
199
199
  )
200
200
 
201
201
  async def execute(self, args: argparse.Namespace) -> int:
@@ -285,9 +285,9 @@ Examples:
285
285
  # Pass options for enhanced format
286
286
  format_options = {}
287
287
  if args.format == "enhanced":
288
- format_options["show_summary"] = not getattr(args, "no_summary", False)
289
- format_options["show_severity_breakdown"] = not getattr(
290
- args, "no_severity_breakdown", False
288
+ format_options["show_summary"] = getattr(args, "summary", False)
289
+ format_options["show_severity_breakdown"] = getattr(
290
+ args, "severity_breakdown", False
291
291
  )
292
292
  output_content = generator.format_report(report, args.format, **format_options)
293
293
  if args.output:
@@ -351,7 +351,7 @@ Examples:
351
351
 
352
352
  # Clean up old review comments at the start (before posting any new ones)
353
353
  if getattr(args, "github_review", False):
354
- await self._cleanup_old_comments()
354
+ await self._cleanup_old_comments(args)
355
355
 
356
356
  logging.info(f"Starting streaming validation from {len(args.paths)} path(s)")
357
357
 
@@ -414,9 +414,9 @@ Examples:
414
414
  # Pass options for enhanced format
415
415
  format_options = {}
416
416
  if args.format == "enhanced":
417
- format_options["show_summary"] = not getattr(args, "no_summary", False)
418
- format_options["show_severity_breakdown"] = not getattr(
419
- args, "no_severity_breakdown", False
417
+ format_options["show_summary"] = getattr(args, "summary", False)
418
+ format_options["show_severity_breakdown"] = getattr(
419
+ args, "severity_breakdown", False
420
420
  )
421
421
  output_content = generator.format_report(report, args.format, **format_options)
422
422
  if args.output:
@@ -460,24 +460,19 @@ Examples:
460
460
  else:
461
461
  return 0 if report.invalid_policies == 0 else 1
462
462
 
463
- async def _cleanup_old_comments(self) -> None:
463
+ async def _cleanup_old_comments(self, args: argparse.Namespace) -> None:
464
464
  """Clean up old bot review comments from previous validation runs.
465
465
 
466
- This ensures the PR stays clean without duplicate/stale comments.
467
- """
468
- try:
469
- from iam_validator.core.pr_commenter import PRCommenter
470
-
471
- async with GitHubIntegration() as github:
472
- if not github.is_configured():
473
- return
466
+ Note: This method is kept for backward compatibility but cleanup is now handled
467
+ automatically by update_or_create_review_comments(). It will update existing
468
+ comments, create new ones, and delete resolved ones smartly.
474
469
 
475
- logging.info("Cleaning up old review comments from previous runs...")
476
- deleted = await github.cleanup_bot_review_comments(PRCommenter.REVIEW_IDENTIFIER)
477
- if deleted > 0:
478
- logging.info(f"Removed {deleted} old comment(s)")
479
- except Exception as e:
480
- logging.warning(f"Failed to cleanup old comments: {e}")
470
+ Args:
471
+ args: Command-line arguments (kept for compatibility)
472
+ """
473
+ # Cleanup is now handled automatically by update_or_create_review_comments()
474
+ # No action needed here
475
+ logging.debug("Comment cleanup will be handled automatically during review posting")
481
476
 
482
477
  async def _post_file_review(self, result, args: argparse.Namespace) -> None:
483
478
  """Post review comments for a single file immediately.
@@ -37,6 +37,26 @@ DEFAULT_CATEGORY_SUGGESTIONS: Final[dict[str, dict[str, Any]]] = {
37
37
  ' "Bool": {"aws:MultiFactorAuthPresent": "true"}\n'
38
38
  "}"
39
39
  ),
40
+ "action_overrides": {
41
+ "iam:CreateAccessKey": {
42
+ "suggestion": (
43
+ "This action creates long-term credentials that can be compromised. Restrict creation to authorized roles:\n"
44
+ "• Require MFA (`aws:MultiFactorAuthPresent` = `true`) - CRITICAL\n"
45
+ "• Limit to specific principal tags (`aws:PrincipalTag/role` = `security-admin`)\n"
46
+ "• Restrict to corporate networks (`aws:SourceIp`)\n"
47
+ "• Consider requiring approval tags (`aws:RequestTag/approved-by`)"
48
+ ),
49
+ "example": (
50
+ '"Condition": {\n'
51
+ ' "StringEquals": {\n'
52
+ ' "aws:PrincipalTag/role": "security-admin"\n'
53
+ " },\n"
54
+ ' "Bool": {"aws:MultiFactorAuthPresent": "true"},\n'
55
+ ' "IpAddress": {"aws:SourceIp": ["10.0.0.0/8"]}\n'
56
+ "}"
57
+ ),
58
+ },
59
+ },
40
60
  },
41
61
  "data_access": {
42
62
  "suggestion": (
@@ -90,6 +110,63 @@ DEFAULT_CATEGORY_SUGGESTIONS: Final[dict[str, dict[str, Any]]] = {
90
110
  " }\n"
91
111
  "}"
92
112
  ),
113
+ "action_overrides": {
114
+ "s3:DeleteObject": {
115
+ "suggestion": (
116
+ "This action permanently deletes S3 objects. Apply strict controls to prevent data loss:\n"
117
+ "• Restrict to organization buckets (`aws:ResourceOrgID` = `${aws:PrincipalOrgID}`)\n"
118
+ "• Ensure account ownership (`aws:ResourceAccount` = `${aws:PrincipalAccount}`)\n"
119
+ "• Require MFA for additional protection (`aws:MultiFactorAuthPresent` = `true`)\n"
120
+ "• Consider restricting to specific environments (`aws:ResourceTag/environment` != `production`)"
121
+ ),
122
+ "example": (
123
+ '"Condition": {\n'
124
+ ' "StringEquals": {\n'
125
+ ' "aws:ResourceOrgID": "${aws:PrincipalOrgID}",\n'
126
+ ' "aws:ResourceAccount": "${aws:PrincipalAccount}"\n'
127
+ " },\n"
128
+ ' "Bool": {"aws:MultiFactorAuthPresent": "true"}\n'
129
+ "}"
130
+ ),
131
+ },
132
+ "s3:PutBucketPolicy": {
133
+ "suggestion": (
134
+ "This action modifies S3 bucket policies, which can expose data to unauthorized parties. Strictly control policy changes:\n"
135
+ "• Require organization ownership (`aws:ResourceOrgID` = `${aws:PrincipalOrgID}`)\n"
136
+ "• Ensure account ownership (`aws:ResourceAccount` = `${aws:PrincipalAccount}`)\n"
137
+ "• Require MFA (`aws:MultiFactorAuthPresent` = `true`) - CRITICAL\n"
138
+ "• Restrict to administrative roles (`aws:PrincipalTag/role` = `security-admin`)"
139
+ ),
140
+ "example": (
141
+ '"Condition": {\n'
142
+ ' "StringEquals": {\n'
143
+ ' "aws:ResourceOrgID": "${aws:PrincipalOrgID}",\n'
144
+ ' "aws:PrincipalTag/role": "security-admin"\n'
145
+ " },\n"
146
+ ' "Bool": {"aws:MultiFactorAuthPresent": "true"}\n'
147
+ "}"
148
+ ),
149
+ },
150
+ "ec2:RunInstances": {
151
+ "suggestion": (
152
+ "This action launches EC2 instances, which can incur costs and create security risks. Control instance creation:\n"
153
+ "• Require resource tagging (`aws:RequestTag/owner`, `aws:RequestTag/environment`)\n"
154
+ "• Restrict instance types (`ec2:InstanceType`)\n"
155
+ "• Limit to specific VPCs (`ec2:Vpc`)\n"
156
+ "• Enforce encryption (`ec2:Encrypted` = `true`)\n"
157
+ "• Match principal tags to resource tags for ownership tracking"
158
+ ),
159
+ "example": (
160
+ '"Condition": {\n'
161
+ ' "StringEquals": {\n'
162
+ ' "aws:RequestTag/owner": "${aws:PrincipalTag/owner}",\n'
163
+ ' "ec2:Vpc": "vpc-xxxxxxxxx"\n'
164
+ " },\n"
165
+ ' "Bool": {"ec2:Encrypted": "true"}\n'
166
+ "}"
167
+ ),
168
+ },
169
+ },
93
170
  },
94
171
  }
95
172