feat: Spring Boot config files in repos (#79)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #79.
This commit is contained in:
1 parent
39cbac9aab
commit
96395cb6a2
15 files changed
+731
-16
No files matched your search
@@ -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
|
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||||
on a secret.
|
on a secret.
|
||||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
- **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.
|
> **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
|
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
- [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
|
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
||||||
opening links, also from the browser and chat
|
opening links, also from the browser and chat
|
||||||
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ Flags:
|
|||||||
if config.On(pc.Repos.Enabled) {
|
if config.On(pc.Repos.Enabled) {
|
||||||
repos := repo.New(repo.Settings{
|
repos := repo.New(repo.Settings{
|
||||||
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
|
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
|
// The repo it started in (shown on top as Repo) and the
|
||||||
// others, checked for secret files in the background like
|
// others, checked for secret files in the background like
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ theme:
|
|||||||
azureColor: ["#82aaff"]
|
azureColor: ["#82aaff"]
|
||||||
databricksColor: ["#ff757f"]
|
databricksColor: ["#ff757f"]
|
||||||
repoColor: ["#c099ff"]
|
repoColor: ["#c099ff"]
|
||||||
|
springColor: ["#c3e88d"] # Spring Boot config files
|
||||||
```
|
```
|
||||||
|
|
||||||
A role lazysecrets doesn't know, a typo or one from a newer version, is
|
A role lazysecrets doesn't know, a typo or one from a newer version, is
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ match case exactly.
|
|||||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
| `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 |
|
| `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) |
|
| `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)) |
|
| `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) |
|
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||||
| `x` | [custom commands](custom-commands.md) |
|
| `x` | [custom commands](custom-commands.md) |
|
||||||
|
|||||||
+41
-4
@@ -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
|
lazysecrets shows git repositories under **Repos**: their
|
||||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
`.env`, INI), plain env files and [Spring Boot config](#spring-boot-config)
|
||||||
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
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.
|
escaped, `app\.name`, so it doesn't read as nesting.
|
||||||
|
|
||||||
## Which repos
|
## 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,
|
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
|
||||||
4. the whole repo.
|
4. the whole repo.
|
||||||
|
|
||||||
`include`/`exclude` apply to env files too.
|
`include`/`exclude` apply to env and Spring files too.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
providers:
|
providers:
|
||||||
@@ -65,6 +65,43 @@ sharing:
|
|||||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
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
|
## Decrypting
|
||||||
|
|
||||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"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/share"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
"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.
|
// a repo's .sops.yaml creation_rules say where to look.
|
||||||
Include []string `yaml:"include"`
|
Include []string `yaml:"include"`
|
||||||
Exclude []string `yaml:"exclude"`
|
Exclude []string `yaml:"exclude"`
|
||||||
|
// Spring says which keys of Spring Boot config files are secrets.
|
||||||
|
Spring repo.SpringPatterns `yaml:"spring"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Azure struct {
|
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)
|
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 {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
if !strings.HasPrefix(v, "https://") {
|
if !strings.HasPrefix(v, "https://") {
|
||||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
|||||||
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
|
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
|
||||||
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
||||||
"classes": "sharing:\n keyRules:\n minClasses: 5\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 {
|
if _, err := LoadAll([]string{write(t, content)}); err == nil {
|
||||||
t.Errorf("%s: no error", name)
|
t.Errorf("%s: no error", name)
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
}
|
}
|
||||||
var out []provider.Attr
|
var out []provider.Attr
|
||||||
meta, isSops := readMeta(c.ID)
|
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, ", ")})
|
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||||
for i, k := range meta.recipients {
|
for i, k := range meta.recipients {
|
||||||
label := ""
|
label := ""
|
||||||
@@ -43,7 +48,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
|||||||
return strings.TrimSpace(string(b)), err == nil
|
return strings.TrimSpace(string(b)), err == nil
|
||||||
}
|
}
|
||||||
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
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})
|
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||||
case tracked:
|
case tracked:
|
||||||
out = append(out, provider.Attr{Label: "git", Value: "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 {
|
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
||||||
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
||||||
} else {
|
} 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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package repo browses the encrypted secret files in git repositories:
|
// Package repo browses the encrypted secret files in git repositories:
|
||||||
// a repo is a source, its secret files the containers (vault level), and
|
// 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
|
// 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
|
// 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
|
// Include and Exclude are globs relative to a repo's root (** matches
|
||||||
// any depth), for repos without their own .lazysecrets.yaml.
|
// any depth), for repos without their own .lazysecrets.yaml.
|
||||||
Include, Exclude []string
|
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 is the sops binary; "sops" (on PATH) when empty.
|
||||||
Sops string
|
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
|
return // a SOPS file where .sops.yaml says SOPS files don't go
|
||||||
case IsEnv(f):
|
case IsEnv(f):
|
||||||
c.Detail = provider.DetailPlainEnv
|
c.Detail = provider.DetailPlainEnv
|
||||||
|
case IsSpring(f):
|
||||||
|
c.Detail = "spring · " + springFormat(f)
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -357,6 +363,9 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
meta, isSops := parseMeta(c.ID, b)
|
meta, isSops := parseMeta(c.ID, b)
|
||||||
|
if !isSops && IsSpring(c.ID) {
|
||||||
|
return p.springObjects(c, b)
|
||||||
|
}
|
||||||
keys, err := parseKeys(c.ID, b)
|
keys, err := parseKeys(c.ID, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
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)
|
file = filePath(o.Container)
|
||||||
}
|
}
|
||||||
if _, isSops := readMeta(file); !isSops {
|
if _, isSops := readMeta(file); !isSops {
|
||||||
|
if IsSpring(file) {
|
||||||
|
return springValue(file, o.Name)
|
||||||
|
}
|
||||||
return plainValue(file, o.Name)
|
return plainValue(file, o.Name)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -443,6 +455,12 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
|
|||||||
if o == nil {
|
if o == nil {
|
||||||
return f, 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)
|
b, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return f, err
|
return f, err
|
||||||
@@ -596,6 +614,7 @@ func normalizeRemote(remote string) (string, string) {
|
|||||||
type lazysecretsFile struct {
|
type lazysecretsFile struct {
|
||||||
Include []string `yaml:"include"`
|
Include []string `yaml:"include"`
|
||||||
Exclude []string `yaml:"exclude"`
|
Exclude []string `yaml:"exclude"`
|
||||||
|
Spring SpringPatterns `yaml:"spring"`
|
||||||
Sharing struct {
|
Sharing struct {
|
||||||
// Key is the link to the secret that encrypts the repo's
|
// Key is the link to the secret that encrypts the repo's
|
||||||
// shared env files (package share).
|
// shared env files (package share).
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,11 +101,14 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
|
|||||||
n := st.Node(storeKey(c))
|
n := st.Node(storeKey(c))
|
||||||
line := c.Name
|
line := c.Name
|
||||||
if c.Source.Kind == provider.KindRepo {
|
if c.Source.Kind == provider.KindRepo {
|
||||||
// A secret file: a lock for an encrypted one, a file in the
|
// A secret file: a lock for an encrypted one, a leaf
|
||||||
// warning colour for plain text, and its type after it
|
// for Spring config, a file in the warning colour for plain
|
||||||
// ("sops · age", "env · plain text").
|
// text, and its type after it ("sops · age", "env · plain text").
|
||||||
icon, style := iconSet.Encrypted, sty.KindRepo
|
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
|
icon, style = iconSet.PlainFile, sty.Warning
|
||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Set struct {
|
|||||||
Repo string
|
Repo string
|
||||||
Encrypted string // an encrypted file (SOPS)
|
Encrypted string // an encrypted file (SOPS)
|
||||||
PlainFile string // a plain text secrets file (.env)
|
PlainFile string // a plain text secrets file (.env)
|
||||||
|
Spring string // a Spring Boot config file
|
||||||
Note string // has a note (lazysecrets list)
|
Note string // has a note (lazysecrets list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ var NerdFont = Set{
|
|||||||
Repo: "\U000F02A2", // md-git
|
Repo: "\U000F02A2", // md-git
|
||||||
Encrypted: "\U000F033E", // md-lock
|
Encrypted: "\U000F033E", // md-lock
|
||||||
PlainFile: "\U000F0214", // md-file
|
PlainFile: "\U000F0214", // md-file
|
||||||
|
Spring: "\U000F032A", // md-leaf
|
||||||
Note: "\U000F09ED",
|
Note: "\U000F09ED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Styles struct {
|
|||||||
KindAzure lipgloss.Style
|
KindAzure lipgloss.Style
|
||||||
KindDatabricks lipgloss.Style
|
KindDatabricks lipgloss.Style
|
||||||
KindRepo lipgloss.Style
|
KindRepo lipgloss.Style
|
||||||
|
Spring lipgloss.Style // Spring Boot config files
|
||||||
|
|
||||||
// themed marks the selection colours as set by the theme, so
|
// themed marks the selection colours as set by the theme, so
|
||||||
// OnBackground leaves them.
|
// OnBackground leaves them.
|
||||||
@@ -70,6 +71,7 @@ func Default() Styles {
|
|||||||
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||||
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||||
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
||||||
|
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ var Roles = map[string]string{
|
|||||||
"azureColor": "Azure sources and vaults",
|
"azureColor": "Azure sources and vaults",
|
||||||
"databricksColor": "Databricks workspaces and scopes",
|
"databricksColor": "Databricks workspaces and scopes",
|
||||||
"repoColor": "repos and their secret files (SOPS)",
|
"repoColor": "repos and their secret files (SOPS)",
|
||||||
|
"springColor": "Spring Boot config files",
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromTheme returns the default styles with the theme's roles applied, and
|
// 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())
|
st.KindDatabricks = v.fg(lipgloss.NewStyle())
|
||||||
case "repoColor":
|
case "repoColor":
|
||||||
st.KindRepo = v.fg(lipgloss.NewStyle())
|
st.KindRepo = v.fg(lipgloss.NewStyle())
|
||||||
|
case "springColor":
|
||||||
|
st.Spring = v.fg(lipgloss.NewStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st, fg, nil
|
return st, fg, nil
|
||||||
|
|||||||
Reference in new issue
Block a user