Skip to content
Open
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
8 changes: 6 additions & 2 deletions libs/openant-core/tests/test_docker_egress_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
omitted `networks:` (implicit default). These tests lock the structural hardening.

This is sandbox HARDENING (opt-in --dynamic-test, already sandboxed), not a vuln fix;
build-time RUN egress remains an accepted, documented residual (not tested here).
build-time RUN egress is the #710 RECORDED RULING (accepted by design — the
executed evidence that killed the host-proxy alternative is named at the
single-build site in docker_executor.py; the drift pins live in
test_issue710_build_egress_ruling.py).
"""
import yaml

Expand Down Expand Up @@ -144,7 +147,8 @@ def test_scrubbed_env_removes_provider_secrets_keeps_docker_essentials():

A compose `build.args` value like `${ANTHROPIC_API_KEY}` is interpolated by
docker compose from the host env at BUILD time and can be exfiltrated by the
LLM-authored Dockerfile's `RUN` over the still-open build-time network. The
LLM-authored Dockerfile's `RUN` over the build-time network (the #710 recorded
ruling: accepted by design — which is exactly why the env scrub matters). The
build/run subprocess never needs any provider secret, so it runs with a
scrubbed env — closing the exfil by construction regardless of build.args.
"""
Expand Down
159 changes: 159 additions & 0 deletions libs/openant-core/tests/test_issue710_build_egress_ruling.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""#710: the recorded ruling — build egress accepted by design, the state pinned.

#247 closed RUNTIME egress (``--network none`` / internal compose nets) and
named the build half as deferred. The falsifier (authored at filing): "a
dynamic-test build whose RUN steps resolve only allowlisted hosts (or a
recorded ruling that build egress is accepted by design)."

