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
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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`, `spec/router.qnt` and the network endpoints module of `spec/docker_socket_policy.qnt`
- `make test-spec` — Quint `run` tests for `spec/listener.qnt`, `spec/router.qnt` and `spec/docker_socket_policy.qnt` (network endpoints, the create model and policy selection)
- `make test-integration` — Docker Compose integration tests
- `make test-integration-sock` — listening-socket integration tests (`IMPL=rs|ts` for the others)

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: 119 unit tests (main/listener: 24, policy: 11, middleware: 28, proxy: 52, audit: 4); the middleware count includes `TestHostConfigRows`, whose 131 #88 rows run as subtests
- Rust: 146 unit tests (main/listener: 23, policy: 13, middleware: 44, proxy: 47, handler: 13, audit: 4, transport: 2); `test_host_config_rows` runs the 131 #88 rows
- TypeScript: 300 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 165 incl. the 131 #88 rows, proxy: 35, policy: 12, handler: 17, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 94 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, for the image pull, `pull`, `pull_pre83`, `pull_body_ignored` and `pull_pre84`), and the `spec/docker_socket_policy.qnt` `run` tests (instances `network_endpoints`, `network_endpoints_pre87`, `create` and `create_pre88`)
- Go: 131 unit tests (main/listener: 24, policy: 14, middleware: 28, proxy: 61, audit: 4); the middleware count includes `TestHostConfigRows`, whose 131 #88 rows run as subtests
- Rust: 158 unit tests (main/listener: 23, policy: 16, middleware: 44, proxy: 52, handler: 17, audit: 4, transport: 2); `test_host_config_rows` runs the 131 #88 rows
- TypeScript: 312 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 165 incl. the 131 #88 rows, proxy: 40, policy: 15, handler: 21, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 108 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`, `router_pre70` and `router_pre86`; and, for the image pull, `pull`, `pull_pre83`, `pull_body_ignored` and `pull_pre84`), and the `spec/docker_socket_policy.qnt` `run` tests (instances `network_endpoints`, `network_endpoints_pre87`, `create` and `create_pre88`, and the policy-selection tests of the main module `docker_socket_policy`, #85)

## 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` (94 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose
- Integration: `make test-integration` (108 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 @@ -114,6 +114,8 @@ test-spec:
$(QUINT) test $(ROUTER_SPEC) --main=pull_pre84
$(QUINT) test $(SPEC) --main=create
$(QUINT) test $(SPEC) --main=create_pre88
$(QUINT) test $(ROUTER_SPEC) --main=router_pre86
$(QUINT) test $(SPEC) --main=docker_socket_policy --max-samples=100

verify-ts:
$(QUINT) run $(SPEC) --max-steps=50 --invariants allInvariants --backend typescript
Expand Down
77 changes: 63 additions & 14 deletions README.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions config/beacon.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Per-service policy for the Lodestar beacon node.
# Test with: docker-socket-policy --config-dir=./config
service_name: beacon
# Optional (#85, #86). Creates use this policy only for these names, and
# lifecycle actions (start, stop, rm, ...) reach only containers it covers:
# these names, or service_name when container_names is unset. A second policy
# for chainsafe/lodestar (a validator client, say) must set its own,
# disjoint container_names, or the proxy refuses to start.
container_names:
- beacon
user_id: '2001'
group_id: '2001'

Expand Down
15 changes: 15 additions & 0 deletions deploy/config/dsp-it-a.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Integration-test policy for #85 and #86 (deploy/test.sh). dsp-it-a and
# dsp-it-b allow one image, the test runner's own, and are told apart by
# container_names; each forces its own user so the test can see which one a
# create got. Without disjoint container_names the two would fail to load.
service_name: dsp-it-a
container_names:
- dsp-it-a

allowed_image_prefixes:
- docker-socket-policy-test-go-test
- docker-socket-policy-test-rs-test
- docker-socket-policy-test-ts-test

container_config:
user: '1111:1111'
15 changes: 15 additions & 0 deletions deploy/config/dsp-it-b.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Integration-test policy for #85 and #86 (deploy/test.sh). dsp-it-a and
# dsp-it-b allow one image, the test runner's own, and are told apart by
# container_names; each forces its own user so the test can see which one a
# create got. Without disjoint container_names the two would fail to load.
service_name: dsp-it-b
container_names:
- dsp-it-b

allowed_image_prefixes:
- docker-socket-policy-test-go-test
- docker-socket-policy-test-rs-test
- docker-socket-policy-test-ts-test

container_config:
user: '2222:2222'
120 changes: 115 additions & 5 deletions deploy/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
# 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).
# second proxy run with --readonly (#75). Creates select their policy by
# container name (#85), and lifecycle actions reach only covered containers
# (#86).

set -e

Expand Down Expand Up @@ -487,11 +489,13 @@ check_denied "POST /containers//start -> 403 (empty name, not a container)" "$S"

# Dotted API-version prefix (#52). The Docker CLI versions every request
# (/v1.45/containers/...). The proxy must strip the prefix and route the rest
# like the unversioned path: a lifecycle call reaches the daemon (404, no such
# container — not a proxy 403), and a reserved segment is still denied.
# like the unversioned path: a lifecycle call reaches the lifecycle branch, and
# a reserved segment is still denied. No policy covers no-such-container, so
# the proxy asks the daemon (404) and denies it as unmanaged (#86); the
# default deny would name the endpoint instead.
# TypeScript only stripped undotted prefixes, so these fell to the default deny.
S=$(post_empty "$PROXY/v1.45/containers/no-such-container/start")
check "POST /v1.45/containers/*/start -> 404 (daemon answered, not proxy 403)" "404" "$S"
check_denied "POST /v1.45/containers/*/start -> 403 (lifecycle branch, unmanaged)" "$S" "$BODY" 'container no-such-container is not managed by any policy'

S=$(delete_status "$PROXY/v1.45/containers/json")
check_denied "DELETE /v1.45/containers/json -> 403 (reserved survives version strip)" "$S" "$BODY" 'endpoint DELETE /containers/json is not allowed'
Expand Down Expand Up @@ -541,7 +545,7 @@ S=$(post_json '{"Image":"chainsafe/lodestar:beacon","Cmd":["--rcConfig","/data/c
check_denied "POST /containers/create/extra -> 403 (not the create endpoint)" "$S" "$BODY" 'endpoint POST /containers/create/extra is not allowed'

S=$(post_empty "$PROXY/containers/exec-nosuch/start")
check "POST /containers/exec-nosuch/start -> 404 (daemon answered, not proxy 403)" "404" "$S"
check_denied "POST /containers/exec-nosuch/start -> 403 (a name, not exec; unmanaged, #86)" "$S" "$BODY" 'container exec-nosuch is not managed by any policy'

S=$(get_status "$PROXY/exec/0000000000000000000000000000000000000000000000000000000000000000/json")
check_denied "GET /exec/*/json -> 403 (exec inspect denied)" "$S" "$BODY" 'exec is not allowed'
Expand Down Expand Up @@ -697,6 +701,112 @@ check_denied "create with an extra endpoint under network_mode host -> 403" "$S"
# Without the deny the create succeeds; do not leave the container behind.
delete_status "$PROXY/containers/net-host?force=true" >/dev/null

# ─── Container names (#85) and lifecycle coverage (#86) ──────

# deploy/config/dsp-it-a.yaml and dsp-it-b.yaml allow this test runner's own
# image, which every run has locally, under container_names dsp-it-a and
# dsp-it-b, and force different users. This runner's container is covered by
# no policy: it stands for an operator container. Its hostname is its short ID.
echo ""
echo "--- Container names (#85) and lifecycle coverage (#86) ---"

SELF=$(hostname)
: >"$BODY"
curl -s -o "$BODY" $TIMEOUT --unix-socket "$PROXY_SOCK" "$PROXY/containers/$SELF/json" 2>/dev/null || true
SELF_ID=$(sed -n 's/.*"Id":"\([0-9a-f]\{64\}\)".*/\1/p' "$BODY")
SELF_NAME=$(sed -n 's/.*"Name":"\/\([^"]*\)".*/\1/p' "$BODY")
IT_IMAGE=$(sed -n 's/.*"Image":"\(docker-socket-policy-test-[a-z]*-test[^"]*\)".*/\1/p' "$BODY")
if [ -z "$SELF_ID" ] || [ -z "$SELF_NAME" ] || [ -z "$IT_IMAGE" ]; then
echo " FAIL: could not inspect the test container (id=$SELF_ID name=$SELF_NAME image=$IT_IMAGE)"
FAIL=$((FAIL+1))
fi
IT_BODY="{\"Image\":\"$IT_IMAGE\",\"Cmd\":[\"sleep\",\"300\"]}"

# Leftovers from an interrupted run. Both names are covered, so this is allowed.
delete_status "$PROXY/containers/dsp-it-a?force=1" >/dev/null
delete_status "$PROXY/containers/dsp-it-b?force=1" >/dev/null

# #85: both policies allow the image. Before #85 the one applied was whichever
# came first in map order (Go, Rust) or load order (TypeScript), so one of the
# two names got the other's user. Five rounds, each create inspected.
OK_A=0
OK_B=0
i=0
while [ $i -lt 5 ]; do
for n in a b; do
case "$n" in a) want='1111:1111' ;; b) want='2222:2222' ;; esac
S=$(post_json "$IT_BODY" "$PROXY/containers/create?name=dsp-it-$n")
: >"$BODY"
curl -s -o "$BODY" $TIMEOUT --unix-socket "$PROXY_SOCK" "$PROXY/containers/dsp-it-$n/json" 2>/dev/null || true
if [ "$S" = "201" ] && grep -qF "\"User\":\"$want\"" "$BODY"; then
case "$n" in a) OK_A=$((OK_A+1)) ;; b) OK_B=$((OK_B+1)) ;; esac
fi
delete_status "$PROXY/containers/dsp-it-$n?force=1" >/dev/null
done
i=$((i + 1))
done
check "create ?name=dsp-it-a x5 -> 201 with policy dsp-it-a's user every time (#85)" "5" "$OK_A"
check "create ?name=dsp-it-b x5 -> 201 with policy dsp-it-b's user every time (#85)" "5" "$OK_B"

