Skip to content
Merged
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
16 changes: 8 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ deploy/ — Docker Compose + integration tests
- `make fmt-all` / `make fmt-go` / `make fmt-rs` / `make fmt-ts` — apply gofmt / rustfmt / prettier (each `lint-*` target checks formatting)
- `make hooks` — enable `.githooks` (pre-commit format check, pre-push `make lint-all`)
- `make verify` — Quint spec simulation (request handling + listener)
- `make test-spec` — Quint `run` tests for `spec/listener.qnt` and `spec/router.qnt`
- `make test-spec` — Quint `run` tests for `spec/listener.qnt`, `spec/router.qnt` and the network endpoints module of `spec/docker_socket_policy.qnt`
- `make test-integration` — Docker Compose integration tests
- `make test-integration-sock` — listening-socket integration tests (`IMPL=rs|ts` for the others)

## Architecture (same across all 3 languages)
- `policy/` — Policy types + Manager (loads YAML from config dir)
- `middleware/` — Gate trait + Chain: RegistryGate, MountSourceGate, EnvFileGate, CmdGate, ContainerConfigMutator (`--readonly` and exec are denied by the router)
- `middleware/` — Gate trait + Chain: RegistryGate, MountSourceGate, EnvFileGate, CmdGate, NetworkEndpointsGate, ContainerConfigMutator (`--readonly` and exec are denied by the router)
- `proxy/` — Router (default-deny), Handler (orchestrates chain), Transport (forwards to Docker socket)
- `audit/` — JSON-structured logging

Expand All @@ -39,17 +39,17 @@ deploy/ — Docker Compose + integration tests
- Zero external deps where possible (Go: yaml.v3, Rust: tokio/hyper/serde/clap, TS: yaml)

