Release / Build and publish (push) Successful in 37s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1081 lines
30 KiB
Go
1081 lines
30 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 = "env · plain text"
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// --- 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|