fix: decrypt SOPS .env keys written with export or spaces #78

Merged
keb merged 1 commits from fix/sops-dotenv-export into main 2026-10-15 12:18:09 +02:00
2 changed files with 53 additions and 46 deletions
Showing only changes of commit ee688c6c9b - Show all commits

No files matched your search

+22 -41
View File
@@ -392,11 +392,16 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file = filePath(o.Container)
extract = extractFor(file, o.Name)
}
if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name)
}
if !ok {
var err error
if extract, err = extractOf(file, o.Name); err != nil {
return provider.SecretValue{}, err
}
}
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
var stdout, stderr bytes.Buffer
@@ -1045,7 +1050,8 @@ func parseKeys(file string, b []byte) ([]key, error) {
if !ok || strings.HasPrefix(k, "sops_") {
continue
}
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
return out, nil
case "ini":
@@ -1110,48 +1116,23 @@ func escape(k string) string {
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
}
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
// its names are the keys as they are.
func extractFor(file, name string) string {
if formatOf(file) == "dotenv" {
return quote(name)
// extractOf is the sops --extract path of the key named name, for objects
// made from a link.
func extractOf(file, name string) (string, error) {
b, err := os.ReadFile(file)
if err != nil {
return "", err
}
var b, part strings.Builder
flush := func() {
if part.Len() > 0 {
b.WriteString(quote(part.String()))
part.Reset()
keys, err := parseKeys(file, b)
if err != nil {
return "", err
}
for _, k := range keys {
if k.name == name {
return k.extract, nil
}
}
for i := 0; i < len(name); i++ {
switch c := name[i]; c {
case '\\':
if i+1 < len(name) {
i++
}
part.WriteByte(name[i])
case '.':
flush()
case '[':
end := strings.IndexByte(name[i:], ']')
if end < 0 || !digits(name[i+1:i+end]) {
part.WriteByte(c)
continue
}
flush()
b.WriteString(name[i : i+end+1])
i += end
default:
part.WriteByte(c)
}
}
flush()
return b.String()
}
func digits(s string) bool {
return s != "" && strings.Trim(s, "0123456789") == ""
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
func absPath(p string) string {
+31 -5
View File
@@ -237,6 +237,25 @@ func TestDecrypt(t *testing.T) {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
// A dotenv file's export lines: sops keeps export in the key.
os.WriteFile(plain, []byte("export TOKEN=s3cret\n"), 0o600)
enc, err = exec.Command("sops", "encrypt", "--age", recipient, "--input-type", "dotenv", "--output-type", "dotenv", plain).Output()
if err != nil {
t.Fatal(err)
}
envFile := filepath.Join(root, "prod.env")
os.WriteFile(envFile, enc, 0o600)
envC := provider.Container{Source: srcs[0], ID: envFile, Name: "prod.env"}
envKeys, _ := p.Objects(ctx, envC, provider.TypeSecret)
if len(envKeys) != 1 {
t.Fatalf("env keys: %v", envKeys)
}
for _, o := range []provider.Object{envKeys[0], {Container: provider.Container{Source: srcs[0], Name: "prod.env"}, Name: "TOKEN"}} {
if v, err := p.Value(ctx, o); err != nil || v.Reveal() != "s3cret" {
t.Errorf("export line (link: %t): %q %v", o.ID == "", v.Reveal(), err)
}
}
// Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
@@ -554,15 +573,22 @@ func TestKeyNamesEscaped(t *testing.T) {
var got []string
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
if e := extractFor("s.yaml", k.name); e != k.extract {
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
}
}
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
if strings.Join(got, "|") != want {
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
}
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
t.Errorf("dotenv: %s", e)
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
if err != nil {
t.Fatal(err)
}
got = nil
for _, k := range keys {
got = append(got, k.name+" "+k.extract)
}
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
if strings.Join(got, "|") != want {
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
}
}