From f63a2fb49cd8f69267b76caf8f57b0c814f4cf15 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 5 Nov 2026 10:16:52 +1100 Subject: [PATCH] Add an explicit Payload dev image scan policy --- README.md | 2 ++ tcdeploy.py | 17 +++++++++-- tests/test_security.py | 68 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1764841..cabd7bf 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ In a separately authorized deploy job, load the workflow-specific image archive, The deployer verifies account/role, live service configuration, source label, ECR immutability, local/remote configuration digest, a scan completed within 24 hours with no MEDIUM/HIGH/CRITICAL/UNDEFINED findings, and the exact healthy running revision/digests. By default only selected images change; roles, secret references, sidecars, resources, volumes and existing health checks remain intact. Non-root runtime images are required and `RESET_DB` cannot be enabled. The source label is a consistency check, not cryptographic build provenance; protect the pipeline and artifact writers. +Payload's reviewed dev adapter may explicitly construct `Publisher(..., scan_policy='payload-dev-high-critical')`. This policy reports the validated severity counts and allows MEDIUM findings while still blocking HIGH, CRITICAL and UNDEFINED findings. It is accepted only for `topcoder-platform/payload-cms`, environment `dev`, branch `develop`, and the exact Payload service, task family and image mapping. Other repositories and production cannot select it. The default remains strict, and there is no CLI flag or environment-variable override. Scan completion, freshness, response validation and artifact verification still apply; IAM permissions do not change. + For an existing service whose task definition has no health check, add a reviewed container-to-check JSON file in the application repository and pass `--bootstrap-health-checks deploy/dev-healthcheck.json`. The deployer adds the missing check in the same task revision as the new, scanned image. It never starts the old image with the new check, and never replaces a different existing check. Matching existing checks are accepted on later releases. Checks must use an explicit `CMD` argument list; all normal identity, configuration, scan, concurrency, readback, and healthy-running-digest gates still apply. This adds no IAM permissions. ECS circuit-breaker rollback retains the previous task revision; a rollback still fails the release. Plaintext runtime credentials remain forbidden. The specific public settings `M2M_TOKEN_ISSUER`, `M2M_TOKEN_AUDIENCE`, and `HUMAN_TOKEN_ISSUER` may contain HTTPS URLs without user information, query strings, or fragments; `TOKEN_CACHE_TIME` may contain an unsigned integer up to 2147483647. Other secret-like names still fail preflight. These exceptions preserve public application metadata and do not fetch tokens or grant access to secret stores. diff --git a/tcdeploy.py b/tcdeploy.py index 99a0dcc..9b30098 100644 --- a/tcdeploy.py +++ b/tcdeploy.py @@ -414,7 +414,15 @@ def scan_counts(scan, m, repository, digest): class Publisher: - def __init__(self, aws, m, directory): + def __init__(self, aws, m, directory, *, scan_policy='strict'): + require(scan_policy in ('strict', 'payload-dev-high-critical'), 'Unsupported image scan policy') + if scan_policy == 'payload-dev-high-critical': + require(m.get('environment') == 'dev' and m.get('branch') == 'develop' + and m.get('repository') == 'github.com/topcoder-platform/payload-cms' + and m.get('service') == m.get('taskFamily') == 'payload-cms' + and m.get('images') == {'payload-cms': 'payload-cms'}, + 'The high/critical scan policy is restricted to Payload dev') + self.scan_policy = scan_policy self.aws, self.m = aws, m self.env = clean_environment(m['region']) # Docker must not receive AWS/OIDC credentials. self.env['DOCKER_CONFIG'] = directory @@ -484,7 +492,12 @@ def publish(self, name, local, tag, deadline): completed = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) require(0 <= (datetime.now(timezone.utc) - completed).total_seconds() < 86400, 'Image scan must have completed within the last 24 hours') counts = scan_counts(scan, self.m, repository, digest) - require(not any(counts.get(level, 0) for level in ('CRITICAL', 'HIGH', 'MEDIUM', 'UNDEFINED')), 'Image scan exceeds the release threshold') + blocked = ('CRITICAL', 'HIGH', 'MEDIUM', 'UNDEFINED') + if self.scan_policy == 'payload-dev-high-critical': + blocked = ('CRITICAL', 'HIGH', 'UNDEFINED') + # Report validated counts, never raw findings or AWS responses. + print('Payload dev image scan counts: ' + compact(counts), flush=True) + require(not any(counts.get(level, 0) for level in blocked), 'Image scan exceeds the release threshold') return uri + '@' + digest require(status in ('IN_PROGRESS', 'PENDING', 'ACTIVE'), 'Image scan failed or is unsupported') time.sleep(10) diff --git a/tests/test_security.py b/tests/test_security.py index 43ba67e..3eba194 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -672,6 +672,7 @@ def test_sidecars_preserved(self): class PublisherTests(unittest.TestCase): def setUp(self): + self.image_name = 'example-v6' self.m = manifest() self.aws = FakeAWS(self.m) self.publisher = d.Publisher(self.aws, self.m, '/tmp/dummy-docker-config') @@ -687,7 +688,7 @@ def ecr(self, api, op, **params): self.assertEqual(api, 'ecr') registry = self.publisher.registry if op == 'describe-repositories': - return {'repositories': [{'repositoryUri': registry + '/example-v6', 'imageTagMutability': self.mutability}]} + return {'repositories': [{'repositoryUri': registry + '/' + self.image_name, 'imageTagMutability': self.mutability}]} if op == 'get-authorization-token': return {'authorizationData': [{'proxyEndpoint': 'https://' + registry, 'authorizationToken': base64.b64encode(b'AWS:dummy-password').decode()}]} if op == 'describe-images': @@ -700,7 +701,7 @@ def ecr(self, api, op, **params): def publish(self): with patch.object(self.publisher, 'docker', return_value=json.dumps([self.info])) as docker, patch.object(self.aws, 'call', side_effect=self.ecr): - result = self.publisher.publish('example-v6', 'example:build', RELEASE, float('inf')) + result = self.publisher.publish(self.image_name, 'example:build', RELEASE, float('inf')) return result, docker def test_publish_verifies_digest_and_password_stdin(self): @@ -760,7 +761,7 @@ def test_failed_scan(self): def empty_scan(self, status='ACTIVE'): # Shape returned by ECR for an enhanced scan with zero findings. - self.scan = {'registryId': self.m['accountId'], 'repositoryName': 'example-v6', + self.scan = {'registryId': self.m['accountId'], 'repositoryName': self.image_name, 'imageId': {'imageDigest': DIGEST}, 'imageScanStatus': {'status': status}, 'imageScanFindings': {'imageScanCompletedAt': datetime.now(timezone.utc).isoformat(), 'findings': []}} @@ -845,6 +846,67 @@ def test_severity_gate(self): with self.assertRaises(d.DeployError): self.publish() + def payload_publisher(self, policy='payload-dev-high-critical'): + self.image_name = 'payload-cms' + self.m.update(environment='dev', branch='develop', + repository='github.com/topcoder-platform/payload-cms', + service='payload-cms', taskFamily='payload-cms', + images={'payload-cms': 'payload-cms'}) + self.publisher = d.Publisher(self.aws, self.m, '/tmp/dummy-docker-config', scan_policy=policy) + + def test_payload_dev_medium_findings_are_reported_and_allowed(self): + self.payload_publisher() + self.scan['imageScanFindings']['findingSeverityCounts'] = {'MEDIUM': 4, 'LOW': 1} + with patch('builtins.print') as report: + result, _ = self.publish() + self.assertTrue(result.endswith('@' + DIGEST)) + report.assert_called_once_with('Payload dev image scan counts: {"MEDIUM":4,"LOW":1}', flush=True) + + def test_payload_keeps_strict_policy_by_default(self): + self.payload_publisher('strict') + self.scan['imageScanFindings']['findingSeverityCounts'] = {'MEDIUM': 1} + with self.assertRaises(d.DeployError): + self.publish() + + def test_payload_dev_policy_still_blocks_high_critical_and_undefined(self): + self.payload_publisher() + for level in ('CRITICAL', 'HIGH', 'UNDEFINED'): + with self.subTest(level=level), patch('builtins.print'): + self.scan['imageScanFindings']['findingSeverityCounts'] = {'MEDIUM': 4, level: 1} + with self.assertRaises(d.DeployError): + self.publish() + + def test_payload_dev_policy_rejects_other_targets_before_side_effects(self): + self.payload_publisher() + for changed in ({'environment': 'prod', 'branch': 'master'}, + {'environment': 'prod'}, {'branch': 'master'}, + {'repository': 'github.com/another-org/payload-cms'}, + {'repository': 'github.com/topcoder-platform/other-api'}, + {'service': 'other'}, {'taskFamily': 'other'}, + {'images': {'payload-cms': 'other'}}, + {'images': {'payload-cms': 'payload-cms', 'sidecar': 'other'}}): + with self.subTest(changed=changed): + with self.assertRaises(d.DeployError): + d.Publisher(self.aws, {**self.m, **changed}, '/tmp/dummy', + scan_policy='payload-dev-high-critical') + self.assertEqual(self.aws.calls, []) + + def test_unknown_scan_policy_is_rejected(self): + for policy in ('HIGH', 'none', '', None, True): + with self.subTest(policy=policy), self.assertRaises(d.DeployError): + d.Publisher(self.aws, self.m, '/tmp/dummy', scan_policy=policy) + self.assertEqual(self.aws.calls, []) + + def test_payload_dev_policy_preserves_scan_and_artifact_validation(self): + for check in ('test_stale_scan_rejected', 'test_failed_scan', + 'test_missing_counts_and_missing_findings_rejected', + 'test_empty_findings_do_not_override_failed_status_or_summary', + 'test_wrong_artifact_digest', 'test_mutable_tag_repository'): + with self.subTest(check=check), patch('builtins.print'): + self.setUp() + self.payload_publisher() + getattr(self, check)() + class EntrypointTests(unittest.TestCase): def test_old_helpers_fail_before_any_network_or_credential_file(self):