From b677201e3414bbce3786fc4f200a22044e405ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Thu, 15 Oct 2026 13:10:05 +0200 Subject: [PATCH] feat: Spring Boot config files in repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit application*.properties/.yml and bootstrap* files tracked by git are listed under Repos, with their secret-looking keys as secrets. - Keys whose name ends like a secret (password, secret, token, key, …) or whose value looks like one (Jasypt ENC(...), a URL or connection string with a password) are listed; providers.repos.spring.keys and .values replace the patterns, also in a repo's .lazysecrets.yaml. - A file's details count the keys left out; ${VAR} values are marked as references, and a tracked file is only marked red when a listed value is in plain text. - Later YAML/properties documents name their keys after the profile: spring.datasource.password (prod). - Maven's @placeholders@ parse; Argo CD's applications.yaml and Helm templates aren't Spring files. - A green leaf icon, themeable with springColor. --- README.md | 4 +- cmd/lazysecrets/main.go | 2 +- docs/config.md | 1 + docs/keys.md | 2 +- docs/repos.md | 45 ++- internal/config/config.go | 6 + internal/config/config_test.go | 1 + internal/provider/repo/details.go | 11 +- internal/provider/repo/repo.go | 25 +- internal/provider/repo/spring.go | 414 ++++++++++++++++++++++++++ internal/provider/repo/spring_test.go | 222 ++++++++++++++ internal/ui/app/favourites.go | 11 +- internal/ui/icons/icons.go | 2 + internal/ui/styles/styles.go | 2 + internal/ui/styles/theme.go | 3 + 15 files changed, 733 insertions(+), 18 deletions(-) create mode 100644 internal/provider/repo/spring.go create mode 100644 internal/provider/repo/spring_test.go diff --git a/README.md b/README.md index 7e6ec8c..8db978a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti - **Your way**: rebind any key, pick a theme, and add custom commands to run on a secret. - **Try it first**: `lazysecrets --demo` runs on built-in sample data. -- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos. +- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env` and Spring Boot config files in your git repos. > **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new. @@ -87,7 +87,7 @@ pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker). - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults - [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes -- [SOPS and .env files](docs/repos.md): which repos and files, decrypting +- [SOPS, .env and Spring files](docs/repos.md): which repos and files, decrypting - [Sharing](docs/sharing.md): links to secrets, encrypted env files, and opening links, also from the browser and chat - [Integration](docs/integration.md): how lazysecrets can integrate into other systems diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 61e21de..bf3fd4b 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -346,7 +346,7 @@ Flags: if config.On(pc.Repos.Enabled) { repos := repo.New(repo.Settings{ Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots), - Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, + Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Spring: pc.Repos.Spring, }) // The repo it started in (shown on top as Repo) and the // others, checked for secret files in the background like diff --git a/docs/config.md b/docs/config.md index 0e2f3ab..fa79722 100644 --- a/docs/config.md +++ b/docs/config.md @@ -74,6 +74,7 @@ theme: azureColor: ["#82aaff"] databricksColor: ["#ff757f"] repoColor: ["#c099ff"] + springColor: ["#c3e88d"] # Spring Boot config files ``` A role lazysecrets doesn't know, a typo or one from a newer version, is diff --git a/docs/keys.md b/docs/keys.md index 829dc24..483b363 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -53,7 +53,7 @@ match case exactly. | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | -| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | +| `e` | open the selected SOPS, `.env` or Spring file, or a secret's file at its line ([how](repos.md#opening-files)) | | `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) | | `ctrl+o` | open a link or reference (or just paste one) | | `x` | [custom commands](custom-commands.md) | diff --git a/docs/repos.md b/docs/repos.md index 08d55a0..2aa0c67 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -1,9 +1,9 @@ -# SOPS and .env files in git repos +# SOPS, .env and Spring files in git repos lazysecrets shows git repositories under **Repos**: their [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, -`.env`, INI) and plain env files as vaults, and the keys in them -(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is +`.env`, INI), plain env files and [Spring Boot config](#spring-boot-config) +as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is escaped, `app\.name`, so it doesn't read as nesting. ## Which repos @@ -46,7 +46,7 @@ Folders of dependencies and caches are never looked in, tracked or not: 3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go, 4. the whole repo. -`include`/`exclude` apply to env files too. +`include`/`exclude` apply to env and Spring files too. ```yaml providers: @@ -65,6 +65,43 @@ sharing: key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link ``` +## Spring Boot config + +`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the +like, tracked by git (not those in a Helm chart's `templates/`). Most of +their keys aren't secret, so only those that look like one are listed: + +- **keys** named like a secret: ending in `password`, `pwd`, `secret`, + `token`, `key`, `credentials`, `salt`, `connection-string` and the + like (`spring.datasource.password`, `client-secret`, `checkoutKey`), +- **values** that look like one: Jasypt's `ENC(...)`, a URL with a + password (`jdbc:postgresql://app:pw@db/app`), or a connection string + with `AccountKey=`, `SharedAccessKey=` or `Password=`. + +A file's details count the keys left out, and `e` opens it whole. A +`${DB_PASSWORD}` value is marked as a reference: the secret is set +elsewhere. A tracked file is marked in red only when a listed key has its +value in plain text. + +Each list of regexps replaces its default; `keys: [".*"]` lists every key: + +```yaml +providers: + repos: + spring: + keys: ["(?i)(password|secret|token|key)$", "bearer$"] # matched against the key's name + values: ["^ENC\\(.+\\)$"] # and against its value +``` + +A repo's `.lazysecrets.yaml` can set `spring` too, for everyone, and wins +over the config, one list at a time. + +Keys are named as Spring names them, `spring.datasource.password`; in a +file with several documents (`---`, or `#---` in `.properties`), a later +document's keys get its profile, `spring.datasource.password (prod)`. +Values Maven fills in at build time (`@project.version@`) are read as +they are. + ## Decrypting Listing never decrypts: SOPS encrypts values only, so keys and metadata diff --git a/internal/config/config.go b/internal/config/config.go index 145afd1..98c63c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ import ( "go.yaml.in/yaml/v3" "git.kebcloud.no/keb/lazysecrets/internal/commands" + "git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/share" "git.kebcloud.no/keb/lazysecrets/internal/update" ) @@ -153,6 +154,8 @@ type Repos struct { // a repo's .sops.yaml creation_rules say where to look. Include []string `yaml:"include"` Exclude []string `yaml:"exclude"` + // Spring says which keys of Spring Boot config files are secrets. + Spring repo.SpringPatterns `yaml:"spring"` } type Azure struct { @@ -292,6 +295,9 @@ func (c Config) validate(path string) error { return fmt.Errorf("%s: %s: %s is negative", path, name, d) } } + if err := c.Providers.Repos.Spring.Check(); err != nil { + return fmt.Errorf("%s: providers.repos.%w", path, err) + } for i, v := range c.Providers.Azure.Vaults { if !strings.HasPrefix(v, "https://") { return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f3aea3a..58e2f73 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -84,6 +84,7 @@ func TestLoadRejectsMistakes(t *testing.T) { "workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n", "short keys": "sharing:\n keyRules:\n minLength: 8\n", "classes": "sharing:\n keyRules:\n minClasses: 5\n", + "regexp": "providers:\n repos:\n spring:\n keys: [\"(pass\"]\n", } { if _, err := LoadAll([]string{write(t, content)}); err == nil { t.Errorf("%s: no error", name) diff --git a/internal/provider/repo/details.go b/internal/provider/repo/details.go index 0ffc1e3..dd97a68 100644 --- a/internal/provider/repo/details.go +++ b/internal/provider/repo/details.go @@ -22,7 +22,12 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide } var out []provider.Attr meta, isSops := readMeta(c.ID) - if isSops { + // A plain file's values are at risk in git; a Spring file's only when + // one of its secrets is in plain text. + plain := !isSops + if !isSops && IsSpring(c.ID) { + out, plain = p.springDetails(c) + } else if isSops { out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")}) for i, k := range meta.recipients { label := "" @@ -43,7 +48,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide return strings.TrimSpace(string(b)), err == nil } switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); { - case tracked && !isSops: + case tracked && plain: out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true}) case tracked: out = append(out, provider.Attr{Label: "git", Value: "tracked"}) @@ -51,7 +56,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide if _, ignored := git("check-ignore", "-q", "--", rel); ignored { out = append(out, provider.Attr{Label: "git", Value: "ignored"}) } else { - out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops}) + out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain}) } return out, nil } diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index e729e6c..c101cbc 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -1,6 +1,7 @@ // 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. Secret files are SOPS files and plain env files. +// their keys the secrets. Secret files are SOPS files, plain env files and +// Spring Boot config (spring.go). // // 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 @@ -49,6 +50,9 @@ type Settings struct { // Include and Exclude are globs relative to a repo's root (** matches // any depth), for repos without their own .lazysecrets.yaml. Include, Exclude []string + // Spring says which keys of Spring Boot config files are secrets, for + // repos whose .lazysecrets.yaml doesn't. + Spring SpringPatterns // Sops is the sops binary; "sops" (on PATH) when empty. Sops string } @@ -320,6 +324,8 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi return // a SOPS file where .sops.yaml says SOPS files don't go case IsEnv(f): c.Detail = provider.DetailPlainEnv + case IsSpring(f): + c.Detail = "spring · " + springFormat(f) default: return } @@ -357,6 +363,9 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O return nil, err } meta, isSops := parseMeta(c.ID, b) + if !isSops && IsSpring(c.ID) { + return p.springObjects(c, b) + } keys, err := parseKeys(c.ID, b) if err != nil { return nil, fmt.Errorf("%s: %w", c.Name, err) @@ -394,6 +403,9 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre file = filePath(o.Container) } if _, isSops := readMeta(file); !isSops { + if IsSpring(file) { + return springValue(file, o.Name) + } return plainValue(file, o.Name) } if !ok { @@ -443,6 +455,12 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File if o == nil { return f, nil } + if f.Sops == "" && IsSpring(path) { + if k, err := springKeyNamed(path, o.Name); err == nil { + f.Line = k.line + } + return f, nil + } b, err := os.ReadFile(path) if err != nil { return f, err @@ -594,8 +612,9 @@ func normalizeRemote(remote string) (string, string) { // lazysecretsFile is a repo's own .lazysecrets.yaml. type lazysecretsFile struct { - Include []string `yaml:"include"` - Exclude []string `yaml:"exclude"` + Include []string `yaml:"include"` + Exclude []string `yaml:"exclude"` + Spring SpringPatterns `yaml:"spring"` Sharing struct { // Key is the link to the secret that encrypts the repo's // shared env files (package share). diff --git a/internal/provider/repo/spring.go b/internal/provider/repo/spring.go new file mode 100644 index 0000000..299a299 --- /dev/null +++ b/internal/provider/repo/spring.go @@ -0,0 +1,414 @@ +package repo + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode" + + "go.yaml.in/yaml/v3" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +// Spring Boot config files (application*.properties/.yml, bootstrap*) hold +// all of an app's config, most of it not secret: of their keys, only those +// named like a secret or with a value that looks like one are listed. The +// patterns are configurable, and the file's details count the rest, so a +// secret the patterns miss isn't hidden without a trace. + +// DefaultSpringKeys and DefaultSpringValues are the patterns used when +// neither the config nor the repo's .lazysecrets.yaml sets them. The key +// pattern is anchored at the end of the name: spring.datasource.password, +// client-secret and checkoutKey, not the oauth2.*, keycloak.* and +// token-uri keys every Spring app has. passord is Norwegian. The file's +// details count the keys left out. +var ( + DefaultSpringKeys = []string{ + `(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`, + } + DefaultSpringValues = []string{ + `^ENC\(.+\)$`, // Jasypt + `://[^/\s:@]*:[^/\s@]+@`, // a URL with a password: jdbc:postgresql://user:pass@host + `(?i)(AccountKey|SharedAccessKey|Password|Pwd)=`, // a connection string with one + } +) + +// SpringPatterns are regexps matched against a Spring config key's name +// and its value: a key is listed when either matches. A list that isn't +// set is DefaultSpringKeys or DefaultSpringValues. +type SpringPatterns struct { + Keys *[]string `yaml:"keys"` + Values *[]string `yaml:"values"` +} + +// Check says which pattern doesn't compile. +func (p SpringPatterns) Check() error { + _, err := compileSpring(p) + return err +} + +type springFilter struct{ keys, values []*regexp.Regexp } + +func compileSpring(p SpringPatterns) (springFilter, error) { + var f springFilter + var errs []error + add := func(field string, patterns *[]string, defaults []string, out *[]*regexp.Regexp) { + list := defaults + if patterns != nil { + list = *patterns + } + for i, s := range list { + re, err := regexp.Compile(s) + if err != nil { + errs = append(errs, fmt.Errorf("spring.%s[%d]: %w", field, i, err)) + continue + } + *out = append(*out, re) + } + } + add("keys", p.Keys, DefaultSpringKeys, &f.keys) + add("values", p.Values, DefaultSpringValues, &f.values) + return f, errors.Join(errs...) +} + +func (f springFilter) secret(k springKey) bool { + return anyMatch(f.keys, k.key) || anyMatch(f.values, k.value) +} + +// springFilterFor is the repo's patterns: its .lazysecrets.yaml's, else the +// config's, each list on its own. +func (p *Provider) springFilterFor(root string) (springFilter, error) { + f, err := readLazysecretsFile(root) + if err != nil { + return springFilter{}, err + } + patterns := p.s.Spring + if f.Spring.Keys != nil { + patterns.Keys = f.Spring.Keys + } + if f.Spring.Values != nil { + patterns.Values = f.Spring.Values + } + filter, err := compileSpring(patterns) + if err != nil && (f.Spring.Keys != nil || f.Spring.Values != nil) { + err = fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err) + } + return filter, err +} + +// springName is application.yml, application-prod.properties, bootstrap.yaml +// and the like; not applications.yaml (Argo CD's) or application.yml.example. +var springName = regexp.MustCompile(`^(application|bootstrap)(-[^.]+)?\.(properties|ya?ml)$`) + +// IsSpring reports whether f is named like a Spring Boot config file, and +// isn't a Helm chart's template of one. +func IsSpring(f string) bool { + return springName.MatchString(strings.ToLower(path.Base(f))) && path.Base(path.Dir(f)) != "templates" +} + +func springFormat(f string) string { + if strings.ToLower(filepath.Ext(f)) == ".properties" { + return "properties" + } + return "yaml" +} + +type springKey struct { + key string // spring.datasource.password + name string // key, with (profile) after it in a later document + value string + line int // 1-based +} + +// reference reports whether the value is only a placeholder, ${DB_PASSWORD}: +// the secret is elsewhere. One with a default, ${DB_PASSWORD:hunter2}, has +// the default in the file. +func (k springKey) reference() bool { + return strings.HasPrefix(k.value, "${") && strings.HasSuffix(k.value, "}") && + !strings.ContainsAny(k.value[2:len(k.value)-1], ":${}") +} + +func (k springKey) jasypt() bool { + return strings.HasPrefix(k.value, "ENC(") && strings.HasSuffix(k.value, ")") +} + +// plain reports whether the file holds the value itself, readable. +func (k springKey) plain() bool { + return k.value != "" && !k.reference() && !k.jasypt() +} + +// readSpring lists a Spring config file's keys, every one, with values. +func readSpring(file string) ([]springKey, error) { + b, err := os.ReadFile(file) + if err != nil { + return nil, err + } + return parseSpring(file, b) +} + +func parseSpring(file string, b []byte) ([]springKey, error) { + if springFormat(file) == "properties" { + return parseProperties(b), nil + } + return parseSpringYAML(b) +} + +// springDocs names the keys of a multi-document file's later documents +// after the profile they're for, so each name is one key: +// spring.datasource.password (prod). +func springDocs(docs [][]springKey) []springKey { + var out []springKey + for i, doc := range docs { + suffix := "" + if i > 0 { + suffix = fmt.Sprintf(" (document %d)", i+1) + for _, k := range doc { + if k.key == "spring.config.activate.on-profile" || k.key == "spring.profiles" { + suffix = " (" + k.value + ")" + } + } + } + for _, k := range doc { + k.name = k.key + suffix + out = append(out, k) + } + } + return out +} + +// mavenFiltered is a value Maven fills in at build time, @project.version@: +// not YAML until it's quoted. +var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`) + +func parseSpringYAML(b []byte) ([]springKey, error) { + b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte { + sub := mavenFiltered.FindSubmatch(m) + return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''"))) + }) + dec := yaml.NewDecoder(bytes.NewReader(b)) + var docs [][]springKey + for { + var doc yaml.Node + err := dec.Decode(&doc) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + var keys []springKey + if len(doc.Content) > 0 { + springWalk(doc.Content[0], "", &keys) + } + docs = append(docs, keys) + } + return springDocs(docs), nil +} + +// springWalk names keys the way Spring does: nested keys joined by dots, +// list items by index (servers[0]). +func springWalk(n *yaml.Node, name string, out *[]springKey) { + switch n.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(n.Content); i += 2 { + child := n.Content[i].Value + if name != "" { + child = name + "." + child + } + springWalk(n.Content[i+1], child, out) + } + case yaml.SequenceNode: + for i, c := range n.Content { + springWalk(c, fmt.Sprintf("%s[%d]", name, i), out) + } + case yaml.AliasNode: + if n.Alias != nil { + springWalk(n.Alias, name, out) + } + case yaml.ScalarNode: + if name != "" { + *out = append(*out, springKey{key: name, value: n.Value, line: n.Line}) + } + } +} + +// parseProperties reads a Java .properties file: key=value, key: value or +// key value, lines continued with a trailing backslash, # and ! comments, +// and #--- between documents (Spring Boot 2.4+). +func parseProperties(b []byte) []springKey { + lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n") + var docs [][]springKey + var doc []springKey + for i := 0; i < len(lines); i++ { + start := i + 1 + line := strings.TrimLeftFunc(lines[i], unicode.IsSpace) + if line == "#---" || line == "!---" { + docs, doc = append(docs, doc), nil + continue + } + if line == "" || line[0] == '#' || line[0] == '!' { + continue + } + for continued(line) && i+1 < len(lines) { + i++ + line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace) + } + k, v := splitProperty(line) + doc = append(doc, springKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start}) + } + return springDocs(append(docs, doc)) +} + +// continued reports whether the line ends in an odd number of backslashes. +func continued(line string) bool { + n := len(line) - len(strings.TrimRight(line, `\`)) + return n%2 == 1 +} + +// splitProperty cuts a logical line at its first unescaped =, : or +// whitespace; whitespace around the separator isn't part of either side. +func splitProperty(line string) (key, value string) { + const space = " \t\f" + for i := 0; i < len(line); i++ { + switch c := line[i]; { + case c == '\\': + i++ + case c == '=' || c == ':': + return line[:i], strings.TrimLeft(line[i+1:], space) + case strings.IndexByte(space, c) >= 0: + rest := strings.TrimLeft(line[i:], space) + if rest != "" && (rest[0] == '=' || rest[0] == ':') { + rest = strings.TrimLeft(rest[1:], space) + } + return line[:i], rest + } + } + return line, "" +} + +func unescapeProperty(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+1 == len(s) { + b.WriteByte(s[i]) + continue + } + i++ + switch s[i] { + case 't': + b.WriteByte('\t') + case 'n': + b.WriteByte('\n') + case 'r': + b.WriteByte('\r') + case 'f': + b.WriteByte('\f') + case 'u': + if i+5 <= len(s) { + if r, err := strconv.ParseUint(s[i+1:i+5], 16, 32); err == nil { + b.WriteRune(rune(r)) + i += 4 + continue + } + } + b.WriteByte('u') + default: + b.WriteByte(s[i]) + } + } + return b.String() +} + +// springObjects lists the file's secret keys. +func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Object, error) { + keys, err := parseSpring(c.ID, b) + if err != nil { + return nil, fmt.Errorf("%s: %w", c.Name, err) + } + filter, err := p.springFilterFor(c.Source.ID) + if err != nil { + return nil, err + } + format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)} + var out []provider.Object + for _, k := range keys { + if !filter.secret(k) { + continue + } + state := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"} + switch { + case k.reference(): + state = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"} + case k.jasypt(): + state = provider.Attr{Label: "encrypted", Value: "jasypt"} + } + out = append(out, provider.Object{ + Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, + Attrs: []provider.Attr{format, state}, + }) + } + return out, nil +} + +func springValue(file, name string) (provider.SecretValue, error) { + k, err := springKeyNamed(file, name) + if err != nil { + return provider.SecretValue{}, err + } + return provider.NewSecretValue([]byte(k.value)), nil +} + +func springKeyNamed(file, name string) (springKey, error) { + keys, err := readSpring(file) + if err != nil { + return springKey{}, err + } + for _, k := range keys { + if k.name == name { + return k, nil + } + } + return springKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file)) +} + +// springDetails says how many of the file's keys aren't listed, and +// whether any listed one is in plain text. +func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, plain bool) { + keys, err := readSpring(c.ID) + if err != nil { + return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false + } + filter, err := p.springFilterFor(c.Source.ID) + if err != nil { + return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false + } + secrets, jasypt := 0, 0 + for _, k := range keys { + if filter.secret(k) { + secrets++ + plain = plain || k.plain() + } + if k.jasypt() { + jasypt++ + } + } + encryption := "none: plain text" + if jasypt > 0 { + encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, len(keys)) + } + return []provider.Attr{ + {Label: "encryption", Value: encryption}, + {Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)}, + }, plain +} diff --git a/internal/provider/repo/spring_test.go b/internal/provider/repo/spring_test.go new file mode 100644 index 0000000..7f92860 --- /dev/null +++ b/internal/provider/repo/spring_test.go @@ -0,0 +1,222 @@ +package repo + +import ( + "context" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +func TestIsSpring(t *testing.T) { + for f, want := range map[string]bool{ + "src/main/resources/application.yml": true, + "application-prod.properties": true, + "config/application-local.yaml": true, + "bootstrap.yml": true, + "applicationset/dev/applications.yaml": false, // Argo CD's + "chart/templates/application.yaml": false, // Helm's + "application.yml.example": false, + "application.json": false, + "src/main/resources/applicationContext.xml": false, + } { + if IsSpring(f) != want { + t.Errorf("%s: %t", f, !want) + } + } +} + +func springNames(keys []springKey) map[string]string { + out := map[string]string{} + for _, k := range keys { + out[k.name] = k.value + } + return out +} + +func TestParseProperties(t *testing.T) { + got := springNames(parseProperties([]byte(`# a comment +! another +a=1 +b : 2 +c 3 +d\:e=4 +long=one, \ + two +unicode=café +empty= +#--- +spring.config.activate.on-profile=prod +a=prod-1 +`))) + want := map[string]string{ + "a": "1", "b": "2", "c": "3", "d:e": "4", "long": "one, two", "unicode": "café", "empty": "", + "spring.config.activate.on-profile (prod)": "prod", "a (prod)": "prod-1", + } + if !maps.Equal(got, want) { + t.Errorf("got %v\nwant %v", got, want) + } +} + +func TestParseSpringYAML(t *testing.T) { + keys, err := parseSpringYAML([]byte(`info.version: @project.version@ +artifacts: + - @project.artifactId@ +spring: + datasource: + password: ${DB_PASSWORD} +servers: + - name: a + secret: x +logging.level: + com.example: DEBUG +--- +spring: + config.activate.on-profile: prod + datasource: + password: hunter2 +--- +other: y +`)) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "info.version": "@project.version@", "artifacts[0]": "@project.artifactId@", + "spring.datasource.password": "${DB_PASSWORD}", "servers[0].name": "a", "servers[0].secret": "x", + "logging.level.com.example": "DEBUG", + "spring.config.activate.on-profile (prod)": "prod", "spring.datasource.password (prod)": "hunter2", + "other (document 3)": "y", + } + if got := springNames(keys); !maps.Equal(got, want) { + t.Errorf("got %v\nwant %v", got, want) + } +} + +func TestSpringDefaults(t *testing.T) { + f, err := compileSpring(SpringPatterns{}) + if err != nil { + t.Fatal(err) + } + for key, value := range map[string]string{ + "spring.datasource.password": "x", + "spring.security.oauth2.client.registration.a.client-secret": "x", + "profapp.payment.checkoutKey": "x", + "app.clients.sgig.sgig-passord": "x", + "jasypt.encryptor.password": "${JASYPT_ENCRYPTOR_PASSWORD}", + "spring.datasource.url": "jdbc:postgresql://app:hunter2@db:5432/app", + "app.servicebus.connection": "Endpoint=sb://x/;SharedAccessKeyName=a;SharedAccessKey=b", + "app.name": "ENC(abc==)", + } { + if !f.secret(springKey{key: key, value: value}) { + t.Errorf("%s=%s isn't listed", key, value) + } + } + for key, value := range map[string]string{ + "spring.security.oauth2.client.provider.a.token-uri": "https://idp/token", + "spring.security.oauth2.client.registration.a.authorization-grant-type": "client_credentials", + "app.frontend.keycloak.url": "https://keycloak", + "server.port": "8080", + "spring.datasource.url": "jdbc:postgresql://db:5432/app", + } { + if f.secret(springKey{key: key, value: value}) { + t.Errorf("%s=%s is listed", key, value) + } + } +} + +func TestSpringFiles(t *testing.T) { + root := gitRepo(t, map[string]string{ + "src/main/resources/application.yml": `server: + port: 8080 +spring: + datasource: + url: jdbc:postgresql://db/app + password: ${DB_PASSWORD} +jasypt: + encryptor: + password: ${JASYPT_ENCRYPTOR_PASSWORD} +app: + api-key: ENC(abc==) +`, + "src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n", + "applicationset/applications.yaml": "kind: ApplicationSet\n", + }) + ctx := context.Background() + details := func(p *Provider, c provider.Container) map[string]provider.Attr { + attrs, err := p.Details(ctx, c) + if err != nil { + t.Fatal(err) + } + out := map[string]provider.Attr{} + for _, a := range attrs { + out[a.Label] = a + } + return out + } + + p := New(Settings{Dir: root}) + srcs, _ := p.Sources(ctx) + cs, err := p.Containers(ctx, srcs[0]) + if err != nil { + t.Fatal(err) + } + var files []string + for _, c := range cs { + files = append(files, c.Name+" · "+c.Detail) + } + if want := []string{ + "src/main/resources/application-local.properties · spring · properties", + "src/main/resources/application.yml · spring · yaml", + }; !slices.Equal(files, want) { + t.Fatalf("files %v\nwant %v", files, want) + } + local, yml := cs[0], cs[1] + + keys, _ := p.Objects(ctx, yml, provider.TypeSecret) + var names []string + for _, k := range keys { + names = append(names, k.Name) + } + if want := []string{"spring.datasource.password", "jasypt.encryptor.password", "app.api-key"}; !slices.Equal(names, want) { + t.Errorf("keys %v, want %v", names, want) + } + if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "ENC(abc==)" { + t.Errorf("value: %q %v", v.Reveal(), err) + } + link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"} + if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "${DB_PASSWORD}" { + t.Errorf("from a link: %q %v", v.Reveal(), err) + } + if f, _ := p.File(yml, &keys[1]); f.Line != 9 || f.Sops != "" { + t.Errorf("file: %+v", f) + } + + // References and Jasypt aren't plain text in git; a password is. + if d := details(p, yml); d["other keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" { + t.Errorf("yml: %+v", d) + } + if d := details(p, local); !d["git"].Warn { + t.Errorf("local: %+v", d) + } + + // The config's patterns, and the repo's over them. + all := []string{".*"} + p = New(Settings{Dir: root, Spring: SpringPatterns{Keys: &all}}) + if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 5 { + t.Errorf("every key: %d", len(keys)) + } + lazysecrets := filepath.Join(root, ".lazysecrets.yaml") + os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"url$\"]\n values: []\n"), 0o644) + if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 1 || keys[0].Name != "spring.datasource.url" { + t.Errorf("repo's patterns: %v", keys) + } + os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644) + if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.keys[0]") { + t.Errorf("bad pattern: %v", err) + } +} diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go index 0afb93a..77bd261 100644 --- a/internal/ui/app/favourites.go +++ b/internal/ui/app/favourites.go @@ -101,11 +101,14 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con n := st.Node(storeKey(c)) line := c.Name if c.Source.Kind == provider.KindRepo { - // A secret file: a lock for an encrypted one, a file in the - // warning colour for plain text, and its type after it - // ("sops · age", "env · plain text"). + // A secret file: a lock for an encrypted one, a leaf + // for Spring config, a file in the warning colour for plain + // text, and its type after it ("sops · age", "env · plain text"). icon, style := iconSet.Encrypted, sty.KindRepo - if !strings.HasPrefix(c.Detail, "sops") { + switch { + case strings.HasPrefix(c.Detail, "spring"): + icon, style = iconSet.Spring, sty.Spring + case !strings.HasPrefix(c.Detail, "sops"): icon, style = iconSet.PlainFile, sty.Warning } if icon != "" { diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index 15a3941..bea7dab 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -11,6 +11,7 @@ type Set struct { Repo string Encrypted string // an encrypted file (SOPS) PlainFile string // a plain text secrets file (.env) + Spring string // a Spring Boot config file Note string // has a note (lazysecrets list) } @@ -25,6 +26,7 @@ var NerdFont = Set{ Repo: "\U000F02A2", // md-git Encrypted: "\U000F033E", // md-lock PlainFile: "\U000F0214", // md-file + Spring: "\U000F032A", // md-leaf Note: "\U000F09ED", } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 8fe991c..8205c43 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -34,6 +34,7 @@ type Styles struct { KindAzure lipgloss.Style KindDatabricks lipgloss.Style KindRepo lipgloss.Style + Spring lipgloss.Style // Spring Boot config files // themed marks the selection colours as set by the theme, so // OnBackground leaves them. @@ -70,6 +71,7 @@ func Default() Styles { KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue), KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red), KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta), + Spring: lipgloss.NewStyle().Foreground(lipgloss.Green), } } diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go index e1aecc0..3ff56be 100644 --- a/internal/ui/styles/theme.go +++ b/internal/ui/styles/theme.go @@ -42,6 +42,7 @@ var Roles = map[string]string{ "azureColor": "Azure sources and vaults", "databricksColor": "Databricks workspaces and scopes", "repoColor": "repos and their secret files (SOPS)", + "springColor": "Spring Boot config files", } // FromTheme returns the default styles with the theme's roles applied, and @@ -99,6 +100,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) { st.KindDatabricks = v.fg(lipgloss.NewStyle()) case "repoColor": st.KindRepo = v.fg(lipgloss.NewStyle()) + case "springColor": + st.Spring = v.fg(lipgloss.NewStyle()) } } return st, fg, nil -- 2.54.0