From ee688c6c9b5f547c3f09214d3199fbf9179a5aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Thu, 15 Oct 2026 12:16:47 +0200 Subject: [PATCH] fix: decrypt SOPS .env keys written with export or spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sops keeps everything before the = in a dotenv key, so `export TOKEN=…` is the key `export TOKEN`. lazysecrets asked sops for `TOKEN` and the value couldn't be decrypted, from the list or a link. - The sops path is the key as written; names and links are unchanged. - A link finds its key's path in the file, as the list does, instead of rebuilding it from the name. --- internal/provider/repo/repo.go | 63 ++++++++++------------------- internal/provider/repo/repo_test.go | 36 ++++++++++++++--- 2 files changed, 53 insertions(+), 46 deletions(-) diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index 8046d98..e729e6c 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -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 { diff --git a/internal/provider/repo/repo_test.go b/internal/provider/repo/repo_test.go index 2251c0b..8c6e60d 100644 --- a/internal/provider/repo/repo_test.go +++ b/internal/provider/repo/repo_test.go @@ -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) } } -- 2.54.0