From 3979b132c1fd466ba2aaf22f6359d99224cd1d2d Mon Sep 17 00:00:00 2001 From: Vladislav Perevezentsev Date: Thu, 29 Oct 2026 06:50:20 -0700 Subject: [PATCH 1/3] fix(metrics): the docker backend of example execution drops every capability The docker backend started the snippet's container with docker's default capabilities and without no-new-privileges. The default image, python:3.11-slim, runs as root, so the snippet kept CAP_SETUID, CAP_CHOWN and the rest, and a setuid binary in the image (su) still worked. The other docker call sites set both: repos/container.py and the task container's TASK_CONTAINER_HARDENING. The ContainerPolicy here now sets no_new_privileges and cap_drop_all. Without CAP_DAC_OVERRIDE that root cannot enter the host user's 0700 temp directory, so the directory is chmod 0755 and the snippet 0644; otherwise every snippet fails with "Permission denied". Refs #229 Signed-off-by: Vladislav Perevezentsev --- ...-perevezentsev_example-docker-hardening.md | 14 ++++++++++++ dokimi/docs/example-execution.md | 7 ++++-- dokimi/dokimi/metrics/example_runner.py | 18 ++++++++++----- .../test_container_argv_characterization.py | 15 ++++++++----- dokimi/tests/test_example_runner.py | 22 +++++++++++++++++++ 5 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 dokimi/changelog.d/20261029_120000_vlad-perevezentsev_example-docker-hardening.md diff --git a/dokimi/changelog.d/20261029_120000_vlad-perevezentsev_example-docker-hardening.md b/dokimi/changelog.d/20261029_120000_vlad-perevezentsev_example-docker-hardening.md new file mode 100644 index 000000000..bc31d982b --- /dev/null +++ b/dokimi/changelog.d/20261029_120000_vlad-perevezentsev_example-docker-hardening.md @@ -0,0 +1,14 @@ + + +### Security + +- **The `docker` backend of example execution drops every capability** — a + fenced example ran as the image's root with docker's default capabilities, + and a setuid binary in the image (`su` in `python:3.11-slim`) still worked. + The container now starts with `--cap-drop ALL` and `--security-opt + no-new-privileges`, as `repo run` containers do. The snippet's directory is + mounted world-readable (0755, the snippet 0644), because a root without + capabilities cannot enter the host user's private temp directory. diff --git a/dokimi/docs/example-execution.md b/dokimi/docs/example-execution.md index 74626b4bd..4922e8ce2 100644 --- a/dokimi/docs/example-execution.md +++ b/dokimi/docs/example-execution.md @@ -36,7 +36,7 @@ metrics: | --- | --- | --- | | `none` (default) | Nothing runs; examples reported as `skipped` | Untrusted corpora, or you only want extraction | | `subprocess` | Host subprocess with per-process CPU/memory caps, minimal env, temp cwd; its process group is killed when it exits or times out. **No network isolation and no process-count cap.** Requires `allow_host: true`. | Trusted, self-owned documentation | -| `docker` | Container with `--network none`, read-only mounts, CPU/memory/pid limits, per-example timeout | Untrusted documentation | +| `docker` | Container with `--network none`, no capabilities (`--cap-drop ALL`, `no-new-privileges`), read-only mounts, CPU/memory/pid limits, per-example timeout | Untrusted documentation | ### Why `none` is the default @@ -65,4 +65,7 @@ DOC_BENCH_EXAMPLE_BACKEND=none dokimi run --spec benchmarks/spec.v1.yaml counts all of the user's processes, not the snippet's). Only use it on documentation you trust. - For anything else, use `docker`, which adds `--network none` and a read-only - filesystem. See also [SECURITY.md](../SECURITY.md). + filesystem, and drops every capability with `no-new-privileges`, so the + image's root cannot gain any back through a setuid binary. The snippet's + temp directory is mounted world-readable for that capability-less root. + See also [SECURITY.md](../SECURITY.md). diff --git a/dokimi/dokimi/metrics/example_runner.py b/dokimi/dokimi/metrics/example_runner.py index 77cbbc647..aa514de88 100644 --- a/dokimi/dokimi/metrics/example_runner.py +++ b/dokimi/dokimi/metrics/example_runner.py @@ -14,8 +14,9 @@ ``allow_host=True`` (an explicit opt-in). Use only for **trusted, self-owned** documentation. Network is *not* isolated in this mode, and the number of processes a snippet spawns is not capped. -* ``docker`` — run inside a locked-down container (``--network none``, - read-only mounts, CPU/memory/time limits). Preferred for untrusted docs. +* ``docker`` — run inside a locked-down container (``--network none``, no + capabilities, ``no-new-privileges``, read-only mounts, CPU/memory/process/time + limits). Preferred for untrusted docs. See ``docs/example-execution.md``. """ @@ -187,15 +188,22 @@ def _run_subprocess(lang: str, code: str, policy: ExecutionPolicy, def _run_docker(lang: str, code: str, policy: ExecutionPolicy, runner: process.Runner) -> tuple[bool, str | None]: - """Run code inside a locked-down container (no network, read-only, limited).""" + """Run code inside a locked-down container (no network, no capabilities, read-only, limited).""" suffix, argv_prefix = EXECUTORS[lang] with tempfile.TemporaryDirectory() as tmp: script = Path(tmp) / f"snippet{suffix}" script.write_text(code, encoding="utf-8") - flags = ContainerPolicy(network="none", cpus=policy.cpus, memory_mb=policy.mem_mb, pids_limit=64, - read_only=True) + # The image's user is often root (it is in the default image). With every capability + # dropped and no-new-privileges it gains none back, through a setuid binary or otherwise. + # Without CAP_DAC_OVERRIDE that root cannot enter a 0700 directory it does not own, so + # the mount is world-readable: the snippet is the document's own text. + os.chmod(tmp, 0o755) # nosec B103 - read and entered as "other", not writable + script.chmod(0o644) + flags = ContainerPolicy(network="none", no_new_privileges=True, cap_drop_all=True, cpus=policy.cpus, + memory_mb=policy.mem_mb, pids_limit=64, read_only=True) cmd = [ "docker", "run", "--rm", + *flags.hardening_argv(), *flags.network_argv(), *flags.resource_argv(), *flags.pids_argv(), diff --git a/dokimi/tests/test_container_argv_characterization.py b/dokimi/tests/test_container_argv_characterization.py index 01c5069e9..505d64903 100644 --- a/dokimi/tests/test_container_argv_characterization.py +++ b/dokimi/tests/test_container_argv_characterization.py @@ -120,6 +120,7 @@ def test_example_docker_argv_default_policy_pins_every_flag_in_order(): argv, timeout = _docker_cmd(ExecutionPolicy(backend="docker", timeout=7)) assert argv == [ "docker", "run", "--rm", + "--security-opt", "no-new-privileges", "--cap-drop", "ALL", "--network", "none", "--cpus", "1.0", "--memory", "512m", @@ -138,6 +139,7 @@ def test_example_docker_argv_resource_values_come_from_the_policy(): argv, _ = _docker_cmd(policy, lang="bash") assert argv == [ "docker", "run", "--rm", + "--security-opt", "no-new-privileges", "--cap-drop", "ALL", "--network", "none", "--cpus", "2.5", "--memory", "1024m", @@ -156,9 +158,10 @@ def test_example_docker_argv_never_sets_network_from_the_policy(): assert argv[argv.index("--network") + 1] == "none" -def test_example_docker_argv_has_no_init_no_cap_drop_no_new_privileges(): - # Recorded gap: this site carries none of the repos/container.py hardening today. - # Adding it is an argv change and is a separate follow-up, so this test pins the absence. - argv, _ = _docker_cmd(ExecutionPolicy(backend="docker", timeout=5)) - for flag in ("--init", "--cap-drop", "no-new-privileges"): - assert flag not in argv +@pytest.mark.parametrize("lang", sorted(EXECUTORS)) +def test_example_docker_argv_drops_every_capability_and_new_privileges(lang): + # The snippet runs as the image's user, root in the default image: it keeps no capability + # and a setuid binary gives it none, as in repos/container.py. + argv, _ = _docker_cmd(ExecutionPolicy(backend="docker", timeout=5), lang=lang) + assert argv[argv.index("--cap-drop") + 1] == "ALL" + assert argv[argv.index("--security-opt") + 1] == "no-new-privileges" diff --git a/dokimi/tests/test_example_runner.py b/dokimi/tests/test_example_runner.py index 336577398..2c7ca9239 100644 --- a/dokimi/tests/test_example_runner.py +++ b/dokimi/tests/test_example_runner.py @@ -5,7 +5,9 @@ from __future__ import annotations +import os import tempfile +from pathlib import Path import pytest @@ -209,6 +211,26 @@ def test_docker_backend_locks_the_container_down(): assert argv[argv.index("timeout") + 1] == "7" and call.timeout == 37 +def test_docker_backend_mount_is_readable_by_a_root_without_capabilities(): + # The container's root has no CAP_DAC_OVERRIDE, and the mount belongs to the host user: + # it can enter the directory and read the snippet only through the "other" permission bits. + seen = {} + + def _modes(call): + work = Path(next(a for a in call.argv if a.endswith(":/work:ro")).removesuffix(":/work:ro")) + seen["dir"] = work.stat().st_mode & 0o777 + seen["script"] = (work / "snippet.py").stat().st_mode & 0o777 + return reply() + + old = os.umask(0o077) + try: + res = run_example("python", "print(1)", ExecutionPolicy(backend="docker"), FakeRunner(_modes)) + finally: + os.umask(old) + assert res.status == "passed" + assert seen == {"dir": 0o755, "script": 0o644} + + def test_docker_backend_reports_a_timeout(): """A docker run killed at the deadline is a timeout, not exit 124 from the snippet.""" runner = FakeRunner(lambda call: reply(timed_out=True)) From 048925505009087d2fe9dbc9a1589f787190c73d Mon Sep 17 00:00:00 2001 From: Vladislav Perevezentsev Date: Thu, 29 Oct 2026 07:16:24 -0700 Subject: [PATCH 2/3] test(metrics): skip the mount-permission test on Windows os.chmod there toggles only the read-only bit, so the directory and the snippet never read 0o755 and 0o644. Signed-off-by: Vladislav Perevezentsev --- dokimi/tests/test_example_runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dokimi/tests/test_example_runner.py b/dokimi/tests/test_example_runner.py index 2c7ca9239..316b04abe 100644 --- a/dokimi/tests/test_example_runner.py +++ b/dokimi/tests/test_example_runner.py @@ -211,6 +211,7 @@ def test_docker_backend_locks_the_container_down(): assert argv[argv.index("timeout") + 1] == "7" and call.timeout == 37 +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits: Windows chmod sets only read-only") def test_docker_backend_mount_is_readable_by_a_root_without_capabilities(): # The container's root has no CAP_DAC_OVERRIDE, and the mount belongs to the host user: # it can enter the directory and read the snippet only through the "other" permission bits. From 903eb6442f9b871a3ed33da109166100d31a5ba0 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Fri, 30 Oct 2026 11:32:01 +0100 Subject: [PATCH 3/3] Shorten the example runner's mount comment to two lines Signed-off-by: Anton Volkov --- dokimi/dokimi/metrics/example_runner.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/dokimi/dokimi/metrics/example_runner.py b/dokimi/dokimi/metrics/example_runner.py index aa514de88..22490e039 100644 --- a/dokimi/dokimi/metrics/example_runner.py +++ b/dokimi/dokimi/metrics/example_runner.py @@ -193,10 +193,8 @@ def _run_docker(lang: str, code: str, policy: ExecutionPolicy, with tempfile.TemporaryDirectory() as tmp: script = Path(tmp) / f"snippet{suffix}" script.write_text(code, encoding="utf-8") - # The image's user is often root (it is in the default image). With every capability - # dropped and no-new-privileges it gains none back, through a setuid binary or otherwise. - # Without CAP_DAC_OVERRIDE that root cannot enter a 0700 directory it does not own, so - # the mount is world-readable: the snippet is the document's own text. + # Root without CAP_DAC_OVERRIDE cannot enter a 0700 dir it does not own (#229), + # so the :ro mount is world-readable; it holds only the document's own snippet. os.chmod(tmp, 0o755) # nosec B103 - read and entered as "other", not writable script.chmod(0o644) flags = ContainerPolicy(network="none", no_new_privileges=True, cap_drop_all=True, cpus=policy.cpus,