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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions tcdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 65 additions & 3 deletions tests/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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':
Expand All @@ -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):
Expand Down Expand Up @@ -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': []}}
Expand Down Expand Up @@ -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):
Expand Down
Loading