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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Read [the migration guide](docs/MIGRATION.md), [the Auth0/AWS explanation](docs/
- A reviewed, digest-pinned Linux executor containing Python >=3.10 with the `tarfile` extraction-filter security backport, AWS CLI v2, Docker/Buildx, Git and system CA certificates. No package installation in the credential-bearing job. This deployer has no external Python dependencies.
- CircleCI Cloud with `CIRCLE_OIDC_TOKEN_V3`, protected exact development (`develop` or `dev`) and production (`master` or legacy `prod`) refs, and restricted deployment contexts containing no broker/static AWS credentials.
- Separate per-service/per-environment deploy, task and execution roles, and a reviewed non-secret manifest.
- Existing immutable ECR repositories with scan-on-push, ECS circuit-breaker rollback, positive desired capacity, explicit task health checks, and pre-provisioned runtime secret references.
- Existing immutable ECR repositories with scan-on-push, ECS circuit-breaker rollback, positive desired capacity, explicit task health checks (or a reviewed first-deployment health-check file), and pre-provisioned runtime secret references.

## Usage

Expand Down Expand Up @@ -42,7 +42,11 @@ In a separately authorized deploy job, load the workflow-specific image archive,

`awsconfiguration.sh` is an optional identity check, not a credential exporter. `master_deploy.sh` authenticates internally. Neither writes AWS credentials to the checkout/default profile, logs, or shell exports. Use the entire SHA-pinned suite, not the old four copied shell files. See `examples/circleci-v2.yml`; replace its explicit executor/commit placeholders before use.

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. Only selected images change; roles, secret references, sidecars, resources, volumes and 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.
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.

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.

ECR may omit its optional severity-count map for a clean image. That response is accepted only after the same completion/freshness checks, with an exact registry/repository/digest match, explicitly empty findings lists, and no remaining page. An absent summary without that evidence, nonempty findings without a summary, or malformed counts fails closed. This requires no additional IAM permissions or changes to registry scanning. See the [ECR response schema](https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_ImageScanFindings.html).

Expand Down
4 changes: 2 additions & 2 deletions docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Merging this PR does not change pinned v1 consumers or installed IAM policies. *
1. Start with a straightforward one-container service, for example lookups. Have an administrator inspect its existing ECS service/task, ECR settings and IAM roles without exporting decrypted SSM values. Record the previous revision/digest in a restricted release record.
2. Create a manifest with exact identifiers from that inspection. Generate IAM documents using `tcdeploy.py policies`; install them through infrastructure review. Keep the explicit secret-access/role-chaining deny in the role policy and preferably use the generated permissions as its permissions boundary too. Remove unrelated policies and audit resource-based grants. The deployer needs no infrastructure-creation permissions.
3. Restrict PassRole to the exact task/execution roles (use JSON `null` for `taskRoleArn` when the existing task has no application role; do not add one just to deploy). Give the execution role only its runtime parameter/secret ARNs and necessary KMS keys. Pre-provision runtime bindings and defaults through administrator-owned infrastructure. v2 preserves references without fetching values/types: missing bindings fail at startup and trigger rollback.
4. Enable unconditional ECR tag immutability and supported basic/enhanced scan-on-push. Enable ECS circuit-breaker rollback, positive desired capacity and explicit task health checks. Remove plaintext secret environment values and `RESET_DB` secret bindings. Use non-root runtime images. Bootstrap/infrastructure updates remain separate from code deployment.
4. Enable unconditional ECR tag immutability and supported basic/enhanced scan-on-push. Enable ECS circuit-breaker rollback, positive desired capacity and explicit task health checks. When the first new image introduces the health endpoint, pass a reviewed `--bootstrap-health-checks` file to add a missing check atomically with that image; do not restart the old image with a check it cannot serve. Existing differing checks cannot be overwritten. Remove plaintext secret environment values and `RESET_DB` secret bindings. Use non-root runtime images. Bootstrap/infrastructure updates remain separate from code deployment.
5. Commit non-secret `deploy/dev.json`. Use an exact `develop` or `dev` branch for dev and the exact reviewed `master` or legacy `prod` branch for production source/config refs. Examples intentionally do not guess real targets. Do not put keys, client secrets, passwords or parameter values in manifests.
6. Prepare a reviewed executor with Python >=3.10 with the `tarfile` extraction-filter security backport, AWS CLI v2, Docker/Buildx, Git and trusted CA certificates. Pin its digest and the suite's full reviewed commit in service configuration. No runtime tool/package installation on the credential host. Replace template placeholders with literal reviewed values, not caller-overridable pipeline parameters.
7. Split build/test and deploy using `examples/circleci-v2.yml`. Remove `org-global`, old helper copies, all `source awsenvconf` / `source *_env`, SSM build/deploy/QA retrieval, and unpinned host installs. Use restricted dev/prod contexts with no broker/static AWS secrets, production approval and serialization. Verify OIDC v3 availability; restrict fork secret passing and SSH debugging. Pin/download the entire suite.
Expand All @@ -20,7 +20,7 @@ For private packages, use a separate package-read-only identity and BuildKit sec
- Run the offline tests and shell syntax checks. They do not prove installed IAM state.
- With an administrator identity, validate/simulate the generated policies. SSM listing/reads, Secrets Manager reads, direct KMS decryption and role chaining must be explicitly denied. Verify only the intended ECS service, ECR repositories, task family and two PassRole ARNs are allowed. Check OIDC audience/project/checkout/config origin/ref; a wrong project/ref must not assume the role. Do not weaken project restrictions to make a test pass.
- Run `awsconfiguration.sh --manifest deploy/dev.json` in the intended job. It must verify identity without files/credential output. Missing v3 token, wrong account/role and legacy `DEV` arguments must fail.
- Run a real dev release. Verify roles, runtime secret references, sidecars, resources, volumes and health checks remain unchanged. Compare reported image digests with healthy running tasks. CloudTrail should show no deployment-principal SSM/Secrets Manager calls; runtime retrieval belongs to the execution role.
- Run a real dev release. Verify roles, runtime secret references, sidecars, resources, volumes and existing health checks remain unchanged. If using health-check bootstrap, verify only the reviewed missing checks were added with the new image. Compare reported image digests with healthy running tasks. CloudTrail should show no deployment-principal SSM/Secrets Manager calls; runtime retrieval belongs to the execution role.
- Test an intentionally unhealthy dev image and verify failed release plus ECS rollback to the previous revision. Wrong digest, failed scan, denied AWS call, concurrency conflict or wrong source label must stop promotion. Old steady-state events/zero capacity must not pass.
- Inspect logs, workspace artifacts and image history/layers privately for accidental secrets. Remove stale broker/static credentials from project settings as well as contexts. Credential rotation/session revocation are separate incident actions.

Expand Down
65 changes: 57 additions & 8 deletions tcdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import sys
import tempfile
import time
from urllib.parse import urlsplit


class DeployError(Exception):
Expand Down Expand Up @@ -240,6 +241,7 @@ def authenticate(m, token, additional_actions=()):


SECRET_ENV = re.compile(r'(PASSWORD|SECRET|TOKEN|ACCESS_KEY|PRIVATE_KEY|DATABASE_URL|POSTGRES_URL|IDENTITY_DB_URL)', re.I)
PUBLIC_TOKEN_URLS = {'M2M_TOKEN_ISSUER', 'M2M_TOKEN_AUDIENCE', 'HUMAN_TOKEN_ISSUER'}
DIGEST = re.compile(r'sha256:[0-9a-f]{64}')
TASK_FIELDS = {'family', 'taskRoleArn', 'executionRoleArn', 'networkMode', 'containerDefinitions', 'volumes',
'placementConstraints', 'requiresCompatibilities', 'cpu', 'memory', 'pidMode', 'ipcMode',
Expand All @@ -248,10 +250,28 @@ def authenticate(m, token, additional_actions=()):
'registeredAt', 'registeredBy', 'deregisteredAt'}


def public_token_setting(name, value):
"""Recognize specific public auth settings, never arbitrary TOKEN names."""
if name == 'TOKEN_CACHE_TIME':
return bool(re.fullmatch(r'[0-9]{1,10}', value)) and int(value) <= 2147483647
if name not in PUBLIC_TOKEN_URLS or len(value) > 2048 or any(c.isspace() for c in value):
return False
try:
url = urlsplit(value)
port = url.port
return (url.scheme == 'https' and bool(url.hostname) and url.username is None
and url.password is None and not url.query and not url.fragment
and (port is None or 1 <= port <= 65535))
except ValueError:
return False


def validate_environment(entries):
for entry in entries:
name, value = entry['name'], entry['value']
require(not (SECRET_ENV.search(name) and value), 'Plaintext secret-like environment values are forbidden')
require(isinstance(name, str) and isinstance(value, str), 'Environment entries must contain strings')
require(not (SECRET_ENV.search(name) and value) or public_token_setting(name, value),
'Plaintext secret-like environment values are forbidden')
require(name != 'RESET_DB' or value == 'false', 'Database reset must be explicitly false')


Expand All @@ -262,14 +282,33 @@ def non_root(user):
return bool(name) and name != 'root' and not (name.isdecimal() and int(name) == 0)


def task_request(m, task, tags, images):
def validate_health_checks(m, checks):
require(isinstance(checks, dict) and checks and set(checks) <= set(m['images']),
'Bootstrap health checks must name configured image containers only')
bounds = {'interval': (5, 300), 'timeout': (2, 60), 'retries': (1, 10), 'startPeriod': (0, 300)}
for check in checks.values():
require(isinstance(check, dict) and 'command' in check and set(check) <= {'command', *bounds},
'Unexpected bootstrap health-check fields')
command = check['command']
require(isinstance(command, list) and 2 <= len(command) <= 50 and command[0] == 'CMD'
and all(isinstance(arg, str) and arg and '\x00' not in arg for arg in command),
'Bootstrap health checks require an explicit CMD argument list')
for key, (low, high) in bounds.items():
require(key not in check or type(check[key]) is int and low <= check[key] <= high,
'Bootstrap health-check timing is outside ECS bounds')
return checks


def task_request(m, task, tags, images, health_checks=None):
require(set(task) <= TASK_FIELDS | TASK_METADATA, 'Unrecognized task fields; update the deployer before changing this definition')
require(task.get('family') == m['taskFamily'] and task.get('status') == 'ACTIVE', 'Unexpected/inactive task family')
require(task.get('taskRoleArn') == m['taskRoleArn'] and task.get('executionRoleArn') == m['executionRoleArn'], 'Task/execution role differs from reviewed manifest')
require(task.get('networkMode') == 'awsvpc' and 'FARGATE' in task.get('requiresCompatibilities', []), 'Expected a Fargate task')
platform = task.get('runtimePlatform', {})
require(platform.get('cpuArchitecture', 'X86_64') == 'X86_64' and platform.get('operatingSystemFamily', 'LINUX') == 'LINUX', 'This release supports Linux/amd64 tasks only')
require(set(images) == set(m['images']), 'Supply exactly the manifest image set')
if health_checks is not None:
validate_health_checks(m, health_checks)
result = {k: deepcopy(v) for k, v in task.items() if k in TASK_FIELDS}
require(isinstance(tags, list), 'Task tags must be a list')
# ECS rejects an explicitly empty tag list on registration. Omission keeps
Expand All @@ -284,11 +323,16 @@ def task_request(m, task, tags, images):
require(not container.get('privileged') and ('user' not in container or non_root(container['user'])), 'Application task must not override the image to root/privileged')
counts[container['name']] += 1
require(DIGEST.fullmatch(images[container['name']].rsplit('@', 1)[-1]), 'Deploy only image digests')
if health_checks is not None and container['name'] in health_checks:
proposed = health_checks[container['name']]
require(not container.get('healthCheck') or container['healthCheck'] == proposed,
'Bootstrap cannot replace an existing task health check')
container['healthCheck'] = deepcopy(proposed)
require(container.get('healthCheck'), 'Updated containers require a task health check')
container['image'] = images[container['name']]
require(all(n == 1 for n in counts.values()), 'Each configured container must exist exactly once')
# All roles, secret references, sidecars, health checks, resources and volumes
# are preserved. Parameter values never enter this process.
# Existing configuration is preserved. Explicitly reviewed checks may be
# added where absent, with the new image, never by restarting the old image.
return result


Expand Down Expand Up @@ -447,7 +491,7 @@ def publish(self, name, local, tag, deadline):
raise DeployError('Image scan timed out; service was not updated')


def deploy(aws, m, locals_by_name, release, publisher):
def deploy(aws, m, locals_by_name, release, publisher, health_checks=None):
require(set(locals_by_name) == set(m['images']), 'Supply exactly one prebuilt image per configured container')
require(re.fullmatch(r'[a-f0-9]{40}-[0-9]{1,12}', release), 'Release must be CIRCLE_SHA1-CIRCLE_BUILD_NUM')
previous = read_service(aws, m)
Expand All @@ -457,14 +501,14 @@ def deploy(aws, m, locals_by_name, release, publisher):
task, tags = response['taskDefinition'], response.get('tags', [])
# Validate configuration before publishing anything. Placeholder digests are
# only used for validation and never sent to AWS.
task_request(m, task, tags, {name: 'preflight@sha256:' + '0' * 64 for name in m['images']})
task_request(m, task, tags, {name: 'preflight@sha256:' + '0' * 64 for name in m['images']}, health_checks)
images = {}
scan_deadline = time.monotonic() + 900
for name, local in locals_by_name.items():
images[name] = publisher.publish(name, local, release, scan_deadline)
current = read_service(aws, m)
require(stable(current, definition), 'Service changed during image publication; retry after inspection')
request = task_request(m, task, tags, images)
request = task_request(m, task, tags, images, health_checks)
registered = aws.call('ecs', 'register-task-definition', **request)['taskDefinition']
target = registered['taskDefinitionArn']
require(re.fullmatch(re.escape(arn(m, 'ecs', 'task-definition/' + m['taskFamily'])) + r':[0-9]+', target), 'Unexpected registered task ARN')
Expand Down Expand Up @@ -499,8 +543,13 @@ def main(argv=None):
p.add_argument('--manifest', required=True)
if name == 'deploy':
p.add_argument('--image', action='append', required=True, metavar='CONTAINER=LOCAL_IMAGE')
p.add_argument('--bootstrap-health-checks', metavar='REVIEWED_JSON')
args = parser.parse_args(argv)
m = read_manifest(args.manifest)
health_checks = None
if args.command == 'deploy' and args.bootstrap_health_checks:
health_checks = validate_health_checks(m, json.loads(
Path(args.bootstrap_health_checks).read_text(), object_pairs_hook=unique_object))
if args.command == 'policies':
print(json.dumps({'trust': trust_policy(m), 'permissions': role_policy(m)}, indent=2))
return
Expand All @@ -518,7 +567,7 @@ def main(argv=None):
images[name] = image
release = os.environ.get('CIRCLE_SHA1', '') + '-' + os.environ.get('CIRCLE_BUILD_NUM', '')
with tempfile.TemporaryDirectory(prefix='tc-deploy-docker-') as directory:
result = deploy(aws, m, images, release, Publisher(aws, m, directory))
result = deploy(aws, m, images, release, Publisher(aws, m, directory), health_checks)
print(json.dumps(result, indent=2)) # Only non-secret rollout evidence.


Expand Down
Loading
Loading