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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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: | |||
| 192 | 192 | ||
| 193 | 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 | 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 | ||
| @@ -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 | |||
| 108 | 108 | // Convert dot notation to sops --extract Python dictionary syntax | |
| 109 | 109 | extractPath := convertToSopsExtractPath(keyPath) | |
| 110 | 110 | ||
| 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. | |
| 113 | 113 | cmd := childenv.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath) | |
| 114 | 114 | output, err := cmd.Output() | |
| 115 | 115 | if err != nil { | |
| @@ -136,7 +136,9 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 136 | 136 | ||
| 137 | 137 | // IsAvailable checks if sops is available in the system. | |
| 138 | 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 | 142 | err := cmd.Run() | |
| 141 | 143 | return err == nil | |
| 142 | 144 | } | |
| @@ -108,8 +108,8 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro | |||||
| 108 | // Convert dot notation to sops --extract Python dictionary syntax | 108 | // 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 key | 111 | + | // Use sops -d --extract to get specific key; filePath and keyPath are |
| 112 | - | // #nosec G204 - filePath and keyPath are validated above | 112 | + | // 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 == nil | 143 | 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 { | |||
| 482 | 482 | metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject) | |
| 483 | 483 | ||
| 484 | 484 | // Remove JWT | |
| 485 | - | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |
| 486 | 485 | cmd := childenv.CommandContext(ctx, "pass", "rm", "-f", jwtPath) | |
| 487 | 486 | if err := cmd.Run(); err != nil { | |
| 488 | 487 | return fmt.Errorf("failed to delete token: %w", err) | |
| 489 | 488 | } | |
| 490 | 489 | ||
| 491 | 490 | // Remove metadata | |
| 492 | - | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |
| 493 | 491 | cmd = childenv.CommandContext(ctx, "pass", "rm", "-f", metaPath) | |
| 494 | 492 | if err := cmd.Run(); err != nil { | |
| 495 | 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 | 547 | // IsAvailable checks if pass is available in the system. | |
| 550 | 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 | 552 | return cmd.Run() == nil | |
| 553 | 553 | } | |
| @@ -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 JWT | 484 | // 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 metadata | 490 | // 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() == nil | 552 | 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 { | |||
| 103 | 103 | ||
| 104 | 104 | // configuredGordonPaths returns the config file, the runtime socket in use, | |
| 105 | 105 | // 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 | |
| 107 | 107 | // so its directory (often a shared CA bundle) stays bindable. | |
| 108 | 108 | func configuredGordonPaths(si *serviceInit) []string { | |
| 109 | 109 | cfg := si.cfg | |
| @@ -118,28 +118,47 @@ func configuredGordonPaths(si *serviceInit) []string { | |||
| 118 | 118 | return paths | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 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) { | |
| 125 | 136 | 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) | |
| 137 | 143 | } | |
| 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 | |
| 140 | 159 | } | |
| 141 | 160 | } | |
| 142 | - | return kept | |
| 161 | + | return true | |
| 143 | 162 | } | |
| 144 | 163 | ||
| 145 | 164 | // pathWithin reports whether target is root or lies under it. | |
| @@ -148,22 +167,39 @@ func pathWithin(root, target string) bool { | |||
| 148 | 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, 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 | + | } | |
| 157 | 180 | } | |
| 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) | |
| 161 | 201 | } | |
| 162 | - | return absWithResolved([]string{ | |
| 163 | - | filepath.Dir(resolveLogFilePath(cfg)), | |
| 164 | - | filepath.Dir(accessLog), | |
| 165 | - | containerLogs, | |
| 166 | - | }) | |
| 202 | + | return locs | |
| 167 | 203 | } | |
| 168 | 204 | ||
| 169 | 205 | // 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 | 213 | home, _ := os.UserHomeDir() | |
| 178 | 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 | 217 | return store, domain.RuntimeGuard{ | |
| 180 | 218 | ProtectedPaths: paths, | |
| 181 | 219 | SecretFilesDir: store.Dir(), | |
| 182 | - | LogDirs: readableLogDirs(gordonLogDirs(si.cfg, dataDir), paths, dataDir), | |
| 220 | + | LogDirs: absWithResolved(logDirs), | |
| 183 | 221 | }, nil | |
| 184 | 222 | } | |
| @@ -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. Log | 105 | // 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 |
| 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.cfg | 109 | cfg := si.cfg | ||
| @@ -118,28 +118,47 @@ func configuredGordonPaths(si *serviceInit) []string { | |||||
| 118 | return paths | 118 | return paths | ||
| 119 | } | 119 | } | ||
| 120 | 120 | ||||
| 121 | - | // readableLogDirs keeps the log dirs that lie under the data dir or | 121 | + | // logLocation is one enabled Gordon log: the directory a read-only bind |
| 122 | - | // outside every protected path, so a log dir misconfigured inside a key | 122 | + | // 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 []string | 137 | + | dataLogs := absWithResolved([]string{filepath.Join(dataDir, "logs")}) |
| 127 | - | for _, logDir := range logDirs { | 138 | + | for _, loc := range locations { |
| 128 | - | ok := true | 139 | + | 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 | - | continue | 142 | + | 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 kept | 161 | + | 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, access | 170 | + | // 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.Dir | 173 | + | 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.FilePath | 181 | + | 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 the | 205 | // 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 | }, nil | 221 | }, nil | ||
| 184 | } | 222 | } | ||
M internal/app/runtime_guard_test.go +59 -5
| @@ -103,19 +103,73 @@ func TestConfiguredGordonPaths(t *testing.T) { | |||
| 103 | 103 | assert.Contains(t, got, "/tls/key.pem") | |
| 104 | 104 | assert.Contains(t, got, "/backups") | |
| 105 | 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 | 128 | 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) | |
| 114 | 165 | } | |
| 115 | 166 | ||
| 116 | 167 | func TestBuildAppRuntimeSafety(t *testing.T) { | |
| 117 | 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 | 173 | require.NoError(t, err) | |
| 120 | 174 | require.NotNil(t, store) | |
| 121 | 175 | 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) { | |||
| 118 | 118 | logs := filepath.Join(dataDir, "logs") | |
| 119 | 119 | require.NoError(t, os.MkdirAll(filepath.Join(logs, "containers"), 0o700)) | |
| 120 | 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 | 125 | guard := domain.RuntimeGuard{ProtectedPaths: []string{dataDir}, LogDirs: []string{logs}} | |
| 122 | 126 | bind := func(source string, readOnly bool) *domain.ContainerConfig { | |
| 123 | 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 | 139 | "writable log subdir": {guard, bind(filepath.Join(logs, "containers"), false)}, | |
| 136 | 140 | "data dir read-only": {guard, bind(dataDir, true)}, | |
| 137 | 141 | "sibling of logs": {guard, bind(filepath.Join(dataDir, "registry"), true)}, | |
| 142 | + | "symlink out of logs": {guard, bind(escape, true)}, | |
| 138 | 143 | "log dir with protected": {withProtected, bind(logs, true)}, | |
| 139 | 144 | } | |
| 140 | 145 | 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) { | |||
| 56 | 56 | require.NoError(t, err) | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | - | func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) { | |
| 59 | + | func TestResolveServiceBinds_GuardRefusesProtectedSource(t *testing.T) { | |
| 60 | 60 | protected := t.TempDir() | |
| 61 | 61 | svc := NewService(Deps{Guard: domain.RuntimeGuard{ProtectedPaths: []string{protected}}}, zerowrap.Default()). | |
| 62 | 62 | WithBindPolicies(map[string]domain.AppBindPolicy{ | |
| @@ -70,3 +70,24 @@ func TestResolveGuardedBinds_RefusesProtectedSource(t *testing.T) { | |||
| 70 | 70 | require.ErrorIs(t, err, domain.ErrRuntimeGuard) | |
| 71 | 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 | + | } | |
| @@ -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 | + | } | |||