1# Copyright The Cloud Custodian Authors.
2# SPDX-License-Identifier: Apache-2.0
3import re
4import datetime
5from datetime import timedelta
6from dateutil.tz import tzutc
7
8import itertools
9import logging
10
11from concurrent.futures import as_completed
12
13from c7n.actions import BaseAction
14from c7n.exceptions import ClientError, PolicyValidationError
15from c7n.filters import (
16 AgeFilter, ValueFilter, Filter, CrossAccountAccessFilter)
17from c7n.manager import resources
18from c7n.query import QueryResourceManager, DescribeSource, TypeInfo
19from c7n.resolver import ValuesFrom
20from c7n.utils import (
21 local_session,
22 type_schema,
23 chunks,
24 merge_dict_list,
25 parse_date,
26 jmespath_compile
27)
28from c7n import deprecated
29
30
31log = logging.getLogger('custodian.ami')
32
33
34class DescribeImageSource(DescribeSource):
35
36 def get_resources(self, ids, cache=True):
37 while ids:
38 try:
39 return super(DescribeImageSource, self).get_resources(ids, cache)
40 except ClientError as e:
41 bad_ami_ids = ErrorHandler.extract_bad_ami(e)
42 if bad_ami_ids:
43 for b in bad_ami_ids:
44 ids.remove(b)
45 continue
46 raise
47 return []
48
49
50@resources.register('ami')
51class AMI(QueryResourceManager):
52
53 class resource_type(TypeInfo):
54 service = 'ec2'
55 arn_type = 'image'
56 enum_spec = (
57 'describe_images', 'Images', None)
58 id = 'ImageId'
59 filter_name = 'ImageIds'
60 filter_type = 'list'
61 name = 'Name'
62 date = 'CreationDate'
63 id_prefix = "ami-"
64
65 source_mapping = {
66 'describe': DescribeImageSource
67 }
68
69 def resources(self, query=None):
70 if query is None and 'query' in self.data:
71 query = merge_dict_list(self.data['query'])
72 elif query is None:
73 query = {}
74 if query.get('Owners') is None:
75 query['Owners'] = ['self']
76 return super(AMI, self).resources(query=query)
77
78
79class ErrorHandler:
80
81 @staticmethod
82 def extract_bad_ami(e):
83 """Handle various client side errors when describing images"""
84 msg = e.response['Error']['Message']
85 error = e.response['Error']['Code']
86 e_ami_ids = None
87 if error == 'InvalidAMIID.NotFound':
88 e_ami_ids = [
89 e_ami_id.strip() for e_ami_id
90 in msg[msg.find("'[") + 2:msg.rfind("]'")].split(',')]
91 log.warning("Image not found %s" % e_ami_ids)
92 elif error == 'InvalidAMIID.Malformed':
93 e_ami_ids = [msg[msg.find('"') + 1:msg.rfind('"')]]
94 log.warning("Image id malformed %s" % e_ami_ids)
95 return e_ami_ids
96
97
98@AMI.action_registry.register('deregister')
99class Deregister(BaseAction):
100 """Action to deregister AMI
101
102 To prevent deregistering all AMI, it is advised to use in conjunction with
103 a filter (such as image-age)
104
105 :example:
106
107 .. code-block:: yaml
108
109 policies:
110 - name: ami-deregister-old
111 resource: ami
112 filters:
113 - type: image-age
114 days: 90
115 actions:
116 - deregister
117 """
118
119 schema = type_schema('deregister', **{'delete-snapshots': {'type': 'boolean'}})
120 permissions = ('ec2:DeregisterImage',)
121 snap_expr = jmespath_compile('BlockDeviceMappings[].Ebs.SnapshotId')
122
123 def process(self, images):
124 client = local_session(self.manager.session_factory).client('ec2')
125 image_count = len(images)
126 images = self.filter_resources(images, 'OwnerId', self.manager.ctx.options.account_id)
127 if len(images) != image_count:
128 self.log.info("Implicitly filtered %d non owned images", image_count - len(images))
129
130 for i in images:
131 self.manager.retry(client.deregister_image, ImageId=i['ImageId'])
132
133 if not self.data.get('delete-snapshots'):
134 continue
135 snap_ids = self.snap_expr.search(i) or ()
136 for s in snap_ids:
137 try:
138 self.manager.retry(client.delete_snapshot, SnapshotId=s)
139 except ClientError as e:
140 if e.response['Error']['Code'] == 'InvalidSnapshot.InUse':
141 continue
142
143
144@AMI.action_registry.register('set-deprecation')
145class SetDeprecation(BaseAction):
146 """Action to enable or disable AMI deprecation
147
148 To prevent deprecation of all AMIs, it is advised to use in conjunction with
149 a filter (such as image-age)
150
151 :example:
152
153 .. code-block:: yaml
154
155 policies:
156 - name: ami-deprecate-old
157 resource: ami
158 filters:
159 - type: image-age
160 days: 30
161 actions:
162 - type: set-deprecation
163 #Number of days from AMI creation
164 age: 90
165 #Number of days from now
166 #days: 90
167 #Specific date/time
168 #date: "2023-11-30"
169
170 """
171
172 schema = type_schema(
173 'set-deprecation',
174 date={'type': 'string'},
175 days={'type': 'integer'},
176 age={'type': 'integer'})
177 permissions = ('ec2:EnableImageDeprecation', 'ec2:DisableImageDeprecation')
178 dep_date = None
179 dep_age = None
180
181 def validate(self):
182 try:
183 if 'date' in self.data:
184 self.dep_date = parse_date(self.data.get('date'))
185 if not self.dep_date:
186 raise PolicyValidationError(
187 "policy:%s filter:%s has invalid date format" % (
188 self.manager.ctx.policy.name, self.type))
189 elif 'days' in self.data:
190 self.dep_date = (datetime.datetime.now(tz=tzutc()) +
191 timedelta(days=int(self.data.get('days'))))
192 elif 'age' in self.data:
193 self.dep_age = (int(self.data.get('age')))
194 except (ValueError, OverflowError):
195 raise PolicyValidationError(
196 "policy:%s filter:%s has invalid time interval" % (
197 self.manager.ctx.policy.name, self.type))
198
199 def process(self, images):
200 client = local_session(self.manager.session_factory).client('ec2')
201 image_count = len(images)
202 images = self.filter_resources(images, 'OwnerId', self.manager.ctx.options.account_id)
203 if len(images) != image_count:
204 self.log.info("Implicitly filtered %d non owned images", image_count - len(images))
205 for i in images:
206 if not self.dep_date and not self.dep_age:
207 self.manager.retry(client.disable_image_deprecation, ImageId=i['ImageId'])
208 else:
209 if self.dep_age:
210 date = parse_date(i['CreationDate']) + timedelta(days=self.dep_age)
211 else:
212 date = self.dep_date
213 # Hack because AWS won't let you set a deprecation time in the
214 # past - set to now + 1 minute if the time is in the past
215 if date < datetime.datetime.now(tz=tzutc()):
216 odate = str(date)
217 date = datetime.datetime.now(tz=tzutc()) + timedelta(minutes=1)
218 log.warning("Deprecation time %s is in the past for Image %s. Setting to %s.",
219 odate, i['ImageId'], date)
220 self.manager.retry(client.enable_image_deprecation,
221 ImageId=i['ImageId'], DeprecateAt=date)
222
223
224@AMI.action_registry.register('remove-launch-permissions')
225class RemoveLaunchPermissions(BaseAction):
226 """Action to remove the ability to launch an instance from an AMI
227
228 DEPRECATED - use set-permissions instead to support AWS Organizations
229 sharing as well as adding permissions
230
231 This action will remove any launch permissions granted to other
232 AWS accounts from the image, leaving only the owner capable of
233 launching it
234
235 :example:
236
237 .. code-block:: yaml
238
239 policies:
240 - name: ami-stop-share-old
241 resource: ami
242 filters:
243 - type: image-age
244 days: 60
245 actions:
246 - type: remove-launch-permissions
247
248 """
249 deprecations = (
250 deprecated.action("use set-permissions instead with 'remove' attribute"),
251 )
252 schema = type_schema(
253 'remove-launch-permissions',
254 accounts={'oneOf': [
255 {'enum': ['matched']},
256 {'type': 'string', 'minLength': 12, 'maxLength': 12}]})
257
258 permissions = ('ec2:ResetImageAttribute', 'ec2:ModifyImageAttribute',)
259
260 def validate(self):
261 if 'accounts' in self.data and self.data['accounts'] == 'matched':
262 found = False
263 for f in self.manager.iter_filters():
264 if isinstance(f, AmiCrossAccountFilter):
265 found = True
266 break
267 if not found:
268 raise PolicyValidationError(
269 "policy:%s filter:%s with matched requires cross-account filter" % (
270 self.manager.ctx.policy.name, self.type))
271
272 def process(self, images):
273 client = local_session(self.manager.session_factory).client('ec2')
274 for i in images:
275 self.process_image(client, i)
276
277 def process_image(self, client, image):
278 accounts = self.data.get('accounts')
279 if not accounts:
280 return client.reset_image_attribute(
281 ImageId=image['ImageId'], Attribute="launchPermission")
282 if accounts == 'matched':
283 accounts = image.get(AmiCrossAccountFilter.annotation_key)
284 if not accounts:
285 return
286 remove = []
287 if 'all' in accounts:
288 remove.append({'Group': 'all'})
289 accounts.remove('all')
290 remove.extend([{'UserId': a} for a in accounts if not a.startswith('arn:')])
291 if not remove:
292 return
293 client.modify_image_attribute(
294 ImageId=image['ImageId'],
295 LaunchPermission={'Remove': remove},
296 OperationType='remove')
297
298
299@AMI.action_registry.register('cancel-launch-permission')
300class CancelLaunchPermissions(BaseAction):
301 """Action to cancel this account's access to another another account's shared AMI
302
303 If another AWS account shares an image with your account, and you
304 no longer want to allow its use in your account, this action will
305 remove the permission for your account to laucnh from the image.
306
307 As this is not reversible without accessing the AMI source account, it defaults
308 to running in dryrun mode. Set dryrun to false to enforce.
309
310 Note this does not apply to AMIs shared by Organization or OU.
311 https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cancel-sharing-an-AMI.html
312
313 :example:
314
315 .. code-block:: yaml
316
317 policies:
318 - name: ami-cancel-share-to-me-old
319 resource: ami
320 query:
321 - ExecutableUsers: [self]
322 - Owners: []
323 filters:
324 - type: image-age
325 days: 90
326 actions:
327 - type: cancel-launch-permission
328
329 """
330 schema = type_schema('cancel-launch-permission', dryrun={'type': 'boolean'})
331
332 permissions = ('ec2:CancelImageLaunchPermission',)
333
334 def process(self, images):
335 client = local_session(self.manager.session_factory).client('ec2')
336 for i in images:
337 self.process_image(client, i)
338
339 def process_image(self, client, image):
340 client.cancel_image_launch_permission(
341 ImageId=image['ImageId'],
342 DryRun=self.data.get('dryrun', True))
343
344
345@AMI.action_registry.register('set-permissions')
346class SetPermissions(BaseAction):
347 """Set or remove AMI launch permissions
348
349 This action will add or remove launch permissions granted to other
350 AWS accounts, organizations or organizational units from the image.
351
352 Use the 'add' and 'remove' parameters to control which principals
353 to add or remove, respectively. The default is to remove any permissions
354 granted to other AWS accounts. Principals can be an AWS account id,
355 an organization ARN, or an organizational unit ARN
356
357 Use 'remove: matched' in combination with the 'cross-account' filter
358 for more flexible removal options such as preserving access for a set of
359 whitelisted accounts:
360
361 :example:
362
363 .. code-block:: yaml
364
365 policies:
366 - name: ami-share-remove-cross-account
367 resource: ami
368 filters:
369 - type: cross-account
370 whitelist:
371 - '112233445566'
372 - 'arn:aws:organizations::112233445566:organization/o-xxyyzzaabb'
373 - 'arn:aws:organizations::112233445566:ou/o-xxyyzzaabb/ou-xxyy-aabbccdd'
374 actions:
375 - type: set-permissions
376 remove: matched
377 # To remove all permissions
378 # - type: set-permissions
379 # To remove public permissions
380 # - type: set-permissions
381 # remove:
382 # - all
383 # To remove specific permissions
384 # - type: set-permissions
385 # remove:
386 # - '223344556677'
387 # - 'arn:aws:organizations::112233445566:organization/o-zzyyxxbbaa'
388 # - 'arn:aws:organizations::112233445566:ou/o-zzyyxxbbaa/ou-xxyy-ddccbbaa'
389 # To set specific permissions
390 # - type: set-permissions
391 # remove: matched
392 # add:
393 # - '223344556677'
394 # - 'arn:aws:organizations::112233445566:organization/o-zzyyxxbbaa'
395 # - 'arn:aws:organizations::112233445566:ou/o-zzyyxxbbaa/ou-xxyy-ddccbbaa'
396 """
397
398 schema = type_schema(
399 'set-permissions',
400 remove={'oneOf': [
401 {'enum': ['matched']},
402 {'type': 'array', 'items': {'type': 'string'}}
403 ]},
404 add={'type': 'array', 'items': {'type': 'string'}}
405 )
406
407 permissions = ('ec2:ResetImageAttribute', 'ec2:ModifyImageAttribute',)
408
409 def validate(self):
410 if self.data.get('remove') == 'matched':
411 found = False
412 for f in self.manager.iter_filters():
413 if isinstance(f, AmiCrossAccountFilter):
414 found = True
415 break
416 if not found:
417 raise PolicyValidationError(
418 "policy:%s filter:%s with matched requires cross-account filter" % (
419 self.manager.ctx.policy.name, self.type))
420
421 def process(self, images):
422 client = local_session(self.manager.session_factory).client('ec2')
423 for i in images:
424 self.process_image(client, i)
425
426 def process_image(self, client, image):
427 to_add = self.data.get('add')
428 to_remove = self.data.get('remove')
429 # Default is to remove all permissions
430 if not to_add and not to_remove:
431 return client.reset_image_attribute(
432 ImageId=image['ImageId'], Attribute="launchPermission")
433 remove = []
434 add = []
435 account_regex = re.compile('\\d{12}')
436 # https://docs.aws.amazon.com/organizations/latest/APIReference/API_Organization.html
437 org_regex = re.compile(
438 r'arn:[a-zA-Z-]+:organizations::\d{12}:organization\/o-[a-z0-9]{10,32}'
439 )
440 # https://docs.aws.amazon.com/organizations/latest/APIReference/API_OrganizationalUnit.html
441 ou_regex = re.compile(
442 r'arn:[a-zA-Z-]+:organizations::\d{12}:ou\/o-[a-z0-9]{10,32}\/ou-[0-9a-z]{4,32}-[0-9a-z]{8,32}'
443 )
444 if to_remove:
445 if 'all' in to_remove:
446 remove.append({'Group': 'all'})
447 to_remove.remove('all')
448 if to_remove == 'matched':
449 to_remove = image.get(AmiCrossAccountFilter.annotation_key)
450 if to_remove:
451 principals = [v for v in to_remove if account_regex.match(v)]
452 if principals:
453 remove.extend([{'UserId': a} for a in principals])
454 principals = [v for v in to_remove if org_regex.match(v)]
455 if principals:
456 remove.extend([{'OrganizationArn': a} for a in principals])
457 principals = [v for v in to_remove if ou_regex.match(v)]
458 if principals:
459 remove.extend([{'OrganizationalUnitArn': a} for a in principals])
460
461 if to_add:
462 if 'all' in to_add:
463 add.append({'Group': 'all'})
464 to_add.remove('all')
465 if to_add:
466 principals = [v for v in to_add if account_regex.match(v)]
467 if principals:
468 add.extend([{'UserId': a} for a in principals])
469 principals = [v for v in to_add if org_regex.match(v)]
470 if principals:
471 add.extend([{'OrganizationArn': a} for a in principals])
472 principals = [v for v in to_add if ou_regex.match(v)]
473 if principals:
474 add.extend([{'OrganizationalUnitArn': a} for a in principals])
475
476 if remove:
477 self.manager.retry(client.modify_image_attribute,
478 ImageId=image['ImageId'],
479 LaunchPermission={'Remove': remove},
480 OperationType='remove')
481
482 if add:
483 self.manager.retry(client.modify_image_attribute,
484 ImageId=image['ImageId'],
485 LaunchPermission={'Add': add},
486 OperationType='add')
487
488
489@AMI.action_registry.register('copy')
490class Copy(BaseAction):
491 """Action to copy AMIs with optional encryption
492
493 This action can copy AMIs while optionally encrypting or decrypting
494 the target AMI. It is advised to use in conjunction with a filter.
495
496 Note there is a max in flight of 5 per account/region.
497
498 :example:
499
500 .. code-block:: yaml
501
502 policies:
503 - name: ami-ensure-encrypted
504 resource: ami
505 filters:
506 - type: value
507 key: encrypted
508 value: true
509 actions:
510 - type: copy
511 encrypt: true
512 key-id: 00000000-0000-0000-0000-000000000000
513 """
514
515 permissions = ('ec2:CopyImage',)
516 schema = {
517 'type': 'object',
518 'additionalProperties': False,
519 'properties': {
520 'type': {'enum': ['copy']},
521 'name': {'type': 'string'},
522 'description': {'type': 'string'},
523 'region': {'type': 'string'},
524 'encrypt': {'type': 'boolean'},
525 'key-id': {'type': 'string'}
526 }
527 }
528
529 def process(self, images):
530 session = local_session(self.manager.session_factory)
531 client = session.client(
532 'ec2',
533 region_name=self.data.get('region', None))
534
535 for image in images:
536 client.copy_image(
537 Name=self.data.get('name', image['Name']),
538 Description=self.data.get('description', image['Description']),
539 SourceRegion=session.region_name,
540 SourceImageId=image['ImageId'],
541 Encrypted=self.data.get('encrypt', False),
542 KmsKeyId=self.data.get('key-id', ''))
543
544
545@AMI.filter_registry.register('image-age')
546class ImageAgeFilter(AgeFilter):
547 """Filters images based on the age (in days)
548
549 :example:
550
551 .. code-block:: yaml
552
553 policies:
554 - name: ami-remove-launch-permissions
555 resource: ami
556 filters:
557 - type: image-age
558 days: 30
559 """
560
561 date_attribute = "CreationDate"
562 schema = type_schema(
563 'image-age',
564 op={'$ref': '#/definitions/filters_common/comparison_operators'},
565 days={'type': 'number', 'minimum': 0})
566
567
568@AMI.filter_registry.register('unused')
569class ImageUnusedFilter(Filter):
570 """Filters images based on usage
571
572 true: image has no instances spawned from it
573 false: image has instances spawned from it
574
575 :example:
576
577 .. code-block:: yaml
578
579 policies:
580 - name: ami-unused
581 resource: ami
582 filters:
583 - type: unused
584 value: true
585 """
586
587 schema = type_schema('unused', value={'type': 'boolean'})
588
589 def get_permissions(self):
590 return list(itertools.chain(*[
591 self.manager.get_resource_manager(m).get_permissions()
592 for m in ('asg', 'launch-config', 'ec2')]))
593
594 def _pull_asg_images(self):
595 asgs = self.manager.get_resource_manager('asg').resources()
596 image_ids = set()
597 lcfgs = set(a['LaunchConfigurationName'] for a in asgs if 'LaunchConfigurationName' in a)
598 lcfg_mgr = self.manager.get_resource_manager('launch-config')
599
600 if lcfgs:
601 image_ids.update([
602 lcfg['ImageId'] for lcfg in lcfg_mgr.resources()
603 if lcfg['LaunchConfigurationName'] in lcfgs])
604
605 tmpl_mgr = self.manager.get_resource_manager('launch-template-version')
606 for tversion in tmpl_mgr.get_resources(
607 list(tmpl_mgr.get_asg_templates(asgs).keys())):
608 image_ids.add(tversion['LaunchTemplateData'].get('ImageId'))
609 return image_ids
610
611 def _pull_ec2_images(self):
612 ec2_manager = self.manager.get_resource_manager('ec2')
613 return {i['ImageId'] for i in ec2_manager.resources()}
614
615 def process(self, resources, event=None):
616 images = self._pull_ec2_images().union(self._pull_asg_images())
617 if self.data.get('value', True):
618 return [r for r in resources if r['ImageId'] not in images]
619 return [r for r in resources if r['ImageId'] in images]
620
621
622@AMI.filter_registry.register('cross-account')
623class AmiCrossAccountFilter(CrossAccountAccessFilter):
624
625 schema = type_schema(
626 'cross-account',
627 # white list accounts
628 whitelist_from=ValuesFrom.schema,
629 whitelist={'type': 'array', 'items': {'type': 'string'}})
630
631 permissions = ('ec2:DescribeImageAttribute',)
632 annotation_key = 'c7n:CrossAccountViolations'
633
634 def process_resource_set(self, client, accounts, resource_set):
635 results = []
636 for r in resource_set:
637 attrs = self.manager.retry(
638 client.describe_image_attribute,
639 ImageId=r['ImageId'],
640 Attribute='launchPermission')['LaunchPermissions']
641 r['c7n:LaunchPermissions'] = attrs
642 image_accounts = {
643 a.get('Group') or a.get('UserId') or
644 a.get('OrganizationArn') or a.get('OrganizationalUnitArn')
645 for a in attrs
646 }
647 delta_accounts = image_accounts.difference(accounts)
648 if delta_accounts:
649 r[self.annotation_key] = list(delta_accounts)
650 results.append(r)
651 return results
652
653 def process(self, resources, event=None):
654 results = []
655 client = local_session(self.manager.session_factory).client('ec2')
656 accounts = self.get_accounts()
657 with self.executor_factory(max_workers=2) as w:
658 futures = []
659 for resource_set in chunks(resources, 20):
660 futures.append(
661 w.submit(
662 self.process_resource_set, client, accounts, resource_set))
663 for f in as_completed(futures):
664 if f.exception():
665 self.log.error(
666 "Exception checking cross account access \n %s" % (
667 f.exception()))
668 continue
669 results.extend(f.result())
670 return results
671
672
673@AMI.filter_registry.register('image-attribute')
674class ImageAttribute(ValueFilter):
675 """AMI Image Value Filter on a given image attribute.
676
677 Filters AMI's with the given AMI attribute
678
679 :example:
680
681 .. code-block:: yaml
682
683 policies:
684 - name: ami-unused-recently
685 resource: ami
686 filters:
687 - type: image-attribute
688 attribute: lastLaunchedTime
689 key: "Value"
690 op: gte
691 value_type: age
692 value: 30
693 """
694
695 valid_attrs = (
696 'description',
697 'kernel',
698 'ramdisk',
699 'launchPermissions',
700 'productCodes',
701 'blockDeviceMapping',
702 'sriovNetSupport',
703 'bootMode',
704 'tpmSupport',
705 'uefiData',
706 'lastLaunchedTime',
707 'imdsSupport'
708 )
709
710 schema = type_schema(
711 'image-attribute',
712 rinherit=ValueFilter.schema,
713 attribute={'enum': valid_attrs},
714 required=('attribute',))
715 schema_alias = False
716
717 def get_permissions(self):
718 return ('ec2:DescribeImageAttribute',)
719
720 def process(self, resources, event=None):
721 attribute = self.data['attribute']
722 self.get_image_attribute(resources, attribute)
723 return [resource for resource in resources
724 if self.match(resource['c7n:attribute-%s' % attribute])]
725
726 def get_image_attribute(self, resources, attribute):
727 client = local_session(
728 self.manager.session_factory).client('ec2')
729
730 for resource in resources:
731 image_id = resource['ImageId']
732 fetched_attribute = self.manager.retry(
733 client.describe_image_attribute,
734 ImageId=image_id,
735 Attribute=attribute)
736 keys = set(fetched_attribute) - {'ResponseMetadata', 'ImageId'}
737 resource['c7n:attribute-%s' % attribute] = fetched_attribute[keys.pop()]