1# Copyright The Cloud Custodian Authors.
2# SPDX-License-Identifier: Apache-2.0
3import json
4
5from c7n.actions import RemovePolicyBase, Action, ModifyPolicyBase
6from c7n.exceptions import PolicyValidationError
7from c7n.filters import CrossAccountAccessFilter, Filter, ValueFilter, MetricsFilter
8from c7n.manager import resources
9from c7n.query import (
10 ConfigSource, DescribeSource, QueryResourceManager, TypeInfo,
11 ChildResourceManager, ChildDescribeSource, ChildResourceQuery, sources)
12from c7n import tags
13from c7n.utils import generate_arn, local_session, type_schema
14
15
16class ConfigECR(ConfigSource):
17
18 def load_resource(self, item):
19 resource = super().load_resource(item)
20 for configk, servicek in {
21 'RepositoryName': 'repositoryName',
22 'Arn': 'repositoryArn',
23 'RepositoryUri': 'repositoryUri',
24 'RepositoryPolicyText': 'Policy'}.items():
25 resource[servicek] = resource.pop(configk, None)
26 return resource
27
28
29class DescribeECR(DescribeSource):
30
31 def augment(self, resources):
32 client = local_session(self.manager.session_factory).client('ecr')
33 results = []
34 for r in resources:
35 try:
36 r['Tags'] = client.list_tags_for_resource(
37 resourceArn=r['repositoryArn']).get('tags')
38 results.append(r)
39 except client.exceptions.RepositoryNotFoundException:
40 continue
41 return results
42
43
44@resources.register('ecr')
45class ECR(QueryResourceManager):
46
47 class resource_type(TypeInfo):
48 service = 'ecr'
49 enum_spec = ('describe_repositories', 'repositories', None)
50 id = name = "repositoryName"
51 arn = "repositoryArn"
52 arn_type = 'repository'
53 filter_name = 'repositoryNames'
54 filter_type = 'list'
55 config_type = cfn_type = 'AWS::ECR::Repository'
56 dimension = 'RepositoryName'
57 permissions_augment = ("ecr:ListTagsForResource",)
58
59 source_mapping = {
60 'describe': DescribeECR,
61 'config': ConfigECR
62 }
63
64
65@ECR.filter_registry.register('metrics')
66class ECRMetricsFilter(MetricsFilter):
67 def get_dimensions(self, resource):
68 return [{"Name": "RepositoryName", "Value": resource['repositoryName']}]
69
70
71class ECRImageQuery(ChildResourceQuery):
72
73 def get(self, resource_manager, identities):
74 m = self.resolve(resource_manager.resource_type)
75 params = {'ImageIds': [identities]}
76 resources = self.filter(resource_manager, **params)
77 resources = [r for r in resources if "{}/{}".format(r[0], r[1][m.id]) in identities]
78 return resources
79
80
81@sources.register('describe-ecr-image')
82class RepositoryImageDescribeSource(ChildDescribeSource):
83
84 resource_query_factory = ECRImageQuery
85
86 def get_query(self):
87 return super().get_query(capture_parent_id=True)
88
89 def get_query_params(self, query):
90 query = query or {}
91 if 'query' not in self.manager.data:
92 return query
93 for q in self.manager.data['query']:
94 query.update(q)
95 return query
96
97 def augment(self, resources):
98 # construct an image arn
99 ecr_manager = self.manager.get_resource_manager(self.manager.resource_type.parent_spec[0])
100 rtype = ecr_manager.resource_type
101
102 repo_arn_map = {}
103 for repo_name in list({repo_name for repo_name, image in resources}):
104 repo_arn_map[repo_name] = generate_arn(
105 rtype.service,
106 region=self.manager.config.region,
107 account_id=self.manager.account_id,
108 resource_type=ecr_manager.resource_type.arn_type,
109 separator="/",
110 resource=repo_name
111 )
112
113 results = []
114 for repo_name, image in resources:
115 image['imageArn'] = "{}/{}".format(repo_arn_map[repo_name], image['imageDigest'])
116 results.append(image)
117 return results
118
119
120@resources.register('ecr-image')
121class RepositoryImage(ChildResourceManager):
122
123 class resource_type(TypeInfo):
124 service = 'ecr'
125 parent_spec = ('ecr', 'repositoryName', None)
126 enum_spec = ('describe_images', 'imageDetails', None)
127 id = 'imageDigest'
128 name = 'repositoryName'
129 arn = "imageArn"
130 arn_type = 'repository'
131
132 source_mapping = {
133 'describe-child': RepositoryImageDescribeSource,
134 'describe': RepositoryImageDescribeSource,
135 }
136
137
138ECR_POLICY_SCHEMA = {
139 'type': 'object',
140 'properties': {
141 'Sid': {'type': 'string'},
142 'Effect': {'type': 'string', 'enum': ['Allow', 'Deny']},
143 'Principal': {'anyOf': [
144 {'type': 'string'},
145 {'type': 'object'}, {'type': 'array'}]},
146 'NotPrincipal': {'anyOf': [{'type': 'object'}, {'type': 'array'}]},
147 'Action': {'anyOf': [{'type': 'string', 'pattern': '^ecr:([a-zA-Z]*|[*])$'},
148 {'type': 'array', 'items': {'type': 'string', 'pattern': '^ecr:([a-zA-Z]*|[*])$'}}]},
149 'NotAction': {'anyOf': [{'type': 'string', 'pattern': '^ecr:([a-zA-Z]*|[*])$'},
150 {'type': 'array', 'items': {'type': 'string', 'pattern': '^ecr:([a-zA-Z]*|[*])$'}}]},
151 'Resource': {'anyOf': [{'type': 'string'}, {'type': 'array'}]},
152 'NotResource': {'anyOf': [{'type': 'string'}, {'type': 'array'}]},
153 'Condition': {'type': 'object'}
154 },
155 'required': ['Sid', 'Effect'],
156 'oneOf': [
157 {'required': ['Principal', 'Action']},
158 {'required': ['NotPrincipal', 'Action']},
159 {'required': ['Principal', 'NotAction']},
160 {'required': ['NotPrincipal', 'NotAction']}
161 ]
162}
163
164
165@RepositoryImage.action_registry.register('modify-ecr-policy')
166@ECR.action_registry.register('modify-ecr-policy')
167class ModifyPolicyStatement(ModifyPolicyBase):
168 """Action to modify ECR policy statements.
169
170 :example:
171
172 .. code-block:: yaml
173
174 policies:
175 - name: ecr-image-prevent-pull
176 resource: ecr-image
177 filters:
178 - type: finding
179 actions:
180 - type: modify-ecr-policy
181 add-statements: [{
182 "Sid": "ReplaceWithMe",
183 "Effect": "Deny",
184 "Principal": "*",
185 "Action": ["ecr:BatchGetImage"]
186 }]
187 remove-statements: "*"
188 """
189 permissions = ('ecr:GetRepositoryPolicy', 'ecr:SetRepositoryPolicy')
190 schema = type_schema(
191 'modify-ecr-policy', schema_alias=False,
192 **{
193 'add-statements': {
194 'type': 'array',
195 'items': ECR_POLICY_SCHEMA,
196 },
197 'remove-statements': {
198 'type': ['array', 'string'],
199 'oneOf': [
200 {'enum': ['matched', '*']},
201 {'type': 'array', 'items': {'type': 'string'}}
202 ],
203 }
204 })
205
206 def process(self, resources):
207 results = []
208 client = local_session(self.manager.session_factory).client('ecr')
209 for r in resources:
210 try:
211 policy = json.loads(
212 client.get_repository_policy(
213 repositoryName=r["repositoryName"])["policyText"])
214 except client.exceptions.RepositoryPolicyNotFoundException:
215 policy = {}
216 policy_statements = policy.setdefault('Statement', [])
217 new_policy, removed = self.remove_statements(
218 policy_statements, r, CrossAccountAccessFilter.annotation_key)
219 if new_policy is None:
220 new_policy = policy_statements
221 new_policy, added = self.add_statements(new_policy)
222
223 if not removed and not added:
224 continue
225 elif not new_policy:
226 client.delete_repository_policy(
227 repositoryName=r['repositoryName'])
228 else:
229 cleaned = []
230 for statement in new_policy:
231 if "Resource" in statement:
232 del statement["Resource"]
233 cleaned.append(statement)
234 else:
235 cleaned.append(statement)
236 policy['Statement'] = cleaned
237 client.set_repository_policy(
238 repositoryName=r['repositoryName'],
239 policyText=json.dumps(policy))
240 results += {
241 'Name': r['repositoryName'],
242 'State': 'PolicyModified',
243 'Statements': new_policy
244 }
245
246 return results
247
248
249@ECR.action_registry.register('tag')
250class ECRTag(tags.Tag):
251
252 permissions = ('ecr:TagResource',)
253
254 def process_resource_set(self, client, resources, tags):
255 for r in resources:
256 try:
257 client.tag_resource(resourceArn=r['repositoryArn'], tags=tags)
258 except client.exceptions.RepositoryNotFoundException:
259 pass
260
261
262@ECR.action_registry.register('set-scanning')
263class ECRSetScanning(Action):
264
265 permissions = ('ecr:PutImageScanningConfiguration',)
266 schema = type_schema(
267 'set-scanning',
268 state={'type': 'boolean', 'default': True})
269
270 def process(self, resources):
271 client = local_session(self.manager.session_factory).client('ecr')
272 s = self.data.get('state', True)
273 for r in resources:
274 try:
275 client.put_image_scanning_configuration(
276 registryId=r['registryId'],
277 repositoryName=r['repositoryName'],
278 imageScanningConfiguration={
279 'scanOnPush': s})
280 except client.exceptions.RepositoryNotFoundException:
281 continue
282
283
284@ECR.action_registry.register('set-immutability')
285class ECRSetImmutability(Action):
286
287 permissions = ('ecr:PutImageTagMutability',)
288 schema = type_schema(
289 'set-immutability',
290 state={'type': 'boolean', 'default': True})
291
292 def process(self, resources):
293 client = local_session(self.manager.session_factory).client('ecr')
294 s = 'IMMUTABLE' if self.data.get('state', True) else 'MUTABLE'
295 for r in resources:
296 try:
297 client.put_image_tag_mutability(
298 registryId=r['registryId'],
299 repositoryName=r['repositoryName'],
300 imageTagMutability=s)
301 except client.exceptions.RepositoryNotFoundException:
302 continue
303
304
305@ECR.action_registry.register('remove-tag')
306class ECRRemoveTags(tags.RemoveTag):
307
308 permissions = ('ecr:UntagResource',)
309
310 def process_resource_set(self, client, resources, tags):
311 for r in resources:
312 try:
313 client.untag_resource(resourceArn=r['repositoryArn'], tagKeys=tags)
314 except client.exceptions.RepositoryNotFoundException:
315 pass
316
317
318ECR.filter_registry.register('marked-for-op', tags.TagActionFilter)
319ECR.action_registry.register('mark-for-op', tags.TagDelayedAction)
320
321
322@ECR.filter_registry.register('cross-account')
323class ECRCrossAccountAccessFilter(CrossAccountAccessFilter):
324 """Filters all EC2 Container Registries (ECR) with cross-account access
325
326 :example:
327
328 .. code-block:: yaml
329
330 policies:
331 - name: ecr-cross-account
332 resource: ecr
333 filters:
334 - type: cross-account
335 whitelist_from:
336 expr: "accounts.*.accountNumber"
337 url: accounts_url
338 """
339 permissions = ('ecr:GetRepositoryPolicy',)
340
341 def process(self, resources, event=None):
342
343 client = local_session(self.manager.session_factory).client('ecr')
344
345 def _augment(r):
346 if r.get('Policy') is not None:
347 return r
348 try:
349 r['Policy'] = client.get_repository_policy(
350 repositoryName=r['repositoryName'])['policyText']
351 except client.exceptions.RepositoryPolicyNotFoundException:
352 return None
353 return r
354
355 self.log.debug("fetching policy for %d repos" % len(resources))
356 with self.executor_factory(max_workers=2) as w:
357 resources = list(filter(None, w.map(_augment, resources)))
358
359 return super(ECRCrossAccountAccessFilter, self).process(resources, event)
360
361
362LIFECYCLE_RULE_SCHEMA = {
363 'type': 'object',
364 'additionalProperties': False,
365 'required': ['rulePriority', 'action', 'selection'],
366 'properties': {
367 'rulePriority': {'type': 'integer'},
368 'description': {'type': 'string'},
369 'action': {
370 'type': 'object',
371 'required': ['type'],
372 'additionalProperties': False,
373 'properties': {'type': {'enum': ['expire']}}},
374 'selection': {
375 'type': 'object',
376 'addtionalProperties': False,
377 'required': ['countType', 'countNumber', 'tagStatus'],
378 'properties': {
379 'tagStatus': {'enum': ['tagged', 'untagged', 'any']},
380 'tagPatternList': {'type': 'array', 'items': {'type': 'string'}},
381 'tagPrefixList': {'type': 'array', 'items': {'type': 'string'}},
382 'countNumber': {'type': 'integer'},
383 'countUnit': {'enum': ['hours', 'days']},
384 'countType': {
385 'enum': ['imageCountMoreThan', 'sinceImagePushed']},
386 }
387 }
388 }
389}
390
391
392def lifecycle_rule_validate(policy, rule):
393 # This is a non exhaustive list of lifecycle validation rules
394 # see this for a more comprehensive list
395 #
396 # https://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html#lp_evaluation_rules
397
398 if rule['selection']['tagStatus'] == 'tagged':
399 if ('tagPrefixList' not in rule['selection'] and
400 'tagPatternList' not in rule['selection']):
401 raise PolicyValidationError(
402 ("{} has invalid lifecycle rule {} tagPrefixList or tagPatternList "
403 "required for tagStatus: tagged").format(
404 policy.name, rule))
405 if (rule['selection']['countType'] == 'sinceImagePushed' and
406 'countUnit' not in rule['selection']):
407 raise PolicyValidationError(
408 ("{} has invalid lifecycle rule {} countUnit "
409 "required for countType: sinceImagePushed").format(
410 policy.name, rule))
411 if (rule['selection']['countType'] == 'imageCountMoreThan' and
412 'countUnit' in rule['selection']):
413 raise PolicyValidationError(
414 ("{} has invalid lifecycle rule {} countUnit "
415 "invalid for countType: imageCountMoreThan").format(
416 policy.name, rule))
417
418
419@ECR.filter_registry.register('lifecycle-rule')
420class LifecycleRule(Filter):
421 """Lifecycle rule filtering
422
423 :Example:
424
425 .. code-block:: yaml
426
427 policies:
428 - name: ecr-life
429 resource: aws.ecr
430 filters:
431 - type: lifecycle-rule
432 state: False
433 match:
434 - selection.tagStatus: untagged
435 - action.type: expire
436 - type: value
437 key: selection.countNumber
438 value: 30
439 op: less-than
440 """
441 permissions = ('ecr:GetLifecyclePolicy',)
442 schema = type_schema(
443 'lifecycle-rule',
444 state={'type': 'boolean'},
445 match={'type': 'array', 'items': {
446 'oneOf': [
447 {'$ref': '#/definitions/filters/value'},
448 {'type': 'object', 'minProperties': 1, 'maxProperties': 1},
449 ]}})
450 policy_annotation = 'c7n:lifecycle-policy'
451
452 def process(self, resources, event=None):
453 client = local_session(self.manager.session_factory).client('ecr')
454 for r in resources:
455 if self.policy_annotation in r:
456 continue
457 try:
458 r[self.policy_annotation] = json.loads(
459 client.get_lifecycle_policy(
460 repositoryName=r['repositoryName']).get(
461 'lifecyclePolicyText', ''))
462 except client.exceptions.LifecyclePolicyNotFoundException:
463 r[self.policy_annotation] = {}
464
465 state = self.data.get('state', False)
466 matchers = []
467 for matcher in self.data.get('match', []):
468 vf = ValueFilter(matcher)
469 vf.annotate = False
470 matchers.append(vf)
471
472 results = []
473 for r in resources:
474 found = False
475 for rule in r[self.policy_annotation].get('rules', []):
476 found = True
477 for m in matchers:
478 if not m(rule):
479 found = False
480 if found and state:
481 results.append(r)
482 if not found and not state:
483 results.append(r)
484 return results
485
486
487@ECR.action_registry.register('set-lifecycle')
488class SetLifecycle(Action):
489 """Set the lifecycle policy for ECR repositories.
490
491
492 Note at the moment this is limited to set/delete/replacement of
493 lifecycle policies, not merge.
494 """
495 permissions = ('ecr:PutLifecyclePolicy', 'ecr:DeleteLifecyclePolicy')
496
497 schema = type_schema(
498 'set-lifecycle',
499 state={'type': 'boolean'},
500 rules={
501 'type': 'array',
502 'items': LIFECYCLE_RULE_SCHEMA})
503
504 def validate(self):
505 if self.data.get('state') is False and 'rules' in self.data:
506 raise PolicyValidationError(
507 "set-lifecycle can't use statements and state: false")
508 elif self.data.get('state', True) and not self.data.get('rules'):
509 raise PolicyValidationError(
510 "set-lifecycle requires rules with state: true")
511 for r in self.data.get('rules', []):
512 lifecycle_rule_validate(self.manager.ctx.policy, r)
513 return self
514
515 def process(self, resources):
516 client = local_session(self.manager.session_factory).client('ecr')
517 state = self.data.get('state', True)
518 for r in resources:
519 if state is False:
520 try:
521 client.delete_lifecycle_policy(
522 registryId=r['registryId'],
523 repositoryName=r['repositoryName'])
524 continue
525 except client.exceptions.LifecyclePolicyNotFoundException:
526 pass
527 client.put_lifecycle_policy(
528 registryId=r['registryId'],
529 repositoryName=r['repositoryName'],
530 lifecyclePolicyText=json.dumps({'rules': self.data['rules']}))
531
532
533@ECR.action_registry.register('remove-statements')
534class RemovePolicyStatement(RemovePolicyBase):
535 """Action to remove policy statements from ECR
536
537 :example:
538
539 .. code-block:: yaml
540
541 policies:
542 - name: ecr-remove-cross-accounts
543 resource: ecr
544 filters:
545 - type: cross-account
546 actions:
547 - type: remove-statements
548 statement_ids: matched
549 """
550
551 permissions = ("ecr:SetRepositoryPolicy", "ecr:GetRepositoryPolicy")
552
553 def process(self, resources):
554 results = []
555 client = local_session(self.manager.session_factory).client('ecr')
556 for r in resources:
557 try:
558 results += filter(None, [self.process_resource(client, r)])
559 except Exception:
560 self.log.exception(
561 "Error processing ecr registry:%s", r['repositoryArn'])
562 return results
563
564 def process_resource(self, client, resource):
565 if 'Policy' not in resource:
566 try:
567 resource['Policy'] = client.get_repository_policy(
568 repositoryName=resource['repositoryName'])['policyText']
569 except client.exceptions.RepositoryPolicyNotFoundException:
570 return
571
572 p = json.loads(resource['Policy'])
573 statements, found = self.process_policy(
574 p, resource, CrossAccountAccessFilter.annotation_key)
575
576 if not found:
577 return
578
579 if not statements:
580 client.delete_repository_policy(
581 repositoryName=resource['repositoryName'])
582 else:
583 client.set_repository_policy(
584 repositoryName=resource['repositoryName'],
585 policyText=json.dumps(p))
586 return {'Name': resource['repositoryName'],
587 'State': 'PolicyRemoved',
588 'Statements': found}