# remove_if_created STATUS: a proxy without the fix creates these; remove the
# container so it does not outlive the run (or block the next one).
remove_if_created() {
if [ "$1" = "201" ]; then
id=$(sed -n 's/.*"Id":"\([0-9a-f]\{64\}\)".*/\1/p' "$BODY")
cp "$BODY" "$BODY.keep"
[ -n "$id" ] && delete_status "$PROXY/containers/$id?force=1" >/dev/null
mv "$BODY.keep" "$BODY"
fi
}

S=$(post_json "$IT_BODY" "$PROXY/containers/create?name=dsp-it-c")
remove_if_created "$S"
check_denied "create ?name=dsp-it-c -> 403 (no policy accepts the name, #85)" "$S" "$BODY" "container name dsp-it-c not allowed for image $IT_IMAGE by any policy"

# The daemon takes the first of a repeated name (r.Form.Get); the proxy denies
# the request rather than guess which one an auditor would read.
S=$(post_json "$IT_BODY" "$PROXY/containers/create?name=dsp-it-a&name=dsp-it-c")
remove_if_created "$S"
check_denied "create ?name=dsp-it-a&name=dsp-it-c -> 403 (duplicate name, #85)" "$S" "$BODY" 'duplicate name query parameter'

S=$(post_json "$IT_BODY" "$PROXY/containers/create")
remove_if_created "$S"
check_denied "create without ?name= -> 403 (both policies set container_names, #85)" "$S" "$BODY" "image $IT_IMAGE requires a container name"

