From 7a62e463123258e47c77fec3de549f5eaf6277f1 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:25:08 +0200 Subject: [PATCH 01/23] harden Docker API permissions and integration builds --- .github/workflows/docker-image.yml | 5 +++ Dockerfile | 4 +-- README.fr.md | 37 +++++++++++++++++-- README.md | 37 +++++++++++++++++-- config/profiles.yml | 28 +++++++++++++++ go.mod | 2 +- src/config.go | 28 ++++++++++++--- src/main_test.go | 57 +++++++++++++++++++++++++++++ src/policy.go | 58 +++++++++++++++++++++++++++--- src/types.go | 10 +++++- 10 files changed, 248 insertions(+), 18 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c5fccc6..eec1fea 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - integration tags: - "v*" pull_request: @@ -41,6 +42,9 @@ jobs: - name: Test run: go test -race ./... + - name: Vulnerability audit + run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... + publish: if: github.event_name != 'pull_request' needs: test @@ -82,6 +86,7 @@ jobs: latest=false tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=integration,enable=${{ github.ref == 'refs/heads/integration' }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/Dockerfile b/Dockerfile index 80491ec..aecf6b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.5-alpine3.24 AS build +FROM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build ARG APP_VERSION="dev" ARG APP_GIT_SHA="unknown" @@ -14,7 +14,7 @@ RUN go build -trimpath \ -ldflags="-s -w -X main.version=${APP_VERSION} -X main.gitSha=${APP_GIT_SHA}" \ -o /out/docker-socket-proxy ./src -FROM gcr.io/distroless/static-debian13:nonroot +FROM gcr.io/distroless/static-debian13:nonroot@sha256:1c2c046bc09ed40fad370b599a0b1ae7987f55b01e247cf27a7c27cd97e5bbc7 COPY --from=build --chown=nonroot:nonroot /out/docker-socket-proxy /usr/local/bin/docker-socket-proxy diff --git a/README.fr.md b/README.fr.md index 830d24a..bbcecbb 100644 --- a/README.fr.md +++ b/README.fr.md @@ -19,6 +19,8 @@ La première référence est publiée sur [Docker Hub](https://hub.docker.com/r/ `latest` suit `main`. Chaque release Git `vX.Y.Z` publie également les tags Docker immuables `X.Y.Z` et `X.Y` sur les deux registres. +La branche `integration` publie uniquement le tag mutable `integration`. Elle ne remplace jamais `latest` ni un tag de release. + L'image publiée est analysée en continu par [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). Le rapport est lié ici plutôt que figé dans le README : son résultat suit les mises à jour des vulnérabilités et de l'image. ## Ce qui le différencie @@ -202,7 +204,7 @@ Chaque famille est désactivée par défaut. Une valeur YAML booléenne (`true`/ | `build` | `/build` | | `commit` | `/commit` | | `configs` | `/configs` | -| `containers` | `/containers` | +| `containers` | `/containers` (famille générale ; les sous-routes sensibles restent contrôlées séparément) | | `distribution` | `/distribution` | | `exec` | `/exec` | | `images` | `/images` | @@ -217,7 +219,38 @@ Chaque famille est désactivée par défaut. Une valeur YAML booléenne (`true`/ | `tasks` | `/tasks` | | `volumes` | `/volumes` | -Les écritures (`POST`, `PUT`, `PATCH`, `DELETE`) restent interdites même lorsqu'une famille est activée, sauf si `post: true` est ajouté. Pour les opérations de conteneur, `post` doit être complété explicitement par `allow_start`, `allow_stop` et/ou `allow_restart` selon le besoin. `allow_restarts` est accepté comme alias de `allow_restart`. +Les écritures génériques (`POST`, `PUT`, `PATCH`, `DELETE`) restent interdites même lorsqu'une famille est activée, sauf si `post: true` est ajouté. Les actions ciblées de cycle de vie sont indépendantes de ce droit large et peuvent être accordées avec `post: false`. + +| Option conteneur | Route | Nécessite `post` | +| --- | --- | --- | +| `allow_archive` | `/containers/{id}/archive` | GET/HEAD : non ; PUT : oui | +| `allow_changes` | `/containers/{id}/changes` | non | +| `allow_export` | `/containers/{id}/export` | non | +| `allow_logs` | `/containers/{id}/logs` | non | +| `allow_top` | `/containers/{id}/top` | non | +| `allow_start` | `/containers/{id}/start` | non | +| `allow_stop` | `/containers/{id}/stop` | non | +| `allow_restart` | `/containers/{id}/restart` | non | +| `allow_pause` | `/containers/{id}/pause` | non | +| `allow_unpause` | `/containers/{id}/unpause` | non | +| `allow_kill` | `/containers/{id}/kill` | non | + +Toutes ces options valent `false` par défaut. `allow_restarts` reste un alias de `allow_restart` ; contrairement au commutateur groupé de LinuxServer, il n'accorde pas implicitement `stop` ou `kill`. Ces droits doivent être explicitement ajoutés. Il n'existe volontairement aucun `allow_all` global : `post` contrôle les écritures larges, les familles d'API restent explicites et les routes sensibles conservent leurs propres droits. + +Exemple minimal limité au cycle de vie : + +```yaml +container-operator: + ping: true + version: true + containers: true + post: false + allow_start: true + allow_stop: true + allow_restart: true + allow_pause: true + allow_unpause: true +``` `apirewrite` force une version d'API Docker pour un profil, par exemple `apirewrite: "1.53"`. diff --git a/README.md b/README.md index 1d6d154..41065dd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Docker Hub is the primary registry; GitHub Container Registry is also available. `latest` follows `main`. A Git release `vX.Y.Z` additionally publishes immutable `X.Y.Z` and `X.Y` tags to both registries. +The `integration` branch publishes only the mutable `integration` tag. It never replaces `latest` or a release tag. + The published image is continuously analysed by [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). The live report is linked rather than hard-coded here, so its result always reflects current image and vulnerability data. ## Why this proxy is different @@ -201,7 +203,7 @@ Every API family is disabled by default. YAML booleans (`true` / `false`) are re | `build` | `/build` | | `commit` | `/commit` | | `configs` | `/configs` | -| `containers` | `/containers` | +| `containers` | `/containers` (general family; sensitive sub-routes remain separately gated) | | `distribution` | `/distribution` | | `exec` | `/exec` | | `images` | `/images` | @@ -216,7 +218,38 @@ Every API family is disabled by default. YAML booleans (`true` / `false`) are re | `tasks` | `/tasks` | | `volumes` | `/volumes` | -Write methods (`POST`, `PUT`, `PATCH`, `DELETE`) remain forbidden even if a family is enabled, unless `post: true` is set. Container operations also require the matching explicit option: `allow_start`, `allow_stop`, and/or `allow_restart`. `allow_restarts` is accepted as an alias for `allow_restart`. +Generic write methods (`POST`, `PUT`, `PATCH`, `DELETE`) remain forbidden even if a family is enabled, unless `post: true` is set. Narrow container lifecycle permissions are independent from that broad switch and can be granted while `post: false`. + +| Container option | Route | Requires `post` | +| --- | --- | --- | +| `allow_archive` | `/containers/{id}/archive` | GET/HEAD: no; PUT: yes | +| `allow_changes` | `/containers/{id}/changes` | no | +| `allow_export` | `/containers/{id}/export` | no | +| `allow_logs` | `/containers/{id}/logs` | no | +| `allow_top` | `/containers/{id}/top` | no | +| `allow_start` | `/containers/{id}/start` | no | +| `allow_stop` | `/containers/{id}/stop` | no | +| `allow_restart` | `/containers/{id}/restart` | no | +| `allow_pause` | `/containers/{id}/pause` | no | +| `allow_unpause` | `/containers/{id}/unpause` | no | +| `allow_kill` | `/containers/{id}/kill` | no | + +All these options default to `false`. `allow_restarts` remains an alias for `allow_restart`; unlike LinuxServer's grouped switch, it deliberately does not silently grant `stop` or `kill`. Grant those operations explicitly when required. There is intentionally no global `allow_all`: broad writes require `post`, API families remain explicit, and sensitive container routes keep their own switches. + +Minimal lifecycle-only example: + +```yaml +container-operator: + ping: true + version: true + containers: true + post: false + allow_start: true + allow_stop: true + allow_restart: true + allow_pause: true + allow_unpause: true +``` `apirewrite` forces a Docker API version for a profile, for example `apirewrite: "1.53"`. diff --git a/config/profiles.yml b/config/profiles.yml index 50c215b..664163a 100644 --- a/config/profiles.yml +++ b/config/profiles.yml @@ -13,6 +13,14 @@ administrator: allow_start: true allow_stop: true allow_restart: true + allow_pause: true + allow_unpause: true + allow_kill: true + allow_archive: true + allow_changes: true + allow_export: true + allow_logs: true + allow_top: true container_scope: all portainer: @@ -31,6 +39,14 @@ portainer: allow_start: true allow_stop: true allow_restart: true + allow_pause: true + allow_unpause: true + allow_kill: true + allow_archive: true + allow_changes: true + allow_export: true + allow_logs: true + allow_top: true # Exemple de profil restreint : le rôle conserve ses droits Docker, mais ne peut # agir que sur le conteneur nommé traefik. @@ -56,6 +72,10 @@ operator: allow_start: true allow_stop: true allow_restart: true + allow_pause: true + allow_unpause: true + allow_logs: true + allow_top: true container_scope: blacklist blocked_containers: - docker-socket-proxy @@ -89,6 +109,14 @@ dockhand: allow_start: true allow_stop: true allow_restart: true + allow_pause: true + allow_unpause: true + allow_kill: true + allow_archive: true + allow_changes: true + allow_export: true + allow_logs: true + allow_top: true container_scope: blacklist blocked_containers: - docker-socket-proxy diff --git a/go.mod b/go.mod index c9a5300..8c48eb5 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/cerede2000/docker-socket-proxy -go 1.26 +go 1.27 require gopkg.in/yaml.v3 v3.0.1 diff --git a/src/config.go b/src/config.go index 8cbfefb..dcaa4e4 100644 --- a/src/config.go +++ b/src/config.go @@ -113,12 +113,28 @@ func applyFlagValue(s *ServiceConfig, flag, value string) { s.Volumes = b case "post": s.Post = b + case "allow_archive": + s.AllowArchive = b + case "allow_changes": + s.AllowChanges = b + case "allow_export": + s.AllowExport = b + case "allow_logs": + s.AllowLogs = b + case "allow_pause": + s.AllowPause = b case "allow_start": s.AllowStart = b case "allow_stop": s.AllowStop = b case "allow_restart", "allow_restarts": s.AllowRestart = b + case "allow_top": + s.AllowTop = b + case "allow_unpause": + s.AllowUnpause = b + case "allow_kill": + s.AllowKill = b case "apirewrite": // Pour apirewrite, on prend la valeur brute (ex: "1.51") s.APIRewrite = strings.TrimSpace(value) @@ -420,9 +436,9 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { logger.Printf("[config] WARNING: aucun profil défini (pas de --home / --portainer / etc.)") } else { for name, svc := range cfg.services { - logger.Printf("[config] profil=%s rights: ping=%v version=%v info=%v containers=%v images=%v networks=%v exec=%v post=%v start=%v stop=%v restart=%v scope=%s rules=%d apirewrite=%q", + logger.Printf("[config] profile=%s rights: ping=%v version=%v info=%v containers=%v images=%v networks=%v exec=%v post=%v start=%v stop=%v restart=%v pause=%v unpause=%v kill=%v scope=%s rules=%d apirewrite=%q", name, svc.Ping, svc.Version, svc.Info, svc.Containers, svc.Images, svc.Networks, - svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.ContainerScope, len(svc.ContainerRules), svc.APIRewrite) + svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.AllowPause, svc.AllowUnpause, svc.AllowKill, svc.ContainerScope, len(svc.ContainerRules), svc.APIRewrite) } } @@ -438,7 +454,9 @@ var knownProfileKeys = map[string]struct{}{ "build": {}, "commit": {}, "configs": {}, "containers": {}, "distribution": {}, "exec": {}, "images": {}, "networks": {}, "nodes": {}, "plugins": {}, "secrets": {}, "services": {}, "session": {}, "swarm": {}, "system": {}, "tasks": {}, "volumes": {}, - "post": {}, "allow_start": {}, "allow_stop": {}, "allow_restart": {}, "allow_restarts": {}, + "post": {}, "allow_archive": {}, "allow_changes": {}, "allow_export": {}, "allow_logs": {}, + "allow_pause": {}, "allow_restart": {}, "allow_restarts": {}, "allow_start": {}, "allow_stop": {}, + "allow_top": {}, "allow_unpause": {}, "allow_kill": {}, "apirewrite": {}, "container_scope": {}, "allowed_containers": {}, "blocked_containers": {}, "container_rules": {}, } @@ -544,9 +562,9 @@ func loadProfilesFromFile(cfg *ProxyConfig, logger *log.Logger) error { logger.Printf("[profiles] loaded %d profiles from %s", len(newServices), cfg.ProfilesFile) for name, svc := range newServices { - logger.Printf("[profiles] profil=%s ping=%v version=%v info=%v events=%v containers=%v exec=%v post=%v start=%v stop=%v restart=%v scope=%s allowed=%d blocked=%d rules=%d apirewrite=%q", + logger.Printf("[profiles] profile=%s ping=%v version=%v info=%v events=%v containers=%v exec=%v post=%v start=%v stop=%v restart=%v pause=%v unpause=%v kill=%v scope=%s allowed=%d blocked=%d rules=%d apirewrite=%q", name, svc.Ping, svc.Version, svc.Info, svc.Events, svc.Containers, - svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.ContainerScope, len(svc.AllowedContainers), len(svc.BlockedContainers), len(svc.ContainerRules), svc.APIRewrite) + svc.Exec, svc.Post, svc.AllowStart, svc.AllowStop, svc.AllowRestart, svc.AllowPause, svc.AllowUnpause, svc.AllowKill, svc.ContainerScope, len(svc.AllowedContainers), len(svc.BlockedContainers), len(svc.ContainerRules), svc.APIRewrite) } return nil diff --git a/src/main_test.go b/src/main_test.go index 92bcacc..a6aaa86 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -63,6 +63,14 @@ func TestClassifyPath(t *testing.T) { {"/v1.51/containers/json", "containers", ""}, {"/v1.51/containers/id/start", "containers", "start"}, {"/v1.51/containers/id/restart", "containers", "restart"}, + {"/v1.51/containers/id/pause", "containers", "pause"}, + {"/v1.51/containers/id/unpause", "containers", "unpause"}, + {"/v1.51/containers/id/kill", "containers", "kill"}, + {"/v1.51/containers/id/logs", "containers", "logs"}, + {"/v1.51/containers/id/top", "containers", "top"}, + {"/v1.51/containers/id/changes", "containers", "changes"}, + {"/v1.51/containers/id/archive", "containers", "archive"}, + {"/v1.51/containers/id/export", "containers", "export"}, {"/v1.51/exec/id/start", "exec", ""}, {"/not-a-docker-endpoint", "unknown", ""}, } @@ -112,6 +120,55 @@ func TestAllowReadAndWritePermissions(t *testing.T) { } } +func TestLifecyclePermissionsDoNotRequireBroadPost(t *testing.T) { + service := &ServiceConfig{ + Containers: true, + AllowStart: true, + AllowStop: true, + AllowRestart: true, + AllowPause: true, + AllowUnpause: true, + AllowKill: true, + } + for _, action := range []string{"start", "stop", "restart", "pause", "unpause", "kill"} { + if !service.Allow("containers", http.MethodPost, action) { + t.Errorf("explicit lifecycle action %q was denied while post=false", action) + } + } + if service.Allow("containers", http.MethodPost, "rename") { + t.Fatal("generic container write was allowed while post=false") + } +} + +func TestSensitiveContainerReadsAreExplicitlyGated(t *testing.T) { + service := &ServiceConfig{Containers: true, Post: true} + tests := []struct { + action string + allow *bool + }{ + {"archive", &service.AllowArchive}, + {"changes", &service.AllowChanges}, + {"export", &service.AllowExport}, + {"logs", &service.AllowLogs}, + {"top", &service.AllowTop}, + } + for _, tt := range tests { + if service.Allow("containers", http.MethodGet, tt.action) { + t.Errorf("sensitive read %q was allowed by containers alone", tt.action) + } + *tt.allow = true + if !service.Allow("containers", http.MethodGet, tt.action) { + t.Errorf("sensitive read %q was denied after explicit grant", tt.action) + } + *tt.allow = false + } + service.AllowArchive = true + service.Post = false + if service.Allow("containers", http.MethodPut, "archive") { + t.Fatal("archive upload was allowed while post=false") + } +} + func TestRewriteAPIVersion(t *testing.T) { tests := map[string]string{ "/containers/json": "/v1.51/containers/json", diff --git a/src/policy.go b/src/policy.go index 1f3a8f7..f204aad 100644 --- a/src/policy.go +++ b/src/policy.go @@ -65,12 +65,28 @@ func classifyPath(path string) (feature string, action string) { segs := strings.Split(strings.Trim(p, "/"), "/") if len(segs) >= 3 { switch segs[2] { + case "archive": + return "containers", "archive" + case "changes": + return "containers", "changes" + case "export": + return "containers", "export" + case "logs": + return "containers", "logs" + case "pause": + return "containers", "pause" case "start": return "containers", "start" case "stop": return "containers", "stop" case "restart": return "containers", "restart" + case "top": + return "containers", "top" + case "unpause": + return "containers", "unpause" + case "kill": + return "containers", "kill" case "exec": return "containers", "exec" } @@ -207,26 +223,58 @@ func (s *ServiceConfig) Allow(feature, method, action string) bool { return false } - if !isWrite { - return true + // Sensitive container reads require their own explicit permission, even + // when the containers family is enabled. This prevents accidental log or + // filesystem disclosure from a broad read profile. + if feature == "containers" { + switch action { + case "archive": + if !s.AllowArchive { + return false + } + case "changes": + if !s.AllowChanges { + return false + } + case "export": + if !s.AllowExport { + return false + } + case "logs": + if !s.AllowLogs { + return false + } + case "top": + if !s.AllowTop { + return false + } + } } - if !s.Post { - return false + if !isWrite { + return true } + // Explicit lifecycle permissions are deliberately narrower than post and + // remain usable while post=false. Generic writes still require post=true. if feature == "containers" { switch action { + case "pause": + return s.AllowPause case "start": return s.AllowStart case "stop": return s.AllowStop case "restart": return s.AllowRestart + case "unpause": + return s.AllowUnpause + case "kill": + return s.AllowKill } } - return true + return s.Post } // ----------------------------- diff --git a/src/types.go b/src/types.go index 07572fd..cdeb67b 100644 --- a/src/types.go +++ b/src/types.go @@ -33,9 +33,17 @@ type ServiceConfig struct { Volumes bool Post bool + AllowArchive bool + AllowChanges bool + AllowExport bool + AllowLogs bool + AllowPause bool + AllowRestart bool AllowStart bool AllowStop bool - AllowRestart bool + AllowTop bool + AllowUnpause bool + AllowKill bool APIRewrite string // Version d'API à forcer (ex: "1.51") From 137e76dcbaf5789a9fb2df4c85a6be6e4c5dcefc Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:31:25 +0200 Subject: [PATCH 02/23] update Docker metadata action to Node 24 runtime --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index eec1fea..d5123f6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -77,7 +77,7 @@ jobs: - name: Generate image metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} From 94a1c933f117fbd3046b88942850f0e7d1a2b99f Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:38:01 +0200 Subject: [PATCH 03/23] add scoped allow_all container shortcut --- README.fr.md | 13 ++++++++++++- README.md | 13 ++++++++++++- src/config.go | 4 +++- src/main_test.go | 20 ++++++++++++++++++++ src/policy.go | 22 +++++++++++----------- src/types.go | 1 + 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/README.fr.md b/README.fr.md index bbcecbb..0aab7e0 100644 --- a/README.fr.md +++ b/README.fr.md @@ -235,7 +235,9 @@ Les écritures génériques (`POST`, `PUT`, `PATCH`, `DELETE`) restent interdite | `allow_unpause` | `/containers/{id}/unpause` | non | | `allow_kill` | `/containers/{id}/kill` | non | -Toutes ces options valent `false` par défaut. `allow_restarts` reste un alias de `allow_restart` ; contrairement au commutateur groupé de LinuxServer, il n'accorde pas implicitement `stop` ou `kill`. Ces droits doivent être explicitement ajoutés. Il n'existe volontairement aucun `allow_all` global : `post` contrôle les écritures larges, les familles d'API restent explicites et les routes sensibles conservent leurs propres droits. +Toutes ces options valent `false` par défaut. `allow_restarts` reste un alias de `allow_restart` ; contrairement au commutateur groupé de LinuxServer, il n'accorde pas implicitement `stop` ou `kill`. Ces droits doivent être explicitement ajoutés. + +`allow_all: true` est un raccourci pour toutes les options `allow_*` du tableau. Ce n'est volontairement **pas** un droit Docker global : il n'active ni `containers`, ni `post`, ni une autre famille d'API et ne contourne pas les portées de conteneurs. L'envoi d'une archive et les autres écritures génériques nécessitent donc toujours `post: true`. Exemple minimal limité au cycle de vie : @@ -252,6 +254,15 @@ container-operator: allow_unpause: true ``` +Tous les contrôles ciblés des conteneurs, sans activer les autres familles Docker : + +```yaml +container-manager: + containers: true + post: true + allow_all: true +``` + `apirewrite` force une version d'API Docker pour un profil, par exemple `apirewrite: "1.53"`. ## Portée des conteneurs diff --git a/README.md b/README.md index 41065dd..3a3e11a 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,9 @@ Generic write methods (`POST`, `PUT`, `PATCH`, `DELETE`) remain forbidden even i | `allow_unpause` | `/containers/{id}/unpause` | no | | `allow_kill` | `/containers/{id}/kill` | no | -All these options default to `false`. `allow_restarts` remains an alias for `allow_restart`; unlike LinuxServer's grouped switch, it deliberately does not silently grant `stop` or `kill`. Grant those operations explicitly when required. There is intentionally no global `allow_all`: broad writes require `post`, API families remain explicit, and sensitive container routes keep their own switches. +All these options default to `false`. `allow_restarts` remains an alias for `allow_restart`; unlike LinuxServer's grouped switch, it deliberately does not silently grant `stop` or `kill`. Grant those operations explicitly when required. + +`allow_all: true` is a convenience shortcut for every `allow_*` option in the table. It is deliberately **not** a global Docker permission: it does not enable `containers`, `post`, any other API family, or bypass container scopes. Archive upload and other generic writes therefore still require `post: true`. Minimal lifecycle-only example: @@ -251,6 +253,15 @@ container-operator: allow_unpause: true ``` +Complete targeted container controls, without enabling unrelated Docker API families: + +```yaml +container-manager: + containers: true + post: true + allow_all: true +``` + `apirewrite` forces a Docker API version for a profile, for example `apirewrite: "1.53"`. ## Container scope diff --git a/src/config.go b/src/config.go index dcaa4e4..64d7e95 100644 --- a/src/config.go +++ b/src/config.go @@ -113,6 +113,8 @@ func applyFlagValue(s *ServiceConfig, flag, value string) { s.Volumes = b case "post": s.Post = b + case "allow_all": + s.AllowAll = b case "allow_archive": s.AllowArchive = b case "allow_changes": @@ -454,7 +456,7 @@ var knownProfileKeys = map[string]struct{}{ "build": {}, "commit": {}, "configs": {}, "containers": {}, "distribution": {}, "exec": {}, "images": {}, "networks": {}, "nodes": {}, "plugins": {}, "secrets": {}, "services": {}, "session": {}, "swarm": {}, "system": {}, "tasks": {}, "volumes": {}, - "post": {}, "allow_archive": {}, "allow_changes": {}, "allow_export": {}, "allow_logs": {}, + "post": {}, "allow_all": {}, "allow_archive": {}, "allow_changes": {}, "allow_export": {}, "allow_logs": {}, "allow_pause": {}, "allow_restart": {}, "allow_restarts": {}, "allow_start": {}, "allow_stop": {}, "allow_top": {}, "allow_unpause": {}, "allow_kill": {}, "apirewrite": {}, "container_scope": {}, "allowed_containers": {}, "blocked_containers": {}, "container_rules": {}, diff --git a/src/main_test.go b/src/main_test.go index a6aaa86..94562dd 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -169,6 +169,26 @@ func TestSensitiveContainerReadsAreExplicitlyGated(t *testing.T) { } } +func TestAllowAllOnlyExpandsTargetedContainerPermissions(t *testing.T) { + service := &ServiceConfig{Containers: true, AllowAll: true} + for _, action := range []string{"archive", "changes", "export", "logs", "top"} { + if !service.Allow("containers", http.MethodGet, action) { + t.Errorf("allow_all did not grant container read %q", action) + } + } + for _, action := range []string{"start", "stop", "restart", "pause", "unpause", "kill"} { + if !service.Allow("containers", http.MethodPost, action) { + t.Errorf("allow_all did not grant lifecycle action %q", action) + } + } + if service.Allow("containers", http.MethodPost, "rename") { + t.Fatal("allow_all unexpectedly bypassed post for a generic write") + } + if service.Allow("images", http.MethodGet, "") { + t.Fatal("allow_all unexpectedly enabled another API family") + } +} + func TestRewriteAPIVersion(t *testing.T) { tests := map[string]string{ "/containers/json": "/v1.51/containers/json", diff --git a/src/policy.go b/src/policy.go index f204aad..2e6c397 100644 --- a/src/policy.go +++ b/src/policy.go @@ -229,23 +229,23 @@ func (s *ServiceConfig) Allow(feature, method, action string) bool { if feature == "containers" { switch action { case "archive": - if !s.AllowArchive { + if !s.AllowAll && !s.AllowArchive { return false } case "changes": - if !s.AllowChanges { + if !s.AllowAll && !s.AllowChanges { return false } case "export": - if !s.AllowExport { + if !s.AllowAll && !s.AllowExport { return false } case "logs": - if !s.AllowLogs { + if !s.AllowAll && !s.AllowLogs { return false } case "top": - if !s.AllowTop { + if !s.AllowAll && !s.AllowTop { return false } } @@ -260,17 +260,17 @@ func (s *ServiceConfig) Allow(feature, method, action string) bool { if feature == "containers" { switch action { case "pause": - return s.AllowPause + return s.AllowAll || s.AllowPause case "start": - return s.AllowStart + return s.AllowAll || s.AllowStart case "stop": - return s.AllowStop + return s.AllowAll || s.AllowStop case "restart": - return s.AllowRestart + return s.AllowAll || s.AllowRestart case "unpause": - return s.AllowUnpause + return s.AllowAll || s.AllowUnpause case "kill": - return s.AllowKill + return s.AllowAll || s.AllowKill } } diff --git a/src/types.go b/src/types.go index cdeb67b..d7354a7 100644 --- a/src/types.go +++ b/src/types.go @@ -33,6 +33,7 @@ type ServiceConfig struct { Volumes bool Post bool + AllowAll bool AllowArchive bool AllowChanges bool AllowExport bool From 1d51b8f5e805c29cb46c10d16815c22114bb9e0a Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:43:44 +0200 Subject: [PATCH 04/23] preserve scoped container lifecycle events --- src/main_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ src/policy.go | 27 ++++++++++++++++++-- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/main_test.go b/src/main_test.go index 94562dd..7b04e6e 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -6,10 +6,26 @@ import ( "log" "net/http" "strings" + "sync" "testing" "time" ) +type blockingReadCloser struct { + closed chan struct{} + once sync.Once +} + +func (b *blockingReadCloser) Read([]byte) (int, error) { + <-b.closed + return 0, io.EOF +} + +func (b *blockingReadCloser) Close() error { + b.once.Do(func() { close(b.closed) }) + return nil +} + func TestParseConfigUsesEnvironment(t *testing.T) { t.Setenv("PROXY_PORT", "4242") t.Setenv("DOCKER_SOCKET_PATH", "/run/custom.sock") @@ -458,3 +474,51 @@ func TestFilterEventsPreservesDockerEventFields(t *testing.T) { t.Fatalf("unexpected filtered events: %s", got) } } + +func TestFilterEventsUsesEventMetadataForUncachedContainers(t *testing.T) { + service := &ServiceConfig{ + ContainerScope: "blacklist", + BlockedContainers: map[string]struct{}{"docker-socket-proxy": {}}, + } + for _, tc := range []struct { + name string + action string + want bool + }{ + {name: "whoami", action: "create", want: true}, + {name: "whoami", action: "destroy", want: true}, + {name: "docker-socket-proxy", action: "create", want: false}, + } { + t.Run(tc.action+"/"+tc.name, func(t *testing.T) { + cfg := &ProxyConfig{containersByRef: map[string]dockerContainerMeta{}} + body := `{"Type":"container","Action":"` + tc.action + `","Actor":{"ID":"new-id","Attributes":{"name":"` + tc.name + `","scope-label":"kept"}}}` + "\n" + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} + filterEventsResponse(resp, cfg, service) + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if (len(got) > 0) != tc.want { + t.Fatalf("event forwarded = %v, want %v; body=%s", len(got) > 0, tc.want, got) + } + }) + } +} + +func TestFilterEventsClosesDockerBodyWhenClientDisconnects(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + body := &blockingReadCloser{closed: make(chan struct{})} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://proxy/events", nil) + if err != nil { + t.Fatal(err) + } + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: body, Request: req} + filterEventsResponse(resp, &ProxyConfig{}, &ServiceConfig{ContainerScope: "blacklist"}) + cancel() + select { + case <-body.closed: + case <-time.After(time.Second): + t.Fatal("Docker event body remained open after client cancellation") + } + _ = resp.Body.Close() +} diff --git a/src/policy.go b/src/policy.go index 2e6c397..aa8a15d 100644 --- a/src/policy.go +++ b/src/policy.go @@ -558,12 +558,18 @@ func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.C func filterContainerListResponse(resp *http.Response, cfg *ProxyConfig, service *ServiceConfig) { originalBody := resp.Body + ctx := context.Background() + if resp.Request != nil { + ctx = resp.Request.Context() + } + stopClose := context.AfterFunc(ctx, func() { _ = originalBody.Close() }) reader, writer := io.Pipe() resp.Body = reader resp.ContentLength = -1 resp.Header.Del("Content-Length") go func() { + defer stopClose() defer originalBody.Close() defer writer.Close() decoder := json.NewDecoder(originalBody) @@ -610,12 +616,18 @@ func filterContainerListResponse(resp *http.Response, cfg *ProxyConfig, service func filterEventsResponse(resp *http.Response, cfg *ProxyConfig, service *ServiceConfig) { originalBody := resp.Body + ctx := context.Background() + if resp.Request != nil { + ctx = resp.Request.Context() + } + stopClose := context.AfterFunc(ctx, func() { _ = originalBody.Close() }) reader, writer := io.Pipe() resp.Body = reader resp.ContentLength = -1 resp.Header.Del("Content-Length") go func() { + defer stopClose() defer originalBody.Close() defer writer.Close() decoder := json.NewDecoder(originalBody) @@ -635,8 +647,19 @@ func filterEventsResponse(resp *http.Response, cfg *ProxyConfig, service *Servic if event.Type != "container" { continue } - meta, ok := cfg.GetContainer(event.Actor.ID) - if !ok || !service.AllowsContainer(meta) { + meta := dockerContainerMeta{ + ID: event.Actor.ID, + Name: normalizeContainerRef(event.Actor.Attributes["name"]), + Labels: event.Actor.Attributes, + } + if meta.Name == "" { + var ok bool + meta, ok = cfg.GetContainer(event.Actor.ID) + if !ok { + continue + } + } + if !service.AllowsContainer(meta) { continue } if _, err := writer.Write(raw); err != nil { From f530da89909c2042c9e9f834f18f1820eeeebfa6 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:49:39 +0200 Subject: [PATCH 05/23] enforce explicit container authorization matrix --- README.fr.md | 3 + README.md | 3 + config/profiles.yml | 7 ++ src/config.go | 4 +- src/main_test.go | 52 ++++++++- src/policy.go | 273 ++++++++++++++++++-------------------------- src/types.go | 1 + 7 files changed, 181 insertions(+), 162 deletions(-) diff --git a/README.fr.md b/README.fr.md index 0aab7e0..de2c8f9 100644 --- a/README.fr.md +++ b/README.fr.md @@ -226,6 +226,7 @@ Les écritures génériques (`POST`, `PUT`, `PATCH`, `DELETE`) restent interdite | `allow_archive` | `/containers/{id}/archive` | GET/HEAD : non ; PUT : oui | | `allow_changes` | `/containers/{id}/changes` | non | | `allow_export` | `/containers/{id}/export` | non | +| `allow_inspect` | `/containers/{id}/json` | non | | `allow_logs` | `/containers/{id}/logs` | non | | `allow_top` | `/containers/{id}/top` | non | | `allow_start` | `/containers/{id}/start` | non | @@ -237,6 +238,8 @@ Les écritures génériques (`POST`, `PUT`, `PATCH`, `DELETE`) restent interdite Toutes ces options valent `false` par défaut. `allow_restarts` reste un alias de `allow_restart` ; contrairement au commutateur groupé de LinuxServer, il n'accorde pas implicitement `stop` ou `kill`. Ces droits doivent être explicitement ajoutés. +La création d'une session exec via `POST /containers/{id}/exec` exige les trois droits explicites `containers: true`, `exec: true` et `post: true`. `allow_all` n'active jamais `exec`. + `allow_all: true` est un raccourci pour toutes les options `allow_*` du tableau. Ce n'est volontairement **pas** un droit Docker global : il n'active ni `containers`, ni `post`, ni une autre famille d'API et ne contourne pas les portées de conteneurs. L'envoi d'une archive et les autres écritures génériques nécessitent donc toujours `post: true`. Exemple minimal limité au cycle de vie : diff --git a/README.md b/README.md index 3a3e11a..7913109 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ Generic write methods (`POST`, `PUT`, `PATCH`, `DELETE`) remain forbidden even i | `allow_archive` | `/containers/{id}/archive` | GET/HEAD: no; PUT: yes | | `allow_changes` | `/containers/{id}/changes` | no | | `allow_export` | `/containers/{id}/export` | no | +| `allow_inspect` | `/containers/{id}/json` | no | | `allow_logs` | `/containers/{id}/logs` | no | | `allow_top` | `/containers/{id}/top` | no | | `allow_start` | `/containers/{id}/start` | no | @@ -236,6 +237,8 @@ Generic write methods (`POST`, `PUT`, `PATCH`, `DELETE`) remain forbidden even i All these options default to `false`. `allow_restarts` remains an alias for `allow_restart`; unlike LinuxServer's grouped switch, it deliberately does not silently grant `stop` or `kill`. Grant those operations explicitly when required. +Creating an exec session with `POST /containers/{id}/exec` requires all three explicit grants: `containers: true`, `exec: true`, and `post: true`. `allow_all` never enables `exec`. + `allow_all: true` is a convenience shortcut for every `allow_*` option in the table. It is deliberately **not** a global Docker permission: it does not enable `containers`, `post`, any other API family, or bypass container scopes. Archive upload and other generic writes therefore still require `post: true`. Minimal lifecycle-only example: diff --git a/config/profiles.yml b/config/profiles.yml index 664163a..058544b 100644 --- a/config/profiles.yml +++ b/config/profiles.yml @@ -2,6 +2,7 @@ home: ping: true version: true containers: true + allow_inspect: true # `all` est le comportement historique : ce profil dispose de ses droits sur # tous les conteneurs. @@ -19,6 +20,7 @@ administrator: allow_archive: true allow_changes: true allow_export: true + allow_inspect: true allow_logs: true allow_top: true container_scope: all @@ -45,6 +47,7 @@ portainer: allow_archive: true allow_changes: true allow_export: true + allow_inspect: true allow_logs: true allow_top: true @@ -54,6 +57,7 @@ traefik-manager: ping: true version: true containers: true + allow_inspect: true post: true allow_start: true allow_stop: true @@ -67,6 +71,7 @@ operator: ping: true version: true containers: true + allow_inspect: true events: true post: true allow_start: true @@ -85,6 +90,7 @@ auditor: ping: true version: true containers: true + allow_inspect: true container_scope: all container_rules: - name: docker-socket-proxy @@ -115,6 +121,7 @@ dockhand: allow_archive: true allow_changes: true allow_export: true + allow_inspect: true allow_logs: true allow_top: true container_scope: blacklist diff --git a/src/config.go b/src/config.go index 64d7e95..21f3b9d 100644 --- a/src/config.go +++ b/src/config.go @@ -121,6 +121,8 @@ func applyFlagValue(s *ServiceConfig, flag, value string) { s.AllowChanges = b case "allow_export": s.AllowExport = b + case "allow_inspect": + s.AllowInspect = b case "allow_logs": s.AllowLogs = b case "allow_pause": @@ -456,7 +458,7 @@ var knownProfileKeys = map[string]struct{}{ "build": {}, "commit": {}, "configs": {}, "containers": {}, "distribution": {}, "exec": {}, "images": {}, "networks": {}, "nodes": {}, "plugins": {}, "secrets": {}, "services": {}, "session": {}, "swarm": {}, "system": {}, "tasks": {}, "volumes": {}, - "post": {}, "allow_all": {}, "allow_archive": {}, "allow_changes": {}, "allow_export": {}, "allow_logs": {}, + "post": {}, "allow_all": {}, "allow_archive": {}, "allow_changes": {}, "allow_export": {}, "allow_inspect": {}, "allow_logs": {}, "allow_pause": {}, "allow_restart": {}, "allow_restarts": {}, "allow_start": {}, "allow_stop": {}, "allow_top": {}, "allow_unpause": {}, "allow_kill": {}, "apirewrite": {}, "container_scope": {}, "allowed_containers": {}, "blocked_containers": {}, "container_rules": {}, diff --git a/src/main_test.go b/src/main_test.go index 7b04e6e..0035576 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -87,7 +87,12 @@ func TestClassifyPath(t *testing.T) { {"/v1.51/containers/id/changes", "containers", "changes"}, {"/v1.51/containers/id/archive", "containers", "archive"}, {"/v1.51/containers/id/export", "containers", "export"}, + {"/v1.51/containers/id/json", "containers", "inspect"}, {"/v1.51/exec/id/start", "exec", ""}, + {"/engine/api/v1.51/containers/json", "containers", ""}, + {"/containersfoo/json", "unknown", ""}, + {"/eventsfoo", "unknown", ""}, + {"/containers/../secrets/id", "unknown", ""}, {"/not-a-docker-endpoint", "unknown", ""}, } @@ -165,6 +170,7 @@ func TestSensitiveContainerReadsAreExplicitlyGated(t *testing.T) { {"archive", &service.AllowArchive}, {"changes", &service.AllowChanges}, {"export", &service.AllowExport}, + {"inspect", &service.AllowInspect}, {"logs", &service.AllowLogs}, {"top", &service.AllowTop}, } @@ -187,7 +193,7 @@ func TestSensitiveContainerReadsAreExplicitlyGated(t *testing.T) { func TestAllowAllOnlyExpandsTargetedContainerPermissions(t *testing.T) { service := &ServiceConfig{Containers: true, AllowAll: true} - for _, action := range []string{"archive", "changes", "export", "logs", "top"} { + for _, action := range []string{"archive", "changes", "export", "inspect", "logs", "top"} { if !service.Allow("containers", http.MethodGet, action) { t.Errorf("allow_all did not grant container read %q", action) } @@ -205,6 +211,50 @@ func TestAllowAllOnlyExpandsTargetedContainerPermissions(t *testing.T) { } } +func TestContainerExecRequiresExecAndPost(t *testing.T) { + for _, tt := range []struct { + name string + service ServiceConfig + want bool + }{ + {"neither", ServiceConfig{Containers: true}, false}, + {"post only", ServiceConfig{Containers: true, Post: true}, false}, + {"exec only", ServiceConfig{Containers: true, Exec: true}, false}, + {"both", ServiceConfig{Containers: true, Exec: true, Post: true}, true}, + {"allow all without exec", ServiceConfig{Containers: true, AllowAll: true, Post: true}, false}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := tt.service.Allow("containers", http.MethodPost, "exec"); got != tt.want { + t.Fatalf("Allow(container exec) = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFeaturePermissionMatrix(t *testing.T) { + tests := map[string]ServiceConfig{ + "ping": {Ping: true}, "version": {Version: true}, "info": {Info: true}, + "events": {Events: true}, "auth": {Auth: true}, "build": {Build: true}, + "commit": {Commit: true}, "configs": {Configs: true}, "containers": {Containers: true}, + "distribution": {Distribution: true}, "exec": {Exec: true}, "images": {Images: true}, + "networks": {Networks: true}, "nodes": {Nodes: true}, "plugins": {Plugins: true}, + "secrets": {Secrets: true}, "services": {Services: true}, "session": {Session: true}, + "swarm": {Swarm: true}, "system": {System: true}, "tasks": {Tasks: true}, + "volumes": {Volumes: true}, + } + if len(tests) != len(featurePermissions) { + t.Fatalf("feature matrix has %d cases for %d permissions", len(tests), len(featurePermissions)) + } + for feature, granted := range tests { + if (&ServiceConfig{}).Allow(feature, http.MethodGet, "") { + t.Errorf("%s allowed without its feature grant", feature) + } + if !granted.Allow(feature, http.MethodGet, "") { + t.Errorf("%s denied with its feature grant", feature) + } + } +} + func TestRewriteAPIVersion(t *testing.T) { tests := map[string]string{ "/containers/json": "/v1.51/containers/json", diff --git a/src/policy.go b/src/policy.go index aa8a15d..25db886 100644 --- a/src/policy.go +++ b/src/policy.go @@ -8,9 +8,39 @@ import ( "io" "net/http" "net/url" + pathpkg "path" "strings" ) +func hasDotDotSegment(p string) bool { + for _, segment := range strings.Split(p, "/") { + if segment == ".." { + return true + } + } + return false +} + +func normalizedDockerPath(rawPath string) (string, bool) { + if i := strings.Index(rawPath, "?"); i >= 0 { + rawPath = rawPath[:i] + } + if hasDotDotSegment(rawPath) { + return "", false + } + p := pathpkg.Clean("/" + strings.TrimPrefix(rawPath, "/")) + if p == "/engine/api" { + p = "/" + } else if strings.HasPrefix(p, "/engine/api/") { + p = strings.TrimPrefix(p, "/engine/api") + } + return trimAPIVersion(p), true +} + +func routeFamily(p, family string) bool { + return p == family || strings.HasPrefix(p, family+"/") +} + func trimAPIVersion(path string) string { if !strings.HasPrefix(path, "/v") { return path @@ -34,14 +64,9 @@ func trimAPIVersion(path string) string { } func classifyPath(path string) (feature string, action string) { - if i := strings.Index(path, "?"); i >= 0 { - path = path[:i] - } - - p := trimAPIVersion(path) - - if strings.HasPrefix(p, "/engine/api/") { - p = strings.TrimPrefix(p, "/engine/api") + p, ok := normalizedDockerPath(path) + if !ok { + return "unknown", "" } switch { @@ -51,17 +76,17 @@ func classifyPath(path string) (feature string, action string) { return "version", "" case p == "/info" || strings.HasPrefix(p, "/info/"): return "info", "" - case strings.HasPrefix(p, "/events"): + case routeFamily(p, "/events"): return "events", "" - case strings.HasPrefix(p, "/auth"): + case routeFamily(p, "/auth"): return "auth", "" - case strings.HasPrefix(p, "/build"): + case routeFamily(p, "/build"): return "build", "" - case strings.HasPrefix(p, "/commit"): + case routeFamily(p, "/commit"): return "commit", "" - case strings.HasPrefix(p, "/configs"): + case routeFamily(p, "/configs"): return "configs", "" - case strings.HasPrefix(p, "/containers"): + case routeFamily(p, "/containers"): segs := strings.Split(strings.Trim(p, "/"), "/") if len(segs) >= 3 { switch segs[2] { @@ -89,39 +114,84 @@ func classifyPath(path string) (feature string, action string) { return "containers", "kill" case "exec": return "containers", "exec" + case "json": + return "containers", "inspect" } } return "containers", "" - case strings.HasPrefix(p, "/distribution"): + case routeFamily(p, "/distribution"): return "distribution", "" - case strings.HasPrefix(p, "/exec"): + case routeFamily(p, "/exec"): return "exec", "" - case strings.HasPrefix(p, "/images"): + case routeFamily(p, "/images"): return "images", "" - case strings.HasPrefix(p, "/networks"): + case routeFamily(p, "/networks"): return "networks", "" - case strings.HasPrefix(p, "/nodes"): + case routeFamily(p, "/nodes"): return "nodes", "" - case strings.HasPrefix(p, "/plugins"): + case routeFamily(p, "/plugins"): return "plugins", "" - case strings.HasPrefix(p, "/secrets"): + case routeFamily(p, "/secrets"): return "secrets", "" - case strings.HasPrefix(p, "/services"): + case routeFamily(p, "/services"): return "services", "" - case strings.HasPrefix(p, "/session"): + case routeFamily(p, "/session"): return "session", "" - case strings.HasPrefix(p, "/swarm"): + case routeFamily(p, "/swarm"): return "swarm", "" - case strings.HasPrefix(p, "/system"): + case routeFamily(p, "/system"): return "system", "" - case strings.HasPrefix(p, "/tasks"): + case routeFamily(p, "/tasks"): return "tasks", "" - case strings.HasPrefix(p, "/volumes"): + case routeFamily(p, "/volumes"): return "volumes", "" } return "unknown", "" } +var featurePermissions = map[string]func(*ServiceConfig) bool{ + "ping": func(s *ServiceConfig) bool { return s.Ping }, + "version": func(s *ServiceConfig) bool { return s.Version }, + "info": func(s *ServiceConfig) bool { return s.Info }, + "events": func(s *ServiceConfig) bool { return s.Events }, + "auth": func(s *ServiceConfig) bool { return s.Auth }, + "build": func(s *ServiceConfig) bool { return s.Build }, + "commit": func(s *ServiceConfig) bool { return s.Commit }, + "configs": func(s *ServiceConfig) bool { return s.Configs }, + "containers": func(s *ServiceConfig) bool { return s.Containers }, + "distribution": func(s *ServiceConfig) bool { return s.Distribution }, + "exec": func(s *ServiceConfig) bool { return s.Exec }, + "images": func(s *ServiceConfig) bool { return s.Images }, + "networks": func(s *ServiceConfig) bool { return s.Networks }, + "nodes": func(s *ServiceConfig) bool { return s.Nodes }, + "plugins": func(s *ServiceConfig) bool { return s.Plugins }, + "secrets": func(s *ServiceConfig) bool { return s.Secrets }, + "services": func(s *ServiceConfig) bool { return s.Services }, + "session": func(s *ServiceConfig) bool { return s.Session }, + "swarm": func(s *ServiceConfig) bool { return s.Swarm }, + "system": func(s *ServiceConfig) bool { return s.System }, + "tasks": func(s *ServiceConfig) bool { return s.Tasks }, + "volumes": func(s *ServiceConfig) bool { return s.Volumes }, +} + +var containerReadPermissions = map[string]func(*ServiceConfig) bool{ + "archive": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowArchive }, + "changes": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowChanges }, + "export": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowExport }, + "inspect": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowInspect }, + "logs": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowLogs }, + "top": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowTop }, +} + +var containerWritePermissions = map[string]func(*ServiceConfig) bool{ + "pause": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowPause }, + "start": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowStart }, + "stop": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowStop }, + "restart": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowRestart }, + "unpause": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowUnpause }, + "kill": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowKill }, +} + func (s *ServiceConfig) Allow(feature, method, action string) bool { isRead := method == http.MethodGet || method == http.MethodHead isWrite := method == http.MethodPost || method == http.MethodPut || @@ -130,124 +200,14 @@ func (s *ServiceConfig) Allow(feature, method, action string) bool { return false } - switch feature { - case "ping": - if !s.Ping { - return false - } - case "version": - if !s.Version { - return false - } - case "info": - if !s.Info { - return false - } - case "events": - if !s.Events { - return false - } - case "auth": - if !s.Auth { - return false - } - case "build": - if !s.Build { - return false - } - case "commit": - if !s.Commit { - return false - } - case "configs": - if !s.Configs { - return false - } - case "containers": - if !s.Containers { - return false - } - case "distribution": - if !s.Distribution { - return false - } - case "exec": - if !s.Exec { - return false - } - case "images": - if !s.Images { - return false - } - case "networks": - if !s.Networks { - return false - } - case "nodes": - if !s.Nodes { - return false - } - case "plugins": - if !s.Plugins { - return false - } - case "secrets": - if !s.Secrets { - return false - } - case "services": - if !s.Services { - return false - } - case "session": - if !s.Session { - return false - } - case "swarm": - if !s.Swarm { - return false - } - case "system": - if !s.System { - return false - } - case "tasks": - if !s.Tasks { - return false - } - case "volumes": - if !s.Volumes { - return false - } - default: + featureAllowed, known := featurePermissions[feature] + if !known || !featureAllowed(s) { return false } - // Sensitive container reads require their own explicit permission, even - // when the containers family is enabled. This prevents accidental log or - // filesystem disclosure from a broad read profile. - if feature == "containers" { - switch action { - case "archive": - if !s.AllowAll && !s.AllowArchive { - return false - } - case "changes": - if !s.AllowAll && !s.AllowChanges { - return false - } - case "export": - if !s.AllowAll && !s.AllowExport { - return false - } - case "logs": - if !s.AllowAll && !s.AllowLogs { - return false - } - case "top": - if !s.AllowAll && !s.AllowTop { - return false - } + if feature == "containers" && isRead { + if permission, protected := containerReadPermissions[action]; protected { + return permission(s) } } @@ -255,22 +215,15 @@ func (s *ServiceConfig) Allow(feature, method, action string) bool { return true } - // Explicit lifecycle permissions are deliberately narrower than post and - // remain usable while post=false. Generic writes still require post=true. if feature == "containers" { - switch action { - case "pause": - return s.AllowAll || s.AllowPause - case "start": - return s.AllowAll || s.AllowStart - case "stop": - return s.AllowAll || s.AllowStop - case "restart": - return s.AllowAll || s.AllowRestart - case "unpause": - return s.AllowAll || s.AllowUnpause - case "kill": - return s.AllowAll || s.AllowKill + if permission, targeted := containerWritePermissions[action]; targeted { + return permission(s) + } + // Creating an exec session is both a generic Docker write and an exec + // capability. Requiring both permissions prevents post from silently + // becoming remote command execution. + if action == "exec" { + return s.Post && s.Exec } } @@ -376,9 +329,9 @@ func resolveExecContainer(ctx context.Context, cfg *ProxyConfig, client *http.Cl } func pathWithoutAPIVersion(path string) string { - p := trimAPIVersion(path) - if strings.HasPrefix(p, "/engine/api/") { - return strings.TrimPrefix(p, "/engine/api") + p, ok := normalizedDockerPath(path) + if !ok { + return "" } return p } diff --git a/src/types.go b/src/types.go index d7354a7..20bf769 100644 --- a/src/types.go +++ b/src/types.go @@ -37,6 +37,7 @@ type ServiceConfig struct { AllowArchive bool AllowChanges bool AllowExport bool + AllowInspect bool AllowLogs bool AllowPause bool AllowRestart bool From 2f16c5253c16127da322dd54cbf6fd96da2f39de Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:52:11 +0200 Subject: [PATCH 06/23] bound caches and harden proxy lifecycle --- src/config.go | 2 +- src/docker_discovery.go | 50 ++++++++------------------------ src/main.go | 17 +++++++++-- src/main_test.go | 53 ++++++++++++++++++++++++++++++++-- src/policy.go | 3 ++ src/proxy.go | 16 +++++------ src/types.go | 64 +++++++++++++++++++++++++++++++++++------ 7 files changed, 144 insertions(+), 61 deletions(-) diff --git a/src/config.go b/src/config.go index 21f3b9d..5c868fa 100644 --- a/src/config.go +++ b/src/config.go @@ -355,7 +355,7 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { ipToRole: make(map[string]string), selfNetworks: make(map[string]struct{}), containersByRef: make(map[string]dockerContainerMeta), - execToContainer: make(map[string]string), + execToContainer: make(map[string]dockerExecCacheEntry), } for _, arg := range args { diff --git a/src/docker_discovery.go b/src/docker_discovery.go index 0ca6837..3d0f6f1 100644 --- a/src/docker_discovery.go +++ b/src/docker_discovery.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "sort" "strings" "sync" "time" @@ -68,14 +69,7 @@ func hashNetworks(nets map[string]struct{}) string { return "" } keys := keysOfSet(nets) - // Tri pour avoir un hash stable - for i := 0; i < len(keys)-1; i++ { - for j := i + 1; j < len(keys); j++ { - if keys[i] > keys[j] { - keys[i], keys[j] = keys[j], keys[i] - } - } - } + sort.Strings(keys) return strings.Join(keys, ",") } @@ -122,15 +116,10 @@ func getSelfNetworksWithCache(ctx context.Context, cfg *ProxyConfig, client *htt newHash := hashNetworks(nets) - // Si le hash n'a pas changé, on garde le cache - if cfg.selfNetworksHash != "" && cfg.selfNetworksHash == newHash { + if !cfg.updateSelfNetworks(nets, newHash) { logger.Printf("[discover] self networks unchanged (cache hit)") return nil } - - // Mise à jour du cache - cfg.selfNetworks = nets - cfg.selfNetworksHash = newHash logger.Printf("[discover] self networks updated (cache miss)") return nil @@ -180,6 +169,7 @@ func discoverOnce(ctx context.Context, cfg *ProxyConfig, client *http.Client, lo newMap := make(map[string]string) newIndex := buildContainerIndex(containers) + selfNetworks := cfg.getSelfNetworks() for _, c := range containers { if c.State != "running" { @@ -202,14 +192,14 @@ func discoverOnce(ctx context.Context, cfg *ProxyConfig, client *http.Client, lo svc := cfg.GetService(role) if svc == nil { logger.Printf("[discover] container=%s id=%s role=%s -> no matching profile, skipping", - name, c.ID[:12], role) + name, shortID(c.ID), role) continue } var ips []string for netName, nw := range c.NetworkSettings.Networks { - if len(cfg.selfNetworks) > 0 { - if _, ok := cfg.selfNetworks[netName]; !ok { + if len(selfNetworks) > 0 { + if _, ok := selfNetworks[netName]; !ok { continue } } @@ -221,7 +211,7 @@ func discoverOnce(ctx context.Context, cfg *ProxyConfig, client *http.Client, lo } if len(ips) > 0 { - logger.Printf("[discover] container=%s id=%s role=%s ips=%v", name, c.ID[:12], role, ips) + logger.Printf("[discover] container=%s id=%s role=%s ips=%v", name, shortID(c.ID), role, ips) } } @@ -317,43 +307,26 @@ func newEventDebouncer(delay time.Duration, callback func()) *eventDebouncer { func (d *eventDebouncer) trigger() { d.mu.Lock() - defer d.mu.Unlock() - d.pendingEvents++ - // Mode 3 : Si le delay est 0, on déclenche toujours immédiatement (pas de debouncing) if d.delay == 0 { d.lastTrigger = time.Now() - count := d.pendingEvents d.pendingEvents = 0 - d.mu.Unlock() - if count > 0 { - d.callback() - } - d.mu.Lock() + d.mu.Unlock() + d.callback() return } - // Mode 1 : Si c'est le premier événement ou si le dernier trigger date de plus de 2x le delay, - // on déclenche immédiatement pour éviter les latences timeSinceLastTrigger := time.Since(d.lastTrigger) if d.lastTrigger.IsZero() || timeSinceLastTrigger > d.delay*2 { - // Déclencher immédiatement d.lastTrigger = time.Now() - count := d.pendingEvents d.pendingEvents = 0 - - // Unlock avant d'appeler le callback d.mu.Unlock() - if count > 0 { - d.callback() - } - d.mu.Lock() + d.callback() return } - // Mode 2 : Rafale d'événements - utiliser le debouncing normal if d.timer != nil { d.timer.Stop() } @@ -369,6 +342,7 @@ func (d *eventDebouncer) trigger() { d.callback() } }) + d.mu.Unlock() } func (d *eventDebouncer) stop() { diff --git a/src/main.go b/src/main.go index 633fb36..e210e42 100644 --- a/src/main.go +++ b/src/main.go @@ -60,7 +60,12 @@ func main() { if err := discoverOnce(ctx, cfg, discoveryClient, logger); err != nil { logger.Printf("[discover] initial discovery attempt %d/%d failed: %v", i+1, maxRetries, err) if i < maxRetries-1 { - time.Sleep(retryDelay) + select { + case <-ctx.Done(): + logger.Printf("[main] startup cancelled during initial discovery") + return + case <-time.After(retryDelay): + } retryDelay = retryDelay * 2 // Backoff } } else { @@ -105,13 +110,19 @@ func main() { logger.Printf("[main] listening on %s, docker socket=%s, discover every %s, debounce=%s, profilesFile=%s", cfg.Listen, cfg.SocketPath, cfg.DiscoverInterval, cfg.DebounceDelay, cfg.ProfilesFile) + serverErrors := make(chan error, 1) go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Fatalf("http server error: %v", err) + serverErrors <- err } }() - <-ctx.Done() + select { + case <-ctx.Done(): + case err := <-serverErrors: + logger.Printf("[main] http server error: %v", err) + stop() + } logger.Printf("[main] shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/src/main_test.go b/src/main_test.go index 0035576..324185f 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -1,10 +1,13 @@ package main import ( + "bytes" "context" + "fmt" "io" "log" "net/http" + "net/http/httptest" "strings" "sync" "testing" @@ -255,6 +258,50 @@ func TestFeaturePermissionMatrix(t *testing.T) { } } +func TestProxyLogsEscapeUntrustedPath(t *testing.T) { + var logs bytes.Buffer + handler := proxyHandler(&ProxyConfig{ipToRole: map[string]string{}}, nil, nil, log.New(&logs, "", 0)) + req := httptest.NewRequest(http.MethodGet, "http://proxy/version", nil) + req.RemoteAddr = "192.0.2.10:1234" + req.URL.Path = "/version\nforged-log-line" + handler.ServeHTTP(httptest.NewRecorder(), req) + if got := strings.Count(strings.TrimSpace(logs.String()), "\n"); got != 0 { + t.Fatalf("untrusted path created extra log lines: %q", logs.String()) + } + if !strings.Contains(logs.String(), `\nforged-log-line`) { + t.Fatalf("escaped path missing from log: %q", logs.String()) + } +} + +func TestExecCacheIsBoundedAndExpires(t *testing.T) { + cfg := &ProxyConfig{execToContainer: make(map[string]dockerExecCacheEntry)} + for i := 0; i <= maxExecCacheEntries; i++ { + cfg.SetExecContainer(fmt.Sprintf("exec-%d", i), "container") + } + if len(cfg.execToContainer) != maxExecCacheEntries { + t.Fatalf("exec cache size = %d, want %d", len(cfg.execToContainer), maxExecCacheEntries) + } + cfg.execToContainer["expired"] = dockerExecCacheEntry{ContainerID: "old", ExpiresAt: time.Now().Add(-time.Second)} + if _, ok := cfg.GetExecContainer("expired"); ok { + t.Fatal("expired exec cache entry was returned") + } +} + +func TestScopeResponseFilterRejectsCompressedBodies(t *testing.T) { + ctx := context.WithValue(context.Background(), responseFilterContextKey{}, &responseFilterContext{ + service: &ServiceConfig{}, kind: filterContainerList, + }) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Encoding": []string{"gzip"}}, + Body: io.NopCloser(strings.NewReader("compressed")), + Request: httptest.NewRequest(http.MethodGet, "http://proxy/containers/json", nil).WithContext(ctx), + } + if err := scopeResponseFilter(&ProxyConfig{})(resp); err == nil { + t.Fatal("compressed scoped response was accepted") + } +} + func TestRewriteAPIVersion(t *testing.T) { tests := map[string]string{ "/containers/json": "/v1.51/containers/json", @@ -358,7 +405,7 @@ func TestEnforceContainerScopeUsesCachedCanonicalID(t *testing.T) { ID: meta.ID, Names: []string{"/traefik"}, }}), - execToContainer: make(map[string]string), + execToContainer: make(map[string]dockerExecCacheEntry), } service := &ServiceConfig{ ContainerScope: "allowlist", @@ -383,7 +430,7 @@ func TestEnforceContainerScopeRejectsBlacklistedAndGlobalOperations(t *testing.T ID: "0123456789abcdef", Names: []string{"/docker-socket-proxy"}, }}), - execToContainer: make(map[string]string), + execToContainer: make(map[string]dockerExecCacheEntry), } service := &ServiceConfig{ ContainerScope: "blacklist", @@ -407,7 +454,7 @@ func TestEnforceContainerScopeAllowsOnlySafeReadOnlyRoutes(t *testing.T) { ID: "0123456789abcdef", Names: []string{"/dockman"}, }}), - execToContainer: make(map[string]string), + execToContainer: make(map[string]dockerExecCacheEntry), } service := &ServiceConfig{ ContainerScope: "all", diff --git a/src/policy.go b/src/policy.go index 25db886..2ab7883 100644 --- a/src/policy.go +++ b/src/policy.go @@ -631,6 +631,9 @@ func scopeResponseFilter(cfg *ProxyConfig) func(*http.Response) error { if filter == nil || resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil } + if encoding := strings.TrimSpace(resp.Header.Get("Content-Encoding")); encoding != "" && !strings.EqualFold(encoding, "identity") { + return fmt.Errorf("cannot safely scope Docker response with content encoding %q", encoding) + } switch filter.kind { case filterContainerList: filterContainerListResponse(resp, cfg, filter.service) diff --git a/src/proxy.go b/src/proxy.go index eaa3d24..ebf9414 100644 --- a/src/proxy.go +++ b/src/proxy.go @@ -78,28 +78,28 @@ func proxyHandler(cfg *ProxyConfig, resolverClient *http.Client, proxy *httputil // Health local : accès direct à /version depuis localhost if isLocalIP(host) && isVersionPath(path) { - logger.Printf("[health] local check ip=%s method=%s path=%s", host, method, path) + logger.Printf("[health] local check ip=%q method=%q path=%q", host, method, path) proxy.ServeHTTP(w, r) return } role := cfg.GetRole(host) if role == "" { - logger.Printf("[deny] ip=%s role= method=%s path=%s", host, method, path) + logger.Printf("[deny] ip=%q role= method=%q path=%q", host, method, path) http.Error(w, "Forbidden", http.StatusForbidden) return } svc := cfg.GetService(role) if svc == nil { - logger.Printf("[deny] ip=%s role=%s (unknown) method=%s path=%s", host, role, method, path) + logger.Printf("[deny] ip=%q role=%q (unknown) method=%q path=%q", host, role, method, path) http.Error(w, "Forbidden", http.StatusForbidden) return } feature, action := classifyPath(path) if !svc.Allow(feature, method, action) { - logger.Printf("[deny] ip=%s role=%s feature=%s action=%s method=%s path=%s", + logger.Printf("[deny] ip=%q role=%q feature=%q action=%q method=%q path=%q", host, role, feature, action, method, path) http.Error(w, "Forbidden", http.StatusForbidden) return @@ -107,7 +107,7 @@ func proxyHandler(cfg *ProxyConfig, resolverClient *http.Client, proxy *httputil responseFilter, err := enforceContainerScope(r.Context(), cfg, resolverClient, svc, feature, r) if err != nil { - logger.Printf("[deny] ip=%s role=%s feature=%s method=%s path=%s scope=%s reason=%v", + logger.Printf("[deny] ip=%q role=%q feature=%q method=%q path=%q scope=%q reason=%v", host, role, feature, method, path, svc.ContainerScope, err) http.Error(w, "Forbidden", http.StatusForbidden) return @@ -121,14 +121,14 @@ func proxyHandler(cfg *ProxyConfig, resolverClient *http.Client, proxy *httputil if svc.APIRewrite != "" { r.URL.Path = rewriteAPIVersion(r.URL.Path, svc.APIRewrite) if r.URL.Path != originalPath { - logger.Printf("[req] ip=%s role=%s feature=%s action=%s method=%s path=%s -> rewritten to=%s (api=%s)", + logger.Printf("[req] ip=%q role=%q feature=%q action=%q method=%q path=%q -> rewritten to=%q (api=%q)", host, role, feature, action, method, originalPath, r.URL.Path, svc.APIRewrite) } else { - logger.Printf("[req] ip=%s role=%s feature=%s action=%s method=%s path=%s (api=%s)", + logger.Printf("[req] ip=%q role=%q feature=%q action=%q method=%q path=%q (api=%q)", host, role, feature, action, method, path, svc.APIRewrite) } } else { - logger.Printf("[req] ip=%s role=%s feature=%s action=%s method=%s path=%s", + logger.Printf("[req] ip=%q role=%q feature=%q action=%q method=%q path=%q", host, role, feature, action, method, path) } diff --git a/src/types.go b/src/types.go index 20bf769..f82992e 100644 --- a/src/types.go +++ b/src/types.go @@ -83,12 +83,12 @@ type ProxyConfig struct { services map[string]*ServiceConfig // effectif (CLI + YAML) ipToRole map[string]string // IP -> nom de rôle - selfNetworks map[string]struct{} // réseaux du conteneur socket-proxy (immuable après init) - selfNetworksHash string // hash des réseaux pour cache DNS + selfNetworks map[string]struct{} + selfNetworksHash string containerMu sync.RWMutex containersByRef map[string]dockerContainerMeta - execToContainer map[string]string + execToContainer map[string]dockerExecCacheEntry } // Getters thread-safe @@ -152,14 +152,62 @@ func (c *ProxyConfig) UpsertContainer(meta dockerContainerMeta) { func (c *ProxyConfig) SetExecContainer(execID, containerID string) { c.containerMu.Lock() defer c.containerMu.Unlock() - c.execToContainer[execID] = containerID + now := time.Now() + for id, entry := range c.execToContainer { + if now.After(entry.ExpiresAt) { + delete(c.execToContainer, id) + } + } + if len(c.execToContainer) >= maxExecCacheEntries { + var oldestID string + var oldest time.Time + for id, entry := range c.execToContainer { + if oldestID == "" || entry.CreatedAt.Before(oldest) { + oldestID, oldest = id, entry.CreatedAt + } + } + delete(c.execToContainer, oldestID) + } + c.execToContainer[execID] = dockerExecCacheEntry{ContainerID: containerID, CreatedAt: now, ExpiresAt: now.Add(execCacheTTL)} } func (c *ProxyConfig) GetExecContainer(execID string) (string, bool) { - c.containerMu.RLock() - defer c.containerMu.RUnlock() - containerID, ok := c.execToContainer[execID] - return containerID, ok + c.containerMu.Lock() + defer c.containerMu.Unlock() + entry, ok := c.execToContainer[execID] + if !ok || time.Now().After(entry.ExpiresAt) { + delete(c.execToContainer, execID) + return "", false + } + return entry.ContainerID, true +} + +const ( + execCacheTTL = 15 * time.Minute + maxExecCacheEntries = 4096 +) + +type dockerExecCacheEntry struct { + ContainerID string + CreatedAt time.Time + ExpiresAt time.Time +} + +func (c *ProxyConfig) updateSelfNetworks(nets map[string]struct{}, hash string) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.selfNetworksHash != "" && c.selfNetworksHash == hash { + return false + } + c.selfNetworks = cloneStringSet(nets) + c.selfNetworksHash = hash + return true +} + +func (c *ProxyConfig) getSelfNetworks() map[string]struct{} { + c.mu.RLock() + defer c.mu.RUnlock() + return cloneStringSet(c.selfNetworks) } // ----------------------------- From a2385ba41601973d982c4b57bc2bcbc0e314d27c Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:53:40 +0200 Subject: [PATCH 07/23] reject ambiguous profile configuration --- src/config.go | 42 +++++++++++++++++++----------------------- src/main_test.go | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/config.go b/src/config.go index 5c868fa..ed81495 100644 --- a/src/config.go +++ b/src/config.go @@ -47,22 +47,7 @@ func ensureService(m map[string]*ServiceConfig, role string) *ServiceConfig { return s } -func anyRightsSet(s *ServiceConfig) bool { - return s.Ping || s.Version || s.Info || s.Events || s.Auth || - s.Build || s.Commit || s.Configs || s.Containers || s.Distribution || - s.Exec || s.Images || s.Networks || s.Nodes || s.Plugins || - s.Secrets || s.Services || s.Session || s.Swarm || - s.System || s.Tasks || s.Volumes -} - -func applyDefaultProfileFlags(s *ServiceConfig, role string) { - // AUCUN DROIT PAR DÉFAUT - // Les droits doivent être explicitement définis dans profiles.yml ou via CLI - // Principe du moindre privilège : deny by default - return -} - -func applyFlagValue(s *ServiceConfig, flag, value string) { +func applyFlagValue(s *ServiceConfig, flag, value string) error { b := parseBoolString(value) f := strings.ToLower(strings.TrimSpace(flag)) @@ -165,18 +150,21 @@ func applyFlagValue(s *ServiceConfig, flag, value string) { case "container_rule": parts := strings.SplitN(value, ":", 2) if len(parts) != 2 { - return + return fmt.Errorf("container_rule must use name:access") } name := normalizeContainerRef(parts[0]) access := ContainerAccess(strings.ToLower(strings.TrimSpace(parts[1]))) if name == "" || (access != containerAccessDeny && access != containerAccessReadOnly) { - return + return fmt.Errorf("container_rule access must be readonly or deny") } if s.ContainerRules == nil { s.ContainerRules = make(map[string]ContainerAccess) } s.ContainerRules[name] = access + default: + return fmt.Errorf("unknown profile option %q", flag) } + return nil } func cloneServices(in map[string]*ServiceConfig) map[string]*ServiceConfig { @@ -414,11 +402,12 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { role := normalizeRoleName(profileKey) svc := ensureService(cfg.baseServices, role) - applyFlagValue(svc, flagKey, valStr) + if err := applyFlagValue(svc, flagKey, valStr); err != nil { + logger.Printf("[config] WARNING profile=%q option=%q: %v", role, flagKey, err) + } } else { role := normalizeRoleName(opt) - svc := ensureService(cfg.baseServices, role) - applyDefaultProfileFlags(svc, role) + ensureService(cfg.baseServices, role) } } @@ -476,6 +465,9 @@ func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { if role == "" { return nil, fmt.Errorf("empty profile name") } + if _, exists := profiles[role]; exists { + return nil, fmt.Errorf("profile names %q and %q normalize to the same role %q", role, rawName, role) + } svc := ensureService(profiles, role) for key, value := range values { if _, known := knownProfileKeys[key]; !known { @@ -492,7 +484,9 @@ func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { if !ok || normalizeContainerRef(name) == "" { return nil, fmt.Errorf("profile %q: %s must contain non-empty names", role, key) } - applyFlagValue(svc, key, name) + if err := applyFlagValue(svc, key, name); err != nil { + return nil, fmt.Errorf("profile %q: %w", role, err) + } } case "container_rules": items, ok := value.([]any) @@ -524,7 +518,9 @@ func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { default: switch typed := value.(type) { case bool, string, int, int64, float64: - applyFlagValue(svc, key, fmt.Sprint(typed)) + if err := applyFlagValue(svc, key, fmt.Sprint(typed)); err != nil { + return nil, fmt.Errorf("profile %q: %w", role, err) + } default: return nil, fmt.Errorf("profile %q: %s must be a scalar", role, key) } diff --git a/src/main_test.go b/src/main_test.go index 324185f..8c81b91 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -59,6 +59,17 @@ func TestParseConfigCLIOverridesEnvironment(t *testing.T) { } } +func TestParseConfigWarnsAboutUnknownProfileOption(t *testing.T) { + var logs bytes.Buffer + cfg := parseConfig([]string{"--traefik.pingg=1"}, log.New(&logs, "", 0)) + if !strings.Contains(logs.String(), `WARNING profile="traefik" option="pingg"`) { + t.Fatalf("unknown option was not reported: %q", logs.String()) + } + if cfg.GetService("traefik") == nil { + t.Fatal("deny-by-default profile was not retained") + } +} + func TestDockerClientTimeoutsSeparateStreamingFromDiscovery(t *testing.T) { streaming := newDockerHTTPClient("/tmp/docker.sock") if streaming.Timeout != 0 { @@ -353,6 +364,13 @@ func TestParseProfilesYAMLRejectsUnknownKey(t *testing.T) { } } +func TestParseProfilesYAMLRejectsNormalizedRoleCollision(t *testing.T) { + _, err := parseProfilesYAML("home:\n ping: true\nproxy-home:\n version: true\n") + if err == nil || !strings.Contains(err.Error(), "normalize to the same role") { + t.Fatalf("normalized role collision was not rejected: %v", err) + } +} + func TestContainerScopes(t *testing.T) { traefik := dockerContainerMeta{ID: "a", Name: "traefik"} proxy := dockerContainerMeta{ID: "b", Name: "docker-socket-proxy"} From c1a797144ae184f4581d88b85448bcbe688100a1 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:54:28 +0200 Subject: [PATCH 08/23] remove obsolete policy state --- src/docker_discovery.go | 2 +- src/main_test.go | 4 ++-- src/policy.go | 37 ++++--------------------------------- src/types.go | 8 ++------ 4 files changed, 9 insertions(+), 42 deletions(-) diff --git a/src/docker_discovery.go b/src/docker_discovery.go index 3d0f6f1..80ff2b5 100644 --- a/src/docker_discovery.go +++ b/src/docker_discovery.go @@ -130,7 +130,7 @@ func indexContainerSummary(c dockerContainerSummary) dockerContainerMeta { if len(c.Names) > 0 { name = normalizeContainerRef(c.Names[0]) } - return dockerContainerMeta{ID: c.ID, Name: name, Labels: c.Labels} + return dockerContainerMeta{ID: c.ID, Name: name} } func buildContainerIndex(containers []dockerContainerSummary) map[string]dockerContainerMeta { diff --git a/src/main_test.go b/src/main_test.go index 8c81b91..cc01e4f 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -521,7 +521,7 @@ func TestFilterContainerListResponse(t *testing.T) { {"Id":"b","Names":["/docker-socket-proxy"]} ]`)), } - filterContainerListResponse(resp, nil, service) + filterContainerListResponse(resp, service) body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) @@ -549,7 +549,7 @@ func TestFilterContainerListKeepsReadOnlyContainer(t *testing.T) { {"Id":"b","Names":["/docker-socket-proxy"]} ]`)), } - filterContainerListResponse(resp, nil, service) + filterContainerListResponse(resp, service) body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) diff --git a/src/policy.go b/src/policy.go index 2ab7883..b626936 100644 --- a/src/policy.go +++ b/src/policy.go @@ -248,31 +248,6 @@ type responseFilterContext struct { type responseFilterContextKey struct{} -func (c *ProxyConfig) containerMetas() []dockerContainerMeta { - c.containerMu.RLock() - defer c.containerMu.RUnlock() - seen := make(map[string]struct{}) - metas := make([]dockerContainerMeta, 0, len(c.containersByRef)/2) - for _, meta := range c.containersByRef { - if _, ok := seen[meta.ID]; ok { - continue - } - seen[meta.ID] = struct{}{} - metas = append(metas, meta) - } - return metas -} - -func (c *ProxyConfig) allowedContainerIDs(service *ServiceConfig) []string { - ids := make([]string, 0) - for _, meta := range c.containerMetas() { - if service.AllowsContainer(meta) { - ids = append(ids, meta.ID) - } - } - return ids -} - func resolveContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client, ref string) (dockerContainerMeta, error) { if meta, ok := cfg.GetContainer(ref); ok { return meta, nil @@ -293,7 +268,7 @@ func resolveContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil { return dockerContainerMeta{}, err } - meta := dockerContainerMeta{ID: inspect.ID, Name: normalizeContainerRef(inspect.Name), Labels: inspect.Config.Labels} + meta := dockerContainerMeta{ID: inspect.ID, Name: normalizeContainerRef(inspect.Name)} if meta.ID == "" || meta.Name == "" { return dockerContainerMeta{}, fmt.Errorf("docker inspect container %q returned incomplete metadata", ref) } @@ -509,7 +484,7 @@ func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.C return nil, nil } -func filterContainerListResponse(resp *http.Response, cfg *ProxyConfig, service *ServiceConfig) { +func filterContainerListResponse(resp *http.Response, service *ServiceConfig) { originalBody := resp.Body ctx := context.Background() if resp.Request != nil { @@ -600,11 +575,7 @@ func filterEventsResponse(resp *http.Response, cfg *ProxyConfig, service *Servic if event.Type != "container" { continue } - meta := dockerContainerMeta{ - ID: event.Actor.ID, - Name: normalizeContainerRef(event.Actor.Attributes["name"]), - Labels: event.Actor.Attributes, - } + meta := dockerContainerMeta{ID: event.Actor.ID, Name: normalizeContainerRef(event.Actor.Attributes["name"])} if meta.Name == "" { var ok bool meta, ok = cfg.GetContainer(event.Actor.ID) @@ -636,7 +607,7 @@ func scopeResponseFilter(cfg *ProxyConfig) func(*http.Response) error { } switch filter.kind { case filterContainerList: - filterContainerListResponse(resp, cfg, filter.service) + filterContainerListResponse(resp, filter.service) case filterEvents: filterEventsResponse(resp, cfg, filter.service) } diff --git a/src/types.go b/src/types.go index f82992e..deca1d3 100644 --- a/src/types.go +++ b/src/types.go @@ -234,15 +234,11 @@ type dockerContainerInspect struct { ID string `json:"Id"` Name string `json:"Name"` NetworkSettings dockerContainerNetworkBlock `json:"NetworkSettings"` - Config struct { - Labels map[string]string `json:"Labels"` - } `json:"Config"` } type dockerContainerMeta struct { - ID string - Name string - Labels map[string]string + ID string + Name string } func normalizeContainerRef(ref string) string { From 176a1477c10372023cc28721e1924cf25a464bf6 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:57:21 +0200 Subject: [PATCH 09/23] document and enforce scoped deployment boundaries --- .dockerignore | 1 + .github/dependabot.yml | 17 +++++++++++++++++ .gitignore | 5 +++++ LICENSE | 21 +++++++++++++++++++++ README.fr.md | 20 ++++++++++++++++++-- README.md | 20 ++++++++++++++++++-- SECURITY.md | 15 +++++++++++++++ compose.yml | 16 ++++++---------- src/main_test.go | 19 +++++++++++++++++++ src/policy.go | 6 ++++++ 10 files changed, 126 insertions(+), 14 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.dockerignore b/.dockerignore index 34c29dc..6e8313f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ compose.yml config README.md +README.fr.md **/*_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..67df797 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d3d886 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +*.log +*.out +coverage.* +docker-socket-proxy diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..980c201 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cerede2000 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.fr.md b/README.fr.md index de2c8f9..772abcd 100644 --- a/README.fr.md +++ b/README.fr.md @@ -39,10 +39,12 @@ Un outil peut donc disposer d'un accès Docker étendu lorsque c'est nécessaire - Le proxy ne retient que les IP partagées avec ses propres réseaux Docker. - Les listes, les événements et les opérations ciblant un conteneur respectent la même portée. - Le cache interne nom / ID de conteneur évite une requête Docker supplémentaire pour les vérifications usuelles. +- Le contrôle local `/version` est accepté sans profil uniquement depuis l'interface loopback. N'utilisez pas le réseau hôte et ne publiez pas le port `2375`. +- Pour les profils à portée limitée, les événements Docker non liés à un conteneur sont volontairement omis car ils ne peuvent pas être rattachés sûrement à une cible autorisée. ## Démarrage rapide -Créez un fichier `profiles.yml`, puis lancez le proxy. Le montage du socket est en lecture seule : les requêtes Docker restent possibles via l'API Unix, mais le fichier socket ne peut pas être remplacé depuis le conteneur. +Créez un fichier `profiles.yml`, puis lancez le proxy. Le montage `:ro` empêche seulement de remplacer le fichier socket Unix ; il ne rend **pas** les appels à l'API Docker accessibles en lecture seule. La politique du proxy constitue la barrière de sécurité. ```yaml services: @@ -144,6 +146,7 @@ traefik: ping: true version: true containers: true + allow_inspect: true networks: true events: true session: true @@ -152,6 +155,7 @@ traefik-manager: ping: true version: true containers: true + allow_inspect: true post: true allow_restart: true container_scope: allowlist @@ -240,7 +244,7 @@ Toutes ces options valent `false` par défaut. `allow_restarts` reste un alias d La création d'une session exec via `POST /containers/{id}/exec` exige les trois droits explicites `containers: true`, `exec: true` et `post: true`. `allow_all` n'active jamais `exec`. -`allow_all: true` est un raccourci pour toutes les options `allow_*` du tableau. Ce n'est volontairement **pas** un droit Docker global : il n'active ni `containers`, ni `post`, ni une autre famille d'API et ne contourne pas les portées de conteneurs. L'envoi d'une archive et les autres écritures génériques nécessitent donc toujours `post: true`. +`allow_all: true` est un raccourci groupé mais limité à la portée pour toutes les options `allow_*` du tableau. Ce n'est volontairement **pas** un droit Docker global : il n'active ni `containers`, ni `exec`, ni `post`, ni une autre famille d'API et ne contourne pas les portées de conteneurs. L'envoi d'une archive et les autres écritures génériques nécessitent donc toujours `post: true`. Traitez-le comme un droit à fort impact : `export` peut lire tout le système de fichiers du conteneur et la lecture d'archive peut exposer n'importe quel fichier de la cible. Exemple minimal limité au cycle de vie : @@ -272,6 +276,10 @@ container-manager: Les noms sont les noms Docker sans le préfixe `/`. Les règles s'appliquent aux listes, événements, inspections, logs, statistiques, exec, opérations réseau et actions ciblées. +### Limites de la portée + +La portée conteneur s'applique uniquement lorsqu'une requête Docker peut être rattachée à un conteneur. Pour un profil limité, les opérations globales sur les conteneurs (`create`, `prune`) et les écritures destructrices sur les images, volumes ou réseaux non ciblés sont refusées. Les lectures des familles globales `images`, `volumes` et `networks` ne sont pas filtrées par conteneur. Évitez d'accorder ces familles avec `post: true` sauf si le client administre réellement tout l'hôte. + ### Accès large : `all` `all` est la valeur par défaut. Le profil conserve ses droits sur tous les conteneurs ; utilisez une règle `deny` pour retirer une cible critique. @@ -281,6 +289,7 @@ portainer: ping: true version: true containers: true + allow_inspect: true images: true networks: true post: true @@ -300,6 +309,7 @@ Les conteneurs absents de `allowed_containers` sont invisibles et inaccessibles. ```yaml traefik-manager: containers: true + allow_inspect: true post: true allow_restart: true container_scope: allowlist @@ -315,6 +325,7 @@ Les conteneurs de `blocked_containers` sont invisibles et toute opération les v dockhand: ping: true containers: true + allow_inspect: true events: true post: true allow_start: true @@ -332,6 +343,7 @@ dockhand: ```yaml dockhand: containers: true + allow_inspect: true events: true post: true allow_start: true @@ -376,3 +388,7 @@ La runtime `distroless/static-debian13:nonroot` est adaptée à ce modèle : le go test -race ./... go vet ./... ``` + +## Licence + +Distribué sous [licence MIT](LICENSE). diff --git a/README.md b/README.md index 7913109..2375b7a 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,12 @@ This lets an operator such as Portainer retain broad access where it is genuinel - The proxy only keeps client IP addresses shared with its own Docker networks. - Container lists, events, and targeted operations obey the same target scope. - The internal name / ID cache avoids an additional Docker request for usual authorization checks. +- The local `/version` health check is accepted without a profile only from the loopback interface. Do not use host networking or publish port `2375`. +- For scoped profiles, non-container Docker events are intentionally omitted because they cannot be tied safely to an authorized container. ## Quick start -Create `profiles.yml`, then start the proxy. The Docker socket is mounted read-only: Docker API requests still work over the Unix socket, but the socket file cannot be replaced from inside the container. +Create `profiles.yml`, then start the proxy. The `:ro` socket mount only prevents replacement of the Unix socket file; it does **not** make Docker API calls read-only. The proxy policy is the security boundary. ```yaml services: @@ -143,6 +145,7 @@ traefik: ping: true version: true containers: true + allow_inspect: true networks: true events: true session: true @@ -151,6 +154,7 @@ traefik-manager: ping: true version: true containers: true + allow_inspect: true post: true allow_restart: true container_scope: allowlist @@ -239,7 +243,7 @@ All these options default to `false`. `allow_restarts` remains an alias for `all Creating an exec session with `POST /containers/{id}/exec` requires all three explicit grants: `containers: true`, `exec: true`, and `post: true`. `allow_all` never enables `exec`. -`allow_all: true` is a convenience shortcut for every `allow_*` option in the table. It is deliberately **not** a global Docker permission: it does not enable `containers`, `post`, any other API family, or bypass container scopes. Archive upload and other generic writes therefore still require `post: true`. +`allow_all: true` is a grouped but scoped convenience shortcut for every `allow_*` option in the table. It is deliberately **not** a global Docker permission: it does not enable `containers`, `exec`, `post`, any other API family, or bypass container scopes. Archive upload and other generic writes therefore still require `post: true`. Treat it as a high-impact permission: `export` can read the complete container filesystem and archive reads can disclose arbitrary files inside the selected container. Minimal lifecycle-only example: @@ -271,6 +275,10 @@ container-manager: Names are Docker container names without the `/` prefix. Scope rules apply to lists, events, inspect, logs, stats, exec, network operations, and targeted actions. +### Scope limits + +Container scope applies only where a Docker request can be tied to a container. For a scoped profile, global container operations (`create`, `prune`) and destructive image, volume, or non-targeted network writes are denied. Read access to the `images`, `volumes`, and global `networks` families is not filtered per container. Avoid granting these families together with `post: true` unless the client genuinely administers the whole host. + ### Broad access: `all` `all` is the default. The profile keeps its rights over every container; add a `deny` rule to remove a critical target. @@ -280,6 +288,7 @@ portainer: ping: true version: true containers: true + allow_inspect: true images: true networks: true post: true @@ -299,6 +308,7 @@ Containers absent from `allowed_containers` are hidden and inaccessible. ```yaml traefik-manager: containers: true + allow_inspect: true post: true allow_restart: true container_scope: allowlist @@ -313,6 +323,7 @@ Containers in `blocked_containers` are hidden and every operation targeting them dockhand: ping: true containers: true + allow_inspect: true events: true post: true allow_start: true @@ -329,6 +340,7 @@ dockhand: ```yaml dockhand: containers: true + allow_inspect: true events: true post: true allow_start: true @@ -372,3 +384,7 @@ The proxy logs profile discovery and denials. A client with no role, an unknown go test -race ./... go vet ./... ``` + +## License + +Licensed under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b6af679 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security policy + +## Supported versions + +Security fixes are applied to the current `main` release line. The `integration` +tag is a test build and should not be treated as a stable release. + +## Reporting a vulnerability + +Please use GitHub private vulnerability reporting for this repository instead +of opening a public issue. Include the affected version, configuration, impact, +and a minimal reproduction when possible. + +Do not include Docker credentials, environment values, tokens, container logs, +or other secrets in a report. diff --git a/compose.yml b/compose.yml index 9bcca6d..8c8c202 100644 --- a/compose.yml +++ b/compose.yml @@ -1,8 +1,8 @@ services: docker-socket-proxy: - image: ghcr.io/cerede2000/docker-socket-proxy:latest + image: cerede2000/docker-socket-proxy:latest container_name: docker-socket-proxy - user: "107:108" + user: "1000:998" # adapt to an UID:GID allowed to read the host socket read_only: true cap_drop: - ALL @@ -12,15 +12,11 @@ services: - /tmp volumes: - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config/profiles.yml:/config/profiles.yml:ro networks: - - traefikfront - command: - - --dockerproxy-traefik.ping=1 - - --dockerproxy-traefik.version=1 - - --dockerproxy-watchtower.info=1 - - --dockerproxy-watchtower.events=1 + - socketproxy restart: unless-stopped networks: - traefikfront: - external: true + socketproxy: + internal: true diff --git a/src/main_test.go b/src/main_test.go index cc01e4f..750c3ab 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -466,6 +466,25 @@ func TestEnforceContainerScopeRejectsBlacklistedAndGlobalOperations(t *testing.T } } +func TestScopedProfilesRejectGlobalResourceWrites(t *testing.T) { + cfg := &ProxyConfig{execToContainer: make(map[string]dockerExecCacheEntry)} + service := &ServiceConfig{ContainerScope: "allowlist", AllowedContainers: map[string]struct{}{"traefik": {}}} + for _, tc := range []struct { + feature string + method string + path string + }{ + {"images", http.MethodDelete, "/images/alpine"}, + {"volumes", http.MethodPost, "/volumes/prune"}, + {"networks", http.MethodDelete, "/networks/internal"}, + } { + req := httptest.NewRequest(tc.method, "http://proxy"+tc.path, nil) + if _, err := enforceContainerScope(context.Background(), cfg, nil, service, tc.feature, req); err == nil { + t.Errorf("scoped %s request %s %s was allowed", tc.feature, tc.method, tc.path) + } + } +} + func TestEnforceContainerScopeAllowsOnlySafeReadOnlyRoutes(t *testing.T) { cfg := &ProxyConfig{ containersByRef: buildContainerIndex([]dockerContainerSummary{{ diff --git a/src/policy.go b/src/policy.go index b626936..83c1192 100644 --- a/src/policy.go +++ b/src/policy.go @@ -418,6 +418,10 @@ func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.C if !service.HasContainerScope() { return nil, nil } + isWrite := r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete + if isWrite && (feature == "images" || feature == "volumes") { + return nil, fmt.Errorf("global %s write is denied for scoped profiles", feature) + } switch feature { case "containers": @@ -469,6 +473,8 @@ func enforceContainerScope(ctx context.Context, cfg *ProxyConfig, client *http.C if err := requireWritableContainer(access, meta); err != nil { return nil, err } + } else if isWrite { + return nil, fmt.Errorf("global network write is denied for scoped profiles") } case "commit": if ref := r.URL.Query().Get("container"); ref != "" { From b9dc06e81f819926583727c60764c8904f15cf0e Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 16:59:38 +0200 Subject: [PATCH 10/23] harden image validation and signing pipeline --- .github/workflows/docker-image.yml | 48 +++++++++++++++++++++++++++++- src/config.go | 5 +--- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index d5123f6..31cf9f2 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -39,12 +39,30 @@ jobs: - name: Vet run: go vet ./... + - name: Staticcheck + uses: dominikh/staticcheck-action@v1 + with: + version: "2026.2.1" + install-go: false + - name: Test run: go test -race ./... - name: Vulnerability audit run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build image + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: false + provenance: false + sbom: false + publish: if: github.event_name != 'pull_request' needs: test @@ -52,6 +70,7 @@ jobs: permissions: contents: read packages: write + id-token: write steps: - name: Checkout uses: actions/checkout@v6 @@ -64,6 +83,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub + if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -75,13 +95,24 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 + - name: Prepare optional Docker Hub image + id: dockerhub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + if [ -n "$DOCKERHUB_USERNAME" ]; then + echo "image=$DOCKERHUB_USERNAME/docker-socket-proxy" >> "$GITHUB_OUTPUT" + else + echo "image=" >> "$GITHUB_OUTPUT" + fi + - name: Generate image metadata id: meta uses: docker/metadata-action@v6 with: images: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - ${{ secrets.DOCKERHUB_USERNAME }}/docker-socket-proxy + ${{ steps.dockerhub.outputs.image }} flavor: | latest=false tags: | @@ -91,6 +122,7 @@ jobs: type=semver,pattern={{major}}.{{minor}} - name: Build and push production image + id: build uses: docker/build-push-action@v7 with: context: . @@ -106,3 +138,17 @@ jobs: cache-to: type=gha,mode=max provenance: mode=max sbom: true + + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign published images + env: + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + while IFS= read -r tag; do + [ -n "$tag" ] && cosign sign --yes "$tag@$DIGEST" + done < Date: Mon, 24 Aug 2026 17:01:29 +0200 Subject: [PATCH 11/23] expand policy and lifecycle regression coverage --- src/docker_discovery.go | 1 - src/main_test.go | 148 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/src/docker_discovery.go b/src/docker_discovery.go index 80ff2b5..d660fea 100644 --- a/src/docker_discovery.go +++ b/src/docker_discovery.go @@ -313,7 +313,6 @@ func (d *eventDebouncer) trigger() { d.lastTrigger = time.Now() d.pendingEvents = 0 d.mu.Unlock() - d.mu.Unlock() d.callback() return } diff --git a/src/main_test.go b/src/main_test.go index 750c3ab..af1b5e9 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -8,6 +8,8 @@ import ( "log" "net/http" "net/http/httptest" + "net/http/httputil" + "net/url" "strings" "sync" "testing" @@ -19,6 +21,10 @@ type blockingReadCloser struct { once sync.Once } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + func (b *blockingReadCloser) Read([]byte) (int, error) { <-b.closed return 0, io.EOF @@ -298,6 +304,89 @@ func TestExecCacheIsBoundedAndExpires(t *testing.T) { } } +func TestResolveExecContainerUsesInspectThenCache(t *testing.T) { + var requests []string + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.URL.Path) + body := `{"ContainerID":"container-id"}` + if strings.HasPrefix(r.URL.Path, "/containers/") { + body = `{"Id":"container-id","Name":"/demo"}` + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + })} + cfg := &ProxyConfig{ + containersByRef: make(map[string]dockerContainerMeta), + execToContainer: make(map[string]dockerExecCacheEntry), + } + meta, err := resolveExecContainer(context.Background(), cfg, client, "exec-id") + if err != nil || meta.Name != "demo" { + t.Fatalf("first resolve = %#v, %v", meta, err) + } + if len(requests) != 2 { + t.Fatalf("first resolve made %d requests, want 2", len(requests)) + } + if _, err := resolveExecContainer(context.Background(), cfg, client, "exec-id"); err != nil { + t.Fatal(err) + } + if len(requests) != 2 { + t.Fatalf("cached resolve made an extra request: %v", requests) + } +} + +func TestResolveExecContainerRejectsMissingContainerID(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`))}, nil + })} + cfg := &ProxyConfig{containersByRef: make(map[string]dockerContainerMeta), execToContainer: make(map[string]dockerExecCacheEntry)} + if _, err := resolveExecContainer(context.Background(), cfg, client, "exec-id"); err == nil { + t.Fatal("exec inspect without ContainerID was accepted") + } +} + +func TestEventDebouncerModes(t *testing.T) { + t.Run("zero delay", func(t *testing.T) { + calls := 0 + d := newEventDebouncer(0, func() { calls++ }) + d.trigger() + d.trigger() + if calls != 2 { + t.Fatalf("calls = %d, want 2", calls) + } + }) + + t.Run("first event immediate", func(t *testing.T) { + called := make(chan struct{}, 1) + d := newEventDebouncer(20*time.Millisecond, func() { called <- struct{}{} }) + defer d.stop() + d.trigger() + select { + case <-called: + case <-time.After(100 * time.Millisecond): + t.Fatal("first event was not immediate") + } + }) + + t.Run("burst coalesced", func(t *testing.T) { + called := make(chan struct{}, 3) + d := newEventDebouncer(20*time.Millisecond, func() { called <- struct{}{} }) + defer d.stop() + d.trigger() + <-called + d.trigger() + d.trigger() + select { + case <-called: + case <-time.After(150 * time.Millisecond): + t.Fatal("burst callback did not run") + } + select { + case <-called: + t.Fatal("burst generated more than one delayed callback") + case <-time.After(40 * time.Millisecond): + } + }) +} + func TestScopeResponseFilterRejectsCompressedBodies(t *testing.T) { ctx := context.WithValue(context.Background(), responseFilterContextKey{}, &responseFilterContext{ service: &ServiceConfig{}, kind: filterContainerList, @@ -326,6 +415,65 @@ func TestRewriteAPIVersion(t *testing.T) { } } +func TestTrimAPIVersionBoundaries(t *testing.T) { + tests := map[string]string{ + "/v1.51": "/v1.51", + "/version": "/version", + "/versionfoo": "/versionfoo", + "/engine/api/v1.51/containers/x": "/containers/x", + } + for input, want := range tests { + if got := pathWithoutAPIVersion(input); got != want { + t.Errorf("pathWithoutAPIVersion(%q) = %q, want %q", input, got, want) + } + } +} + +func TestProxyHandlerDenialsHealthAndAPIRewrite(t *testing.T) { + upstreamPaths := make(chan string, 2) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamPaths <- r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + target, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + proxy := httputil.NewSingleHostReverseProxy(target) + cfg := &ProxyConfig{ + ipToRole: map[string]string{"192.0.2.20": "missing", "192.0.2.30": "client"}, + services: map[string]*ServiceConfig{"client": {Version: true, APIRewrite: "1.51"}}, + } + handler := proxyHandler(cfg, nil, proxy, log.New(io.Discard, "", 0)) + + request := func(remote, path string) int { + req := httptest.NewRequest(http.MethodGet, "http://proxy"+path, nil) + req.RemoteAddr = remote + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + return rr.Code + } + if got := request("192.0.2.10:1", "/version"); got != http.StatusForbidden { + t.Fatalf("unknown IP status = %d", got) + } + if got := request("192.0.2.20:1", "/version"); got != http.StatusForbidden { + t.Fatalf("missing profile status = %d", got) + } + if got := request("127.0.0.1:1", "/version"); got != http.StatusOK { + t.Fatalf("local health status = %d", got) + } + if got := <-upstreamPaths; got != "/version" { + t.Fatalf("health path = %q", got) + } + if got := request("192.0.2.30:1", "/version"); got != http.StatusOK { + t.Fatalf("authorized status = %d", got) + } + if got := <-upstreamPaths; got != "/v1.51/version" { + t.Fatalf("rewritten path = %q", got) + } +} + func TestParseProfilesYAML(t *testing.T) { profiles, err := parseProfilesYAML("home:\n ping: true\n containers: false\n container_scope: allowlist\n allowed_containers:\n - traefik\n container_rules:\n - name: dockman\n access: readonly\n") if err != nil { From 1eee67f4954e2eaff53cc2406705d52facbbc8bb Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 17:01:59 +0200 Subject: [PATCH 12/23] cover scoped network and commit targets --- src/main_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main_test.go b/src/main_test.go index af1b5e9..fd435d3 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -633,6 +633,37 @@ func TestScopedProfilesRejectGlobalResourceWrites(t *testing.T) { } } +func TestScopedNetworkAndCommitTargets(t *testing.T) { + cfg := &ProxyConfig{ + containersByRef: buildContainerIndex([]dockerContainerSummary{{ID: "allowed-id", Names: []string{"/allowed"}}, {ID: "blocked-id", Names: []string{"/blocked"}}}), + execToContainer: make(map[string]dockerExecCacheEntry), + } + service := &ServiceConfig{ContainerScope: "allowlist", AllowedContainers: map[string]struct{}{"allowed": {}}} + + for _, name := range []string{"allowed", "blocked"} { + body := strings.NewReader(fmt.Sprintf(`{"Container":%q}`, name)) + req := httptest.NewRequest(http.MethodPost, "http://proxy/networks/internal/connect", body) + _, err := enforceContainerScope(context.Background(), cfg, nil, service, "networks", req) + if name == "allowed" && err != nil { + t.Fatalf("allowed network target denied: %v", err) + } + if name == "blocked" && err == nil { + t.Fatal("blocked network target allowed") + } + } + + for _, name := range []string{"allowed", "blocked"} { + req := httptest.NewRequest(http.MethodPost, "http://proxy/commit?container="+name, nil) + _, err := enforceContainerScope(context.Background(), cfg, nil, service, "commit", req) + if name == "allowed" && err != nil { + t.Fatalf("allowed commit target denied: %v", err) + } + if name == "blocked" && err == nil { + t.Fatal("blocked commit target allowed") + } + } +} + func TestEnforceContainerScopeAllowsOnlySafeReadOnlyRoutes(t *testing.T) { cfg := &ProxyConfig{ containersByRef: buildContainerIndex([]dockerContainerSummary{{ From 00820c7cc5c5038c82c32974fce7fc0002d528b6 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 17:04:24 +0200 Subject: [PATCH 13/23] make registry login fork safe --- .github/workflows/docker-image.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 31cf9f2..3b58cc0 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -71,6 +71,8 @@ jobs: contents: read packages: write id-token: write + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} steps: - name: Checkout uses: actions/checkout@v6 @@ -83,7 +85,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub - if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} + if: env.DOCKERHUB_USERNAME != '' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -97,8 +99,6 @@ jobs: - name: Prepare optional Docker Hub image id: dockerhub - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} run: | if [ -n "$DOCKERHUB_USERNAME" ]; then echo "image=$DOCKERHUB_USERNAME/docker-socket-proxy" >> "$GITHUB_OUTPUT" From adf8ff6986a7c0f3919695f96905788a1ed1480f Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 17:07:26 +0200 Subject: [PATCH 14/23] disable deprecated staticcheck cache runtime --- .github/workflows/docker-image.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 3b58cc0..583ee16 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,6 +44,7 @@ jobs: with: version: "2026.2.1" install-go: false + use-cache: false - name: Test run: go test -race ./... From a2b23575c167a25c73daeb50fa10e3a6e55994a7 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 21:52:54 +0200 Subject: [PATCH 15/23] fix archive upload authorization --- src/main_test.go | 24 +++++++++++++++++++----- src/policy.go | 1 + 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/main_test.go b/src/main_test.go index fd435d3..52f2c2f 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -181,7 +181,7 @@ func TestLifecyclePermissionsDoNotRequireBroadPost(t *testing.T) { } } -func TestSensitiveContainerReadsAreExplicitlyGated(t *testing.T) { +func TestSensitiveContainerRoutesAreExplicitlyGated(t *testing.T) { service := &ServiceConfig{Containers: true, Post: true} tests := []struct { action string @@ -204,10 +204,24 @@ func TestSensitiveContainerReadsAreExplicitlyGated(t *testing.T) { } *tt.allow = false } - service.AllowArchive = true - service.Post = false - if service.Allow("containers", http.MethodPut, "archive") { - t.Fatal("archive upload was allowed while post=false") + + for _, tt := range []struct { + name string + service ServiceConfig + want bool + }{ + {"neither permission", ServiceConfig{Containers: true}, false}, + {"post only", ServiceConfig{Containers: true, Post: true}, false}, + {"archive only", ServiceConfig{Containers: true, AllowArchive: true}, false}, + {"archive and post", ServiceConfig{Containers: true, AllowArchive: true, Post: true}, true}, + {"allow_all only", ServiceConfig{Containers: true, AllowAll: true}, false}, + {"allow_all and post", ServiceConfig{Containers: true, AllowAll: true, Post: true}, true}, + } { + t.Run("archive upload "+tt.name, func(t *testing.T) { + if got := tt.service.Allow("containers", http.MethodPut, "archive"); got != tt.want { + t.Fatalf("archive upload permission = %v, want %v", got, tt.want) + } + }) } } diff --git a/src/policy.go b/src/policy.go index 83c1192..138860c 100644 --- a/src/policy.go +++ b/src/policy.go @@ -184,6 +184,7 @@ var containerReadPermissions = map[string]func(*ServiceConfig) bool{ } var containerWritePermissions = map[string]func(*ServiceConfig) bool{ + "archive": func(s *ServiceConfig) bool { return s.Post && (s.AllowAll || s.AllowArchive) }, "pause": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowPause }, "start": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowStart }, "stop": func(s *ServiceConfig) bool { return s.AllowAll || s.AllowStop }, From d65cf7f28b2afda1b190929517c869217b2eb0d4 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 21:53:33 +0200 Subject: [PATCH 16/23] docs add security migration notes --- CHANGELOG.md | 25 +++++++++++++++++++++++++ README.fr.md | 6 ++++++ README.md | 6 ++++++ 3 files changed, 37 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..acc26a6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes are documented here. Release tags use semantic versioning. + +## Unreleased + +### Security + +- Container inspection now requires `allow_inspect: true` in addition to `containers: true`. +- Creating an exec session with `POST /containers/{id}/exec` now requires both `exec: true` and `post: true`. +- Scoped profiles can no longer perform global image, volume, or network writes because those operations cannot be tied safely to an authorized target container. +- Uploading an archive with `PUT /containers/{id}/archive` requires both `allow_archive: true` and `post: true`. + +### Migration + +- Add `allow_inspect: true` to every existing profile that calls `GET /containers/{id}/json`. +- Add `exec: true` to every profile that creates exec sessions; `post: true` alone is no longer sufficient. +- Use an unscoped, explicitly privileged profile only when a client genuinely needs global image, volume, or network writes. +- Unknown CLI profile options are rejected during startup rather than being silently ignored. Correct any reported typo before restarting. + +These changes will be included in the next tagged release. Pin an existing immutable tag such as `1.1.2` until the profile migration has been tested. + +## 1.1.2 + +- Previous stable release. See the Git history for its detailed changes. diff --git a/README.fr.md b/README.fr.md index 772abcd..d4c25de 100644 --- a/README.fr.md +++ b/README.fr.md @@ -21,6 +21,12 @@ La première référence est publiée sur [Docker Hub](https://hub.docker.com/r/ La branche `integration` publie uniquement le tag mutable `integration`. Elle ne remplace jamais `latest` ni un tag de release. +## Notes de mise à niveau + +La prochaine release renforce plusieurs permissions et peut nécessiter une adaptation des profils. L'inspection d'un conteneur demande `allow_inspect: true` ; la création d'une session exec demande à la fois `exec: true` et `post: true` ; enfin, les profils à portée limitée ne peuvent plus effectuer d'écritures globales sur les images, volumes ou réseaux. Les options de profil CLI inconnues sont rejetées afin qu'une faute de frappe ne produise pas silencieusement une politique inattendue. + +Consultez la procédure de migration complète dans [CHANGELOG.md](CHANGELOG.md) avant de remplacer une image `1.1.2` ou antérieure. Testez d'abord le tag `integration`, puis utilisez le tag immuable de la release lorsqu'il sera publié. + L'image publiée est analysée en continu par [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). Le rapport est lié ici plutôt que figé dans le README : son résultat suit les mises à jour des vulnérabilités et de l'image. ## Ce qui le différencie diff --git a/README.md b/README.md index 2375b7a..445e1f5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ Docker Hub is the primary registry; GitHub Container Registry is also available. The `integration` branch publishes only the mutable `integration` tag. It never replaces `latest` or a release tag. +## Upgrade notes + +The next release tightens several permissions and may require profile changes. Container inspection needs `allow_inspect: true`; creating exec sessions needs both `exec: true` and `post: true`; and scoped profiles cannot perform global image, volume, or network writes. Unknown CLI profile options are rejected so that a typo cannot silently produce an unintended policy. + +Review the complete migration checklist in [CHANGELOG.md](CHANGELOG.md) before moving an existing deployment from `1.1.2` or an older image. Test with the `integration` tag first, then use the immutable release tag when it is published. + The published image is continuously analysed by [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). The live report is linked rather than hard-coded here, so its result always reflects current image and vulnerability data. ## Why this proxy is different From ac1b2bb469d52b40bc1905952d610faea51228ca Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 21:53:52 +0200 Subject: [PATCH 17/23] ci validate multiarch and safe signing --- .github/workflows/docker-image.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 583ee16..c89709e 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -52,6 +52,9 @@ jobs: - name: Vulnerability audit run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -61,6 +64,7 @@ jobs: context: . file: ./Dockerfile push: false + platforms: linux/amd64,linux/arm64 provenance: false sbom: false @@ -148,8 +152,13 @@ jobs: DIGEST: ${{ steps.build.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} run: | + if [ -z "$TAGS" ]; then + echo "no tags to sign" + exit 0 + fi while IFS= read -r tag; do - [ -n "$tag" ] && cosign sign --yes "$tag@$DIGEST" + [ -n "$tag" ] || continue + cosign sign --yes "$tag@$DIGEST" done < Date: Mon, 24 Aug 2026 21:54:37 +0200 Subject: [PATCH 18/23] fix fatal server exit status --- src/main.go | 14 +++++++++++++- src/main_test.go | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main.go b/src/main.go index e210e42..2ffe678 100644 --- a/src/main.go +++ b/src/main.go @@ -110,6 +110,13 @@ func main() { logger.Printf("[main] listening on %s, docker socket=%s, discover every %s, debounce=%s, profilesFile=%s", cfg.Listen, cfg.SocketPath, cfg.DiscoverInterval, cfg.DebounceDelay, cfg.ProfilesFile) + if err := serveUntilShutdown(ctx, stop, srv, logger); err != nil { + logger.Printf("[main] fatal server error: %v", err) + os.Exit(1) + } +} + +func serveUntilShutdown(ctx context.Context, stop context.CancelFunc, srv *http.Server, logger *log.Logger) error { serverErrors := make(chan error, 1) go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -117,15 +124,20 @@ func main() { } }() + var serverErr error select { case <-ctx.Done(): case err := <-serverErrors: logger.Printf("[main] http server error: %v", err) + serverErr = err stop() } logger.Printf("[main] shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - _ = srv.Shutdown(shutdownCtx) + if err := srv.Shutdown(shutdownCtx); err != nil && serverErr == nil { + return err + } + return serverErr } diff --git a/src/main_test.go b/src/main_test.go index 52f2c2f..e20a7ab 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -265,6 +265,17 @@ func TestContainerExecRequiresExecAndPost(t *testing.T) { } } +func TestServeUntilShutdownReturnsFatalListenError(t *testing.T) { + ctx, stop := context.WithCancel(context.Background()) + defer stop() + srv := &http.Server{Addr: "invalid listen address"} + + err := serveUntilShutdown(ctx, stop, srv, log.New(io.Discard, "", 0)) + if err == nil { + t.Fatal("fatal listen error was reported as a clean shutdown") + } +} + func TestFeaturePermissionMatrix(t *testing.T) { tests := map[string]ServiceConfig{ "ping": {Ping: true}, "version": {Version: true}, "info": {Info: true}, From b200757d13ca5c36acd48434eaf02de83fb2ceda Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 21:57:07 +0200 Subject: [PATCH 19/23] harden profile parsing and container resolution --- README.fr.md | 2 ++ README.md | 2 ++ src/config.go | 43 ++++++++++++----------------------- src/main.go | 6 ++++- src/main_test.go | 49 ++++++++++++++++++++++++++++++++-------- src/policy.go | 6 +++++ src/types.go | 59 ++++++++++++++++++++++++++++++++++++++++-------- 7 files changed, 118 insertions(+), 49 deletions(-) diff --git a/README.fr.md b/README.fr.md index d4c25de..abb967c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -250,6 +250,8 @@ Toutes ces options valent `false` par défaut. `allow_restarts` reste un alias d La création d'une session exec via `POST /containers/{id}/exec` exige les trois droits explicites `containers: true`, `exec: true` et `post: true`. `allow_all` n'active jamais `exec`. +`GET /containers/{id}/stats` reste volontairement inclus dans le droit général de lecture `containers`. Cette route expose la télémétrie d'exécution, respecte la portée de conteneurs configurée et ne possède pas de commutateur `allow_stats` distinct. + `allow_all: true` est un raccourci groupé mais limité à la portée pour toutes les options `allow_*` du tableau. Ce n'est volontairement **pas** un droit Docker global : il n'active ni `containers`, ni `exec`, ni `post`, ni une autre famille d'API et ne contourne pas les portées de conteneurs. L'envoi d'une archive et les autres écritures génériques nécessitent donc toujours `post: true`. Traitez-le comme un droit à fort impact : `export` peut lire tout le système de fichiers du conteneur et la lecture d'archive peut exposer n'importe quel fichier de la cible. Exemple minimal limité au cycle de vie : diff --git a/README.md b/README.md index 445e1f5..aa7c1ae 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,8 @@ All these options default to `false`. `allow_restarts` remains an alias for `all Creating an exec session with `POST /containers/{id}/exec` requires all three explicit grants: `containers: true`, `exec: true`, and `post: true`. `allow_all` never enables `exec`. +`GET /containers/{id}/stats` deliberately remains part of the general `containers` read permission. It exposes runtime telemetry, follows the configured container scope, and has no independent `allow_stats` switch. + `allow_all: true` is a grouped but scoped convenience shortcut for every `allow_*` option in the table. It is deliberately **not** a global Docker permission: it does not enable `containers`, `exec`, `post`, any other API family, or bypass container scopes. Archive upload and other generic writes therefore still require `post: true`. Treat it as a high-impact permission: `export` can read the complete container filesystem and archive reads can disclose arbitrary files inside the selected container. Minimal lifecycle-only example: diff --git a/src/config.go b/src/config.go index 4a34ebe..df29cb1 100644 --- a/src/config.go +++ b/src/config.go @@ -305,7 +305,7 @@ func debounceDelayFromEnv(logger *log.Logger) time.Duration { return def } -func parseConfig(args []string, logger *log.Logger) *ProxyConfig { +func parseConfig(args []string, logger *log.Logger) (*ProxyConfig, error) { // Fichier de profils par défaut profilesPath := strings.TrimSpace(os.Getenv("SOCKETPROXY_PROFILE_FILE")) if profilesPath == "" { @@ -330,17 +330,18 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { } cfg := &ProxyConfig{ - Listen: listen, - SocketPath: socketPath, - DiscoverInterval: discoverIntervalFromEnv(logger), - DebounceDelay: debounceDelayFromEnv(logger), - ProfilesFile: profilesPath, - baseServices: make(map[string]*ServiceConfig), - services: make(map[string]*ServiceConfig), - ipToRole: make(map[string]string), - selfNetworks: make(map[string]struct{}), - containersByRef: make(map[string]dockerContainerMeta), - execToContainer: make(map[string]dockerExecCacheEntry), + Listen: listen, + SocketPath: socketPath, + DiscoverInterval: discoverIntervalFromEnv(logger), + DebounceDelay: debounceDelayFromEnv(logger), + ProfilesFile: profilesPath, + baseServices: make(map[string]*ServiceConfig), + services: make(map[string]*ServiceConfig), + ipToRole: make(map[string]string), + selfNetworks: make(map[string]struct{}), + containersByRef: make(map[string]dockerContainerMeta), + missingContainers: make(map[string]time.Time), + execToContainer: make(map[string]dockerExecCacheEntry), } for _, arg := range args { @@ -400,7 +401,7 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { role := normalizeRoleName(profileKey) svc := ensureService(cfg.baseServices, role) if err := applyFlagValue(svc, flagKey, valStr); err != nil { - logger.Printf("[config] WARNING profile=%q option=%q: %v", role, flagKey, err) + return nil, fmt.Errorf("profile %q option %q: %w", role, flagKey, err) } } else { role := normalizeRoleName(opt) @@ -432,24 +433,13 @@ func parseConfig(args []string, logger *log.Logger) *ProxyConfig { } } - return cfg + return cfg, nil } // ----------------------------- // Parser YAML des profils // ----------------------------- -var knownProfileKeys = map[string]struct{}{ - "ping": {}, "version": {}, "info": {}, "events": {}, "event": {}, "auth": {}, - "build": {}, "commit": {}, "configs": {}, "containers": {}, "distribution": {}, - "exec": {}, "images": {}, "networks": {}, "nodes": {}, "plugins": {}, "secrets": {}, - "services": {}, "session": {}, "swarm": {}, "system": {}, "tasks": {}, "volumes": {}, - "post": {}, "allow_all": {}, "allow_archive": {}, "allow_changes": {}, "allow_export": {}, "allow_inspect": {}, "allow_logs": {}, - "allow_pause": {}, "allow_restart": {}, "allow_restarts": {}, "allow_start": {}, "allow_stop": {}, - "allow_top": {}, "allow_unpause": {}, "allow_kill": {}, - "apirewrite": {}, "container_scope": {}, "allowed_containers": {}, "blocked_containers": {}, "container_rules": {}, -} - func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { var raw map[string]map[string]any if err := yaml.Unmarshal([]byte(content), &raw); err != nil { @@ -467,9 +457,6 @@ func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { } svc := ensureService(profiles, role) for key, value := range values { - if _, known := knownProfileKeys[key]; !known { - return nil, fmt.Errorf("profile %q: unknown key %q", role, key) - } switch key { case "allowed_containers", "blocked_containers": items, ok := value.([]any) diff --git a/src/main.go b/src/main.go index 2ffe678..3b287ea 100644 --- a/src/main.go +++ b/src/main.go @@ -28,7 +28,11 @@ func main() { logger := log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds) logger.Printf("[main] starting docker-socket-proxy version=%s git=%s", version, gitSha) - cfg := parseConfig(os.Args[1:], logger) + cfg, err := parseConfig(os.Args[1:], logger) + if err != nil { + logger.Printf("[main] invalid configuration: %v", err) + os.Exit(2) + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/src/main_test.go b/src/main_test.go index e20a7ab..594efd4 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -40,7 +40,10 @@ func TestParseConfigUsesEnvironment(t *testing.T) { t.Setenv("DOCKER_SOCKET_PATH", "/run/custom.sock") t.Setenv("SOCKETPROXY_PROFILE_FILE", "/tmp/profiles.yml") - cfg := parseConfig(nil, log.New(io.Discard, "", 0)) + cfg, err := parseConfig(nil, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } if cfg.Listen != ":4242" { t.Fatalf("Listen = %q, want %q", cfg.Listen, ":4242") } @@ -56,7 +59,10 @@ func TestParseConfigCLIOverridesEnvironment(t *testing.T) { t.Setenv("PROXY_PORT", "4242") t.Setenv("DOCKER_SOCKET_PATH", "/run/env.sock") - cfg := parseConfig([]string{"--listen=:5252", "--socket=/run/cli.sock"}, log.New(io.Discard, "", 0)) + cfg, err := parseConfig([]string{"--listen=:5252", "--socket=/run/cli.sock"}, log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } if cfg.Listen != ":5252" { t.Fatalf("Listen = %q, want %q", cfg.Listen, ":5252") } @@ -65,14 +71,9 @@ func TestParseConfigCLIOverridesEnvironment(t *testing.T) { } } -func TestParseConfigWarnsAboutUnknownProfileOption(t *testing.T) { - var logs bytes.Buffer - cfg := parseConfig([]string{"--traefik.pingg=1"}, log.New(&logs, "", 0)) - if !strings.Contains(logs.String(), `WARNING profile="traefik" option="pingg"`) { - t.Fatalf("unknown option was not reported: %q", logs.String()) - } - if cfg.GetService("traefik") == nil { - t.Fatal("deny-by-default profile was not retained") +func TestParseConfigRejectsUnknownProfileOption(t *testing.T) { + if _, err := parseConfig([]string{"--traefik.pingg=1"}, log.New(io.Discard, "", 0)); err == nil { + t.Fatal("unknown CLI profile option was accepted") } } @@ -368,6 +369,34 @@ func TestResolveExecContainerRejectsMissingContainerID(t *testing.T) { } } +func TestResolveContainerCachesNotFoundResponses(t *testing.T) { + requests := 0 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests++ + return &http.Response{StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found"))}, nil + })} + cfg := &ProxyConfig{containersByRef: make(map[string]dockerContainerMeta), missingContainers: make(map[string]time.Time)} + + for i := 0; i < 2; i++ { + if _, err := resolveContainer(context.Background(), cfg, client, "missing"); err == nil { + t.Fatal("missing container was resolved") + } + } + if requests != 1 { + t.Fatalf("Docker received %d missing-container inspections, want 1", requests) + } +} + +func TestMissingContainerCacheIsBounded(t *testing.T) { + cfg := &ProxyConfig{missingContainers: make(map[string]time.Time)} + for i := 0; i <= maxMissingContainerEntries; i++ { + cfg.MarkContainerMissing(fmt.Sprintf("missing-%d", i)) + } + if got := len(cfg.missingContainers); got != maxMissingContainerEntries { + t.Fatalf("missing-container cache size = %d, want %d", got, maxMissingContainerEntries) + } +} + func TestEventDebouncerModes(t *testing.T) { t.Run("zero delay", func(t *testing.T) { calls := 0 diff --git a/src/policy.go b/src/policy.go index 138860c..32ec55a 100644 --- a/src/policy.go +++ b/src/policy.go @@ -253,6 +253,9 @@ func resolveContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client if meta, ok := cfg.GetContainer(ref); ok { return meta, nil } + if cfg.ContainerRecentlyMissing(ref) { + return dockerContainerMeta{}, fmt.Errorf("docker container %q was not found recently", ref) + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://unix/containers/"+url.PathEscape(normalizeContainerRef(ref))+"/json", nil) if err != nil { return dockerContainerMeta{}, err @@ -263,6 +266,9 @@ func resolveContainer(ctx context.Context, cfg *ProxyConfig, client *http.Client } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + cfg.MarkContainerMissing(ref) + } return dockerContainerMeta{}, fmt.Errorf("docker inspect container %q: status %d", ref, resp.StatusCode) } var inspect dockerContainerInspect diff --git a/src/types.go b/src/types.go index deca1d3..2bbf745 100644 --- a/src/types.go +++ b/src/types.go @@ -86,9 +86,10 @@ type ProxyConfig struct { selfNetworks map[string]struct{} selfNetworksHash string - containerMu sync.RWMutex - containersByRef map[string]dockerContainerMeta - execToContainer map[string]dockerExecCacheEntry + containerMu sync.RWMutex + containersByRef map[string]dockerContainerMeta + missingContainers map[string]time.Time + execToContainer map[string]dockerExecCacheEntry } // Getters thread-safe @@ -127,6 +128,7 @@ func (c *ProxyConfig) SetContainerIndex(m map[string]dockerContainerMeta) { c.containerMu.Lock() defer c.containerMu.Unlock() c.containersByRef = m + c.missingContainers = make(map[string]time.Time) } func (c *ProxyConfig) GetContainer(ref string) (dockerContainerMeta, bool) { @@ -139,14 +141,49 @@ func (c *ProxyConfig) GetContainer(ref string) (dockerContainerMeta, bool) { func (c *ProxyConfig) UpsertContainer(meta dockerContainerMeta) { c.containerMu.Lock() defer c.containerMu.Unlock() - next := make(map[string]dockerContainerMeta, len(c.containersByRef)+3) - for k, v := range c.containersByRef { - next[k] = v + if c.containersByRef == nil { + c.containersByRef = make(map[string]dockerContainerMeta) } for _, ref := range meta.refs() { - next[ref] = meta + c.containersByRef[ref] = meta + delete(c.missingContainers, ref) } - c.containersByRef = next +} + +func (c *ProxyConfig) ContainerRecentlyMissing(ref string) bool { + c.containerMu.Lock() + defer c.containerMu.Unlock() + key := normalizeContainerRef(ref) + expiresAt, ok := c.missingContainers[key] + if !ok { + return false + } + if time.Now().After(expiresAt) { + delete(c.missingContainers, key) + return false + } + return true +} + +func (c *ProxyConfig) MarkContainerMissing(ref string) { + c.containerMu.Lock() + defer c.containerMu.Unlock() + if c.missingContainers == nil { + c.missingContainers = make(map[string]time.Time) + } + now := time.Now() + for key, expiresAt := range c.missingContainers { + if now.After(expiresAt) { + delete(c.missingContainers, key) + } + } + if len(c.missingContainers) >= maxMissingContainerEntries { + for key := range c.missingContainers { + delete(c.missingContainers, key) + break + } + } + c.missingContainers[normalizeContainerRef(ref)] = now.Add(missingContainerTTL) } func (c *ProxyConfig) SetExecContainer(execID, containerID string) { @@ -183,8 +220,10 @@ func (c *ProxyConfig) GetExecContainer(execID string) (string, bool) { } const ( - execCacheTTL = 15 * time.Minute - maxExecCacheEntries = 4096 + execCacheTTL = 15 * time.Minute + maxExecCacheEntries = 4096 + missingContainerTTL = 10 * time.Second + maxMissingContainerEntries = 1024 ) type dockerExecCacheEntry struct { From 29aee44c1a002395b20965bcb7c3dee1ba5b6018 Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 22:25:48 +0200 Subject: [PATCH 20/23] fix yaml profile key validation --- src/config.go | 21 +++++++++++++++++++++ src/main_test.go | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/config.go b/src/config.go index df29cb1..f92f0ff 100644 --- a/src/config.go +++ b/src/config.go @@ -440,6 +440,24 @@ func parseConfig(args []string, logger *log.Logger) (*ProxyConfig, error) { // Parser YAML des profils // ----------------------------- +func validateYAMLProfileKey(key string) error { + switch key { + case "container_rules": + // Structured YAML-only option, handled directly by parseProfilesYAML. + return nil + case "allowed_container": + return fmt.Errorf("unknown profile option %q; use %q in YAML", key, "allowed_containers") + case "blocked_container": + return fmt.Errorf("unknown profile option %q; use %q in YAML", key, "blocked_containers") + case "container_rule": + return fmt.Errorf("unknown profile option %q; use %q in YAML", key, "container_rules") + default: + // Use the canonical option parser as the single source of truth. The + // scratch service is discarded; only option-name validation matters. + return applyFlagValue(&ServiceConfig{}, key, "") + } +} + func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { var raw map[string]map[string]any if err := yaml.Unmarshal([]byte(content), &raw); err != nil { @@ -457,6 +475,9 @@ func parseProfilesYAML(content string) (map[string]*ServiceConfig, error) { } svc := ensureService(profiles, role) for key, value := range values { + if err := validateYAMLProfileKey(key); err != nil { + return nil, fmt.Errorf("profile %q: %w", role, err) + } switch key { case "allowed_containers", "blocked_containers": items, ok := value.([]any) diff --git a/src/main_test.go b/src/main_test.go index 594efd4..a4af704 100644 --- a/src/main_test.go +++ b/src/main_test.go @@ -560,9 +560,38 @@ func TestParseProfilesYAMLRejectsInvalidContainerRule(t *testing.T) { } func TestParseProfilesYAMLRejectsUnknownKey(t *testing.T) { - _, err := parseProfilesYAML("manager:\n containers: true\n allowd_containers: []\n") - if err == nil { - t.Fatal("unknown profile key was accepted") + for _, tt := range []struct { + name string + yaml string + want string + mustNotSay string + }{ + {"unknown list", "manager:\n allowd_containers: []\n", `unknown profile option "allowd_containers"`, "must be a scalar"}, + {"unknown scalar", "manager:\n allowd_containers: true\n", `unknown profile option "allowd_containers"`, "must be a scalar"}, + {"list option with scalar", "manager:\n allowed_containers: \"a,b\"\n", "allowed_containers must be a YAML list", "unknown profile option"}, + {"scalar option with list", "manager:\n containers: []\n", "containers must be a scalar", "unknown profile option"}, + } { + t.Run(tt.name, func(t *testing.T) { + _, err := parseProfilesYAML(tt.yaml) + if err == nil || !strings.Contains(err.Error(), tt.want) || strings.Contains(err.Error(), tt.mustNotSay) { + t.Fatalf("error = %v, want %q and not %q", err, tt.want, tt.mustNotSay) + } + }) + } +} + +func TestParseProfilesYAMLRejectsCLISingularAliases(t *testing.T) { + for key, plural := range map[string]string{ + "allowed_container": "allowed_containers", + "blocked_container": "blocked_containers", + "container_rule": "container_rules", + } { + t.Run(key, func(t *testing.T) { + _, err := parseProfilesYAML("manager:\n " + key + ": \"a:readonly\"\n") + if err == nil || !strings.Contains(err.Error(), `unknown profile option "`+key+`"`) || !strings.Contains(err.Error(), `use "`+plural+`" in YAML`) { + t.Fatalf("unexpected alias error: %v", err) + } + }) } } From e38961d8a008f7112dc1f8414936ea5e01582a8a Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 22:26:33 +0200 Subject: [PATCH 21/23] ci cross compile multiarch binaries --- .github/workflows/docker-image.yml | 6 ------ Dockerfile | 6 ++++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c89709e..4cefe1d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -52,9 +52,6 @@ jobs: - name: Vulnerability audit run: go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./... - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -96,9 +93,6 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/Dockerfile b/Dockerfile index aecf6b4..4af1b44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -FROM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build +FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS build ARG APP_VERSION="dev" ARG APP_GIT_SHA="unknown" +ARG TARGETOS +ARG TARGETARCH ENV CGO_ENABLED=0 @@ -10,7 +12,7 @@ WORKDIR /src COPY go.mod go.sum ./ COPY src/ ./src -RUN go build -trimpath \ +RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath \ -ldflags="-s -w -X main.version=${APP_VERSION} -X main.gitSha=${APP_GIT_SHA}" \ -o /out/docker-socket-proxy ./src From 7e907bfe830eff965dc5c3bc3289949cee9742fb Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 22:27:37 +0200 Subject: [PATCH 22/23] docs prepare 1.2.0 release migration --- CHANGELOG.md | 2 +- README.fr.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acc26a6..e186edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes are documented here. Release tags use semantic versioning. - Use an unscoped, explicitly privileged profile only when a client genuinely needs global image, volume, or network writes. - Unknown CLI profile options are rejected during startup rather than being silently ignored. Correct any reported typo before restarting. -These changes will be included in the next tagged release. Pin an existing immutable tag such as `1.1.2` until the profile migration has been tested. +These changes are scheduled for `1.2.0`. Test the profile migration with the `integration` image first; once `v1.2.0` is published, pin the immutable `1.2.0` tag instead of relying on `latest`. ## 1.1.2 diff --git a/README.fr.md b/README.fr.md index abb967c..c2cc13f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -25,7 +25,7 @@ La branche `integration` publie uniquement le tag mutable `integration`. Elle ne La prochaine release renforce plusieurs permissions et peut nécessiter une adaptation des profils. L'inspection d'un conteneur demande `allow_inspect: true` ; la création d'une session exec demande à la fois `exec: true` et `post: true` ; enfin, les profils à portée limitée ne peuvent plus effectuer d'écritures globales sur les images, volumes ou réseaux. Les options de profil CLI inconnues sont rejetées afin qu'une faute de frappe ne produise pas silencieusement une politique inattendue. -Consultez la procédure de migration complète dans [CHANGELOG.md](CHANGELOG.md) avant de remplacer une image `1.1.2` ou antérieure. Testez d'abord le tag `integration`, puis utilisez le tag immuable de la release lorsqu'il sera publié. +Consultez la procédure de migration complète dans [CHANGELOG.md](CHANGELOG.md) avant de remplacer une image `1.1.2` ou antérieure. Testez d'abord le tag `integration` ; une fois la migration validée et `v1.2.0` publié, épinglez le tag immuable `1.2.0` plutôt que `latest`. L'image publiée est analysée en continu par [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). Le rapport est lié ici plutôt que figé dans le README : son résultat suit les mises à jour des vulnérabilités et de l'image. diff --git a/README.md b/README.md index aa7c1ae..4d690c5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The `integration` branch publishes only the mutable `integration` tag. It never The next release tightens several permissions and may require profile changes. Container inspection needs `allow_inspect: true`; creating exec sessions needs both `exec: true` and `post: true`; and scoped profiles cannot perform global image, volume, or network writes. Unknown CLI profile options are rejected so that a typo cannot silently produce an unintended policy. -Review the complete migration checklist in [CHANGELOG.md](CHANGELOG.md) before moving an existing deployment from `1.1.2` or an older image. Test with the `integration` tag first, then use the immutable release tag when it is published. +Review the complete migration checklist in [CHANGELOG.md](CHANGELOG.md) before moving an existing deployment from `1.1.2` or an older image. Test with the `integration` tag first; once the migration is validated and `v1.2.0` is published, pin the immutable `1.2.0` tag rather than `latest`. The published image is continuously analysed by [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). The live report is linked rather than hard-coded here, so its result always reflects current image and vulnerability data. From 74f6dac7c3b4094e9424126473df19392aefd9cc Mon Sep 17 00:00:00 2001 From: Benjy Date: Mon, 24 Aug 2026 22:47:57 +0200 Subject: [PATCH 23/23] release 1.2.0 --- CHANGELOG.md | 4 ++-- README.fr.md | 4 ++-- README.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e186edf..f246899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes are documented here. Release tags use semantic versioning. -## Unreleased +## 1.2.0 - 2026-08-24 ### Security @@ -18,7 +18,7 @@ All notable changes are documented here. Release tags use semantic versioning. - Use an unscoped, explicitly privileged profile only when a client genuinely needs global image, volume, or network writes. - Unknown CLI profile options are rejected during startup rather than being silently ignored. Correct any reported typo before restarting. -These changes are scheduled for `1.2.0`. Test the profile migration with the `integration` image first; once `v1.2.0` is published, pin the immutable `1.2.0` tag instead of relying on `latest`. +Review and apply the migration notes before upgrading, then pin the immutable `1.2.0` tag instead of relying on `latest`. ## 1.1.2 diff --git a/README.fr.md b/README.fr.md index c2cc13f..25277bb 100644 --- a/README.fr.md +++ b/README.fr.md @@ -23,9 +23,9 @@ La branche `integration` publie uniquement le tag mutable `integration`. Elle ne ## Notes de mise à niveau -La prochaine release renforce plusieurs permissions et peut nécessiter une adaptation des profils. L'inspection d'un conteneur demande `allow_inspect: true` ; la création d'une session exec demande à la fois `exec: true` et `post: true` ; enfin, les profils à portée limitée ne peuvent plus effectuer d'écritures globales sur les images, volumes ou réseaux. Les options de profil CLI inconnues sont rejetées afin qu'une faute de frappe ne produise pas silencieusement une politique inattendue. +La release `1.2.0` renforce plusieurs permissions et peut nécessiter une adaptation des profils. L'inspection d'un conteneur demande `allow_inspect: true` ; la création d'une session exec demande à la fois `exec: true` et `post: true` ; enfin, les profils à portée limitée ne peuvent plus effectuer d'écritures globales sur les images, volumes ou réseaux. Les options de profil CLI inconnues sont rejetées afin qu'une faute de frappe ne produise pas silencieusement une politique inattendue. -Consultez la procédure de migration complète dans [CHANGELOG.md](CHANGELOG.md) avant de remplacer une image `1.1.2` ou antérieure. Testez d'abord le tag `integration` ; une fois la migration validée et `v1.2.0` publié, épinglez le tag immuable `1.2.0` plutôt que `latest`. +Consultez la procédure de migration complète dans [CHANGELOG.md](CHANGELOG.md) avant de remplacer une image `1.1.2` ou antérieure, puis épinglez le tag immuable `1.2.0` plutôt que `latest`. L'image publiée est analysée en continu par [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). Le rapport est lié ici plutôt que figé dans le README : son résultat suit les mises à jour des vulnérabilités et de l'image. diff --git a/README.md b/README.md index 4d690c5..bfc7366 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ The `integration` branch publishes only the mutable `integration` tag. It never ## Upgrade notes -The next release tightens several permissions and may require profile changes. Container inspection needs `allow_inspect: true`; creating exec sessions needs both `exec: true` and `post: true`; and scoped profiles cannot perform global image, volume, or network writes. Unknown CLI profile options are rejected so that a typo cannot silently produce an unintended policy. +Release `1.2.0` tightens several permissions and may require profile changes. Container inspection needs `allow_inspect: true`; creating exec sessions needs both `exec: true` and `post: true`; and scoped profiles cannot perform global image, volume, or network writes. Unknown CLI profile options are rejected so that a typo cannot silently produce an unintended policy. -Review the complete migration checklist in [CHANGELOG.md](CHANGELOG.md) before moving an existing deployment from `1.1.2` or an older image. Test with the `integration` tag first; once the migration is validated and `v1.2.0` is published, pin the immutable `1.2.0` tag rather than `latest`. +Review the complete migration checklist in [CHANGELOG.md](CHANGELOG.md) before moving an existing deployment from `1.1.2` or an older image, then pin the immutable `1.2.0` tag rather than `latest`. The published image is continuously analysed by [Docker Scout](https://scout.docker.com/reports/org/cerede2000/images/host/hub.docker.com/repo/cerede2000%2Fdocker-socket-proxy). The live report is linked rather than hard-coded here, so its result always reflects current image and vulnerability data.