From eaeeea86fc0bf3bdf0ddf2ba11e2e16cfccbd498 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 12:47:08 -0300 Subject: [PATCH] =?UTF-8?q?feat(secrets):=20S2=20=E2=80=94=20SOPS+age=20pr?= =?UTF-8?q?ovider=20(offline=20default)=20(spec=2004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "works on a plane" provider: a secrets.Provider that decrypts via the `sops` binary (NOT the getsops Go SDK, which pulls every cloud-KMS SDK and bloats the static binary — DECISIONS). - SopsProvider.Resolve groups refs by file and runs `sops -d --output-type json ` ONCE per file (batch), then extracts each ref's dot-path key from the decrypted JSON. SOPS_AGE_KEY_FILE is set from the provider config so the age identity is found regardless of platform default key paths. - SopsFactory + RegisterBuiltins(reg) wire it under kind "sops"; AWS/Infisical register additively (S3/S4). Missing binary / missing key are clear errors. - lookupPath walks nested maps and stringifies scalar leaves. Unit-tested with a fake runner: batch-per-file (2 refs → 1 decrypt), missing-key error, sops-absent error, factory wiring (ageKeyFile), lookupPath nested/scalar/ missing. The real `sops` path is standard `-d --output-type json`; a sops-in-CI integration + the S6 no-leak test exercise it end to end once sops is on the runner. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/secrets/sops.go | 153 ++++++++++++++++++++++++++++++++++ internal/secrets/sops_test.go | 114 +++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 internal/secrets/sops.go create mode 100644 internal/secrets/sops_test.go diff --git a/internal/secrets/sops.go b/internal/secrets/sops.go new file mode 100644 index 0000000..c413d50 --- /dev/null +++ b/internal/secrets/sops.go @@ -0,0 +1,153 @@ +package secrets + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" +) + +// This file is the SOPS+age provider (S2): the offline, no-account "works on a +// plane" default. It shells out to the `sops` binary (NOT the getsops Go SDK, +// which transitively pulls every cloud-KMS SDK and bloats the static binary — +// DECISIONS) and decrypts each referenced file ONCE (batch), then extracts each +// ref's dot-path key from the decrypted JSON. SOPS_AGE_KEY_FILE is set from the +// provider config so `decrypt` finds the age identity regardless of platform +// default key paths. +// +// Reference shape: secret:///# + +// SopsKind is the provider kind that selects this factory. +const SopsKind = "sops" + +// CmdRunner runs an external command capturing stdout. Injectable for tests. +type CmdRunner interface { + Output(ctx context.Context, env []string, name string, args ...string) ([]byte, error) + LookPath(file string) (string, error) +} + +// SopsProvider decrypts SOPS files via the sops binary. +type SopsProvider struct { + name string + ageKeyFile string // SOPS_AGE_KEY_FILE; "" inherits the process env + baseDir string // resolve relative ref paths against this (workspace root) + runner CmdRunner +} + +// SopsFactory builds a SopsProvider from its config. The age key file comes from +// the `env` field (a path) or Opts["ageKeyFile"]; baseDir from Opts["baseDir"]. +func SopsFactory(cfg ProviderConfig) (Provider, error) { + p := &SopsProvider{ + name: cfg.Name, + ageKeyFile: firstNonEmpty(cfg.Opts["ageKeyFile"], cfg.Env), + baseDir: cfg.Opts["baseDir"], + runner: execCmdRunner{}, + } + return p, nil +} + +// RegisterBuiltins registers the offline built-in provider factories (currently +// SOPS+age) on a registry. AWS/Infisical register additively in S3/S4. +func RegisterBuiltins(reg *Registry) { + reg.RegisterFactory(SopsKind, SopsFactory) +} + +func (p *SopsProvider) Name() string { return p.name } + +// Resolve decrypts each referenced file once and extracts every ref's key. +func (p *SopsProvider) Resolve(ctx context.Context, refs []Ref) (map[string]string, error) { + if p.runner == nil { + p.runner = execCmdRunner{} + } + if _, err := p.runner.LookPath("sops"); err != nil { + return nil, fmt.Errorf("sops not found on PATH — install it (https://github.com/getsops/sops) for the %q provider", p.name) + } + + byFile := map[string][]Ref{} + for _, r := range refs { + byFile[r.Path] = append(byFile[r.Path], r) + } + + out := map[string]string{} + for _, file := range sortedKeys(byFile) { + data, err := p.decrypt(ctx, file) + if err != nil { + return nil, err + } + for _, r := range byFile[file] { + v, ok := lookupPath(data, r.Key) + if !ok { + return nil, fmt.Errorf("sops: key %q not found in %q", r.Key, file) + } + out[r.Raw] = v + } + } + return out, nil +} + +// decrypt runs `sops -d --output-type json ` and parses the result. +func (p *SopsProvider) decrypt(ctx context.Context, file string) (map[string]any, error) { + path := file + if p.baseDir != "" && !strings.HasPrefix(file, "/") { + path = p.baseDir + "/" + file + } + var env []string + if p.ageKeyFile != "" { + env = append(env, "SOPS_AGE_KEY_FILE="+p.ageKeyFile) + } + out, err := p.runner.Output(ctx, env, "sops", "-d", "--output-type", "json", path) + if err != nil { + return nil, fmt.Errorf("sops decrypt %q: %w", file, err) + } + var data map[string]any + if err := json.Unmarshal(out, &data); err != nil { + return nil, fmt.Errorf("sops: parse decrypted JSON of %q: %w", file, err) + } + return data, nil +} + +// lookupPath walks a dot-separated key path through nested maps and returns the +// leaf as a string. Numbers/bools are stringified. +func lookupPath(data map[string]any, key string) (string, bool) { + if key == "" { + return "", false + } + var cur any = data + for part := range strings.SplitSeq(key, ".") { + m, ok := cur.(map[string]any) + if !ok { + return "", false + } + cur, ok = m[part] + if !ok { + return "", false + } + } + switch v := cur.(type) { + case string: + return v, true + case nil: + return "", false + default: + return fmt.Sprintf("%v", v), true + } +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +// execCmdRunner is the production CmdRunner. +type execCmdRunner struct{} + +func (execCmdRunner) Output(ctx context.Context, env []string, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), env...) + return cmd.Output() +} +func (execCmdRunner) LookPath(file string) (string, error) { return exec.LookPath(file) } diff --git a/internal/secrets/sops_test.go b/internal/secrets/sops_test.go new file mode 100644 index 0000000..ddc7747 --- /dev/null +++ b/internal/secrets/sops_test.go @@ -0,0 +1,114 @@ +package secrets + +import ( + "context" + "errors" + "strings" + "testing" +) + +type fakeCmdRunner struct { + have map[string]bool + outputs map[string][]byte // keyed by the file path argument + err error + calls int +} + +func (f *fakeCmdRunner) Output(_ context.Context, _ []string, name string, args ...string) ([]byte, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + // last arg is the file path. + file := args[len(args)-1] + if out, ok := f.outputs[file]; ok { + return out, nil + } + return nil, errors.New("no canned output for " + file) +} +func (f *fakeCmdRunner) LookPath(file string) (string, error) { + if f.have[file] { + return "/usr/bin/" + file, nil + } + return "", errors.New("not found") +} + +func TestSopsResolveBatchesPerFile(t *testing.T) { + fr := &fakeCmdRunner{ + have: map[string]bool{"sops": true}, + outputs: map[string][]byte{ + "secrets.enc.yaml": []byte(`{"postgres":{"password":"pg-secret"},"redis":{"url":"redis://x"}}`), + }, + } + p := &SopsProvider{name: "sops", runner: fr} + refs := []Ref{ + {Raw: "secret://sops/secrets.enc.yaml#postgres.password", Path: "secrets.enc.yaml", Key: "postgres.password"}, + {Raw: "secret://sops/secrets.enc.yaml#redis.url", Path: "secrets.enc.yaml", Key: "redis.url"}, + } + got, err := p.Resolve(context.Background(), refs) + if err != nil { + t.Fatal(err) + } + if got["secret://sops/secrets.enc.yaml#postgres.password"] != "pg-secret" || got["secret://sops/secrets.enc.yaml#redis.url"] != "redis://x" { + t.Errorf("resolved = %v", got) + } + // Two refs to one file → ONE decrypt (batch). + if fr.calls != 1 { + t.Errorf("decrypted %d times, want 1 (batched per file)", fr.calls) + } +} + +func TestSopsMissingKeyErrors(t *testing.T) { + fr := &fakeCmdRunner{have: map[string]bool{"sops": true}, outputs: map[string][]byte{ + "f.yaml": []byte(`{"a":"1"}`), + }} + p := &SopsProvider{name: "sops", runner: fr} + _, err := p.Resolve(context.Background(), []Ref{{Raw: "secret://sops/f.yaml#missing", Path: "f.yaml", Key: "missing"}}) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("want a missing-key error, got %v", err) + } +} + +func TestSopsBinaryAbsentErrors(t *testing.T) { + p := &SopsProvider{name: "sops", runner: &fakeCmdRunner{have: map[string]bool{}}} + _, err := p.Resolve(context.Background(), []Ref{{Raw: "secret://sops/f#k", Path: "f", Key: "k"}}) + if err == nil || !strings.Contains(err.Error(), "sops not found") { + t.Fatalf("want sops-not-found error, got %v", err) + } +} + +func TestSopsFactoryAndRegister(t *testing.T) { + reg := NewRegistry() + RegisterBuiltins(reg) + reg.Configure(ProviderConfig{Name: "vault", Kind: SopsKind, Opts: map[string]string{"ageKeyFile": "/k/age.txt"}}) + p, err := reg.Provider("vault") + if err != nil { + t.Fatal(err) + } + if p.Name() != "vault" { + t.Errorf("provider name = %q", p.Name()) + } + sp, ok := p.(*SopsProvider) + if !ok || sp.ageKeyFile != "/k/age.txt" { + t.Errorf("factory did not wire ageKeyFile: %+v", p) + } +} + +func TestLookupPath(t *testing.T) { + data := map[string]any{"a": map[string]any{"b": "deep"}, "n": 42, "t": true} + if v, ok := lookupPath(data, "a.b"); !ok || v != "deep" { + t.Errorf("a.b = %q,%v", v, ok) + } + if v, _ := lookupPath(data, "n"); v != "42" { + t.Errorf("n = %q, want stringified 42", v) + } + if v, _ := lookupPath(data, "t"); v != "true" { + t.Errorf("t = %q, want true", v) + } + if _, ok := lookupPath(data, "a.missing"); ok { + t.Error("missing nested key should not be found") + } + if _, ok := lookupPath(data, ""); ok { + t.Error("empty key should not be found") + } +}