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