This suite pins the SECOND disjunct: the recorded ruling (the executed
evidence that killed the host-proxy alternative is named at the single-build
site in ``docker_executor.py``) + the DRIFT PINS — the accepted state (the
build invocations run with default networking, exactly as #247 left them;
the runtime argv closed; the env allowlist in force) cannot drift silently:
any flag added to a build (e.g. ``--network host``), any runtime flag
removed, any allowlist entry loosened, is a deliberate diff that fires these
pins.
"""

import sys
from pathlib import Path

_CORE_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_CORE_ROOT))


def _executor():
import utilities.dynamic_tester.docker_executor as de
return de


def _capture(argvs):
def run(cmd, timeout, cwd=None):
argvs.append(cmd)
return ("", "", 0, False)
return run


# --- the drift pins: the accepted build state, exactly ---

def test_the_single_container_build_argv_is_exactly_the_accepted_state(monkeypatch):
"""The accepted build state pinned: `docker build -t <tag> .` — no
network flag, no proxy routing, nothing added. A drift (e.g.
--network host, a build-arg) is a deliberate diff this pin names."""
de = _executor()
argvs = []
monkeypatch.setattr(de, "_run_command", _capture(argvs))
de._run_single("/tmp/w", "img", "net", container_timeout=5,
build_timeout=5, container_name="c")
builds = [a for a in argvs if a[:2] == ["docker", "build"]]
assert builds, argvs
assert builds[0] == ["docker", "build", "-t", "img", "."], builds[0]


def test_the_compose_build_argv_is_exactly_the_accepted_state(monkeypatch):
"""The compose twin: `docker compose -p <proj> build` — nothing added."""
de = _executor()
argvs = []
monkeypatch.setattr(de, "_run_command", _capture(argvs))
de._run_compose("/tmp/w", "proj", container_timeout=5, build_timeout=5)
builds = [a for a in argvs if a[:2] == ["docker", "compose"] and a[4:5] == ["build"]]
assert builds, argvs
assert builds[0] == ["docker", "compose", "-p", "proj", "build"], builds[0]


def test_the_runtime_argv_stays_closed(monkeypatch):
"""The #247 runtime posture unchanged: --network none + the resource/
capability caps. (Re-derived here: the ruling's 'the RUNTIME sandbox is
the hard boundary' clause is pinned, not just stated.)"""
de = _executor()
argvs = []
monkeypatch.setattr(de, "_run_command", _capture(argvs))
de._run_single("/tmp/w", "img", "net", container_timeout=5,
build_timeout=5, container_name="c")
runs = [a for a in argvs if a[:2] == ["docker", "run"]]
assert runs, argvs
r = runs[0]
assert "--network" in r and r[r.index("--network") + 1] == "none", r
for flag in ("--rm", "--read-only", "--cap-drop", "--pids-limit",
"--memory", "--cpus", "--security-opt", "--tmpfs"):
assert flag in r, f"the runtime cap {flag} drifted: {r}"
assert r.count("--tmpfs") == 2, r # /tmp + /root (the writable mounts)
assert r[r.index("--cap-drop") + 1] == "ALL", r
assert r[r.index("--security-opt") + 1] == "no-new-privileges", r


def test_the_env_allowlist_still_scrubs(monkeypatch):
"""The #247 env allowlist (what the build subprocess SEES) is the
accepted posture's other half — a loosened entry is a drift this pin
names. (Pinned on the machinery's contract, re-derived here: a provider
secret is dropped, the docker essentials kept.)"""
de = _executor()
monkeypatch.setenv("PATH", "/usr/bin")
monkeypatch.setenv("DOCKER_HOST", "unix:///var/run/docker.sock")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-secret")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "x")
env = de._scrubbed_subprocess_env()
assert "ANTHROPIC_API_KEY" not in env
assert "AWS_SECRET_ACCESS_KEY" not in env
assert env["PATH"] == "/usr/bin" and env["DOCKER_HOST"].startswith("unix://")


def test_the_compose_build_file_side_route_is_dropped():
"""The compose FILE-side route to a host-network build: the LLM-authored
compose's `build.network: host` (or extra_hosts / entitlements) is
dropped by _safe_build's allowlist (context/dockerfile/args only) — the
r2-D3 catch: no pin held this drop, so a one-line drift in _safe_build
would silently reopen the exact --network host posture the build-argv
pin guards. Pinned now: the drop is a deliberate state."""
de = _executor()
out = de._safe_build({
"context": ".", "dockerfile": "Dockerfile",
"network": "host",
"extra_hosts": ["pypi.org=169.254.0.1"],
"entitlements": ["network.host"],
"args": {"A": "b"},
})
assert "network" not in out, out
assert "extra_hosts" not in out, out
assert "entitlements" not in out, out
assert out.get("context") == "." and out.get("args") == {"A": "b"}, out


# --- the ruling's presence (the recorded decision, named where it lives) ---

def test_the_recorded_ruling_names_the_executed_evidence():
"""The falsifier's second disjunct: the ruling is RECORDED — the
single-build site in docker_executor.py carries the decision and the
TWO executed findings that killed the host-proxy alternative
(unreachable-from-RUN-steps; advisory-not-structural) plus the ONE
design judgment (the netns alternative disproportionate), so a future
reader finds the DECISION and its evidence honestly labelled, not a
bare 'accepted residual'."""
# the pinned executor is load-bearing: assert its #247 half exists
# (the runtime sandbox + the env scrub — the ruling's premises)
import utilities.dynamic_tester.docker_executor as de
assert callable(de._scrubbed_subprocess_env)
src = (_CORE_ROOT / "utilities" / "dynamic_tester"
/ "docker_executor.py").read_text()
assert "THE RECORDED RULING (the falsifier's second disjunct)" in src
assert "ACCEPTED BY DESIGN" in src
# the two executed findings + the one design judgment are named
# (honestly labelled — not a bare assertion)
assert "TWO EXECUTED findings" in src and "ONE" in src
assert "DESIGN JUDGMENT (not executed)" in src
assert "UNREACHABLE from the RUN steps" in src
assert "ADVISORY, not" in src
assert "disproportionate" in src
# the runtime-boundary clause (the ruling's load-bearing premise)
assert "the RUNTIME sandbox is" in src


def test_no_proxy_routing_was_left_behind():
"""The killed alternative left no residue: no proxy build-args anywhere
in the executor (the wiring is fully reverted, the module is gone)."""
src = (_CORE_ROOT / "utilities" / "dynamic_tester"
/ "docker_executor.py").read_text()
assert "build_arg" not in src.replace("--build-arg", "build_arg__X")
assert "HTTP_PROXY" not in src
assert not (_CORE_ROOT / "utilities" / "dynamic_tester"
/ "build_egress_proxy.py").exists()
50 changes: 41 additions & 9 deletions libs/openant-core/utilities/dynamic_tester/docker_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,12 @@ def _sanitize_compose(content: str) -> str:
Fails CLOSED (returns a refusing comment-only compose) on unparseable YAML, a build
context that escapes the work dir, or a service with neither a safe build nor an image.

RESIDUAL (documented, NOT closed here): build-time `RUN` egress — `docker build`
still runs the generated Dockerfile's RUN steps with network (an egress-allowlisting
build proxy is deferred, an accepted residual). This change is RUNTIME sandbox
hardening that makes the code enforce its stated "no host volume mounts or privileged
mode" contract.
#710: the build-time `RUN` egress residual formerly documented here is
now the RECORDED RULING (accepted by design — the executed evidence that
killed the host-proxy alternative is named at the single-container build
site; the drift pins hold the accepted state). This change is RUNTIME
sandbox hardening that makes the code enforce its stated "no host volume
mounts or privileged mode" contract.
"""
try:
import yaml
Expand Down Expand Up @@ -466,7 +467,36 @@ def _run_single(
"""
result = DockerExecutionResult()

