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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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.
- Removed plaintext secret values from `config set-secret` process arguments;
interactive entry now uses a no-echo prompt and fails closed if terminal echo
suppression is unavailable. Automation accepts one bounded logical line over
Expand Down
12 changes: 8 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -39,11 +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:
- "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:
31 changes: 31 additions & 0 deletions docs/doctoring/compose-loopback-publishing.md
Original file line number Diff line number Diff line change
@@ -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, rejects unexpected host-published services, and verifies that the obsolete shell-era `PG_LLM_BATCH_HEALTH_PORT` environment override is absent. 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 integration refs; those results remain distinct from exact-source proof.

## References

Docker, Inc. (n.d.). *Define services in Docker Compose*. Docker Documentation. Retrieved August 12, 2026, from https://docs.docker.com/reference/compose-file/services/

Docker, Inc. (n.d.). *Port publishing and mapping*. Docker Documentation. Retrieved August 12, 2026, from https://docs.docker.com/engine/network/port-publishing/
141 changes: 141 additions & 0 deletions tests/test_compose_network_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: Apache-2.0
"""Security contracts for standalone Docker Compose host publishing."""

from __future__ import annotations

import json
from pathlib import Path
import shutil
import subprocess
from typing import Any

import pytest


_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]:
"""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
Comment on lines +20 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

sed -n '1,120p' tests/test_compose_network_boundary.py
printf '\n--- docker-compose.yml ---\n'
sed -n '1,80p' docker-compose.yml
printf '\n--- documentation ---\n'
sed -n '1,80p' docs/doctoring/compose-loopback-publishing.md
printf '\n--- repository references ---\n'
rg -n --hidden -S '28\.0\.0|Server Version|docker version|compose_network|loopback|local-only|5432:5432|8080:8080' \
  -g '!node_modules' -g '!dist' -g '!build' .

Repository: ContextualWisdomLab/pg-llm-batch

Length of output: 11486


🌐 Web query:

Docker Engine port publishing localhost L2 access 28.0.0 release notes

💡 Result:

In Docker Engine 28.0.0, a significant security improvement addresses how ports bound to the loopback interface (localhost) are accessed [1][2]. Historically, in versions older than 28.0.0, ports published to localhost (e.g., -p 127.0.0.1:8080:80) could still be accessed by other hosts on the same Layer 2 (L2) network segment due to the way Docker managed iptables rules [1][3]. This meant that traffic destined for 127.0.0.1 could bypass expected host-level restrictions and be reached by remote devices on the local area network [3][2]. Docker Engine 28.0.0 resolves this by hardening container networking, ensuring that ports explicitly bound to the localhost interface are not accessible from external hosts on the LAN [1][2]. Additionally, Docker 28.0.0 introduces a default "secure by default" posture where unpublished container ports are no longer accessible from remote LAN hosts, even if the host's filter-FORWARD policy is set to ACCEPT [2][4]. For further details, refer to the official Docker Engine 28 release notes and the associated documentation on port publishing [5][1].

Citations:


Docker Engine 28.0.0 이상을 요구하고 강제하세요.

Docker Engine 28.0.0 미만에서는 127.0.0.1에 게시한 포트가 같은 L2 네트워크의 원격 호스트에서 접근될 수 있습니다.

  • 테스트에서 Docker daemon의 Server 버전을 검사하고, 28.0.0 미만이면 실패시키세요.
  • docker-compose.ymllocal-only 주석을 지원 런타임 조건에 맞게 수정하세요.
  • 문서에 Docker Engine 28.0.0 이상 요구사항과 이전 버전의 L2 노출 위험을 명시하세요.
🧰 Tools
🪛 ast-grep (0.45.1)

[error] 23-37: Command coming from incoming request
Context: subprocess.run(
[
docker,
"compose",
"-f",
str(_COMPOSE_PATH),
"config",
"--format",
"json",
],
cwd=_ROOT,
check=True,
capture_output=True,
text=True,
)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

📍 Affects 3 files
  • tests/test_compose_network_boundary.py#L20-L41 (this comment)
  • docker-compose.yml#L17-L19
  • docs/doctoring/compose-loopback-publishing.md#L11-L11
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_compose_network_boundary.py` around lines 20 - 41, Require Docker
Engine 28.0.0 or newer across the Compose security checks: update _compose_model
in tests/test_compose_network_boundary.py to query and validate the daemon
Server version, failing below 28.0.0; update the local-only comment in
docker-compose.yml at lines 17-19 to state the supported runtime requirement;
and update docs/doctoring/compose-loopback-publishing.md at line 11 to document
the minimum version and the L2 exposure risk on older engines.



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 _assert_standalone_port_contract(model: dict[str, Any]) -> None:
"""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:
"""Canonical Compose ports must expose only the two intended loopback sockets."""
_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] = {
"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)
Loading