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
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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)
- Go: 133 unit tests (main/listener: 24, policy: 15, middleware: 29, proxy: 61, audit: 4); the middleware count includes `TestHostConfigRows`, whose 139 #88 and #93 rows run as subtests, and `TestGroupAddGateRows` (9 #93 rows)
- Rust: 160 unit tests (main/listener: 23, policy: 17, middleware: 45, proxy: 52, handler: 17, audit: 4, transport: 2); `test_host_config_rows` runs the 139 #88 and #93 rows, `test_group_add_gate_rows` the 9 #93 gate rows
- TypeScript: 330 unit tests, 1 skipped (flags: 44, listen: 13 incl. 1 skipped concurrency test (#46), middleware: 182 incl. the 139 #88 and #93 rows and the 9 #93 gate rows, proxy: 40, policy: 16, handler: 21, shutdown: 5, transport: 5, audit: 4)
- Integration, per implementation: 116 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` (108 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose
- Integration: `make test-integration` (116 test cases) and `make test-integration-sock` (15 socket cases) via Docker Compose

## Contribution Workflow

Expand Down
29 changes: 24 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/) | 131 unit + 108 integration + 15 socket | stdlib net/http + yaml.v3 |
| Rust | [rs/](rs/) | 158 unit + 108 integration + 15 socket | tokio, hyper, serde, clap |
| TypeScript | [ts/](ts/) | 312 unit (1 skipped) + 108 integration + 15 socket | Node 22 ESM, built-in http |
| Go | [go/](go/) | 133 unit + 116 integration + 15 socket | stdlib net/http + yaml.v3 |
| Rust | [rs/](rs/) | 160 unit + 116 integration + 15 socket | tokio, hyper, serde, clap |
| TypeScript | [ts/](ts/) | 330 unit (1 skipped) + 116 integration + 15 socket | Node 22 ESM, built-in http |

### Build All

Expand Down Expand Up @@ -157,6 +157,8 @@ container_config:
security_options:
- no-new-privileges:true
user: '2001:2001'
group_add: # optional; forced as HostConfig.GroupAdd
- '64190' # host GID of the jwt group (quote GIDs)
volumes:
- host_path: /home/beacon
container_path: /data
Expand All @@ -182,6 +184,22 @@ the bind must carry `:ro`, or the mount `ReadOnly: true`, and a bind without a
mode is read-write in Docker, so it is denied. See
[Container create: HostConfig rules](#container-create-hostconfig-rules).

`container_config.group_add` is optional
([#93](https://github.com/ChainSafe/docker-socket-policy/issues/93)). `user`
sets only the primary UID and GID, and the daemon checks a host-mounted file
against the GIDs the process has, so a service that reads a file such as a
`root:jwt 0640` JWT secret needs the host group as a supplementary group. The
mutator overwrites `HostConfig.GroupAdd` with the list, as `user` overwrites
`User`: a caller's own list (`community.docker.docker_container`'s `groups:`,
`docker run --group-add`) is replaced, not denied, and a caller that sends the
same list sees no change. Each entry must be a quoted YAML string holding a
decimal GID that fits in 32 bits or a group name
(`[a-z_][a-z0-9_-]*$?`, at most 32 characters); `0` (in any decimal form) and
`root`, duplicates and anything else fail startup with an error that names the
file. An empty list is the same as leaving the key out. A group name is
resolved by the daemon against the container's `/etc/group` when it starts;
a GID needs no entry there.

`container_names` is optional. It lists the container names the policy
manages, each a valid Docker name (`[a-zA-Z0-9][a-zA-Z0-9_.-]*`; anything else
fails startup). It does two things
Expand Down Expand Up @@ -246,7 +264,7 @@ docker run --mount type=bind,src=/,dst=/host chainsafe/lodestar:next # denied:

| Middleware | Type | What it checks |
|------------|------|----------------|
| ContainerConfigMutator | Mutator | Forces `network_mode`, `user`, `security_options`, `restart_policy`, `log_driver` from policy, `HostConfig.Privileged: false` on every create, and makes `NetworkingConfig.EndpointsConfig` match a forced `network_mode` |
| ContainerConfigMutator | Mutator | Forces `network_mode`, `user`, `group_add`, `security_options`, `restart_policy`, `log_driver` from policy, `HostConfig.Privileged: false` on every create, and makes `NetworkingConfig.EndpointsConfig` match a forced `network_mode` |
| RegistryGate | Gate | Validates the normalised image ref against `allowed_image_prefixes`, then `image_tag_pattern` and `image_digest_allowed` |
| MountSourceGate | Gate | Validates `HostConfig.Binds`, `HostConfig.Mounts` and top-level `Volumes` against `volumes`, including bind options and `read_write: false` |
| EnvFileGate | Gate | Strips `Env` field from create body; env must come from locked `env_file` |
Expand All @@ -268,6 +286,7 @@ The chain runs only on container create. The mutator runs first, so the gates ch
| `Mounts` | `Type: bind`: `Source` must be a policy `volumes` entry; `ReadOnly: true` for a `read_write: false` volume; `BindOptions.Propagation` empty, `private` or `rprivate`; `BindOptions.ReadOnlyNonRecursive` must be zero. `Type: tmpfs`: allowed, without a `Source`. Every other type (`volume`, `image`, `npipe`, `cluster`) is denied. Other entry keys besides `Target`, `Consistency` and `TmpfsOptions` must be zero. |
| `NetworkMode`, `RestartPolicy`, `LogConfig` | Any value; overwritten when the policy sets `network_mode`, `restart_policy` or `log_driver`. |
| `SecurityOpt` | Each entry must be in the policy's `security_options` or be `no-new-privileges` / `no-new-privileges:true`. Overwritten when the policy sets `security_options`. |
| `GroupAdd` | Absent or zero, unless the policy sets `group_add`: the mutator then overwrites it with that list, and the gate allows exactly that list, in order ([#93](https://github.com/ChainSafe/docker-socket-policy/issues/93)). |
| `Privileged` | Forced to `false` by the mutator on every create, so `true` is neutralised, not denied. |
| `PidMode`, `UTSMode`, `UsernsMode`, `Cgroup` | `""` only. |
| `IpcMode` | `""`, `private`, `shareable` or `none`. |
Expand All @@ -276,7 +295,7 @@ The chain runs only on container create. The mutator runs first, so the gates ch
| `OomScoreAdj` | `0` or more. |
| `MaskedPaths`, `ReadonlyPaths` | Absent or `null` only: an empty list unmasks `/proc` (`--security-opt systempaths=unconfined`). |
| `CapDrop`, `ReadonlyRootfs`, `Init`, `AutoRemove`, `ConsoleSize`, `ShmSize`, `PortBindings`, `PublishAllPorts`, `Dns`, `DnsOptions`, `DnsSearch`, `ExtraHosts`, `Tmpfs`, `Ulimits`, `Memory`, `MemoryReservation`, `MemorySwap`, `MemorySwappiness`, `NanoCpus`, `CpuShares`, `CpuPeriod`, `CpuQuota`, `CpusetCpus`, `CpusetMems`, `PidsLimit`, `BlkioWeight`, `ContainerIDFile` | Any value. |
| Everything else: `CapAdd`, `Devices`, `DeviceRequests`, `DeviceCgroupRules`, `GroupAdd`, `VolumesFrom`, `Links`, `Sysctls`, `CgroupParent`, `Runtime`, `VolumeDriver`, `OomKillDisable`, `Annotations`, `StorageOpt`, the other resource fields, and unknown keys | Absent or zero. |
| Everything else: `CapAdd`, `Devices`, `DeviceRequests`, `DeviceCgroupRules`, `VolumesFrom`, `Links`, `Sysctls`, `CgroupParent`, `Runtime`, `VolumeDriver`, `OomKillDisable`, `Annotations`, `StorageOpt`, the other resource fields, and unknown keys | Absent or zero. |

The daemon matches JSON field names without regard to case, and the last match wins, so `"privileged": true` would set `Privileged` after the mutator's `"Privileged": false`, and `"securityopt": null` would clear a forced `SecurityOpt`. A key that the daemon would read as a known field but that is not spelled exactly like it (`privileged`, `hostConfig`, `user`, `image`, …) is denied, at the top level of the body, in `HostConfig`, in `Mounts` entries and their `BindOptions`, and in `NetworkingConfig`, whether or not the policy forces `network_mode`: `NetworkEndpointsGate` reads `EndpointsConfig` only, and the daemon would attach the networks under `endpointsconfig` too. Inside `RestartPolicy` and `LogConfig` the spelling does not matter: the mutator replaces the whole object when the policy sets it, and otherwise any value is allowed; no gate reads `Healthcheck` or the settings inside an endpoint entry. The policy's `ports` field is parsed but not enforced; `PortBindings` is not restricted by it.

Expand Down
5 changes: 5 additions & 0 deletions config/beacon.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ container_config:
security_options:
- no-new-privileges:true
user: '2001:2001'
# Optional (#93). Forced as HostConfig.GroupAdd: supplementary groups for
# reading host files, here the JWT secret (root:jwt 0640). Quote GIDs; 0
# and root are refused. A caller's own list is replaced.
group_add:
- '64190'
log_driver: json-file
log_options:
max-file: '5'
Expand Down
4 changes: 4 additions & 0 deletions deploy/config/dsp-it-a.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ allowed_image_prefixes:

container_config:
user: '1111:1111'
# Two forced supplementary groups for the #93 checks; dsp-it-b has none.
group_add:
- '64190'
- '64191'
48 changes: 48 additions & 0 deletions deploy/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,54 @@ check_denied "DELETE /v1.45/containers/<runner short ID> -> 403 (uncovered, #86)
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'

# ─── Forced supplementary groups (#93) ───────────────────

# deploy/config/dsp-it-a.yaml forces group_add ['64190', '64191']; dsp-it-b
# has no group_add. community.docker.docker_container's groups: becomes
# HostConfig.GroupAdd, a list of strings. Each create is inspected through the
# proxy (reads pass) and removed. Before #93 the proxy forced no list and
# denied any non-empty GroupAdd.
echo ""
echo "--- Forced group_add (#93) ---"

FORCED_GROUPS='"GroupAdd":["64190","64191"]'

# create_groups NAME HOSTCONFIG_JSON: create NAME from this runner's image.
create_groups() {
delete_status "$PROXY/containers/$1?force=1" >/dev/null
post_json "{\"Image\":\"$IT_IMAGE\",\"Cmd\":[\"sleep\",\"300\"],\"HostConfig\":$2}" "$PROXY/containers/create?name=$1"
}

# inspect_groups NAME: the container's HostConfig.GroupAdd as the daemon
# reports it, or "none"; then remove the container.
inspect_groups() {
: >"$BODY"
curl -s -o "$BODY" $TIMEOUT --unix-socket "$PROXY_SOCK" "$PROXY/containers/$1/json" 2>/dev/null || true
g=$(grep -oE '"GroupAdd":(null|\[[^]]*\])' "$BODY" | head -n 1)
delete_status "$PROXY/containers/$1?force=1" >/dev/null
echo "${g:-none}"
}

S=$(create_groups dsp-it-a '{}')
check "create dsp-it-a without GroupAdd -> 201" "201" "$S"
check "dsp-it-a without GroupAdd: the daemon has the forced list" "$FORCED_GROUPS" "$(inspect_groups dsp-it-a)"

S=$(create_groups dsp-it-a '{"GroupAdd":["64190","64191"]}')
check "create dsp-it-a with the forced list -> 201" "201" "$S"
check "dsp-it-a with the forced list: the daemon has it unchanged" "$FORCED_GROUPS" "$(inspect_groups dsp-it-a)"

S=$(create_groups dsp-it-a '{"GroupAdd":["0","docker"]}')
check "create dsp-it-a with another GroupAdd -> 201 (replaced, not denied)" "201" "$S"
check "dsp-it-a with another GroupAdd: the daemon has the forced list" "$FORCED_GROUPS" "$(inspect_groups dsp-it-a)"

S=$(create_groups dsp-it-a '{"groupadd":["0"]}')
remove_if_created "$S"
check_denied "create dsp-it-a with lower-case groupadd -> 403" "$S" "$BODY" 'HostConfig.groupadd is not allowed for service dsp-it-a'

S=$(create_groups dsp-it-b '{"GroupAdd":["64190","64191"]}')
remove_if_created "$S"
check_denied "create dsp-it-b (no group_add) with GroupAdd -> 403" "$S" "$BODY" 'HostConfig.GroupAdd is not allowed for service dsp-it-b'

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

# The same config behind --readonly. Every request whose method is not GET or
Expand Down
12 changes: 11 additions & 1 deletion go/internal/middleware/containerconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import (

// ContainerConfigMutator overwrites the fields the policy's container_config
// sets. It forces HostConfig.Privileged to false on every create, even when
// container_config is empty (#88). A HostConfig that is not an object is left
// container_config is empty (#88), and HostConfig.GroupAdd to the policy's
// group_add when set (#93). A HostConfig that is not an object is left
// alone for HostConfigGate to deny.
type ContainerConfigMutator struct{}

Expand Down Expand Up @@ -49,6 +50,15 @@ func (m *ContainerConfigMutator) Mutate(r *http.Request, p *policy.Policy, body

hostConfig["Privileged"] = false

if len(cc.GroupAdd) > 0 {
// Overwritten like User (#93); HostConfigGate allows exactly this list.
groups := make([]interface{}, len(cc.GroupAdd))
for i, g := range cc.GroupAdd {
groups[i] = g
}
hostConfig["GroupAdd"] = groups
}

if cc.User != "" {
body["User"] = cc.User
}
Expand Down
22 changes: 22 additions & 0 deletions go/internal/middleware/hostconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ func (g *HostConfigGate) Check(r *http.Request, p *policy.Policy, body map[strin
if value != nil {
return fmt.Errorf("HostConfig.%s is not allowed for service %s", key, p.ServiceName)
}
case key == "GroupAdd":
// Zero, or exactly the policy's group_add, which the mutator
// forces (#93). Checked here too so that the gate does not open
// up if the mutator ever stops forcing it.
if !isZeroValue(value) && !equalsStringList(value, p.ContainerConfig.GroupAdd) {
return fmt.Errorf("HostConfig.GroupAdd is not allowed for service %s", p.ServiceName)
}
case key == "SecurityOpt":
if err := checkSecurityOpt(value, p); err != nil {
return err
Expand Down Expand Up @@ -238,6 +245,21 @@ func isZeroValue(v interface{}) bool {
return false
}

// equalsStringList reports whether v is a JSON list of strings equal to want,
// in order. An empty want matches nothing.
func equalsStringList(v interface{}, want []string) bool {
list, ok := v.([]interface{})
if !ok || len(want) == 0 || len(list) != len(want) {
return false
}
for i, e := range list {
if s, isStr := e.(string); !isStr || s != want[i] {
return false
}
}
return true
}

func containsString(list []string, s string) bool {
for _, x := range list {
if x == s {
Expand Down
Loading
Loading