Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7db795cbe | ||
|
|
59c1222f4d
|
||
|
|
c1dcda6c2b
|
||
|
|
27d6251579 | ||
|
|
b3a3c59159 | ||
|
|
e2f289da5c | ||
|
|
0f5248d8f6 | ||
|
|
580c7ef985 | ||
|
|
4bac06ae5b | ||
|
|
913b3db6a9 | ||
|
|
17da9eaa32 | ||
|
|
96395cb6a2 | ||
|
|
39cbac9aab |
No files matched your search
@@ -2,6 +2,22 @@
|
||||
|
||||
Every release of lazysecrets, newest first.
|
||||
|
||||
## v0.3.0 – 2026-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Spring Boot config files in repos ([#79](https://git.kebcloud.no/keb/lazysecrets/pulls/79)) ([96395cb](https://git.kebcloud.no/keb/lazysecrets/commit/96395cb6a27a94293e35401790296a894821d075))
|
||||
- Decrypt Jasypt values in .env and Spring files ([#80](https://git.kebcloud.no/keb/lazysecrets/pulls/80)) ([17da9ea](https://git.kebcloud.no/keb/lazysecrets/commit/17da9eaa3271dfa315a517ad0092ab5cda0b0597))
|
||||
- H shows what's hidden; Spring keys that don't look secret are hidden ([#81](https://git.kebcloud.no/keb/lazysecrets/pulls/81)) ([913b3db](https://git.kebcloud.no/keb/lazysecrets/commit/913b3db6a938d21c6d8c9285a3111418495c7345))
|
||||
- Secret patterns for all of a repo's config files, with per-type overrides ([#82](https://git.kebcloud.no/keb/lazysecrets/pulls/82)) ([4bac06a](https://git.kebcloud.no/keb/lazysecrets/commit/4bac06ae5ba6fcb8c96d46198f01235391e29f1a))
|
||||
- Example env files are listed hidden, not skipped ([#83](https://git.kebcloud.no/keb/lazysecrets/pulls/83)) ([580c7ef](https://git.kebcloud.no/keb/lazysecrets/commit/580c7ef985599b98277d80be38e39abe169b2dc4))
|
||||
- Docker Compose files in repos ([#84](https://git.kebcloud.no/keb/lazysecrets/pulls/84)) ([0f5248d](https://git.kebcloud.no/keb/lazysecrets/commit/0f5248d8f6d27e3f95f8b9cbc28310ee7b0581f7))
|
||||
- Go to shows a repo file's icon before its name ([#85](https://git.kebcloud.no/keb/lazysecrets/pulls/85)) ([e2f289d](https://git.kebcloud.no/keb/lazysecrets/commit/e2f289da5c54dab869e7ba10ca35bdc1ab74bf28))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Decrypt SOPS .env keys written with export or spaces ([#78](https://git.kebcloud.no/keb/lazysecrets/pulls/78)) ([39cbac9](https://git.kebcloud.no/keb/lazysecrets/commit/39cbac9aabcea11e9915363e4adae5873f9724de))
|
||||
|
||||
## v0.2.1 – 2026-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -23,7 +23,7 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
||||
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||
on a secret.
|
||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
|
||||
- **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot, Docker Compose and .NET appsettings files in your git repos.
|
||||
|
||||
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
|
||||
|
||||
@@ -87,7 +87,7 @@ pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
|
||||
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
|
||||
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
|
||||
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
|
||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
||||
- [SOPS, .env, Spring, compose and .NET files](docs/repos.md): which repos and files, decrypting
|
||||
- [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
|
||||
opening links, also from the browser and chat
|
||||
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
|
||||
|
||||
@@ -57,6 +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)")
|
||||
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\")")
|
||||
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: config file keys (Spring, compose, .NET) that don't look secret, example files, and files and repos with only those (json: \"hidden\")")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -129,7 +130,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
|
||||
f = cachefile.Read(cachePath)
|
||||
}
|
||||
f, loaded := listing.Run(context.Background(), listing.Options{
|
||||
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout,
|
||||
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Hidden: *hidden,
|
||||
Mark: func(link, account string) listing.Marks {
|
||||
n := st.Node(link)
|
||||
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
|
||||
|
||||
@@ -347,6 +347,7 @@ Flags:
|
||||
repos := repo.New(repo.Settings{
|
||||
Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
|
||||
Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
||||
SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring, "compose": pc.Repos.Compose, "dotnet": pc.Repos.Dotnet},
|
||||
})
|
||||
// The repo it started in (shown on top as Repo) and the
|
||||
// others, checked for secret files in the background like
|
||||
|
||||
@@ -74,6 +74,9 @@ theme:
|
||||
azureColor: ["#82aaff"]
|
||||
databricksColor: ["#ff757f"]
|
||||
repoColor: ["#c099ff"]
|
||||
springColor: ["#c3e88d"] # Spring Boot config files
|
||||
composeColor: ["#82aaff"] # Docker Compose files
|
||||
dotnetColor: ["#c099ff"] # .NET appsettings files
|
||||
```
|
||||
|
||||
A role lazysecrets doesn't know, a typo or one from a newer version, is
|
||||
|
||||
+6
-2
@@ -49,10 +49,14 @@ what its pull request tested, and CI doesn't run again there.
|
||||
Merge as many pull requests as you like, then release `main`:
|
||||
|
||||
```sh
|
||||
scripts/release.sh --dry-run v0.2.0 # the notes the release would have
|
||||
scripts/release.sh v0.2.0 # CHANGELOG.md, commit, tag, push
|
||||
scripts/release.sh --dry-run minor # the tag and notes the release would have
|
||||
scripts/release.sh minor # CHANGELOG.md, commit, tag, push
|
||||
```
|
||||
|
||||
`patch`, `minor` and `major` bump the latest release (`v0.2.1` → `v0.2.2`,
|
||||
`v0.3.0`, `v1.0.0`; pre-releases don't count); a tag (`v0.3.0`,
|
||||
`v0.3.0-rc.1`) is taken as it is.
|
||||
|
||||
It refuses unless you're on a clean `main` that matches `origin/main`.
|
||||
[git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md`
|
||||
from the pull request titles. The tag runs the release workflow
|
||||
|
||||
@@ -51,6 +51,7 @@ so `^prod` finds names starting with prod.
|
||||
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
|
||||
| `--max-age 2m` | load cached lists older than this again |
|
||||
| `--errors=false` | leave out the vaults and sources it couldn't read |
|
||||
| `--hidden` | list what's [hidden](repos.md#spring-boot-config) too, as `H` shows it (json: `"hidden": true`) |
|
||||
| `--color never` | no colours, for scripts |
|
||||
| `--icons=false` | no provider icons |
|
||||
| `--stars=false` | no ★ before starred names (in `favouriteColor`) |
|
||||
|
||||
+2
-1
@@ -20,6 +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 |
|
||||
| `/` | 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 |
|
||||
| `H` | show / hide what's hidden: [config file keys](repos.md#spring-boot-config) (Spring, compose, .NET) that don't look secret, example files, and files and repos with only those; shown dimmed |
|
||||
| `r` | refresh the focused pane |
|
||||
| `q`, `ctrl+c` | quit |
|
||||
|
||||
@@ -53,7 +54,7 @@ match case exactly.
|
||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
||||
| `e` | open the selected SOPS, `.env`, Spring, compose or .NET file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
||||
| `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
|
||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
|
||||
+110
-11
@@ -1,9 +1,10 @@
|
||||
# SOPS and .env files in git repos
|
||||
# SOPS, .env, Spring, compose and .NET files in git repos
|
||||
|
||||
lazysecrets shows git repositories under **Repos**: their
|
||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
||||
(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
||||
`.env`, INI), plain env files, [Spring Boot config](#spring-boot-config)
|
||||
[Docker Compose files](#docker-compose) and [.NET appsettings](#net-appsettings)
|
||||
as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
|
||||
escaped, `app\.name`, so it doesn't read as nesting.
|
||||
|
||||
## Which repos
|
||||
@@ -29,8 +30,9 @@ anything by hand.
|
||||
## Which files
|
||||
|
||||
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when
|
||||
git-ignored (as they usually are), in the folders git tracks files in;
|
||||
not `.env.example`, `.sample`, `.template` and the like. They're read
|
||||
git-ignored (as they usually are), in the folders git tracks files in.
|
||||
`.env.example`, `.sample`, `.template` and the like are listed hidden
|
||||
(`H` shows them): someone may have put a real value in one. They're read
|
||||
directly; a file with SOPS metadata is always treated as SOPS, whatever
|
||||
its name.
|
||||
|
||||
@@ -46,7 +48,7 @@ Folders of dependencies and caches are never looked in, tracked or not:
|
||||
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
|
||||
4. the whole repo.
|
||||
|
||||
`include`/`exclude` apply to env files too.
|
||||
`include`/`exclude` apply to env, Spring, compose and .NET files too.
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -65,13 +67,92 @@ sharing:
|
||||
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
||||
```
|
||||
|
||||
## Spring Boot config
|
||||
|
||||
`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the
|
||||
like, tracked by git (not those in a Helm chart's `templates/`). Most of
|
||||
their keys aren't secret, so only those that look like one are listed; the
|
||||
rest are hidden:
|
||||
|
||||
- **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 with none is hidden too, and a repo with only such files. `H` shows
|
||||
what's hidden, dimmed, in the panes and in Go to (`lazysecrets list
|
||||
--hidden` lists it); a pane's counter says how many it hides. A file's
|
||||
details count its hidden keys, 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.
|
||||
|
||||
The patterns are regexps, for all of a repo's config files and per type of
|
||||
file; each list replaces the one it falls back to, and `keys: [".*"]`
|
||||
lists every key:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
repos:
|
||||
secretPatterns: # all config files
|
||||
keys: ["(?i)(password|secret|token|key)$"] # matched against the key's name
|
||||
values: ["^ENC\\(.+\\)$"] # and against its value
|
||||
spring:
|
||||
secretPatterns: # Spring's: only what differs
|
||||
keys: ["(?i)(password|secret)$", "bearer$"]
|
||||
compose:
|
||||
secretPatterns: # and compose's
|
||||
values: []
|
||||
```
|
||||
|
||||
A repo's `.lazysecrets.yaml` can set them too, the same way
|
||||
(`secretPatterns:`, `spring:`, `compose:` or `dotnet: secretPatterns:`),
|
||||
for everyone. Each list
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## .NET appsettings
|
||||
|
||||
`appsettings.json` and `appsettings.<Environment>.json`, tracked by git.
|
||||
Keys are named as .NET's configuration names them, sections joined by
|
||||
colons: `ConnectionStrings:Default`, `Servers:0:Name`. The same
|
||||
[secret patterns](#spring-boot-config) pick them, the rest hidden, with
|
||||
`dotnet: secretPatterns:` for .NET files' own; a connection string with
|
||||
`Password=` is listed by its value. An App Service Key Vault reference,
|
||||
`@Microsoft.KeyVault(…)`, is marked as a reference. Jasypt values decrypt
|
||||
with `$JASYPT_ENCRYPTOR_PASSWORD` only.
|
||||
|
||||
## Decrypting
|
||||
|
||||
Listing never decrypts: SOPS encrypts values only, so keys and metadata
|
||||
(encryption, last modified) are read straight from the files. Revealing,
|
||||
copying or `lazysecrets get` decrypts one value with your `sops` binary, so
|
||||
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP
|
||||
KMS, PGP). When sops can't decrypt, its whole error message is shown.
|
||||
Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
|
||||
metadata (encryption, last modified) are read straight from the files.
|
||||
Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with
|
||||
your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault
|
||||
through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error
|
||||
message is shown.
|
||||
|
||||
On a file, the right pane shows how it's encrypted and the keys it's
|
||||
encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
|
||||
@@ -80,6 +161,24 @@ tracked, ignored or neither, and its last commit. A plain `.env` file that
|
||||
git tracks, or that isn't ignored, is marked in red: its values are in the
|
||||
repo's history, or easily could be.
|
||||
|
||||
### Jasypt
|
||||
|
||||
A value like `ENC(...)` in a plain `.env`, Spring, compose or .NET file is
|
||||
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
|
||||
with jasypt-spring-boot's default algorithm (3.0+:
|
||||
`PBEWITHHMACSHA512ANDAES_256`). The password is, as in Spring:
|
||||
|
||||
1. `$JASYPT_ENCRYPTOR_PASSWORD`,
|
||||
2. the file's own: `jasypt.encryptor.password` in a Spring file, the
|
||||
service's `JASYPT_ENCRYPTOR_PASSWORD` in a compose file,
|
||||
`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
|
||||
starts it without your shell's. A `.env` whose values are all Jasypt's
|
||||
isn't marked in red.
|
||||
|
||||
## Opening files
|
||||
|
||||
`e` opens the selected file, or on a secret, its file at that key's line:
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
@@ -153,6 +154,12 @@ type Repos struct {
|
||||
// a repo's .sops.yaml creation_rules say where to look.
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
// SecretPatterns say which keys of config files are secrets; Spring,
|
||||
// Compose and Dotnet override them for their files, list by list.
|
||||
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
|
||||
Spring repo.FileType `yaml:"spring"`
|
||||
Compose repo.FileType `yaml:"compose"`
|
||||
Dotnet repo.FileType `yaml:"dotnet"`
|
||||
}
|
||||
|
||||
type Azure struct {
|
||||
@@ -292,6 +299,12 @@ func (c Config) validate(path string) error {
|
||||
return fmt.Errorf("%s: %s: %s is negative", path, name, d)
|
||||
}
|
||||
}
|
||||
if err := errors.Join(c.Providers.Repos.SecretPatterns.Check("providers.repos.secretPatterns"),
|
||||
c.Providers.Repos.Spring.SecretPatterns.Check("providers.repos.spring.secretPatterns"),
|
||||
c.Providers.Repos.Compose.SecretPatterns.Check("providers.repos.compose.secretPatterns"),
|
||||
c.Providers.Repos.Dotnet.SecretPatterns.Check("providers.repos.dotnet.secretPatterns")); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
for i, v := range c.Providers.Azure.Vaults {
|
||||
if !strings.HasPrefix(v, "https://") {
|
||||
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
|
||||
|
||||
@@ -84,6 +84,8 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
"workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n",
|
||||
"short keys": "sharing:\n keyRules:\n minLength: 8\n",
|
||||
"classes": "sharing:\n keyRules:\n minClasses: 5\n",
|
||||
"regexp": "providers:\n repos:\n secretPatterns:\n keys: [\"(pass\"]\n",
|
||||
"spring re": "providers:\n repos:\n spring:\n secretPatterns:\n values: [\"(\"]\n",
|
||||
} {
|
||||
if _, err := LoadAll([]string{write(t, content)}); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package jasypt decrypts values encrypted with Jasypt the way
|
||||
// jasypt-spring-boot 3.0 and later does by default: ENC(...) around the
|
||||
// base64 of a random salt, a random IV and the ciphertext, with
|
||||
// PBEWITHHMACSHA512ANDAES_256 (PBKDF2-HMAC-SHA512, 1000 iterations,
|
||||
// AES-256-CBC).
|
||||
package jasypt
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const iterations = 1000
|
||||
|
||||
// ErrDecrypt is a value that doesn't decrypt with the password: a wrong
|
||||
// password, or one encrypted with another algorithm.
|
||||
var ErrDecrypt = errors.New("doesn't decrypt: a wrong password, or not Jasypt's default algorithm (PBEWITHHMACSHA512ANDAES_256)")
|
||||
|
||||
// Is reports whether v is a Jasypt value, ENC(...).
|
||||
func Is(v string) bool {
|
||||
return len(v) > len("ENC()") && strings.HasPrefix(v, "ENC(") && strings.HasSuffix(v, ")")
|
||||
}
|
||||
|
||||
// Decrypt decrypts an ENC(...) value.
|
||||
func Decrypt(v, password string) (string, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(v, "ENC("), ")"))
|
||||
if err != nil {
|
||||
return "", errors.New("not Jasypt: ENC(...) isn't base64")
|
||||
}
|
||||
const size = aes.BlockSize // the salt's and the IV's too
|
||||
if len(b) < 3*size || len(b)%size != 0 {
|
||||
return "", ErrDecrypt
|
||||
}
|
||||
salt, iv, data := b[:size], b[size:2*size], b[2*size:]
|
||||
key, err := pbkdf2.Key(sha512.New, password, salt, iterations, 32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain := make([]byte, len(data))
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
|
||||
// A wrong password decrypts to noise: its padding is almost never
|
||||
// valid, and the rest rarely UTF-8.
|
||||
n := int(plain[len(plain)-1])
|
||||
if n == 0 || n > size {
|
||||
return "", ErrDecrypt
|
||||
}
|
||||
for _, c := range plain[len(plain)-n:] {
|
||||
if int(c) != n {
|
||||
return "", ErrDecrypt
|
||||
}
|
||||
}
|
||||
plain = plain[:len(plain)-n]
|
||||
if !utf8.Valid(plain) {
|
||||
return "", ErrDecrypt
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package jasypt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Encrypted by Jasypt 1.9.3 itself, set up as jasypt-spring-boot's default
|
||||
// encryptor, with the password "test-password".
|
||||
var vectors = map[string]string{
|
||||
"ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)": "hunter2",
|
||||
"ENC(w4pm2JVZcbG5R+Ud9uKHg8VhJ/0aAK7sFXtNBv+Vo450SAchTQtfhw0TnIJ9gN+D0VFbwqJzfXU369coTJz2rw==)": "blåbærsyltetøy ✓",
|
||||
}
|
||||
|
||||
func TestDecrypt(t *testing.T) {
|
||||
for enc, want := range vectors {
|
||||
if !Is(enc) {
|
||||
t.Errorf("%s: not Jasypt", enc)
|
||||
}
|
||||
if got, err := Decrypt(enc, "test-password"); err != nil || got != want {
|
||||
t.Errorf("%s: %q %v, want %q", enc, got, err, want)
|
||||
}
|
||||
if _, err := Decrypt(enc, "wrong"); !errors.Is(err, ErrDecrypt) {
|
||||
t.Errorf("%s with a wrong password: %v", enc, err)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"ENC(not base64!)", "ENC(aGVsbG8=)"} {
|
||||
if _, err := Decrypt(v, "test-password"); err == nil {
|
||||
t.Errorf("%s: no error", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"ENC()", "hunter2", "ENC[AES256_GCM,data:x]", "${DB_PASSWORD}"} {
|
||||
if Is(v) {
|
||||
t.Errorf("%s: Jasypt", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ type Entry struct {
|
||||
// web page (portal, workspace), if any.
|
||||
Reference string `json:"reference,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
// Hidden is left out by its provider's rules (a Spring file's key
|
||||
// that doesn't look secret), listed only with Options.Hidden.
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
// Error says why a source's vaults or a vault's secrets couldn't be
|
||||
// listed. The entry is then that source or vault, again, or with an
|
||||
// account's sources unreadable, the account without a link.
|
||||
@@ -70,6 +73,8 @@ type Options struct {
|
||||
MaxAge time.Duration
|
||||
// Cached lists only what's cached (and repos): nothing is loaded.
|
||||
Cached bool
|
||||
// Hidden lists what providers hide too, as H does in the TUI.
|
||||
Hidden bool
|
||||
// Kinds are the kinds of entry to list (Kinds' names); none is all.
|
||||
// Lists only needed for other kinds aren't loaded: sources and vaults
|
||||
// alone don't load any secrets. Lists that fail are always listed.
|
||||
@@ -180,6 +185,9 @@ func (l *lister) provider(p provider.Provider) {
|
||||
|
||||
// source emits src, and what's cached under it, and queues what's not.
|
||||
func (l *lister) source(p provider.Provider, src provider.Source) {
|
||||
if src.Hidden && !l.o.Hidden {
|
||||
return
|
||||
}
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
l.put(p, sourceEntry(src))
|
||||
row := cachefile.Row(p.ID(), src.ID)
|
||||
@@ -196,6 +204,9 @@ func (l *lister) source(p provider.Provider, src provider.Source) {
|
||||
|
||||
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
|
||||
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
|
||||
if c.Hidden && !l.o.Hidden {
|
||||
continue
|
||||
}
|
||||
c.Source = src
|
||||
l.put(p, containerEntry(c))
|
||||
for _, t := range c.ObjectTypes() {
|
||||
@@ -221,17 +232,17 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
|
||||
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
|
||||
o.Container = c
|
||||
r, err := ref.ForObject(o, "")
|
||||
if err != nil {
|
||||
if err != nil || (o.Hidden && !l.o.Hidden) {
|
||||
continue
|
||||
}
|
||||
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
|
||||
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
|
||||
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL})
|
||||
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL, Hidden: o.Hidden})
|
||||
}
|
||||
}
|
||||
|
||||
func sourceEntry(src provider.Source) Entry {
|
||||
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL}
|
||||
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL, Hidden: src.Hidden}
|
||||
if r, err := ref.ForSource(src); err == nil {
|
||||
e.Link = r.String()
|
||||
}
|
||||
@@ -239,7 +250,7 @@ func sourceEntry(src provider.Source) Entry {
|
||||
}
|
||||
|
||||
func containerEntry(c provider.Container) Entry {
|
||||
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL}
|
||||
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL, Hidden: c.Hidden}
|
||||
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
||||
e.Link = r.String()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
@@ -18,8 +19,9 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
return attrs, ctx.Err()
|
||||
}
|
||||
|
||||
// Repos returns a fake repo provider: three git repos with SOPS and plain
|
||||
// .env files, the first one the repo lazysecrets was started in.
|
||||
// Repos returns a fake repo provider: four git repos with SOPS, plain
|
||||
// .env, Spring and compose files, the first one the repo lazysecrets was
|
||||
// started in.
|
||||
func Repos() *Provider {
|
||||
p := newProvider(provider.KindRepo, 0)
|
||||
p.id, p.label = "Repos", "Repos"
|
||||
@@ -113,6 +115,90 @@ func Repos() *Provider {
|
||||
"AZURE_CLIENT_SECRET", "azure-demo-client-secret",
|
||||
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000")
|
||||
|
||||
// config adds a Spring or compose file (kind) and its keys as name,
|
||||
// value and how it's listed: "" a secret, "hidden", "reference" (the
|
||||
// value is a placeholder) or "jasypt" (the value is the decrypted one).
|
||||
config := func(src provider.Source, path, kind, git, commit string, kv ...string) {
|
||||
form := "yaml"
|
||||
if strings.HasSuffix(path, ".properties") {
|
||||
form = "properties"
|
||||
}
|
||||
c := p.addContainer(src, path, kind+" · "+form, provider.TypeSecret)
|
||||
format := provider.Attr{Label: "format", Value: kind + " " + form}
|
||||
secrets, hidden, jasypt, plain := 0, 0, 0, false
|
||||
for i := 0; i+2 < len(kv); i += 3 {
|
||||
o := provider.Object{Name: kv[i], Attrs: []provider.Attr{format, {Label: "encrypted", Value: "no: plain text in the file"}}}
|
||||
switch kv[i+2] {
|
||||
case "hidden":
|
||||
o.Hidden = true
|
||||
hidden++
|
||||
continue
|
||||
case "reference":
|
||||
o.Detail, o.Attrs[1] = "reference", provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}
|
||||
case "jasypt":
|
||||
o.Detail, o.Attrs[1] = "jasypt", provider.Attr{Label: "encrypted", Value: "jasypt"}
|
||||
jasypt++
|
||||
default:
|
||||
plain = plain || kv[i+1] != ""
|
||||
}
|
||||
secrets++
|
||||
p.add(c, provider.TypeSecret, o, kv[i+1])
|
||||
}
|
||||
// Hidden keys after the rest, so the list opens on a secret.
|
||||
for i := 0; i+2 < len(kv); i += 3 {
|
||||
if kv[i+2] == "hidden" {
|
||||
p.add(c, provider.TypeSecret, provider.Object{Name: kv[i], Hidden: true, Attrs: []provider.Attr{format}}, kv[i+1])
|
||||
}
|
||||
}
|
||||
if secrets == 0 {
|
||||
p.hide(src, path)
|
||||
}
|
||||
encryption := "none: plain text"
|
||||
if jasypt > 0 {
|
||||
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, secrets+hidden)
|
||||
}
|
||||
d := []provider.Attr{
|
||||
{Label: "encryption", Value: encryption},
|
||||
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", hidden)},
|
||||
}
|
||||
if git == "tracked" && plain {
|
||||
d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
} else {
|
||||
d = append(d, provider.Attr{Label: "git", Value: git})
|
||||
}
|
||||
p.details[c.ID] = append(d, provider.Attr{Label: "commit", Value: commit})
|
||||
}
|
||||
|
||||
orders := repo("orders-service", false)
|
||||
config(orders, "src/main/resources/application.yml", "spring", "tracked", "5be2c07 · 6 days ago",
|
||||
"spring.application.name", "orders-service", "hidden",
|
||||
"server.port", "8080", "hidden",
|
||||
"spring.datasource.url", "jdbc:postgresql://orders-db:5432/orders", "hidden",
|
||||
"spring.datasource.password", "${DB_PASSWORD}", "reference",
|
||||
"spring.security.oauth2.client.provider.idp.token-uri", "https://idp.acme.example/token", "hidden",
|
||||
"spring.security.oauth2.client.registration.orders.client-secret", "orders-demo-client-secret", "jasypt",
|
||||
"jasypt.encryptor.password", "${JASYPT_ENCRYPTOR_PASSWORD}", "reference",
|
||||
"app.payment.api-key", "pay_live_demo_7Hq2", "jasypt")
|
||||
config(orders, "src/main/resources/application-local.properties", "spring", "tracked", "5be2c07 · 6 days ago",
|
||||
"spring.datasource.url", "jdbc:postgresql://localhost:5432/orders", "hidden",
|
||||
"spring.datasource.password", "local-orders-pw", "",
|
||||
"logging.level.com.acme", "DEBUG", "hidden")
|
||||
config(orders, "src/test/resources/application-test.yml", "spring", "tracked", "d81a3f9 · 3 weeks ago",
|
||||
"spring.datasource.url", "jdbc:h2:mem:orders", "hidden",
|
||||
"spring.jpa.hibernate.ddl-auto", "create-drop", "hidden")
|
||||
config(orders, "docker-compose.yml", "compose", "tracked", "5be2c07 · 6 days ago",
|
||||
"api.SPRING_PROFILES_ACTIVE", "local", "hidden",
|
||||
"api.DB_PASSWORD", "${DB_PASSWORD}", "reference",
|
||||
"db.POSTGRES_USER", "orders", "hidden",
|
||||
"db.POSTGRES_PASSWORD", "orders-db-demo-pw", "")
|
||||
config(orders, "docker-compose.example.yml", "compose", "tracked", "5be2c07 · 6 days ago",
|
||||
"db.POSTGRES_PASSWORD", "changeme", "")
|
||||
p.hide(orders, "docker-compose.example.yml")
|
||||
file(orders, ".env.example", "", "tracked", "5be2c07 · 6 days ago",
|
||||
"DB_PASSWORD", "changeme",
|
||||
"JASYPT_ENCRYPTOR_PASSWORD", "changeme")
|
||||
p.hide(orders, ".env.example")
|
||||
|
||||
web := repo("web-frontend", false)
|
||||
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
|
||||
"SENTRY_AUTH_TOKEN", "sntrys_demo_token",
|
||||
@@ -149,3 +235,13 @@ func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte,
|
||||
}
|
||||
return []byte(b.String()), ctx.Err()
|
||||
}
|
||||
|
||||
// hide marks a file hidden, as the repo provider does an example file or
|
||||
// a config file without secrets.
|
||||
func (p *Provider) hide(src provider.Source, name string) {
|
||||
for i, c := range p.containers[src.ID] {
|
||||
if c.Name == name {
|
||||
p.containers[src.ID][i].Hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,10 @@ type Source struct {
|
||||
// Here marks the source lazysecrets was started in (the current git
|
||||
// repo): listed first, starred or not, and labelled.
|
||||
Here bool
|
||||
// Hidden marks a source, container or object the provider's rules
|
||||
// left out (a Spring file with no secret-looking keys): listed only
|
||||
// while the app shows hidden ones.
|
||||
Hidden bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ObjectType is the kind of object stored in a container.
|
||||
@@ -109,7 +113,8 @@ type Container struct {
|
||||
Types []ObjectType
|
||||
URL string // web page for the container, if any
|
||||
// Attrs describe it further, from the listing (a vault's settings).
|
||||
Attrs []Attr `json:",omitempty"`
|
||||
Attrs []Attr `json:",omitempty"`
|
||||
Hidden bool `json:",omitempty"` // see Source.Hidden
|
||||
}
|
||||
|
||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||
@@ -133,6 +138,8 @@ type Object struct {
|
||||
Type ObjectType
|
||||
ID string
|
||||
Name string
|
||||
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
|
||||
Hidden bool `json:",omitempty"` // see Source.Hidden
|
||||
Version string
|
||||
Enabled *bool
|
||||
Updated time.Time
|
||||
|
||||
@@ -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; nil when only $JASYPT_ENCRYPTOR_PASSWORD can.
|
||||
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, dotnetType}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -22,7 +24,12 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
}
|
||||
var out []provider.Attr
|
||||
meta, isSops := readMeta(c.ID)
|
||||
if isSops {
|
||||
// A plain file's values are at risk in git, unless they're all Jasypt;
|
||||
// a config file's only when one of its secrets is in plain text.
|
||||
plain := !isSops
|
||||
if t, ok := configTypeOf(c.ID); ok && !isSops {
|
||||
out, plain = p.configDetails(t, c)
|
||||
} else if isSops {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||
for i, k := range meta.recipients {
|
||||
label := ""
|
||||
@@ -35,7 +42,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
||||
}
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
||||
out, plain = envDetails(c.ID)
|
||||
}
|
||||
|
||||
git := func(args ...string) (string, bool) {
|
||||
@@ -43,7 +50,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
return strings.TrimSpace(string(b)), err == nil
|
||||
}
|
||||
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
|
||||
case tracked && !isSops:
|
||||
case tracked && plain:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
|
||||
case tracked:
|
||||
out = append(out, provider.Attr{Label: "git", Value: "tracked"})
|
||||
@@ -51,7 +58,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "ignored"})
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -60,3 +67,28 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// envDetails says how a plain env file's values are encrypted, and whether
|
||||
// any is in plain text.
|
||||
func envDetails(file string) (attrs []provider.Attr, plain bool) {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return []provider.Attr{{Label: "encryption", Value: err.Error(), Warn: true}}, true
|
||||
}
|
||||
keys, _ := parseKeys(file, b)
|
||||
n := 0
|
||||
for _, k := range keys {
|
||||
if k.jasypt {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return []provider.Attr{{Label: "encryption", Value: jasyptShare(n, len(keys))}}, n < len(keys)
|
||||
}
|
||||
|
||||
// jasyptShare is how many of a plain file's values are Jasypt's.
|
||||
func jasyptShare(n, of int) string {
|
||||
if n == 0 {
|
||||
return "none: plain text"
|
||||
}
|
||||
return fmt.Sprintf("jasypt: %d of %d values", n, of)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// .NET config files: appsettings.json and appsettings.<Environment>.json.
|
||||
// Keys are named as .NET's configuration names them, sections joined by
|
||||
// colons: ConnectionStrings:Default, Servers:0:Name.
|
||||
|
||||
var dotnetType = configType{
|
||||
name: "dotnet", is: IsDotnet, format: func(string) string { return "json" }, parse: parseDotnet,
|
||||
placeholder: dotnetPlaceholder,
|
||||
}
|
||||
|
||||
var dotnetName = regexp.MustCompile(`^appsettings(\.[^/]+)?\.json$`)
|
||||
|
||||
// IsDotnet reports whether f is named like a .NET appsettings file.
|
||||
func IsDotnet(f string) bool {
|
||||
return dotnetName.MatchString(strings.ToLower(path.Base(f)))
|
||||
}
|
||||
|
||||
// dotnetPlaceholder reads an App Service Key Vault reference,
|
||||
// @Microsoft.KeyVault(SecretUri=…): the secret is in the vault.
|
||||
func dotnetPlaceholder(v string) (name, def string, hasDef, ok bool) {
|
||||
if strings.HasPrefix(v, "@Microsoft.KeyVault(") && strings.HasSuffix(v, ")") {
|
||||
return v, "", false, true
|
||||
}
|
||||
return "", "", false, false
|
||||
}
|
||||
|
||||
func parseDotnet(_ string, b []byte) ([]configKey, error) {
|
||||
var doc yaml.Node // JSON is YAML, and the YAML parser says where each key is
|
||||
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []configKey
|
||||
if len(doc.Content) > 0 {
|
||||
dotnetWalk(doc.Content[0], "", &out)
|
||||
}
|
||||
for i := range out {
|
||||
out[i].name = out[i].key
|
||||
out[i].ref = reference(dotnetPlaceholder, out[i].value)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dotnetWalk(n *yaml.Node, name string, out *[]configKey) {
|
||||
join := func(child string) string {
|
||||
if name == "" {
|
||||
return child
|
||||
}
|
||||
return name + ":" + child
|
||||
}
|
||||
switch n.Kind {
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||
dotnetWalk(n.Content[i+1], join(n.Content[i].Value), out)
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
for i, c := range n.Content {
|
||||
dotnetWalk(c, join(fmt.Sprint(i)), out)
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
if name != "" {
|
||||
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestIsDotnet(t *testing.T) {
|
||||
for f, want := range map[string]bool{
|
||||
"appsettings.json": true,
|
||||
"src/Api/appsettings.Production.json": true,
|
||||
"appsettings.Example.json": true, // listed hidden
|
||||
"appsettings.json.bak": false,
|
||||
"settings.json": false,
|
||||
"package.json": false,
|
||||
} {
|
||||
if IsDotnet(f) != want {
|
||||
t.Errorf("%s: %t", f, !want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDotnet(t *testing.T) {
|
||||
keys, err := parseDotnet("", []byte("\xef\xbb\xbf"+`{
|
||||
"ConnectionStrings": { "Default": "Server=db;Password=hunter2" },
|
||||
"AzureAd": { "ClientId": "abc", "ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad)" },
|
||||
"Servers": [ { "Name": "a", "ApiKey": "k" } ],
|
||||
"Logging": { "LogLevel": { "Default": "Information" } }
|
||||
}`))
|
||||
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{
|
||||
"ConnectionStrings:Default": "Server=db;Password=hunter2",
|
||||
"AzureAd:ClientId": "abc",
|
||||
"AzureAd:ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad) (ref)",
|
||||
"Servers:0:Name": "a",
|
||||
"Servers:0:ApiKey": "k",
|
||||
"Logging:LogLevel:Default": "Information",
|
||||
}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Errorf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDotnetFiles(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"src/Api/appsettings.json": `{
|
||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||
"ConnectionStrings": { "Default": "Server=db;User Id=app;Password=hunter2" },
|
||||
"AzureAd": { "ClientSecret": "@Microsoft.KeyVault(VaultName=kv;SecretName=ad)" }
|
||||
}`,
|
||||
"src/Api/appsettings.Development.json": `{ "Logging": { "LogLevel": { "Default": "Debug" } } }`,
|
||||
})
|
||||
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 != "dotnet · json" || c.Hidden != (c.Name == "src/Api/appsettings.Development.json") {
|
||||
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
|
||||
}
|
||||
}
|
||||
settings := cs[1]
|
||||
|
||||
keys, _ := p.Objects(ctx, settings, provider.TypeSecret)
|
||||
got := map[string]string{}
|
||||
for _, k := range shown(keys) {
|
||||
got[k.Name] = k.Detail
|
||||
}
|
||||
// The connection string by its value, the client secret by its name.
|
||||
if want := map[string]string{"ConnectionStrings:Default": "", "AzureAd:ClientSecret": "reference"}; !maps.Equal(got, want) {
|
||||
t.Errorf("keys %v, want %v", got, want)
|
||||
}
|
||||
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/Api/appsettings.json"}, Name: "ConnectionStrings:Default"}
|
||||
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "Server=db;User Id=app;Password=hunter2" {
|
||||
t.Errorf("value: %q %v", v.Reveal(), err)
|
||||
}
|
||||
if f, _ := p.File(settings, &link); f.Line != 3 {
|
||||
t.Errorf("line %d", f.Line)
|
||||
}
|
||||
if d, plain := p.configDetails(dotnetType, settings); !plain || d[1].Value != "1, not matched by the secret patterns" {
|
||||
t.Errorf("details: %+v %t", d, plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// jasyptEnv is where Spring looks for the password first, before the
|
||||
// config's jasypt.encryptor.password, and so does lazysecrets.
|
||||
const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD"
|
||||
|
||||
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
|
||||
// file; other values are as they are.
|
||||
func jasyptValue(file, name string, v provider.SecretValue) (provider.SecretValue, error) {
|
||||
s := v.Reveal()
|
||||
if !jasypt.Is(s) {
|
||||
return v, nil
|
||||
}
|
||||
password, from, err := jasyptPassword(file, name)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
plain, err := jasypt.Decrypt(s, password)
|
||||
if errors.Is(err, jasypt.ErrDecrypt) {
|
||||
return provider.SecretValue{}, fmt.Errorf("jasypt: with the password from %s: %w", from, err)
|
||||
}
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, fmt.Errorf("jasypt: %w", err)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(plain)), nil
|
||||
}
|
||||
|
||||
// jasyptPassword is the password for the Jasypt value of the key named
|
||||
// name, and where it's from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's
|
||||
// own setting (jasypt.encryptor.password in a Spring file; the service's
|
||||
// JASYPT_ENCRYPTOR_PASSWORD in a compose file, the file's in an env file),
|
||||
// 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 != "" {
|
||||
return v, "$" + jasyptEnv, nil
|
||||
}
|
||||
setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose
|
||||
if t, ok := configTypeOf(file); ok {
|
||||
if t.jasyptSetting == nil {
|
||||
return "", "", fmt.Errorf("jasypt: no password: set $%s", jasyptEnv)
|
||||
}
|
||||
setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder
|
||||
}
|
||||
v, err := read(file, setting)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
|
||||
}
|
||||
from = setting + " in " + filepath.Base(file)
|
||||
env, def, hasDef, ok := placeholder(v.Reveal())
|
||||
switch {
|
||||
case !ok:
|
||||
return v.Reveal(), from, nil
|
||||
case os.Getenv(env) != "":
|
||||
return os.Getenv(env), "$" + env + " (" + from + ")", nil
|
||||
case hasDef:
|
||||
return def, "the default in " + from, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v.Reveal(), env)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// hunter2, encrypted by Jasypt with the password "test-password".
|
||||
const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)"
|
||||
|
||||
func TestJasyptPassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
value := func(name, body string) (string, error) {
|
||||
file := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(file, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "app.token"
|
||||
if !IsSpring(name) {
|
||||
key = "TOKEN"
|
||||
}
|
||||
v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key})
|
||||
return v.Reveal(), err
|
||||
}
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||
t.Setenv("MY_PASSWORD", "")
|
||||
spring := func(password string) string {
|
||||
return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n"
|
||||
}
|
||||
|
||||
for _, c := range []struct{ name, file, body, env, want string }{
|
||||
{"the file's own", "application.yml", spring("test-password"), "", "hunter2"},
|
||||
{"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"},
|
||||
{"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"},
|
||||
{"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||
{"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||
{"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"},
|
||||
} {
|
||||
t.Setenv("MY_PASSWORD", c.env)
|
||||
if got, err := value(c.file, c.body); err != nil || got != c.want {
|
||||
t.Errorf("%s: %q %v", c.name, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring.
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||
if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" {
|
||||
t.Errorf("environment first: %q %v", got, err)
|
||||
}
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong")
|
||||
if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") {
|
||||
t.Errorf("a wrong one says where it's from: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||
t.Setenv("MY_PASSWORD", "")
|
||||
for body, want := range map[string]string{
|
||||
spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set",
|
||||
"app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml",
|
||||
} {
|
||||
if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("no password: %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJasyptEnvFile(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"jasypt.env": "TOKEN=" + jasyptHunter2 + "\n",
|
||||
"mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n",
|
||||
})
|
||||
ctx := context.Background()
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(ctx)
|
||||
cs, _ := p.Containers(ctx, srcs[0])
|
||||
for _, c := range cs {
|
||||
attrs, _ := p.Details(ctx, c)
|
||||
got := map[string]provider.Attr{}
|
||||
for _, a := range attrs {
|
||||
got[a.Label] = a
|
||||
}
|
||||
switch c.Name {
|
||||
case "jasypt.env":
|
||||
if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn {
|
||||
t.Errorf("all jasypt: %+v", got)
|
||||
}
|
||||
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
||||
if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" {
|
||||
t.Errorf("keys: %+v", keys)
|
||||
}
|
||||
case "mixed.env":
|
||||
if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn {
|
||||
t.Errorf("mixed: %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Config files (Spring's) hold all of an app's config, most of it not
|
||||
// secret: of their keys, those named like a secret or with a value that
|
||||
// looks like one are listed, the rest hidden (H shows them). The patterns
|
||||
// can be set for all of a repo's files and per file type, in the config
|
||||
// and in a repo's .lazysecrets.yaml.
|
||||
|
||||
// DefaultSecretKeys and DefaultSecretValues are the patterns used when
|
||||
// nothing sets them. The key pattern is anchored at the end of the name:
|
||||
// spring.datasource.password, client-secret, DB_PASSWORD and checkoutKey,
|
||||
// not the oauth2.*, keycloak.* and token-uri keys every Spring app has.
|
||||
// passord is Norwegian. They err on listing too much: a row too many is
|
||||
// cheap, a secret missed isn't.
|
||||
var (
|
||||
DefaultSecretKeys = []string{
|
||||
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
|
||||
}
|
||||
DefaultSecretValues = []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
|
||||
}
|
||||
)
|
||||
|
||||
// SecretPatterns are regexps matched against a key's name and its value:
|
||||
// a key is a secret when either matches. A list that isn't set is taken
|
||||
// from the next place that sets it (see filterFor).
|
||||
type SecretPatterns struct {
|
||||
Keys *[]string `yaml:"keys"`
|
||||
Values *[]string `yaml:"values"`
|
||||
}
|
||||
|
||||
// FileType holds the settings of one type of file (spring:), for now its
|
||||
// own secret patterns.
|
||||
type FileType struct {
|
||||
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
|
||||
}
|
||||
|
||||
// Check says which pattern doesn't compile; field is where they're set
|
||||
// (spring.secretPatterns), for the message.
|
||||
func (p SecretPatterns) Check(field string) error {
|
||||
var errs []error
|
||||
for name, list := range map[string]*[]string{"keys": p.Keys, "values": p.Values} {
|
||||
if list == nil {
|
||||
continue
|
||||
}
|
||||
for i, s := range *list {
|
||||
if _, err := regexp.Compile(s); err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s.%s[%d]: %w", field, name, i, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
type secretFilter struct{ keys, values []*regexp.Regexp }
|
||||
|
||||
func (f secretFilter) secret(key, value string) bool {
|
||||
return anyMatch(f.keys, key) || anyMatch(f.values, value)
|
||||
}
|
||||
|
||||
// filterFor is the patterns for a repo's files of one type (spring), each
|
||||
// list from the first that sets it: the repo's .lazysecrets.yaml for the
|
||||
// type, then for all its files, the config for the type, then for all
|
||||
// repos, then the defaults.
|
||||
func (p *Provider) filterFor(root, fileType string) (secretFilter, error) {
|
||||
f, err := readLazysecretsFile(root)
|
||||
if err != nil {
|
||||
return secretFilter{}, err
|
||||
}
|
||||
own := filepath.Join(root, ".lazysecrets.yaml") + ": "
|
||||
type source struct {
|
||||
field string
|
||||
patterns SecretPatterns
|
||||
}
|
||||
sources := []source{
|
||||
{own + fileType + ".secretPatterns", f.types()[fileType].SecretPatterns},
|
||||
{own + "secretPatterns", f.SecretPatterns},
|
||||
{"providers.repos." + fileType + ".secretPatterns", p.s.Types[fileType].SecretPatterns},
|
||||
{"providers.repos.secretPatterns", p.s.SecretPatterns},
|
||||
}
|
||||
pick := func(name string, list func(SecretPatterns) *[]string, defaults []string) ([]*regexp.Regexp, error) {
|
||||
for _, s := range sources {
|
||||
if l := list(s.patterns); l != nil {
|
||||
return compileAll(*l, s.field+"."+name)
|
||||
}
|
||||
}
|
||||
return compileAll(defaults, "default "+name)
|
||||
}
|
||||
keys, kerr := pick("keys", func(p SecretPatterns) *[]string { return p.Keys }, DefaultSecretKeys)
|
||||
values, verr := pick("values", func(p SecretPatterns) *[]string { return p.Values }, DefaultSecretValues)
|
||||
return secretFilter{keys, values}, errors.Join(kerr, verr)
|
||||
}
|
||||
|
||||
func compileAll(patterns []string, field string) ([]*regexp.Regexp, error) {
|
||||
out := make([]*regexp.Regexp, 0, len(patterns))
|
||||
for i, s := range patterns {
|
||||
re, err := regexp.Compile(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s[%d]: %w", field, i, err)
|
||||
}
|
||||
out = append(out, re)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Each list comes from the first that sets it: the repo's spring, the
|
||||
// repo's, the config's spring, the config's, the defaults.
|
||||
func TestSecretPatternsOrder(t *testing.T) {
|
||||
list := func(s ...string) *[]string { return &s }
|
||||
root := t.TempDir()
|
||||
write := func(body string) {
|
||||
if err := os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p := New(Settings{
|
||||
SecretPatterns: SecretPatterns{Keys: list("^config$"), Values: list("^config$")},
|
||||
Types: map[string]FileType{"spring": {SecretPatterns: SecretPatterns{Keys: list("^config-spring$")}}},
|
||||
})
|
||||
secret := func(key, value string) bool {
|
||||
t.Helper()
|
||||
f, err := p.filterFor(root, "spring")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.secret(key, value)
|
||||
}
|
||||
|
||||
if !secret("config-spring", "") || secret("config", "") || !secret("", "config") {
|
||||
t.Error("the config's spring keys, its shared values")
|
||||
}
|
||||
write("secretPatterns:\n keys: [\"^repo$\"]\n")
|
||||
if !secret("repo", "") || secret("config-spring", "") || !secret("", "config") {
|
||||
t.Error("the repo's shared keys over the config's")
|
||||
}
|
||||
write("secretPatterns:\n keys: [\"^repo$\"]\nspring:\n secretPatterns:\n keys: [\"^repo-spring$\"]\n values: []\n")
|
||||
if !secret("repo-spring", "") || secret("repo", "") || secret("", "config") {
|
||||
t.Error("the repo's spring over everything; an empty list matches nothing")
|
||||
}
|
||||
|
||||
p = New(Settings{})
|
||||
write("")
|
||||
if !secret("spring.datasource.password", "") || secret("server.port", "") {
|
||||
t.Error("the defaults")
|
||||
}
|
||||
}
|
||||
+104
-60
@@ -1,6 +1,7 @@
|
||||
// Package repo browses the encrypted secret files in git repositories:
|
||||
// a repo is a source, its secret files the containers (vault level), and
|
||||
// their keys the secrets. Secret files are SOPS files and plain env files.
|
||||
// their keys the secrets. Secret files are SOPS files, plain env files and
|
||||
// config files: Spring Boot's, Docker Compose's and .NET's (configfile.go).
|
||||
//
|
||||
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
||||
// metadata are read directly. A value is decrypted when it's revealed or
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -49,6 +51,11 @@ type Settings struct {
|
||||
// Include and Exclude are globs relative to a repo's root (** matches
|
||||
// any depth), for repos without their own .lazysecrets.yaml.
|
||||
Include, Exclude []string
|
||||
// SecretPatterns say which keys of config files are secrets, Types
|
||||
// per type of file (spring), for repos whose .lazysecrets.yaml
|
||||
// doesn't.
|
||||
SecretPatterns SecretPatterns
|
||||
Types map[string]FileType
|
||||
// Sops is the sops binary; "sops" (on PATH) when empty.
|
||||
Sops string
|
||||
}
|
||||
@@ -185,12 +192,19 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
|
||||
return provider.Source{}, false
|
||||
}
|
||||
src := p.source(r)
|
||||
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
|
||||
files, err := p.Containers(ctx, src)
|
||||
if err == nil && len(files) == 0 {
|
||||
return provider.Source{}, false
|
||||
}
|
||||
src.Hidden = err == nil && allHidden(files)
|
||||
return src, true
|
||||
}
|
||||
|
||||
// allHidden reports whether a repo's files are all hidden: the repo is too.
|
||||
func allHidden(files []provider.Container) bool {
|
||||
return !slices.ContainsFunc(files, func(c provider.Container) bool { return !c.Hidden })
|
||||
}
|
||||
|
||||
func (p *Provider) source(r repoInfo) provider.Source {
|
||||
return provider.Source{
|
||||
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
|
||||
@@ -259,6 +273,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
parallel(len(srcs), func(i int) {
|
||||
files, err := p.Containers(ctx, srcs[i])
|
||||
keep[i] = err != nil || len(files) > 0
|
||||
srcs[i].Hidden = err == nil && allHidden(files)
|
||||
})
|
||||
var out []provider.Source
|
||||
for i, src := range srcs {
|
||||
@@ -318,8 +333,13 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
||||
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
|
||||
case isSops:
|
||||
return // a SOPS file where .sops.yaml says SOPS files don't go
|
||||
case IsEnv(f):
|
||||
case isEnvNamed(f):
|
||||
c.Detail = provider.DetailPlainEnv
|
||||
c.Hidden = isExample(f)
|
||||
case isConfig(f):
|
||||
t, _ := configTypeOf(f)
|
||||
c.Detail = t.detail(f)
|
||||
c.Hidden = isExample(f) || p.configHidden(t, r.root, path)
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -357,6 +377,9 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
||||
return nil, err
|
||||
}
|
||||
meta, isSops := parseMeta(c.ID, b)
|
||||
if t, ok := configTypeOf(c.ID); ok && !isSops {
|
||||
return p.configObjects(t, c, b)
|
||||
}
|
||||
keys, err := parseKeys(c.ID, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||
@@ -371,8 +394,11 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
||||
Container: c, Type: provider.TypeSecret,
|
||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
|
||||
}
|
||||
if isSops && !k.encrypted {
|
||||
switch {
|
||||
case isSops && !k.encrypted:
|
||||
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||
case !isSops && k.jasypt:
|
||||
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
@@ -387,15 +413,29 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
// Value decrypts one key with sops.
|
||||
// Value reads one key: from a plain file, decrypting a Jasypt value; from
|
||||
// a SOPS file, with sops.
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
file, extract, ok := strings.Cut(o.ID, "\x00")
|
||||
if !ok { // made from a link: the repo's root, the file's path, the key
|
||||
file = filePath(o.Container)
|
||||
extract = extractFor(file, o.Name)
|
||||
}
|
||||
if _, isSops := readMeta(file); !isSops {
|
||||
return plainValue(file, o.Name)
|
||||
read := plainValue
|
||||
if t, ok := configTypeOf(file); ok {
|
||||
read = t.value
|
||||
}
|
||||
v, err := read(file, o.Name)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
return jasyptValue(file, o.Name, v)
|
||||
}
|
||||
if !ok {
|
||||
var err error
|
||||
if extract, err = extractOf(file, o.Name); err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
|
||||
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
|
||||
@@ -438,6 +478,12 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
|
||||
if o == nil {
|
||||
return f, nil
|
||||
}
|
||||
if t, ok := configTypeOf(path); ok && f.Sops == "" {
|
||||
if k, err := t.keyNamed(path, o.Name); err == nil {
|
||||
f.Line = k.line
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return f, err
|
||||
@@ -589,15 +635,23 @@ func normalizeRemote(remote string) (string, string) {
|
||||
|
||||
// lazysecretsFile is a repo's own .lazysecrets.yaml.
|
||||
type lazysecretsFile struct {
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
Sharing struct {
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
|
||||
Spring FileType `yaml:"spring"`
|
||||
Compose FileType `yaml:"compose"`
|
||||
Dotnet FileType `yaml:"dotnet"`
|
||||
Sharing struct {
|
||||
// Key is the link to the secret that encrypts the repo's
|
||||
// shared env files (package share).
|
||||
Key string `yaml:"key"`
|
||||
} `yaml:"sharing"`
|
||||
}
|
||||
|
||||
func (f lazysecretsFile) types() map[string]FileType {
|
||||
return map[string]FileType{"spring": f.Spring, "compose": f.Compose, "dotnet": f.Dotnet}
|
||||
}
|
||||
|
||||
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
||||
func ShareKey(root string) (string, error) {
|
||||
f, err := readLazysecretsFile(root)
|
||||
@@ -667,7 +721,7 @@ func envFiles(root string, tracked []string) []string {
|
||||
dirs := map[string]bool{".": true}
|
||||
for _, f := range tracked {
|
||||
isTracked[f] = true
|
||||
if IsEnv(f) {
|
||||
if isEnvNamed(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
||||
@@ -681,7 +735,7 @@ func envFiles(root string, tracked []string) []string {
|
||||
}
|
||||
for _, e := range entries {
|
||||
f := path.Join(d, e.Name())
|
||||
if e.Type().IsRegular() && !isTracked[f] && IsEnv(f) {
|
||||
if e.Type().IsRegular() && !isTracked[f] && isEnvNamed(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
@@ -716,16 +770,29 @@ func inPackages(f string) bool {
|
||||
// .env, .env.local, prod.env; not .env.example and the like, nor one in
|
||||
// dependencies.
|
||||
func IsEnv(f string) bool {
|
||||
if inPackages(f) {
|
||||
return false
|
||||
}
|
||||
return isEnvNamed(f) && !isExample(f)
|
||||
}
|
||||
|
||||
// isEnvNamed reports whether a file outside dependencies is named like an
|
||||
// env file, examples too.
|
||||
func isEnvNamed(f string) bool {
|
||||
base := strings.ToLower(path.Base(f))
|
||||
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
|
||||
if strings.HasSuffix(base, ext) {
|
||||
return false
|
||||
return !inPackages(f) && (base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env"))
|
||||
}
|
||||
|
||||
var exampleWords = []string{"example", "sample", "template", "dist", "tmpl"}
|
||||
|
||||
// isExample reports whether a file is an example or template of another:
|
||||
// .env.example, docker-compose.example.yml. They're listed hidden: someone
|
||||
// may have put a real value in one.
|
||||
func isExample(f string) bool {
|
||||
base := strings.ToLower(path.Base(f))
|
||||
for _, w := range exampleWords {
|
||||
if strings.HasSuffix(base, "."+w) || strings.Contains(base, "."+w+".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
|
||||
return false
|
||||
}
|
||||
|
||||
// plainValue reads a key from a plain env file.
|
||||
@@ -1021,7 +1088,8 @@ type key struct {
|
||||
name string // db.password, list[0], with . [ ] \ in keys escaped
|
||||
extract string // sops --extract: ["db"]["password"]
|
||||
encrypted bool
|
||||
line int // 1-based
|
||||
jasypt bool // a plain env file's ENC(...) value
|
||||
line int // 1-based
|
||||
}
|
||||
|
||||
// parseKeys lists a sops file's keys without decrypting anything.
|
||||
@@ -1045,7 +1113,8 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
if !ok || strings.HasPrefix(k, "sops_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
|
||||
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
|
||||
}
|
||||
return out, nil
|
||||
case "ini":
|
||||
@@ -1110,48 +1179,23 @@ func escape(k string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
|
||||
}
|
||||
|
||||
// extractFor rebuilds the extract path from a key name, for objects made
|
||||
// from a link (db.password, list[0], app\.name). A dotenv file is flat:
|
||||
// its names are the keys as they are.
|
||||
func extractFor(file, name string) string {
|
||||
if formatOf(file) == "dotenv" {
|
||||
return quote(name)
|
||||
// extractOf is the sops --extract path of the key named name, for objects
|
||||
// made from a link.
|
||||
func extractOf(file, name string) (string, error) {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b, part strings.Builder
|
||||
flush := func() {
|
||||
if part.Len() > 0 {
|
||||
b.WriteString(quote(part.String()))
|
||||
part.Reset()
|
||||
keys, err := parseKeys(file, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.name == name {
|
||||
return k.extract, nil
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
switch c := name[i]; c {
|
||||
case '\\':
|
||||
if i+1 < len(name) {
|
||||
i++
|
||||
}
|
||||
part.WriteByte(name[i])
|
||||
case '.':
|
||||
flush()
|
||||
case '[':
|
||||
end := strings.IndexByte(name[i:], ']')
|
||||
if end < 0 || !digits(name[i+1:i+end]) {
|
||||
part.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
b.WriteString(name[i : i+end+1])
|
||||
i += end
|
||||
default:
|
||||
part.WriteByte(c)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func digits(s string) bool {
|
||||
return s != "" && strings.Trim(s, "0123456789") == ""
|
||||
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||
}
|
||||
|
||||
func absPath(p string) string {
|
||||
|
||||
@@ -237,6 +237,25 @@ func TestDecrypt(t *testing.T) {
|
||||
t.Errorf("from a link: %q %v", v.Reveal(), err)
|
||||
}
|
||||
|
||||
// A dotenv file's export lines: sops keeps export in the key.
|
||||
os.WriteFile(plain, []byte("export TOKEN=s3cret\n"), 0o600)
|
||||
enc, err = exec.Command("sops", "encrypt", "--age", recipient, "--input-type", "dotenv", "--output-type", "dotenv", plain).Output()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envFile := filepath.Join(root, "prod.env")
|
||||
os.WriteFile(envFile, enc, 0o600)
|
||||
envC := provider.Container{Source: srcs[0], ID: envFile, Name: "prod.env"}
|
||||
envKeys, _ := p.Objects(ctx, envC, provider.TypeSecret)
|
||||
if len(envKeys) != 1 {
|
||||
t.Fatalf("env keys: %v", envKeys)
|
||||
}
|
||||
for _, o := range []provider.Object{envKeys[0], {Container: provider.Container{Source: srcs[0], Name: "prod.env"}, Name: "TOKEN"}} {
|
||||
if v, err := p.Value(ctx, o); err != nil || v.Reveal() != "s3cret" {
|
||||
t.Errorf("export line (link: %t): %q %v", o.ID == "", v.Reveal(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the key: sops' own error.
|
||||
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
|
||||
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
|
||||
@@ -273,9 +292,12 @@ func TestPlainEnvFiles(t *testing.T) {
|
||||
got := map[string]string{}
|
||||
for _, c := range files {
|
||||
got[c.Name] = c.Detail
|
||||
if c.Hidden != (c.Name == ".env.example") {
|
||||
t.Errorf("%s: hidden %t", c.Name, c.Hidden)
|
||||
}
|
||||
}
|
||||
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
|
||||
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
|
||||
if len(got) != 3 || got[".env"] != "env · plain text" || got[".env.example"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
|
||||
t.Fatalf("files %v (want .env, the example, hidden, and the sops file; not node_modules)", got)
|
||||
}
|
||||
|
||||
var env provider.Container
|
||||
@@ -300,7 +322,7 @@ func TestPlainEnvFiles(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
|
||||
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
|
||||
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
|
||||
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
|
||||
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env", ".env.example"}) {
|
||||
t.Errorf("with rules elsewhere: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -554,15 +576,22 @@ func TestKeyNamesEscaped(t *testing.T) {
|
||||
var got []string
|
||||
for _, k := range keys {
|
||||
got = append(got, k.name+" "+k.extract)
|
||||
if e := extractFor("s.yaml", k.name); e != k.extract {
|
||||
t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract)
|
||||
}
|
||||
}
|
||||
want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]`
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want)
|
||||
}
|
||||
if e := extractFor(".env", "spring.url"); e != `["spring.url"]` {
|
||||
t.Errorf("dotenv: %s", e)
|
||||
|
||||
keys, err = parseKeys(".env", []byte("spring.url=a\nexport TOKEN=b\nSPACED = c\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = nil
|
||||
for _, k := range keys {
|
||||
got = append(got, k.name+" "+k.extract)
|
||||
}
|
||||
want = `spring.url ["spring.url"]|TOKEN ["export TOKEN"]|SPACED ["SPACED "]`
|
||||
if strings.Join(got, "|") != want {
|
||||
t.Errorf("dotenv keys = %s\nwant %s", strings.Join(got, "|"), want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// Spring Boot config files: application*.properties/.yml and bootstrap*,
|
||||
// their keys filtered by the secret patterns (patterns.go).
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
var springType = configType{
|
||||
name: "spring", is: IsSpring, format: springFormat, parse: parseSpring,
|
||||
jasyptSetting: func(string) string { return "jasypt.encryptor.password" },
|
||||
placeholder: springPlaceholder,
|
||||
}
|
||||
|
||||
// springPlaceholderRe is Spring's ${name} or ${name:default}.
|
||||
var springPlaceholderRe = regexp.MustCompile(`^\$\{([^:}]+)(:([^}]*))?\}$`)
|
||||
|
||||
func springPlaceholder(v string) (name, def string, hasDef, ok bool) {
|
||||
m := springPlaceholderRe.FindStringSubmatch(v)
|
||||
if m == nil {
|
||||
return "", "", false, false
|
||||
}
|
||||
return m[1], m[3], m[2] != "", true
|
||||
}
|
||||
|
||||
func parseSpring(file string, b []byte) ([]configKey, error) {
|
||||
var keys []configKey
|
||||
var err error
|
||||
if springFormat(file) == "properties" {
|
||||
keys = parseProperties(b)
|
||||
} else {
|
||||
keys, err = 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
|
||||
// after the profile they're for, so each name is one key:
|
||||
// spring.datasource.password (prod).
|
||||
func springDocs(docs [][]configKey) []configKey {
|
||||
var out []configKey
|
||||
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) ([]configKey, 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 [][]configKey
|
||||
for {
|
||||
var doc yaml.Node
|
||||
err := dec.Decode(&doc)
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var keys []configKey
|
||||
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 *[]configKey) {
|
||||
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, configKey{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) []configKey {
|
||||
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
|
||||
var docs [][]configKey
|
||||
var doc []configKey
|
||||
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, configKey{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()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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 []configKey) 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 := (&Provider{}).filterFor(t.TempDir(), "spring")
|
||||
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(key, 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(key, 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(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)
|
||||
`,
|
||||
"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]
|
||||
|
||||
listed, _ := p.Objects(ctx, yml, provider.TypeSecret)
|
||||
keys := shown(listed)
|
||||
var names []string
|
||||
for _, k := range keys {
|
||||
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
|
||||
}
|
||||
if len(listed) != 5 || !listed[0].Hidden || listed[0].Name != "server.port" {
|
||||
t.Errorf("hidden keys: %+v", listed)
|
||||
}
|
||||
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
|
||||
t.Errorf("keys %v, want %v", names, want)
|
||||
}
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" {
|
||||
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["hidden 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, SecretPatterns: SecretPatterns{Keys: &all}})
|
||||
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 {
|
||||
t.Errorf("every key: %d", len(shown(keys)))
|
||||
}
|
||||
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
|
||||
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"url$\"]\n values: []\n"), 0o644)
|
||||
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 1 || shown(keys)[0].Name != "spring.datasource.url" {
|
||||
t.Errorf("repo's patterns: %v", keys)
|
||||
}
|
||||
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"(\"]\n"), 0o644)
|
||||
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.secretPatterns.keys[0]") {
|
||||
t.Errorf("bad pattern: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func shown(objects []provider.Object) []provider.Object {
|
||||
return slices.DeleteFunc(slices.Clone(objects), func(o provider.Object) bool { return o.Hidden })
|
||||
}
|
||||
|
||||
func TestHiddenFiles(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"a/application.yml": "server:\n port: 8080\n",
|
||||
"b/application.yml": "spring.datasource.password: x\n",
|
||||
})
|
||||
ctx := context.Background()
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(ctx)
|
||||
if len(srcs) != 1 || srcs[0].Hidden {
|
||||
t.Fatalf("sources: %+v", srcs)
|
||||
}
|
||||
cs, _ := p.Containers(ctx, srcs[0])
|
||||
if len(cs) != 2 || !cs[0].Hidden || cs[1].Hidden {
|
||||
t.Errorf("files: %+v", cs)
|
||||
}
|
||||
|
||||
// A repo with only hidden files is hidden too.
|
||||
root = gitRepo(t, map[string]string{"application.yml": "server:\n port: 8080\n"})
|
||||
srcs, _ = New(Settings{Dir: root}).Sources(ctx)
|
||||
if len(srcs) != 1 || !srcs[0].Hidden {
|
||||
t.Errorf("hidden repo: %+v", srcs)
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,7 @@ type Model struct {
|
||||
// provider finds it (provider.Current), before Sources lists them all.
|
||||
current *provider.Source
|
||||
flatSources bool // Sources pane without provider groups
|
||||
showHidden bool // H: what providers hide is listed too, dimmed
|
||||
sourcesFavs bool // Sources pane on its Favourites tab
|
||||
history bool // count uses of items (usage.go)
|
||||
onActive func()
|
||||
@@ -469,6 +470,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
Filter: func(c provider.Container) string { return c.Name },
|
||||
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
|
||||
Render: m.containerRender(false),
|
||||
Hidden: func(c provider.Container) bool { return c.Hidden },
|
||||
})
|
||||
|
||||
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
|
||||
@@ -478,6 +480,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
// Typing "expir" finds what has expired or soon will.
|
||||
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
|
||||
Render: m.objectRender(),
|
||||
Hidden: func(o provider.Object) bool { return o.Hidden },
|
||||
})
|
||||
m.secrets.Empty = "no secrets"
|
||||
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
|
||||
@@ -979,6 +982,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
m.cycleZoom(-1)
|
||||
case key.Matches(msg, k.SourcesView):
|
||||
m.toggleSourcesView()
|
||||
case key.Matches(msg, k.ToggleHidden):
|
||||
m.toggleHidden()
|
||||
case key.Matches(msg, k.Versions):
|
||||
if m.versionsOf != nil {
|
||||
m.closeVersions()
|
||||
|
||||
@@ -1188,7 +1188,7 @@ func TestStatusLineCountsRepos(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t, fake.Azure(0), fake.Repos())
|
||||
// The status line, not the pane, which an expired secret's ⚠ fills.
|
||||
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 3 repos") {
|
||||
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "1 account · 4 repos") {
|
||||
t.Errorf("status line: %q", line)
|
||||
}
|
||||
d = newDriver(t, fake.Azure(0))
|
||||
@@ -1344,3 +1344,34 @@ func TestErrorLog(t *testing.T) {
|
||||
t.Errorf("log %+v", d.m.errLog)
|
||||
}
|
||||
}
|
||||
|
||||
// hiding marks the secret db-password hidden, as the repo provider does
|
||||
// a Spring key that doesn't look secret.
|
||||
type hiding struct{ provider.Provider }
|
||||
|
||||
func (h hiding) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
objs, err := h.Provider.Objects(ctx, c, t)
|
||||
for i := range objs {
|
||||
objs[i].Hidden = objs[i].Name == "db-password"
|
||||
}
|
||||
return objs, err
|
||||
}
|
||||
|
||||
func TestToggleHidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t, hiding{fake.Azure(0)})
|
||||
if _, _, s := d.selected(); s == "db-password" || s == "" {
|
||||
t.Fatalf("selected %q: the hidden one, or none", s)
|
||||
}
|
||||
if strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "1 hidden") {
|
||||
t.Fatalf("hidden one listed:\n%s", d.screen())
|
||||
}
|
||||
d.keys("H")
|
||||
if !strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "showing hidden") {
|
||||
t.Fatalf("H doesn't show it:\n%s", d.screen())
|
||||
}
|
||||
d.keys("H")
|
||||
if strings.Contains(d.screen(), "db-password") {
|
||||
t.Fatalf("H again doesn't hide it:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||
@@ -96,24 +97,19 @@ func (m *Model) refreshContext(key, name string, path []string, src provider.Sou
|
||||
// containerRender draws a vault or scope, starred or not. In the
|
||||
// Opened links list it also names the source, since they come from many.
|
||||
func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
|
||||
st, iconSet := m.store, m.icons
|
||||
st := m.store
|
||||
return func(sty styles.Styles, c provider.Container, width int) string {
|
||||
n := st.Node(storeKey(c))
|
||||
line := c.Name
|
||||
if c.Source.Kind == provider.KindRepo {
|
||||
// A secret file: a lock for an encrypted one, a file in the
|
||||
// warning colour for plain text, and its type after it
|
||||
// ("sops · age", "env · plain text").
|
||||
icon, style := iconSet.Encrypted, sty.KindRepo
|
||||
if !strings.HasPrefix(c.Detail, "sops") {
|
||||
icon, style = iconSet.PlainFile, sty.Warning
|
||||
}
|
||||
if icon != "" {
|
||||
line = style.Render(icon) + " " + line
|
||||
// A secret file: its icon, and its type after it ("sops ·
|
||||
// age", "env · plain text").
|
||||
if icon := m.fileIcon(sty, c); icon != "" {
|
||||
line = icon + " " + line
|
||||
}
|
||||
line += sty.Muted.Render(" " + c.Detail)
|
||||
}
|
||||
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
|
||||
line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
|
||||
if withSource {
|
||||
src := c.Source.Name
|
||||
if account := accountOf(c.Source); account != "" {
|
||||
@@ -134,6 +130,10 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
||||
line = sty.Muted.Render(o.Name + " (disabled)")
|
||||
}
|
||||
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
||||
if o.Detail != "" {
|
||||
line += sty.Muted.Render(" " + o.Detail)
|
||||
}
|
||||
line = dimIf(sty, o.Hidden, line)
|
||||
switch state, left := m.expiry(o); state {
|
||||
case expiry.Expired:
|
||||
line += " " + sty.Error.Render("✗ expired")
|
||||
@@ -144,6 +144,39 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// fileIcon is a repo file's icon, in its colour: a lock for an encrypted
|
||||
// one, a leaf for Spring config, a whale for compose, .NET's for
|
||||
// appsettings, a file in the warning colour for plain text; "" for other vaults, or without icons.
|
||||
func (m Model) fileIcon(sty styles.Styles, c provider.Container) string {
|
||||
if c.Source.Kind != provider.KindRepo {
|
||||
return ""
|
||||
}
|
||||
icon, style := m.icons.Encrypted, sty.KindRepo
|
||||
switch {
|
||||
case strings.HasPrefix(c.Detail, "spring"):
|
||||
icon, style = m.icons.Spring, sty.Spring
|
||||
case strings.HasPrefix(c.Detail, "compose"):
|
||||
icon, style = m.icons.Compose, sty.Compose
|
||||
case strings.HasPrefix(c.Detail, "dotnet"):
|
||||
icon, style = m.icons.Dotnet, sty.Dotnet
|
||||
case !strings.HasPrefix(c.Detail, "sops"):
|
||||
icon, style = m.icons.PlainFile, sty.Warning
|
||||
}
|
||||
if icon == "" {
|
||||
return ""
|
||||
}
|
||||
return style.Render(icon)
|
||||
}
|
||||
|
||||
// dimIf draws a hidden item's row (shown with H) in the muted colour,
|
||||
// whole, so it stands apart from what's listed by the provider's rules.
|
||||
func dimIf(sty styles.Styles, hidden bool, line string) string {
|
||||
if !hidden {
|
||||
return line
|
||||
}
|
||||
return sty.Muted.Render(ansi.Strip(line))
|
||||
}
|
||||
|
||||
// starredFirst moves starred items to the front, keeping the order
|
||||
// otherwise. It doesn't modify items, which may be cached.
|
||||
func starredFirst[T any](items []T, starred func(T) bool) []T {
|
||||
|
||||
+41
-8
@@ -38,14 +38,16 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
||||
return nil
|
||||
}
|
||||
var out []entry
|
||||
detail := m.objectDetail(key)
|
||||
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
|
||||
account := ""
|
||||
if p, ok := m.byID[acct]; ok {
|
||||
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
|
||||
}
|
||||
account = strings.Trim(detail+" · "+account, " ·")
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
out = append(out, entry{depth, sort, option{
|
||||
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
|
||||
label: m.pathLabel(r.Kind, path, account, m.refFileIcon(r)), note: n.Note, mark: " ",
|
||||
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
|
||||
plain: m.inPlainEnv(r),
|
||||
run: func(m *Model) tea.Cmd {
|
||||
@@ -126,12 +128,15 @@ const recentMax = 20
|
||||
|
||||
// pathLabel draws a jump list entry: the provider's icon and the path in
|
||||
// its colour, then the account.
|
||||
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
|
||||
func (m Model) pathLabel(kind provider.Kind, path []string, account, fileIcon string) string {
|
||||
style := m.st.Kind(kind)
|
||||
parts := make([]string, len(path))
|
||||
for i, p := range path {
|
||||
parts[i] = style.Render(p)
|
||||
}
|
||||
if fileIcon != "" && len(parts) > 1 {
|
||||
parts[1] = fileIcon + " " + parts[1]
|
||||
}
|
||||
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
|
||||
if account != "" {
|
||||
label += m.st.Muted.Render(" · " + account)
|
||||
@@ -142,25 +147,53 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
|
||||
// inPlainEnv reports whether r is a key in a plain env file, as far as
|
||||
// the files loaded tell.
|
||||
func (m Model) inPlainEnv(r ref.Ref) bool {
|
||||
if r.Kind != provider.KindRepo || r.Name == "" {
|
||||
return false
|
||||
c, ok := m.repoFile(r)
|
||||
return ok && r.Name != "" && c.Detail == provider.DetailPlainEnv
|
||||
}
|
||||
|
||||
// repoFile is the repo file r is or is in, as far as the files loaded
|
||||
// tell.
|
||||
func (m Model) repoFile(r ref.Ref) (provider.Container, bool) {
|
||||
if r.Kind != provider.KindRepo || r.IsSource() {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
file := r
|
||||
file.Name, file.Type = "", ""
|
||||
for _, e := range m.cache.containers {
|
||||
for _, c := range e.value {
|
||||
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
|
||||
return true
|
||||
if storeKey(c) == file.String() {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return provider.Container{}, false
|
||||
}
|
||||
|
||||
// refFileIcon is the icon of the repo file r is or is in, if it's loaded.
|
||||
func (m Model) refFileIcon(r ref.Ref) string {
|
||||
if c, ok := m.repoFile(r); ok {
|
||||
return m.fileIcon(m.st, c)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// objectDetail is the Detail of the secret with the store key key, as far
|
||||
// as the lists loaded tell.
|
||||
func (m Model) objectDetail(key string) string {
|
||||
for _, e := range m.cache.objects {
|
||||
for _, o := range e.value {
|
||||
if o.Detail != "" && objectKey(o) == key {
|
||||
return o.Detail
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
|
||||
// icon and colour, "app / apps/api/.env".
|
||||
func (m Model) repoLabel(repo string, file ...string) string {
|
||||
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
|
||||
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "", "")
|
||||
}
|
||||
|
||||
// sourceMatches reports whether src is the subscription or workspace r
|
||||
|
||||
@@ -269,7 +269,7 @@ func (m Model) refLabel(r ref.Ref) string {
|
||||
default:
|
||||
path = append(path, r.ContainerName(), r.Name)
|
||||
}
|
||||
label := m.pathLabel(r.Kind, path, "")
|
||||
label := m.pathLabel(r.Kind, path, "", m.refFileIcon(r))
|
||||
var extra []string
|
||||
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
|
||||
if r.IsContainer() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
@@ -57,49 +58,63 @@ func (m *Model) searchEntries() (sources, items []option) {
|
||||
var es []entry
|
||||
// key is the store key, so ctrl+f stars the entry; mark shows how
|
||||
// much is loaded, reload (ctrl+r) loads it again.
|
||||
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option {
|
||||
add := func(depth int, row string, kind provider.Kind, path []string, account, fileIcon, key, mark string, run, reload func(*Model) tea.Cmd) *option {
|
||||
n := m.store.Node(key)
|
||||
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
|
||||
es = append(es, entry{depth, sort,
|
||||
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
|
||||
option{label: m.pathLabel(kind, path, account, fileIcon), note: n.Note, star: key,
|
||||
starAccount: cachefile.ProviderOf(row), starred: n.StarredFor(cachefile.ProviderOf(row)),
|
||||
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
|
||||
return &es[len(es)-1].opt
|
||||
}
|
||||
// What providers hide is left out, unless H shows it, dimmed.
|
||||
skip := func(hidden bool) bool { return hidden && !m.showHidden }
|
||||
dim := func(hidden bool, e *option) {
|
||||
if hidden {
|
||||
e.label = m.st.Muted.Render(ansi.Strip(e.label))
|
||||
}
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if skip(src.Hidden) {
|
||||
continue
|
||||
}
|
||||
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
||||
dim(src.Hidden, add(0, row, src.Kind, []string{src.Name}, accountOf(src), "", sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
|
||||
m.nav = &nav{sourceRow: row, sourceOnly: true}
|
||||
return nil
|
||||
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
|
||||
}, func(m *Model) tea.Cmd { return m.loadSource(src) }))
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
if skip(c.Hidden) {
|
||||
continue
|
||||
}
|
||||
r, err := ref.ForContainer(c, provider.TypeSecret)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
|
||||
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
||||
dim(c.Hidden, add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), m.fileIcon(m.st, c), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
|
||||
m.startNav(r, actGo, row)
|
||||
return nil
|
||||
}, reload)
|
||||
}, reload))
|
||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||
r, err := ref.ForObject(o, "")
|
||||
if err != nil {
|
||||
if err != nil || skip(o.Hidden) {
|
||||
continue
|
||||
}
|
||||
suffix := accountOf(src)
|
||||
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
|
||||
if t != provider.TypeSecret {
|
||||
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
|
||||
suffix = strings.Trim(typ+" · "+suffix, " ·")
|
||||
}
|
||||
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
|
||||
e := add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, m.fileIcon(m.st, c), objectKey(o), " ", func(m *Model) tea.Cmd {
|
||||
m.startNav(r, actGo, row)
|
||||
return nil
|
||||
}, reload)
|
||||
e.plain = c.Detail == provider.DetailPlainEnv
|
||||
dim(o.Hidden, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
|
||||
return msg
|
||||
}
|
||||
for _, c := range all {
|
||||
if c.Detail == provider.DetailPlainEnv {
|
||||
if c.Detail == provider.DetailPlainEnv && !c.Hidden { // not .env.example
|
||||
cs = append(cs, c)
|
||||
}
|
||||
}
|
||||
|
||||
+55
-35
@@ -59,6 +59,7 @@ func sourceRowID(r sourceRow) string {
|
||||
}
|
||||
|
||||
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
|
||||
render := renderSourceRow(iconSet, mem, recent)
|
||||
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
|
||||
ID: sourceRowID,
|
||||
// Headers have no filter text, so filtering shows just sources.
|
||||
@@ -77,40 +78,48 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
|
||||
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
|
||||
},
|
||||
Render: func(st styles.Styles, r sourceRow, width int) string {
|
||||
node := mem.Node(sourceKey(r.source))
|
||||
kind := st.Kind(r.group)
|
||||
starred := ""
|
||||
if r.kind == rowSource {
|
||||
starred = marks(st, node, r.source.Provider, recent, time.Now())
|
||||
}
|
||||
switch {
|
||||
case r.kind == rowLinks:
|
||||
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
|
||||
case r.kind == rowSource && r.top:
|
||||
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
|
||||
case r.kind == rowSource && r.flat:
|
||||
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
|
||||
if r.account != "" {
|
||||
line += st.Muted.Render(" · " + r.account)
|
||||
}
|
||||
return withNote(st, line+here(st, r.source), node.Note, width)
|
||||
case r.kind == rowSource:
|
||||
// The provider's colour, so long groups stay recognisable.
|
||||
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
|
||||
}
|
||||
arrow := "▾"
|
||||
if r.collapsed {
|
||||
arrow = "▸"
|
||||
}
|
||||
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
|
||||
if r.status == "!" {
|
||||
status = st.Error.Render("(!)")
|
||||
}
|
||||
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
|
||||
return dimIf(st, r.kind == rowSource && r.source.Hidden, render(st, r, width))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// renderSourceRow draws a source, a provider's header or the Opened links
|
||||
// row.
|
||||
func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styles.Styles, sourceRow, int) string {
|
||||
return func(st styles.Styles, r sourceRow, width int) string {
|
||||
node := mem.Node(sourceKey(r.source))
|
||||
kind := st.Kind(r.group)
|
||||
starred := ""
|
||||
if r.kind == rowSource {
|
||||
starred = marks(st, node, r.source.Provider, recent, time.Now())
|
||||
}
|
||||
switch {
|
||||
case r.kind == rowLinks:
|
||||
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
|
||||
case r.kind == rowSource && r.top:
|
||||
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
|
||||
case r.kind == rowSource && r.flat:
|
||||
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
|
||||
if r.account != "" {
|
||||
line += st.Muted.Render(" · " + r.account)
|
||||
}
|
||||
return withNote(st, line+here(st, r.source), node.Note, width)
|
||||
case r.kind == rowSource:
|
||||
// The provider's colour, so long groups stay recognisable.
|
||||
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
|
||||
}
|
||||
arrow := "▾"
|
||||
if r.collapsed {
|
||||
arrow = "▸"
|
||||
}
|
||||
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
|
||||
if r.status == "!" {
|
||||
status = st.Error.Render("(!)")
|
||||
}
|
||||
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildSources lays out the Sources pane from the per-provider results.
|
||||
func (m *Model) rebuildSources() {
|
||||
// While filtering, headers drop out, so rows show their provider and
|
||||
@@ -129,17 +138,16 @@ func (m *Model) rebuildSources() {
|
||||
}
|
||||
// The repo lazysecrets started in gets a row of its own on top, so it
|
||||
// opens on it; flat, it sorts first instead.
|
||||
if src, ok := m.currentSource(); ok && !flat {
|
||||
if src, ok := m.currentSource(); ok && !flat && (!src.Hidden || m.showHidden) {
|
||||
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
kind, id := p.Kind(), p.ID()
|
||||
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
|
||||
r, done := m.sourceResults[id]
|
||||
sources := r.sources
|
||||
if !flat {
|
||||
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
|
||||
}
|
||||
sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool {
|
||||
return (s.Here && !flat) || (s.Hidden && !m.showHidden)
|
||||
})
|
||||
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
|
||||
continue // repos show once some are found to have secret files
|
||||
}
|
||||
@@ -239,6 +247,18 @@ func (m Model) onLinks() bool {
|
||||
return m.sources.SelectedID() == linksRowID
|
||||
}
|
||||
|
||||
// toggleHidden lists what providers hide, dimmed, or leaves it out again.
|
||||
func (m *Model) toggleHidden() {
|
||||
m.showHidden = !m.showHidden
|
||||
m.containers.SetShowHidden(m.showHidden)
|
||||
m.secrets.SetShowHidden(m.showHidden)
|
||||
m.rebuildSources()
|
||||
m.flash = flash{text: "hidden items hidden"}
|
||||
if m.showHidden {
|
||||
m.flash = flash{text: "showing hidden items, dimmed"}
|
||||
}
|
||||
}
|
||||
|
||||
// toggleSourcesView switches between grouped and flat, keeping the cursor
|
||||
// on the same source.
|
||||
func (m *Model) toggleSourcesView() {
|
||||
|
||||
@@ -11,6 +11,9 @@ type Set struct {
|
||||
Repo string
|
||||
Encrypted string // an encrypted file (SOPS)
|
||||
PlainFile string // a plain text secrets file (.env)
|
||||
Spring string // a Spring Boot config file
|
||||
Compose string // a Docker Compose file
|
||||
Dotnet string // a .NET appsettings file
|
||||
Note string // has a note (lazysecrets list)
|
||||
}
|
||||
|
||||
@@ -25,6 +28,9 @@ var NerdFont = Set{
|
||||
Repo: "\U000F02A2", // md-git
|
||||
Encrypted: "\U000F033E", // md-lock
|
||||
PlainFile: "\U000F0214", // md-file
|
||||
Spring: "\U000F032A", // md-leaf
|
||||
Compose: "\U000F0868", // md-docker
|
||||
Dotnet: "\U000F0AAE", // md-dot_net
|
||||
Note: "\U000F09ED",
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ type Map struct {
|
||||
Recent key.Binding
|
||||
Note, Share, ShareMenu, OpenRef key.Binding
|
||||
Search, SourcesView, Commands key.Binding
|
||||
ToggleHidden key.Binding
|
||||
SplitLeft, SplitRight, SplitReset key.Binding
|
||||
Taller, Shorter, ZoomIn, ZoomOut key.Binding
|
||||
Login, Refresh, Help, Quit key.Binding
|
||||
@@ -102,6 +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, "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, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: config file keys (Spring, compose, .NET) 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, "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."),
|
||||
@@ -185,7 +187,7 @@ var defaults = map[string][]string{
|
||||
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
|
||||
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.toggleHidden": {"H"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"},
|
||||
"lists.commands": {"x"},
|
||||
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
||||
|
||||
@@ -335,6 +335,12 @@ func (d Detail) lines(st styles.Styles) []string {
|
||||
}
|
||||
}
|
||||
case ValueShown:
|
||||
if d.value.Reveal() == "" {
|
||||
// Not in the value's colour, nor a word a value could be.
|
||||
field(label, st.Muted.Italic(true).Render("∅ no value (an empty string)"))
|
||||
out = append(out, valueIndent+hints("hide"))
|
||||
break
|
||||
}
|
||||
// Where the dots were, continuing under itself; the keys below.
|
||||
first := true
|
||||
lines, note := valueView(d.value.Reveal())
|
||||
|
||||
@@ -3,6 +3,12 @@ package panes
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func TestWrappedValueKeepsItsStyle(t *testing.T) {
|
||||
@@ -15,3 +21,17 @@ func TestWrappedValueKeepsItsStyle(t *testing.T) {
|
||||
t.Errorf("the style went on past its reset: %q", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyValue(t *testing.T) {
|
||||
d := NewDetail(icons.None)
|
||||
d.SetSize(80, 20)
|
||||
d.ShowSecret(provider.Object{Name: "spring.datasource.password", Type: provider.TypeSecret})
|
||||
d.SetValue(provider.NewSecretValue(nil))
|
||||
if view := ansi.Strip(d.View(styles.Default(), true, "")); !strings.Contains(view, "∅ no value (an empty string)") {
|
||||
t.Errorf("empty value:\n%s", view)
|
||||
}
|
||||
d.SetValue(provider.NewSecretValue([]byte("empty")))
|
||||
if view := ansi.Strip(d.View(styles.Default(), true, "")); strings.Contains(view, "∅") || !strings.Contains(view, "empty") {
|
||||
t.Errorf("the word empty:\n%s", view)
|
||||
}
|
||||
}
|
||||
+34
-10
@@ -22,6 +22,9 @@ type Item[T any] struct {
|
||||
// text the row doesn't show (e.g. the provider of a grouped source).
|
||||
FilterExtra func(T) string
|
||||
Render func(st styles.Styles, v T, width int) string
|
||||
// Hidden, if set, says which items are listed only while the list
|
||||
// shows hidden ones (SetShowHidden).
|
||||
Hidden func(T) bool
|
||||
}
|
||||
|
||||
// List is a scrollable, filterable list pane with loading and error states.
|
||||
@@ -53,6 +56,9 @@ type List[T any] struct {
|
||||
|
||||
input textinput.Model
|
||||
filtering bool
|
||||
|
||||
showHidden bool
|
||||
hidden int // items left out for being hidden
|
||||
}
|
||||
|
||||
func NewList[T any](title string, item Item[T]) List[T] {
|
||||
@@ -77,6 +83,12 @@ func (l *List[T]) SetItems(items []T) {
|
||||
l.applyFilter(prev)
|
||||
}
|
||||
|
||||
// SetShowHidden lists hidden items too, or leaves them out again.
|
||||
func (l *List[T]) SetShowHidden(on bool) {
|
||||
l.showHidden = on
|
||||
l.applyFilter(l.SelectedID())
|
||||
}
|
||||
|
||||
// Reset clears items, error, filter and cursor.
|
||||
func (l *List[T]) Reset() {
|
||||
l.items, l.visible = nil, nil
|
||||
@@ -237,21 +249,28 @@ func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
|
||||
// applyFilter recomputes the visible items and puts the cursor back on the
|
||||
// item with ID keep if it is still visible.
|
||||
func (l *List[T]) applyFilter(keep string) {
|
||||
l.visible, l.hits = l.visible[:0], nil
|
||||
if q := l.input.Value(); q == "" {
|
||||
for i := range l.items {
|
||||
l.visible = append(l.visible, i)
|
||||
l.visible, l.hits, l.hidden = l.visible[:0], nil, 0
|
||||
shown := make([]int, 0, len(l.items))
|
||||
for i, v := range l.items {
|
||||
if !l.showHidden && l.item.Hidden != nil && l.item.Hidden(v) {
|
||||
l.hidden++
|
||||
continue
|
||||
}
|
||||
shown = append(shown, i)
|
||||
}
|
||||
if q := l.input.Value(); q == "" {
|
||||
l.visible = append(l.visible, shown...)
|
||||
} else {
|
||||
// The row's own text is the name; FilterExtra (a note, a provider)
|
||||
// is context, matched too but ranked lower.
|
||||
items := make([]match.Item, len(l.items))
|
||||
for i, v := range l.items {
|
||||
items := make([]match.Item, len(shown))
|
||||
for j, i := range shown {
|
||||
v := l.items[i]
|
||||
text := l.item.Filter(v)
|
||||
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
|
||||
items[j] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
|
||||
if l.item.FilterExtra != nil {
|
||||
if extra := l.item.FilterExtra(v); extra != "" {
|
||||
items[i].Text += " " + extra
|
||||
items[j].Text += " " + extra
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,7 +281,7 @@ func (l *List[T]) applyFilter(keep string) {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
}
|
||||
l.visible = append(l.visible, h.Index)
|
||||
l.visible = append(l.visible, shown[h.Index])
|
||||
l.hits = append(l.hits, hits)
|
||||
}
|
||||
}
|
||||
@@ -353,8 +372,10 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
for _, line := range wrapLines(l.Err.Error(), inner-1) {
|
||||
body = append(body, st.Error.Render(" "+line))
|
||||
}
|
||||
case len(l.visible) == 0 && len(l.items) > 0:
|
||||
case len(l.visible) == 0 && len(l.items) > l.hidden:
|
||||
body = append(body, st.Muted.Render(" no matches"))
|
||||
case len(l.visible) == 0 && l.hidden > 0:
|
||||
body = append(body, st.Muted.Render(fmt.Sprintf(" %s (%d hidden)", l.Empty, l.hidden)))
|
||||
case len(l.visible) == 0:
|
||||
body = append(body, st.Muted.Render(" "+l.Empty))
|
||||
default:
|
||||
@@ -376,6 +397,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
if len(l.visible) > 0 {
|
||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
||||
}
|
||||
if l.hidden > 0 {
|
||||
counter = strings.Trim(counter+fmt.Sprintf(" · %d hidden", l.hidden), " ·")
|
||||
}
|
||||
if l.Loading && l.Age != "" && len(l.items) > 0 {
|
||||
counter = "cached " + l.Age + " · " + counter
|
||||
}
|
||||
|
||||
@@ -201,3 +201,41 @@ func TestListHeader(t *testing.T) {
|
||||
t.Errorf("no room, the text:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHidden(t *testing.T) {
|
||||
l := NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
Hidden: func(s string) bool { return strings.HasPrefix(s, "hidden") },
|
||||
})
|
||||
l.SetSize(40, 7)
|
||||
l.SetItems([]string{"shown-a", "hidden-b", "shown-c"})
|
||||
view := ansi.Strip(l.View(styles.Default(), true, ""))
|
||||
if l.Len() != 2 || strings.Contains(view, "hidden-b") || !strings.Contains(view, "1 hidden") {
|
||||
t.Fatalf("hidden listed:\n%s", view)
|
||||
}
|
||||
l.Move(1)
|
||||
l.SetShowHidden(true)
|
||||
if l.Len() != 3 || l.SelectedID() != "shown-c" {
|
||||
t.Fatalf("shown: %d, selected %q", l.Len(), l.SelectedID())
|
||||
}
|
||||
l.StartFilter()
|
||||
l.input.SetValue("hid")
|
||||
l.applyFilter("")
|
||||
if l.Len() != 1 || l.SelectedID() != "hidden-b" {
|
||||
t.Fatalf("filtered with hidden shown: %d %q", l.Len(), l.SelectedID())
|
||||
}
|
||||
|
||||
l = NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s }, Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
Hidden: func(string) bool { return true },
|
||||
})
|
||||
l.Empty = "no secrets"
|
||||
l.SetSize(40, 7)
|
||||
l.SetItems([]string{"a", "b"})
|
||||
if view := ansi.Strip(l.View(styles.Default(), true, "")); !strings.Contains(view, "no secrets (2 hidden)") {
|
||||
t.Fatalf("all hidden:\n%s", view)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ type Styles struct {
|
||||
KindAzure lipgloss.Style
|
||||
KindDatabricks lipgloss.Style
|
||||
KindRepo lipgloss.Style
|
||||
Spring lipgloss.Style // Spring Boot config files
|
||||
Compose lipgloss.Style // Docker Compose files
|
||||
Dotnet lipgloss.Style // .NET appsettings files
|
||||
|
||||
// themed marks the selection colours as set by the theme, so
|
||||
// OnBackground leaves them.
|
||||
@@ -70,6 +73,9 @@ func Default() Styles {
|
||||
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
||||
Spring: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||
Compose: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||
Dotnet: lipgloss.NewStyle().Foreground(lipgloss.BrightMagenta),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ var Roles = map[string]string{
|
||||
"azureColor": "Azure sources and vaults",
|
||||
"databricksColor": "Databricks workspaces and scopes",
|
||||
"repoColor": "repos and their secret files (SOPS)",
|
||||
"springColor": "Spring Boot config files",
|
||||
"composeColor": "Docker Compose files",
|
||||
"dotnetColor": ".NET appsettings files",
|
||||
}
|
||||
|
||||
// FromTheme returns the default styles with the theme's roles applied, and
|
||||
@@ -99,6 +102,12 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
st.KindDatabricks = v.fg(lipgloss.NewStyle())
|
||||
case "repoColor":
|
||||
st.KindRepo = v.fg(lipgloss.NewStyle())
|
||||
case "springColor":
|
||||
st.Spring = v.fg(lipgloss.NewStyle())
|
||||
case "composeColor":
|
||||
st.Compose = v.fg(lipgloss.NewStyle())
|
||||
case "dotnetColor":
|
||||
st.Dotnet = v.fg(lipgloss.NewStyle())
|
||||
}
|
||||
}
|
||||
return st, fg, nil
|
||||
|
||||
+41
-13
@@ -3,26 +3,55 @@
|
||||
# it, tags the commit and pushes both; the tag runs
|
||||
# .gitea/workflows/release.yml, which builds and publishes the release.
|
||||
#
|
||||
# scripts/release.sh minor # or patch, major: after the latest release
|
||||
# scripts/release.sh v0.2.0 # or v0.2.0-rc.1 for a pre-release
|
||||
# scripts/release.sh --dry-run v0.2.0 # print the notes, change nothing
|
||||
# scripts/release.sh --dry-run minor # print the tag and notes, change nothing
|
||||
#
|
||||
# A pre-release is tagged without touching CHANGELOG.md: its changes are
|
||||
# listed under the release that follows.
|
||||
set -euo pipefail
|
||||
|
||||
dry=false
|
||||
if [ "${1:-}" = "--dry-run" ]; then
|
||||
dry=true
|
||||
shift
|
||||
fi
|
||||
tag="${1:?usage: $0 [--dry-run] <tag, e.g. v0.2.0>}"
|
||||
if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "$tag is not vMAJOR.MINOR.PATCH (optionally -suffix)" >&2
|
||||
exit 1
|
||||
fi
|
||||
usage="usage: $0 [--dry-run] <patch|minor|major, or a tag like v0.2.0>"
|
||||
# Options anywhere: minor --dry-run must not release.
|
||||
dry=false tag=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run | -n) dry=true ;;
|
||||
-*) echo "$arg: unknown option. $usage" >&2; exit 1 ;;
|
||||
*)
|
||||
[ -z "$tag" ] || { echo "$arg: one version only. $usage" >&2; exit 1; }
|
||||
tag=$arg
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ -n "$tag" ] || { echo "$usage" >&2; exit 1; }
|
||||
command -v git-cliff >/dev/null || { echo "git-cliff is needed: brew install git-cliff" >&2; exit 1; }
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
git fetch -q origin main --tags
|
||||
|
||||
bumped=""
|
||||
case "$tag" in
|
||||
patch | minor | major)
|
||||
# The latest release, pre-releases left out: v0.3.0-rc.1 isn't one.
|
||||
tags=$(git tag -l 'v*' --sort=-v:refname)
|
||||
last=$(grep -m1 -E '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$tags" || true)
|
||||
[ -n "$last" ] || { echo "No vMAJOR.MINOR.PATCH tag to bump from; give the tag." >&2; exit 1; }
|
||||
IFS=. read -r major minor patch <<<"${last#v}"
|
||||
case "$tag" in
|
||||
major) next="v$((major + 1)).0.0" ;;
|
||||
minor) next="v$major.$((minor + 1)).0" ;;
|
||||
patch) next="v$major.$minor.$((patch + 1))" ;;
|
||||
esac
|
||||
bumped=" ($tag after $last)"
|
||||
tag=$next
|
||||
;;
|
||||
esac
|
||||
if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "$tag is not patch, minor, major or vMAJOR.MINOR.PATCH (optionally -suffix)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$tag$bumped"
|
||||
|
||||
notes=$(git cliff --unreleased --tag "$tag" --strip all)
|
||||
if ! grep -q '^- ' <<<"$notes"; then
|
||||
@@ -40,7 +69,6 @@ if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "The working tree has changes; commit or stash them first." >&2
|
||||
exit 1
|
||||
fi
|
||||
git fetch -q origin main --tags
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
|
||||
echo "main isn't origin/main; pull (or push) first." >&2
|
||||
exit 1
|
||||
@@ -51,7 +79,7 @@ if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
|
||||
fi
|
||||
|
||||
echo
|
||||
read -r -p "Release $tag with these notes? [y/N] " ok
|
||||
read -r -p "Release $tag$bumped with these notes? [y/N] " ok
|
||||
[ "$ok" = y ] || [ "$ok" = Y ] || exit 1
|
||||
|
||||
if [[ "$tag" != *-* ]]; then
|
||||
|
||||
Reference in new issue
Block a user