A validating Docker API proxy that enforces per-service policies through a middleware pipeline. Designed for granting safe, audited Docker access to external contributors, CI/CD pipelines, and automated tooling — without giving them direct Docker daemon access.
Key features:
- Policy-driven: Per-service YAML policies control images, volumes, flags, and env vars
- Middleware pipeline: 6 validation gates + 1 config mutator chain
- Default-deny router: Only explicitly allowed endpoints pass through
- Formally verified: Quint specification with 9 security invariants
- Audit logging: JSON-structured logs for all requests and decisions
- Three implementations: Go, Rust, TypeScript — equal peer languages
- Minimal dependencies: Zero external deps for Go, crate-based for Rust, npm for TypeScript
docker-socket-policy builds and runs on amd64 (x86-64) and arm64 (AArch64) Linux architectures:
-
Docker Images: Multi-arch manifest indexes automatically select the correct architecture when pulling. No platform flag needed:
docker pull ghcr.io/chainsafe/docker-socket-policy-go:latest # Pulls amd64 on x86-64, arm64 on ARM machines -
Prebuilt Binaries: Both amd64 and arm64 variants are published with each release.
See docs/reproducible-builds.md for verification and per-architecture build instructions.
Signed, SBOM-attested images are published to GHCR for all three implementations:
docker pull ghcr.io/chainsafe/docker-socket-policy-go:latest
docker pull ghcr.io/chainsafe/docker-socket-policy-rs:latest
docker pull ghcr.io/chainsafe/docker-socket-policy-ts:latest
# Pin to a specific release instead of latest
docker pull ghcr.io/chainsafe/docker-socket-policy-go:v0.2.8Every image is Cosign-signed and ships with SPDX + CycloneDX SBOMs attached to the corresponding release. See docs/reproducible-builds.md to verify signatures and reproduce a build byte-for-byte.
Each release attaches binaries for amd64 and arm64 architectures, plus SBOMs for each:
Go (statically linked ELF binary):
# amd64
curl -LO https://github.com/ChainSafe/docker-socket-policy/releases/latest/download/docker-socket-policy-go-linux-amd64
chmod +x docker-socket-policy-go-linux-amd64
# arm64
curl -LO https://github.com/ChainSafe/docker-socket-policy/releases/latest/download/docker-socket-policy-go-linux-arm64
chmod +x docker-socket-policy-go-linux-arm64Rust (statically linked ELF binary with musl):
# amd64
curl -LO https://github.com/ChainSafe/docker-socket-policy/releases/latest/download/docker-socket-policy-rs-linux-amd64
chmod +x docker-socket-policy-rs-linux-amd64
# arm64
curl -LO https://github.com/ChainSafe/docker-socket-policy/releases/latest/download/docker-socket-policy-rs-linux-arm64
chmod +x docker-socket-policy-rs-linux-arm64TypeScript (Node 22+ required; archive includes dist/, node_modules/, and package files):
# Extract and run (platform-independent Node archive)
curl -LO https://github.com/ChainSafe/docker-socket-policy/releases/latest/download/docker-socket-policy-ts-<version>.tar.gz
tar xzf docker-socket-policy-ts-<version>.tar.gz
node dist/index.jsTo build any implementation from source instead, see Build All below.
Docker CLI → docker-socket-policy (middleware chain) → Docker daemon
│
├── Mutators: modify request (force container config)
├── Gates: validate request (image refs, volumes, flags)
└── Proxy: forward allowed requests to daemon
All three implementations expose the same API surface, share the same Quint spec and YAML policies, and pass the same integration tests.
| Language | Directory | Tests | Stack |
|---|---|---|---|
| Go | go/ | 74 unit + 26 integration | stdlib net/http + yaml.v3 |
| Rust | rs/ | 112 unit | tokio, hyper, serde, clap |
| TypeScript | ts/ | 108 unit | Node 22 ESM, built-in http |
# Build all three language implementations
make build-all
# Run all tests (unit + integration)
make test-all
# Lint all three
make lint-all
# Full validation: typecheck + verify + vet + test (Go)
make validatesudo groupadd --system docker-socket-policy
sudo usermod -aG docker-socket-policy alice
sudo ./docker-socket-policy \
--listen-socket=/var/run/docker-socket-policy.sock \
--docker-host=/var/run/docker.sock \
--config-dir=./config \
--log-file=/tmp/docker-socket-policy.logLike docker.sock, the socket is always created 0660 and owned by the
docker-socket-policy group, so members of that group can connect and nobody
else can. Grant or revoke access with group membership alone. If the group
does not exist, the proxy warns and uses its own group instead. See the
Unix socket security boundary note under CLI flags for the
details.
Create a YAML policy in the config directory:
# config/beacon.yaml
service_name: beacon
allowed_image_prefixes:
- chainsafe/lodestar
container_config:
network_mode: host
restart_policy: unless-stopped
security_options:
- no-new-privileges:true
user: '2001:2001'
volumes:
- host_path: /home/beacon
container_path: /data
read_write: true
env_file: /home/beacon/beacon.env
allowed_cli_flags:
- --rcConfig
- --logLevel
denied_flags:
- --privileged
- --volume
- --cap-addexport DOCKER_HOST=unix:///var/run/docker-socket-policy.sock
# These work (validated against policy)
docker pull chainsafe/lodestar:next
docker run --name beacon chainsafe/lodestar:next --rcConfig /data/config.yml
docker ps
docker logs -f beacon
docker stop beacon
docker rm beacon
# These are denied
docker exec -it beacon bash # denied: exec not allowed
docker run --privileged alpine sh # denied: privileged containers blocked
docker pull attacker/malware:latest # denied: image not in allowlist| Middleware | Type | What it checks |
|---|---|---|
| ContainerConfigMutator | Mutator | Forces network_mode, user, security_options, restart_policy from policy |
| ExecGate | Gate | Denies POST /containers/*/exec and POST /exec/*/start |
| ReadonlyGate | Gate | Denies all POST, PUT, DELETE, PATCH (optional --readonly flag) |
| RegistryGate | Gate | Validates image ref against allowed_image_prefixes |
| MountSourceGate | Gate | Validates volume binds against whitelist |
| EnvFileGate | Gate | Strips Env field from create body; env must come from locked env_file |
| CmdGate | Gate | Validates each CLI flag in Cmd array against allowlist + denylist |
| HTTP Method | Path | Action |
|---|---|---|
| POST | /containers/create |
Validated by middleware chain |
| POST | /containers/{name}/start|stop|restart|kill|wait|pause|unpause |
Allowed on known containers |
| DELETE | /containers/{name} |
Allowed on known containers |
| POST | /containers/{name}/exec |
DENIED |
| POST | /containers/{name}/rename|update |
DENIED |
| POST | /images/create |
Validated by registry gate |
| POST | /auth |
DENIED |
| POST | /build |
DENIED |
| POST | /commit |
DENIED |
| GET/HEAD | Any | Allowed (read-only) |
| Other | Other | DENIED |
| Flag | Default | Description |
|---|---|---|
--listen-socket |
/var/run/docker-socket-policy.sock |
Unix socket path to listen on (absolute filesystem path only) |
--docker-host |
/var/run/docker.sock |
Docker daemon socket path (Unix socket only) |
--config-dir |
/etc/docker-socket-policy/services |
Policy config directory |
--log-file |
/var/log/docker-socket-policy.log |
Audit log path |
--readonly |
false |
Enable read-only mode |
--listen-socket-group |
docker-socket-policy |
Group owning the socket (default docker-socket-policy; "" = the proxy's own group) |
Unix socket security boundary: the proxy listens on a Unix socket only, in all three implementations. Access control is the file permissions and Unix group on that socket — a caller is authorised because it can
connect(2)to it. A TCP listener carries no peer identity, so anything able to reach the port would be implicitly trusted; there is no--listen-tcp.The same rule applies outbound: all three implementations connect to the Docker daemon over Unix sockets exclusively and reject
tcp://andhttp://schemes for--docker-host.The socket works like
docker.sock. It is always0660, whatever the ambient umask, and there is no flag to change the mode.connect(2)on a Unix socket needs write permission, so any other mode either locks the group out or opens the socket to every local uid. The group is chosen the way dockerd chooses thedockergroup:
--listen-socket-groupGroup exists Socket group not passed yes docker-socket-policynot passed no the proxy's own group, with the warning group docker-socket-policy not found, using the proxy's own group <gid>=nameyes that group =nameno none: startup fails, exit 2 =gid— that gid, used as-is (no lookup). Digits only, 0-4294967294; a larger value fails with exit 2=""— the proxy's own group, no warning To grant access, create the group once and add callers to it:
groupadd --system docker-socket-policy usermod -aG docker-socket-policy aliceFor a container caller, give its user that group (
group_add:) and bind-mount the socket in. To revoke access, remove the group membership. If the proxy cannot reach the daemon socket because of its own group permissions, requests surface as403.To give the socket a group other than its own, a non-root proxy must be a member of that group (
SupplementaryGroups=/group_add:). Otherwise startup fails with exit 1 and the messagecannot give <path> to group <gid>: the proxy's user must be a member of it. The proxy does not fall back to another group, because a group that exists was chosen on purpose.The socket is bound at
0600, then given its group, and only then widened to0660. It is never reachable by the wrong group, even for a moment.One instance per socket path. At startup the proxy handles what it finds at the path as follows:
- A stale socket (
connect(2)is refused) is removed and replaced.- A live socket is refused with
<path> is in use by another process, exit 1. The proxy leaves that socket untouched.- A socket that fails
connect(2)in any other way (for exampleEACCES) is refused, and the proxy does not remove it.- Anything that is not a socket is refused, and the proxy does not remove it.
The Go and Rust implementations also take an exclusive
flockon<path>.lock(mode0600) before they touch the socket. They hold it for the life of the process. A second instance fails with<path> is in use by another instance (lock <path>.lock held), exit 1. The kernel releases the lock on any exit, includingSIGKILL, so a crash never leaves a stale lock. The.lockfile stays next to the socket after shutdown. Do not delete it, especially while the proxy is running: deleting it lets a second instance take the socket. A.lockleft by another uid (for example an earlier run as root on a persistent volume) makes startup fail withopening lock …and a permission-denied error, exit 1; delete that lock file only when no instance is running.TypeScript exception: Node has no
flock, so the TypeScript implementation takes no lock and relies on the live-socket check alone. If two TypeScript instances start on the same path within the same few milliseconds, both can see the old socket as stale. The second one then removes the first one's new socket and binds its own, and the first keeps running but nothing can reach it. An instance that starts after another is already listening is still refused. Node also unlinks its socket path on close, so stopping the orphaned instance (the obvious remedy) deletes the surviving instance's live socket; restart the survivor afterwards. The same holds when a Go or Rust instance is the orphan in a race with TypeScript. This gap is tracked in #46.What the socket does not give you is per-service isolation. The proxy performs no caller authentication: it selects a policy from the
Imagefield of the request body, not from the identity of the connection. Every caller of one socket therefore shares one trust domain, and can act under any policy in that proxy's--config-dirby naming that policy's image. Treat the socket as a boundary around the whole proxy, not around a single service. To isolate services from one another, run a proxy instance per service, each with its own socket and a--config-dircontaining only that service's policy.
The proxy always creates its own socket. systemd socket activation
(fd://) is not supported, so the proxy never listens on a socket it did not
create. Run it as a plain service:
sudo groupadd --system docker-socket-policy # skip if it already exists
sudo useradd --system --no-create-home -g docker-socket-policy docker-socket-policy
sudo usermod -aG docker-socket-policy alice # grant a caller accessdocker-socket-policy.service:
[Service]
ExecStart=/usr/local/bin/docker-socket-policy \
--listen-socket=/run/docker-socket-policy/docker-socket-policy.sock \
--docker-host=/var/run/docker.sock \
--config-dir=/etc/docker-socket-policy/services \
--log-file=/var/log/docker-socket-policy/audit.log
User=docker-socket-policy
Group=docker-socket-policy
# Reach the Docker daemon socket.
SupplementaryGroups=docker
# A non-root proxy cannot create files in /var/run; systemd creates this
# directory for it, owned by User=/Group=.
RuntimeDirectory=docker-socket-policy
RuntimeDirectoryMode=0755
LogsDirectory=docker-socket-policy
Restart=on-failure
NoNewPrivileges=trueGroup=docker-socket-policy makes that group the proxy's own group, so it
can give the socket to it. docker-socket-policy.sock.lock is created next
to the socket in the same directory. Callers then use
DOCKER_HOST=unix:///run/docker-socket-policy/docker-socket-policy.sock.
To use a different group, pass --listen-socket-group=<name> and add that
group to SupplementaryGroups=. Otherwise startup fails with the
"must be a member of it" error.
This project includes a Quint formal specification that models the security invariants as a state machine. Random-simulation verification runs 10,000 sampled traces of up to 100 steps each, checking all 9 invariants on every state transition. A second module, spec/listener.qnt, models listening-socket startup (group selection, existing-path checks, the single-instance lock) with 6 more invariants.
The CI pipeline runs verification on every push and PR. A violation blocks the build.
make typecheck # Quint type-check (proves type safety)
make verify # Random-simulation verification (default evaluator)
make verify BACKEND=rust # Same, using the faster Rust backend
make test-spec # Quint `run` tests for listener.qnt (one per design-table row)
make validate # All checks: typecheck + verify + go vet + go testSee spec/README.md for details on the invariants, the middleware gate each maps to, and the attack scenarios each prevents.
Apache 2.0