detached 2423197

fix(security): keep dropped log paths protected and limit log-dir exception to data_dir/logs

Log dirs that do not qualify for the read-only exception now keep
their exact log path protected, so a log file in $HOME stays
write-protected. Under the data dir the exception only applies at or
under <data_dir>/logs, and disabled logs grant nothing. pass/sops
availability probes get a timeout. Covers createAndStart guard and a
symlink escape out of the log dir.
commit
2423197273d490cd30c59016cd750ec5a2b98c1c
author
brice · · 2026-10-10 01:23 UTC
parent
a320e99
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +167 -47
M docs/config/security-hardening.md +1 -1
@@ -192,7 +192,7 @@ Protected paths:
192192
193193 A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.
194194
195-Gordon's log directories (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only, even under the data directory, so a log shipper can read them. A writable bind on them is refused, as is a log directory that is or contains another protected path.
195+Gordon's enabled file logs (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only so a log shipper can read them. Under the data directory this applies only at or under `<data_dir>/logs`; outside it, only when the directory neither is, contains, nor lies under another protected path. A writable bind on them is refused. When a log directory does not qualify, for example a log file directly in `$HOME`, the log file itself stays protected and no read-only exception applies.
196196
197197 The only other exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
198198
@@ -192,7 +192,7 @@ Protected paths:
192 192
193 A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.193 A protected path that does not exist yet is also protected in its symlink-resolved form, based on its closest existing parent. Backup directories are protected: a backup app cannot bind them, even read-only.
194 194
195-Gordon's log directories (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only, even under the data directory, so a log shipper can read them. A writable bind on them is refused, as is a log directory that is or contains another protected path.195+Gordon's enabled file logs (the Gordon log file's directory, the access log's directory, and `logging.container_logs.dir`) can be bound read-only so a log shipper can read them. Under the data directory this applies only at or under `<data_dir>/logs`; outside it, only when the directory neither is, contains, nor lies under another protected path. A writable bind on them is refused. When a log directory does not qualify, for example a log file directly in `$HOME`, the log file itself stays protected and no read-only exception applies.
196 196
197 The only other exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.197 The only other exception is Gordon's own read-only secret file mount: `<data_dir>/secrets-run/<app>/<container>/<KEY>` at `/run/secrets/<KEY>`. A refused `[app_mounts]` policy fails before the running generation is replaced or restarted. Point the policy `source` outside every protected path.
198 198
M internal/adapters/out/secrets/sops.go +5 -3
@@ -108,8 +108,8 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
108108 // Convert dot notation to sops --extract Python dictionary syntax
109109 extractPath := convertToSopsExtractPath(keyPath)
110110
111- // Use sops -d --extract to get specific key
112- // #nosec G204 - filePath and keyPath are validated above
111+ // Use sops -d --extract to get specific key; filePath and keyPath are
112+ // validated above.
113113 cmd := childenv.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
114114 output, err := cmd.Output()
115115 if err != nil {
@@ -136,7 +136,9 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
136136
137137 // IsAvailable checks if sops is available in the system.
138138 func (s *SopsProvider) IsAvailable() bool {
139- cmd := childenv.CommandContext(context.Background(), "sops", "--version")
139+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
140+ defer cancel()
141+ cmd := childenv.CommandContext(ctx, "sops", "--version")
140142 err := cmd.Run()
141143 return err == nil
142144 }
@@ -108,8 +108,8 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
108 // Convert dot notation to sops --extract Python dictionary syntax108 // Convert dot notation to sops --extract Python dictionary syntax
109 extractPath := convertToSopsExtractPath(keyPath)109 extractPath := convertToSopsExtractPath(keyPath)
110 110
111- // Use sops -d --extract to get specific key111+ // Use sops -d --extract to get specific key; filePath and keyPath are
112- // #nosec G204 - filePath and keyPath are validated above112+ // validated above.
113 cmd := childenv.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)113 cmd := childenv.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
114 output, err := cmd.Output()114 output, err := cmd.Output()
115 if err != nil {115 if err != nil {
@@ -136,7 +136,9 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro
136 136
137 // IsAvailable checks if sops is available in the system.137 // IsAvailable checks if sops is available in the system.
138 func (s *SopsProvider) IsAvailable() bool {138 func (s *SopsProvider) IsAvailable() bool {
139- cmd := childenv.CommandContext(context.Background(), "sops", "--version")139+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
140+ defer cancel()
141+ cmd := childenv.CommandContext(ctx, "sops", "--version")
140 err := cmd.Run()142 err := cmd.Run()
141 return err == nil143 return err == nil
142 }144 }
M internal/adapters/out/tokenstore/pass.go +3 -3
@@ -482,14 +482,12 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
482482 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
483483
484484 // Remove JWT
485- // #nosec G204 -- command and args are fixed; subject is treated as data only.
486485 cmd := childenv.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
487486 if err := cmd.Run(); err != nil {
488487 return fmt.Errorf("failed to delete token: %w", err)
489488 }
490489
491490 // Remove metadata
492- // #nosec G204 -- command and args are fixed; subject is treated as data only.
493491 cmd = childenv.CommandContext(ctx, "pass", "rm", "-f", metaPath)
494492 if err := cmd.Run(); err != nil {
495493 s.log.Warn().Err(err).Msg("failed to delete token metadata")
@@ -548,6 +546,8 @@ func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
548546
549547 // IsAvailable checks if pass is available in the system.
550548 func (s *PassStore) IsAvailable() bool {
551- cmd := childenv.CommandContext(context.Background(), "pass", "version")
549+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
550+ defer cancel()
551+ cmd := childenv.CommandContext(ctx, "pass", "version")
552552 return cmd.Run() == nil
553553 }
@@ -482,14 +482,12 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error {
482 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)482 metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject)
483 483
484 // Remove JWT484 // Remove JWT
485- // #nosec G204 -- command and args are fixed; subject is treated as data only.
486 cmd := childenv.CommandContext(ctx, "pass", "rm", "-f", jwtPath)485 cmd := childenv.CommandContext(ctx, "pass", "rm", "-f", jwtPath)
487 if err := cmd.Run(); err != nil {486 if err := cmd.Run(); err != nil {
488 return fmt.Errorf("failed to delete token: %w", err)487 return fmt.Errorf("failed to delete token: %w", err)
489 }488 }
490 489
491 // Remove metadata490 // Remove metadata
492- // #nosec G204 -- command and args are fixed; subject is treated as data only.
493 cmd = childenv.CommandContext(ctx, "pass", "rm", "-f", metaPath)491 cmd = childenv.CommandContext(ctx, "pass", "rm", "-f", metaPath)
494 if err := cmd.Run(); err != nil {492 if err := cmd.Run(); err != nil {
495 s.log.Warn().Err(err).Msg("failed to delete token metadata")493 s.log.Warn().Err(err).Msg("failed to delete token metadata")
@@ -548,6 +546,8 @@ func (s *PassStore) getRevokedList(ctx context.Context) ([]string, error) {
548 546
549 // IsAvailable checks if pass is available in the system.547 // IsAvailable checks if pass is available in the system.
550 func (s *PassStore) IsAvailable() bool {548 func (s *PassStore) IsAvailable() bool {
551- cmd := childenv.CommandContext(context.Background(), "pass", "version")549+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
550+ defer cancel()
551+ cmd := childenv.CommandContext(ctx, "pass", "version")
552 return cmd.Run() == nil552 return cmd.Run() == nil
553 }553 }
M internal/app/runtime_guard.go +72 -34
@@ -103,7 +103,7 @@ func subPath(base string, elems ...string) string {
103103
104104 // configuredGordonPaths returns the config file, the runtime socket in use,
105105 // and every private file or directory the config points Gordon at. Log
106-// paths are left to gordonLogDirs. The public TLS certificate is left out
106+// paths are left to gordonLogLocations. The public TLS certificate is left out
107107 // so its directory (often a shared CA bundle) stays bindable.
108108 func configuredGordonPaths(si *serviceInit) []string {
109109 cfg := si.cfg
@@ -118,28 +118,47 @@ func configuredGordonPaths(si *serviceInit) []string {
118118 return paths
119119 }
120120
121-// readableLogDirs keeps the log dirs that lie under the data dir or
122-// outside every protected path, so a log dir misconfigured inside a key
123-// store or runtime dir never opens a read-only exception there.
124-func readableLogDirs(logDirs, protected []string, dataDir string) []string {
121+// logLocation is one enabled Gordon log: the directory a read-only bind
122+// may expose and the exact file or directory Gordon writes.
123+type logLocation struct {
124+ dir string
125+ path string
126+}
127+
128+// readableLogDirs splits the log locations into dirs that may be bound
129+// read-only and exact log paths that must stay protected instead. A dir
130+// under the data dir is readable only at or under <data_dir>/logs; a dir
131+// outside it only when it neither is, contains, nor lies under another
132+// protected path. So a misconfigured log path never opens a read-only
133+// exception on secrets, key stores, or runtime dirs, and a dropped log
134+// stays write-protected.
135+func readableLogDirs(locations []logLocation, protected []string, dataDir string) (readable, keepProtected []string) {
125136 dataDirs := absWithResolved([]string{dataDir})
126- var kept []string
127- for _, logDir := range logDirs {
128- ok := true
129- for _, p := range protected {
130- if slices.Contains(dataDirs, p) {
131- continue
132- }
133- if pathWithin(p, logDir) || pathWithin(logDir, p) {
134- ok = false
135- break
136- }
137+ dataLogs := absWithResolved([]string{filepath.Join(dataDir, "logs")})
138+ for _, loc := range locations {
139+ if readableLogDir(loc.dir, protected, dataDirs, dataLogs) {
140+ readable = append(readable, loc.dir)
141+ } else {
142+ keepProtected = append(keepProtected, loc.path)
137143 }
138- if ok {
139- kept = append(kept, logDir)
144+ }
145+ return readable, keepProtected
146+}
147+
148+func readableLogDir(dir string, protected, dataDirs, dataLogs []string) bool {
149+ underData := slices.ContainsFunc(dataDirs, func(d string) bool { return pathWithin(d, dir) })
150+ if underData && !slices.ContainsFunc(dataLogs, func(l string) bool { return pathWithin(l, dir) }) {
151+ return false
152+ }
153+ for _, p := range protected {
154+ if underData && slices.Contains(dataDirs, p) {
155+ continue
156+ }
157+ if pathWithin(p, dir) || pathWithin(dir, p) {
158+ return false
140159 }
141160 }
142- return kept
161+ return true
143162 }
144163
145164 // pathWithin reports whether target is root or lies under it.
@@ -148,22 +167,39 @@ func pathWithin(root, target string) bool {
148167 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
149168 }
150169
151-// gordonLogDirs returns the directories of Gordon's process log, access
152-// log, and container logs. App binds may read them but never write them.
153-func gordonLogDirs(cfg Config, dataDir string) []string {
154- containerLogs := cfg.Logging.ContainerLogs.Dir
155- if containerLogs == "" {
156- containerLogs = filepath.Join(dataDir, "logs", "containers")
170+// gordonLogLocations returns the enabled file logs: Gordon's process log,
171+// the access log, and the container logs dir, with paths made absolute.
172+func gordonLogLocations(cfg Config, dataDir string) []logLocation {
173+ var locs []logLocation
174+ add := func(dir, path string) {
175+ dir, errDir := filepath.Abs(dir)
176+ path, errPath := filepath.Abs(path)
177+ if errDir == nil && errPath == nil {
178+ locs = append(locs, logLocation{dir: dir, path: path})
179+ }
157180 }
158- accessLog := cfg.Logging.AccessLog.FilePath
159- if accessLog == "" {
160- accessLog = filepath.Join(dataDir, "logs", "access.log")
181+ if cfg.Logging.File.Enabled {
182+ p := cfg.Logging.File.Path
183+ if p == "" {
184+ p = filepath.Join(dataDir, "logs", "gordon.log")
185+ }
186+ add(filepath.Dir(p), p)
187+ }
188+ if cfg.Logging.AccessLog.Enabled && cfg.Logging.AccessLog.Output == "file" {
189+ p := cfg.Logging.AccessLog.FilePath
190+ if p == "" {
191+ p = filepath.Join(dataDir, "logs", "access.log")
192+ }
193+ add(filepath.Dir(p), p)
194+ }
195+ if cfg.Logging.ContainerLogs.Enabled {
196+ p := cfg.Logging.ContainerLogs.Dir
197+ if p == "" {
198+ p = filepath.Join(dataDir, "logs", "containers")
199+ }
200+ add(p, p)
161201 }
162- return absWithResolved([]string{
163- filepath.Dir(resolveLogFilePath(cfg)),
164- filepath.Dir(accessLog),
165- containerLogs,
166- })
202+ return locs
167203 }
168204
169205 // buildAppRuntimeSafety opens the host secret file store and builds the
@@ -176,9 +212,11 @@ func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore
176212 }
177213 home, _ := os.UserHomeDir()
178214 paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)
215+ logDirs, logPaths := readableLogDirs(gordonLogLocations(si.cfg, dataDir), paths, dataDir)
216+ paths = absWithResolved(append(paths, logPaths...))
179217 return store, domain.RuntimeGuard{
180218 ProtectedPaths: paths,
181219 SecretFilesDir: store.Dir(),
182- LogDirs: readableLogDirs(gordonLogDirs(si.cfg, dataDir), paths, dataDir),
220+ LogDirs: absWithResolved(logDirs),
183221 }, nil
184222 }
@@ -103,7 +103,7 @@ func subPath(base string, elems ...string) string {
103 103
104 // configuredGordonPaths returns the config file, the runtime socket in use,104 // configuredGordonPaths returns the config file, the runtime socket in use,
105 // and every private file or directory the config points Gordon at. Log105 // and every private file or directory the config points Gordon at. Log
106-// paths are left to gordonLogDirs. The public TLS certificate is left out106+// paths are left to gordonLogLocations. The public TLS certificate is left out
107 // so its directory (often a shared CA bundle) stays bindable.107 // so its directory (often a shared CA bundle) stays bindable.
108 func configuredGordonPaths(si *serviceInit) []string {108 func configuredGordonPaths(si *serviceInit) []string {
109 cfg := si.cfg109 cfg := si.cfg
@@ -118,28 +118,47 @@ func configuredGordonPaths(si *serviceInit) []string {
118 return paths118 return paths
119 }119 }
120 120
121-// readableLogDirs keeps the log dirs that lie under the data dir or121+// logLocation is one enabled Gordon log: the directory a read-only bind
122-// outside every protected path, so a log dir misconfigured inside a key122+// may expose and the exact file or directory Gordon writes.
123-// store or runtime dir never opens a read-only exception there.123+type logLocation struct {
124-func readableLogDirs(logDirs, protected []string, dataDir string) []string {124+ dir string
125+ path string
126+}
127+
128+// readableLogDirs splits the log locations into dirs that may be bound
129+// read-only and exact log paths that must stay protected instead. A dir
130+// under the data dir is readable only at or under <data_dir>/logs; a dir
131+// outside it only when it neither is, contains, nor lies under another
132+// protected path. So a misconfigured log path never opens a read-only
133+// exception on secrets, key stores, or runtime dirs, and a dropped log
134+// stays write-protected.
135+func readableLogDirs(locations []logLocation, protected []string, dataDir string) (readable, keepProtected []string) {
125 dataDirs := absWithResolved([]string{dataDir})136 dataDirs := absWithResolved([]string{dataDir})
126- var kept []string137+ dataLogs := absWithResolved([]string{filepath.Join(dataDir, "logs")})
127- for _, logDir := range logDirs {138+ for _, loc := range locations {
128- ok := true139+ if readableLogDir(loc.dir, protected, dataDirs, dataLogs) {
129- for _, p := range protected {140+ readable = append(readable, loc.dir)
130- if slices.Contains(dataDirs, p) {141+ } else {
131- continue142+ keepProtected = append(keepProtected, loc.path)
132- }
133- if pathWithin(p, logDir) || pathWithin(logDir, p) {
134- ok = false
135- break
136- }
137 }143 }
138- if ok {144+ }
139- kept = append(kept, logDir)145+ return readable, keepProtected
146+}
147+
148+func readableLogDir(dir string, protected, dataDirs, dataLogs []string) bool {
149+ underData := slices.ContainsFunc(dataDirs, func(d string) bool { return pathWithin(d, dir) })
150+ if underData && !slices.ContainsFunc(dataLogs, func(l string) bool { return pathWithin(l, dir) }) {
151+ return false
152+ }
153+ for _, p := range protected {
154+ if underData && slices.Contains(dataDirs, p) {
155+ continue
156+ }
157+ if pathWithin(p, dir) || pathWithin(dir, p) {
158+ return false
140 }159 }
141 }160 }
142- return kept161+ return true
143 }162 }
144 163
145 // pathWithin reports whether target is root or lies under it.164 // pathWithin reports whether target is root or lies under it.
@@ -148,22 +167,39 @@ func pathWithin(root, target string) bool {
148 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))167 return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
149 }168 }
150 169
151-// gordonLogDirs returns the directories of Gordon's process log, access170+// gordonLogLocations returns the enabled file logs: Gordon's process log,
152-// log, and container logs. App binds may read them but never write them.171+// the access log, and the container logs dir, with paths made absolute.
153-func gordonLogDirs(cfg Config, dataDir string) []string {172+func gordonLogLocations(cfg Config, dataDir string) []logLocation {
154- containerLogs := cfg.Logging.ContainerLogs.Dir173+ var locs []logLocation
155- if containerLogs == "" {174+ add := func(dir, path string) {
156- containerLogs = filepath.Join(dataDir, "logs", "containers")175+ dir, errDir := filepath.Abs(dir)
176+ path, errPath := filepath.Abs(path)
177+ if errDir == nil && errPath == nil {
178+ locs = append(locs, logLocation{dir: dir, path: path})
179+ }
157 }180 }
158- accessLog := cfg.Logging.AccessLog.FilePath181+ if cfg.Logging.File.Enabled {
159- if accessLog == "" {182+ p := cfg.Logging.File.Path
160- accessLog = filepath.Join(dataDir, "logs", "access.log")183+ if p == "" {
184+ p = filepath.Join(dataDir, "logs", "gordon.log")
185+ }
186+ add(filepath.Dir(p), p)
187+ }
188+ if cfg.Logging.AccessLog.Enabled && cfg.Logging.AccessLog.Output == "file" {
189+ p := cfg.Logging.AccessLog.FilePath
190+ if p == "" {
191+ p = filepath.Join(dataDir, "logs", "access.log")
192+ }
193+ add(filepath.Dir(p), p)
194+ }
195+ if cfg.Logging.ContainerLogs.Enabled {
196+ p := cfg.Logging.ContainerLogs.Dir
197+ if p == "" {
198+ p = filepath.Join(dataDir, "logs", "containers")
199+ }
200+ add(p, p)
161 }201 }
162- return absWithResolved([]string{202+ return locs
163- filepath.Dir(resolveLogFilePath(cfg)),
164- filepath.Dir(accessLog),
165- containerLogs,
166- })
167 }203 }
168 204
169 // buildAppRuntimeSafety opens the host secret file store and builds the205 // buildAppRuntimeSafety opens the host secret file store and builds the
@@ -176,9 +212,11 @@ func buildAppRuntimeSafety(si *serviceInit, dataDir string) (out.SecretFileStore
176 }212 }
177 home, _ := os.UserHomeDir()213 home, _ := os.UserHomeDir()
178 paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)214 paths := protectedRuntimePaths(dataDir, configuredGordonPaths(si), os.Getenv, home)
215+ logDirs, logPaths := readableLogDirs(gordonLogLocations(si.cfg, dataDir), paths, dataDir)
216+ paths = absWithResolved(append(paths, logPaths...))
179 return store, domain.RuntimeGuard{217 return store, domain.RuntimeGuard{
180 ProtectedPaths: paths,218 ProtectedPaths: paths,
181 SecretFilesDir: store.Dir(),219 SecretFilesDir: store.Dir(),
182- LogDirs: readableLogDirs(gordonLogDirs(si.cfg, dataDir), paths, dataDir),220+ LogDirs: absWithResolved(logDirs),
183 }, nil221 }, nil
184 }222 }
M internal/app/runtime_guard_test.go +59 -5
@@ -103,19 +103,73 @@ func TestConfiguredGordonPaths(t *testing.T) {
103103 assert.Contains(t, got, "/tls/key.pem")
104104 assert.Contains(t, got, "/backups")
105105 assert.NotContains(t, got, "/logs/access.log")
106+}
106107
107- assert.ElementsMatch(t, []string{"/data/logs", "/data/logs/containers", "/logs"}, gordonLogDirs(si.cfg, "/data"))
108+func TestGordonLogLocations(t *testing.T) {
109+ var cfg Config
110+ cfg.Server.DataDir = "/data"
111+ cfg.Logging.AccessLog.FilePath = "/logs/access.log"
112+ assert.Empty(t, gordonLogLocations(cfg, "/data"), "disabled logs grant nothing")
113+
114+ cfg.Logging.File.Enabled = true
115+ cfg.Logging.AccessLog.Enabled = true
116+ cfg.Logging.AccessLog.Output = "file"
117+ cfg.Logging.ContainerLogs.Enabled = true
118+ assert.ElementsMatch(t, []logLocation{
119+ {dir: "/data/logs", path: "/data/logs/gordon.log"},
120+ {dir: "/logs", path: "/logs/access.log"},
121+ {dir: "/data/logs/containers", path: "/data/logs/containers"},
122+ }, gordonLogLocations(cfg, "/data"))
123+
124+ cfg.Logging.AccessLog.Output = "stdout"
125+ assert.Len(t, gordonLogLocations(cfg, "/data"), 2, "non-file access log grants nothing")
108126 }
109127
110128 func TestReadableLogDirs(t *testing.T) {
111- protected := []string{"/data", "/home/u/.gnupg", "/srv/logs/keys"}
112- got := readableLogDirs([]string{"/data/logs", "/home/u/.gnupg/logs", "/srv/logs", "/var/log/gordon"}, protected, "/data")
113- assert.Equal(t, []string{"/data/logs", "/var/log/gordon"}, got)
129+ protected := []string{"/data", "/home/u", "/home/u/.gnupg", "/srv/logs/keys"}
130+ loc := func(dir string) logLocation { return logLocation{dir: dir, path: dir + "/x.log"} }
131+ readable, keep := readableLogDirs([]logLocation{
132+ loc("/data/logs"),
133+ loc("/data/logs/containers"),
134+ loc("/data"), // data dir root
135+ loc("/data/secrets-run"), // data child outside logs
136+ loc("/data/secrets"), // data child outside logs
137+ loc("/home/u"), // protected itself
138+ loc("/home/u/.gnupg/log"), // under a key store
139+ loc("/srv/logs"), // contains a protected path
140+ loc("/srv"), // ancestor of a protected path
141+ loc("/var/log/gordon"),
142+ }, protected, "/data")
143+ assert.Equal(t, []string{"/data/logs", "/data/logs/containers", "/var/log/gordon"}, readable)
144+ assert.Equal(t, []string{
145+ "/data/x.log", "/data/secrets-run/x.log", "/data/secrets/x.log", "/home/u/x.log",
146+ "/home/u/.gnupg/log/x.log", "/srv/logs/x.log", "/srv/x.log",
147+ }, keep)
148+}
149+
150+func TestBuildAppRuntimeSafety_DroppedLogStaysProtected(t *testing.T) {
151+ home := t.TempDir()
152+ t.Setenv("HOME", home)
153+ si := &serviceInit{}
154+ si.cfg.Logging.File.Enabled = true
155+ si.cfg.Logging.File.Path = filepath.Join(home, "gordon.log")
156+ store, guard, err := buildAppRuntimeSafety(si, t.TempDir())
157+ require.NoError(t, err)
158+ t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
159+
160+ // $HOME contains key stores, so it is no readable log dir, and the
161+ // log file itself stays protected even against a writable bind.
162+ assert.NotContains(t, guard.LogDirs, home)
163+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "log", Source: filepath.Join(home, "gordon.log"), Destination: "/log"}}})
164+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
114165 }
115166
116167 func TestBuildAppRuntimeSafety(t *testing.T) {
117168 dataDir := t.TempDir()
118- store, guard, err := buildAppRuntimeSafety(&serviceInit{}, dataDir)
169+ si := &serviceInit{}
170+ si.cfg.Logging.ContainerLogs.Enabled = true
171+ si.cfg.Logging.File.Enabled = true
172+ store, guard, err := buildAppRuntimeSafety(si, dataDir)
119173 require.NoError(t, err)
120174 require.NotNil(t, store)
121175 t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
@@ -103,19 +103,73 @@ func TestConfiguredGordonPaths(t *testing.T) {
103 assert.Contains(t, got, "/tls/key.pem")103 assert.Contains(t, got, "/tls/key.pem")
104 assert.Contains(t, got, "/backups")104 assert.Contains(t, got, "/backups")
105 assert.NotContains(t, got, "/logs/access.log")105 assert.NotContains(t, got, "/logs/access.log")
106+}
106 107
107- assert.ElementsMatch(t, []string{"/data/logs", "/data/logs/containers", "/logs"}, gordonLogDirs(si.cfg, "/data"))108+func TestGordonLogLocations(t *testing.T) {
109+ var cfg Config
110+ cfg.Server.DataDir = "/data"
111+ cfg.Logging.AccessLog.FilePath = "/logs/access.log"
112+ assert.Empty(t, gordonLogLocations(cfg, "/data"), "disabled logs grant nothing")
113+
114+ cfg.Logging.File.Enabled = true
115+ cfg.Logging.AccessLog.Enabled = true
116+ cfg.Logging.AccessLog.Output = "file"
117+ cfg.Logging.ContainerLogs.Enabled = true
118+ assert.ElementsMatch(t, []logLocation{
119+ {dir: "/data/logs", path: "/data/logs/gordon.log"},
120+ {dir: "/logs", path: "/logs/access.log"},
121+ {dir: "/data/logs/containers", path: "/data/logs/containers"},
122+ }, gordonLogLocations(cfg, "/data"))
123+
124+ cfg.Logging.AccessLog.Output = "stdout"
125+ assert.Len(t, gordonLogLocations(cfg, "/data"), 2, "non-file access log grants nothing")
108 }126 }
109 127
110 func TestReadableLogDirs(t *testing.T) {128 func TestReadableLogDirs(t *testing.T) {
111- protected := []string{"/data", "/home/u/.gnupg", "/srv/logs/keys"}129+ protected := []string{"/data", "/home/u", "/home/u/.gnupg", "/srv/logs/keys"}
112- got := readableLogDirs([]string{"/data/logs", "/home/u/.gnupg/logs", "/srv/logs", "/var/log/gordon"}, protected, "/data")130+ loc := func(dir string) logLocation { return logLocation{dir: dir, path: dir + "/x.log"} }
113- assert.Equal(t, []string{"/data/logs", "/var/log/gordon"}, got)131+ readable, keep := readableLogDirs([]logLocation{
132+ loc("/data/logs"),
133+ loc("/data/logs/containers"),
134+ loc("/data"), // data dir root
135+ loc("/data/secrets-run"), // data child outside logs
136+ loc("/data/secrets"), // data child outside logs
137+ loc("/home/u"), // protected itself
138+ loc("/home/u/.gnupg/log"), // under a key store
139+ loc("/srv/logs"), // contains a protected path
140+ loc("/srv"), // ancestor of a protected path
141+ loc("/var/log/gordon"),
142+ }, protected, "/data")
143+ assert.Equal(t, []string{"/data/logs", "/data/logs/containers", "/var/log/gordon"}, readable)
144+ assert.Equal(t, []string{
145+ "/data/x.log", "/data/secrets-run/x.log", "/data/secrets/x.log", "/home/u/x.log",
146+ "/home/u/.gnupg/log/x.log", "/srv/logs/x.log", "/srv/x.log",
147+ }, keep)
148+}
149+
150+func TestBuildAppRuntimeSafety_DroppedLogStaysProtected(t *testing.T) {
151+ home := t.TempDir()
152+ t.Setenv("HOME", home)
153+ si := &serviceInit{}
154+ si.cfg.Logging.File.Enabled = true
155+ si.cfg.Logging.File.Path = filepath.Join(home, "gordon.log")
156+ store, guard, err := buildAppRuntimeSafety(si, t.TempDir())
157+ require.NoError(t, err)
158+ t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
159+
160+ // $HOME contains key stores, so it is no readable log dir, and the
161+ // log file itself stays protected even against a writable bind.
162+ assert.NotContains(t, guard.LogDirs, home)
163+ err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "log", Source: filepath.Join(home, "gordon.log"), Destination: "/log"}}})
164+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
114 }165 }
115 166
116 func TestBuildAppRuntimeSafety(t *testing.T) {167 func TestBuildAppRuntimeSafety(t *testing.T) {
117 dataDir := t.TempDir()168 dataDir := t.TempDir()
118- store, guard, err := buildAppRuntimeSafety(&serviceInit{}, dataDir)169+ si := &serviceInit{}
170+ si.cfg.Logging.ContainerLogs.Enabled = true
171+ si.cfg.Logging.File.Enabled = true
172+ store, guard, err := buildAppRuntimeSafety(si, dataDir)
119 require.NoError(t, err)173 require.NoError(t, err)
120 require.NotNil(t, store)174 require.NotNil(t, store)
121 t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })175 t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
M internal/domain/runtime_guard_test.go +5 -0
@@ -118,6 +118,10 @@ func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
118118 logs := filepath.Join(dataDir, "logs")
119119 require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))
120120 keys := filepath.Join(dataDir, "logs", "keys")
121+ secrets := filepath.Join(dataDir, "secrets-run")
122+ require.NoError(t, os.MkdirAll(secrets, 0o700))
123+ escape := filepath.Join(logs, "escape")
124+ require.NoError(t, os.Symlink(secrets, escape))
121125 guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}
122126 bind := func(source string, readOnly bool) *domain.ContainerConfig {
123127 return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})
@@ -135,6 +139,7 @@ func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
135139 "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},
136140 "data dir read-only": {guard, bind(dataDir, true)},
137141 "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},
142+ "symlink out of logs": {guard, bind(escape, true)},
138143 "log dir with protected": {withProtected, bind(logs, true)},
139144 }
140145 for name, tc := range refused {
@@ -118,6 +118,10 @@ func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
118 logs := filepath.Join(dataDir, "logs")118 logs := filepath.Join(dataDir, "logs")
119 require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))119 require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700))
120 keys := filepath.Join(dataDir, "logs", "keys")120 keys := filepath.Join(dataDir, "logs", "keys")
121+ secrets := filepath.Join(dataDir, "secrets-run")
122+ require.NoError(t, os.MkdirAll(secrets, 0o700))
123+ escape := filepath.Join(logs, "escape")
124+ require.NoError(t, os.Symlink(secrets, escape))
121 guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}125 guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}}
122 bind := func(source string, readOnly bool) *domain.ContainerConfig {126 bind := func(source string, readOnly bool) *domain.ContainerConfig {
123 return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})127 return guardConfig(domain.ContainerBind{Name: "logs", Source: source, Destination: "/var/log/gordon", ReadOnly: readOnly})
@@ -135,6 +139,7 @@ func TestRuntimeGuard_ReadOnlyLogDirs(t *testing.T) {
135 "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},139 "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)},
136 "data dir read-only": {guard, bind(dataDir, true)},140 "data dir read-only": {guard, bind(dataDir, true)},
137 "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},141 "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)},
142+ "symlink out of logs": {guard, bind(escape, true)},
138 "log dir with protected": {withProtected, bind(logs, true)},143 "log dir with protected": {withProtected, bind(logs, true)},
139 }144 }
140 for name, tc := range refused {145 for name, tc := range refused {
M internal/usecase/deployment/runtime_guard_internal_test.go +22 -1
@@ -56,7 +56,7 @@ func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) {
5656 require.NoError(t, err)
5757 }
5858
59-func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) {
59+func TestResolveServiceBinds_GuardRefusesProtectedSource(t *testing.T) {
6060 protected := t.TempDir()
6161 svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
6262 WithBindPolicies(map[string]domain.AppBindPolicy{
@@ -70,3 +70,24 @@ func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) {
7070 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
7171 assert.NotContains(t, err.Error(), protected)
7272 }
73+
74+// TestCreateAndStart_GuardRefusesBeforeRuntimeMutation proves the guard in
75+// resolveServiceBinds stops a protected bind before secrets, volumes, or
76+// container create.
77+func TestCreateAndStart_GuardRefusesBeforeRuntimeMutation(t *testing.T) {
78+ protected := t.TempDir()
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
81+ WithBindPolicies(map[string]domain.AppBindPolicy{
82+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
83+ })
84+ p := pinnedService{name: "web", spec: domain.AppService{
85+ Name: "web",
86+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
87+ }}
88+
89+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
90+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
91+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
92+ runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything)
93+}
@@ -56,7 +56,7 @@ func TestCreateWithSecretFiles_GuardAllowsOwnSecretBind(t *testing.T) {
56 require.NoError(t, err)56 require.NoError(t, err)
57 }57 }
58 58
59-func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) {59+func TestResolveServiceBinds_GuardRefusesProtectedSource(t *testing.T) {
60 protected := t.TempDir()60 protected := t.TempDir()
61 svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).61 svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
62 WithBindPolicies(map[string]domain.AppBindPolicy{62 WithBindPolicies(map[string]domain.AppBindPolicy{
@@ -70,3 +70,24 @@ func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) {
70 require.ErrorIs(t, err, domain.ErrRuntimeGuard)70 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
71 assert.NotContains(t, err.Error(), protected)71 assert.NotContains(t, err.Error(), protected)
72 }72 }
73+
74+// TestCreateAndStart_GuardRefusesBeforeRuntimeMutation proves the guard in
75+// resolveServiceBinds stops a protected bind before secrets, volumes, or
76+// container create.
77+func TestCreateAndStart_GuardRefusesBeforeRuntimeMutation(t *testing.T) {
78+ protected := t.TempDir()
79+ runtime := outmocks.NewMockContainerRuntime(t)
80+ svc := NewService(Deps{Runtime: runtime, Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()).
81+ WithBindPolicies(map[string]domain.AppBindPolicy{
82+ "config": bindPolicyFor(protected, protected, true, []string{"blog"}, []string{"web"}),
83+ })
84+ p := pinnedService{name: "web", spec: domain.AppService{
85+ Name: "web",
86+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app", ReadOnly: true}},
87+ }}
88+
89+ _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
90+ require.ErrorIs(t, err, domain.ErrRuntimeGuard)
91+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
92+ runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything)
93+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close