diff --git a/README.md b/README.md index cabd7bf..4bc64bc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tcdeploy.py b/tcdeploy.py index 9b30098..c26c687 100644 --- a/tcdeploy.py +++ b/tcdeploy.py @@ -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 diff --git a/tests/test_security.py b/tests/test_security.py index 3eba194..7c2945d 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -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) @@ -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):