1// Package secrets implements secret provider adapters.
2package secrets
3
4import (
5 "context"
6 "fmt"
7 "os/exec"
8 "regexp"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/pkg/childenv"
16)
17
18// passPathRegex validates pass paths.
19// Allows alphanumeric characters, forward slashes, underscores, dots, and hyphens.
20// This prevents command injection via shell metacharacters.
21var passPathRegex = regexp.MustCompile(`^[a-zA-Z0-9/_.-]+$`)
22
23// PassProvider implements the SecretProvider interface using the pass password manager.
24type PassProvider struct {
25 timeout time.Duration
26 log zerowrap.Logger
27}
28
29// NewPassProvider creates a new pass provider.
30func NewPassProvider(log zerowrap.Logger) *PassProvider {
31 return &PassProvider{
32 timeout: 10 * time.Second,
33 log: log,
34 }
35}
36
37// Name returns the provider name.
38func (p *PassProvider) Name() string {
39 return "pass"
40}
41
42// ValidatePath validates a pass path to prevent command injection and path traversal.
43// Returns an error if the path is invalid.
44func ValidatePath(path string) error {
45 if path == "" {
46 return fmt.Errorf("path cannot be empty")
47 }
48
49 // SECURITY: Validate path format to prevent command injection
50 if !passPathRegex.MatchString(path) {
51 return fmt.Errorf("invalid path: must contain only alphanumeric characters, /, _, ., -")
52 }
53
54 // SECURITY: Prevent path traversal
55 if strings.Contains(path, "..") {
56 return domain.ErrPathTraversal
57 }
58
59 // SECURITY: Reject absolute paths
60 if strings.HasPrefix(path, "/") {
61 return fmt.Errorf("invalid path: absolute paths not allowed")
62 }
63
64 return nil
65}
66
67// GetSecret retrieves a secret from pass by path.
68func (p *PassProvider) GetSecret(ctx context.Context, path string) (string, error) {
69 // SECURITY: Validate path before executing command
70 if err := ValidatePath(path); err != nil {
71 p.log.Warn().
72 Str(zerowrap.FieldLayer, "adapter").
73 Str(zerowrap.FieldAdapter, "secrets").
74 Str("provider", "pass").
75 Str("path", path).
76 Err(err).
77 Msg("rejected invalid pass path")
78 return "", err
79 }
80
81 ctx, cancel := context.WithTimeout(ctx, p.timeout)
82 defer cancel()
83
84 cmd := childenv.CommandContext(ctx, "pass", "show", path)
85 output, err := cmd.Output()
86 if err != nil {
87 if exitError, ok := err.(*exec.ExitError); ok {
88 return "", fmt.Errorf("pass command failed: %s", string(exitError.Stderr))
89 }
90 return "", fmt.Errorf("failed to execute pass command: %w", err)
91 }
92
93 secret := strings.TrimSpace(string(output))
94 if secret == "" {
95 return "", fmt.Errorf("empty secret returned from pass for path: %s", path)
96 }
97
98 p.log.Debug().
99 Str(zerowrap.FieldLayer, "adapter").
100 Str(zerowrap.FieldAdapter, "secrets").
101 Str("provider", "pass").
102 Str("path", path).
103 Msg("successfully retrieved secret from pass")
104
105 return secret, nil
106}
107
108// IsAvailable checks if pass is available in the system.
109// Uses a short timeout so a stalled GPG agent does not hang the caller.
110func (p *PassProvider) IsAvailable() bool {
111 ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
112 defer cancel()
113 cmd := childenv.CommandContext(ctx, "pass", "version")
114 return cmd.Run() == nil
115}