Add ecr image scan (#4166)

* Add ecr.start_image_scan

* Add ecr.describe_image_scan_findings
This commit is contained in:
Anton Grübel 2021-08-12 14:06:21 +09:00 committed by GitHub
parent a5eb46962d
commit b8405b39b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 435 additions and 36 deletions

View File

@ -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): class RegistryPolicyNotFoundException(JsonRESTError):
code = 400 code = 400
@ -101,3 +111,17 @@ class InvalidParameterException(JsonRESTError):
def __init__(self, message): def __init__(self, message):
super().__init__(error_type=__class__.__name__, message=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}'"
),
)

View File

@ -7,7 +7,7 @@ import uuid
from collections import namedtuple from collections import namedtuple
from datetime import datetime from datetime import datetime
from random import random from random import random
from typing import Dict from typing import Dict, List
from botocore.exceptions import ParamValidationError from botocore.exceptions import ParamValidationError
@ -23,6 +23,8 @@ from moto.ecr.exceptions import (
RepositoryPolicyNotFoundException, RepositoryPolicyNotFoundException,
LifecyclePolicyNotFoundException, LifecyclePolicyNotFoundException,
RegistryPolicyNotFoundException, RegistryPolicyNotFoundException,
LimitExceededException,
ScanNotFoundException,
) )
from moto.ecr.policy_validation import EcrLifecyclePolicyValidator from moto.ecr.policy_validation import EcrLifecyclePolicyValidator
from moto.iam.exceptions import MalformedPolicyDocument from moto.iam.exceptions import MalformedPolicyDocument
@ -87,7 +89,7 @@ class Repository(BaseObject, CloudFormationModel):
) )
self.policy = None self.policy = None
self.lifecycle_policy = None self.lifecycle_policy = None
self.images = [] self.images: List[Image] = []
def _determine_encryption_config(self, encryption_config): def _determine_encryption_config(self, encryption_config):
if not 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()}" ] = f"arn:aws:kms:{self.region_name}:{ACCOUNT_ID}:key/{uuid.uuid4()}"
return encryption_config 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 @property
def physical_resource_id(self): def physical_resource_id(self):
return self.name return self.name
@ -210,6 +237,7 @@ class Image(BaseObject):
self.registry_id = registry_id self.registry_id = registry_id
self.image_digest = digest self.image_digest = digest
self.image_pushed_at = str(datetime.utcnow().isoformat()) self.image_pushed_at = str(datetime.utcnow().isoformat())
self.last_scan = None
def _create_digest(self): def _create_digest(self):
image_contents = "docker_image{0}".format(int(random() * 10 ** 6)) image_contents = "docker_image{0}".format(int(random() * 10 ** 6))
@ -407,38 +435,15 @@ class ECRBackend(BaseBackend):
return images return images
def describe_images(self, repository_name, registry_id=None, image_ids=None): def describe_images(self, repository_name, registry_id=None, image_ids=None):
repository = self._get_repository(repository_name, registry_id)
if repository_name in self.repositories:
repository = self.repositories[repository_name]
else:
raise RepositoryNotFoundException(
repository_name, registry_id or DEFAULT_REGISTRY_ID
)
if image_ids: if image_ids:
response = set() response = set(
for image_id in image_ids: repository._get_image(
found = False image_id.get("imageTag"), image_id.get("imageDigest")
for image in repository.images: )
if ( for image_id in image_ids
"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,
)
else: else:
response = [] response = []
@ -816,6 +821,81 @@ class ECRBackend(BaseBackend):
"policyText": policy, "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 = {} ecr_backends = {}
for region, ec2_backend in ec2_backends.items(): for region, ec2_backend in ec2_backends.items():

View File

@ -271,3 +271,29 @@ class ECRResponse(BaseResponse):
def delete_registry_policy(self): def delete_registry_policy(self):
return json.dumps(self.ecr_backend.delete_registry_policy()) 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,
)
)

View File

@ -651,24 +651,24 @@ def test_describe_image_that_doesnt_exist():
error_msg1 = re.compile( error_msg1 = re.compile(
r".*The image with imageId {imageDigest:'null', imageTag:'testtag'} does not exist within " 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, re.MULTILINE,
) )
client.describe_images.when.called_with( client.describe_images.when.called_with(
repositoryName="test_repository", repositoryName="test_repository",
imageIds=[{"imageTag": "testtag"}], imageIds=[{"imageTag": "testtag"}],
registryId="123", registryId=ACCOUNT_ID,
).should.throw(client.exceptions.ImageNotFoundException, error_msg1) ).should.throw(client.exceptions.ImageNotFoundException, error_msg1)
error_msg2 = re.compile( 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, re.MULTILINE,
) )
client.describe_images.when.called_with( client.describe_images.when.called_with(
repositoryName="repo-that-doesnt-exist", repositoryName="repo-that-doesnt-exist",
imageIds=[{"imageTag": "testtag"}], imageIds=[{"imageTag": "testtag"}],
registryId="123", registryId=ACCOUNT_ID,
).should.throw(ClientError, error_msg2) ).should.throw(ClientError, error_msg2)
@ -2113,3 +2113,272 @@ def test_delete_registry_policy_error_policy_not_exists():
ex.response["Error"]["Message"].should.equal( ex.response["Error"]["Message"].should.equal(
f"Registry policy does not exist in the registry with id '{ACCOUNT_ID}'" 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}'"
)