feat: Docker Compose files in repos
compose.yaml, docker-compose.yml and their variants tracked by git are
listed under Repos, their services' environment variables as secrets,
named service.VAR.
- environment as a map or a list, and <<: *anchors shared between
services; the secret patterns pick the keys, the rest hidden, with
compose.secretPatterns for compose files' own.
- ${VAR}, $VAR, ${VAR:?…} and variables without a value are marked as
references; docker-compose.example.yml and the like are hidden.
- Jasypt values decrypt with the service's JASYPT_ENCRYPTOR_PASSWORD.
- A blue Docker icon, themeable with composeColor.
This commit is contained in:
20 files changed
+579
-204
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, `.env` and Spring Boot config files in your git repos.
|
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot and Docker Compose 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, .env and Spring files](docs/repos.md): which repos and files, decrypting
|
- [SOPS, .env, Spring and compose 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
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
|||||||
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
|
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
|
||||||
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
|
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)")
|
||||||
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
|
showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
|
||||||
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring config keys that don't look secret, and files and repos with only those (json: \"hidden\")")
|
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring and compose keys that don't look secret, example files, and files and repos with only those (json: \"hidden\")")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ Flags:
|
|||||||
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,
|
||||||
SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring},
|
SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring, "compose": pc.Repos.Compose},
|
||||||
})
|
})
|
||||||
// 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
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ theme:
|
|||||||
databricksColor: ["#ff757f"]
|
databricksColor: ["#ff757f"]
|
||||||
repoColor: ["#c099ff"]
|
repoColor: ["#c099ff"]
|
||||||
springColor: ["#c3e88d"] # Spring Boot config files
|
springColor: ["#c3e88d"] # Spring Boot config files
|
||||||
|
composeColor: ["#82aaff"] # Docker Compose 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
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
|
|||||||
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
|
||||||
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
|
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
|
||||||
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
|
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
|
||||||
| `H` | show / hide what's hidden: [Spring keys](repos.md#spring-boot-config) that don't look secret, and files and repos with only those; shown dimmed |
|
| `H` | show / hide what's hidden: [Spring and compose keys](repos.md#spring-boot-config) that don't look secret, example files, and files and repos with only those; shown dimmed |
|
||||||
| `r` | refresh the focused pane |
|
| `r` | refresh the focused pane |
|
||||||
| `q`, `ctrl+c` | quit |
|
| `q`, `ctrl+c` | quit |
|
||||||
|
|
||||||
@@ -54,7 +54,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, `.env` or Spring file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
| `e` | open the selected SOPS, `.env`, Spring or compose 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) |
|
||||||
|
|||||||
+31
-9
@@ -1,9 +1,9 @@
|
|||||||
# SOPS, .env and Spring files in git repos
|
# SOPS, .env, Spring and compose 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), plain env files and [Spring Boot config](#spring-boot-config)
|
`.env`, INI), plain env files, [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
|
and [Docker Compose files](#docker-compose) 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
|
||||||
@@ -47,7 +47,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 and Spring files too.
|
`include`/`exclude` apply to env, Spring and compose files too.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
providers:
|
providers:
|
||||||
@@ -100,10 +100,14 @@ providers:
|
|||||||
spring:
|
spring:
|
||||||
secretPatterns: # Spring's: only what differs
|
secretPatterns: # Spring's: only what differs
|
||||||
keys: ["(?i)(password|secret)$", "bearer$"]
|
keys: ["(?i)(password|secret)$", "bearer$"]
|
||||||
|
compose:
|
||||||
|
secretPatterns: # and compose's
|
||||||
|
values: []
|
||||||
```
|
```
|
||||||
|
|
||||||
A repo's `.lazysecrets.yaml` can set them too, the same way
|
A repo's `.lazysecrets.yaml` can set them too, the same way
|
||||||
(`secretPatterns:`, `spring: secretPatterns:`), for everyone. Each list
|
(`secretPatterns:`, `spring: secretPatterns:`, `compose:
|
||||||
|
secretPatterns:`), for everyone. Each list
|
||||||
comes from the first that sets it: the repo's for the type, the repo's,
|
comes from the first that sets it: the repo's for the type, the repo's,
|
||||||
the config's for the type, the config's, the defaults.
|
the config's for the type, the config's, the defaults.
|
||||||
|
|
||||||
@@ -113,6 +117,22 @@ document's keys get its profile, `spring.datasource.password (prod)`.
|
|||||||
Values Maven fills in at build time (`@project.version@`) are read as
|
Values Maven fills in at build time (`@project.version@`) are read as
|
||||||
they are.
|
they are.
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
`compose.yaml`, `docker-compose.yml` and their variants
|
||||||
|
(`docker-compose.test.yml`), tracked by git; `docker-compose.example.yml`
|
||||||
|
and the like are hidden. Their secrets are the services' `environment`
|
||||||
|
variables, as a map (`DB_PASSWORD: x`) or a list (`- DB_PASSWORD=x`),
|
||||||
|
named `service.VAR` (`api.DB_PASSWORD`); a `<<: *anchor` shared between
|
||||||
|
services is read too. The same [secret patterns](#spring-boot-config)
|
||||||
|
pick them, the rest hidden, with `compose: secretPatterns:` for compose
|
||||||
|
files' own.
|
||||||
|
|
||||||
|
`${DB_PASSWORD}`, `$DB_PASSWORD` and `${DB_PASSWORD:?…}`, and a variable
|
||||||
|
without a value (`DB_PASSWORD:`, `- DB_PASSWORD`), taken from where
|
||||||
|
compose runs, are marked as references; `${DB_PASSWORD:-x}` has its
|
||||||
|
default in the file.
|
||||||
|
|
||||||
## Decrypting
|
## Decrypting
|
||||||
|
|
||||||
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
|
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
|
||||||
@@ -131,15 +151,17 @@ repo's history, or easily could be.
|
|||||||
|
|
||||||
### Jasypt
|
### Jasypt
|
||||||
|
|
||||||
A value like `ENC(...)` in a plain `.env` or Spring file is
|
A value like `ENC(...)` in a plain `.env`, Spring or compose file is
|
||||||
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
|
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
|
||||||
with jasypt-spring-boot's default algorithm (3.0+:
|
with jasypt-spring-boot's default algorithm (3.0+:
|
||||||
`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring:
|
`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring:
|
||||||
|
|
||||||
1. `$JASYPT_ENCRYPTOR_PASSWORD`,
|
1. `$JASYPT_ENCRYPTOR_PASSWORD`,
|
||||||
2. the file's own: `jasypt.encryptor.password` in a Spring file,
|
2. the file's own: `jasypt.encryptor.password` in a Spring file, the
|
||||||
`JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` (or
|
service's `JASYPT_ENCRYPTOR_PASSWORD` in a compose file,
|
||||||
`${VAR:default}`) in it is read from the environment.
|
`JASYPT_ENCRYPTOR_PASSWORD` in a `.env`; a `${VAR}` in it (with a
|
||||||
|
default: `${VAR:default}` in Spring, `${VAR:-default}` elsewhere) is
|
||||||
|
read from the environment.
|
||||||
|
|
||||||
Environment variables are lazysecrets' own: a link opened from the browser
|
Environment variables are lazysecrets' own: a link opened from the browser
|
||||||
starts it without your shell's. A `.env` whose values are all Jasypt's
|
starts it without your shell's. A `.env` whose values are all Jasypt's
|
||||||
|
|||||||
@@ -155,9 +155,10 @@ type Repos struct {
|
|||||||
Include []string `yaml:"include"`
|
Include []string `yaml:"include"`
|
||||||
Exclude []string `yaml:"exclude"`
|
Exclude []string `yaml:"exclude"`
|
||||||
// SecretPatterns say which keys of config files are secrets; Spring
|
// SecretPatterns say which keys of config files are secrets; Spring
|
||||||
// overrides them for Spring Boot's, list by list.
|
// and Compose override them for their files, list by list.
|
||||||
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
|
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
|
||||||
Spring repo.FileType `yaml:"spring"`
|
Spring repo.FileType `yaml:"spring"`
|
||||||
|
Compose repo.FileType `yaml:"compose"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Azure struct {
|
type Azure struct {
|
||||||
@@ -298,7 +299,8 @@ func (c Config) validate(path string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"),
|
if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"),
|
||||||
c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns")); err != nil {
|
c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns"),
|
||||||
|
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns")); err != nil {
|
||||||
return fmt.Errorf("%s: %w", path, err)
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
for i, v := range c.Providers.Azure.Vaults {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Docker Compose files: compose.yaml, docker-compose.yml and their
|
||||||
|
// variants (docker-compose.test.yml). Their keys are the services'
|
||||||
|
// environment variables, named service.VAR.
|
||||||
|
|
||||||
|
var composeType = configType{
|
||||||
|
name: "compose", is: IsCompose, format: func(string) string { return "yaml" }, parse: parseCompose,
|
||||||
|
jasyptSetting: func(name string) string {
|
||||||
|
service, _, _ := strings.Cut(name, ".")
|
||||||
|
return service + "." + jasyptEnv
|
||||||
|
},
|
||||||
|
placeholder: composePlaceholder,
|
||||||
|
}
|
||||||
|
|
||||||
|
var composeName = regexp.MustCompile(`^(docker-)?compose([.-][^/]+)?\.ya?ml$`)
|
||||||
|
|
||||||
|
// IsCompose reports whether f is named like a Docker Compose file.
|
||||||
|
func IsCompose(f string) bool {
|
||||||
|
return composeName.MatchString(strings.ToLower(path.Base(f)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// composePlaceholderRe is compose's ${VAR}, ${VAR:-default}, ${VAR-default},
|
||||||
|
// ${VAR:?error}, ${VAR?error} or $VAR.
|
||||||
|
var composePlaceholderRe = regexp.MustCompile(`^\$(?:\{(\w+)(?:(:?-)([^}]*)|:?\?[^}]*)?\}|(\w+))$`)
|
||||||
|
|
||||||
|
func composePlaceholder(v string) (name, def string, hasDef, ok bool) {
|
||||||
|
m := composePlaceholderRe.FindStringSubmatch(v)
|
||||||
|
if m == nil {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
return m[1] + m[4], m[3], m[2] != "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCompose(_ string, b []byte) ([]configKey, error) {
|
||||||
|
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 []configKey
|
||||||
|
for _, service := range pairs(field(doc.Content[0], "services")) {
|
||||||
|
for _, k := range environment(field(service.value, "environment")) {
|
||||||
|
k.name = service.key.Value + "." + k.key
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// environment reads a service's environment, a map (KEY: value) or a list
|
||||||
|
// (- KEY=value). A variable without a value (KEY:, - KEY) is taken from
|
||||||
|
// where compose runs: a reference.
|
||||||
|
func environment(n *yaml.Node) []configKey {
|
||||||
|
var out []configKey
|
||||||
|
switch n = deref(n); {
|
||||||
|
case n == nil:
|
||||||
|
case n.Kind == yaml.MappingNode:
|
||||||
|
for _, p := range pairs(n) {
|
||||||
|
v := deref(p.value)
|
||||||
|
ref := v.Tag == "!!null" || reference(composePlaceholder, v.Value)
|
||||||
|
out = append(out, configKey{key: p.key.Value, value: v.Value, ref: ref, line: p.key.Line})
|
||||||
|
}
|
||||||
|
case n.Kind == yaml.SequenceNode:
|
||||||
|
for _, item := range n.Content {
|
||||||
|
item = deref(item)
|
||||||
|
k, v, ok := strings.Cut(item.Value, "=")
|
||||||
|
out = append(out, configKey{key: k, value: v, ref: !ok || reference(composePlaceholder, v), line: item.Line})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type pair struct{ key, value *yaml.Node }
|
||||||
|
|
||||||
|
// pairs are a mapping's entries, with those merged in by << (anchors
|
||||||
|
// shared between services) first and its own over them.
|
||||||
|
func pairs(n *yaml.Node) []pair {
|
||||||
|
n = deref(n)
|
||||||
|
if n == nil || n.Kind != yaml.MappingNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var merged, own []pair
|
||||||
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||||
|
k, v := n.Content[i], n.Content[i+1]
|
||||||
|
if k.Value != "<<" {
|
||||||
|
own = append(own, pair{k, v})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
from := []*yaml.Node{v}
|
||||||
|
if v.Kind == yaml.SequenceNode {
|
||||||
|
from = v.Content
|
||||||
|
}
|
||||||
|
for _, m := range from {
|
||||||
|
merged = append(merged, pairs(m)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []pair
|
||||||
|
for _, p := range append(merged, own...) {
|
||||||
|
if i := indexOf(out, p.key.Value); i >= 0 {
|
||||||
|
out[i] = p
|
||||||
|
} else {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(ps []pair, key string) int {
|
||||||
|
for i, p := range ps {
|
||||||
|
if p.key.Value == key {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func field(n *yaml.Node, key string) *yaml.Node {
|
||||||
|
ps := pairs(n)
|
||||||
|
if i := indexOf(ps, key); i >= 0 {
|
||||||
|
return ps[i].value
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(n *yaml.Node) *yaml.Node {
|
||||||
|
for n != nil && n.Kind == yaml.AliasNode {
|
||||||
|
n = n.Alias
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsCompose(t *testing.T) {
|
||||||
|
for f, want := range map[string]bool{
|
||||||
|
"docker-compose.yml": true,
|
||||||
|
"deploy/compose.yaml": true,
|
||||||
|
"docker-compose.test.yml": true,
|
||||||
|
"docker-compose-dev.yaml": true,
|
||||||
|
"docker-compose.example.yml": true, // listed hidden
|
||||||
|
"applications.yaml": false,
|
||||||
|
"composer.json": false,
|
||||||
|
"compose.yml.bak": false,
|
||||||
|
} {
|
||||||
|
if IsCompose(f) != want {
|
||||||
|
t.Errorf("%s: %t", f, !want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposePlaceholder(t *testing.T) {
|
||||||
|
for v, want := range map[string]bool{
|
||||||
|
"${DB_PASSWORD}": true, "$DB_PASSWORD": true, "${DB_PASSWORD:?set it}": true, "${DB_PASSWORD?}": true,
|
||||||
|
"${DB_PASSWORD:-hunter2}": false, "${DB_PASSWORD-hunter2}": false, // the default is in the file
|
||||||
|
"hunter2": false, "pre-${DB_PASSWORD}": false, "$": false,
|
||||||
|
} {
|
||||||
|
if got := reference(composePlaceholder, v); got != want {
|
||||||
|
t.Errorf("%s: reference %t", v, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name, def, hasDef, _ := composePlaceholder("${PW:-x}"); name != "PW" || def != "x" || !hasDef {
|
||||||
|
t.Errorf("default: %s %s %t", name, def, hasDef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCompose(t *testing.T) {
|
||||||
|
keys, err := parseCompose("", []byte(`x-db: &db
|
||||||
|
DB_USER: app
|
||||||
|
DB_PASSWORD: shared
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: api
|
||||||
|
environment:
|
||||||
|
<<: *db
|
||||||
|
DB_PASSWORD: own
|
||||||
|
API_KEY: ${API_KEY}
|
||||||
|
FROM_HOST:
|
||||||
|
worker:
|
||||||
|
environment:
|
||||||
|
- QUEUE_PASSWORD=hunter2
|
||||||
|
- TOKEN
|
||||||
|
- EMPTY=
|
||||||
|
web:
|
||||||
|
image: web
|
||||||
|
`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, k := range keys {
|
||||||
|
ref := ""
|
||||||
|
if k.ref {
|
||||||
|
ref = " (ref)"
|
||||||
|
}
|
||||||
|
got[k.name] = k.value + ref
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"api.DB_USER": "app", "api.DB_PASSWORD": "own", "api.API_KEY": "${API_KEY} (ref)", "api.FROM_HOST": " (ref)",
|
||||||
|
"worker.QUEUE_PASSWORD": "hunter2", "worker.TOKEN": " (ref)", "worker.EMPTY": "",
|
||||||
|
}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("got %v\nwant %v", got, want)
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.name == "worker.QUEUE_PASSWORD" && (k.line != 14 || k.key != "QUEUE_PASSWORD") {
|
||||||
|
t.Errorf("%s: line %d, key %s", k.name, k.line, k.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComposeFiles(t *testing.T) {
|
||||||
|
root := gitRepo(t, map[string]string{
|
||||||
|
"docker-compose.yml": `services:
|
||||||
|
api:
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
JASYPT_ENCRYPTOR_PASSWORD: test-password
|
||||||
|
API_TOKEN: ` + jasyptHunter2 + `
|
||||||
|
`,
|
||||||
|
"docker-compose.example.yml": "services:\n api:\n environment:\n DB_PASSWORD: changeme\n",
|
||||||
|
})
|
||||||
|
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||||
|
ctx := context.Background()
|
||||||
|
p := New(Settings{Dir: root})
|
||||||
|
srcs, _ := p.Sources(ctx)
|
||||||
|
cs, err := p.Containers(ctx, srcs[0])
|
||||||
|
if err != nil || len(cs) != 2 {
|
||||||
|
t.Fatalf("files: %+v %v", cs, err)
|
||||||
|
}
|
||||||
|
for _, c := range cs {
|
||||||
|
if c.Detail != "compose · yaml" || c.Hidden != (c.Name == "docker-compose.example.yml") {
|
||||||
|
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose := cs[1] // after docker-compose.example.yml
|
||||||
|
|
||||||
|
keys, _ := p.Objects(ctx, compose, provider.TypeSecret)
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, k := range shown(keys) {
|
||||||
|
got[k.Name] = k.Detail
|
||||||
|
}
|
||||||
|
if want := map[string]string{"api.DB_PASSWORD": "reference", "api.JASYPT_ENCRYPTOR_PASSWORD": "", "api.API_TOKEN": "jasypt"}; !maps.Equal(got, want) {
|
||||||
|
t.Errorf("keys %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
// The service's JASYPT_ENCRYPTOR_PASSWORD decrypts its values.
|
||||||
|
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "docker-compose.yml"}, Name: "api.API_TOKEN"}
|
||||||
|
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "hunter2" {
|
||||||
|
t.Errorf("jasypt: %q %v", v.Reveal(), err)
|
||||||
|
}
|
||||||
|
if f, _ := p.File(compose, &link); f.Line != 7 {
|
||||||
|
t.Errorf("line %d", f.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compose.secretPatterns over the shared ones.
|
||||||
|
os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte("secretPatterns:\n keys: [\"^nothing$\"]\ncompose:\n secretPatterns:\n keys: [\"^PORT$\"]\n values: []\n"), 0o644)
|
||||||
|
keys, _ = p.Objects(ctx, compose, provider.TypeSecret)
|
||||||
|
if s := shown(keys); len(s) != 1 || s[0].Name != "api.PORT" {
|
||||||
|
t.Errorf("compose's patterns: %v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config files (Spring's, compose's) are read directly, like env files,
|
||||||
|
// but most of their keys aren't secret: those the secret patterns
|
||||||
|
// (patterns.go) don't match are hidden, and a file with none of them is.
|
||||||
|
|
||||||
|
// configType is a type of config file: what it's called (in settings, as
|
||||||
|
// spring:, and on the file's row) and how its keys are read.
|
||||||
|
type configType struct {
|
||||||
|
name string
|
||||||
|
is func(file string) bool
|
||||||
|
format func(file string) string // yaml, properties
|
||||||
|
parse func(file string, b []byte) ([]configKey, error)
|
||||||
|
// jasyptSetting is the key that holds the Jasypt password for the
|
||||||
|
// key named name.
|
||||||
|
jasyptSetting func(name string) string
|
||||||
|
// placeholder reads a value that only refers to another,
|
||||||
|
// ${DB_PASSWORD}: what it names, and its default if it has one.
|
||||||
|
placeholder func(v string) (name, def string, hasDef, ok bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var configTypes = []configType{springType, composeType}
|
||||||
|
|
||||||
|
func isConfig(f string) bool {
|
||||||
|
_, ok := configTypeOf(f)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// configTypeOf is the type of config file f is, if it's one.
|
||||||
|
func configTypeOf(f string) (configType, bool) {
|
||||||
|
for _, t := range configTypes {
|
||||||
|
if t.is(f) {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configType{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detail is the file's row: "spring · yaml".
|
||||||
|
func (t configType) detail(file string) string { return t.name + " · " + t.format(file) }
|
||||||
|
|
||||||
|
// configKey is a key of a config file, with its value.
|
||||||
|
type configKey struct {
|
||||||
|
key string // as the app sees it: spring.datasource.password, DB_PASSWORD
|
||||||
|
name string // one key in the file: spring.datasource.password (prod), api.DB_PASSWORD
|
||||||
|
value string
|
||||||
|
ref bool // the value only refers to the secret, set elsewhere: ${DB_PASSWORD}
|
||||||
|
line int // 1-based
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k configKey) jasypt() bool { return jasypt.Is(k.value) }
|
||||||
|
|
||||||
|
// plain reports whether the file holds the value itself, readable.
|
||||||
|
func (k configKey) plain() bool { return k.value != "" && !k.ref && !k.jasypt() }
|
||||||
|
|
||||||
|
// reference reports whether v is only a placeholder without a default:
|
||||||
|
// one with a default has the default in the file.
|
||||||
|
func reference(placeholder func(string) (string, string, bool, bool), v string) bool {
|
||||||
|
_, _, hasDef, ok := placeholder(v)
|
||||||
|
return ok && !hasDef
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t configType) read(file string) ([]configKey, error) {
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t.parse(file, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t configType) keyNamed(file, name string) (configKey, error) {
|
||||||
|
keys, err := t.read(file)
|
||||||
|
if err != nil {
|
||||||
|
return configKey{}, err
|
||||||
|
}
|
||||||
|
for _, k := range keys {
|
||||||
|
if k.name == name {
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t configType) value(file, name string) (provider.SecretValue, error) {
|
||||||
|
k, err := t.keyNamed(file, name)
|
||||||
|
if err != nil {
|
||||||
|
return provider.SecretValue{}, err
|
||||||
|
}
|
||||||
|
return provider.NewSecretValue([]byte(k.value)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configHidden reports whether a config file has no secret keys. One that
|
||||||
|
// can't be read isn't hidden: its error is worth seeing.
|
||||||
|
func (p *Provider) configHidden(t configType, root, file string) bool {
|
||||||
|
keys, err := t.read(file)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
filter, err := p.filterFor(root, t.name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !slices.ContainsFunc(keys, func(k configKey) bool { return filter.secret(k.key, k.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// configObjects lists the file's keys, those that don't look like secrets
|
||||||
|
// hidden.
|
||||||
|
func (p *Provider) configObjects(t configType, c provider.Container, b []byte) ([]provider.Object, error) {
|
||||||
|
keys, err := t.parse(c.ID, b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||||
|
}
|
||||||
|
filter, err := p.filterFor(c.Source.ID, t.name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
format := provider.Attr{Label: "format", Value: t.name + " " + t.format(c.ID)}
|
||||||
|
var out []provider.Object
|
||||||
|
for _, k := range keys {
|
||||||
|
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
|
||||||
|
switch {
|
||||||
|
case k.ref:
|
||||||
|
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
|
||||||
|
case k.jasypt():
|
||||||
|
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
|
||||||
|
}
|
||||||
|
out = append(out, provider.Object{
|
||||||
|
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
|
||||||
|
Hidden: !filter.secret(k.key, k.value), Attrs: []provider.Attr{format, state},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configDetails says how many of the file's keys aren't listed, and
|
||||||
|
// whether any listed one is in plain text.
|
||||||
|
func (p *Provider) configDetails(t configType, c provider.Container) (attrs []provider.Attr, plain bool) {
|
||||||
|
keys, err := t.read(c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
|
||||||
|
}
|
||||||
|
filter, err := p.filterFor(c.Source.ID, t.name)
|
||||||
|
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.key, k.value) {
|
||||||
|
secrets++
|
||||||
|
plain = plain || k.plain()
|
||||||
|
}
|
||||||
|
if k.jasypt() {
|
||||||
|
jasypt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []provider.Attr{
|
||||||
|
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
|
||||||
|
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
|
||||||
|
}, plain
|
||||||
|
}
|
||||||
@@ -25,10 +25,10 @@ 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)
|
||||||
// A plain file's values are at risk in git, unless they're all Jasypt;
|
// A plain file's values are at risk in git, unless they're all Jasypt;
|
||||||
// a Spring file's only when one of its secrets is in plain text.
|
// a config file's only when one of its secrets is in plain text.
|
||||||
plain := !isSops
|
plain := !isSops
|
||||||
if !isSops && IsSpring(c.ID) {
|
if t, ok := configTypeOf(c.ID); ok && !isSops {
|
||||||
out, plain = p.springDetails(c)
|
out, plain = p.configDetails(t, c)
|
||||||
} else if isSops {
|
} 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 {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
@@ -17,12 +16,12 @@ const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD"
|
|||||||
|
|
||||||
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
|
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
|
||||||
// file; other values are as they are.
|
// file; other values are as they are.
|
||||||
func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, error) {
|
func jasyptValue(file, name string, v provider.SecretValue) (provider.SecretValue, error) {
|
||||||
s := v.Reveal()
|
s := v.Reveal()
|
||||||
if !jasypt.Is(s) {
|
if !jasypt.Is(s) {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
password, from, err := jasyptPassword(file)
|
password, from, err := jasyptPassword(file, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return provider.SecretValue{}, err
|
return provider.SecretValue{}, err
|
||||||
}
|
}
|
||||||
@@ -36,39 +35,32 @@ func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, err
|
|||||||
return provider.NewSecretValue([]byte(plain)), nil
|
return provider.NewSecretValue([]byte(plain)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// jasyptPassword is the password for a file's Jasypt values, and where it's
|
// jasyptPassword is the password for the Jasypt value of the key named
|
||||||
// from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's own setting
|
// name, and where it's from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's
|
||||||
// (jasypt.encryptor.password in a Spring file, JASYPT_ENCRYPTOR_PASSWORD
|
// own setting (jasypt.encryptor.password in a Spring file; the service's
|
||||||
// in an env file), with a ${VAR} in it read from the environment.
|
// JASYPT_ENCRYPTOR_PASSWORD in a compose file, the file's in an env file),
|
||||||
func jasyptPassword(file string) (password, from string, err error) {
|
// with a ${VAR} in it read from the environment.
|
||||||
|
func jasyptPassword(file, name string) (password, from string, err error) {
|
||||||
if v := os.Getenv(jasyptEnv); v != "" {
|
if v := os.Getenv(jasyptEnv); v != "" {
|
||||||
return v, "$" + jasyptEnv, nil
|
return v, "$" + jasyptEnv, nil
|
||||||
}
|
}
|
||||||
setting, read := jasyptEnv, plainValue
|
setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose
|
||||||
if IsSpring(file) {
|
if t, ok := configTypeOf(file); ok {
|
||||||
setting, read = "jasypt.encryptor.password", springValue
|
setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder
|
||||||
}
|
}
|
||||||
v, err := read(file, setting)
|
v, err := read(file, setting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
|
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
|
||||||
}
|
}
|
||||||
return resolvePlaceholder(v.Reveal(), setting+" in "+filepath.Base(file))
|
from = setting + " in " + filepath.Base(file)
|
||||||
}
|
env, def, hasDef, ok := placeholder(v.Reveal())
|
||||||
|
|
||||||
// placeholder is Spring's ${VAR} or ${VAR:default}.
|
|
||||||
var placeholder = regexp.MustCompile(`^\$\{([^:}]+)(:[^}]*)?\}$`)
|
|
||||||
|
|
||||||
// resolvePlaceholder reads a ${VAR} value from the environment, falling
|
|
||||||
// back to its default; other values are as they are.
|
|
||||||
func resolvePlaceholder(v, from string) (string, string, error) {
|
|
||||||
m := placeholder.FindStringSubmatch(v)
|
|
||||||
switch {
|
switch {
|
||||||
case m == nil:
|
case !ok:
|
||||||
return v, from, nil
|
return v.Reveal(), from, nil
|
||||||
case os.Getenv(m[1]) != "":
|
case os.Getenv(env) != "":
|
||||||
return os.Getenv(m[1]), "$" + m[1] + " (" + from + ")", nil
|
return os.Getenv(env), "$" + env + " (" + from + ")", nil
|
||||||
case m[2] != "":
|
case hasDef:
|
||||||
return m[2][1:], "the default in " + from, nil
|
return def, "the default in " + from, nil
|
||||||
}
|
}
|
||||||
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v, m[1])
|
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v.Reveal(), env)
|
||||||
}
|
}
|
||||||
@@ -1,7 +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, plain env files and
|
// their keys the secrets. Secret files are SOPS files, plain env files and
|
||||||
// Spring Boot config (spring.go).
|
// config files: Spring Boot's and Docker Compose's (configfile.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
|
||||||
@@ -336,9 +336,10 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
|||||||
case isEnvNamed(f):
|
case isEnvNamed(f):
|
||||||
c.Detail = provider.DetailPlainEnv
|
c.Detail = provider.DetailPlainEnv
|
||||||
c.Hidden = isExample(f)
|
c.Hidden = isExample(f)
|
||||||
case IsSpring(f):
|
case isConfig(f):
|
||||||
c.Detail = "spring · " + springFormat(f)
|
t, _ := configTypeOf(f)
|
||||||
c.Hidden = p.springHidden(r.root, path)
|
c.Detail = t.detail(f)
|
||||||
|
c.Hidden = isExample(f) || p.configHidden(t, r.root, path)
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -376,8 +377,8 @@ 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) {
|
if t, ok := configTypeOf(c.ID); ok && !isSops {
|
||||||
return p.springObjects(c, b)
|
return p.configObjects(t, c, b)
|
||||||
}
|
}
|
||||||
keys, err := parseKeys(c.ID, b)
|
keys, err := parseKeys(c.ID, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -421,14 +422,14 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
}
|
}
|
||||||
if _, isSops := readMeta(file); !isSops {
|
if _, isSops := readMeta(file); !isSops {
|
||||||
read := plainValue
|
read := plainValue
|
||||||
if IsSpring(file) {
|
if t, ok := configTypeOf(file); ok {
|
||||||
read = springValue
|
read = t.value
|
||||||
}
|
}
|
||||||
v, err := read(file, o.Name)
|
v, err := read(file, o.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v, err
|
return v, err
|
||||||
}
|
}
|
||||||
return jasyptValue(file, v)
|
return jasyptValue(file, o.Name, v)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
var err error
|
var err error
|
||||||
@@ -477,8 +478,8 @@ 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 t, ok := configTypeOf(path); ok && f.Sops == "" {
|
||||||
if k, err := springKeyNamed(path, o.Name); err == nil {
|
if k, err := t.keyNamed(path, o.Name); err == nil {
|
||||||
f.Line = k.line
|
f.Line = k.line
|
||||||
}
|
}
|
||||||
return f, nil
|
return f, nil
|
||||||
@@ -638,6 +639,7 @@ type lazysecretsFile struct {
|
|||||||
Exclude []string `yaml:"exclude"`
|
Exclude []string `yaml:"exclude"`
|
||||||
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
|
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
|
||||||
Spring FileType `yaml:"spring"`
|
Spring FileType `yaml:"spring"`
|
||||||
|
Compose FileType `yaml:"compose"`
|
||||||
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).
|
||||||
@@ -646,7 +648,7 @@ type lazysecretsFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f lazysecretsFile) types() map[string]FileType {
|
func (f lazysecretsFile) types() map[string]FileType {
|
||||||
return map[string]FileType{"spring": f.Spring}
|
return map[string]FileType{"spring": f.Spring, "compose": f.Compose}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
||||||
|
|||||||
@@ -5,19 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"go.yaml.in/yaml/v3"
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Spring Boot config files: application*.properties/.yml and bootstrap*,
|
// Spring Boot config files: application*.properties/.yml and bootstrap*,
|
||||||
@@ -40,51 +35,42 @@ func springFormat(f string) string {
|
|||||||
return "yaml"
|
return "yaml"
|
||||||
}
|
}
|
||||||
|
|
||||||
type springKey struct {
|
var springType = configType{
|
||||||
key string // spring.datasource.password
|
name: "spring", is: IsSpring, format: springFormat, parse: parseSpring,
|
||||||
name string // key, with (profile) after it in a later document
|
jasyptSetting: func(string) string { return "jasypt.encryptor.password" },
|
||||||
value string
|
placeholder: springPlaceholder,
|
||||||
line int // 1-based
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reference reports whether the value is only a placeholder, ${DB_PASSWORD}:
|
// springPlaceholderRe is Spring's ${name} or ${name:default}.
|
||||||
// the secret is elsewhere. One with a default, ${DB_PASSWORD:hunter2}, has
|
var springPlaceholderRe = regexp.MustCompile(`^\$\{([^:}]+)(:([^}]*))?\}$`)
|
||||||
// the default in the file.
|
|
||||||
func (k springKey) reference() bool {
|
func springPlaceholder(v string) (name, def string, hasDef, ok bool) {
|
||||||
return strings.HasPrefix(k.value, "${") && strings.HasSuffix(k.value, "}") &&
|
m := springPlaceholderRe.FindStringSubmatch(v)
|
||||||
!strings.ContainsAny(k.value[2:len(k.value)-1], ":${}")
|
if m == nil {
|
||||||
|
return "", "", false, false
|
||||||
|
}
|
||||||
|
return m[1], m[3], m[2] != "", true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k springKey) jasypt() bool {
|
func parseSpring(file string, b []byte) ([]configKey, error) {
|
||||||
return jasypt.Is(k.value)
|
var keys []configKey
|
||||||
}
|
var err error
|
||||||
|
|
||||||
// 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" {
|
if springFormat(file) == "properties" {
|
||||||
return parseProperties(b), nil
|
keys = parseProperties(b)
|
||||||
|
} else {
|
||||||
|
keys, err = parseSpringYAML(b)
|
||||||
}
|
}
|
||||||
return parseSpringYAML(b)
|
for i := range keys {
|
||||||
|
keys[i].ref = reference(springPlaceholder, keys[i].value)
|
||||||
|
}
|
||||||
|
return keys, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// springDocs names the keys of a multi-document file's later documents
|
// springDocs names the keys of a multi-document file's later documents
|
||||||
// after the profile they're for, so each name is one key:
|
// after the profile they're for, so each name is one key:
|
||||||
// spring.datasource.password (prod).
|
// spring.datasource.password (prod).
|
||||||
func springDocs(docs [][]springKey) []springKey {
|
func springDocs(docs [][]configKey) []configKey {
|
||||||
var out []springKey
|
var out []configKey
|
||||||
for i, doc := range docs {
|
for i, doc := range docs {
|
||||||
suffix := ""
|
suffix := ""
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
@@ -107,13 +93,13 @@ func springDocs(docs [][]springKey) []springKey {
|
|||||||
// not YAML until it's quoted.
|
// not YAML until it's quoted.
|
||||||
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
|
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
|
||||||
|
|
||||||
func parseSpringYAML(b []byte) ([]springKey, error) {
|
func parseSpringYAML(b []byte) ([]configKey, error) {
|
||||||
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
|
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
|
||||||
sub := mavenFiltered.FindSubmatch(m)
|
sub := mavenFiltered.FindSubmatch(m)
|
||||||
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
|
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
|
||||||
})
|
})
|
||||||
dec := yaml.NewDecoder(bytes.NewReader(b))
|
dec := yaml.NewDecoder(bytes.NewReader(b))
|
||||||
var docs [][]springKey
|
var docs [][]configKey
|
||||||
for {
|
for {
|
||||||
var doc yaml.Node
|
var doc yaml.Node
|
||||||
err := dec.Decode(&doc)
|
err := dec.Decode(&doc)
|
||||||
@@ -123,7 +109,7 @@ func parseSpringYAML(b []byte) ([]springKey, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var keys []springKey
|
var keys []configKey
|
||||||
if len(doc.Content) > 0 {
|
if len(doc.Content) > 0 {
|
||||||
springWalk(doc.Content[0], "", &keys)
|
springWalk(doc.Content[0], "", &keys)
|
||||||
}
|
}
|
||||||
@@ -134,7 +120,7 @@ func parseSpringYAML(b []byte) ([]springKey, error) {
|
|||||||
|
|
||||||
// springWalk names keys the way Spring does: nested keys joined by dots,
|
// springWalk names keys the way Spring does: nested keys joined by dots,
|
||||||
// list items by index (servers[0]).
|
// list items by index (servers[0]).
|
||||||
func springWalk(n *yaml.Node, name string, out *[]springKey) {
|
func springWalk(n *yaml.Node, name string, out *[]configKey) {
|
||||||
switch n.Kind {
|
switch n.Kind {
|
||||||
case yaml.MappingNode:
|
case yaml.MappingNode:
|
||||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||||
@@ -154,7 +140,7 @@ func springWalk(n *yaml.Node, name string, out *[]springKey) {
|
|||||||
}
|
}
|
||||||
case yaml.ScalarNode:
|
case yaml.ScalarNode:
|
||||||
if name != "" {
|
if name != "" {
|
||||||
*out = append(*out, springKey{key: name, value: n.Value, line: n.Line})
|
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,10 +148,10 @@ func springWalk(n *yaml.Node, name string, out *[]springKey) {
|
|||||||
// parseProperties reads a Java .properties file: key=value, key: value or
|
// parseProperties reads a Java .properties file: key=value, key: value or
|
||||||
// key value, lines continued with a trailing backslash, # and ! comments,
|
// key value, lines continued with a trailing backslash, # and ! comments,
|
||||||
// and #--- between documents (Spring Boot 2.4+).
|
// and #--- between documents (Spring Boot 2.4+).
|
||||||
func parseProperties(b []byte) []springKey {
|
func parseProperties(b []byte) []configKey {
|
||||||
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
|
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
|
||||||
var docs [][]springKey
|
var docs [][]configKey
|
||||||
var doc []springKey
|
var doc []configKey
|
||||||
for i := 0; i < len(lines); i++ {
|
for i := 0; i < len(lines); i++ {
|
||||||
start := i + 1
|
start := i + 1
|
||||||
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
||||||
@@ -181,7 +167,7 @@ func parseProperties(b []byte) []springKey {
|
|||||||
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
|
||||||
}
|
}
|
||||||
k, v := splitProperty(line)
|
k, v := splitProperty(line)
|
||||||
doc = append(doc, springKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
|
doc = append(doc, configKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
|
||||||
}
|
}
|
||||||
return springDocs(append(docs, doc))
|
return springDocs(append(docs, doc))
|
||||||
}
|
}
|
||||||
@@ -248,94 +234,3 @@ func unescapeProperty(s string) string {
|
|||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// springHidden reports whether a Spring file has no secret keys. One that
|
|
||||||
// can't be read isn't hidden: its error is worth seeing.
|
|
||||||
func (p *Provider) springHidden(root, file string) bool {
|
|
||||||
keys, err := readSpring(file)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
filter, err := p.filterFor(root, "spring")
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !slices.ContainsFunc(keys, func(k springKey) bool { return filter.secret(k.key, k.value) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// springObjects lists the file's keys, those that don't look like secrets
|
|
||||||
// hidden.
|
|
||||||
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.filterFor(c.Source.ID, "spring")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)}
|
|
||||||
var out []provider.Object
|
|
||||||
for _, k := range keys {
|
|
||||||
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
|
|
||||||
switch {
|
|
||||||
case k.reference():
|
|
||||||
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
|
|
||||||
case k.jasypt():
|
|
||||||
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
|
|
||||||
}
|
|
||||||
out = append(out, provider.Object{
|
|
||||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
|
|
||||||
Hidden: !filter.secret(k.key, k.value), 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.filterFor(c.Source.ID, "spring")
|
|
||||||
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.key, k.value) {
|
|
||||||
secrets++
|
|
||||||
plain = plain || k.plain()
|
|
||||||
}
|
|
||||||
if k.jasypt() {
|
|
||||||
jasypt++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return []provider.Attr{
|
|
||||||
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
|
|
||||||
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
|
|
||||||
}, plain
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ func TestIsSpring(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func springNames(keys []springKey) map[string]string {
|
func springNames(keys []configKey) map[string]string {
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
out[k.name] = k.value
|
out[k.name] = k.value
|
||||||
|
|||||||
@@ -102,13 +102,16 @@ 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 leaf
|
// A secret file: a lock for an encrypted one, a leaf for
|
||||||
// for Spring config, a file in the warning colour for plain
|
// Spring config, a whale for compose, a file in the warning
|
||||||
// text, and its type after it ("sops · age", "env · plain text").
|
// colour for plain text, and its type after it ("sops ·
|
||||||
|
// age", "env · plain text").
|
||||||
icon, style := iconSet.Encrypted, sty.KindRepo
|
icon, style := iconSet.Encrypted, sty.KindRepo
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(c.Detail, "spring"):
|
case strings.HasPrefix(c.Detail, "spring"):
|
||||||
icon, style = iconSet.Spring, sty.Spring
|
icon, style = iconSet.Spring, sty.Spring
|
||||||
|
case strings.HasPrefix(c.Detail, "compose"):
|
||||||
|
icon, style = iconSet.Compose, sty.Compose
|
||||||
case !strings.HasPrefix(c.Detail, "sops"):
|
case !strings.HasPrefix(c.Detail, "sops"):
|
||||||
icon, style = iconSet.PlainFile, sty.Warning
|
icon, style = iconSet.PlainFile, sty.Warning
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Set struct {
|
|||||||
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
|
Spring string // a Spring Boot config file
|
||||||
|
Compose string // a Docker Compose file
|
||||||
Note string // has a note (lazysecrets list)
|
Note string // has a note (lazysecrets list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ var NerdFont = Set{
|
|||||||
Encrypted: "\U000F033E", // md-lock
|
Encrypted: "\U000F033E", // md-lock
|
||||||
PlainFile: "\U000F0214", // md-file
|
PlainFile: "\U000F0214", // md-file
|
||||||
Spring: "\U000F032A", // md-leaf
|
Spring: "\U000F032A", // md-leaf
|
||||||
|
Compose: "\U000F0868", // md-docker
|
||||||
Note: "\U000F09ED",
|
Note: "\U000F09ED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func (m *Map) Actions() []Action {
|
|||||||
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
|
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
|
||||||
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
||||||
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
||||||
a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring config keys that don't look like secrets, and files and repos with only those. Shown, they're dimmed; Go to finds them too."),
|
a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring and compose keys that don't look like secrets, example files (.env.example), and files and repos with only those. Shown, they're dimmed; Go to finds them too."),
|
||||||
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
||||||
a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."),
|
||||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Styles struct {
|
|||||||
KindDatabricks lipgloss.Style
|
KindDatabricks lipgloss.Style
|
||||||
KindRepo lipgloss.Style
|
KindRepo lipgloss.Style
|
||||||
Spring lipgloss.Style // Spring Boot config files
|
Spring lipgloss.Style // Spring Boot config files
|
||||||
|
Compose lipgloss.Style // Docker Compose 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.
|
||||||
@@ -72,6 +73,7 @@ func Default() Styles {
|
|||||||
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),
|
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||||
|
Compose: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ var Roles = map[string]string{
|
|||||||
"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",
|
"springColor": "Spring Boot config files",
|
||||||
|
"composeColor": "Docker Compose 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
|
||||||
@@ -102,6 +103,8 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
|||||||
st.KindRepo = v.fg(lipgloss.NewStyle())
|
st.KindRepo = v.fg(lipgloss.NewStyle())
|
||||||
case "springColor":
|
case "springColor":
|
||||||
st.Spring = v.fg(lipgloss.NewStyle())
|
st.Spring = v.fg(lipgloss.NewStyle())
|
||||||
|
case "composeColor":
|
||||||
|
st.Compose = v.fg(lipgloss.NewStyle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st, fg, nil
|
return st, fg, nil
|
||||||
|
|||||||
Reference in new issue
Block a user