Providers whose containers are files give their contents through provider.Contents, which sharing reads instead of the disk. The demo repos build their env files from their keys, and the second demo account has a key to share with (kv-shared-prod / env-share), so S → f and opening the link work in --demo; the demo repos aren't on disk, so opening asks where to save. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1121 lines
31 KiB
Go
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
|
|
}
|