## Test Coverage
- Go: 113 unit tests (main/listener: 24, policy: 10, middleware: 25, proxy: 50, audit: 4)
- Rust: 145 unit tests (main/listener: 23, policy: 15, middleware: 41, proxy: 49, handler: 11, audit: 4, transport: 2)
- TypeScript: 164 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 32, proxy: 36, policy: 10, handler: 15, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 63 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose)
- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`, `router_pre55`, `router_readonly`, whose `readonly*` tests run with `--match`, `router_pre76`, `router_pre78` and `router_pre70`)
- Go: 114 unit tests (main/listener: 24, policy: 10, middleware: 26, proxy: 50, audit: 4)
- Rust: 146 unit tests (main/listener: 23, policy: 15, middleware: 42, proxy: 49, handler: 11, audit: 4, transport: 2)
- TypeScript: 165 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 33, proxy: 36, policy: 10, handler: 15, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 72 tests via deploy/test.sh and 15 socket tests via deploy/test-sock.sh (docker-compose)
- Quint: `make test-spec` runs the `spec/listener.qnt` `run` tests (instances `listener_locked`, `listener_unlocked`) and the `spec/router.qnt` `run` tests (instances `router`, `router_pre48`, `router_pre53`, `router_pre55`, `router_readonly`, whose `readonly*` tests run with `--match`, `router_pre76`, `router_pre78` and `router_pre70`), and the `spec/docker_socket_policy.qnt` `run` tests (instances `network_endpoints`, `network_endpoints_pre87`)

## Test Conventions
- Go: stdlib `testing` package, `go test ./...`
- Rust: `#[cfg(test)]` inline modules, `cargo test`
- TypeScript: `node:test` framework, `npm run build && node --test dist/*.test.js`
- Integration: `make test-integration` (63 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose
- Integration: `make test-integration` (72 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose

## Contribution Workflow

Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ test-spec:
$(QUINT) test $(ROUTER_SPEC) --main=router_pre76
$(QUINT) test $(ROUTER_SPEC) --main=router_pre78
$(QUINT) test $(ROUTER_SPEC) --main=router_pre70
$(QUINT) test $(SPEC) --main=network_endpoints
$(QUINT) test $(SPEC) --main=network_endpoints_pre87

verify-ts:
$(QUINT) run $(SPEC) --max-steps=50 --invariants allInvariants --backend typescript
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ All three implementations expose the same API surface, share the same [Quint spe

| Language | Directory | Tests | Stack |
|----------|-----------|-------|-------|
| Go | [go/](go/) | 113 unit + 63 integration + 15 socket | stdlib net/http + yaml.v3 |
| Rust | [rs/](rs/) | 145 unit + 63 integration + 15 socket | tokio, hyper, serde, clap |
| TypeScript | [ts/](ts/) | 164 unit (1 skipped) + 63 integration + 15 socket | Node 22 ESM, built-in http |
| Go | [go/](go/) | 114 unit + 72 integration + 15 socket | stdlib net/http + yaml.v3 |
| Rust | [rs/](rs/) | 146 unit + 72 integration + 15 socket | tokio, hyper, serde, clap |
| TypeScript | [ts/](ts/) | 165 unit (1 skipped) + 72 integration + 15 socket | Node 22 ESM, built-in http |

### Build All

Expand Down Expand Up @@ -192,11 +192,14 @@ 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 |
| ContainerConfigMutator | Mutator | Forces `network_mode`, `user`, `security_options`, `restart_policy` from policy, and makes `NetworkingConfig.EndpointsConfig` match a forced `network_mode` |
| 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 |
| NetworkEndpointsGate | Gate | With a forced `network_mode`, denies `NetworkingConfig.EndpointsConfig` entries for other networks |

When a policy forces `network_mode` M, the endpoint entries in `NetworkingConfig.EndpointsConfig` must agree with it, or the daemon creates the container and then refuses to start it with `network default not found`: without `--network`, the Docker CLI sends an entry for `default` ([#87](https://github.com/ChainSafe/docker-socket-policy/issues/87)). Entries may be keyed `default` or M; any other key is denied with `network "<name>" is not allowed for service <service>; policy forces network_mode <M>`, since it would attach the container to a network the policy does not allow. For `host`, `none` and `container:<id>` the proxy then forwards an empty `EndpointsConfig`; for `bridge` or a named network it forwards exactly one entry, keyed M, with the settings (aliases, for example) of the caller's M entry, else of its `default` entry. A `NetworkingConfig` that is not a JSON object is denied. Without a forced `network_mode`, `NetworkingConfig` is forwarded as sent.

The chain runs only on container create. `--readonly` is not a gate: the router denies every request that is not `GET` or `HEAD`, on every path, before any other check ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)). Attach is denied by the router for every method, so a websocket attach (`GET /containers/{name}/attach/ws`) cannot reach a container's stdin, with or without `--readonly` ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)).

Expand Down Expand Up @@ -451,13 +454,15 @@ not at the socket.

This project includes a [Quint](https://quint-lang.org/) 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. A third module, `spec/router.qnt`, models the router's denies for writes under `--readonly` ([#75](https://github.com/ChainSafe/docker-socket-policy/issues/75)), percent-encoded paths ([#53](https://github.com/ChainSafe/docker-socket-policy/issues/53)), empty interior segments ([#55](https://github.com/ChainSafe/docker-socket-policy/issues/55)), dot segments ([#70](https://github.com/ChainSafe/docker-socket-policy/issues/70)), exec ([#49](https://github.com/ChainSafe/docker-socket-policy/issues/49)) and attach ([#76](https://github.com/ChainSafe/docker-socket-policy/issues/76)), the GET/HEAD-only read passthrough ([#78](https://github.com/ChainSafe/docker-socket-policy/issues/78)), the `POST /containers/create` route, container-name extraction and the container-lifecycle branch, not the full routing table ([#24](https://github.com/ChainSafe/docker-socket-policy/issues/24), [#48](https://github.com/ChainSafe/docker-socket-policy/issues/48)). Its 12 properties are checked exhaustively over the methods `GET`, `HEAD`, `POST`, `DELETE` and every path of 1 to 3 segments over the model's word set; exec is denied by the router, not by a middleware gate.

The request-handling spec also has a module, `network_endpoints_model`, for the endpoint entries under a forced `network_mode` ([#87](https://github.com/ChainSafe/docker-socket-policy/issues/87)). Its 3 properties (extra networks are denied, an allowed create names no network but the forced one, and every allowed create starts) are checked over five network modes and every endpoint map over seven keys; `network_endpoints_pre87` shows all three failing before the fix.

The CI pipeline runs verification on every push and PR. A violation blocks the build.

```bash
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 and router.qnt (one per table row)
make test-spec # Quint `run` tests for listener.qnt, router.qnt and network_endpoints (one per table row)
make validate # All checks: typecheck + verify + go vet + go test
```

Expand Down
9 changes: 9 additions & 0 deletions deploy/config/net-host.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Integration test policy for #87: a forced network_mode of host. hello-world
# is small and exits at once, so deploy/test.sh can pull, create and start a
# real container through the proxy. The container is named after the service.
service_name: net-host
allowed_image_prefixes:
- hello-world

container_config:
network_mode: host
9 changes: 9 additions & 0 deletions deploy/config/net-none.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Integration test policy for #87: a forced network_mode of none. The pause
# image is small, so deploy/test.sh can pull, create and start a real
# container through the proxy. The container is named after the service.
service_name: net-none
allowed_image_prefixes:
- registry.k8s.io/pause

container_config:
network_mode: none
58 changes: 56 additions & 2 deletions deploy/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
# Integration tests for docker-socket-policy.
# Sends HTTP requests directly to the proxy's Docker API and checks status codes,
# and the deny message on every expected proxy 403.
# The proxy's RegistryGate, MountSourceGate, EnvFileGate, CmdGate, and
# ContainerConfigMutator are all exercised; exec and attach are denied by the
# The proxy's RegistryGate, MountSourceGate, EnvFileGate, CmdGate,
# NetworkEndpointsGate and ContainerConfigMutator are all exercised, the last
# two by starting real containers (#87); exec and attach are denied by the
# router, protocol upgrades by the handler (#76), non-read methods on the read
# endpoints by the router and CONNECT by the handler (#78), and every write on a
# second proxy run with --readonly (#75).
Expand Down Expand Up @@ -530,6 +531,59 @@ check_denied "GET /x/../exec/*/json -> 403 (dot segment)" "$S" "$BODY" 'dot path
S=$(dot_get "$PROXY/containers/./json")
check_denied "GET /containers/./json -> 403 (dot segment)" "$S" "$BODY" 'dot path segment not allowed'

# ─── Forced network_mode and NetworkingConfig (#87) ──────

# A policy that forces network_mode must also make the endpoint entries match
# it. Without --network the Docker CLI sends EndpointsConfig {"default": {}};
# before #87 the proxy forwarded that as sent, the create succeeded, and the
# start failed with "network default not found". These create and START real
# containers through the proxy, as the CLI does, under the net-host (host) and
# net-none (none) policies in deploy/config, then remove them.
echo ""
echo "--- Forced network_mode (#87) ---"

# pull IMAGE TAG: pull through the proxy. The router reads the body (and the
# query string, #83); the daemon reads the query string. A pull streams until
# it completes, so it gets a longer timeout.
pull() {
: >"$BODY"
out=$(curl -s -o "$BODY" -w '%{http_code}' --max-time 120 --connect-timeout 2 --unix-socket "$PROXY_SOCK" \
-X POST -H "Content-Type: application/json" -d "{\"fromImage\":\"$1:$2\"}" \
"$PROXY/images/create?fromImage=$1&tag=$2" 2>/dev/null || true)
echo "${out:-000}"
}

# start_forced NAME IMAGE MODE: create NAME as the CLI does, start it, and
# remove it. A leftover from an earlier run is removed first.
start_forced() {
delete_status "$PROXY/containers/$1?force=true" >/dev/null
S=$(post_json "{\"Image\":\"$2\",\"HostConfig\":{},\"NetworkingConfig\":{\"EndpointsConfig\":{\"default\":{}}}}" \
"$PROXY/containers/create?name=$1")
check "create $1 with EndpointsConfig {\"default\":{}} -> 201 (network_mode $3)" "201" "$S"
S=$(post_empty "$PROXY/containers/$1/start")
check "start $1 -> 204 (network_mode $3)" "204" "$S"
[ "$S" = "204" ] || echo " start body: $(head -c 200 "$BODY")"
S=$(delete_status "$PROXY/containers/$1?force=true")
check "remove $1 -> 204" "204" "$S"
}

S=$(pull hello-world latest)
check "pull hello-world (net-host policy) -> 200" "200" "$S"
start_forced net-host hello-world:latest host

S=$(pull registry.k8s.io/pause 3.10)
check "pull registry.k8s.io/pause (net-none policy) -> 200" "200" "$S"
start_forced net-none registry.k8s.io/pause:3.10 none

# An extra endpoint entry would attach the container to a network the policy
# does not allow, so it is denied, not dropped.
S=$(post_json '{"Image":"hello-world:latest","NetworkingConfig":{"EndpointsConfig":{"default":{},"bridge":{}}}}' \
"$PROXY/containers/create?name=net-host")
check_denied "create with an extra endpoint under network_mode host -> 403" "$S" "$BODY" \
'network "bridge" is not allowed for service net-host; policy forces network_mode host'
# Without the deny the create succeeds; do not leave the container behind.
delete_status "$PROXY/containers/net-host?force=true" >/dev/null

# ─── Read-only mode (#75) ────────────────────────────────

# The same config behind --readonly. Every request whose method is not GET or
Expand Down
1 change: 1 addition & 0 deletions go/internal/middleware/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func NewChain() *Chain {
c.gates = append(c.gates, &MountSourceGate{})
c.gates = append(c.gates, &EnvFileGate{})
c.gates = append(c.gates, &CmdGate{})
c.gates = append(c.gates, &NetworkEndpointsGate{})

c.mutators = append(c.mutators, &ContainerConfigMutator{})

Expand Down
Loading
Loading