# Build
# #710 — THE RECORDED RULING (the falsifier's second disjunct): build
# egress is ACCEPTED BY DESIGN. The host-proxy alternative was tried and
# killed by TWO EXECUTED findings (the review round's probes) plus ONE
# DESIGN JUDGMENT:
# (1) EXECUTED: a host-side proxy pointed at via Docker's predefined
# proxy build-args is UNREACHABLE from the RUN steps — the
# build-args reach the RUN env, but 127.0.0.1 there is the BUILD
# CONTAINER's own loopback, not the host's — measured: every
# dependency build fails with ConnectionRefused. The routing that
# would reach a host-side proxy (--network host on the build)
# puts the RUN steps in the HOST's netns — the build-time
# posture this module exists to prevent — and binding the proxy
# on a non-loopback interface to make it bridge-reachable exposes
# it to every container on the host's bridge networks;
# (2) EXECUTED: even with routing, the proxy-env control is
# ADVISORY, not structural — the Dockerfile is LLM-authored from
# untrusted findings, and `ENV http_proxy=` (one line) or a raw
# socket egresses freely (both executed) — a cooperating-client
# control cannot close a channel the content author controls;
# (3) DESIGN JUDGMENT (not executed): the real structural control (a
# build network-namespace with a proxy sidecar, or --network none
# with vendored deps) either breaks every dependency build
# (--network none) or is a disproportionate re-architecture for
# an opt-in, defense-in-depth surface.
# The accepted posture (unchanged from #247): the RUNTIME sandbox is
# the hard boundary (--network none below), the env allowlist caps what
# the build subprocess SEES (no host secret can be interpolated into
# the build context), and the build context is minimal (generated files
# + one pre-staged source). The drift pins in the test suite hold this
# exact state so it cannot drift silently.
stdout, stderr, code, timed_out = _run_command(
["docker", "build", "-t", image_tag, "."],
timeout=build_timeout,
Expand All @@ -483,8 +513,9 @@ def _run_single(
# stronger and simpler than an --internal network: an executing test derived from
# untrusted findings cannot exfiltrate at runtime, and in-container client->server
# tests still work over loopback. (No `docker network create` needed for this path.)
# RESIDUAL: build-time RUN egress is still open — see _sanitize_compose. Hardening,
# not a vuln fix. The multi-service path uses an internal compose network instead.
# #710: build-time RUN egress is the RECORDED RULING above (accepted
# by design, the executed evidence named). Hardening, not a vuln fix.
# The multi-service path uses an internal compose network instead.
#
# #732: the container is NAMED. subprocess's timeout (inside _run_command)
# kills the `docker run` CLIENT only, and `--rm` is daemon-side (it fires
Expand Down Expand Up @@ -563,7 +594,8 @@ def _run_compose(

compose_base = ["docker", "compose", "-p", project_name]

# Build all services
# Build all services — #710: the compose twin of the recorded ruling
# above (build egress accepted by design; the drift pins hold it)
stdout, stderr, code, timed_out = _run_command(
compose_base + ["build"],
timeout=build_timeout,
Expand Down
Loading