From 5dd2c5400ac1e8f8aa5ecd43a826f27462e530df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:43:54 +0900 Subject: [PATCH 01/11] test(compose): require loopback-only host publishing --- tests/test_compose_network_boundary.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/test_compose_network_boundary.py diff --git a/tests/test_compose_network_boundary.py b/tests/test_compose_network_boundary.py new file mode 100644 index 000000000..9a365cf19 --- /dev/null +++ b/tests/test_compose_network_boundary.py @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Security contracts for standalone Docker Compose host publishing.""" + +from __future__ import annotations + +from pathlib import Path + + +_COMPOSE_PATH = Path(__file__).resolve().parents[1] / "docker-compose.yml" + + +def test_standalone_compose_publishes_database_and_health_only_on_loopback() -> None: + """Default standalone ports must not listen on every host network interface.""" + compose = _COMPOSE_PATH.read_text(encoding="utf-8") + + assert '"127.0.0.1:5432:5432"' in compose + assert '"127.0.0.1:8080:8080"' in compose + assert '\n - "5432:5432"' not in compose + assert '\n - "8080:8080"' not in compose From d188ad940d9fa5b617c3cd29644316dd44427f07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:44:41 +0900 Subject: [PATCH 02/11] fix(compose): restrict standalone published ports to loopback --- docker-compose.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 30c926328..ba3022af3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,9 @@ services: POSTGRES_PASSWORD: pgllm POSTGRES_DB: pgllm ports: - - "5432:5432" + # Standalone host access is local-only by default. Remote deployments must + # opt into an explicit ingress/network policy instead of inheriting 0.0.0.0. + - "127.0.0.1:5432:5432" healthcheck: # Readiness = DB up AND pg_tiktoken present (mirrors /healthz gate). test: @@ -43,7 +45,9 @@ services: PG_LLM_BATCH_DSN: "postgresql://pgllm:pgllm@postgres:5432/pgllm" PG_LLM_BATCH_HEALTH_PORT: "8080" ports: - - "8080:8080" + # The bundled standalone profile is deliberately host-local. Operators + # expose production health/API surfaces only through reviewed ingress. + - "127.0.0.1:8080:8080" volumes: pgdata: From 186fb09a20dead450654b0dd8fb4d9c5bf82521a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:45:18 +0900 Subject: [PATCH 03/11] docs(compose): document loopback-only standalone boundary --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 092a1eb67..c854a5db4 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,15 @@ docker compose up -d --build curl -fsS localhost:8080/healthz ``` +The bundled standalone Compose profile publishes PostgreSQL `5432` and the +component health port `8080` on IPv4 loopback (`127.0.0.1`) only. This keeps the +developer-facing DB and readiness surfaces reachable from the Docker host while +preventing the default profile from listening on every host interface. Remote or +production ingress must be an explicit deployment decision with its own network, +authentication, and exposure policy; do not broaden the standalone mappings as a +shortcut. See [`docs/doctoring/compose-loopback-publishing.md`](docs/doctoring/compose-loopback-publishing.md) +for the primary Docker networking evidence and verification contract. + ### 2. Point it at your gateway (config + secret in the DB, not env) ```bash @@ -223,6 +232,9 @@ PG_LLM_BATCH_TEST_DSN=postgresql://pgllm:pgllm@localhost:5432/pgllm \ ## Docs +- [`docs/doctoring/compose-loopback-publishing.md`](docs/doctoring/compose-loopback-publishing.md) + — default standalone host-network boundary, primary Docker evidence, and + regression/release verification. - [`docs/doctoring/opentelemetry-operations.md`](docs/doctoring/opentelemetry-operations.md) — opt-in operation traces/metrics, host ownership, privacy and cardinality boundaries, verification, and APA 7 references. From 07ab7c4084ff952c88328bfcd5d6bbdfd373c21a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:45:44 +0900 Subject: [PATCH 04/11] docs(security): record Compose loopback evidence --- docs/doctoring/compose-loopback-publishing.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 docs/doctoring/compose-loopback-publishing.md diff --git a/docs/doctoring/compose-loopback-publishing.md b/docs/doctoring/compose-loopback-publishing.md new file mode 100644 index 000000000..06446a730 --- /dev/null +++ b/docs/doctoring/compose-loopback-publishing.md @@ -0,0 +1,31 @@ +# Standalone Compose loopback publishing + +## Decision + +The bundled `docker-compose.yml` is a standalone/developer profile. Its published PostgreSQL (`5432`) and component health (`8080`) ports bind to IPv4 loopback (`127.0.0.1`) by default. + +Remote access is not silently inherited from this profile. A production deployment that needs external ingress must define an explicit deployment-specific network and authorization boundary rather than broadening the bundled standalone mappings. + +## Security rationale + +Docker's Compose service reference defines the short port syntax as `[HOST:]CONTAINER[/PROTOCOL]` and warns that omitting the host IP binds the published port to all host interfaces (`0.0.0.0`). Docker's port-publishing documentation likewise states that a mapping that includes `127.0.0.1` is accessible only from the Docker host. The previous `"5432:5432"` and `"8080:8080"` mappings therefore created a broader default host-network surface than the standalone workflow requires. + +The loopback binding preserves the documented local commands: + +- host-side PostgreSQL access through `localhost:5432`; +- host-side readiness checks through `localhost:8080`; +- Compose-internal service-to-service traffic over the project network. + +It intentionally does not claim that loopback binding replaces authentication, tenant isolation, firewalling, ingress policy, or production deployment hardening. + +## Verification contract + +`tests/test_compose_network_boundary.py` requires the exact loopback mappings and rejects the prior all-interface short mappings. CI also renders the Compose model with `docker compose config` and builds both component and PostgreSQL images. + +Release evidence must be taken from the exact source head under review. Organization-provided security/SAST workflows may independently exercise a synthetic merge ref; those results are integration evidence and are not relabeled as exact-source proof. + +## References + +Docker, Inc. (n.d.). *Define services in Docker Compose*. Docker Documentation. Retrieved August 9, 2026, from https://docs.docker.com/reference/compose-file/services/ + +Docker, Inc. (n.d.). *Port publishing and mapping*. Docker Documentation. Retrieved August 9, 2026, from https://docs.docker.com/engine/network/port-publishing/ From f0ab870deb976a080fd79912dc8df91fb7b2f73e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 19:46:04 +0900 Subject: [PATCH 05/11] docs(changelog): record loopback-only standalone ports --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83fd56b75..3c7c62321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Restricted the bundled standalone Compose PostgreSQL and component-health + published ports to IPv4 loopback so the default developer profile no longer + listens on every host interface when operators have not made an explicit + ingress decision. - Enforced byte-accurate control-plane limits for multi-byte `memoryview` chunks using `nbytes`, and rejected malformed non-byte adapter chunks with bounded body-free diagnostics. From 8e084be4d3504f6abe2dca75c859a2a9b0e04753 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 9 Aug 2026 20:15:20 +0900 Subject: [PATCH 06/11] test(compose): validate canonical loopback port model --- tests/test_compose_network_boundary.py | 61 +++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/tests/test_compose_network_boundary.py b/tests/test_compose_network_boundary.py index 9a365cf19..46957cdc0 100644 --- a/tests/test_compose_network_boundary.py +++ b/tests/test_compose_network_boundary.py @@ -3,17 +3,64 @@ from __future__ import annotations +import json from pathlib import Path +import shutil +import subprocess +from typing import Any -_COMPOSE_PATH = Path(__file__).resolve().parents[1] / "docker-compose.yml" +_ROOT = Path(__file__).resolve().parents[1] +_COMPOSE_PATH = _ROOT / "docker-compose.yml" + + +def _compose_model() -> dict[str, Any]: + """Return Docker Compose's normalized JSON model for the standalone stack.""" + docker = shutil.which("docker") + assert docker is not None, "Docker CLI is required to validate Compose security" + result = subprocess.run( + [ + docker, + "compose", + "-f", + str(_COMPOSE_PATH), + "config", + "--format", + "json", + ], + cwd=_ROOT, + check=True, + capture_output=True, + text=True, + ) + model = json.loads(result.stdout) + assert isinstance(model, dict) + return model + + +def _assert_only_loopback_port( + model: dict[str, Any], service_name: str, port_number: int +) -> None: + """Require one exact IPv4-loopback TCP publication for a service port.""" + services = model.get("services") + assert isinstance(services, dict) + service = services.get(service_name) + assert isinstance(service, dict) + ports = service.get("ports") + assert isinstance(ports, list) + assert len(ports) == 1 + + published = ports[0] + assert isinstance(published, dict) + assert published.get("host_ip") == "127.0.0.1" + assert int(published.get("published")) == port_number + assert published.get("target") == port_number + assert published.get("protocol", "tcp") == "tcp" def test_standalone_compose_publishes_database_and_health_only_on_loopback() -> None: - """Default standalone ports must not listen on every host network interface.""" - compose = _COMPOSE_PATH.read_text(encoding="utf-8") + """Canonical Compose ports must expose only the two intended loopback sockets.""" + model = _compose_model() - assert '"127.0.0.1:5432:5432"' in compose - assert '"127.0.0.1:8080:8080"' in compose - assert '\n - "5432:5432"' not in compose - assert '\n - "8080:8080"' not in compose + _assert_only_loopback_port(model, "postgres", 5432) + _assert_only_loopback_port(model, "component", 8080) From d3515a0e76a1ea3a2f16ea904701fb5d41c2215d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 04:14:33 +0900 Subject: [PATCH 07/11] test(compose): reject unexpected published services --- tests/test_compose_network_boundary.py | 52 ++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/tests/test_compose_network_boundary.py b/tests/test_compose_network_boundary.py index 46957cdc0..0e60df20b 100644 --- a/tests/test_compose_network_boundary.py +++ b/tests/test_compose_network_boundary.py @@ -9,6 +9,8 @@ import subprocess from typing import Any +import pytest + _ROOT = Path(__file__).resolve().parents[1] _COMPOSE_PATH = _ROOT / "docker-compose.yml" @@ -58,9 +60,53 @@ def _assert_only_loopback_port( assert published.get("protocol", "tcp") == "tcp" +def _assert_standalone_port_contract(model: dict[str, Any]) -> None: + """Require the currently intended database and health publications.""" + _assert_only_loopback_port(model, "postgres", 5432) + _assert_only_loopback_port(model, "component", 8080) + + def test_standalone_compose_publishes_database_and_health_only_on_loopback() -> None: """Canonical Compose ports must expose only the two intended loopback sockets.""" - model = _compose_model() + _assert_standalone_port_contract(_compose_model()) - _assert_only_loopback_port(model, "postgres", 5432) - _assert_only_loopback_port(model, "component", 8080) + +def test_standalone_port_contract_rejects_an_unexpected_published_service() -> None: + """Adding another host-published service must fail the standalone boundary.""" + model: dict[str, Any] = { + "services": { + "postgres": { + "ports": [ + { + "host_ip": "127.0.0.1", + "published": "5432", + "target": 5432, + "protocol": "tcp", + } + ] + }, + "component": { + "ports": [ + { + "host_ip": "127.0.0.1", + "published": "8080", + "target": 8080, + "protocol": "tcp", + } + ] + }, + "rogue": { + "ports": [ + { + "host_ip": "0.0.0.0", + "published": "9090", + "target": 9090, + "protocol": "tcp", + } + ] + }, + } + } + + with pytest.raises(AssertionError): + _assert_standalone_port_contract(model) From 085d672aaff668980a0be3e5965fa57080bc10c0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 04:15:37 +0900 Subject: [PATCH 08/11] test(compose): enforce complete published-port allowlist --- tests/test_compose_network_boundary.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/test_compose_network_boundary.py b/tests/test_compose_network_boundary.py index 0e60df20b..e0d29d657 100644 --- a/tests/test_compose_network_boundary.py +++ b/tests/test_compose_network_boundary.py @@ -14,6 +14,7 @@ _ROOT = Path(__file__).resolve().parents[1] _COMPOSE_PATH = _ROOT / "docker-compose.yml" +_EXPECTED_PUBLISHED_PORTS = {"postgres": 5432, "component": 8080} def _compose_model() -> dict[str, Any]: @@ -61,9 +62,24 @@ def _assert_only_loopback_port( def _assert_standalone_port_contract(model: dict[str, Any]) -> None: - """Require the currently intended database and health publications.""" - _assert_only_loopback_port(model, "postgres", 5432) - _assert_only_loopback_port(model, "component", 8080) + """Require exactly the reviewed database and health host publications.""" + services = model.get("services") + assert isinstance(services, dict) + + published_services: set[str] = set() + for service_name, service in services.items(): + assert isinstance(service_name, str) + assert isinstance(service, dict) + ports = service.get("ports") + if ports is None: + continue + assert isinstance(ports, list) + if ports: + published_services.add(service_name) + + assert published_services == set(_EXPECTED_PUBLISHED_PORTS) + for service_name, port_number in _EXPECTED_PUBLISHED_PORTS.items(): + _assert_only_loopback_port(model, service_name, port_number) def test_standalone_compose_publishes_database_and_health_only_on_loopback() -> None: From e00a1138bb24f29351e27db31dd1d363de597577 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 12:17:46 +0900 Subject: [PATCH 09/11] test(compose): reject legacy health-port environment override --- tests/test_compose_network_boundary.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_compose_network_boundary.py b/tests/test_compose_network_boundary.py index e0d29d657..bb50457a2 100644 --- a/tests/test_compose_network_boundary.py +++ b/tests/test_compose_network_boundary.py @@ -87,6 +87,19 @@ def test_standalone_compose_publishes_database_and_health_only_on_loopback() -> _assert_standalone_port_contract(_compose_model()) +def test_standalone_compose_does_not_override_legacy_health_port_environment() -> None: + """The standalone profile must not advertise an inert shell-era health-port knob.""" + model = _compose_model() + services = model.get("services") + assert isinstance(services, dict) + component = services.get("component") + assert isinstance(component, dict) + environment = component.get("environment") + assert isinstance(environment, dict) + + assert "PG_LLM_BATCH_HEALTH_PORT" not in environment + + def test_standalone_port_contract_rejects_an_unexpected_published_service() -> None: """Adding another host-published service must fail the standalone boundary.""" model: dict[str, Any] = { From 69f3228aec480683de016b545d3f1799b189682a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 12:22:22 +0900 Subject: [PATCH 10/11] fix(compose): remove legacy health-port environment override --- docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ba3022af3..f433b0f72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,13 +41,13 @@ services: postgres: condition: service_healthy environment: - # Bootstrap transport only. + # Bootstrap transport only. The component image owns its default health + # port; alternate ports require an explicit command/healthcheck override. PG_LLM_BATCH_DSN: "postgresql://pgllm:pgllm@postgres:5432/pgllm" - PG_LLM_BATCH_HEALTH_PORT: "8080" ports: # The bundled standalone profile is deliberately host-local. Operators # expose production health/API surfaces only through reviewed ingress. - "127.0.0.1:8080:8080" volumes: - pgdata: + pgdata:/var/lib/postgresql/data From 4debfc68655e300a6c393b0cbd3c251daf4faf03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 12:22:46 +0900 Subject: [PATCH 11/11] fix(compose): preserve named volume declaration --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index f433b0f72..f35674fae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,4 +50,4 @@ services: - "127.0.0.1:8080:8080" volumes: - pgdata:/var/lib/postgresql/data + pgdata: