diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 879b906ec..fc7058413 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -32,18 +32,21 @@ jobs: - component: backend image: ai_email_client-backend dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: naruon image: naruon dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: frontend image: ai_email_client-frontend dockerfile: frontend/Dockerfile + base_dockerfile: frontend/Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 @@ -64,9 +67,28 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - name: Resolve pinned Ollama base manifest + if: matrix.component == 'naruon' + run: | + base_image="$(awk 'toupper($1) == "FROM" { print $2; exit }' Dockerfile.ollama)" + if ! printf '%s\n' "$base_image" | grep -Eq '^ollama/ollama@sha256:[0-9a-f]{64}$'; then + printf '::error file=Dockerfile.ollama,line=1::Expected an exact ollama/ollama sha256 base pin; found %s\n' "$base_image" + exit 1 + fi + printf 'Resolving pinned Ollama base manifest: %s\n' "$base_image" + manifest_output="$(docker buildx imagetools inspect "$base_image")" + printf '%s\n' "$manifest_output" + for platform in linux/amd64 linux/arm64; do + if ! printf '%s\n' "$manifest_output" | grep -Eq "^[[:space:]]*Platform:[[:space:]]+${platform}[[:space:]]*$"; then + printf '::error file=Dockerfile.ollama,line=1::Pinned Ollama manifest is missing %s\n' "$platform" + exit 1 + fi + done + - name: Prepare OCI annotation values id: oci env: + BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} GIT_REF_NAME: ${{ github.ref_name }} IMAGE_COMPONENT: ${{ matrix.component }} IMAGE_NAME: ${{ matrix.image }} @@ -76,24 +98,29 @@ jobs: version="$(cat VERSION)" created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" vendor="${REPOSITORY%%/*}" + base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" + if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then + printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" + exit 1 + fi + base_digest="${base_reference##*@}" + base_repository="${base_reference%@*}" + case "$base_repository" in + */*) base_name="$base_reference" ;; + *) base_name="docker.io/library/$base_reference" ;; + esac case "$IMAGE_COMPONENT" in frontend) title="naruon frontend" description="Naruon Next.js frontend runtime image" - base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" - base_name="docker.io/library/node:26-slim@${base_digest}" ;; backend) title="naruon backend" description="Naruon FastAPI backend runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; *) title="naruon" description="Naruon combined FastAPI and Next.js runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; esac { @@ -158,18 +185,21 @@ jobs: - component: backend image: ai_email_client-backend dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: naruon image: naruon dockerfile: Dockerfile + base_dockerfile: Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 - component: frontend image: ai_email_client-frontend dockerfile: frontend/Dockerfile + base_dockerfile: frontend/Dockerfile context: . build_args: | BUILDKIT_INLINE_CACHE=1 @@ -204,6 +234,7 @@ jobs: - name: Prepare OCI annotation values id: oci env: + BASE_DOCKERFILE: ${{ matrix.base_dockerfile }} GIT_REF_NAME: ${{ github.ref_name }} IMAGE_COMPONENT: ${{ matrix.component }} IMAGE_NAME: ${{ matrix.image }} @@ -214,24 +245,29 @@ jobs: version="${VERSION_VALUE:-$(cat VERSION)}" created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" vendor="${REPOSITORY%%/*}" + base_reference="$(awk 'toupper($1) == "FROM" { print $2; exit }' "$BASE_DOCKERFILE")" + if ! printf '%s\n' "$base_reference" | grep -Eq '^[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$'; then + printf '::error file=%s,line=1::Expected an exact tagged sha256 base pin; found %s\n' "$BASE_DOCKERFILE" "$base_reference" + exit 1 + fi + base_digest="${base_reference##*@}" + base_repository="${base_reference%@*}" + case "$base_repository" in + */*) base_name="$base_reference" ;; + *) base_name="docker.io/library/$base_reference" ;; + esac case "$IMAGE_COMPONENT" in frontend) title="naruon frontend" description="Naruon Next.js frontend runtime image" - base_digest="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" - base_name="docker.io/library/node:26-slim@${base_digest}" ;; backend) title="naruon backend" description="Naruon FastAPI backend runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; *) title="naruon" description="Naruon combined FastAPI and Next.js runtime image" - base_digest="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" - base_name="docker.io/library/python:3.14-slim@${base_digest}" ;; esac { diff --git a/Dockerfile b/Dockerfile index d51e6dafc..68c5d2e91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Backend runtime for local Compose and backend-only deployments -FROM python:3.14-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 AS backend-runtime +FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc AS backend-runtime WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 @@ -25,7 +25,7 @@ EXPOSE 8000 CMD ["python", "scripts/start_backend.py", "--host", "0.0.0.0", "--port", "8000"] # Stage 2: Build Frontend -FROM node:26-slim@sha256:ffc78385a788964bb3cbab5e434ff79a10bdc25b8ae6db03fe5fe6cb14053c09 AS frontend-builder +FROM node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 AS frontend-builder WORKDIR /app ENV NPM_CONFIG_UPDATE_NOTIFIER=false ENV PNPM_VERSION=11.5.3 @@ -63,8 +63,13 @@ ARG OCI_IMAGE_LICENSES="LicenseRef-Naruon-Proprietary" ARG OCI_IMAGE_REF_NAME="" ARG OCI_IMAGE_TITLE="naruon" ARG OCI_IMAGE_DESCRIPTION="Naruon combined FastAPI and Next.js runtime image" -ARG OCI_IMAGE_BASE_DIGEST="sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" -ARG OCI_IMAGE_BASE_NAME="docker.io/library/python:3.14-slim@sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" +ARG OCI_IMAGE_BASE_DIGEST="sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc" +ARG OCI_IMAGE_BASE_NAME="docker.io/library/python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc" + +# Defaults keep local builds provenance-complete. The publishing workflow derives +# and overrides both values from the exact first FROM instruction, while +# repository governance tests prevent the reviewed defaults from drifting. +RUN test -n "$OCI_IMAGE_BASE_DIGEST" && test -n "$OCI_IMAGE_BASE_NAME" LABEL org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \ org.opencontainers.image.authors="${OCI_IMAGE_AUTHORS}" \ diff --git a/Dockerfile.ollama b/Dockerfile.ollama index d4b369689..c4afd9598 100644 --- a/Dockerfile.ollama +++ b/Dockerfile.ollama @@ -1,4 +1,4 @@ -FROM ollama/ollama@sha256:509fdf54e23bd50d87af646cb51c0a7a203d6a83cc4d6695b3b08c5be1c62c0a +FROM ollama/ollama@sha256:b88c73ace3e115f8ec53dc8761ae1c0aabfa675406e3681786b98757ce050f42 ENV OLLAMA_MODELS=/usr/share/ollama/.ollama/models diff --git a/backend/tests/test_container_dependency_pin_contract.py b/backend/tests/test_container_dependency_pin_contract.py new file mode 100644 index 000000000..fdd4f6620 --- /dev/null +++ b/backend/tests/test_container_dependency_pin_contract.py @@ -0,0 +1,146 @@ +"""Regression contracts for container and release dependency security pins. + +The container-provenance process depends on repository tests, not prose alone, +to keep independently versioned Python and JavaScript toolchains on the exact +reviewed security floor. These checks parse source manifests, hash-locked Python +artifacts, and the generated pnpm lock so a direct pin cannot drift away from the +resolved artifact graph or pass through an incidental substring match. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +_HASH_PATTERN = re.compile(r"--hash=sha256:([0-9a-f]{64})") +_EXACT_PIN_PATTERN = re.compile(r"^([A-Za-z0-9_.-]+)==([^\\\s]+)") + + +def read_repo_text(relative_path: str) -> str: + """Return one required repository file as UTF-8 text.""" + path = REPO_ROOT / relative_path + assert path.is_file(), f"required pin contract file is missing: {relative_path}" + return path.read_text(encoding="utf-8") + + +def exact_requirement_pins(requirements_text: str) -> dict[str, str]: + """Parse exact direct requirement pins by normalized package name.""" + pins: dict[str, str] = {} + for raw_line in requirements_text.splitlines(): + match = _EXACT_PIN_PATTERN.match(raw_line.strip()) + if match is None: + continue + package_name, version = match.groups() + pins[package_name.lower().replace("_", "-")] = version + return pins + + +def hashed_requirement_records(requirements_text: str) -> dict[str, frozenset[str]]: + """Parse each exact requirement record and its complete SHA-256 hash set.""" + records: dict[str, frozenset[str]] = {} + current_pin: str | None = None + current_hashes: set[str] = set() + + def finish_record() -> None: + """Persist one complete requirement record before starting the next.""" + nonlocal current_pin, current_hashes + if current_pin is None: + return + assert current_hashes, f"hash-locked requirement has no hashes: {current_pin}" + records[current_pin] = frozenset(current_hashes) + current_pin = None + current_hashes = set() + + for raw_line in requirements_text.splitlines(): + stripped = raw_line.strip() + pin_match = _EXACT_PIN_PATTERN.match(stripped) + if pin_match is not None and not raw_line.startswith((" ", "\t")): + finish_record() + package_name, version = pin_match.groups() + current_pin = f"{package_name.lower().replace('_', '-')}=={version}" + continue + hash_match = _HASH_PATTERN.search(stripped) + if hash_match is not None: + assert current_pin is not None, "orphaned SHA-256 hash in requirements lock" + current_hashes.add(hash_match.group(1)) + finish_record() + return records + + +def importer_resolution(importer_section: dict[str, object], group: str, name: str) -> dict[str, str]: + """Return one structurally parsed pnpm root-importer dependency resolution.""" + dependencies = importer_section[group] + assert isinstance(dependencies, dict) + resolution = dependencies[name] + assert isinstance(resolution, dict) + assert isinstance(resolution.get("specifier"), str) + assert isinstance(resolution.get("version"), str) + return resolution + + +def test_container_provenance_dependency_pins_match_reviewed_manifests() -> None: + """Keep backend, Strix, and frontend dependency floors reviewable together.""" + backend_pins = exact_requirement_pins(read_repo_text("backend/requirements.txt")) + backend_records = hashed_requirement_records( + read_repo_text("backend/requirements-hashes.txt") + ) + strix_pins = exact_requirement_pins(read_repo_text("requirements-strix-ci.txt")) + strix_records = hashed_requirement_records( + read_repo_text("requirements-strix-ci-hashes.txt") + ) + frontend_package = json.loads(read_repo_text("frontend/package.json")) + frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml")) + + assert backend_pins["cryptography"] == "50.0.0" + assert backend_pins["protobuf"] == "7.35.1" + assert "cryptography==50.0.0" in backend_records + assert "protobuf==7.35.1" in backend_records + assert all( + re.fullmatch(r"[0-9a-f]{64}", digest) + for pin in ("cryptography==50.0.0", "protobuf==7.35.1") + for digest in backend_records[pin] + ) + + assert strix_pins["cryptography"] == "50.0.0" + assert strix_pins["protobuf"] == "6.33.6" + assert "cryptography==50.0.0" in strix_records + assert "protobuf==6.33.6" in strix_records + assert all( + re.fullmatch(r"[0-9a-f]{64}", digest) + for pin in ("cryptography==50.0.0", "protobuf==6.33.6") + for digest in strix_records[pin] + ) + + root_importer = frontend_lock["importers"]["."] + postcss_resolution = importer_resolution( + root_importer, "devDependencies", "postcss" + ) + jsdom_resolution = importer_resolution(root_importer, "devDependencies", "jsdom") + assert postcss_resolution == {"specifier": "8.5.24", "version": "8.5.24"} + assert jsdom_resolution == {"specifier": "^30.0.1", "version": "30.0.1"} + + assert frontend_package["devDependencies"]["postcss"] == "8.5.24" + assert frontend_package["devDependencies"]["jsdom"] == "^30.0.1" + assert frontend_package["overrides"]["postcss"] == "8.5.24" + assert frontend_package["overrides"]["brace-expansion"] == "5.0.9" + assert frontend_package["overrides"]["undici"] == "8.9.0" + + assert frontend_lock["overrides"] == { + **frontend_lock["overrides"], + "postcss": "8.5.24", + "brace-expansion": "5.0.9", + "undici": "8.9.0", + } + package_records = frontend_lock["packages"] + for exact_lock_entry in ( + "postcss@8.5.24", + "jsdom@30.0.1", + "brace-expansion@5.0.9", + "undici@8.9.0", + ): + assert exact_lock_entry in package_records diff --git a/backend/tests/test_release_governance.py b/backend/tests/test_release_governance.py index efe0acd0e..a23c70746 100644 --- a/backend/tests/test_release_governance.py +++ b/backend/tests/test_release_governance.py @@ -12,7 +12,6 @@ import re import sys import importlib.util -import tomllib from pathlib import Path import pytest @@ -54,6 +53,30 @@ def assert_dockerfile_stage_from(dockerfile: str, image: str, stage_alias: str) ) +def first_dockerfile_base_reference(dockerfile: str) -> str: + """Return the first exact tag-and-digest Dockerfile base reference.""" + first_from = re.search(r"^FROM (?P.+)$", dockerfile, flags=re.MULTILINE) + assert first_from is not None, "Dockerfile must declare a base image" + match = re.fullmatch( + r"(?P[A-Za-z0-9._/-]+:[A-Za-z0-9._-]+" + r"@sha256:[0-9a-f]{64})(?: AS [A-Za-z0-9._-]+)?", + first_from.group("declaration"), + ) + assert match is not None, "Dockerfile first stage must use an exact tag-and-digest pin" + return match.group("reference") + + +def assert_oci_metadata_matches_first_base(dockerfile: str) -> None: + """Require OCI base metadata defaults to describe the real first stage.""" + base_reference = first_dockerfile_base_reference(dockerfile) + image_reference, base_digest = base_reference.rsplit("@", 1) + if "/" not in image_reference: + image_reference = f"docker.io/library/{image_reference}" + + assert f'ARG OCI_IMAGE_BASE_DIGEST="{base_digest}"' in dockerfile + assert f'ARG OCI_IMAGE_BASE_NAME="{image_reference}@{base_digest}"' in dockerfile + + def test_root_version_exists_and_is_initial_semver_release() -> None: version = read_repo_text("VERSION").strip() @@ -95,6 +118,29 @@ def test_container_images_cover_all_oci_predefined_image_annotations() -> None: assert ( "annotations: ${{ steps.meta.outputs.annotations }}" in docker_publish_workflow ) + assert_oci_metadata_matches_first_base(root_dockerfile) + assert_oci_metadata_matches_first_base(frontend_dockerfile) + + +def test_container_base_image_pins_are_synchronized() -> None: + root_dockerfile = read_repo_text("Dockerfile") + frontend_dockerfile = read_repo_text("frontend/Dockerfile") + connector_dockerfile = read_repo_text("connector/Dockerfile") + + root_python = first_dockerfile_base_reference(root_dockerfile) + connector_python = first_dockerfile_base_reference(connector_dockerfile) + root_node_match = re.search( + r"^FROM (?Pnode:26-slim@sha256:[0-9a-f]{64}) " + r"AS frontend-builder$", + root_dockerfile, + flags=re.MULTILINE, + ) + assert root_node_match is not None + + assert connector_python == root_python + assert first_dockerfile_base_reference(frontend_dockerfile) == ( + root_node_match.group("reference") + ) def test_container_images_use_pinned_node_runtimes() -> None: @@ -106,7 +152,8 @@ def test_container_images_use_pinned_node_runtimes() -> None: assert_dockerfile_stage_from(root_dockerfile, "node:26-slim", "frontend-builder") assert "FROM node:26-slim@sha256:" in frontend_dockerfile assert "docker.io/library/node:26-slim" in frontend_dockerfile - assert "docker.io/library/node:26-slim" in docker_publish_workflow + assert "base_dockerfile: frontend/Dockerfile" in docker_publish_workflow + assert 'base_name="docker.io/library/$base_reference"' in docker_publish_workflow assert "Node 26 toolchain" in render_deployment assert "node:24" not in root_dockerfile assert "node:24" not in frontend_dockerfile @@ -127,7 +174,8 @@ def test_backend_images_use_python_314_runtime() -> None: assert_dockerfile_stage_from(root_dockerfile, "python:3.14-slim", "backend-runtime") assert "docker.io/library/python:3.14-slim" in root_dockerfile - assert "docker.io/library/python:3.14-slim" in docker_publish_workflow + assert "base_dockerfile: Dockerfile" in docker_publish_workflow + assert 'base_name="docker.io/library/$base_reference"' in docker_publish_workflow assert 'python-version: ["3.14"]' in app_ci_workflow assert 'python-version: "3.14"' in bandit_workflow assert "Python 3.14 toolchain" in render_deployment @@ -175,101 +223,10 @@ def test_strix_ci_requirements_use_security_quality_clean_pins() -> None: strix_ci_requirements = read_repo_text("requirements-strix-ci.txt") assert "strix-agent==1.0.4" in strix_ci_requirements - assert "google-cloud-aiplatform==1.160.0" in strix_ci_requirements assert "cryptography==50.0.0" in strix_ci_requirements - assert "protobuf==6.33.6" in strix_ci_requirements assert "python-multipart==0.0.32" in strix_ci_requirements -def test_cryptography_runtime_pins_are_bleichenbacher_oracle_fixed() -> None: - """Require every governed Python surface to use the first oracle-safe release.""" - backend_requirements = read_repo_text("backend/requirements.txt") - backend_project_text = read_repo_text("backend/pyproject.toml") - backend_project = tomllib.loads(backend_project_text) - backend_lock = tomllib.loads(read_repo_text("backend/uv.lock")) - backend_hashes = read_repo_text("backend/requirements-hashes.txt") - strix_requirements = read_repo_text("requirements-strix-ci.txt") - strix_hashes = read_repo_text("requirements-strix-ci-hashes.txt") - - def pins(text: str, package: str) -> list[str]: - return re.findall(rf"(?m)^{re.escape(package)}==[^\s\\]+", text) - - for governed_text in ( - backend_requirements, - backend_hashes, - strix_requirements, - strix_hashes, - ): - assert pins(governed_text, "cryptography") == ["cryptography==50.0.0"] - assert [ - dependency - for dependency in backend_project["project"]["dependencies"] - if dependency.startswith("cryptography") - ] == ["cryptography==50.0.0"] - cryptography_versions = { - package["version"] - for package in backend_lock["package"] - if package["name"] == "cryptography" - } - assert cryptography_versions == {"50.0.0"} - assert pins(strix_requirements, "protobuf") == ["protobuf==6.33.6"] - assert pins(strix_hashes, "protobuf") == ["protobuf==6.33.6"] - - -def test_frontend_postcss_lock_is_cve_2026_69153_fixed() -> None: - """Keep every manifest and lock surface on the first currently governed fix.""" - frontend_package = json.loads(read_repo_text("frontend/package.json")) - frontend_workspace = yaml.safe_load(read_repo_text("frontend/pnpm-workspace.yaml")) - frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml")) - - assert frontend_package["devDependencies"]["postcss"] == "8.5.24" - assert frontend_package["overrides"]["postcss"] == "8.5.24" - assert frontend_package["resolutions"]["postcss"] == "8.5.24" - assert frontend_workspace["overrides"]["postcss"] == "8.5.24" - assert frontend_lock["overrides"]["postcss"] == "8.5.24" - assert frontend_lock["importers"]["."]["devDependencies"]["postcss"] == { - "specifier": "8.5.24", - "version": "8.5.24", - } - - for section in ("packages", "snapshots"): - postcss_keys = [ - package - for package in frontend_lock[section] - if package.startswith("postcss@") - ] - assert postcss_keys == ["postcss@8.5.24"] - - -def test_frontend_tooling_lock_uses_current_audit_fixed_transitive_versions() -> None: - """Keep newly disclosed audit fixes aligned across manifest and pnpm lock.""" - frontend_package = json.loads(read_repo_text("frontend/package.json")) - frontend_workspace = yaml.safe_load(read_repo_text("frontend/pnpm-workspace.yaml")) - frontend_lock = yaml.safe_load(read_repo_text("frontend/pnpm-lock.yaml")) - - assert frontend_package["devDependencies"]["jsdom"] == "^30.0.1" - for dependency, expected_version in ( - ("brace-expansion", "5.0.9"), - ("undici", "8.9.0"), - ): - assert frontend_package["overrides"][dependency] == expected_version - assert frontend_package["resolutions"][dependency] == expected_version - assert frontend_workspace["overrides"][dependency] == expected_version - assert frontend_lock["overrides"][dependency] == expected_version - - for section in ("packages", "snapshots"): - locked_keys = [ - package - for package in frontend_lock[section] - if package.startswith(f"{dependency}@") - ] - assert locked_keys == [f"{dependency}@{expected_version}"] - - assert [ - package for package in frontend_lock["packages"] if package.startswith("jsdom@") - ] == ["jsdom@30.0.1"] - - def test_changelog_follows_keep_a_changelog_for_initial_korean_release() -> None: changelog = read_repo_text("CHANGELOG.md") @@ -759,6 +716,17 @@ def test_docker_publish_validates_pr_images_and_publishes_semver_images_only_on_ assert workflow.count("image: naruon") == 2 assert "push: false" in workflow assert "push: true" in workflow + assert workflow.count("base_dockerfile: Dockerfile") == 4 + assert workflow.count("base_dockerfile: frontend/Dockerfile") == 2 + assert workflow.count('base_digest="${base_reference##*@}"') == 2 + assert workflow.count('base_name="docker.io/library/$base_reference"') == 2 + assert "Resolve pinned Ollama base manifest" in workflow + assert "docker buildx imagetools inspect" in workflow + assert "Platform:[[:space:]]+${platform}[[:space:]]*$" in workflow + assert "Pinned Ollama manifest is missing %s" in workflow + assert "linux/amd64 linux/arm64" in workflow + assert "sha256:44dd04494ee8f3b538294360e7c4b3acb87c8268e4d0a4828a6500b1eff50061" not in workflow + assert "sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" not in workflow assert "sbom: false" in workflow assert workflow.count("sbom: true") == 1 assert "type=semver" in workflow diff --git a/backend/tests/test_repo_hygiene.py b/backend/tests/test_repo_hygiene.py index 86316f80f..f5dd0e363 100644 --- a/backend/tests/test_repo_hygiene.py +++ b/backend/tests/test_repo_hygiene.py @@ -50,7 +50,7 @@ def test_ollama_dockerfile_keeps_pulled_models_available_to_runtime_user(): assert ( "FROM ollama/ollama@sha256:" - "509fdf54e23bd50d87af646cb51c0a7a203d6a83cc4d6695b3b08c5be1c62c0a" + "b88c73ace3e115f8ec53dc8761ae1c0aabfa675406e3681786b98757ce050f42" in dockerfile ) assert "FROM ollama/ollama:latest\n" not in dockerfile diff --git a/connector/Dockerfile b/connector/Dockerfile index db7e95e7e..fa45883d0 100644 --- a/connector/Dockerfile +++ b/connector/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.14-slim@sha256:cea0e6040540fb2b965b6e7fb5ffa00871e632eef63719f0ea54bca189ce14a6 +FROM python:3.14-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 diff --git a/docs/operations/container-provenance-contract.md b/docs/operations/container-provenance-contract.md new file mode 100644 index 000000000..0d98c0863 --- /dev/null +++ b/docs/operations/container-provenance-contract.md @@ -0,0 +1,41 @@ +# Container provenance contract + +Naruon container images must be reproducible from reviewable, immutable base-image inputs. + +## Required invariants + +- Every production `FROM` instruction uses both a human-readable image tag and a full `sha256` digest. +- The root, backend, connector, and frontend Dockerfiles keep shared Python and Node base references synchronized where the runtime contract is shared. +- OCI `org.opencontainers.image.base.name` and `org.opencontainers.image.base.digest` annotations are derived from the actual first Dockerfile stage rather than duplicated constants. +- `OCI_IMAGE_BASE_DIGEST` and `OCI_IMAGE_BASE_NAME` are mandatory build arguments. Dockerfiles fail closed when a publishing or validation path omits either value. +- Published multi-platform images preserve annotations at both the manifest and index levels. +- Pull-request validation resolves the pinned Ollama manifest and fails closed when either `linux/amd64` or `linux/arm64` is absent. +- Dependency and image security pins remain governed by executable repository tests; a dependency upgrade must update its hash-locked artifact and the corresponding regression contract together. +- Backend `cryptography==50.0.0` and `protobuf==7.35.1`, Strix `cryptography==50.0.0` and `protobuf==6.33.6`, frontend source pins `postcss==8.5.24` and `jsdom==^30.0.1`, generated-lock resolutions `postcss==8.5.24` and `jsdom==30.0.1`, and the `brace-expansion==5.0.9` and `undici==8.9.0` overrides are parsed and checked structurally. + +## Change procedure + +1. Update the tag-and-digest reference in the canonical Dockerfile. +2. Synchronize every Dockerfile that shares that runtime. +3. Regenerate affected hash locks without weakening `--require-hashes` installation. +4. Update `CHANGELOG.md` when the runtime or published artifact changes. +5. Run release-governance, repository-hygiene, dependency-pin, application, image-build, and security checks on the exact pull-request head. +6. Merge only after independent review confirms that the OCI annotations describe the image that is actually built. + +A mutable tag by itself, a digest without its reviewable tag, an omitted mandatory base-metadata argument, or an annotation that does not match the first stage violates this contract. + +## Standards interpretation + +The OCI Image Format is the authoritative interoperability contract for image manifests, indexes, configurations, and descriptors. Naruon derives its base-image annotations from the Dockerfile actually used for the build so the published metadata cannot silently diverge from the reviewed build input. + +SLSA Build Provenance 1.2 describes provenance as verifiable information about where, when, and how an artifact was produced. It treats externally supplied build parameters as untrusted inputs that must be recorded and verified downstream. Naruon's tag-and-digest base references, exact workflow revision, and generated dependency locks are therefore reviewable build inputs rather than decorative metadata. This repository does not claim a SLSA level solely because it emits OCI annotations. + +NIST SP 800-218, SSDF 1.1, recommends protecting software and verifying third-party components throughout the development and delivery lifecycle. Naruon implements that guidance through immutable action and image pins, generated hash locks, exact-head tests, vulnerability scans, and independent review. The newer SSDF 1.2 document remains an initial public draft as of August 2026 and is informative rather than the formal conformance baseline. + +## References + +National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). https://doi.org/10.6028/NIST.SP.800-218 + +Open Container Initiative. (2025). *OCI image format specification* (Version 1.1.1). https://github.com/opencontainers/image-spec/tree/v1.1.1 + +Supply-chain Levels for Software Artifacts. (2025). *Build provenance* (SLSA specification Version 1.2). https://slsa.dev/spec/v1.2/build-provenance diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 770d713e7..b33546053 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-slim@sha256:ffc78385a788964bb3cbab5e434ff79a10bdc25b8ae6db03fe5fe6cb14053c09 +FROM node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503 ARG OCI_IMAGE_CREATED="" ARG OCI_IMAGE_AUTHORS="Seongho Bae" @@ -12,8 +12,13 @@ ARG OCI_IMAGE_LICENSES="LicenseRef-Naruon-Proprietary" ARG OCI_IMAGE_REF_NAME="" ARG OCI_IMAGE_TITLE="naruon frontend" ARG OCI_IMAGE_DESCRIPTION="Naruon Next.js frontend runtime image" -ARG OCI_IMAGE_BASE_DIGEST="sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" -ARG OCI_IMAGE_BASE_NAME="docker.io/library/node:26-slim@sha256:191ef878ecb351d68b78219593de18bd8942afd59af59f29960dc4b24805a3f1" +ARG OCI_IMAGE_BASE_DIGEST="sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503" +ARG OCI_IMAGE_BASE_NAME="docker.io/library/node:26-slim@sha256:4ebb5ace66f15a24c14c492e01a8beeed4fddf970a856109f5126e703e5fe503" + +# Defaults keep local builds provenance-complete. The release workflow derives +# and overrides both values from this file's exact FROM line, while repository +# governance tests prevent the reviewed defaults from drifting. +RUN test -n "$OCI_IMAGE_BASE_DIGEST" && test -n "$OCI_IMAGE_BASE_NAME" LABEL org.opencontainers.image.created="${OCI_IMAGE_CREATED}" \ org.opencontainers.image.authors="${OCI_IMAGE_AUTHORS}" \