# #86: lifecycle actions and DELETE on a covered container, by name, full ID
# and short ID, reach the daemon.
S=$(post_json "$IT_BODY" "$PROXY/containers/create?name=dsp-it-a")
IT_ID=$(sed -n 's/.*"Id":"\([0-9a-f]\{64\}\)".*/\1/p' "$BODY")
check "create ?name=dsp-it-a -> 201" "201" "$S"
IT_SHORT=$(echo "$IT_ID" | cut -c1-12)

S=$(post_empty "$PROXY/containers/dsp-it-a/start")
check "POST /containers/dsp-it-a/start -> 204 (covered name)" "204" "$S"

S=$(post_empty "$PROXY/containers/$IT_ID/kill")
check "POST /containers/<full ID of dsp-it-a>/kill -> 204 (resolved, covered)" "204" "$S"

S=$(post_empty "$PROXY/v1.45/containers/$IT_SHORT/start")
check "POST /v1.45/containers/<short ID of dsp-it-a>/start -> 204 (resolved, covered)" "204" "$S"

S=$(delete_status "$PROXY/containers/$IT_SHORT?force=1")
check "DELETE /containers/<short ID of dsp-it-a>?force=1 -> 204 (resolved, covered)" "204" "$S"

# #86: this runner's container is covered by no policy. Before #86 each of
# these reached the daemon: start answered 304 (already running) and DELETE
# without force 409 (running). Nothing here can stop the runner.
S=$(post_empty "$PROXY/containers/$SELF_NAME/start")
check_denied "POST /containers/<runner name>/start -> 403 (uncovered, #86)" "$S" "$BODY" "container $SELF_NAME is not managed by any policy"

S=$(post_empty "$PROXY/containers/$SELF_ID/start")
check_denied "POST /containers/<runner full ID>/start -> 403 (uncovered, #86)" "$S" "$BODY" "container $SELF_ID is not managed by any policy"

S=$(delete_status "$PROXY/v1.45/containers/$SELF")
check_denied "DELETE /v1.45/containers/<runner short ID> -> 403 (uncovered, #86)" "$S" "$BODY" "container $SELF is not managed by any policy"

S=$(delete_status "$PROXY/containers/no-such-container")
check_denied "DELETE /containers/no-such-container -> 403 (daemon 404, #86)" "$S" "$BODY" 'container no-such-container is not managed by any policy'

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

# The same config behind --readonly. Every request whose method is not GET or
Expand Down
Loading
Loading