Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ ECR may omit its optional severity-count map for a clean image. That response is

Enhanced scanning also requires `inspector2:ListFindings` and `inspector2:ListCoverage` in the deployment role, its permissions boundary, and the STS session policy. The generated role policy limits these read-only actions to the deployment region. AWS does not support repository-level resource restrictions for these Inspector list actions, so they can read account-wide scan metadata in that region. No Inspector configuration/write actions or parameter/secret access are granted. Existing roles and boundaries must be updated through the private infrastructure repository before adopting this change. See [AWS enhanced-scanning permissions](https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-enhanced-iam.html).

Use a unique CircleCI build number when retrying publication to immutable tags. A failed rollout remains a failure even if ECS rolls back. The script never blindly reverts over another deployment.
Use a unique CircleCI build number when retrying publication to immutable tags. ECR can return multiple image records when several tags share one digest; publication verification resolves the exact immutable release tag and checks its returned tag, image digest and configuration digest before scanning. A failed rollout remains a failure even if ECS rolls back. The script never blindly reverts over another deployment.

## Tests

Expand Down
7 changes: 6 additions & 1 deletion tcdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,10 +466,15 @@ def publish(self, name, local, tag, deadline):
imageIds=[{'imageTag': tag}]).get('imageDetails', [])
require(len(details) == 1 and DIGEST.fullmatch(details[0].get('imageDigest', '')), 'ECR did not return a single image digest')
digest = details[0]['imageDigest']
# Digest lookups can return one record per tag when a retry republishes
# the same image. Resolve this immutable release tag, then verify its digest.
remote = self.aws.call('ecr', 'batch-get-image', repositoryName=repository,
imageIds=[{'imageDigest': digest}], acceptedMediaTypes=[
imageIds=[{'imageTag': tag}], acceptedMediaTypes=[
'application/vnd.docker.distribution.manifest.v2+json', 'application/vnd.oci.image.manifest.v1+json'])
require(not remote.get('failures') and len(remote.get('images', [])) == 1, 'Cannot verify pushed image')
image_id = remote['images'][0].get('imageId', {})
require(isinstance(image_id, dict) and image_id.get('imageTag') == tag
and image_id.get('imageDigest') == digest, 'Pushed image identity differs from the release tag/digest')
manifest = json.loads(remote['images'][0]['imageManifest'])
require(manifest.get('config', {}).get('digest') == info['configDigest'], 'Pushed artifact differs from local image (multi-platform indexes are unsupported)')
# Repositories must scan on push (basic or enhanced). No start_image_scan
Expand Down
35 changes: 34 additions & 1 deletion tests/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,8 @@ def ecr(self, api, op, **params):
if op == 'describe-images':
return {'imageDetails': [{'imageDigest': DIGEST}]}
if op == 'batch-get-image':
return {'images': [{'imageManifest': json.dumps({'config': {'digest': self.digest_config}})}]}
return {'images': [{'imageId': {'imageTag': RELEASE, 'imageDigest': DIGEST},
'imageManifest': json.dumps({'config': {'digest': self.digest_config}})}]}
if op == 'describe-image-scan-findings':
return self.scan
raise AssertionError(op)
Expand All @@ -712,6 +713,38 @@ def test_publish_verifies_digest_and_password_stdin(self):
self.assertEqual(login.kwargs['input_text'], 'dummy-password')
self.assertNotIn('AWS_ACCESS_KEY_ID', self.publisher.env)

def test_retry_with_multiple_tags_verifies_the_exact_release_tag(self):
# ECR can return one image record per tag for a digest lookup. A tag lookup
# must select only this release even when another tag names the same image.
def ecr(api, op, **params):
response = self.ecr(api, op, **params)
if op == 'batch-get-image':
if params['imageIds'] == [{'imageDigest': DIGEST}]:
prior = deepcopy(response['images'][0])
prior['imageId']['imageTag'] = SHA + '-122'
response['images'].append(prior)
else:
self.assertEqual(params['imageIds'], [{'imageTag': RELEASE}])
return response
with patch.object(self.publisher, 'docker', return_value=json.dumps([self.info])), patch.object(self.aws, 'call', side_effect=ecr):
result = self.publisher.publish(self.image_name, 'example:build', RELEASE, float('inf'))
self.assertTrue(result.endswith('@' + DIGEST))

def test_wrong_or_missing_remote_image_identity_is_rejected(self):
for identity in ({'imageDigest': DIGEST}, {'imageTag': RELEASE},
{'imageTag': SHA + '-122', 'imageDigest': DIGEST},
{'imageTag': RELEASE, 'imageDigest': 'sha256:' + 'f' * 64},
None, []):
with self.subTest(identity=identity):
def ecr(api, op, **params):
response = self.ecr(api, op, **params)
if op == 'batch-get-image':
response['images'][0]['imageId'] = identity
return response
with patch.object(self.publisher, 'docker', return_value=json.dumps([self.info])), patch.object(self.aws, 'call', side_effect=ecr):
with self.assertRaises(d.DeployError):
self.publisher.publish(self.image_name, 'example:build', RELEASE, float('inf'))

def test_wrong_artifact_digest(self):
self.digest_config = 'sha256:' + 'f' * 64
with self.assertRaises(d.DeployError):
Expand Down
Loading