From b8405b39b57fba166a88bae093217bb1ea14369e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Gr=C3=BCbel?= Date: Thu, 12 Aug 2021 14:06:21 +0900 Subject: [PATCH] Add ecr image scan (#4166) * Add ecr.start_image_scan * Add ecr.describe_image_scan_findings --- moto/ecr/exceptions.py | 24 +++ moto/ecr/models.py | 144 ++++++++++++---- moto/ecr/responses.py | 26 +++ tests/test_ecr/test_ecr_boto3.py | 277 ++++++++++++++++++++++++++++++- 4 files changed, 435 insertions(+), 36 deletions(-) diff --git a/moto/ecr/exceptions.py b/moto/ecr/exceptions.py index b1086fee6..211917a11 100644 --- a/moto/ecr/exceptions.py +++ b/moto/ecr/exceptions.py @@ -16,6 +16,16 @@ class LifecyclePolicyNotFoundException(JsonRESTError): ) +class LimitExceededException(JsonRESTError): + code = 400 + + def __init__(self): + super().__init__( + error_type=__class__.__name__, + message=("The scan quota per image has been exceeded. Wait and try again."), + ) + + class RegistryPolicyNotFoundException(JsonRESTError): code = 400 @@ -101,3 +111,17 @@ class InvalidParameterException(JsonRESTError): def __init__(self, message): super().__init__(error_type=__class__.__name__, message=message) + + +class ScanNotFoundException(JsonRESTError): + code = 400 + + def __init__(self, image_id, repository_name, registry_id): + super().__init__( + error_type=__class__.__name__, + message=( + f"Image scan does not exist for the image with '{image_id}' " + f"in the repository with name '{repository_name}' " + f"in the registry with id '{registry_id}'" + ), + ) diff --git a/moto/ecr/models.py b/moto/ecr/models.py index 78575e5ec..648223a97 100644 --- a/moto/ecr/models.py +++ b/moto/ecr/models.py @@ -7,7 +7,7 @@ import uuid from collections import namedtuple from datetime import datetime from random import random -from typing import Dict +from typing import Dict, List from botocore.exceptions import ParamValidationError @@ -23,6 +23,8 @@ from moto.ecr.exceptions import ( RepositoryPolicyNotFoundException, LifecyclePolicyNotFoundException, RegistryPolicyNotFoundException, + LimitExceededException, + ScanNotFoundException, ) from moto.ecr.policy_validation import EcrLifecyclePolicyValidator from moto.iam.exceptions import MalformedPolicyDocument @@ -87,7 +89,7 @@ class Repository(BaseObject, CloudFormationModel): ) self.policy = None self.lifecycle_policy = None - self.images = [] + self.images: List[Image] = [] def _determine_encryption_config(self, encryption_config): if not encryption_config: @@ -98,6 +100,31 @@ class Repository(BaseObject, CloudFormationModel): ] = f"arn:aws:kms:{self.region_name}:{ACCOUNT_ID}:key/{uuid.uuid4()}" return encryption_config + def _get_image(self, image_tag, image_digest): + # you can either search for one or both + image = next( + ( + i + for i in self.images + if (not image_tag or image_tag in i.image_tags) + and (not image_digest or image_digest == i.get_image_digest()) + ), + None, + ) + + if not image: + image_id_rep = "{{imageDigest:'{0}', imageTag:'{1}'}}".format( + image_digest or "null", image_tag or "null" + ) + + raise ImageNotFoundException( + image_id=image_id_rep, + repository_name=self.name, + registry_id=self.registry_id, + ) + + return image + @property def physical_resource_id(self): return self.name @@ -210,6 +237,7 @@ class Image(BaseObject): self.registry_id = registry_id self.image_digest = digest self.image_pushed_at = str(datetime.utcnow().isoformat()) + self.last_scan = None def _create_digest(self): image_contents = "docker_image{0}".format(int(random() * 10 ** 6)) @@ -407,38 +435,15 @@ class ECRBackend(BaseBackend): return images def describe_images(self, repository_name, registry_id=None, image_ids=None): - - if repository_name in self.repositories: - repository = self.repositories[repository_name] - else: - raise RepositoryNotFoundException( - repository_name, registry_id or DEFAULT_REGISTRY_ID - ) + repository = self._get_repository(repository_name, registry_id) if image_ids: - response = set() - for image_id in image_ids: - found = False - for image in repository.images: - if ( - "imageDigest" in image_id - and image.get_image_digest() == image_id["imageDigest"] - ) or ( - "imageTag" in image_id - and image_id["imageTag"] in image.image_tags - ): - found = True - response.add(image) - if not found: - image_id_representation = "{imageDigest:'%s', imageTag:'%s'}" % ( - image_id.get("imageDigest", "null"), - image_id.get("imageTag", "null"), - ) - raise ImageNotFoundException( - image_id=image_id_representation, - repository_name=repository_name, - registry_id=registry_id or DEFAULT_REGISTRY_ID, - ) + response = set( + repository._get_image( + image_id.get("imageTag"), image_id.get("imageDigest") + ) + for image_id in image_ids + ) else: response = [] @@ -816,6 +821,81 @@ class ECRBackend(BaseBackend): "policyText": policy, } + def start_image_scan(self, registry_id, repository_name, image_id): + repo = self._get_repository(repository_name, registry_id) + + image = repo._get_image(image_id.get("imageTag"), image_id.get("imageDigest")) + + # scanning an image is only allowed once per day + if image.last_scan and image.last_scan.date() == datetime.today().date(): + raise LimitExceededException() + + image.last_scan = datetime.today() + + return { + "registryId": repo.registry_id, + "repositoryName": repository_name, + "imageId": { + "imageDigest": image.image_digest, + "imageTag": image.image_tag, + }, + "imageScanStatus": {"status": "IN_PROGRESS"}, + } + + def describe_image_scan_findings(self, registry_id, repository_name, image_id): + repo = self._get_repository(repository_name, registry_id) + + image = repo._get_image(image_id.get("imageTag"), image_id.get("imageDigest")) + + if not image.last_scan: + image_id_rep = "{{imageDigest:'{0}', imageTag:'{1}'}}".format( + image_id.get("imageDigest") or "null", + image_id.get("imageTag") or "null", + ) + raise ScanNotFoundException( + image_id=image_id_rep, + repository_name=repository_name, + registry_id=repo.registry_id, + ) + + return { + "registryId": repo.registry_id, + "repositoryName": repository_name, + "imageId": { + "imageDigest": image.image_digest, + "imageTag": image.image_tag, + }, + "imageScanStatus": { + "status": "COMPLETE", + "description": "The scan was completed successfully.", + }, + "imageScanFindings": { + "imageScanCompletedAt": iso_8601_datetime_without_milliseconds( + image.last_scan + ), + "vulnerabilitySourceUpdatedAt": iso_8601_datetime_without_milliseconds( + datetime.utcnow() + ), + "findings": [ + { + "name": "CVE-9999-9999", + "uri": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-9999-9999", + "severity": "HIGH", + "attributes": [ + {"key": "package_version", "value": "9.9.9"}, + {"key": "package_name", "value": "moto_fake"}, + { + "key": "CVSS2_VECTOR", + "value": "AV:N/AC:L/Au:N/C:P/I:P/A:P", + }, + {"key": "CVSS2_SCORE", "value": "7.5"}, + ], + } + ], + "findingSeverityCounts": {"HIGH": 1}, + }, + } + ecr_backends = {} for region, ec2_backend in ec2_backends.items(): diff --git a/moto/ecr/responses.py b/moto/ecr/responses.py index 2f070afa6..5b29947cd 100644 --- a/moto/ecr/responses.py +++ b/moto/ecr/responses.py @@ -271,3 +271,29 @@ class ECRResponse(BaseResponse): def delete_registry_policy(self): return json.dumps(self.ecr_backend.delete_registry_policy()) + + def start_image_scan(self): + registry_id = self._get_param("registryId") + repository_name = self._get_param("repositoryName") + image_id = self._get_param("imageId") + + return json.dumps( + self.ecr_backend.start_image_scan( + registry_id=registry_id, + repository_name=repository_name, + image_id=image_id, + ) + ) + + def describe_image_scan_findings(self): + registry_id = self._get_param("registryId") + repository_name = self._get_param("repositoryName") + image_id = self._get_param("imageId") + + return json.dumps( + self.ecr_backend.describe_image_scan_findings( + registry_id=registry_id, + repository_name=repository_name, + image_id=image_id, + ) + ) diff --git a/tests/test_ecr/test_ecr_boto3.py b/tests/test_ecr/test_ecr_boto3.py index 501ea2e70..d53621903 100644 --- a/tests/test_ecr/test_ecr_boto3.py +++ b/tests/test_ecr/test_ecr_boto3.py @@ -651,24 +651,24 @@ def test_describe_image_that_doesnt_exist(): error_msg1 = re.compile( r".*The image with imageId {imageDigest:'null', imageTag:'testtag'} does not exist within " - r"the repository with name 'test_repository' in the registry with id '123'.*", + r"the repository with name 'test_repository' in the registry with id '123456789012'.*", re.MULTILINE, ) client.describe_images.when.called_with( repositoryName="test_repository", imageIds=[{"imageTag": "testtag"}], - registryId="123", + registryId=ACCOUNT_ID, ).should.throw(client.exceptions.ImageNotFoundException, error_msg1) error_msg2 = re.compile( - r".*The repository with name 'repo-that-doesnt-exist' does not exist in the registry with id '123'.*", + r".*The repository with name 'repo-that-doesnt-exist' does not exist in the registry with id '123456789012'.*", re.MULTILINE, ) client.describe_images.when.called_with( repositoryName="repo-that-doesnt-exist", imageIds=[{"imageTag": "testtag"}], - registryId="123", + registryId=ACCOUNT_ID, ).should.throw(ClientError, error_msg2) @@ -2113,3 +2113,272 @@ def test_delete_registry_policy_error_policy_not_exists(): ex.response["Error"]["Message"].should.equal( f"Registry policy does not exist in the registry with id '{ACCOUNT_ID}'" ) + + +@mock_ecr +def test_start_image_scan(): + # given + client = boto3.client("ecr", region_name="eu-central-1") + repo_name = "test-repo" + client.create_repository(repositoryName=repo_name) + image_tag = "latest" + image_digest = client.put_image( + repositoryName=repo_name, + imageManifest=json.dumps(_create_image_manifest()), + imageTag="latest", + )["image"]["imageId"]["imageDigest"] + + # when + response = client.start_image_scan( + repositoryName=repo_name, imageId={"imageTag": image_tag} + ) + + # then + response["registryId"].should.equal(ACCOUNT_ID) + response["repositoryName"].should.equal(repo_name) + response["imageId"].should.equal( + {"imageDigest": image_digest, "imageTag": image_tag} + ) + response["imageScanStatus"].should.equal({"status": "IN_PROGRESS"}) + + +@mock_ecr +def test_start_image_scan_error_repo_not_exists(): + # given + region_name = "eu-central-1" + client = boto3.client("ecr", region_name=region_name) + repo_name = "not-exists" + + # when + with pytest.raises(ClientError) as e: + client.start_image_scan( + repositoryName=repo_name, imageId={"imageTag": "latest"} + ) + + # then + ex = e.value + ex.operation_name.should.equal("StartImageScan") + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("RepositoryNotFoundException") + ex.response["Error"]["Message"].should.equal( + f"The repository with name '{repo_name}' does not exist " + f"in the registry with id '{ACCOUNT_ID}'" + ) + + +@mock_ecr +def test_start_image_scan_error_image_not_exists(): + # given + client = boto3.client("ecr", region_name="eu-central-1") + repo_name = "test-repo" + client.create_repository(repositoryName=repo_name) + image_tag = "not-exists" + + # when + with pytest.raises(ClientError) as e: + client.start_image_scan( + repositoryName=repo_name, imageId={"imageTag": image_tag} + ) + + # then + ex = e.value + ex.operation_name.should.equal("StartImageScan") + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("ImageNotFoundException") + ex.response["Error"]["Message"].should.equal( + f"The image with imageId {{imageDigest:'null', imageTag:'{image_tag}'}} does not exist " + f"within the repository with name '{repo_name}' " + f"in the registry with id '{ACCOUNT_ID}'" + ) + + +@mock_ecr +def test_start_image_scan_error_image_tag_digest_mismatch(): + # given + client = boto3.client("ecr", region_name="eu-central-1") + repo_name = "test-repo" + client.create_repository(repositoryName=repo_name) + image_digest = client.put_image( + repositoryName=repo_name, + imageManifest=json.dumps(_create_image_manifest()), + imageTag="latest", + )["image"]["imageId"]["imageDigest"] + image_tag = "not-latest" + + # when + with pytest.raises(ClientError) as e: + client.start_image_scan( + repositoryName=repo_name, + imageId={"imageTag": image_tag, "imageDigest": image_digest}, + ) + + # then + ex = e.value + ex.operation_name.should.equal("StartImageScan") + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("ImageNotFoundException") + ex.response["Error"]["Message"].should.equal( + f"The image with imageId {{imageDigest:'{image_digest}', imageTag:'{image_tag}'}} does not exist " + f"within the repository with name '{repo_name}' " + f"in the registry with id '{ACCOUNT_ID}'" + ) + + +@mock_ecr +def test_start_image_scan_error_daily_limit(): + # given + client = boto3.client("ecr", region_name="eu-central-1") + repo_name = "test-repo" + client.create_repository(repositoryName=repo_name) + image_tag = "latest" + image_digest = client.put_image( + repositoryName=repo_name, + imageManifest=json.dumps(_create_image_manifest()), + imageTag="latest", + )["image"]["imageId"]["imageDigest"] + client.start_image_scan(repositoryName=repo_name, imageId={"imageTag": image_tag}) + + # when + with pytest.raises(ClientError) as e: + client.start_image_scan( + repositoryName=repo_name, imageId={"imageTag": image_tag} + ) + + # then + ex = e.value + ex.operation_name.should.equal("StartImageScan") + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("LimitExceededException") + ex.response["Error"]["Message"].should.equal( + "The scan quota per image has been exceeded. Wait and try again." + ) + + +@mock_ecr +def test_describe_image_scan_findings(): + # given + client = boto3.client("ecr", region_name="eu-central-1") + repo_name = "test-repo" + client.create_repository(repositoryName=repo_name) + image_tag = "latest" + image_digest = client.put_image( + repositoryName=repo_name, + imageManifest=json.dumps(_create_image_manifest()), + imageTag="latest", + )["image"]["imageId"]["imageDigest"] + client.start_image_scan(repositoryName=repo_name, imageId={"imageTag": image_tag}) + + # when + response = client.describe_image_scan_findings( + repositoryName=repo_name, imageId={"imageTag": image_tag} + ) + + # then + response["registryId"].should.equal(ACCOUNT_ID) + response["repositoryName"].should.equal(repo_name) + response["imageId"].should.equal( + {"imageDigest": image_digest, "imageTag": image_tag} + ) + response["imageScanStatus"].should.equal( + {"status": "COMPLETE", "description": "The scan was completed successfully."} + ) + scan_findings = response["imageScanFindings"] + scan_findings["imageScanCompletedAt"].should.be.a(datetime) + scan_findings["vulnerabilitySourceUpdatedAt"].should.be.a(datetime) + scan_findings["findings"].should.equal( + [ + { + "name": "CVE-9999-9999", + "uri": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-9999-9999", + "severity": "HIGH", + "attributes": [ + {"key": "package_version", "value": "9.9.9"}, + {"key": "package_name", "value": "moto_fake"}, + {"key": "CVSS2_VECTOR", "value": "AV:N/AC:L/Au:N/C:P/I:P/A:P",}, + {"key": "CVSS2_SCORE", "value": "7.5"}, + ], + } + ] + ) + scan_findings["findingSeverityCounts"].should.equal({"HIGH": 1}) + + +@mock_ecr +def test_describe_image_scan_findings_error_repo_not_exists(): + # given + region_name = "eu-central-1" + client = boto3.client("ecr", region_name=region_name) + repo_name = "not-exists" + + # when + with pytest.raises(ClientError) as e: + client.describe_image_scan_findings( + repositoryName=repo_name, imageId={"imageTag": "latest"} + ) + + # then + ex = e.value + ex.operation_name.should.equal("DescribeImageScanFindings") + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("RepositoryNotFoundException") + ex.response["Error"]["Message"].should.equal( + f"The repository with name '{repo_name}' does not exist " + f"in the registry with id '{ACCOUNT_ID}'" + ) + + +@mock_ecr +def test_describe_image_scan_findings_error_image_not_exists(): + # given + client = boto3.client("ecr", region_name="eu-central-1") + repo_name = "test-repo" + client.create_repository(repositoryName=repo_name) + image_tag = "not-exists" + + # when + with pytest.raises(ClientError) as e: + client.describe_image_scan_findings( + repositoryName=repo_name, imageId={"imageTag": image_tag} + ) + + # then + ex = e.value + ex.operation_name.should.equal("DescribeImageScanFindings") + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("ImageNotFoundException") + ex.response["Error"]["Message"].should.equal( + f"The image with imageId {{imageDigest:'null', imageTag:'{image_tag}'}} does not exist " + f"within the repository with name '{repo_name}' " + f"in the registry with id '{ACCOUNT_ID}'" + ) + + +@mock_ecr +def test_describe_image_scan_findings_error_scan_not_exists(): + # given + client = boto3.client("ecr", region_name="eu-central-1") + repo_name = "test-repo" + client.create_repository(repositoryName=repo_name) + image_tag = "latest" + client.put_image( + repositoryName=repo_name, + imageManifest=json.dumps(_create_image_manifest()), + imageTag=image_tag, + ) + + # when + with pytest.raises(ClientError) as e: + client.describe_image_scan_findings( + repositoryName=repo_name, imageId={"imageTag": image_tag} + ) + + # then + ex = e.value + ex.operation_name.should.equal("DescribeImageScanFindings") + ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.response["Error"]["Code"].should.contain("ScanNotFoundException") + ex.response["Error"]["Message"].should.equal( + f"Image scan does not exist for the image with '{{imageDigest:'null', imageTag:'{image_tag}'}}' " + f"in the repository with name '{repo_name}' " + f"in the registry with id '{ACCOUNT_ID}'" + )