feat(docker): migrate socket proxy to wollomatic, pin allowlist to GET /images/json - #89
Merged
Conversation
…T /images/json
Replaces the tecnativa/docker-socket-proxy sidecar with
wollomatic/socket-proxy:1.12.3 (digest-pinned) on the docker-env profile — the
only container in the stack that mounts /var/run/docker.sock.
The point of the migration is the allowlist. The backend makes exactly one
request of this proxy, GET /images/json. tecnativa's coarse env-var toggles
permitted far more than that: IMAGES=1 and CONTAINERS=1 are prefix rules, so
between them they opened the whole /images and /containers GET surface —
including /containers/{id}/json (every container's environment variables and
command line), /containers/{id}/archive (arbitrary file read out of any
container), /containers/{id}/export, and /images/{name}/get — plus /info and the
image's default /events, /_ping and /version, to any client on the network.
The new config is a single anchored regex, -allowGET=(/v1\.[0-9]{1,2})?/images/json.
Omitting the other five method flags is what makes the proxy read-only (any
method without an entry gets 405), replacing POST=0, and -allowfrom=scrye limits
connections to the app container.
Hardening: the image is from-scratch and runs as uid 65534 (tecnativa ran as
root on haproxy:alpine), so the /run tmpfs added for HAProxy's pid/stats path
under read_only is retired — the sidecar now needs no writable path at all.
Memory cap 128M -> 64M. The healthcheck moves off the proxied API onto the
bundled healthcheck binary and its separate listener, which is why /info no
longer has to be exposed just to have a liveness probe. Adds a socket watchdog
so a Docker engine update can't leave the proxy wedged.
Operators enabling docker-env must now set DOCKER_GID to the host's docker group
id: the proxy runs unprivileged and needs that group to read the socket. No
application, API, schema, or client change — SCRYE_DOCKER_PROXY_URL and port
2375 are unchanged.
Adds seven regression tests that keep the allowlist tied to the client: the
-allowGET pattern is compiled the way upstream compiles it and checked against
the path list_images() is observed to request, and against every previously
permitted sensitive path.
Closes #63. See docs/ARCHIVE.md § Deviations (2026-07-24) for the full
before/after allowlist mapping and how this was verified without a Docker daemon.
tyler-rich
added a commit
that referenced
this pull request
Jul 26, 2026
…and Compose comment (#97) The non-200 detail in docker_proxy.list_images() advised checking that the proxy is "read-only with IMAGES=1" — a tecnativa env var that has not existed since the wollomatic migration (#89), printed in exactly the 403 case the README teaches operators to diagnose. It now names the real causes: a non-allowlisted path (-allowGET permits only /images/json and /v1.NN/images/json), a non-GET method, or the -allowfrom source check rejecting a non-scrye client, with the note that the proxy's log distinguishes them, and a pointer to the README rather than a restatement of it. The docker-compose.yml DOCKER_GID comment called 999 "the Debian/Ubuntu default"; it now matches the README's corrected wording (a placeholder, not a safe default; 989 on the host this was verified against) and describes the wrong-GID failure as the crash loop it actually is. The stat -c '%g' derivation remains the instruction and the ${DOCKER_GID:-999} value is unchanged. Adds a regression test driving the 403 path through httpx.MockTransport. No existing test asserted on the old string. See docs/ARCHIVE.md § Deviations for the dated entry retiring both follow-ups.
tyler-rich
added a commit
that referenced
this pull request
Jul 26, 2026
…work) (#101) Read-only audit producing findings and proposals only; no code, config, or existing document is changed. - Part 1: true status of all five open issues against the code on dev — #63 and #83 are fully resolved (#89, #87) and only open because closing keywords never fire on a dev-targeted PR; #98 and #52 are live waiver trackers; #75 needs a release. - Part 2: full recursive docs/ inventory with a KEEP/ARCHIVE/DELETE disposition per file, the inbound-reference blast radius for each proposed removal, and a proposed docs/history/ structure. - Part 3: consolidated list of deferred work still outstanding in ARCHIVE.md section 14, separating items with a live tracking reference from the three recorded in prose only. - Part 4: ARCHIVE.md navigability (including 14 deviation entries misfiled under the Build performance heading), ROADMAP items now done, the Unreleased changelog backlog, 33 prunable merged branches, and proposed CLAUDE.md wording for two operational lessons.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63. Completes the half of M23/SC-7 that was deliberately deferred out of the 2026-07-13 supply-chain batch.
Summary
The
docker-envsidecar — the only container in the stack that mounts/var/run/docker.sock— moves fromtecnativa/docker-socket-proxy:v0.4.2(HAProxy on Alpine,USER root, coarse env-var toggles) towollomatic/socket-proxy:1.12.3(from-scratch Go binary,USER 65534, per-method regex allowlisting), pinned by digest.The point of the migration is the allowlist. Scrye makes exactly one request of this proxy —
GET /images/json(backend/app/core/docker_proxy.py;list_images()is the only caller, and thecore/egress.pyguard in front of it is DNS-only). The old config permitted far more than that, because tecnativa's rules are prefix matches:GET /containers…— incl./containers/{id}/json(every container's env vars and command line),/logs,/top,/stats,/export(container filesystem tarball),/archive(arbitrary file read out of any container)CONTAINERS=1GET /images…— incl./images/{n}/json,/history,/search,/images/{n}/get(image tarball export)IMAGES=1GET /infoINFO=1GET /events,/_ping,/versionGET /images/jsonIMAGES=1GETmethodPOST=0scrye_netscryecontainerThe whole allowlist is now one flag:
-allowGET=(/v1\.[0-9]{1,2})?/images/json. Upstream anchors patterns itself (regexp.Compile("^"+regex+"$")) and matches against the URL path only, and answers 405 for any method with no-allow*entry — so omitting the other five method flags is what makes the proxy read-only, replacingPOST=0.Hardening deltas
/runtmpfs retired. INF-5 existed only because HAProxy needed a writable pid/stats path underread_only: true. A static Go binary needs none, so the sidecar is now read-only with no writable path at all.haproxy:alpine.wget …/info) onto the bundled/healthcheckbinary and its separate127.0.0.1:55555listener — which is why/infono longer has to be exposed just to have a liveness probe.-watchdoginterval=3600 -stoponwatchdogso a socket broken by a Docker engine update is recovered byrestart: unless-stopped.cap_drop: ALL,no-new-privileges, digest pin, no host port, capped logging, CPU limit unchanged.tecnativa ran as root in-container and could read the socket regardless of ownership. wollomatic ships
USER 65534:65534, and/var/run/docker.sockisroot:dockermode 0660 — so the container's GID must be the host's docker group. The service is nowuser: "65534:${DOCKER_GID:-999}"and operators enablingdocker-envmust set:The
999default is a Debian/Ubuntu convention, not a guarantee; a wrong GID surfaces as a socket permission error at proxy start. This is a deployment prerequisite only — no application behavior, API contract, schema, or client code changed, andSCRYE_DOCKER_PROXY_URL/ port 2375 are unchanged.Verification
No Docker daemon was reachable in the environment where this was prepared, and Docker Hub's blob CDN (
production.cloudfront.docker.com) is egress-blocked, so the published image could not be pulled or booted. The request-handling path was therefore exercised directly instead: upstream'shandlehttprequest.goandbindmount.goat tag1.12.3were compiled verbatim (the module is stdlib-only) against a minimalinternal/configcarrying upstream'sAllowListtypes andcompileRegexpunmodified, put in front of a stub Docker API on a real unix socket, and configured with the-allowGETpattern read out ofdocker-compose.yml. Against that harness:docker_proxy.list_images()enumerated images successfully (untagged dropped as expected);/v1.NNform and a query string were accepted;docker compose --profile docker-env configrenders the service with the regex andDOCKER_GIDinterpolation intact. The egress guard's loopback refusal was also confirmed intact along the way.Still to do on a Docker-capable host: one
docker compose --profile docker-env upto confirm the published image boots under the full hardened option set and thatDOCKER_GIDis right for that host.Tests
backend/tests/test_compose_hardening.pygains seven regression tests that keep the allowlist tied to the client rather than to a hardcoded string: the-allowGETpattern is compiled the way upstream compiles it and checked against the pathlist_images()is observed to request, checked to reject every previously-allowed sensitive path, and checked for method flags, source restriction, digest pin, unprivileged uid, and the absence of a re-introduced writable/run. Each was verified to fail against a deliberately widened Compose config before being committed.Full backend suite (561 passed, 3 skipped),
ruff,black, ESLint, Prettier, and the frontend Vitest suite (54 passed) are green locally.Docs
README § Optional sidecars, § Configuration (the
DOCKER_GIDnote), and § Security model updated;CHANGELOG.mdUnreleased entry with the action-required note;docs/reviews/STATUS.mdmoves M23/SC-7 from § Deferred to § Resolved.See
docs/ARCHIVE.md§ Deviations (2026-07-24) for the full before/after allowlist mapping.