diff --git a/containers/Dockerfile b/containers/Dockerfile index a1ff49ef0..f1f52474c 100644 --- a/containers/Dockerfile +++ b/containers/Dockerfile @@ -333,4 +333,5 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh USER pentester WORKDIR /workspace +LABEL strix.sandbox.caido-loopback="true" ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/containers/docker-entrypoint.sh b/containers/docker-entrypoint.sh index e4f472d62..e1ab0b4f5 100644 --- a/containers/docker-entrypoint.sh +++ b/containers/docker-entrypoint.sh @@ -38,7 +38,12 @@ if [ -n "${STRIX_CAIDO_ALLOWED_DOMAINS:-}" ]; then done fi -caido-cli --listen 0.0.0.0:${CAIDO_PORT} \ +# Bridge mode publishes this port on the host loopback. Host networking sets +# STRIX_CAIDO_LISTEN_ADDRESS=127.0.0.1 so --allow-guests is not reachable on +# every host interface. Unset keeps the bridge listener. +CAIDO_LISTEN_ADDRESS="${STRIX_CAIDO_LISTEN_ADDRESS:-0.0.0.0}" + +caido-cli --listen "${CAIDO_LISTEN_ADDRESS}:${CAIDO_PORT}" \ --allow-guests \ --no-logging \ --no-open \ diff --git a/docs/advanced/configuration.mdx b/docs/advanced/configuration.mdx index 29be4c871..88b2be333 100644 --- a/docs/advanced/configuration.mdx +++ b/docs/advanced/configuration.mdx @@ -198,6 +198,27 @@ When remote vars are set, Strix dual-writes telemetry to both local JSONL and th Runtime backend for the sandbox environment. + + Docker network for the sandbox. Leave unset to use the default bridge and publish the Caido port on `127.0.0.1`. Set to `host` when the target is reachable only through a VPN route on a Linux host running a local Docker engine. Any other value is the name of a Docker network to attach. + + + + Extra sandbox hosts entries, as a JSON object of hostname to a literal IPv4 or IPv6 address (for example `{"app.htb":"10.10.11.42","v6.htb":"fd00::20"}`). Entries merge with the reserved `host.docker.internal` → `host-gateway` mapping. Empty hostnames, values that are not IP addresses, and replacing `host.docker.internal` are rejected. The host `/etc/hosts` file is not inherited. + + +The sandbox does not copy `/etc/hosts` from the machine running Docker. On a Linux host with a local Docker engine and the VPN already connected, map the lab hostname explicitly: + +```bash +export STRIX_DOCKER_SANDBOX_NETWORK=host +export STRIX_DOCKER_EXTRA_HOSTS='{"app.htb":"10.10.11.42"}' +``` + +`host` shares the Docker engine's network namespace. Use it with a Docker engine on the same Linux machine as Strix. Docker Desktop runs the engine in a virtual machine, so this does not follow a VPN connected on macOS or Windows. A remote daemon (`DOCKER_HOST`) applies host networking on that other machine, while Strix still resolves Caido at `127.0.0.1` on the machine running the CLI. + +Host mode binds Caido to the fixed port `48080`, so only one host-mode scan can run on a daemon at a time. The default bridge network publishes that container port to a free localhost port for each scan. + +Host mode requires image label `strix.sandbox.caido-loopback=true`. That label marks an entrypoint that honors `STRIX_CAIDO_LISTEN_ADDRESS` and binds Caido to loopback. A cached older image is rejected instead of listening on every host interface. + ## Sandbox Configuration diff --git a/strix/runtime/docker_client.py b/strix/runtime/docker_client.py index 68ffa2fba..7395fa33e 100644 --- a/strix/runtime/docker_client.py +++ b/strix/runtime/docker_client.py @@ -3,7 +3,7 @@ NET_ADMIN/NET_RAW capabilities + host-gateway. The SDK's ``DockerSandboxClient._create_container`` does not expose a hook for extending ``create_kwargs`` before ``containers.create`` is called. We subclass -and reimplement the method body verbatim from the SDK source, with three +and reimplement the method body verbatim from the SDK source, with five deltas: 1. Drop the SDK's ``entrypoint=["tail"]`` override; supply ``["tail", "-f", @@ -15,6 +15,12 @@ deltas: other raw-socket tools). 3. Add ``host.docker.internal`` → host-gateway to ``extra_hosts`` so the agent can reach host-served apps. +4. Honor ``STRIX_DOCKER_SANDBOX_NETWORK``. ``host`` omits published ports, + tells the entrypoint to bind Caido to loopback, and resolves exposed + ports on the daemon loopback. Any other name attaches that network and + resolves the container IP. Unset keeps the SDK published-port path. +5. Merge ``STRIX_DOCKER_EXTRA_HOSTS`` (a JSON hostname-to-IP object) into + ``extra_hosts``. ``host.docker.internal`` stays reserved for host-gateway. Pinned to ``openai-agents==0.14.6``. Bumping the SDK requires re-merging the parent body. Track upstream for an injection hook. @@ -23,6 +29,8 @@ re-merging the parent body. Track upstream for an injection hook. from __future__ import annotations import contextlib +import ipaddress +import json import logging import os import uuid @@ -52,6 +60,14 @@ logger = logging.getLogger(__name__) _SANDBOX_NETWORK_ENV = "STRIX_DOCKER_SANDBOX_NETWORK" +_EXTRA_HOSTS_ENV = "STRIX_DOCKER_EXTRA_HOSTS" +_HOST_GATEWAY_HOSTNAME = "host.docker.internal" +_HOST_NETWORK_NAME = "host" +_DAEMON_LOOPBACK = "127.0.0.1" +# Read by containers/docker-entrypoint.sh. Set only for host networking so the +# unauthenticated Caido listener stays on loopback instead of 0.0.0.0. +_CAIDO_LISTEN_ADDRESS_ENV = "STRIX_CAIDO_LISTEN_ADDRESS" +_CAIDO_LOOPBACK_LABEL = "strix.sandbox.caido-loopback" def _sandbox_network() -> str | None: @@ -59,11 +75,114 @@ def _sandbox_network() -> str | None: return value or None -def _apply_sandbox_network(create_kwargs: dict[str, Any]) -> None: +def _apply_host_network_listener(create_kwargs: dict[str, Any]) -> None: + """Point the entrypoint at loopback when the sandbox uses host networking. + + ``docker-entrypoint.sh`` otherwise runs ``caido-cli --listen 0.0.0.0:48080`` + with guest access enabled. Host networking would expose that proxy on every + host interface. Named and bridge networks keep the bridge listener. + """ + raw_environment = create_kwargs.get("environment") + if isinstance(raw_environment, dict): + environment: dict[str, Any] = dict(cast("dict[str, Any]", raw_environment)) + else: + environment = {} + environment[_CAIDO_LISTEN_ADDRESS_ENV] = _DAEMON_LOOPBACK + create_kwargs["environment"] = environment + + +def _require_host_network_image(docker_client: Any, image: str) -> None: + """Reject host mode when a cached image may still bind Caido on 0.0.0.0.""" + try: + attrs = docker_client.images.get(image).attrs + except docker_errors.ImageNotFound: + attrs = None + config = attrs.get("Config") if isinstance(attrs, dict) else None + labels = config.get("Labels") if isinstance(config, dict) else None + if isinstance(labels, dict) and labels.get(_CAIDO_LOOPBACK_LABEL) == "true": + return + raise ValueError( + f"{_SANDBOX_NETWORK_ENV}=host requires label {_CAIDO_LOOPBACK_LABEL}=true " + f"so {_CAIDO_LISTEN_ADDRESS_ENV} is honored. Cached older images bind " + "Caido to 0.0.0.0 with guest access." + ) + + +def _apply_sandbox_network(create_kwargs: dict[str, Any], docker_client: Any, image: str) -> None: network = _sandbox_network() - if network: - create_kwargs["network"] = network - create_kwargs.pop("ports", None) + if not network: + return + if network == _HOST_NETWORK_NAME: + _require_host_network_image(docker_client, image) + create_kwargs["network"] = network + create_kwargs.pop("ports", None) + if network == _HOST_NETWORK_NAME: + _apply_host_network_listener(create_kwargs) + + +def _extra_host_mappings() -> dict[str, str]: + """Hostname → literal IP entries from ``STRIX_DOCKER_EXTRA_HOSTS``. + + Unset or blank leaves the built-in host-gateway mapping untouched. A + malformed value raises before the container is created. + """ + raw = os.environ.get(_EXTRA_HOSTS_ENV, "").strip() + if not raw: + return {} + + try: + parsed: object = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError( + f"{_EXTRA_HOSTS_ENV} must be a JSON object mapping hostnames to " + f"IP addresses ({exc.msg})" + ) from exc + + if not isinstance(parsed, dict): + got = "array" if isinstance(parsed, list) else type(parsed).__name__ + raise TypeError( + f"{_EXTRA_HOSTS_ENV} must be a JSON object mapping hostnames to IP addresses, got {got}" + ) + + mappings: dict[str, str] = {} + for name, address in cast("dict[object, object]", parsed).items(): + hostname = _extra_hostname(name) + mappings[hostname] = _extra_host_address(hostname, address) + return mappings + + +def _extra_hostname(name: object) -> str: + if not isinstance(name, str): + raise TypeError(f"{_EXTRA_HOSTS_ENV} hostnames must be strings") + hostname = name.strip() + if not hostname: + raise ValueError(f"{_EXTRA_HOSTS_ENV} contains an empty hostname") + if hostname.lower() == _HOST_GATEWAY_HOSTNAME: + raise ValueError( + f"{_EXTRA_HOSTS_ENV} cannot replace the reserved {_HOST_GATEWAY_HOSTNAME} mapping" + ) + return hostname + + +def _extra_host_address(hostname: str, address: object) -> str: + if not isinstance(address, str): + raise TypeError( + f"{_EXTRA_HOSTS_ENV} value for {hostname!r} must be an IPv4 or IPv6 address string" + ) + candidate = address.strip() + try: + ipaddress.ip_address(candidate) + except ValueError as exc: + raise ValueError( + f"{_EXTRA_HOSTS_ENV} value for {hostname!r} is not a literal IPv4 or IPv6 address" + ) from exc + return candidate + + +def _apply_extra_hosts(create_kwargs: dict[str, Any]) -> None: + extra_hosts = create_kwargs.setdefault("extra_hosts", {}) + extra_hosts[_HOST_GATEWAY_HOSTNAME] = "host-gateway" + extra_hosts.update(_extra_host_mappings()) _SANDBOX_DEVICES_ENV = "STRIX_DOCKER_SANDBOX_DEVICES" @@ -169,6 +288,11 @@ class StrixDockerSandboxSession(DockerSandboxSession): cause=e, ) from e + # Host mode has no container IP. The entrypoint binds Caido to the + # daemon loopback on the requested port. + if self.sandbox_network == _HOST_NETWORK_NAME: + return ExposedPortEndpoint(host=_DAEMON_LOOPBACK, port=port, tls=False) + attrs = getattr(self._container, "attrs", {}) or {} networks = attrs.get("NetworkSettings", {}).get("Networks", {}) endpoint = networks.get(self.sandbox_network) or {} @@ -256,10 +380,8 @@ class StrixDockerSandboxClient(DockerSandboxClient): if cap not in cap_add: cap_add.append(cap) - extra_hosts = create_kwargs.setdefault("extra_hosts", {}) - extra_hosts["host.docker.internal"] = "host-gateway" - - _apply_sandbox_network(create_kwargs) + _apply_extra_hosts(create_kwargs) + _apply_sandbox_network(create_kwargs, self.docker_client, image) _apply_sandbox_devices(create_kwargs) _apply_resource_limits(create_kwargs) _apply_log_limits(create_kwargs) diff --git a/tests/test_docker_client_network.py b/tests/test_docker_client_network.py new file mode 100644 index 000000000..796f85255 --- /dev/null +++ b/tests/test_docker_client_network.py @@ -0,0 +1,292 @@ +"""Sandbox network override and explicit hostname mappings. + +Host networking cannot use the SDK's published-port resolver: Docker reports +no container IP, and published ports are omitted. ``STRIX_DOCKER_EXTRA_HOSTS`` +adds target names the sandbox does not inherit from the host hosts file. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest +from agents.sandbox.errors import ExposedPortUnavailableError +from agents.sandbox.manifest import Environment, Manifest +from agents.sandbox.sandboxes.docker import DockerSandboxClientOptions, DockerSandboxSession +from agents.sandbox.types import ExposedPortEndpoint +from docker import errors as docker_errors + +from strix.runtime.docker_client import StrixDockerSandboxClient, StrixDockerSandboxSession + + +_IMAGE = "strix-sandbox:test" +_CAIDO_PORT = 48080 +_GATEWAY = {"host.docker.internal": "host-gateway"} +_CAIDO_LOOPBACK_LABEL = "strix.sandbox.caido-loopback" + + +def _clear_network_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("STRIX_DOCKER_SANDBOX_NETWORK", raising=False) + monkeypatch.delenv("STRIX_DOCKER_EXTRA_HOSTS", raising=False) + + +def _client( + *, caido_loopback: bool = False +) -> tuple[StrixDockerSandboxClient, MagicMock, MagicMock]: + docker = MagicMock() + container = MagicMock() + container.id = "container-1" + container.short_id = "container-1" + docker.containers.create.return_value = container + if caido_loopback: + image = MagicMock() + image.attrs = {"Config": {"Labels": {_CAIDO_LOOPBACK_LABEL: "true"}}} + docker.images.get.return_value = image + return StrixDockerSandboxClient(docker), docker, container + + +def _created_kwargs(docker: MagicMock) -> dict[str, Any]: + kwargs: dict[str, Any] = docker.containers.create.call_args.kwargs + return kwargs + + +async def _create_session( + monkeypatch: pytest.MonkeyPatch, + network: str | None, + *, + manifest: Manifest | None = None, +) -> tuple[Any, MagicMock, MagicMock]: + _clear_network_env(monkeypatch) + if network is not None: + monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network) + client, docker, container = _client(caido_loopback=network == "host") + session = await client.create( + options=DockerSandboxClientOptions(_IMAGE, (_CAIDO_PORT,)), + manifest=manifest, + ) + return session, docker, container + + +@pytest.mark.asyncio +async def test_default_network_publishes_localhost_and_keeps_entrypoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_network_env(monkeypatch) + client, docker, _container = _client() + + await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,)) + + kwargs = _created_kwargs(docker) + docker.images.get.assert_called_with(_IMAGE) + docker.images.pull.assert_not_called() + assert kwargs["ports"] == {f"{_CAIDO_PORT}/tcp": ("127.0.0.1", None)} + assert kwargs["command"] == ["tail", "-f", "/dev/null"] + assert "entrypoint" not in kwargs + assert kwargs["cap_add"] == ["NET_ADMIN", "NET_RAW"] + assert kwargs["extra_hosts"] == _GATEWAY + assert "network" not in kwargs + assert kwargs["environment"] is None + + +@pytest.mark.asyncio +async def test_host_network_resolves_loopback_without_container_ip( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Manifest(environment=Environment(value={"SCAN_TARGET": "app.htb"})) + session, docker, container = await _create_session(monkeypatch, "host", manifest=manifest) + + kwargs = _created_kwargs(docker) + assert "ports" not in kwargs + assert kwargs["network"] == "host" + assert kwargs["environment"] == { + "SCAN_TARGET": "app.htb", + "STRIX_CAIDO_LISTEN_ADDRESS": "127.0.0.1", + } + assert kwargs["extra_hosts"] == _GATEWAY + assert type(session._inner) is StrixDockerSandboxSession + + container.attrs = { + "NetworkSettings": { + "Networks": {"host": {"IPAddress": "", "GlobalIPv6Address": ""}}, + } + } + endpoint = await session.resolve_exposed_port(_CAIDO_PORT) + assert endpoint == ExposedPortEndpoint(host="127.0.0.1", port=_CAIDO_PORT, tls=False) + container.reload.assert_called() + + container.reload.side_effect = docker_errors.APIError("reload failed") + with pytest.raises(ExposedPortUnavailableError) as exc_info: + await session.resolve_exposed_port(_CAIDO_PORT) + assert exc_info.value.context["detail"] == "container_reload_failed" + + +@pytest.mark.asyncio +async def test_host_network_rejects_image_without_caido_loopback_label( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clear_network_env(monkeypatch) + monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", "host") + client, docker, _container = _client() + docker.images.get.return_value.attrs = {"Config": {"Labels": None}} + with pytest.raises(ValueError, match="caido-loopback"): + await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,)) + docker.containers.create.assert_not_called() + + +@pytest.mark.asyncio +async def test_named_network_resolves_ipv4_and_ipv6(monkeypatch: pytest.MonkeyPatch) -> None: + session, docker, container = await _create_session(monkeypatch, "lab") + + kwargs = _created_kwargs(docker) + assert kwargs["network"] == "lab" + assert "ports" not in kwargs + assert kwargs["environment"] == {} + assert type(session._inner) is StrixDockerSandboxSession + assert session._inner.sandbox_network == "lab" + + container.attrs = { + "NetworkSettings": {"Networks": {"lab": {"IPAddress": "172.18.0.8"}}}, + } + assert await session.resolve_exposed_port(_CAIDO_PORT) == ExposedPortEndpoint( + host="172.18.0.8", port=_CAIDO_PORT, tls=False + ) + + container.attrs = { + "NetworkSettings": { + "Networks": {"lab": {"IPAddress": "", "GlobalIPv6Address": "fd00::8"}}, + } + } + assert await session.resolve_exposed_port(_CAIDO_PORT) == ExposedPortEndpoint( + host="[fd00::8]", port=_CAIDO_PORT, tls=False + ) + + +@pytest.mark.asyncio +async def test_named_network_missing_address_and_reload_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session, _docker, container = await _create_session(monkeypatch, "lab") + container.attrs = {"NetworkSettings": {"Networks": {"lab": {"IPAddress": ""}}}} + + with pytest.raises(ExposedPortUnavailableError) as missing: + await session.resolve_exposed_port(_CAIDO_PORT) + assert missing.value.context["detail"] == "container_not_on_network" + + container.reload.side_effect = docker_errors.APIError("reload failed") + with pytest.raises(ExposedPortUnavailableError) as failed: + await session.resolve_exposed_port(_CAIDO_PORT) + assert failed.value.context["detail"] == "container_reload_failed" + + +@pytest.mark.parametrize("network", [None, "", " "]) +@pytest.mark.asyncio +async def test_unset_network_keeps_sdk_published_port_resolver( + monkeypatch: pytest.MonkeyPatch, + network: str | None, +) -> None: + session, docker, container = await _create_session(monkeypatch, network) + + kwargs = _created_kwargs(docker) + assert "network" not in kwargs + assert kwargs["ports"] == {f"{_CAIDO_PORT}/tcp": ("127.0.0.1", None)} + assert type(session._inner) is DockerSandboxSession + container.attrs = { + "NetworkSettings": { + "Ports": {f"{_CAIDO_PORT}/tcp": [{"HostIp": "127.0.0.1", "HostPort": "35123"}]}, + "Networks": {}, + } + } + endpoint = await session.resolve_exposed_port(_CAIDO_PORT) + assert endpoint == ExposedPortEndpoint(host="127.0.0.1", port=35123, tls=False) + + +@pytest.mark.parametrize("network", [None, "host"]) +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ('{"app.htb":"10.10.11.42"}', {"app.htb": "10.10.11.42"}), + ( + '{"app.htb":"10.10.11.42","db.htb":"10.10.11.7","v6.htb":"fd00::10"}', + {"app.htb": "10.10.11.42", "db.htb": "10.10.11.7", "v6.htb": "fd00::10"}, + ), + ], +) +@pytest.mark.asyncio +async def test_extra_hosts_merge_with_gateway( + monkeypatch: pytest.MonkeyPatch, + network: str | None, + raw: str, + expected: dict[str, str], +) -> None: + _clear_network_env(monkeypatch) + if network is not None: + monkeypatch.setenv("STRIX_DOCKER_SANDBOX_NETWORK", network) + monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw) + client, docker, _container = _client(caido_loopback=network == "host") + + await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,)) + + assert _created_kwargs(docker)["extra_hosts"] == {**_GATEWAY, **expected} + + +@pytest.mark.parametrize("raw", [None, "", " "]) +@pytest.mark.asyncio +async def test_blank_extra_hosts_keep_gateway_mapping( + monkeypatch: pytest.MonkeyPatch, + raw: str | None, +) -> None: + _clear_network_env(monkeypatch) + if raw is not None: + monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw) + client, docker, _container = _client() + + await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,)) + + assert _created_kwargs(docker)["extra_hosts"] == _GATEWAY + + +@pytest.mark.parametrize( + ("raw", "exc_type", "match"), + [ + ("{", ValueError, r"STRIX_DOCKER_EXTRA_HOSTS must be a JSON object"), + ('["app.htb"]', TypeError, r"got array"), + ( + '{"app.htb": 1}', + TypeError, + r"value for 'app\.htb' must be an IPv4 or IPv6 address string", + ), + ( + '{"app.htb": "not-an-ip"}', + ValueError, + r"value for 'app\.htb' is not a literal IPv4 or IPv6 address", + ), + ('{"": "10.0.0.1"}', ValueError, r"empty hostname"), + ('{" ": "10.0.0.1"}', ValueError, r"empty hostname"), + ( + '{"host.docker.internal": "10.0.0.1"}', + ValueError, + r"cannot replace the reserved host\.docker\.internal mapping", + ), + ( + '{"HOST.Docker.Internal": "10.0.0.1"}', + ValueError, + r"cannot replace the reserved host\.docker\.internal mapping", + ), + ], +) +@pytest.mark.asyncio +async def test_extra_hosts_rejected_before_container_create( + monkeypatch: pytest.MonkeyPatch, + raw: str, + exc_type: type[Exception], + match: str, +) -> None: + _clear_network_env(monkeypatch) + monkeypatch.setenv("STRIX_DOCKER_EXTRA_HOSTS", raw) + client, docker, _container = _client() + + with pytest.raises(exc_type, match=match): + await client._create_container(_IMAGE, exposed_ports=(_CAIDO_PORT,)) + + docker.containers.create.assert_not_called()