detached ccfc417

fix(secrets): harden pass storage and auth

commit
ccfc41759bd0097b9b799e54f71a49551a7c3c0a
author
bnema · · 2026-01-30 21:07 UTC
parent
80b88e8
signature
unsigned
5 files · +98 -22
M internal/adapters/in/cli/auth.go +11 -9
@@ -326,22 +326,24 @@ func runAuthLoginWithToken(remoteName, token string) error {
326326 return err
327327 }
328328
329- if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
330- return fmt.Errorf("failed to save token: %w", err)
331- }
332-
333- fmt.Println()
334- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
335-
336329 client := remote.NewClient(remoteConfig.URL, remote.WithToken(token))
337330 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
338331 defer cancel()
332+ verified := true
339333 if _, err := client.GetStatus(ctx); err != nil {
340334 fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
341- return nil
335+ verified = false
342336 }
343337
344- fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
338+ if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
339+ return fmt.Errorf("failed to save token: %w", err)
340+ }
341+
342+ fmt.Println()
343+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
344+ if verified {
345+ fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
346+ }
345347 return nil
346348 }
347349
@@ -326,22 +326,24 @@ func runAuthLoginWithToken(remoteName, token string) error {
326 return err326 return err
327 }327 }
328 328
329- if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
330- return fmt.Errorf("failed to save token: %w", err)
331- }
332-
333- fmt.Println()
334- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
335-
336 client := remote.NewClient(remoteConfig.URL, remote.WithToken(token))329 client := remote.NewClient(remoteConfig.URL, remote.WithToken(token))
337 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)330 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
338 defer cancel()331 defer cancel()
332+ verified := true
339 if _, err := client.GetStatus(ctx); err != nil {333 if _, err := client.GetStatus(ctx); err != nil {
340 fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))334 fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
341- return nil335+ verified = false
342 }336 }
343 337
344- fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))338+ if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
339+ return fmt.Errorf("failed to save token: %w", err)
340+ }
341+
342+ fmt.Println()
343+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
344+ if verified {
345+ fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
346+ }
345 return nil347 return nil
346 }348 }
347 349
M internal/adapters/out/domainsecrets/pass_store.go +49 -12
@@ -86,9 +86,6 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
8686 return nil, err
8787 }
8888
89- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
90- defer cancel()
91-
9289 secretsMap := make(map[string]string)
9390 for _, key := range keys {
9491 keyPath, err := s.keyPath(domainName, key)
@@ -96,7 +93,9 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
9693 return nil, err
9794 }
9895
96+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
9997 value, exists, err := s.passShow(ctx, keyPath)
98+ cancel()
10099 if err != nil {
101100 return nil, err
102101 }
@@ -121,22 +120,38 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
121120 return err
122121 }
123122
124- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
125- defer cancel()
123+ existingKeys, err := s.ListKeys(domainName)
124+ if err != nil {
125+ return err
126+ }
127+
128+ existingSet := make(map[string]struct{}, len(existingKeys))
129+ for _, key := range existingKeys {
130+ existingSet[key] = struct{}{}
131+ }
126132
133+ insertedNewPaths := make([]string, 0, len(secretsMap))
127134 for key, value := range secretsMap {
135+ if err := domain.ValidateEnvKey(key); err != nil {
136+ return err
137+ }
138+
128139 keyPath, err := s.keyPath(domainName, key)
129140 if err != nil {
130141 return err
131142 }
132- if err := s.passInsert(ctx, keyPath, value); err != nil {
143+
144+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
145+ err = s.passInsert(ctx, keyPath, value)
146+ cancel()
147+ if err != nil {
148+ s.cleanupInsertedPaths(insertedNewPaths)
133149 return fmt.Errorf("failed to store secret %s: %w", key, err)
134150 }
135- }
136151
137- existingKeys, err := s.ListKeys(domainName)
138- if err != nil {
139- return err
152+ if _, exists := existingSet[key]; !exists {
153+ insertedNewPaths = append(insertedNewPaths, keyPath)
154+ }
140155 }
141156
142157 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
@@ -158,9 +173,13 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
158173 return err
159174 }
160175
176+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
161177 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
178+ cancel()
179+ s.cleanupInsertedPaths(insertedNewPaths)
162180 return fmt.Errorf("failed to update manifest: %w", err)
163181 }
182+ cancel()
164183
165184 return nil
166185 }
@@ -281,6 +300,9 @@ func (s *PassStore) keyPath(domainName, key string) (string, error) {
281300 if err != nil {
282301 return "", err
283302 }
303+ if err := domain.ValidateEnvKey(key); err != nil {
304+ return "", err
305+ }
284306 path := fmt.Sprintf("%s/%s", domainPath, key)
285307 if err := secrets.ValidatePath(path); err != nil {
286308 return "", err
@@ -363,7 +385,9 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er
363385 }
364386 return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
365387 }
366- return strings.TrimSpace(string(output)), true, nil
388+
389+ clean := ansiRegex.ReplaceAllString(string(output), "")
390+ return strings.TrimSpace(clean), true, nil
367391 }
368392
369393 func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
@@ -453,7 +477,20 @@ func parsePassListOutput(basePath, output string) []passListEntry {
453477 }
454478
455479 func passEntryMissing(output string) bool {
456- lower := strings.ToLower(output)
480+ clean := ansiRegex.ReplaceAllString(output, "")
481+ lower := strings.ToLower(clean)
457482 return strings.Contains(lower, "not in the password store") ||
458483 strings.Contains(lower, "password store is empty")
459484 }
485+
486+func (s *PassStore) cleanupInsertedPaths(paths []string) {
487+ if len(paths) == 0 {
488+ return
489+ }
490+
491+ for _, path := range paths {
492+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
493+ _ = s.passRemove(ctx, path)
494+ cancel()
495+ }
496+}
@@ -86,9 +86,6 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
86 return nil, err86 return nil, err
87 }87 }
88 88
89- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
90- defer cancel()
91-
92 secretsMap := make(map[string]string)89 secretsMap := make(map[string]string)
93 for _, key := range keys {90 for _, key := range keys {
94 keyPath, err := s.keyPath(domainName, key)91 keyPath, err := s.keyPath(domainName, key)
@@ -96,7 +93,9 @@ func (s *PassStore) GetAll(domainName string) (map[string]string, error) {
96 return nil, err93 return nil, err
97 }94 }
98 95
96+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
99 value, exists, err := s.passShow(ctx, keyPath)97 value, exists, err := s.passShow(ctx, keyPath)
98+ cancel()
100 if err != nil {99 if err != nil {
101 return nil, err100 return nil, err
102 }101 }
@@ -121,22 +120,38 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
121 return err120 return err
122 }121 }
123 122
124- ctx, cancel := context.WithTimeout(context.Background(), s.timeout)123+ existingKeys, err := s.ListKeys(domainName)
125- defer cancel()124+ if err != nil {
125+ return err
126+ }
127+
128+ existingSet := make(map[string]struct{}, len(existingKeys))
129+ for _, key := range existingKeys {
130+ existingSet[key] = struct{}{}
131+ }
126 132
133+ insertedNewPaths := make([]string, 0, len(secretsMap))
127 for key, value := range secretsMap {134 for key, value := range secretsMap {
135+ if err := domain.ValidateEnvKey(key); err != nil {
136+ return err
137+ }
138+
128 keyPath, err := s.keyPath(domainName, key)139 keyPath, err := s.keyPath(domainName, key)
129 if err != nil {140 if err != nil {
130 return err141 return err
131 }142 }
132- if err := s.passInsert(ctx, keyPath, value); err != nil {143+
144+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
145+ err = s.passInsert(ctx, keyPath, value)
146+ cancel()
147+ if err != nil {
148+ s.cleanupInsertedPaths(insertedNewPaths)
133 return fmt.Errorf("failed to store secret %s: %w", key, err)149 return fmt.Errorf("failed to store secret %s: %w", key, err)
134 }150 }
135- }
136 151
137- existingKeys, err := s.ListKeys(domainName)152+ if _, exists := existingSet[key]; !exists {
138- if err != nil {153+ insertedNewPaths = append(insertedNewPaths, keyPath)
139- return err154+ }
140 }155 }
141 156
142 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))157 keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
@@ -158,9 +173,13 @@ func (s *PassStore) Set(domainName string, secretsMap map[string]string) error {
158 return err173 return err
159 }174 }
160 175
176+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
161 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {177 if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
178+ cancel()
179+ s.cleanupInsertedPaths(insertedNewPaths)
162 return fmt.Errorf("failed to update manifest: %w", err)180 return fmt.Errorf("failed to update manifest: %w", err)
163 }181 }
182+ cancel()
164 183
165 return nil184 return nil
166 }185 }
@@ -281,6 +300,9 @@ func (s *PassStore) keyPath(domainName, key string) (string, error) {
281 if err != nil {300 if err != nil {
282 return "", err301 return "", err
283 }302 }
303+ if err := domain.ValidateEnvKey(key); err != nil {
304+ return "", err
305+ }
284 path := fmt.Sprintf("%s/%s", domainPath, key)306 path := fmt.Sprintf("%s/%s", domainPath, key)
285 if err := secrets.ValidatePath(path); err != nil {307 if err := secrets.ValidatePath(path); err != nil {
286 return "", err308 return "", err
@@ -363,7 +385,9 @@ func (s *PassStore) passShow(ctx context.Context, path string) (string, bool, er
363 }385 }
364 return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)386 return "", false, fmt.Errorf("pass show failed: %s: %w", strings.TrimSpace(string(output)), err)
365 }387 }
366- return strings.TrimSpace(string(output)), true, nil388+
389+ clean := ansiRegex.ReplaceAllString(string(output), "")
390+ return strings.TrimSpace(clean), true, nil
367 }391 }
368 392
369 func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {393 func (s *PassStore) listTopLevelEntries(ctx context.Context, basePath string) ([]string, error) {
@@ -453,7 +477,20 @@ func parsePassListOutput(basePath, output string) []passListEntry {
453 }477 }
454 478
455 func passEntryMissing(output string) bool {479 func passEntryMissing(output string) bool {
456- lower := strings.ToLower(output)480+ clean := ansiRegex.ReplaceAllString(output, "")
481+ lower := strings.ToLower(clean)
457 return strings.Contains(lower, "not in the password store") ||482 return strings.Contains(lower, "not in the password store") ||
458 strings.Contains(lower, "password store is empty")483 strings.Contains(lower, "password store is empty")
459 }484 }
485+
486+func (s *PassStore) cleanupInsertedPaths(paths []string) {
487+ if len(paths) == 0 {
488+ return
489+ }
490+
491+ for _, path := range paths {
492+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
493+ _ = s.passRemove(ctx, path)
494+ cancel()
495+ }
496+}
M internal/app/migrate_env.go +6 -0
@@ -61,6 +61,12 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log
6161 if err != nil {
6262 return fmt.Errorf("failed to parse env file %s: %w", filePath, err)
6363 }
64+ if len(secrets) == 0 {
65+ log.Info().
66+ Str("file", filePath).
67+ Msg("no secrets found in env file; skipping migration")
68+ return nil
69+ }
6470
6571 existingKeys, err := passStore.ListKeys(domainName)
6672 if err != nil {
@@ -61,6 +61,12 @@ func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log
61 if err != nil {61 if err != nil {
62 return fmt.Errorf("failed to parse env file %s: %w", filePath, err)62 return fmt.Errorf("failed to parse env file %s: %w", filePath, err)
63 }63 }
64+ if len(secrets) == 0 {
65+ log.Info().
66+ Str("file", filePath).
67+ Msg("no secrets found in env file; skipping migration")
68+ return nil
69+ }
64 70
65 existingKeys, err := passStore.ListKeys(domainName)71 existingKeys, err := passStore.ListKeys(domainName)
66 if err != nil {72 if err != nil {
M internal/app/run.go +17 -1
@@ -483,6 +483,7 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err
483483 const (
484484 internalRegistryUsername = "gordon-internal"
485485 serviceTokenSubject = "gordon-service"
486+ serviceTokenDefaultTTL = 30 * 24 * time.Hour
486487 )
487488
488489 func generateInternalRegistryAuth() (string, string, error) {
@@ -789,6 +790,17 @@ func parseTokenExpiry(expiry string) (time.Duration, error) {
789790 return parsed, nil
790791 }
791792
793+func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
794+ expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
795+ if err != nil {
796+ return 0, err
797+ }
798+ if expiry <= 0 {
799+ return serviceTokenDefaultTTL, nil
800+ }
801+ return expiry, nil
802+}
803+
792804 // loadSecret loads a secret from the configured backend.
793805 func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
794806 switch backend {
@@ -831,7 +843,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
831843 }
832844
833845 if cfg.Auth.Enabled && svc.authSvc != nil {
834- serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, 0)
846+ expiry, err := resolveServiceTokenExpiry(cfg)
847+ if err != nil {
848+ return nil, log.WrapErr(err, "failed to resolve service token expiry")
849+ }
850+ serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
835851 if err != nil {
836852 return nil, log.WrapErr(err, "failed to generate registry service token")
837853 }
@@ -483,6 +483,7 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err
483 const (483 const (
484 internalRegistryUsername = "gordon-internal"484 internalRegistryUsername = "gordon-internal"
485 serviceTokenSubject = "gordon-service"485 serviceTokenSubject = "gordon-service"
486+ serviceTokenDefaultTTL = 30 * 24 * time.Hour
486 )487 )
487 488
488 func generateInternalRegistryAuth() (string, string, error) {489 func generateInternalRegistryAuth() (string, string, error) {
@@ -789,6 +790,17 @@ func parseTokenExpiry(expiry string) (time.Duration, error) {
789 return parsed, nil790 return parsed, nil
790 }791 }
791 792
793+func resolveServiceTokenExpiry(cfg Config) (time.Duration, error) {
794+ expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry)
795+ if err != nil {
796+ return 0, err
797+ }
798+ if expiry <= 0 {
799+ return serviceTokenDefaultTTL, nil
800+ }
801+ return expiry, nil
802+}
803+
792 // loadSecret loads a secret from the configured backend.804 // loadSecret loads a secret from the configured backend.
793 func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {805 func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
794 switch backend {806 switch backend {
@@ -831,7 +843,11 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
831 }843 }
832 844
833 if cfg.Auth.Enabled && svc.authSvc != nil {845 if cfg.Auth.Enabled && svc.authSvc != nil {
834- serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, 0)846+ expiry, err := resolveServiceTokenExpiry(cfg)
847+ if err != nil {
848+ return nil, log.WrapErr(err, "failed to resolve service token expiry")
849+ }
850+ serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, expiry)
835 if err != nil {851 if err != nil {
836 return nil, log.WrapErr(err, "failed to generate registry service token")852 return nil, log.WrapErr(err, "failed to generate registry service token")
837 }853 }
M internal/domain/env.go +15 -0
@@ -10,6 +10,7 @@ import (
1010 // envDomainRegex validates domain names used for env secrets.
1111 // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
1212 var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
13+var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
1314
1415 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
1516 // Returns a safe domain string suitable for filenames.
@@ -68,3 +69,17 @@ func ParseEnvData(data []byte) (map[string]string, error) {
6869
6970 return secrets, nil
7071 }
72+
73+// ValidateEnvKey validates an env key for storage.
74+func ValidateEnvKey(key string) error {
75+ if key == "" {
76+ return ErrPathTraversal
77+ }
78+ if strings.Contains(key, "..") || strings.ContainsAny(key, "/\\") {
79+ return ErrPathTraversal
80+ }
81+ if !envKeyRegex.MatchString(key) {
82+ return ErrPathTraversal
83+ }
84+ return nil
85+}
@@ -10,6 +10,7 @@ import (
10 // envDomainRegex validates domain names used for env secrets.10 // envDomainRegex validates domain names used for env secrets.
11 // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).11 // Allows: alphanumeric, dots, hyphens, colons (for ports), and forward slashes (for paths).
12 var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)12 var envDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$`)
13+var envKeyRegex = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
13 14
14 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.15 // SanitizeDomainForEnvFile validates and sanitizes a domain name for env file storage.
15 // Returns a safe domain string suitable for filenames.16 // Returns a safe domain string suitable for filenames.
@@ -68,3 +69,17 @@ func ParseEnvData(data []byte) (map[string]string, error) {
68 69
69 return secrets, nil70 return secrets, nil
70 }71 }
72+
73+// ValidateEnvKey validates an env key for storage.
74+func ValidateEnvKey(key string) error {
75+ if key == "" {
76+ return ErrPathTraversal
77+ }
78+ if strings.Contains(key, "..") || strings.ContainsAny(key, "/\\") {
79+ return ErrPathTraversal
80+ }
81+ if !envKeyRegex.MatchString(key) {
82+ return ErrPathTraversal
83+ }
84+ return nil
85+}

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 8

Tags 106

↑↓ moveenter openesc close