diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b02780f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.pytest_cache +.mypy_cache +.ruff_cache +__pycache__ +*.pyc +*.db +*.log +dist +build +tests +docs +examples +experiments +benchmarks diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e7798be..688cc85 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,6 +4,14 @@ on: push: tags: - "v*" + pull_request: + paths: + - "Dockerfile" + - ".dockerignore" + - "pyproject.toml" + - "src/**" + - "schemas/**" + - ".github/workflows/docker.yml" env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -22,6 +30,7 @@ jobs: uses: actions/checkout@v7 - name: Log in to GitHub Container Registry + if: startsWith(github.ref, 'refs/tags/') uses: docker/login-action@v4 with: registry: ghcr.io @@ -29,6 +38,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract tag name + if: startsWith(github.ref, 'refs/tags/') id: tag run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" @@ -37,20 +47,23 @@ jobs: uses: docker/build-push-action@v7 with: context: . - push: true + push: ${{ startsWith(github.ref, 'refs/tags/') }} tags: | - ghcr.io/agentrust-io/cmcp-gateway:${{ steps.tag.outputs.tag }} + ghcr.io/agentrust-io/cmcp-gateway:${{ steps.tag.outputs.tag || github.sha }} ghcr.io/agentrust-io/cmcp-gateway:latest - name: Install cosign + if: startsWith(github.ref, 'refs/tags/') uses: sigstore/cosign-installer@v3 - name: Sign the image (keyless, by digest) + if: startsWith(github.ref, 'refs/tags/') env: DIGEST: ${{ steps.build.outputs.digest }} run: cosign sign --yes ghcr.io/agentrust-io/cmcp-gateway@${DIGEST} - name: Attest build provenance (SLSA) + if: startsWith(github.ref, 'refs/tags/') uses: actions/attest-build-provenance@v4 with: subject-name: ghcr.io/agentrust-io/cmcp-gateway diff --git a/CHANGELOG.md b/CHANGELOG.md index 018b660..e9ec672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upstream stdio children and provenance verdicts are now cached by complete execution and trust identity rather than the non-unique human-readable `display_name`. Distinct catalog servers sharing a label can no longer reuse another server's process or provenance result. - Built wheels now include the catalog-entry JSON Schema, and catalog loading fails closed if that schema is absent or unreadable. Previously source-tree tests validated catalog structure, but installed wheels omitted the schema and silently skipped that validation. - PyPI publication now installs and smoke-tests the exact wheel and source distribution before upload, including release-tag/version agreement, import provenance, runtime configuration, and the packaged CLI. +- The runtime container now uses a patch-pinned Python slim base, builds a non-editable production wheelhouse in a separate stage, excludes development dependencies and build tooling from the final image, and runs as an unprivileged numeric UID/GID. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb655ab..bdff621 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,16 @@ clean environments before upload. It checks release-tag/version agreement, metadata and runtime versions, import provenance outside the checkout, core configuration construction, and the installed `cmcp` console entry point. +### Release container build + +The release container uses a multi-stage build: the builder creates a wheelhouse +from production dependencies only, while the runtime installs it offline and +runs as numeric UID/GID 10001. Do not add editable installs, the `dev` extra, or +root execution to the runtime stage. +Dockerfile, schema, and container-workflow pull requests build the image without +registry credentials or a push; publishing, signing, and provenance attestation +remain restricted to version tags. + ## Commit format Follow [Conventional Commits](https://www.conventionalcommits.org/): diff --git a/Dockerfile b/Dockerfile index fbe263f..4fdfcf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,33 @@ -FROM python:3.11-slim +FROM python:3.11.15-slim-bookworm AS builder -WORKDIR /app +WORKDIR /build -# Install package -COPY pyproject.toml . +COPY pyproject.toml README.md LICENSE ./ +COPY schemas/ schemas/ COPY src/ src/ -RUN python -m pip install --upgrade pip setuptools && pip install -e ".[dev]" -# Config, policy, catalog injected via volume mounts -RUN mkdir -p /etc/cmcp +# Resolve runtime dependencies and build a non-editable wheelhouse. Development +# extras and build tooling never cross into the runtime stage. +RUN python -m pip wheel --disable-pip-version-check --wheel-dir /wheels . + + +FROM python:3.11.15-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN groupadd --system --gid 10001 cmcp \ + && useradd --system --uid 10001 --gid cmcp --home-dir /var/lib/cmcp cmcp \ + && mkdir -p /etc/cmcp /var/lib/cmcp \ + && chown -R cmcp:cmcp /var/lib/cmcp + +COPY --from=builder /wheels /wheels +RUN python -m pip install --disable-pip-version-check --no-index \ + --find-links=/wheels cmcp-runtime \ + && rm -rf /wheels + +WORKDIR /var/lib/cmcp +USER 10001:10001 EXPOSE 8443 diff --git a/tests/unit/test_container_hardening.py b/tests/unit/test_container_hardening.py new file mode 100644 index 0000000..e7f2dae --- /dev/null +++ b/tests/unit/test_container_hardening.py @@ -0,0 +1,59 @@ +"""Static release invariants for the CMCP runtime container.""" + +from pathlib import Path + +import yaml + + +def _dockerfile() -> str: + return (Path(__file__).parents[2] / "Dockerfile").read_text(encoding="utf-8") + + +def test_runtime_image_uses_non_editable_production_install() -> None: + dockerfile = _dockerfile() + + assert 'pip install -e ".[dev]"' not in dockerfile + assert "pip wheel" in dockerfile + assert "--no-index" in dockerfile + + +def test_runtime_image_drops_root_privileges() -> None: + dockerfile = _dockerfile() + + assert "USER 10001:10001" in dockerfile + assert "useradd --system --uid 10001" in dockerfile + + +def test_runtime_image_pins_python_patch_and_distribution() -> None: + stages = [ + line for line in _dockerfile().splitlines() + if line.startswith("FROM ") + ] + + assert stages == [ + "FROM python:3.11.15-slim-bookworm AS builder", + "FROM python:3.11.15-slim-bookworm AS runtime", + ] + + +def test_container_prs_build_without_registry_write() -> None: + workflow_path = Path(__file__).parents[2] / ".github" / "workflows" / "docker.yml" + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8")) + triggers = workflow[True] + steps = workflow["jobs"]["build-and-push"]["steps"] + build = next(step for step in steps if step.get("name") == "Build and push") + + assert "pull_request" in triggers + assert build["with"]["push"] == "${{ startsWith(github.ref, 'refs/tags/') }}" + assert "steps.tag.outputs.tag || github.sha" in build["with"]["tags"] + assert "cmcp-gateway:latest" in build["with"]["tags"] + assert all( + "startsWith(github.ref, 'refs/tags/')" in step.get("if", "") + for step in steps + if step.get("name") in { + "Log in to GitHub Container Registry", + "Install cosign", + "Sign the image (keyless, by digest)", + "Attest build provenance (SLSA)", + } + )