Files
lazysecrets/internal/provider/repo/repo.go
T
Karl-Einar Bergstrøm 4377eb98bf feat: share env files as encrypted links (#54)
Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link.

**Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file).
- **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference.
- **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels.
- **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening.
- **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them.
- **The command line:** `lazysecrets share [--key <secret>] <env files>`.

**Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`.
1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`.
2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore.
3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written.
4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged.

**Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable.

**Safety:**
- A link can only write env files, inside the folder: never in `.git`, never through a symlink.
- New files are created with mode 0600.
- Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB.

**Along the way:**
- Opening a link to a secret ends on its details, as `enter` would.
- `o` on an Azure secret opens its portal page, and in the versions view, that version's page.
- The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address.
- `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning.
- `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there.
- New dependency: `filippo.io/age`.

Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #54
2026-10-13 14:55:22 +02:00

1121 lines
31 KiB
Go

// Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. SOPS files are the one type so far.
//
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or
// copied, one at a time, by the sops binary, with the keys sops itself
// uses (age, Azure Key Vault through az, AWS/GCP KMS, PGP).
//
// Which files: tracked by git, then, in order of preference, the include
// and exclude globs of the repo's .lazysecrets.yaml, those of the config,
// or where the repo's .sops.yaml creation_rules say SOPS files go; a repo
// with none of those is searched whole.
package repo
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
type Settings struct {
// Dir is where lazysecrets started: the repo containing it, if any,
// is listed first and marked Here, with its files under Dir first.
Dir string
// Paths are repos to show.
Paths []string
// Roots are folders of repos (like ~/git): their repos, and repos one
// level further down (org/repo).
Roots []string
// Starred are repos the user starred something in, shown wherever
// they are.
Starred []string
// Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string
// Sops is the sops binary; "sops" (on PATH) when empty.
Sops string
}
type Provider struct {
s Settings
repos []repoInfo
err error
once sync.Once
// here is the repo containing Dir, found on its own so it can show
// before the roots are scanned.
here *repoInfo
hereOnce sync.Once
}
var (
_ provider.Provider = (*Provider)(nil)
_ provider.Locator = (*Provider)(nil)
_ provider.Current = (*Provider)(nil)
)
type repoInfo struct {
root string // absolute
name string // the folder, or its path under a root (org/repo)
id string // the remote without scheme or .git, else the root path
web string // the remote's web page, if it has one
prefix string // Dir relative to root ("" when Dir isn't in it)
here bool // the repo lazysecrets started in
}
func New(s Settings) *Provider {
if s.Sops == "" {
s.Sops = "sops"
}
return &Provider{s: s}
}
func (p *Provider) Kind() provider.Kind { return provider.KindRepo }
func (p *Provider) ID() string { return "Repos" }
func (p *Provider) Label() string { return "Repos" }
func (p *Provider) load() {
p.once.Do(func() {
seen := map[string]bool{}
var roots []string
names := map[string]string{} // path under a root (org/repo)
add := func(root string) {
if !seen[root] {
seen[root] = true
roots = append(roots, root)
}
}
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current()
if inRepo {
seen[here.root] = true
}
for _, dir := range p.s.Paths {
root, err := gitRoot(expandHome(dir))
if err != nil {
p.err = errors.Join(p.err, fmt.Errorf("%s: not a git repository", dir))
continue
}
add(root)
}
for _, dir := range p.s.Roots {
base, _ := filepath.EvalSymlinks(expandHome(dir))
for _, root := range reposIn(expandHome(dir), 2) {
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
names[root] = filepath.ToSlash(name)
}
add(root)
}
}
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil {
add(root)
}
}
// A git call each for the remote: dozens under ~/git, so several
// at a time.
infos := make([]repoInfo, len(roots))
parallel(len(roots), func(i int) {
infos[i] = info(roots[i], "")
if n := names[roots[i]]; n != "" {
infos[i].name = n
}
})
if inRepo {
infos = append([]repoInfo{here}, infos...)
}
p.repos = infos
})
}
// current is the repo lazysecrets started in.
func (p *Provider) current() (repoInfo, bool) {
p.hereOnce.Do(func() {
root, err := gitRoot(p.s.Dir)
if err != nil {
return
}
rel, _ := filepath.Rel(root, absPath(p.s.Dir))
if rel == "." {
rel = ""
}
r := info(root, filepath.ToSlash(rel))
r.here = true
p.here = &r
})
if p.here == nil {
return repoInfo{}, false
}
return *p.here, true
}
// Current is the repo lazysecrets started in, if it has secret files:
// one repo to check, so it can show before the others are.
func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
r, ok := p.current()
if !ok {
return provider.Source{}, false
}
src := p.source(r)
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
return provider.Source{}, false
}
return src, true
}
func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
ID: r.root, Name: r.name, Detail: r.id, URL: r.web, Here: r.here,
}
}
// parallel runs f for 0..n-1, several at a time.
func parallel(n int, f func(i int)) {
var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
f(i)
}()
}
wg.Wait()
}
// reposIn finds git repositories in dir, depth levels down at most,
// without looking inside a repo or hidden folders.
func reposIn(dir string, depth int) []string {
entries, err := os.ReadDir(dir)
if err != nil || depth == 0 {
return nil
}
var out []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
path := filepath.Join(dir, e.Name())
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
if abs, err := filepath.EvalSymlinks(path); err == nil {
path = abs
}
out = append(out, path)
continue
}
out = append(out, reposIn(path, depth-1)...)
}
return out
}
func info(root, prefix string) repoInfo {
id, web := remoteID(root)
if id == "" {
id = root
}
return repoInfo{root: root, name: filepath.Base(root), id: id, web: web, prefix: prefix}
}
// Sources lists the repos that have secret files: one without any isn't
// worth a row.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.load()
// Each repo is checked for secret files, several at a time: under
// ~/git that's dozens of repos.
keep := make([]bool, len(p.repos))
srcs := make([]provider.Source, len(p.repos))
for i, r := range p.repos {
srcs[i] = p.source(r)
}
parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0
})
var out []provider.Source
for i, src := range srcs {
if keep[i] {
out = append(out, src)
}
}
if len(out) == 0 && p.err != nil {
return nil, p.err
}
return out, nil
}
func (p *Provider) repo(id string) (repoInfo, bool) {
if r, ok := p.current(); ok && r.root == id {
return r, true // without waiting for the others
}
p.load()
for _, r := range p.repos {
if r.root == id {
return r, true
}
}
return repoInfo{}, false
}
// Containers lists a repo's secret files: those under where lazysecrets
// started first, then the rest, each by path.
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
r, ok := p.repo(src.ID)
if !ok {
return nil, fmt.Errorf("unknown repo %s", src.ID)
}
files, err := tracked(ctx, r.root)
if err != nil {
return nil, err
}
user, sopsWhere, err := p.selector(r.root, files)
if err != nil {
return nil, err
}
// SOPS files are tracked, where .sops.yaml says they go. Env files
// usually aren't tracked (they're in .gitignore), so they're looked
// for by name among untracked and ignored files too.
envs := envFiles(r.root, files)
seen := map[string]bool{}
var out []provider.Container
add := func(f string) {
if seen[f] || !user(f) {
return
}
seen[f] = true
path := filepath.Join(r.root, f)
c := provider.Container{Source: src, ID: path, Name: f, Types: []provider.ObjectType{provider.TypeSecret}}
switch meta, isSops := readMeta(path); {
case isSops && sopsWhere(f):
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go
case IsEnv(f):
c.Detail = provider.DetailPlainEnv
default:
return
}
out = append(out, c)
}
for _, f := range envs {
add(f)
}
for _, f := range files {
if !inPackages(f) {
add(f)
}
}
under := func(c provider.Container) bool {
return r.prefix == "" || strings.HasPrefix(c.Name, r.prefix+"/")
}
slices.SortStableFunc(out, func(a, b provider.Container) int {
if ua, ub := under(a), under(b); ua != ub {
if ua {
return -1
}
return 1
}
return strings.Compare(a.Name, b.Name)
})
return out, nil
}
func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
if t != provider.TypeSecret {
return nil, nil
}
b, err := os.ReadFile(c.ID)
if err != nil {
return nil, err
}
meta, isSops := readMeta(c.ID)
keys, err := parseKeys(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
if !isSops { // a plain env file
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
}
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
o := provider.Object{
Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
}
if !k.encrypted {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
}
out = append(out, o)
}
return out, nil
}
func (p *Provider) Describe(_ context.Context, o provider.Object) (provider.Object, error) {
return o, nil
}
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
return nil, provider.ErrUnsupported
}
// Value decrypts one key with sops.
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
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, extract = o.Container.ID, extractFor(o.Name)
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
}
if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name)
}
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
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return provider.SecretValue{}, errors.New("sops isn't installed: decrypting needs it (brew install sops)")
}
return provider.SecretValue{}, errors.New(sopsError(stderr.String(), err))
}
return provider.NewSecretValue(stdout.Bytes()), nil
}
// Contents reads the file c is.
func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) {
f, err := p.File(c, nil)
if err != nil {
return nil, err
}
return os.ReadFile(f.Path)
}
// File is the file c is, and the line o's key is on.
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := c.ID
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops
}
if o == nil {
return f, nil
}
b, err := os.ReadFile(path)
if err != nil {
return f, err
}
keys, err := parseKeys(path, b)
if err != nil {
return f, nil // still worth opening, at the top
}
_, extract, _ := strings.Cut(o.ID, "\x00")
for _, k := range keys {
if k.extract == extract || (extract == "" && k.name == o.Name) {
f.Line = k.line
break
}
}
return f, nil
}
// sopsError is sops' own message, whole: which keys it tried and why
// each failed can all matter.
func sopsError(stderr string, err error) string {
msg := strings.TrimSpace(stderr)
if msg == "" {
msg = err.Error()
}
return "sops couldn't decrypt it:\n" + msg
}
// Locate finds the repo a link names, by its remote (or root path).
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
p.load()
for _, r := range p.repos {
if strings.EqualFold(r.id, host) {
return r.root, true, nil
}
}
return "", false, nil
}
// Root is the root of the git repo dir is in, and the repo's ID: its
// remote without scheme or .git (a repo link's host), else the root.
func Root(dir string) (root, id string, err error) {
root, err = gitRoot(dir)
if err != nil {
return "", "", fmt.Errorf("%s isn't in a git repository", dir)
}
return root, info(root, "").id, nil
}
// --- git ---
func gitRoot(dir string) (string, error) {
if dir == "" {
return "", errors.New("no directory")
}
out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func tracked(ctx context.Context, root string) ([]string, error) {
out, err := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "-z").Output()
if err != nil {
return nil, fmt.Errorf("git ls-files in %s: %w", root, err)
}
var files []string
for _, f := range strings.Split(string(out), "\x00") {
if f != "" {
files = append(files, f)
}
}
return files, nil
}
// remoteID turns the origin remote into an ID ("git.kebcloud.no/keb/repo")
// and a web URL, both empty without a remote.
func remoteID(root string) (string, string) {
// From .git/config directly: a git process per repo is most of what
// listing dozens of them costs. git answers for worktrees and
// submodules (.git is a file) and remotes it rewrites (insteadOf).
if url, ok := originURL(filepath.Join(root, ".git", "config")); ok {
if url == "" {
return "", ""
}
return normalizeRemote(url)
}
out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output()
if err != nil {
return "", ""
}
return normalizeRemote(strings.TrimSpace(string(out)))
}
// originURL reads [remote "origin"] url from a git config file. It isn't
// ok when the file is missing or uses insteadOf, which only git resolves.
func originURL(config string) (string, bool) {
b, err := os.ReadFile(config)
if err != nil || bytes.Contains(b, []byte("insteadOf")) || bytes.Contains(b, []byte("insteadof")) {
return "", false
}
inOrigin := false
for line := range strings.SplitSeq(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
inOrigin = line == `[remote "origin"]`
continue
}
if k, v, ok := strings.Cut(line, "="); ok && inOrigin && strings.TrimSpace(k) == "url" {
return strings.TrimSpace(v), true
}
}
return "", true // no origin
}
// normalizeRemote handles https://host/path(.git), ssh://user@host[:port]/path
// and scp-style user@host:path.
func normalizeRemote(remote string) (string, string) {
u := strings.TrimSuffix(remote, ".git")
scheme := false
for _, s := range []string{"https://", "http://", "ssh://", "git://"} {
if rest, ok := strings.CutPrefix(u, s); ok {
u, scheme = rest, true
}
}
host, rest, _ := strings.Cut(u, "/")
if !scheme {
// scp-style: user@host:path
if h, r, ok := strings.Cut(u, ":"); ok {
host, rest = h, r
}
}
if at := strings.LastIndex(host, "@"); at >= 0 {
host = host[at+1:]
}
host, _, _ = strings.Cut(host, ":") // a port
if host == "" || rest == "" {
return "", ""
}
id := strings.ToLower(host + "/" + strings.Trim(rest, "/"))
if strings.Contains(host, ".") {
return id, "https://" + host + "/" + strings.Trim(rest, "/")
}
return id, ""
}
// --- which files ---
// lazysecretsFile is a repo's own .lazysecrets.yaml.
type lazysecretsFile struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
Share struct {
// Key is the link to the secret that encrypts the repo's
// shared env files (package share).
Key string `yaml:"key"`
} `yaml:"share"`
}
// ShareKey is the share.key of the repo's .lazysecrets.yaml, if any.
func ShareKey(root string) (string, error) {
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return "", fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
return f.Share.Key, nil
}
// selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
include, exclude := p.s.Include, p.s.Exclude
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
}
inc, err := globs(include)
if err != nil {
return nil, nil, err
}
exc, err := globs(exclude)
if err != nil {
return nil, nil, err
}
var rules []sopsRule
if len(inc) == 0 {
rules = sopsRules(root, files)
}
user = func(f string) bool {
return !anyMatch(exc, f) && (len(inc) == 0 || anyMatch(inc, f))
}
sopsWhere = func(f string) bool {
return len(rules) == 0 || slices.ContainsFunc(rules, func(r sopsRule) bool { return r.matches(f) })
}
return user, sopsWhere, nil
}
// envFiles lists env files by name (.env, .env.*, *.env), tracked or
// not, ignored ones included, leaving out examples and dependencies.
// tracked is the repo's tracked files. Untracked ones are looked for in
// the folders that have tracked files, not by walking the whole tree: an
// ignored .env sits next to the code it's for, and the walk would go
// through node_modules and the like, which is most of the time a scan
// takes.
func envFiles(root string, tracked []string) []string {
var out []string
isTracked := make(map[string]bool, len(tracked))
dirs := map[string]bool{".": true}
for _, f := range tracked {
isTracked[f] = true
if IsEnv(f) {
out = append(out, f)
}
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
dirs[d] = true
}
}
for d := range dirs {
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(d)))
if err != nil {
continue
}
for _, e := range entries {
f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
out = append(out, f)
}
}
}
return out
}
// packages are folders of dependencies, caches and build output: never
// looked in for secret files.
var packages = map[string]bool{
"node_modules": true, "bower_components": true, "jspm_packages": true,
"vendor": true, "Pods": true, "Carthage": true,
".venv": true, "venv": true, "__pycache__": true, ".tox": true, ".nox": true,
".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true,
".terraform": true, ".terragrunt-cache": true,
".gradle": true, ".m2": true, ".yarn": true, ".pnpm-store": true,
".npm": true, ".cache": true, ".next": true, ".nuxt": true, ".svelte-kit": true,
".git": true,
}
// inPackages reports whether f is inside one of the packages folders.
func inPackages(f string) bool {
for d := range strings.SplitSeq(path.Dir(f), "/") {
if packages[d] {
return true
}
}
return false
}
// IsEnv reports whether a file is named like an env file with real values:
// .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies.
func IsEnv(f string) bool {
if inPackages(f) {
return false
}
base := strings.ToLower(path.Base(f))
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
if strings.HasSuffix(base, ext) {
return false
}
}
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
}
// plainValue reads a key from a plain env file.
func plainValue(file, name string) (provider.SecretValue, error) {
b, err := os.ReadFile(file)
if err != nil {
return provider.SecretValue{}, err
}
for _, line := range strings.Split(string(b), "\n") {
if k, v, ok := envLine(line); ok && k == name {
return provider.NewSecretValue([]byte(v)), nil
}
}
return provider.SecretValue{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
// envLine reads KEY=value (or export KEY=value), unquoting the value and
// dropping a comment after an unquoted one.
func envLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
line = strings.TrimPrefix(line, "export ")
k, v, ok := strings.Cut(line, "=")
if !ok {
return "", "", false
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') {
if end := strings.LastIndexByte(v, v[0]); end > 0 {
inner := v[1:end]
if v[0] == '"' {
inner = strings.NewReplacer(`\n`, "\n", `\"`, `"`, `\\`, `\`).Replace(inner)
}
return k, inner, true
}
}
if i := strings.Index(v, " #"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return k, v, true
}
// sopsRule is a creation rule of a .sops.yaml, which applies to files
// under the directory it's in.
type sopsRule struct {
dir string // relative to the repo root, "" for the root
re *regexp.Regexp
}
func (r sopsRule) matches(f string) bool {
rel := f
if r.dir != "" {
var ok bool
if rel, ok = strings.CutPrefix(f, r.dir+"/"); !ok {
return false
}
}
return r.re == nil || r.re.MatchString(rel)
}
func sopsRules(root string, files []string) []sopsRule {
var rules []sopsRule
for _, f := range files {
if path.Base(f) != ".sops.yaml" {
continue
}
b, err := os.ReadFile(filepath.Join(root, f))
if err != nil {
continue
}
var cfg struct {
CreationRules []struct {
PathRegex string `yaml:"path_regex"`
} `yaml:"creation_rules"`
}
if yaml.Unmarshal(b, &cfg) != nil {
continue
}
dir := path.Dir(f)
if dir == "." {
dir = ""
}
for _, cr := range cfg.CreationRules {
r := sopsRule{dir: dir}
if cr.PathRegex != "" {
re, err := regexp.Compile(cr.PathRegex)
if err != nil {
continue
}
r.re = re
}
rules = append(rules, r)
}
}
return rules
}
// globs compiles globs: * and ? within a path segment, ** across them.
func globs(patterns []string) ([]*regexp.Regexp, error) {
var out []*regexp.Regexp
for _, g := range patterns {
var b strings.Builder
b.WriteString("^")
for i := 0; i < len(g); i++ {
switch c := g[i]; {
case strings.HasPrefix(g[i:], "**/"):
b.WriteString("(.*/)?")
i += 2
case strings.HasPrefix(g[i:], "**"):
b.WriteString(".*")
i++
case c == '*':
b.WriteString("[^/]*")
case c == '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(string(c)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return nil, fmt.Errorf("glob %q: %w", g, err)
}
out = append(out, re)
}
return out, nil
}
func anyMatch(res []*regexp.Regexp, f string) bool {
return slices.ContainsFunc(res, func(re *regexp.Regexp) bool { return re.MatchString(f) })
}
// --- sops files ---
type sopsMeta struct {
methods []string // age, azure_kv, …
recipients []string // "age age1…", "azure_kv https://kv…/keys/sops"
modified time.Time
}
// recipient names a key a file is encrypted for, from its sops metadata:
// public, never a secret.
func recipient(method string, f map[string]string) string {
var v string
switch method {
case "age":
v = f["recipient"]
case "pgp":
v = f["fp"]
case "kms":
v = f["arn"]
case "gcp_kms":
v = f["resource_id"]
case "azure_kv":
v = strings.TrimSuffix(f["vault_url"], "/") + "/keys/" + f["name"]
case "hc_vault":
v = strings.TrimSuffix(f["vault_address"], "/") + " " + f["engine_path"] + "/" + f["key_name"]
}
return method + " " + v
}
const maxFileSize = 4 << 20
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
// file of a supported format.
func readMeta(file string) (sopsMeta, bool) {
fi, err := os.Stat(file)
if err != nil || fi.Size() > maxFileSize {
return sopsMeta{}, false
}
if formatOf(file) == "" {
return sopsMeta{}, false // not a format sops writes: skip reading
}
b, err := os.ReadFile(file)
if err != nil || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
}
var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch formatOf(file) {
case "yaml", "json":
var doc struct {
Sops map[string]any `yaml:"sops"`
}
if yaml.Unmarshal(b, &doc) != nil || doc.Sops == nil || doc.Sops["mac"] == nil {
return m, false
}
for _, k := range known {
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
m.methods = append(m.methods, k)
for _, e := range v {
fields := map[string]string{}
if em, ok := e.(map[string]any); ok {
for fk, fv := range em {
fields[fk] = fmt.Sprint(fv)
}
}
m.recipients = append(m.recipients, recipient(k, fields))
}
}
}
if s, ok := doc.Sops["lastmodified"].(string); ok {
m.modified, _ = time.Parse(time.RFC3339, s)
}
if t, ok := doc.Sops["lastmodified"].(time.Time); ok {
m.modified = t
}
case "dotenv", "ini":
text := string(b)
if !strings.Contains(text, "sops_mac") && !strings.Contains(text, "sops_version") {
return m, false
}
for _, k := range known {
if strings.Contains(text, "sops_"+k+"__") {
m.methods = append(m.methods, k)
m.recipients = append(m.recipients, envRecipients(text, k)...)
}
}
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
line, _, _ := strings.Cut(text[i:], "\n")
if _, v, ok := strings.Cut(line, "="); ok {
m.modified, _ = time.Parse(time.RFC3339, strings.TrimSpace(v))
}
}
default:
return m, false
}
if len(m.methods) == 0 {
m.methods = []string{"unknown keys"}
}
return m, true
}
// envRecipients are method's keys in a dotenv or ini sops file, whose
// metadata is flattened: sops_age__list_0__map_recipient=age1….
func envRecipients(text, method string) []string {
prefix := "sops_" + method + "__list_"
var order []string
keys := map[string]map[string]string{}
for line := range strings.Lines(text) {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
rest, found := strings.CutPrefix(strings.TrimSpace(k), prefix)
if !ok || !found {
continue
}
n, field, ok := strings.Cut(rest, "__map_")
if !ok {
continue
}
if keys[n] == nil {
keys[n] = map[string]string{}
order = append(order, n)
}
keys[n][field] = strings.TrimSpace(v)
}
out := make([]string, len(order))
for i, n := range order {
out[i] = recipient(method, keys[n])
}
return out
}
func formatOf(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".yaml", ".yml":
return "yaml"
case ".json":
return "json"
case ".env":
return "dotenv"
case ".ini":
return "ini"
}
if strings.HasPrefix(filepath.Base(file), ".env") {
return "dotenv"
}
return ""
}
type key struct {
name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"]
encrypted bool
line int // 1-based
}
// parseKeys lists a sops file's keys without decrypting anything.
func parseKeys(file string, b []byte) ([]key, error) {
switch formatOf(file) {
case "yaml", "json":
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
if len(doc.Content) == 0 {
return nil, nil
}
var out []key
walk(doc.Content[0], "", "", true, &out)
return out, nil
case "dotenv":
var out []key
for i, line := range strings.Split(string(b), "\n") {
k, v, ok := envLine(line)
if !ok || strings.HasPrefix(k, "sops_") {
continue
}
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
return out, nil
case "ini":
var out []key
section := ""
for i, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
switch {
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"):
section = strings.TrimSpace(line[1 : len(line)-1])
case section != "sops":
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
}
}
return out, nil
}
return nil, fmt.Errorf("unsupported format")
}
// walk collects the leaves of a YAML/JSON tree, skipping sops' own block.
func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
k := n.Content[i].Value
if top && k == "sops" {
continue
}
child := k
if name != "" {
child = name + "." + k
}
walk(n.Content[i+1], child, extract+quote(k), false, out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
walk(c, fmt.Sprintf("%s[%d]", name, i), fmt.Sprintf("%s[%d]", extract, i), false, out)
}
case yaml.AliasNode:
if n.Alias != nil {
walk(n.Alias, name, extract, false, out)
}
default:
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC["), line: n.Line})
}
}
// quote is a sops --extract path element.
func quote(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]). Names with dots in keys can't be
// told apart from nesting: those come from the listing instead.
func extractFor(name string) string {
var b strings.Builder
for _, part := range strings.Split(name, ".") {
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
}
if idx != "" {
b.WriteString("[" + idx)
}
}
return b.String()
}
func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil {
return a
}
return p
}
func expandHome(p string) string {
if rest, ok := strings.CutPrefix(p, "~/"); ok {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, rest)
}
}
return p
}