Skip to content

docker-socket-policy

CI Release Go Version License

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

Installation

Supported Architectures

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.

Docker Images

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.8

Every 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.

Prebuilt Binaries

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-arm64

Rust (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-arm64

TypeScript (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.js

To build any implementation from source instead, see Build All below.

Architecture

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

Language Implementations

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

# 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 validate

Run

sudo 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.log

Like 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.

Configure a Service

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-add

Use the Proxy

export 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 Pipeline

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

Endpoint Access

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

Configuration

CLI Flags

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:// and http:// schemes for --docker-host.

The socket works like docker.sock. It is always 0660, 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 the docker group:

--listen-socket-group Group exists Socket group
not passed yes docker-socket-policy
not passed no the proxy's own group, with the warning group docker-socket-policy not found, using the proxy's own group <gid>
=name yes that group
=name no 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 alice

For 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 as 403.

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 message cannot 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 to 0660. 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 example EACCES) 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 flock on <path>.lock (mode 0600) 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, including SIGKILL, so a crash never leaves a stale lock. The .lock file 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 .lock left by another uid (for example an earlier run as root on a persistent volume) makes startup fail with opening 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 Image field 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-dir by 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-dir containing only that service's policy.

systemd Service

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 access

docker-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=true

Group=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.

Formal Verification

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 test

See spec/README.md for details on the invariants, the middleware gate each maps to, and the attack scenarios each prevents.

License

Apache 2.0

About

Per-service policy enforcement proxy for the Docker API

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages