feat(security): guard app container configs and disable process dumping
- commit
- 49efad03a5f5400a1b7604437e90789c88407b1b
- author
- brice · · 2026-10-08 20:32 UTC
- parent
- e683751
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +602 -3
M internal/adapters/in/http/admin/handler_apps.go +2 -0
| @@ -559,6 +559,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) { | |||
| 559 | 559 | h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization") | |
| 560 | 560 | case errors.Is(err, domain.ErrDevicePolicy): | |
| 561 | 561 | h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization") | |
| 562 | + | case errors.Is(err, domain.ErrRuntimeGuard): | |
| 563 | + | h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings") | |
| 562 | 564 | case errors.Is(err, domain.ErrRuntimeUnsupported): | |
| 563 | 565 | h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support") | |
| 564 | 566 | case errors.Is(err, domain.ErrAppReservationConflict): | |
| @@ -559,6 +559,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) { | |||||
| 559 | h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization") | 559 | h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization") | ||
| 560 | case errors.Is(err, domain.ErrDevicePolicy): | 560 | case errors.Is(err, domain.ErrDevicePolicy): | ||
| 561 | h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization") | 561 | h.sendAppError(w, http.StatusBadRequest, "device-policy-violation", err.Error(), "", "check the administrative device authorization") | ||
| 562 | + | case errors.Is(err, domain.ErrRuntimeGuard): | |||
| 563 | + | h.sendAppError(w, http.StatusBadRequest, "runtime-guard-violation", err.Error(), "", "check administrative mounts and network settings") | |||
| 562 | case errors.Is(err, domain.ErrRuntimeUnsupported): | 564 | case errors.Is(err, domain.ErrRuntimeUnsupported): | ||
| 563 | h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support") | 565 | h.sendAppError(w, http.StatusBadRequest, "runtime-unsupported", err.Error(), "", "use an engine with native CDI support") | ||
| 564 | case errors.Is(err, domain.ErrAppReservationConflict): | 566 | case errors.Is(err, domain.ErrAppReservationConflict): | ||
M internal/adapters/in/http/admin/handler_apps_test.go +17 -0
| @@ -682,6 +682,23 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) { | |||
| 682 | 682 | assert.Equal(t, "device-policy-violation", envelope.Error) | |
| 683 | 683 | } | |
| 684 | 684 | ||
| 685 | + | func TestHandler_AppDeploy_MapsRuntimeGuardViolation(t *testing.T) { | |
| 686 | + | appSvc := inmocks.NewMockAppService(t) | |
| 687 | + | handler := appsTestHandler(t, appSvc) | |
| 688 | + | ||
| 689 | + | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return( | |
| 690 | + | nil, | |
| 691 | + | fmt.Errorf("deployment: create container for service %q: %w: bind %q overlaps a protected Gordon path", "web", domain.ErrRuntimeGuard, "conf"), | |
| 692 | + | ).Once() | |
| 693 | + | ||
| 694 | + | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy", | |
| 695 | + | dto.AppDeployRequest{}, "admin:apps:write") | |
| 696 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 697 | + | var envelope dto.AppError | |
| 698 | + | require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) | |
| 699 | + | assert.Equal(t, "runtime-guard-violation", envelope.Error) | |
| 700 | + | } | |
| 701 | + | ||
| 685 | 702 | func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) { | |
| 686 | 703 | appSvc := inmocks.NewMockAppService(t) | |
| 687 | 704 | handler := appsTestHandler(t, appSvc) | |
| @@ -682,6 +682,23 @@ func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) { | |||||
| 682 | assert.Equal(t, "device-policy-violation", envelope.Error) | 682 | assert.Equal(t, "device-policy-violation", envelope.Error) | ||
| 683 | } | 683 | } | ||
| 684 | 684 | ||||
| 685 | + | func TestHandler_AppDeploy_MapsRuntimeGuardViolation(t *testing.T) { | |||
| 686 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 687 | + | handler := appsTestHandler(t, appSvc) | |||
| 688 | + | ||||
| 689 | + | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return( | |||
| 690 | + | nil, | |||
| 691 | + | fmt.Errorf("deployment: create container for service %q: %w: bind %q overlaps a protected Gordon path", "web", domain.ErrRuntimeGuard, "conf"), | |||
| 692 | + | ).Once() | |||
| 693 | + | ||||
| 694 | + | rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy", | |||
| 695 | + | dto.AppDeployRequest{}, "admin:apps:write") | |||
| 696 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 697 | + | var envelope dto.AppError | |||
| 698 | + | require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) | |||
| 699 | + | assert.Equal(t, "runtime-guard-violation", envelope.Error) | |||
| 700 | + | } | |||
| 701 | + | ||||
| 685 | func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) { | 702 | func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) { | ||
| 686 | appSvc := inmocks.NewMockAppService(t) | 703 | appSvc := inmocks.NewMockAppService(t) | ||
| 687 | handler := appsTestHandler(t, appSvc) | 704 | handler := appsTestHandler(t, appSvc) | ||
M internal/adapters/out/secretfiles/store.go +5 -1
| @@ -38,7 +38,11 @@ var _ out.SecretFileStore = (*Store)(nil) | |||
| 38 | 38 | ||
| 39 | 39 | // NewStore opens (creating if needed) <dataDir>/secrets-run. | |
| 40 | 40 | func NewStore(dataDir string) (*Store, error) { | |
| 41 | - | dir := filepath.Join(dataDir, "secrets-run") | |
| 41 | + | // Bind sources must be absolute: resolve a relative data dir once. | |
| 42 | + | dir, err := filepath.Abs(filepath.Join(dataDir, "secrets-run")) | |
| 43 | + | if err != nil { | |
| 44 | + | return nil, fmt.Errorf("secret files: resolve base directory: %w", err) | |
| 45 | + | } | |
| 42 | 46 | if err := os.MkdirAll(dir, dirMode); err != nil { | |
| 43 | 47 | return nil, fmt.Errorf("secret files: create base directory: %w", err) | |
| 44 | 48 | } | |
| @@ -38,7 +38,11 @@ var _ out.SecretFileStore = (*Store)(nil) | |||||
| 38 | 38 | ||||
| 39 | // NewStore opens (creating if needed) <dataDir>/secrets-run. | 39 | // NewStore opens (creating if needed) <dataDir>/secrets-run. | ||
| 40 | func NewStore(dataDir string) (*Store, error) { | 40 | func NewStore(dataDir string) (*Store, error) { | ||
| 41 | - | dir := filepath.Join(dataDir, "secrets-run") | 41 | + | // Bind sources must be absolute: resolve a relative data dir once. |
| 42 | + | dir, err := filepath.Abs(filepath.Join(dataDir, "secrets-run")) | |||
| 43 | + | if err != nil { | |||
| 44 | + | return nil, fmt.Errorf("secret files: resolve base directory: %w", err) | |||
| 45 | + | } | |||
| 42 | if err := os.MkdirAll(dir, dirMode); err != nil { | 46 | if err := os.MkdirAll(dir, dirMode); err != nil { | ||
| 43 | return nil, fmt.Errorf("secret files: create base directory: %w", err) | 47 | return nil, fmt.Errorf("secret files: create base directory: %w", err) | ||
| 44 | } | 48 | } | ||
M internal/app/app_wiring.go +11 -0
| @@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error { | |||
| 88 | 88 | if err != nil { | |
| 89 | 89 | return err | |
| 90 | 90 | } | |
| 91 | + | secretFiles, guard, err := buildAppRuntimeSafety(si, dataDir) | |
| 92 | + | if err != nil { | |
| 93 | + | return si.log.WrapErr(err, "failed to prepare app runtime safety") | |
| 94 | + | } | |
| 91 | 95 | si.svc.appDeploySvc = deployment.NewService(deployment.Deps{ | |
| 92 | 96 | State: store, | |
| 93 | 97 | Runtime: si.svc.runtime, | |
| @@ -106,7 +110,14 @@ func (si *serviceInit) initApps() error { | |||
| 106 | 110 | Limits: limits, | |
| 107 | 111 | ImagePolicy: imagePolicy, | |
| 108 | 112 | Traffic: si.svc.appTrafficPublisher, | |
| 113 | + | SecretFiles: secretFiles, | |
| 114 | + | Guard: guard, | |
| 109 | 115 | }, si.log).WithGCBarrier(si.svc.gcBarrier) | |
| 116 | + | // createServices runs before any listener starts, so no deploy can | |
| 117 | + | // race this sweep of secret files left by crashes or downgrades. | |
| 118 | + | if err := si.svc.appDeploySvc.SweepSecretFiles(si.ctx); err != nil { | |
| 119 | + | si.log.Warn().Err(err).Msg("failed to sweep orphan secret files") | |
| 120 | + | } | |
| 110 | 121 | si.svc.appActivator = apptraffic.NewActivator(si.log) | |
| 111 | 122 | mountPolicies, err := buildAppMountPolicies(si.cfg) | |
| 112 | 123 | if err != nil { | |
| @@ -88,6 +88,10 @@ func (si *serviceInit) initApps() error { | |||||
| 88 | if err != nil { | 88 | if err != nil { | ||
| 89 | return err | 89 | return err | ||
| 90 | } | 90 | } | ||
| 91 | + | secretFiles, guard, err := buildAppRuntimeSafety(si, dataDir) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return si.log.WrapErr(err, "failed to prepare app runtime safety") | |||
| 94 | + | } | |||
| 91 | si.svc.appDeploySvc = deployment.NewService(deployment.Deps{ | 95 | si.svc.appDeploySvc = deployment.NewService(deployment.Deps{ | ||
| 92 | State: store, | 96 | State: store, | ||
| 93 | Runtime: si.svc.runtime, | 97 | Runtime: si.svc.runtime, | ||
| @@ -106,7 +110,14 @@ func (si *serviceInit) initApps() error { | |||||
| 106 | Limits: limits, | 110 | Limits: limits, | ||
| 107 | ImagePolicy: imagePolicy, | 111 | ImagePolicy: imagePolicy, | ||
| 108 | Traffic: si.svc.appTrafficPublisher, | 112 | Traffic: si.svc.appTrafficPublisher, | ||
| 113 | + | SecretFiles: secretFiles, | |||
| 114 | + | Guard: guard, | |||
| 109 | }, si.log).WithGCBarrier(si.svc.gcBarrier) | 115 | }, si.log).WithGCBarrier(si.svc.gcBarrier) | ||
| 116 | + | // createServices runs before any listener starts, so no deploy can | |||
| 117 | + | // race this sweep of secret files left by crashes or downgrades. | |||
| 118 | + | if err := si.svc.appDeploySvc.SweepSecretFiles(si.ctx); err != nil { | |||
| 119 | + | si.log.Warn().Err(err).Msg("failed to sweep orphan secret files") | |||
| 120 | + | } | |||
| 110 | si.svc.appActivator = apptraffic.NewActivator(si.log) | 121 | si.svc.appActivator = apptraffic.NewActivator(si.log) | ||
| 111 | mountPolicies, err := buildAppMountPolicies(si.cfg) | 122 | mountPolicies, err := buildAppMountPolicies(si.cfg) | ||
| 112 | if err != nil { | 123 | if err != nil { | ||
A internal/app/dumpable_linux.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import "golang.org/x/sys/unix" | |
| 6 | + | ||
| 7 | + | // disableDumpable marks the daemon non-dumpable: no core dumps, and other | |
| 8 | + | // processes of the same UID can no longer read /proc/<pid>/environ, mem, | |
| 9 | + | // or fds, nor ptrace it. Root keeps that access. | |
| 10 | + | func disableDumpable() error { | |
| 11 | + | return unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) | |
| 12 | + | } | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import "golang.org/x/sys/unix" | |||
| 6 | + | ||||
| 7 | + | // disableDumpable marks the daemon non-dumpable: no core dumps, and other | |||
| 8 | + | // processes of the same UID can no longer read /proc/<pid>/environ, mem, | |||
| 9 | + | // or fds, nor ptrace it. Root keeps that access. | |||
| 10 | + | func disableDumpable() error { | |||
| 11 | + | return unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) | |||
| 12 | + | } | |||
A internal/app/dumpable_linux_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | "golang.org/x/sys/unix" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestDisableDumpable(t *testing.T) { | |
| 17 | + | t.Cleanup(func() { _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) }) | |
| 18 | + | ||
| 19 | + | require.NoError(t, disableDumpable()) | |
| 20 | + | got, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | assert.Equal(t, 0, got) | |
| 23 | + | ||
| 24 | + | // readFileBeneath reads secrets through /proc/self/fd/N; the kernel | |
| 25 | + | // keeps that self access for a non-dumpable process. | |
| 26 | + | path := filepath.Join(t.TempDir(), "secret") | |
| 27 | + | require.NoError(t, os.WriteFile(path, []byte("value"), 0o600)) | |
| 28 | + | fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC, 0) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | defer unix.Close(fd) | |
| 31 | + | data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd)) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | assert.Equal(t, "value", string(data)) | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | "golang.org/x/sys/unix" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestDisableDumpable(t *testing.T) { | |||
| 17 | + | t.Cleanup(func() { _ = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0) }) | |||
| 18 | + | ||||
| 19 | + | require.NoError(t, disableDumpable()) | |||
| 20 | + | got, err := unix.PrctlRetInt(unix.PR_GET_DUMPABLE, 0, 0, 0, 0) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | assert.Equal(t, 0, got) | |||
| 23 | + | ||||
| 24 | + | // readFileBeneath reads secrets through /proc/self/fd/N; the kernel | |||
| 25 | + | // keeps that self access for a non-dumpable process. | |||
| 26 | + | path := filepath.Join(t.TempDir(), "secret") | |||
| 27 | + | require.NoError(t, os.WriteFile(path, []byte("value"), 0o600)) | |||
| 28 | + | fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC, 0) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | defer unix.Close(fd) | |||
| 31 | + | data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd)) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | assert.Equal(t, "value", string(data)) | |||
| 34 | + | } | |||
A internal/app/dumpable_other.go +6 -0
| @@ -0,0 +1,6 @@ | |||
| 1 | + | //go:build !linux | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | // disableDumpable is Linux-only; other platforms keep their defaults. | |
| 6 | + | func disableDumpable() error { return nil } | |
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | //go:build !linux | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | // disableDumpable is Linux-only; other platforms keep their defaults. | |||
| 6 | + | func disableDumpable() error { return nil } | |||
M internal/app/run.go +6 -0
| @@ -32,6 +32,12 @@ import ( | |||
| 32 | 32 | ||
| 33 | 33 | // Run initializes and starts the Gordon application. | |
| 34 | 34 | func Run(ctx context.Context, configPath string) error { | |
| 35 | + | // Before any secret is loaded: keep same-UID processes out of this | |
| 36 | + | // process's /proc entries (environ, mem, fds) and disable core dumps. | |
| 37 | + | if err := disableDumpable(); err != nil { | |
| 38 | + | return fmt.Errorf("failed to disable process dumping: %w", err) | |
| 39 | + | } | |
| 40 | + | ||
| 35 | 41 | // Load configuration | |
| 36 | 42 | v, cfg, err := initConfig(configPath) | |
| 37 | 43 | if err != nil { | |
| @@ -32,6 +32,12 @@ import ( | |||||
| 32 | 32 | ||||
| 33 | // Run initializes and starts the Gordon application. | 33 | // Run initializes and starts the Gordon application. | ||
| 34 | func Run(ctx context.Context, configPath string) error { | 34 | func Run(ctx context.Context, configPath string) error { | ||
| 35 | + | // Before any secret is loaded: keep same-UID processes out of this | |||
| 36 | + | // process's /proc entries (environ, mem, fds) and disable core dumps. | |||
| 37 | + | if err := disableDumpable(); err != nil { | |||
| 38 | + | return fmt.Errorf("failed to disable process dumping: %w", err) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 35 | // Load configuration | 41 | // Load configuration | ||
| 36 | v, cfg, err := initConfig(configPath) | 42 | v, cfg, err := initConfig(configPath) | ||
| 37 | if err != nil { | 43 | if err != nil { | ||
A internal/app/runtime_guard.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/adapters/out/secretfiles" | |
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // protectedRuntimePaths lists the host paths no app bind may equal, lie | |
| 15 | + | // under, or contain: Gordon's data and config, runtime sockets, Gordon's | |
| 16 | + | // runtime credentials, and key stores. Empty entries are skipped; each | |
| 17 | + | // entry is cleaned and its symlink-resolved form is added when it differs. | |
| 18 | + | func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv func(string) string, home string) []string { | |
| 19 | + | runtimeDir := getenv("XDG_RUNTIME_DIR") | |
| 20 | + | configHome := getenv("XDG_CONFIG_HOME") | |
| 21 | + | if configHome == "" { | |
| 22 | + | configHome = homePath(home, ".config") | |
| 23 | + | } | |
| 24 | + | dockerHost, _ := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://") | |
| 25 | + | if strings.Contains(dockerHost, "://") { | |
| 26 | + | dockerHost = "" | |
| 27 | + | } | |
| 28 | + | raw := []string{ | |
| 29 | + | dataDir, | |
| 30 | + | configFile, | |
| 31 | + | "/var/run/docker.sock", | |
| 32 | + | "/run/docker.sock", | |
| 33 | + | "/run/podman/podman.sock", | |
| 34 | + | subPath(runtimeDir, "docker.sock"), | |
| 35 | + | subPath(runtimeDir, "podman", "podman.sock"), | |
| 36 | + | subPath(runtimeDir, "gordon"), | |
| 37 | + | homePath(home, ".gordon", "run"), | |
| 38 | + | dockerHost, | |
| 39 | + | runtimeSocket, | |
| 40 | + | getenv("GNUPGHOME"), | |
| 41 | + | homePath(home, ".gnupg"), | |
| 42 | + | getenv("PASSWORD_STORE_DIR"), | |
| 43 | + | homePath(home, ".password-store"), | |
| 44 | + | getenv("SOPS_AGE_KEY_FILE"), | |
| 45 | + | subPath(configHome, "sops", "age"), | |
| 46 | + | } | |
| 47 | + | var paths []string | |
| 48 | + | for _, p := range raw { | |
| 49 | + | if p == "" { | |
| 50 | + | continue | |
| 51 | + | } | |
| 52 | + | // A relative data dir or config path is resolved against the | |
| 53 | + | // working directory, as the daemon itself resolves it. | |
| 54 | + | clean, err := filepath.Abs(p) | |
| 55 | + | if err != nil { | |
| 56 | + | continue | |
| 57 | + | } | |
| 58 | + | paths = append(paths, clean) | |
| 59 | + | if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean { | |
| 60 | + | paths = append(paths, resolved) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | slices.Sort(paths) | |
| 64 | + | return slices.Compact(paths) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // subPath joins elems under base, or returns "" when base is empty. | |
| 68 | + | func subPath(base string, elems ...string) string { | |
| 69 | + | if base == "" { | |
| 70 | + | return "" | |
| 71 | + | } | |
| 72 | + | return filepath.Join(append([]string{base}, elems...)...) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // homePath joins elems under the home directory, or returns "" when the | |
| 76 | + | // home directory is unknown. | |
| 77 | + | func homePath(home string, elems ...string) string { | |
| 78 | + | return subPath(home, elems...) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // buildAppRuntimeSafety opens the host secret file store and builds the | |
| 82 | + | // runtime guard applied to every app container create. | |
| 83 | + | func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) { | |
| 84 | + | store, err := secretfiles.NewStore(dataDir) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, domain.RuntimeGuard{}, err | |
| 87 | + | } | |
| 88 | + | home, _ := os.UserHomeDir() | |
| 89 | + | var configFile, runtimeSocket string | |
| 90 | + | if si.v != nil { | |
| 91 | + | configFile = si.v.ConfigFileUsed() | |
| 92 | + | runtimeSocket = resolveRuntimeConfig(si.v.GetString("server.runtime")) | |
| 93 | + | } | |
| 94 | + | paths := protectedRuntimePaths(dataDir, configFile, runtimeSocket, os.Getenv, home) | |
| 95 | + | return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "slices" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/adapters/out/secretfiles" | |||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // protectedRuntimePaths lists the host paths no app bind may equal, lie | |||
| 15 | + | // under, or contain: Gordon's data and config, runtime sockets, Gordon's | |||
| 16 | + | // runtime credentials, and key stores. Empty entries are skipped; each | |||
| 17 | + | // entry is cleaned and its symlink-resolved form is added when it differs. | |||
| 18 | + | func protectedRuntimePaths(dataDir, configFile, runtimeSocket string, getenv func(string) string, home string) []string { | |||
| 19 | + | runtimeDir := getenv("XDG_RUNTIME_DIR") | |||
| 20 | + | configHome := getenv("XDG_CONFIG_HOME") | |||
| 21 | + | if configHome == "" { | |||
| 22 | + | configHome = homePath(home, ".config") | |||
| 23 | + | } | |||
| 24 | + | dockerHost, _ := strings.CutPrefix(getenv("DOCKER_HOST"), "unix://") | |||
| 25 | + | if strings.Contains(dockerHost, "://") { | |||
| 26 | + | dockerHost = "" | |||
| 27 | + | } | |||
| 28 | + | raw := []string{ | |||
| 29 | + | dataDir, | |||
| 30 | + | configFile, | |||
| 31 | + | "/var/run/docker.sock", | |||
| 32 | + | "/run/docker.sock", | |||
| 33 | + | "/run/podman/podman.sock", | |||
| 34 | + | subPath(runtimeDir, "docker.sock"), | |||
| 35 | + | subPath(runtimeDir, "podman", "podman.sock"), | |||
| 36 | + | subPath(runtimeDir, "gordon"), | |||
| 37 | + | homePath(home, ".gordon", "run"), | |||
| 38 | + | dockerHost, | |||
| 39 | + | runtimeSocket, | |||
| 40 | + | getenv("GNUPGHOME"), | |||
| 41 | + | homePath(home, ".gnupg"), | |||
| 42 | + | getenv("PASSWORD_STORE_DIR"), | |||
| 43 | + | homePath(home, ".password-store"), | |||
| 44 | + | getenv("SOPS_AGE_KEY_FILE"), | |||
| 45 | + | subPath(configHome, "sops", "age"), | |||
| 46 | + | } | |||
| 47 | + | var paths []string | |||
| 48 | + | for _, p := range raw { | |||
| 49 | + | if p == "" { | |||
| 50 | + | continue | |||
| 51 | + | } | |||
| 52 | + | // A relative data dir or config path is resolved against the | |||
| 53 | + | // working directory, as the daemon itself resolves it. | |||
| 54 | + | clean, err := filepath.Abs(p) | |||
| 55 | + | if err != nil { | |||
| 56 | + | continue | |||
| 57 | + | } | |||
| 58 | + | paths = append(paths, clean) | |||
| 59 | + | if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean { | |||
| 60 | + | paths = append(paths, resolved) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | slices.Sort(paths) | |||
| 64 | + | return slices.Compact(paths) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // subPath joins elems under base, or returns "" when base is empty. | |||
| 68 | + | func subPath(base string, elems ...string) string { | |||
| 69 | + | if base == "" { | |||
| 70 | + | return "" | |||
| 71 | + | } | |||
| 72 | + | return filepath.Join(append([]string{base}, elems...)...) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // homePath joins elems under the home directory, or returns "" when the | |||
| 76 | + | // home directory is unknown. | |||
| 77 | + | func homePath(home string, elems ...string) string { | |||
| 78 | + | return subPath(home, elems...) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // buildAppRuntimeSafety opens the host secret file store and builds the | |||
| 82 | + | // runtime guard applied to every app container create. | |||
| 83 | + | func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore, domain.RuntimeGuard, error) { | |||
| 84 | + | store, err := secretfiles.NewStore(dataDir) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, domain.RuntimeGuard{}, err | |||
| 87 | + | } | |||
| 88 | + | home, _ := os.UserHomeDir() | |||
| 89 | + | var configFile, runtimeSocket string | |||
| 90 | + | if si.v != nil { | |||
| 91 | + | configFile = si.v.ConfigFileUsed() | |||
| 92 | + | runtimeSocket = resolveRuntimeConfig(si.v.GetString("server.runtime")) | |||
| 93 | + | } | |||
| 94 | + | paths := protectedRuntimePaths(dataDir, configFile, runtimeSocket, os.Getenv, home) | |||
| 95 | + | return store, domain.RuntimeGuard{ProtectedPaths: paths, SecretFilesDir: store.Dir()}, nil | |||
| 96 | + | } | |||
A internal/app/runtime_guard_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func fakeEnv(values map[string]string) func(string) string { | |
| 15 | + | return func(key string) string { return values[key] } | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |
| 19 | + | home := t.TempDir() | |
| 20 | + | env := fakeEnv(map[string]string{ | |
| 21 | + | "XDG_RUNTIME_DIR": "/run/user/1000", | |
| 22 | + | "DOCKER_HOST": "unix:///x/d.sock", | |
| 23 | + | "GNUPGHOME": "/keys/gnupg", | |
| 24 | + | "PASSWORD_STORE_DIR": "/keys/pass", | |
| 25 | + | "SOPS_AGE_KEY_FILE": "/keys/age.txt", | |
| 26 | + | "XDG_CONFIG_HOME": "/cfg", | |
| 27 | + | }) | |
| 28 | + | got := protectedRuntimePaths("/data/gordon", "/etc/gordon/gordon.toml", "/custom/podman.sock", env, home) | |
| 29 | + | ||
| 30 | + | for _, want := range []string{ | |
| 31 | + | "/data/gordon", | |
| 32 | + | "/etc/gordon/gordon.toml", | |
| 33 | + | "/var/run/docker.sock", | |
| 34 | + | "/run/docker.sock", | |
| 35 | + | "/run/podman/podman.sock", | |
| 36 | + | "/run/user/1000/docker.sock", | |
| 37 | + | "/run/user/1000/podman/podman.sock", | |
| 38 | + | "/run/user/1000/gordon", | |
| 39 | + | filepath.Join(home, ".gordon", "run"), | |
| 40 | + | "/x/d.sock", | |
| 41 | + | "/custom/podman.sock", | |
| 42 | + | "/keys/gnupg", | |
| 43 | + | filepath.Join(home, ".gnupg"), | |
| 44 | + | "/keys/pass", | |
| 45 | + | filepath.Join(home, ".password-store"), | |
| 46 | + | "/keys/age.txt", | |
| 47 | + | "/cfg/sops/age", | |
| 48 | + | } { | |
| 49 | + | assert.Contains(t, got, want) | |
| 50 | + | } | |
| 51 | + | assert.IsIncreasing(t, got) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) { | |
| 55 | + | got := protectedRuntimePaths("/data", "", "", fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "") | |
| 56 | + | assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) { | |
| 60 | + | base := t.TempDir() | |
| 61 | + | real := filepath.Join(base, "real") | |
| 62 | + | require.NoError(t, os.Mkdir(real, 0o700)) | |
| 63 | + | link := filepath.Join(base, "link") | |
| 64 | + | require.NoError(t, os.Symlink(real, link)) | |
| 65 | + | realResolved, err := filepath.EvalSymlinks(real) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | ||
| 68 | + | got := protectedRuntimePaths(link, "", "", fakeEnv(nil), "") | |
| 69 | + | assert.Contains(t, got, link) | |
| 70 | + | assert.Contains(t, got, realResolved) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestBuildAppRuntimeSafety(t *testing.T) { | |
| 74 | + | dataDir := t.TempDir() | |
| 75 | + | store, guard, err := buildAppRuntimeSafety(&serviceInit{}, dataDir) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.NotNil(t, store) | |
| 78 | + | assert.Equal(t, filepath.Join(dataDir, "secrets-run"), guard.SecretFilesDir) | |
| 79 | + | assert.Contains(t, guard.ProtectedPaths, dataDir) | |
| 80 | + | ||
| 81 | + | // The data dir itself cannot be bound into an app. | |
| 82 | + | err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}}) | |
| 83 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func fakeEnv(values map[string]string) func(string) string { | |||
| 15 | + | return func(key string) string { return values[key] } | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestProtectedRuntimePaths_AllSources(t *testing.T) { | |||
| 19 | + | home := t.TempDir() | |||
| 20 | + | env := fakeEnv(map[string]string{ | |||
| 21 | + | "XDG_RUNTIME_DIR": "/run/user/1000", | |||
| 22 | + | "DOCKER_HOST": "unix:///x/d.sock", | |||
| 23 | + | "GNUPGHOME": "/keys/gnupg", | |||
| 24 | + | "PASSWORD_STORE_DIR": "/keys/pass", | |||
| 25 | + | "SOPS_AGE_KEY_FILE": "/keys/age.txt", | |||
| 26 | + | "XDG_CONFIG_HOME": "/cfg", | |||
| 27 | + | }) | |||
| 28 | + | got := protectedRuntimePaths("/data/gordon", "/etc/gordon/gordon.toml", "/custom/podman.sock", env, home) | |||
| 29 | + | ||||
| 30 | + | for _, want := range []string{ | |||
| 31 | + | "/data/gordon", | |||
| 32 | + | "/etc/gordon/gordon.toml", | |||
| 33 | + | "/var/run/docker.sock", | |||
| 34 | + | "/run/docker.sock", | |||
| 35 | + | "/run/podman/podman.sock", | |||
| 36 | + | "/run/user/1000/docker.sock", | |||
| 37 | + | "/run/user/1000/podman/podman.sock", | |||
| 38 | + | "/run/user/1000/gordon", | |||
| 39 | + | filepath.Join(home, ".gordon", "run"), | |||
| 40 | + | "/x/d.sock", | |||
| 41 | + | "/custom/podman.sock", | |||
| 42 | + | "/keys/gnupg", | |||
| 43 | + | filepath.Join(home, ".gnupg"), | |||
| 44 | + | "/keys/pass", | |||
| 45 | + | filepath.Join(home, ".password-store"), | |||
| 46 | + | "/keys/age.txt", | |||
| 47 | + | "/cfg/sops/age", | |||
| 48 | + | } { | |||
| 49 | + | assert.Contains(t, got, want) | |||
| 50 | + | } | |||
| 51 | + | assert.IsIncreasing(t, got) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) { | |||
| 55 | + | got := protectedRuntimePaths("/data", "", "", fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "") | |||
| 56 | + | assert.Equal(t, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"}, got) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) { | |||
| 60 | + | base := t.TempDir() | |||
| 61 | + | real := filepath.Join(base, "real") | |||
| 62 | + | require.NoError(t, os.Mkdir(real, 0o700)) | |||
| 63 | + | link := filepath.Join(base, "link") | |||
| 64 | + | require.NoError(t, os.Symlink(real, link)) | |||
| 65 | + | realResolved, err := filepath.EvalSymlinks(real) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | ||||
| 68 | + | got := protectedRuntimePaths(link, "", "", fakeEnv(nil), "") | |||
| 69 | + | assert.Contains(t, got, link) | |||
| 70 | + | assert.Contains(t, got, realResolved) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestBuildAppRuntimeSafety(t *testing.T) { | |||
| 74 | + | dataDir := t.TempDir() | |||
| 75 | + | store, guard, err := buildAppRuntimeSafety(&serviceInit{}, dataDir) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.NotNil(t, store) | |||
| 78 | + | assert.Equal(t, filepath.Join(dataDir, "secrets-run"), guard.SecretFilesDir) | |||
| 79 | + | assert.Contains(t, guard.ProtectedPaths, dataDir) | |||
| 80 | + | ||||
| 81 | + | // The data dir itself cannot be bound into an app. | |||
| 82 | + | err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}}) | |||
| 83 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |||
| 84 | + | } | |||
M internal/domain/errors.go +4 -0
| @@ -83,6 +83,10 @@ var ( | |||
| 83 | 83 | // distinct from ErrInvalidAppSpec: the manifest may be valid while the | |
| 84 | 84 | // installation policy refuses to serve it. | |
| 85 | 85 | ErrDevicePolicy = errors.New("device policy violation") | |
| 86 | + | // ErrRuntimeGuard wraps app container configs refused by the runtime | |
| 87 | + | // guard: host or shared namespaces, host paths passed as volume | |
| 88 | + | // names, or binds overlapping Gordon's own sensitive paths. | |
| 89 | + | ErrRuntimeGuard = errors.New("runtime guard violation") | |
| 86 | 90 | ||
| 87 | 91 | // App state errors | |
| 88 | 92 | ErrAppStateIO = errors.New("app state storage failure") | |
| @@ -83,6 +83,10 @@ var ( | |||||
| 83 | // distinct from ErrInvalidAppSpec: the manifest may be valid while the | 83 | // distinct from ErrInvalidAppSpec: the manifest may be valid while the | ||
| 84 | // installation policy refuses to serve it. | 84 | // installation policy refuses to serve it. | ||
| 85 | ErrDevicePolicy = errors.New("device policy violation") | 85 | ErrDevicePolicy = errors.New("device policy violation") | ||
| 86 | + | // ErrRuntimeGuard wraps app container configs refused by the runtime | |||
| 87 | + | // guard: host or shared namespaces, host paths passed as volume | |||
| 88 | + | // names, or binds overlapping Gordon's own sensitive paths. | |||
| 89 | + | ErrRuntimeGuard = errors.New("runtime guard violation") | |||
| 86 | 90 | ||||
| 87 | // App state errors | 91 | // App state errors | ||
| 88 | ErrAppStateIO = errors.New("app state storage failure") | 92 | ErrAppStateIO = errors.New("app state storage failure") | ||
A internal/domain/runtime_guard.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // RuntimeGuard is the last check on every app container config before it | |
| 10 | + | // reaches the runtime. It refuses configs that would give a workload | |
| 11 | + | // access to the host or to Gordon itself, whatever produced them. | |
| 12 | + | // | |
| 13 | + | // The zero value still refuses host/shared network namespaces and host | |
| 14 | + | // paths passed as volume names; ProtectedPaths adds the bind checks. | |
| 15 | + | type RuntimeGuard struct { | |
| 16 | + | // ProtectedPaths are absolute host paths no bind may equal, lie under, | |
| 17 | + | // or contain (data dir, config, runtime sockets, key stores). | |
| 18 | + | ProtectedPaths []string | |
| 19 | + | // SecretFilesDir is the host base directory of file-delivered secrets | |
| 20 | + | // (<data_dir>/secrets-run). Gordon's own read-only secret binds under | |
| 21 | + | // it are the only allowed exception. | |
| 22 | + | SecretFilesDir string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Check refuses a config that breaks the guard. Errors wrap | |
| 26 | + | // ErrRuntimeGuard and name the bind, volume destination, or network mode, | |
| 27 | + | // never a host path. | |
| 28 | + | func (g RuntimeGuard) Check(config *ContainerConfig) error { | |
| 29 | + | if err := checkGuardNetworkMode(config.NetworkMode); err != nil { | |
| 30 | + | return err | |
| 31 | + | } | |
| 32 | + | if err := checkGuardVolumes(config.Volumes); err != nil { | |
| 33 | + | return err | |
| 34 | + | } | |
| 35 | + | if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | for _, bind := range config.Binds { | |
| 39 | + | if g.isOwnSecretBind(config, bind) { | |
| 40 | + | continue | |
| 41 | + | } | |
| 42 | + | if g.overlapsProtected(bind.Source) { | |
| 43 | + | return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func checkGuardNetworkMode(mode string) error { | |
| 50 | + | if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") { | |
| 51 | + | return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode)) | |
| 52 | + | } | |
| 53 | + | return nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // guardNetworkLabel keeps the mode kind but drops a namespace path. | |
| 57 | + | func guardNetworkLabel(mode string) string { | |
| 58 | + | if kind, _, ok := strings.Cut(mode, ":"); ok { | |
| 59 | + | return kind + ":" | |
| 60 | + | } | |
| 61 | + | return mode | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // checkGuardVolumes refuses volume names the runtime would read as host | |
| 65 | + | // paths and turn into binds. | |
| 66 | + | func checkGuardVolumes(volumes map[string]string) error { | |
| 67 | + | for dest, name := range volumes { | |
| 68 | + | if strings.ContainsAny(name, `/\`) { | |
| 69 | + | return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | return nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // isOwnSecretBind reports whether bind is exactly the read-only secret file | |
| 76 | + | // mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at | |
| 77 | + | // /run/secrets/<KEY>. | |
| 78 | + | func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool { | |
| 79 | + | if g.SecretFilesDir == "" || !bind.ReadOnly { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | app := config.Labels[LabelApp] | |
| 83 | + | if app == "" || config.Name == "" { | |
| 84 | + | return false | |
| 85 | + | } | |
| 86 | + | key := filepath.Base(bind.Source) | |
| 87 | + | if err := ValidateEnvKey(key); err != nil { | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) && | |
| 91 | + | bind.Destination == SecretFilePath(key) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // overlapsProtected reports whether source, raw or symlink-resolved, is | |
| 95 | + | // equal to, under, or an ancestor of a protected path. | |
| 96 | + | func (g RuntimeGuard) overlapsProtected(source string) bool { | |
| 97 | + | for _, candidate := range guardCandidates(source) { | |
| 98 | + | for _, protected := range g.ProtectedPaths { | |
| 99 | + | if resolvedUnderRoot(protected, candidate) || resolvedUnderRoot(candidate, protected) { | |
| 100 | + | return true | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func guardCandidates(source string) []string { | |
| 108 | + | clean := filepath.Clean(source) | |
| 109 | + | candidates := []string{clean} | |
| 110 | + | if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean { | |
| 111 | + | candidates = append(candidates, resolved) | |
| 112 | + | } | |
| 113 | + | return candidates | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // RuntimeGuard is the last check on every app container config before it | |||
| 10 | + | // reaches the runtime. It refuses configs that would give a workload | |||
| 11 | + | // access to the host or to Gordon itself, whatever produced them. | |||
| 12 | + | // | |||
| 13 | + | // The zero value still refuses host/shared network namespaces and host | |||
| 14 | + | // paths passed as volume names; ProtectedPaths adds the bind checks. | |||
| 15 | + | type RuntimeGuard struct { | |||
| 16 | + | // ProtectedPaths are absolute host paths no bind may equal, lie under, | |||
| 17 | + | // or contain (data dir, config, runtime sockets, key stores). | |||
| 18 | + | ProtectedPaths []string | |||
| 19 | + | // SecretFilesDir is the host base directory of file-delivered secrets | |||
| 20 | + | // (<data_dir>/secrets-run). Gordon's own read-only secret binds under | |||
| 21 | + | // it are the only allowed exception. | |||
| 22 | + | SecretFilesDir string | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Check refuses a config that breaks the guard. Errors wrap | |||
| 26 | + | // ErrRuntimeGuard and name the bind, volume destination, or network mode, | |||
| 27 | + | // never a host path. | |||
| 28 | + | func (g RuntimeGuard) Check(config *ContainerConfig) error { | |||
| 29 | + | if err := checkGuardNetworkMode(config.NetworkMode); err != nil { | |||
| 30 | + | return err | |||
| 31 | + | } | |||
| 32 | + | if err := checkGuardVolumes(config.Volumes); err != nil { | |||
| 33 | + | return err | |||
| 34 | + | } | |||
| 35 | + | if err := checkGuardVolumes(config.ReadOnlyVolumes); err != nil { | |||
| 36 | + | return err | |||
| 37 | + | } | |||
| 38 | + | for _, bind := range config.Binds { | |||
| 39 | + | if g.isOwnSecretBind(config, bind) { | |||
| 40 | + | continue | |||
| 41 | + | } | |||
| 42 | + | if g.overlapsProtected(bind.Source) { | |||
| 43 | + | return fmt.Errorf("%w: bind %q overlaps a protected Gordon path", ErrRuntimeGuard, bind.Name) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func checkGuardNetworkMode(mode string) error { | |||
| 50 | + | if mode == "host" || strings.HasPrefix(mode, "container:") || strings.HasPrefix(mode, "ns:") { | |||
| 51 | + | return fmt.Errorf("%w: network mode %q is not allowed", ErrRuntimeGuard, guardNetworkLabel(mode)) | |||
| 52 | + | } | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // guardNetworkLabel keeps the mode kind but drops a namespace path. | |||
| 57 | + | func guardNetworkLabel(mode string) string { | |||
| 58 | + | if kind, _, ok := strings.Cut(mode, ":"); ok { | |||
| 59 | + | return kind + ":" | |||
| 60 | + | } | |||
| 61 | + | return mode | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // checkGuardVolumes refuses volume names the runtime would read as host | |||
| 65 | + | // paths and turn into binds. | |||
| 66 | + | func checkGuardVolumes(volumes map[string]string) error { | |||
| 67 | + | for dest, name := range volumes { | |||
| 68 | + | if strings.ContainsAny(name, `/\`) { | |||
| 69 | + | return fmt.Errorf("%w: volume for %q is not a named volume", ErrRuntimeGuard, dest) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // isOwnSecretBind reports whether bind is exactly the read-only secret file | |||
| 76 | + | // mount Gordon creates: SecretFilesDir/<app>/<container>/<KEY> mounted at | |||
| 77 | + | // /run/secrets/<KEY>. | |||
| 78 | + | func (g RuntimeGuard) isOwnSecretBind(config *ContainerConfig, bind ContainerBind) bool { | |||
| 79 | + | if g.SecretFilesDir == "" || !bind.ReadOnly { | |||
| 80 | + | return false | |||
| 81 | + | } | |||
| 82 | + | app := config.Labels[LabelApp] | |||
| 83 | + | if app == "" || config.Name == "" { | |||
| 84 | + | return false | |||
| 85 | + | } | |||
| 86 | + | key := filepath.Base(bind.Source) | |||
| 87 | + | if err := ValidateEnvKey(key); err != nil { | |||
| 88 | + | return false | |||
| 89 | + | } | |||
| 90 | + | return bind.Source == filepath.Join(g.SecretFilesDir, app, config.Name, key) && | |||
| 91 | + | bind.Destination == SecretFilePath(key) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // overlapsProtected reports whether source, raw or symlink-resolved, is | |||
| 95 | + | // equal to, under, or an ancestor of a protected path. | |||
| 96 | + | func (g RuntimeGuard) overlapsProtected(source string) bool { | |||
| 97 | + | for _, candidate := range guardCandidates(source) { | |||
| 98 | + | for _, protected := range g.ProtectedPaths { | |||
| 99 | + | if resolvedUnderRoot(protected, candidate) || resolvedUnderRoot(candidate, protected) { | |||
| 100 | + | return true | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func guardCandidates(source string) []string { | |||
| 108 | + | clean := filepath.Clean(source) | |||
| 109 | + | candidates := []string{clean} | |||
| 110 | + | if resolved, err := filepath.EvalSymlinks(clean); err == nil && resolved != clean { | |||
| 111 | + | candidates = append(candidates, resolved) | |||
| 112 | + | } | |||
| 113 | + | return candidates | |||
| 114 | + | } | |||
A internal/domain/runtime_guard_test.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | package domain_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const guardContainer = "gordon-blog--web--x" | |
| 15 | + | ||
| 16 | + | func guardConfig(binds ...domain.ContainerBind) *domain.ContainerConfig { | |
| 17 | + | return &domain.ContainerConfig{ | |
| 18 | + | Name: guardContainer, | |
| 19 | + | NetworkMode: "gordon-app-1", | |
| 20 | + | Labels: map[string]string{domain.LabelApp: "blog"}, | |
| 21 | + | Binds: binds, | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestRuntimeGuard_NetworkModes(t *testing.T) { | |
| 26 | + | var guard domain.RuntimeGuard | |
| 27 | + | for _, mode := range []string{"host", "container:abc", "ns:/proc/1/ns/net"} { | |
| 28 | + | cfg := guardConfig() | |
| 29 | + | cfg.NetworkMode = mode | |
| 30 | + | err := guard.Check(cfg) | |
| 31 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard, mode) | |
| 32 | + | assert.NotContains(t, err.Error(), "/proc") | |
| 33 | + | } | |
| 34 | + | require.NoError(t, guard.Check(guardConfig())) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestRuntimeGuard_VolumeNames(t *testing.T) { | |
| 38 | + | var guard domain.RuntimeGuard | |
| 39 | + | for _, name := range []string{"/etc", "a/b", `a\b`} { | |
| 40 | + | cfg := guardConfig() | |
| 41 | + | cfg.Volumes = map[string]string{"/data": name} | |
| 42 | + | require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name) | |
| 43 | + | ||
| 44 | + | cfg = guardConfig() | |
| 45 | + | cfg.ReadOnlyVolumes = map[string]string{"/data": name} | |
| 46 | + | require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name) | |
| 47 | + | } | |
| 48 | + | cfg := guardConfig() | |
| 49 | + | cfg.Volumes = map[string]string{"/data": "gordon-blog--web--vol--data"} | |
| 50 | + | require.NoError(t, guard.Check(cfg)) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestRuntimeGuard_ProtectedBinds(t *testing.T) { | |
| 54 | + | base := t.TempDir() | |
| 55 | + | dataDir := filepath.Join(base, "data") | |
| 56 | + | protectedChild := filepath.Join(dataDir, "registry") | |
| 57 | + | require.NoError(t, os.MkdirAll(protectedChild, 0o700)) | |
| 58 | + | unrelated := filepath.Join(base, "public") | |
| 59 | + | require.NoError(t, os.MkdirAll(unrelated, 0o700)) | |
| 60 | + | link := filepath.Join(base, "link") | |
| 61 | + | require.NoError(t, os.Symlink(dataDir, link)) | |
| 62 | + | ||
| 63 | + | guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}} | |
| 64 | + | cases := map[string]string{ | |
| 65 | + | "equal": dataDir, | |
| 66 | + | "under": protectedChild, | |
| 67 | + | "ancestor": base, | |
| 68 | + | "symlink": link, | |
| 69 | + | } | |
| 70 | + | for name, source := range cases { | |
| 71 | + | t.Run(name, func(t *testing.T) { | |
| 72 | + | err := guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: source, Destination: "/etc/app", ReadOnly: true})) | |
| 73 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |
| 74 | + | assert.Contains(t, err.Error(), `"conf"`) | |
| 75 | + | assert.NotContains(t, err.Error(), base) | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | require.NoError(t, guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: unrelated, Destination: "/etc/app", ReadOnly: true}))) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestRuntimeGuard_OwnSecretBindException(t *testing.T) { | |
| 82 | + | dataDir := t.TempDir() | |
| 83 | + | secretDir := filepath.Join(dataDir, "secrets-run") | |
| 84 | + | guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: secretDir} | |
| 85 | + | own := domain.ContainerBind{ | |
| 86 | + | Name: "secret-file-DB", | |
| 87 | + | Source: filepath.Join(secretDir, "blog", guardContainer, "DB"), | |
| 88 | + | Destination: "/run/secrets/DB", | |
| 89 | + | ReadOnly: true, | |
| 90 | + | } | |
| 91 | + | require.NoError(t, guard.Check(guardConfig(own))) | |
| 92 | + | ||
| 93 | + | cases := map[string]func(*domain.ContainerConfig){ | |
| 94 | + | "read-write": func(c *domain.ContainerConfig) { c.Binds[0].ReadOnly = false }, | |
| 95 | + | "wrong dest": func(c *domain.ContainerConfig) { c.Binds[0].Destination = "/run/secrets/OTHER" }, | |
| 96 | + | "wrong app": func(c *domain.ContainerConfig) { c.Labels[domain.LabelApp] = "other" }, | |
| 97 | + | "no app label": func(c *domain.ContainerConfig) { c.Labels = nil }, | |
| 98 | + | "wrong name": func(c *domain.ContainerConfig) { c.Name = "gordon-blog--web--y" }, | |
| 99 | + | "extra segment": func(c *domain.ContainerConfig) { | |
| 100 | + | c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer, "sub", "DB") | |
| 101 | + | }, | |
| 102 | + | "directory bind": func(c *domain.ContainerConfig) { c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer) }, | |
| 103 | + | "whole base": func(c *domain.ContainerConfig) { c.Binds[0].Source = secretDir }, | |
| 104 | + | } | |
| 105 | + | for name, mutate := range cases { | |
| 106 | + | t.Run(name, func(t *testing.T) { | |
| 107 | + | cfg := guardConfig(own) | |
| 108 | + | mutate(cfg) | |
| 109 | + | err := guard.Check(cfg) | |
| 110 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |
| 111 | + | assert.NotContains(t, err.Error(), dataDir) | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package domain_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const guardContainer = "gordon-blog--web--x" | |||
| 15 | + | ||||
| 16 | + | func guardConfig(binds ...domain.ContainerBind) *domain.ContainerConfig { | |||
| 17 | + | return &domain.ContainerConfig{ | |||
| 18 | + | Name: guardContainer, | |||
| 19 | + | NetworkMode: "gordon-app-1", | |||
| 20 | + | Labels: map[string]string{domain.LabelApp: "blog"}, | |||
| 21 | + | Binds: binds, | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestRuntimeGuard_NetworkModes(t *testing.T) { | |||
| 26 | + | var guard domain.RuntimeGuard | |||
| 27 | + | for _, mode := range []string{"host", "container:abc", "ns:/proc/1/ns/net"} { | |||
| 28 | + | cfg := guardConfig() | |||
| 29 | + | cfg.NetworkMode = mode | |||
| 30 | + | err := guard.Check(cfg) | |||
| 31 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard, mode) | |||
| 32 | + | assert.NotContains(t, err.Error(), "/proc") | |||
| 33 | + | } | |||
| 34 | + | require.NoError(t, guard.Check(guardConfig())) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestRuntimeGuard_VolumeNames(t *testing.T) { | |||
| 38 | + | var guard domain.RuntimeGuard | |||
| 39 | + | for _, name := range []string{"/etc", "a/b", `a\b`} { | |||
| 40 | + | cfg := guardConfig() | |||
| 41 | + | cfg.Volumes = map[string]string{"/data": name} | |||
| 42 | + | require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name) | |||
| 43 | + | ||||
| 44 | + | cfg = guardConfig() | |||
| 45 | + | cfg.ReadOnlyVolumes = map[string]string{"/data": name} | |||
| 46 | + | require.ErrorIs(t, guard.Check(cfg), domain.ErrRuntimeGuard, name) | |||
| 47 | + | } | |||
| 48 | + | cfg := guardConfig() | |||
| 49 | + | cfg.Volumes = map[string]string{"/data": "gordon-blog--web--vol--data"} | |||
| 50 | + | require.NoError(t, guard.Check(cfg)) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestRuntimeGuard_ProtectedBinds(t *testing.T) { | |||
| 54 | + | base := t.TempDir() | |||
| 55 | + | dataDir := filepath.Join(base, "data") | |||
| 56 | + | protectedChild := filepath.Join(dataDir, "registry") | |||
| 57 | + | require.NoError(t, os.MkdirAll(protectedChild, 0o700)) | |||
| 58 | + | unrelated := filepath.Join(base, "public") | |||
| 59 | + | require.NoError(t, os.MkdirAll(unrelated, 0o700)) | |||
| 60 | + | link := filepath.Join(base, "link") | |||
| 61 | + | require.NoError(t, os.Symlink(dataDir, link)) | |||
| 62 | + | ||||
| 63 | + | guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}} | |||
| 64 | + | cases := map[string]string{ | |||
| 65 | + | "equal": dataDir, | |||
| 66 | + | "under": protectedChild, | |||
| 67 | + | "ancestor": base, | |||
| 68 | + | "symlink": link, | |||
| 69 | + | } | |||
| 70 | + | for name, source := range cases { | |||
| 71 | + | t.Run(name, func(t *testing.T) { | |||
| 72 | + | err := guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: source, Destination: "/etc/app", ReadOnly: true})) | |||
| 73 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |||
| 74 | + | assert.Contains(t, err.Error(), `"conf"`) | |||
| 75 | + | assert.NotContains(t, err.Error(), base) | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | require.NoError(t, guard.Check(guardConfig(domain.ContainerBind{Name: "conf", Source: unrelated, Destination: "/etc/app", ReadOnly: true}))) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestRuntimeGuard_OwnSecretBindException(t *testing.T) { | |||
| 82 | + | dataDir := t.TempDir() | |||
| 83 | + | secretDir := filepath.Join(dataDir, "secrets-run") | |||
| 84 | + | guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: secretDir} | |||
| 85 | + | own := domain.ContainerBind{ | |||
| 86 | + | Name: "secret-file-DB", | |||
| 87 | + | Source: filepath.Join(secretDir, "blog", guardContainer, "DB"), | |||
| 88 | + | Destination: "/run/secrets/DB", | |||
| 89 | + | ReadOnly: true, | |||
| 90 | + | } | |||
| 91 | + | require.NoError(t, guard.Check(guardConfig(own))) | |||
| 92 | + | ||||
| 93 | + | cases := map[string]func(*domain.ContainerConfig){ | |||
| 94 | + | "read-write": func(c *domain.ContainerConfig) { c.Binds[0].ReadOnly = false }, | |||
| 95 | + | "wrong dest": func(c *domain.ContainerConfig) { c.Binds[0].Destination = "/run/secrets/OTHER" }, | |||
| 96 | + | "wrong app": func(c *domain.ContainerConfig) { c.Labels[domain.LabelApp] = "other" }, | |||
| 97 | + | "no app label": func(c *domain.ContainerConfig) { c.Labels = nil }, | |||
| 98 | + | "wrong name": func(c *domain.ContainerConfig) { c.Name = "gordon-blog--web--y" }, | |||
| 99 | + | "extra segment": func(c *domain.ContainerConfig) { | |||
| 100 | + | c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer, "sub", "DB") | |||
| 101 | + | }, | |||
| 102 | + | "directory bind": func(c *domain.ContainerConfig) { c.Binds[0].Source = filepath.Join(secretDir, "blog", guardContainer) }, | |||
| 103 | + | "whole base": func(c *domain.ContainerConfig) { c.Binds[0].Source = secretDir }, | |||
| 104 | + | } | |||
| 105 | + | for name, mutate := range cases { | |||
| 106 | + | t.Run(name, func(t *testing.T) { | |||
| 107 | + | cfg := guardConfig(own) | |||
| 108 | + | mutate(cfg) | |||
| 109 | + | err := guard.Check(cfg) | |||
| 110 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |||
| 111 | + | assert.NotContains(t, err.Error(), dataDir) | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | } | |||
M internal/usecase/deployment/candidate.go +3 -0
| @@ -199,6 +199,9 @@ func (s *Service) createWithSecretFiles(ctx context.Context, app, service string | |||
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | 201 | func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) { | |
| 202 | + | if err := s.deps.Guard.Check(config); err != nil { | |
| 203 | + | return nil, fmt.Errorf("deployment: create container for service %q: %w", service, err) | |
| 204 | + | } | |
| 202 | 205 | created, err := s.deps.Runtime.CreateContainer(ctx, config) | |
| 203 | 206 | if err == nil { | |
| 204 | 207 | return created, nil | |
| @@ -199,6 +199,9 @@ func (s *Service) createWithSecretFiles(ctx context.Context, app, service string | |||||
| 199 | } | 199 | } | ||
| 200 | 200 | ||||
| 201 | func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) { | 201 | func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) { | ||
| 202 | + | if err := s.deps.Guard.Check(config); err != nil { | |||
| 203 | + | return nil, fmt.Errorf("deployment: create container for service %q: %w", service, err) | |||
| 204 | + | } | |||
| 202 | created, err := s.deps.Runtime.CreateContainer(ctx, config) | 205 | created, err := s.deps.Runtime.CreateContainer(ctx, config) | ||
| 203 | if err == nil { | 206 | if err == nil { | ||
| 204 | return created, nil | 207 | return created, nil | ||
M internal/usecase/deployment/execute.go +1 -1
| @@ -591,7 +591,7 @@ func (s *Service) deployService(ctx context.Context, app, revision string, p pin | |||
| 591 | 591 | // Revalidate the current authorization and source before withdrawing or | |
| 592 | 592 | // retiring the serving generation. A reload between preflight and this | |
| 593 | 593 | // service step must fail without mutating the existing workload. | |
| 594 | - | if _, err := s.resolveServiceBinds(app, p.spec); err != nil { | |
| 594 | + | if _, err := s.resolveGuardedBinds(app, p.spec); err != nil { | |
| 595 | 595 | return s.failResult(revision, before, "", err) | |
| 596 | 596 | } | |
| 597 | 597 | if _, err := s.resolveServiceDevices(app, p.spec); err != nil { | |
| @@ -591,7 +591,7 @@ func (s *Service) deployService(ctx context.Context, app, revision string, p pin | |||||
| 591 | // Revalidate the current authorization and source before withdrawing or | 591 | // Revalidate the current authorization and source before withdrawing or | ||
| 592 | // retiring the serving generation. A reload between preflight and this | 592 | // retiring the serving generation. A reload between preflight and this | ||
| 593 | // service step must fail without mutating the existing workload. | 593 | // service step must fail without mutating the existing workload. | ||
| 594 | - | if _, err := s.resolveServiceBinds(app, p.spec); err != nil { | 594 | + | if _, err := s.resolveGuardedBinds(app, p.spec); err != nil { |
| 595 | return s.failResult(revision, before, "", err) | 595 | return s.failResult(revision, before, "", err) | ||
| 596 | } | 596 | } | ||
| 597 | if _, err := s.resolveServiceDevices(app, p.spec); err != nil { | 597 | if _, err := s.resolveServiceDevices(app, p.spec); err != nil { | ||
A internal/usecase/deployment/runtime_guard_internal_test.go +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | package deployment | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func TestCreateContainer_GuardRefusesHostNetwork(t *testing.T) { | |
| 19 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 20 | + | svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()) | |
| 21 | + | ||
| 22 | + | _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{Name: "c", NetworkMode: "host"}) | |
| 23 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |
| 24 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestCreateContainer_GuardRefusesProtectedBind(t *testing.T) { | |
| 28 | + | protected := t.TempDir() | |
| 29 | + | child := filepath.Join(protected, "registry") | |
| 30 | + | require.NoError(t, os.MkdirAll(child, 0o700)) | |
| 31 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 32 | + | svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()) | |
| 33 | + | ||
| 34 | + | _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{ | |
| 35 | + | Name: "c", | |
| 36 | + | Binds: []domain.ContainerBind{{Name: "conf", Source: child, Destination: "/etc/app", ReadOnly: true}}, | |
| 37 | + | }) | |
| 38 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |
| 39 | + | assert.NotContains(t, err.Error(), protected) | |
| 40 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) { | |
| 44 | + | dataDir := t.TempDir() | |
| 45 | + | base := filepath.Join(dataDir, "secrets-run") | |
| 46 | + | store := outmocks.NewMockSecretFileStore(t) | |
| 47 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 48 | + | guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: base} | |
| 49 | + | svc := NewService(Deps{Runtime: runtime, SecretFiles: store, Guard: guard}, zerowrap.Default()) | |
| 50 | + | config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Labels: appLabels("blog", "web", "rev-1")} | |
| 51 | + | ||
| 52 | + | store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything). | |
| 53 | + | Return(map[string]string{"B": filepath.Join(base, "blog", "gordon-blog--web--abc", "B")}, nil).Once() | |
| 54 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-1"}, nil).Once() | |
| 55 | + | ||
| 56 | + | _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": "v"}) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) { | |
| 61 | + | protected := t.TempDir() | |
| 62 | + | svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()). | |
| 63 | + | WithBindPolicies(map[string]domain.AppBindPolicy{ | |
| 64 | + | "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}), | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | _, err := svc.resolveGuardedBinds("blog", domain.AppService{ | |
| 68 | + | Name: "web", | |
| 69 | + | Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}}, | |
| 70 | + | }) | |
| 71 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |
| 72 | + | assert.NotContains(t, err.Error(), protected) | |
| 73 | + | } | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | package deployment | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestCreateContainer_GuardRefusesHostNetwork(t *testing.T) { | |||
| 19 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 20 | + | svc := NewService(Deps{Runtime: runtime}, zerowrap.Default()) | |||
| 21 | + | ||||
| 22 | + | _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{Name: "c", NetworkMode: "host"}) | |||
| 23 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |||
| 24 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestCreateContainer_GuardRefusesProtectedBind(t *testing.T) { | |||
| 28 | + | protected := t.TempDir() | |||
| 29 | + | child := filepath.Join(protected, "registry") | |||
| 30 | + | require.NoError(t, os.MkdirAll(child, 0o700)) | |||
| 31 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 32 | + | svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()) | |||
| 33 | + | ||||
| 34 | + | _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{ | |||
| 35 | + | Name: "c", | |||
| 36 | + | Binds: []domain.ContainerBind{{Name: "conf", Source: child, Destination: "/etc/app", ReadOnly: true}}, | |||
| 37 | + | }) | |||
| 38 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |||
| 39 | + | assert.NotContains(t, err.Error(), protected) | |||
| 40 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) { | |||
| 44 | + | dataDir := t.TempDir() | |||
| 45 | + | base := filepath.Join(dataDir, "secrets-run") | |||
| 46 | + | store := outmocks.NewMockSecretFileStore(t) | |||
| 47 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 48 | + | guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, SecretFilesDir: base} | |||
| 49 | + | svc := NewService(Deps{Runtime: runtime, SecretFiles: store, Guard: guard}, zerowrap.Default()) | |||
| 50 | + | config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Labels: appLabels("blog", "web", "rev-1")} | |||
| 51 | + | ||||
| 52 | + | store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything). | |||
| 53 | + | Return(map[string]string{"B": filepath.Join(base, "blog", "gordon-blog--web--abc", "B")}, nil).Once() | |||
| 54 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-1"}, nil).Once() | |||
| 55 | + | ||||
| 56 | + | _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": "v"}) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) { | |||
| 61 | + | protected := t.TempDir() | |||
| 62 | + | svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()). | |||
| 63 | + | WithBindPolicies(map[string]domain.AppBindPolicy{ | |||
| 64 | + | "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}), | |||
| 65 | + | }) | |||
| 66 | + | ||||
| 67 | + | _, err := svc.resolveGuardedBinds("blog", domain.AppService{ | |||
| 68 | + | Name: "web", | |||
| 69 | + | Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}}, | |||
| 70 | + | }) | |||
| 71 | + | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |||
| 72 | + | assert.NotContains(t, err.Error(), protected) | |||
| 73 | + | } | |||
M internal/usecase/deployment/service.go +20 -1
| @@ -51,6 +51,10 @@ type Deps struct { | |||
| 51 | 51 | // nil is valid only while no service delivers secrets as files; | |
| 52 | 52 | // preflight refuses such services otherwise. | |
| 53 | 53 | SecretFiles out.SecretFileStore | |
| 54 | + | // Guard is checked on every app container config right before the | |
| 55 | + | // runtime create. The zero value still refuses host/shared network | |
| 56 | + | // namespaces and host paths passed as volume names. | |
| 57 | + | Guard domain.RuntimeGuard | |
| 54 | 58 | } | |
| 55 | 59 | ||
| 56 | 60 | // RegistryConfig carries the installation registry identity for pulls. | |
| @@ -278,6 +282,21 @@ func (s *Service) resolveServiceBinds(app string, svc domain.AppService) ([]doma | |||
| 278 | 282 | return resolved, nil | |
| 279 | 283 | } | |
| 280 | 284 | ||
| 285 | + | // resolveGuardedBinds resolves the service binds and applies the runtime | |
| 286 | + | // guard to them, so a bind overlapping a protected Gordon path fails at | |
| 287 | + | // preflight and before the serving generation is retired, not only at | |
| 288 | + | // create time. createContainer keeps the full guard as the final check. | |
| 289 | + | func (s *Service) resolveGuardedBinds(app string, svc domain.AppService) ([]domain.ContainerBind, error) { | |
| 290 | + | binds, err := s.resolveServiceBinds(app, svc) | |
| 291 | + | if err != nil { | |
| 292 | + | return nil, err | |
| 293 | + | } | |
| 294 | + | if err := s.deps.Guard.Check(&domain.ContainerConfig{Binds: binds}); err != nil { | |
| 295 | + | return nil, fmt.Errorf("deployment: app %q service %q: %w", app, svc.Name, err) | |
| 296 | + | } | |
| 297 | + | return binds, nil | |
| 298 | + | } | |
| 299 | + | ||
| 281 | 300 | // acquireAppContext takes the GC shared lease first and the per-app | |
| 282 | 301 | // coordinator second, following the documented lock order | |
| 283 | 302 | // (GC barrier → per-app coordinator → registry lock → bbolt | |
| @@ -719,7 +738,7 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain. | |||
| 719 | 738 | // must fail before any workload mutation. The successful values | |
| 720 | 739 | // are discarded because createAndStart re-resolves against the | |
| 721 | 740 | // current policy immediately before the runtime mutation. | |
| 722 | - | if _, err := s.resolveServiceBinds(app, svc); err != nil { | |
| 741 | + | if _, err := s.resolveGuardedBinds(app, svc); err != nil { | |
| 723 | 742 | return nil, err | |
| 724 | 743 | } | |
| 725 | 744 | if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil { | |
| @@ -51,6 +51,10 @@ type Deps struct { | |||||
| 51 | // nil is valid only while no service delivers secrets as files; | 51 | // nil is valid only while no service delivers secrets as files; | ||
| 52 | // preflight refuses such services otherwise. | 52 | // preflight refuses such services otherwise. | ||
| 53 | SecretFiles out.SecretFileStore | 53 | SecretFiles out.SecretFileStore | ||
| 54 | + | // Guard is checked on every app container config right before the | |||
| 55 | + | // runtime create. The zero value still refuses host/shared network | |||
| 56 | + | // namespaces and host paths passed as volume names. | |||
| 57 | + | Guard domain.RuntimeGuard | |||
| 54 | } | 58 | } | ||
| 55 | 59 | ||||
| 56 | // RegistryConfig carries the installation registry identity for pulls. | 60 | // RegistryConfig carries the installation registry identity for pulls. | ||
| @@ -278,6 +282,21 @@ func (s *Service) resolveServiceBinds(app string, svc domain.AppService) ([]doma | |||||
| 278 | return resolved, nil | 282 | return resolved, nil | ||
| 279 | } | 283 | } | ||
| 280 | 284 | ||||
| 285 | + | // resolveGuardedBinds resolves the service binds and applies the runtime | |||
| 286 | + | // guard to them, so a bind overlapping a protected Gordon path fails at | |||
| 287 | + | // preflight and before the serving generation is retired, not only at | |||
| 288 | + | // create time. createContainer keeps the full guard as the final check. | |||
| 289 | + | func (s *Service) resolveGuardedBinds(app string, svc domain.AppService) ([]domain.ContainerBind, error) { | |||
| 290 | + | binds, err := s.resolveServiceBinds(app, svc) | |||
| 291 | + | if err != nil { | |||
| 292 | + | return nil, err | |||
| 293 | + | } | |||
| 294 | + | if err := s.deps.Guard.Check(&domain.ContainerConfig{Binds: binds}); err != nil { | |||
| 295 | + | return nil, fmt.Errorf("deployment: app %q service %q: %w", app, svc.Name, err) | |||
| 296 | + | } | |||
| 297 | + | return binds, nil | |||
| 298 | + | } | |||
| 299 | + | ||||
| 281 | // acquireAppContext takes the GC shared lease first and the per-app | 300 | // acquireAppContext takes the GC shared lease first and the per-app | ||
| 282 | // coordinator second, following the documented lock order | 301 | // coordinator second, following the documented lock order | ||
| 283 | // (GC barrier → per-app coordinator → registry lock → bbolt | 302 | // (GC barrier → per-app coordinator → registry lock → bbolt | ||
| @@ -719,7 +738,7 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain. | |||||
| 719 | // must fail before any workload mutation. The successful values | 738 | // must fail before any workload mutation. The successful values | ||
| 720 | // are discarded because createAndStart re-resolves against the | 739 | // are discarded because createAndStart re-resolves against the | ||
| 721 | // current policy immediately before the runtime mutation. | 740 | // current policy immediately before the runtime mutation. | ||
| 722 | - | if _, err := s.resolveServiceBinds(app, svc); err != nil { | 741 | + | if _, err := s.resolveGuardedBinds(app, svc); err != nil { |
| 723 | return nil, err | 742 | return nil, err | ||
| 724 | } | 743 | } | ||
| 725 | if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil { | 744 | if err := s.preflightServiceDevices(ctx, app, svc, pinned); err != nil { | ||