1package secrets
2
3import (
4 "context"
5 "fmt"
6 "os/exec"
7 "path/filepath"
8 "regexp"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/bnema/gordon/pkg/childenv"
15)
16
17// sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets).
18var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`)
19
20// convertToSopsExtractPath converts dot notation (app.database.password) to
21// sops --extract Python dictionary syntax (["app"]["database"]["password"]).
22// Array indices like [0] are preserved as-is.
23func convertToSopsExtractPath(keyPath string) string {
24 if keyPath == "" {
25 return ""
26 }
27
28 var result strings.Builder
29 parts := strings.Split(keyPath, ".")
30
31 for _, part := range parts {
32 if part == "" {
33 continue
34 }
35 // Check if the part already contains array access like "items[0]"
36 // Split on '[' to handle cases like "items[0]" -> ["items"] + [0]
37 if idx := strings.Index(part, "["); idx > 0 {
38 // Has both key and array index, e.g., "items[0]"
39 key := part[:idx]
40 arrayPart := part[idx:] // includes "[0]" or "[0][1]" etc.
41 result.WriteString(`["`)
42 result.WriteString(key)
43 result.WriteString(`"]`)
44 result.WriteString(arrayPart)
45 } else if strings.HasPrefix(part, "[") {
46 // Pure array index like "[0]"
47 result.WriteString(part)
48 } else {
49 // Regular key
50 result.WriteString(`["`)
51 result.WriteString(part)
52 result.WriteString(`"]`)
53 }
54 }
55
56 return result.String()
57}
58
59// SopsProvider implements the SecretProvider interface using sops.
60type SopsProvider struct {
61 timeout time.Duration
62 log zerowrap.Logger
63}
64
65// NewSopsProvider creates a new sops provider.
66func NewSopsProvider(log zerowrap.Logger) *SopsProvider {
67 return &SopsProvider{
68 timeout: 10 * time.Second,
69 log: log,
70 }
71}
72
73// Name returns the provider name.
74func (s *SopsProvider) Name() string {
75 return "sops"
76}
77
78// GetSecret retrieves a secret from a sops-encrypted file.
79// The path format is "file.yaml:key.nested.path".
80func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, error) {
81 ctx, cancel := context.WithTimeout(ctx, s.timeout)
82 defer cancel()
83
84 // Extract key from path format: file.yaml:key.nested.path
85 parts := strings.SplitN(path, ":", 2)
86 if len(parts) != 2 {
87 return "", fmt.Errorf("sops path must be in format 'file:key', got: %s", path)
88 }
89
90 filePath := parts[0]
91 keyPath := parts[1]
92
93 // Validate file path to prevent command injection and path traversal
94 cleanPath := filepath.Clean(filePath)
95 if strings.Contains(cleanPath, "..") {
96 return "", fmt.Errorf("invalid file path: path traversal not allowed")
97 }
98 // SECURITY: Reject absolute paths to prevent reading arbitrary files on the system.
99 if filepath.IsAbs(cleanPath) {
100 return "", fmt.Errorf("invalid file path: absolute paths not allowed")
101 }
102
103 // Validate key path to prevent command injection
104 if !sopsKeyPathRegex.MatchString(keyPath) {
105 return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets")
106 }
107
108 // Convert dot notation to sops --extract Python dictionary syntax
109 extractPath := convertToSopsExtractPath(keyPath)
110
111 // Use sops -d --extract to get specific key; filePath and keyPath are
112 // validated above.
113 cmd := childenv.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath)
114 output, err := cmd.Output()
115 if err != nil {
116 if exitError, ok := err.(*exec.ExitError); ok {
117 return "", fmt.Errorf("sops command failed: %s", string(exitError.Stderr))
118 }
119 return "", fmt.Errorf("failed to execute sops command: %w", err)
120 }
121
122 secret := strings.TrimSpace(string(output))
123 if secret == "" {
124 return "", fmt.Errorf("empty secret returned from sops for path: %s", path)
125 }
126
127 s.log.Debug().
128 Str(zerowrap.FieldLayer, "adapter").
129 Str(zerowrap.FieldAdapter, "secrets").
130 Str("provider", "sops").
131 Str("path", path).
132 Msg("successfully retrieved secret from sops")
133
134 return secret, nil
135}
136
137// IsAvailable checks if sops is available in the system.
138func (s *SopsProvider) IsAvailable() bool {
139 ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
140 defer cancel()
141 cmd := childenv.CommandContext(ctx, "sops", "--version")
142 err := cmd.Run()
143 return err == nil
144}