Compare commits
1
Commits
main
..
713bfe2504
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713bfe2504 |
No files matched your search
@@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# The runner mounts Go, the module cache and the build cache as
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: staticcheck
|
||||
# Pinned, so a new release can't fail CI on its own.
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./...
|
||||
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./...
|
||||
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
@@ -23,12 +23,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout (full history, for the changelog)
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v7
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
# The runner mounts Go, the module cache and the build cache as
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
cat "$RUNNER_TEMP/notes.md"
|
||||
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: v2.18.3
|
||||
|
||||
+28
-8
@@ -1,13 +1,33 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
|
||||
# Build output
|
||||
/lazysecrets
|
||||
|
||||
# Release builds (GoReleaser, see .goreleaser.yaml)
|
||||
/dist/
|
||||
|
||||
# go test -c, coverage profiles
|
||||
*.test
|
||||
*.out
|
||||
|
||||
go.work
|
||||
go.work.sum
|
||||
.env
|
||||
@@ -1,2 +0,0 @@
|
||||
<1+keb@noreply.git.kebcloud.no> <1+keb@noreply.localhost>
|
||||
<3+renovate-bot@noreply.git.kebcloud.no> <3+renovate-bot@noreply.localhost>
|
||||
@@ -2,60 +2,6 @@
|
||||
|
||||
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
|
||||
|
||||
- A link opens lazysecrets with your cache and next to your running instances ([#71](https://git.kebcloud.no/keb/lazysecrets/pulls/71)) ([bb41767](https://git.kebcloud.no/keb/lazysecrets/commit/bb41767cbad00bab4f1824f4e0754cc7abd98c59))
|
||||
|
||||
## v0.2.0 – 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers ([#70](https://git.kebcloud.no/keb/lazysecrets/pulls/70)) ([39454d4](https://git.kebcloud.no/keb/lazysecrets/commit/39454d47a864c8628c50b023aee9539d33be2f44))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Lazysecrets starts from fzf's become, on a non-blocking terminal ([#67](https://git.kebcloud.no/keb/lazysecrets/pulls/67)) ([4480e8f](https://git.kebcloud.no/keb/lazysecrets/commit/4480e8fed00024691091eb7096abb9dc83b8044d))
|
||||
- Local builds are dev-<commit> again, not a Go pseudo-version ([#68](https://git.kebcloud.no/keb/lazysecrets/pulls/68)) ([e825a51](https://git.kebcloud.no/keb/lazysecrets/commit/e825a51b30261b55c536af18fefde79dc6308377))
|
||||
- A wrapped value in the details keeps its colour on every line ([#69](https://git.kebcloud.no/keb/lazysecrets/pulls/69)) ([dbb0eb2](https://git.kebcloud.no/keb/lazysecrets/commit/dbb0eb2bbdf6d695cf0d9ac15a1b513af8ba431d))
|
||||
|
||||
## v0.1.2 – 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- Look for repos in the usual folders, not just ~/git ([#57](https://git.kebcloud.no/keb/lazysecrets/pulls/57)) ([47ef862](https://git.kebcloud.no/keb/lazysecrets/commit/47ef86293d757f965069b1ea100a4d53d5a9b876))
|
||||
- Config grouped by area, with per-provider timeouts and an update interval ([#60](https://git.kebcloud.no/keb/lazysecrets/pulls/60)) ([10d1fc6](https://git.kebcloud.no/keb/lazysecrets/commit/10d1fc63c75fbb91088119a2e81884e44c36c4a3))
|
||||
- The update changelog covers every version you missed ([#61](https://git.kebcloud.no/keb/lazysecrets/pulls/61)) ([75bab25](https://git.kebcloud.no/keb/lazysecrets/commit/75bab2528677086219817ff9bc08a36755c96dcd))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Help and errors name sharing.key and sharing.keyRules, not share.* ([#64](https://git.kebcloud.no/keb/lazysecrets/pulls/64)) ([6b3edd4](https://git.kebcloud.no/keb/lazysecrets/commit/6b3edd45d83b9b2af06a40f1f7e67f35c6861a69))
|
||||
- Unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one ([#66](https://git.kebcloud.no/keb/lazysecrets/pulls/66)) ([a81fcaa](https://git.kebcloud.no/keb/lazysecrets/commit/a81fcaa5a6cef3ce0d07d6195f3e7bbb98808253))
|
||||
|
||||
## v0.1.1 – 2026-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- `e` on the status pane edits the config ([#55](https://git.kebcloud.no/keb/lazysecrets/pulls/55)) ([2044341](https://git.kebcloud.no/keb/lazysecrets/commit/2044341b20d795fa7cadcabb9514631990993e0a))
|
||||
- Share env files as encrypted links ([#54](https://git.kebcloud.no/keb/lazysecrets/pulls/54)) ([4377eb9](https://git.kebcloud.no/keb/lazysecrets/commit/4377eb98bf0dd523d5c4371bf84668412053290e))
|
||||
|
||||
## v0.1.0 – 2026-10-12
|
||||
|
||||
Hello world 👋
|
||||
|
||||
@@ -7,45 +7,47 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
||||
## Main features
|
||||
|
||||
- **Read-only**: lazysecrets never creates, changes or deletes a secret.
|
||||
That's by design and may stay that way.
|
||||
- **Zero setup**: uses the logins you already have (`az`, the Databricks
|
||||
CLI, `sops`, `git`). No config file needed.
|
||||
- **Search everything**: `ctrl+s` searches every source at once, with
|
||||
fzf-style matching.
|
||||
- **Share links**: Share `lazysecrets://` links with colleagues.
|
||||
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it
|
||||
with their own access, from the terminal or with a click in the browser
|
||||
or chat.
|
||||
- **Neovim integration**: via [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||
- **Careful with values**: values stay hidden until you reveal them, and
|
||||
copied values are cleared from the clipboard after 30 seconds.
|
||||
- **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
|
||||
pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
|
||||
for Neovim.
|
||||
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and
|
||||
pipes.
|
||||
- **Favourites**: star the secrets you use most for easy access.
|
||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||
marked, listed together in the status pane.
|
||||
- **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, `.env`, Spring Boot, Docker Compose and .NET appsettings files in your git repos.
|
||||
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos.
|
||||
|
||||
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
|
||||
|
||||
## Install
|
||||
|
||||
On macOS or Linux (arm64 or x86_64):
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
|
||||
```
|
||||
Or
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
Both approaches will install the latest release from [GitHub](https://github.com/karleinarb/lazysecrets/releases) with fallback to
|
||||
It installs the latest release, from GitHub or else
|
||||
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
|
||||
`~/.local/bin` after checking it against the release's checksums.
|
||||
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
|
||||
somewhere else.
|
||||
|
||||
`U` (or `lazysecrets update`) updates in place when there's a newer
|
||||
release.
|
||||
|
||||
lazysecrets looks for a newer release once a day: the footer then shows
|
||||
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
|
||||
`lazysecrets update`).
|
||||
|
||||
Or build it yourself:
|
||||
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
|
||||
@@ -68,44 +70,36 @@ use most:
|
||||
| `l` / `h` | the details of what's selected / back |
|
||||
| `/` | filter the list you're in |
|
||||
| `space` | reveal / hide the value |
|
||||
| `y` | copy the value |
|
||||
| `y` | copy the value (cleared from the clipboard after 30s) |
|
||||
| `ctrl+s` | Go to: search everything and jump there |
|
||||
| `f` / `F` | star / your favourites |
|
||||
| `s` | copy a link to share |
|
||||
| `?` | every key, searchable |
|
||||
| `q` | quit |
|
||||
|
||||
## Integration
|
||||
|
||||
`lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
|
||||
pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
|
||||
|
||||

|
||||
|
||||
## Docs
|
||||
|
||||
- [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, .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
|
||||
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting
|
||||
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts,
|
||||
and opening links from the browser and chat
|
||||
- [Custom commands](docs/custom-commands.md): run your own commands on a
|
||||
secret
|
||||
- [Config](docs/config.md): every option, themes, layered configs, the
|
||||
state file
|
||||
- [Development](docs/development.md): building, tests, how changes land and
|
||||
- [Development](docs/development.md): building, tests, pull requests and
|
||||
releasing
|
||||
|
||||
## Privacy
|
||||
|
||||
lazysecrets never stores, caches or sends your secret values. It keeps the
|
||||
names and metadata of what you've loaded in a cache file readable only by
|
||||
you, to show it at once next time (`storage.cache: false` turns it off). It talks
|
||||
you, to show it at once next time (`cache: false` turns it off). It talks
|
||||
only to the services you use it with, and collects no usage data. The one
|
||||
call of its own is a daily check for a new release, on GitHub or else
|
||||
git.kebcloud.no, which `updates.autoCheck: false` turns off.
|
||||
git.kebcloud.no, which `updateCheck: false` turns off.
|
||||
|
||||
## Repositories
|
||||
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
# Security
|
||||
|
||||
Please report a vulnerability privately, not in a public issue:
|
||||
|
||||
- on GitHub, with **Report a vulnerability** under the
|
||||
[Security tab](https://github.com/karleinarb/lazysecrets/security), or
|
||||
- by email to karl-einarb@outlook.com.
|
||||
|
||||
Say what's affected, how to reproduce it and the version
|
||||
(`lazysecrets --version`). Fixes go into the next release, and only the
|
||||
latest release gets them.
|
||||
@@ -51,8 +51,6 @@ commit_preprocessors = [
|
||||
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
|
||||
# #11 was squash-merged with #10 (the status pane) in it: name both.
|
||||
{ pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" },
|
||||
# #55's title starts with a key, which capitalised reads as another.
|
||||
{ pattern = "^feat: e on the status pane edits the config", replace = "feat: `e` on the status pane edits the config" },
|
||||
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
|
||||
# pr-title.yml); all become "feat: x".
|
||||
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
|
||||
|
||||
+78
-9
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/term"
|
||||
@@ -18,7 +21,7 @@ import (
|
||||
// It ends with a newline only when printing to a terminal, so command
|
||||
// substitution and pipes get the value exactly.
|
||||
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
|
||||
v, _, err := readKey(ctx, providers, st, r)
|
||||
v, err := value(ctx, providers, st, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -30,16 +33,82 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
|
||||
return err
|
||||
}
|
||||
|
||||
// readKey reads what r points at (ref.Read), and the version read. The
|
||||
// account asked for (--account) goes first, else the one that last read
|
||||
// it.
|
||||
func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
|
||||
// value reads what r points at without listing its vault: the vault and
|
||||
// name are enough for Azure, tried with each account (the one that last
|
||||
// read it first); for Databricks the workspace's profile is found by its
|
||||
// host.
|
||||
func value(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, error) {
|
||||
if r.IsSource() || r.IsContainer() {
|
||||
return provider.SecretValue{}, fmt.Errorf("%s points at a %s, not a secret", r, map[bool]string{true: "subscription or workspace", false: "vault or scope"}[r.IsSource()])
|
||||
}
|
||||
if r.Kind != provider.KindAzure && r.Host == "" {
|
||||
return provider.SecretValue{}, errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
|
||||
}
|
||||
key := r
|
||||
key.Version = ""
|
||||
node := st.Node(key.String())
|
||||
account, source := node.Account, node.Source
|
||||
if r.Account != "" && r.Account != account {
|
||||
account, source = r.Account, ""
|
||||
|
||||
type try struct {
|
||||
p provider.Provider
|
||||
o provider.Object
|
||||
}
|
||||
return ref.Read(ctx, providers, r, account, source)
|
||||
var tries []try
|
||||
for _, p := range providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
||||
if r.Kind != provider.KindAzure { // by host: workspaces, repos
|
||||
loc, ok := p.(provider.Locator)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
profile, found, err := loc.Locate(ctx, r.Host)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
|
||||
o.Container = provider.Container{Source: src, Name: r.Scope}
|
||||
// Listing the scopes tells a Key Vault-backed one, whose value
|
||||
// can then be read from the vault if Databricks refuses.
|
||||
if cs, err := p.Containers(ctx, src); err == nil {
|
||||
for _, c := range cs {
|
||||
if c.Name == r.Scope {
|
||||
c.Source = src
|
||||
o.Container = c
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
|
||||
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
||||
}
|
||||
t := try{p, o}
|
||||
if p.ID() == node.Account {
|
||||
tries = append([]try{t}, tries...)
|
||||
} else {
|
||||
tries = append(tries, t)
|
||||
}
|
||||
}
|
||||
if len(tries) == 0 {
|
||||
switch r.Kind {
|
||||
case provider.KindDatabricks:
|
||||
return provider.SecretValue{}, fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
||||
case provider.KindRepo:
|
||||
return provider.SecretValue{}, fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
|
||||
}
|
||||
return provider.SecretValue{}, fmt.Errorf("no %s account to read %s with", r.Kind, r)
|
||||
}
|
||||
var errs []error
|
||||
for _, t := range tries {
|
||||
v, err := t.p.Value(ctx, t.o)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
if len(tries) == 1 {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
||||
}
|
||||
return provider.SecretValue{}, errors.Join(errs...)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
|
||||
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
|
||||
} {
|
||||
r, _ := ref.Parse(link)
|
||||
v, _, err := readKey(context.Background(), providers, st, r)
|
||||
v, err := value(context.Background(), providers, st, r)
|
||||
if err != nil || v.Reveal() != want {
|
||||
t.Errorf("%s: %q, %v", link, v.Reveal(), err)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ func TestValue(t *testing.T) {
|
||||
"{{secrets/app/storage-key}}": "names no workspace",
|
||||
} {
|
||||
r, _ := ref.Parse(link)
|
||||
if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||
t.Errorf("%s: %v, want %q", link, err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
|
||||
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
launch := append(append([]string{exe}, configArgs(configPath)...), "launch")
|
||||
launch := []string{exe}
|
||||
if configPath != defaultConfigPath() {
|
||||
launch = append(launch, "--config", configPath)
|
||||
}
|
||||
launch = append(launch, "launch")
|
||||
if term != "" {
|
||||
launch = append(launch, "--terminal", term)
|
||||
}
|
||||
@@ -184,14 +188,8 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
|
||||
// innerCommand runs lazysecrets on link, with the environment it needs.
|
||||
func innerCommand(configPath, exe, link string) []string {
|
||||
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
|
||||
inner = append(inner, configArgs(configPath)...)
|
||||
if configPath != defaultConfigPath() {
|
||||
inner = append(inner, "--config", configPath)
|
||||
}
|
||||
return append(inner, "open", link)
|
||||
}
|
||||
|
||||
// configArgs passes the config files on, unless they're the default.
|
||||
func configArgs(configPath string) []string {
|
||||
if configPath == defaultConfigPath() {
|
||||
return nil
|
||||
}
|
||||
return []string{"--config", configPath}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/listing"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// listCmd prints every source, vault and secret lazysecrets can find, one
|
||||
// per line, for fzf, editor pickers and scripts:
|
||||
//
|
||||
// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1
|
||||
//
|
||||
// Each line is the kind, a mark (✗ or ⚠, ★, ◷ and the provider's icon),
|
||||
// the name, a status (why it couldn't be read, or that it has expired or
|
||||
// expires soon; else empty), its note (muted, or empty), the account and
|
||||
// the link, tab-separated. The mark has a field of its own so a search can
|
||||
// leave it out (fzf --nth), and ^prod finds names starting with prod. The
|
||||
// name is coloured from the theme,
|
||||
// with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS,
|
||||
// ui.icons); the link never is.
|
||||
//
|
||||
// What's cached prints at once; what's missing or older than --max-age
|
||||
// follows as it loads. A list that can't be read is a line too, as it
|
||||
// fails: the vault or source, then ✗ and why in the theme's error colour.
|
||||
// They're in the output rather than on stderr, which would draw over fzf.
|
||||
func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons, withRecent bool, expiryWarn time.Duration, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("list", flag.ContinueOnError)
|
||||
cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks")
|
||||
maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again")
|
||||
format := fs.String("format", "text", "text: kind, mark, name, status, note, account and link, tab-separated; json: an object per line")
|
||||
color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi")
|
||||
iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)")
|
||||
stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)")
|
||||
recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)")
|
||||
notes := fs.Bool("notes", true, "start a note with a note icon (✎ without --icons) (text; the note is printed either way)")
|
||||
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
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return fmt.Errorf("usage: lazysecrets list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]")
|
||||
}
|
||||
var only []string
|
||||
for k := range strings.SplitSeq(*kinds, ",") {
|
||||
if k = strings.TrimSpace(k); k == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(listing.Kinds, k) {
|
||||
return fmt.Errorf("--kind: %q is not one of %s", k, strings.Join(listing.Kinds, ", "))
|
||||
}
|
||||
only = append(only, k)
|
||||
}
|
||||
if *color != "always" && *color != "never" {
|
||||
return fmt.Errorf("--color: %q is not always or never", *color)
|
||||
}
|
||||
now := time.Now()
|
||||
m := marks{theme: theme, icons: icons.None, stars: *stars, recent: *recent, expiry: *expiry,
|
||||
color: *color == "always", warn: expiryWarn, now: now}
|
||||
if *iconsOn {
|
||||
m.icons = icons.NerdFont
|
||||
}
|
||||
var write func(listing.Entry) error
|
||||
switch *format {
|
||||
case "text":
|
||||
write = func(e listing.Entry) error {
|
||||
colored := *color == "always"
|
||||
mark := m.of(e)
|
||||
name := nameOf(e, theme, colored)
|
||||
// Why it couldn't be read (vaults, sources), or when it
|
||||
// expires (secrets, keys, certificates): never both.
|
||||
status := errorOf(e, theme, colored)
|
||||
if status == "" && *expiry {
|
||||
status = expiryOf(e, theme, expiryWarn, now, colored)
|
||||
}
|
||||
note := noteOf(e, theme, m.icons, *notes, colored)
|
||||
// A tab or line break in a name would break the line up.
|
||||
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(status),
|
||||
oneLine.Replace(note), e.Account, e.Link)
|
||||
return err
|
||||
}
|
||||
case "json":
|
||||
enc := json.NewEncoder(out)
|
||||
enc.SetEscapeHTML(false)
|
||||
write = func(e listing.Entry) error { return enc.Encode(e) }
|
||||
default:
|
||||
return fmt.Errorf("--format: %q is not text or json", *format)
|
||||
}
|
||||
|
||||
// When the reader goes away (a pick in fzf), writing fails instead of
|
||||
// killing the process, so what loaded is still saved.
|
||||
signal.Notify(make(chan os.Signal, 1), syscall.SIGPIPE)
|
||||
var gone error
|
||||
emit := func(e listing.Entry) error {
|
||||
if e.Error != "" && !*showErrors {
|
||||
return nil
|
||||
}
|
||||
if err := write(e); err != nil {
|
||||
gone = err
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
f := cachefile.New()
|
||||
if cachePath != "" {
|
||||
f = cachefile.Read(cachePath)
|
||||
}
|
||||
f, loaded := listing.Run(context.Background(), listing.Options{
|
||||
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),
|
||||
LastUsed: n.LastUsed, Uses: n.Uses, Note: n.Note}
|
||||
},
|
||||
}, emit)
|
||||
// Only what was loaded: list never moves a cursor, so the TUI's stay.
|
||||
if cachePath != "" && loaded {
|
||||
f.Cursor = nil
|
||||
if err := cachefile.Save(cachePath, f, cachefile.Keep(providers), time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if gone != nil && !errors.Is(gone, syscall.EPIPE) {
|
||||
return gone
|
||||
}
|
||||
return nil // or the reader has what it wanted
|
||||
}
|
||||
|
||||
var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ")
|
||||
|
||||
// noteOf is the entry's note, muted, after a note icon (✎ without Nerd
|
||||
// Font icons) with icon; or "".
|
||||
func noteOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, icon, color bool) string {
|
||||
if e.Note == "" {
|
||||
return ""
|
||||
}
|
||||
note := e.Note
|
||||
if icon {
|
||||
note = cmp.Or(iconSet.Note, "✎") + " " + note
|
||||
}
|
||||
return renderer(color)(theme.Muted, note)
|
||||
}
|
||||
|
||||
// expiryOf says that a secret, key or certificate has expired, in red,
|
||||
// or when it will if that's within warn (expires in 12d), in yellow, or
|
||||
// is "". Its mark (✗, ⚠) is before the name.
|
||||
func expiryOf(e listing.Entry, theme styles.Styles, warn time.Duration, now time.Time, color bool) string {
|
||||
render := renderer(color)
|
||||
switch state, left := expiry.Of(e.Expires, now, warn); state {
|
||||
case expiry.Expired:
|
||||
return render(theme.Error, "expired")
|
||||
case expiry.Soon:
|
||||
return render(theme.Warning, "expires in "+expiry.Left(left))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// errorOf is why a list couldn't be read, in red, or "". Its mark (✗)
|
||||
// is before the name.
|
||||
func errorOf(e listing.Entry, theme styles.Styles, color bool) string {
|
||||
return renderer(color)(theme.Error, e.Error)
|
||||
}
|
||||
|
||||
// nameOf is an entry's name for the text format: the path with the
|
||||
// item's own name standing out, and the account.
|
||||
func nameOf(e listing.Entry, theme styles.Styles, color bool) string {
|
||||
render := renderer(color)
|
||||
var name string
|
||||
if n := len(e.Path); n > 0 {
|
||||
if n > 1 {
|
||||
name = render(theme.Muted, strings.Join(e.Path[:n-1], " / ")+" / ")
|
||||
}
|
||||
name += e.Path[n-1]
|
||||
}
|
||||
if e.AccountLabel != "" {
|
||||
name += render(theme.Muted, " · "+e.AccountLabel)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// marks says what goes before the name, in a field of its own so a
|
||||
// search leaves it out: ✗ in red when something's wrong (it couldn't be
|
||||
// read, or has expired) or ⚠ when it expires within warn; ★ for a starred
|
||||
// entry and ◷ for one used lately, as the TUI marks them; then the
|
||||
// provider's icon. The marks are right-aligned in room for every kind
|
||||
// turned on, so they sit by the icon and names line up.
|
||||
type marks struct {
|
||||
theme styles.Styles
|
||||
icons icons.Set
|
||||
stars, recent, expiry, color bool
|
||||
warn time.Duration // ui.expiryWarningDays
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (m marks) of(e listing.Entry) string {
|
||||
render := renderer(m.color)
|
||||
var parts []string
|
||||
room := 0 // marks turned on
|
||||
mark := func(on bool, st lipgloss.Style, s string) {
|
||||
room++
|
||||
if on {
|
||||
parts = append(parts, render(st, s))
|
||||
}
|
||||
}
|
||||
// What's wrong, then what to watch: room for one of them.
|
||||
state := expiry.None
|
||||
if m.expiry {
|
||||
state, _ = expiry.Of(e.Expires, m.now, m.warn)
|
||||
}
|
||||
switch {
|
||||
case e.Error != "" || state == expiry.Expired:
|
||||
mark(true, m.theme.Error, "✗")
|
||||
case state == expiry.Soon:
|
||||
mark(true, m.theme.Warning, "⚠")
|
||||
default:
|
||||
mark(false, m.theme.Error, "")
|
||||
}
|
||||
if m.stars {
|
||||
mark(e.Starred, m.theme.Favourite, "★")
|
||||
}
|
||||
if m.recent {
|
||||
mark(e.Recent, m.theme.Muted, "◷")
|
||||
}
|
||||
pad := strings.Repeat(" ", room-len(parts)) // each mark and its space
|
||||
kind := provider.Kind("")
|
||||
for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} {
|
||||
if strings.EqualFold(string(k), e.Provider) {
|
||||
kind = k
|
||||
}
|
||||
}
|
||||
if icon := strings.TrimSpace(m.icons.Prefix(kind)); icon != "" {
|
||||
parts = append(parts, render(m.theme.Kind(kind), icon))
|
||||
} else if len(parts) == 0 {
|
||||
pad = strings.TrimSuffix(pad, " ") // no mark after the room
|
||||
}
|
||||
return pad + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func renderer(color bool) func(lipgloss.Style, string) string {
|
||||
return func(st lipgloss.Style, s string) string {
|
||||
if !color || s == "" {
|
||||
return s
|
||||
}
|
||||
return st.Render(s)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/listing"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func TestListNames(t *testing.T) {
|
||||
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"}
|
||||
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" {
|
||||
t.Errorf("plain: %q", got)
|
||||
}
|
||||
// The item's own name isn't muted with its path.
|
||||
if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") {
|
||||
t.Errorf("name not set apart: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarks(t *testing.T) {
|
||||
az := icons.NerdFont.Azure
|
||||
plain := marks{theme: styles.Default(), icons: icons.NerdFont, stars: true}
|
||||
noIcons := plain
|
||||
noIcons.icons = icons.None
|
||||
colored := noIcons
|
||||
colored.color = true
|
||||
for _, c := range []struct {
|
||||
m marks
|
||||
e listing.Entry
|
||||
want string
|
||||
}{
|
||||
// Room for the status mark and the star, so names line up.
|
||||
{plain, listing.Entry{Provider: "azure", Starred: true}, " ★ " + az},
|
||||
{plain, listing.Entry{Provider: "azure"}, " " + az},
|
||||
{marks{icons: icons.NerdFont}, listing.Entry{Provider: "azure", Starred: true}, " " + az},
|
||||
{noIcons, listing.Entry{Provider: "azure", Starred: true}, " ★"},
|
||||
{marks{}, listing.Entry{Provider: "azure", Starred: true}, " "},
|
||||
{colored, listing.Entry{Provider: "azure", Starred: true}, " " + styles.Default().Favourite.Render("★")},
|
||||
} {
|
||||
if got := c.m.of(c.e); got != c.want {
|
||||
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRecentMarks(t *testing.T) {
|
||||
az := icons.NerdFont.Azure
|
||||
m := marks{icons: icons.NerdFont, stars: true, recent: true}
|
||||
for _, c := range []struct {
|
||||
e listing.Entry
|
||||
want string
|
||||
}{
|
||||
// Right-aligned in room for three: by the icon, names lined up.
|
||||
{listing.Entry{Provider: "azure", Starred: true, Recent: true}, " ★ ◷ " + az},
|
||||
{listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az},
|
||||
{listing.Entry{Provider: "azure"}, " " + az},
|
||||
} {
|
||||
if got := m.of(c.e); got != c.want {
|
||||
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExpiry(t *testing.T) {
|
||||
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||
warn := 30 * 24 * time.Hour
|
||||
for _, c := range []struct {
|
||||
expires time.Time
|
||||
want string
|
||||
}{
|
||||
{time.Time{}, ""},
|
||||
{now.Add(-time.Hour), "expired"},
|
||||
{now.Add(12*24*time.Hour + time.Hour), "expires in 12d"},
|
||||
{now.Add(5 * time.Hour), "expires in 5h"},
|
||||
{now.Add(60 * 24 * time.Hour), ""},
|
||||
} {
|
||||
if got := expiryOf(listing.Entry{Expires: c.expires}, styles.Default(), warn, now, false); got != c.want {
|
||||
t.Errorf("expires %v: %q, want %q", c.expires, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := expiryOf(listing.Entry{Expires: now.Add(-time.Hour)}, styles.Default(), warn, now, true); got != styles.Default().Error.Render("expired") {
|
||||
t.Errorf("expired isn't red: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNotes(t *testing.T) {
|
||||
e := listing.Entry{Note: "rotate in May"}
|
||||
if got := noteOf(e, styles.Default(), icons.NerdFont, true, false); got != icons.NerdFont.Note+" rotate in May" {
|
||||
t.Errorf("icons: %q", got)
|
||||
}
|
||||
if got := noteOf(e, styles.Default(), icons.None, true, false); got != "✎ rotate in May" {
|
||||
t.Errorf("no icons: %q", got)
|
||||
}
|
||||
if got := noteOf(e, styles.Default(), icons.NerdFont, false, false); got != "rotate in May" {
|
||||
t.Errorf("--notes=false: %q", got)
|
||||
}
|
||||
if got := noteOf(listing.Entry{}, styles.Default(), icons.NerdFont, true, true); got != "" {
|
||||
t.Errorf("no note: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStatusMark(t *testing.T) {
|
||||
az := icons.NerdFont.Azure
|
||||
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||
m := marks{icons: icons.NerdFont, stars: true, expiry: true, warn: 30 * 24 * time.Hour, now: now}
|
||||
for _, c := range []struct {
|
||||
e listing.Entry
|
||||
want string
|
||||
}{
|
||||
// Right-aligned in room for two, as with ★ and ◷.
|
||||
{listing.Entry{Provider: "azure", Error: "403 Forbidden"}, " ✗ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}, " ✗ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour), Starred: true}, "✗ ★ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(5 * 24 * time.Hour)}, " ⚠ " + az},
|
||||
{listing.Entry{Provider: "azure", Expires: now.Add(60 * 24 * time.Hour), Starred: true}, " ★ " + az},
|
||||
} {
|
||||
if got := m.of(c.e); got != c.want {
|
||||
t.Errorf("%+v: %q, want %q", c.e, got, c.want)
|
||||
}
|
||||
}
|
||||
m.expiry = false // --expiry=false: no ⚠ or ✗ for expiry; errors still
|
||||
if got := m.of(listing.Entry{Provider: "azure", Expires: now.Add(-time.Hour)}); got != " "+az {
|
||||
t.Errorf("--expiry=false: %q", got)
|
||||
}
|
||||
m.expiry, m.color, m.theme = true, true, styles.Default()
|
||||
if got := m.of(listing.Entry{Error: "403"}); !strings.Contains(got, styles.Default().Error.Render("✗")) {
|
||||
t.Errorf("not a red ✗: %q", got)
|
||||
}
|
||||
if got := m.of(listing.Entry{Expires: now.Add(time.Hour)}); !strings.Contains(got, styles.Default().Warning.Render("⚠")) {
|
||||
t.Errorf("not a yellow ⚠: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListErrors(t *testing.T) {
|
||||
e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"}
|
||||
if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" {
|
||||
t.Errorf("the name has more: %q", got)
|
||||
}
|
||||
if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" {
|
||||
t.Errorf("plain: %q", got)
|
||||
}
|
||||
if got := errorOf(e, styles.Default(), true); got != styles.Default().Error.Render("403 Forbidden") {
|
||||
t.Errorf("why isn't red: %q", got)
|
||||
}
|
||||
if got := errorOf(listing.Entry{}, styles.Default(), true); got != "" {
|
||||
t.Errorf("no error: %q", got)
|
||||
}
|
||||
}
|
||||
+70
-157
@@ -3,9 +3,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -68,44 +66,25 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
|
||||
if c.Key == "" {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
|
||||
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command))
|
||||
if err := km.CheckExtra(c.Key, name); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range cmds[:i] {
|
||||
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
|
||||
if d.Key == c.Key && overlap {
|
||||
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
|
||||
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refArg parses get's and open's arguments: a reference, and optionally
|
||||
// the account to use when several could (lazysecrets list's column) and,
|
||||
// for open, what to do with it rather than asking (--action).
|
||||
func refArg(cmd string, args []string) (r ref.Ref, action string, err error) {
|
||||
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||
account := fs.String("account", "", "the account to use when several could, as lazysecrets list prints it")
|
||||
usage := "usage: lazysecrets get [--account <account>] <ref>"
|
||||
act := new(string)
|
||||
if cmd == "open" {
|
||||
act = fs.String("action", "", "do this rather than ask: go (to it), copy (go, and copy its value) or show (go, and show it)")
|
||||
usage = "usage: lazysecrets open [--account <account>] [--action go|copy|show] <ref>"
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return r, "", err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return r, "", errors.New(usage)
|
||||
}
|
||||
if _, ok := app.OpenActions[*act]; *act != "" && !ok {
|
||||
return r, "", fmt.Errorf("--action: %q is not go, copy or show", *act)
|
||||
}
|
||||
r, err = ref.Parse(fs.Arg(0))
|
||||
r.Account = *account
|
||||
return r, *act, err
|
||||
return b
|
||||
}
|
||||
|
||||
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
|
||||
@@ -127,29 +106,18 @@ func run() error {
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
dir := flag.String("dir", "", "start in this directory: its git repo's SOPS files, the ones under it first (default: the current directory)")
|
||||
demo := flag.Bool("demo", false, "browse built-in sample data instead of real backends")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)")
|
||||
timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request")
|
||||
icons := flag.Bool("icons", true,
|
||||
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0")
|
||||
clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), `Usage:
|
||||
lazysecrets [flags] browse secrets
|
||||
lazysecrets [flags] [open [--account <account>] [--action go|copy|show]] <ref>
|
||||
open a lazysecrets:// link (also a share link),
|
||||
Key Vault URL, @Microsoft.KeyVault(...) or
|
||||
{{secrets/scope/key}}
|
||||
lazysecrets [flags] get [--account <account>] <ref>
|
||||
print the value a lazysecrets:// link points at,
|
||||
lazysecrets [flags] [open] <ref>
|
||||
open a lazysecrets:// link, Key Vault URL,
|
||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||
for scripts: VAR=$(lazysecrets get <ref>)
|
||||
lazysecrets [flags] list [--kind secret,…] [--cached] [--max-age 2m] [--format text|json]
|
||||
[--color always|never] [--icons=false] [--errors=false]
|
||||
[--stars=false] [--notes=false] [--recent] [--expiry=false]
|
||||
print every source, vault and secret it can
|
||||
find, one per line, for fzf and pickers:
|
||||
what's cached at once, then what loads
|
||||
lazysecrets [flags] share [--key <secret>] <env file>...
|
||||
print a link to env files of a repo, encrypted
|
||||
with a secret's value (default: sharing.key)
|
||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||
config applied), as config to copy and edit
|
||||
lazysecrets handler install [--terminal NAME]
|
||||
@@ -171,22 +139,17 @@ Flags:
|
||||
return nil
|
||||
}
|
||||
|
||||
sub := "" // the subcommand; "" browses
|
||||
var subArgs []string
|
||||
var open *ref.Ref
|
||||
var getRef ref.Ref
|
||||
var openAction string
|
||||
var open, getRef *ref.Ref
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) > 0 && (args[0] == "share" || args[0] == "list"):
|
||||
sub, subArgs = args[0], args[1:]
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
sub = "keybindings"
|
||||
case len(args) > 0 && args[0] == "get":
|
||||
r, _, err := refArg("get", args[1:])
|
||||
printKeys = true
|
||||
case len(args) == 2 && args[0] == "get":
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sub, getRef = "get", r
|
||||
getRef = &r
|
||||
case len(args) > 0 && args[0] == "handler":
|
||||
return handler(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "launch":
|
||||
@@ -194,12 +157,12 @@ Flags:
|
||||
case len(args) > 0 && args[0] == "update":
|
||||
return updateCmd(*configPath, args[1:])
|
||||
case len(args) == 0:
|
||||
case args[0] == "open" && len(args) >= 2:
|
||||
r, act, err := refArg("open", args[1:])
|
||||
case args[0] == "open" && len(args) == 2:
|
||||
r, err := ref.Parse(args[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
open, openAction = &r, act
|
||||
open = &r
|
||||
case len(args) == 1 && looksLikeRef(args[0]):
|
||||
// lazysecrets <link>, like lazysecrets open <link>
|
||||
r, err := ref.Parse(args[0])
|
||||
@@ -220,11 +183,6 @@ Flags:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Warnings show when the window opens.
|
||||
var warnings []string
|
||||
if w := styles.Unknown(cfg.Theme); w != "" {
|
||||
warnings = append(warnings, w)
|
||||
}
|
||||
keymap := keys.Default()
|
||||
if err := keymap.Apply(cfg.Keybinding); err != nil {
|
||||
return err
|
||||
@@ -232,62 +190,40 @@ Flags:
|
||||
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
|
||||
return err
|
||||
}
|
||||
if sub == "keybindings" {
|
||||
if printKeys {
|
||||
fmt.Print(keymap.YAML())
|
||||
return nil
|
||||
}
|
||||
// Precedence: flag > environment > config file > default.
|
||||
set := map[string]bool{}
|
||||
flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
|
||||
if *timeout <= 0 {
|
||||
return fmt.Errorf("--timeout: %s is not positive", *timeout)
|
||||
if !set["timeout"] && cfg.Timeout > 0 {
|
||||
*timeout = cfg.Timeout
|
||||
}
|
||||
// Each provider's own timeout, unless --timeout sets them all.
|
||||
timeouts := map[provider.Kind]time.Duration{}
|
||||
if !set["timeout"] {
|
||||
if cfg.Providers.Timeout > 0 {
|
||||
*timeout = cfg.Providers.Timeout
|
||||
}
|
||||
for k, d := range map[provider.Kind]time.Duration{
|
||||
provider.KindAzure: cfg.Providers.Azure.Timeout,
|
||||
provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
|
||||
provider.KindRepo: cfg.Providers.Repos.Timeout,
|
||||
} {
|
||||
if d > 0 {
|
||||
timeouts[k] = d
|
||||
}
|
||||
}
|
||||
if !set["clipboard-clear"] && cfg.ClipboardClear != nil {
|
||||
*clipClear = *cfg.ClipboardClear
|
||||
}
|
||||
// timeoutFor is how long a request to a provider of kind k may take.
|
||||
timeoutFor := func(k provider.Kind) time.Duration { return cmp.Or(timeouts[k], *timeout) }
|
||||
if !set["clipboard-clear"] {
|
||||
*clipClear = valueOr(cfg.Values.ClipboardClear, *clipClear)
|
||||
expiryDays := 30
|
||||
if cfg.ExpiryWarningDays != nil {
|
||||
expiryDays = *cfg.ExpiryWarningDays
|
||||
}
|
||||
revealTimeout := 60 * time.Second
|
||||
if cfg.RevealTimeout != nil {
|
||||
revealTimeout = *cfg.RevealTimeout
|
||||
}
|
||||
loadDelay := 150 * time.Millisecond
|
||||
if cfg.LoadDelay != nil {
|
||||
loadDelay = *cfg.LoadDelay
|
||||
}
|
||||
expiryWarning := time.Duration(valueOr(cfg.UI.ExpiryWarningDays, 30)) * 24 * time.Hour
|
||||
revealTimeout := valueOr(cfg.Values.RevealTimeout, 60*time.Second)
|
||||
loadDelay := valueOr(cfg.UI.LoadDelay, 150*time.Millisecond)
|
||||
updateEvery := valueOr(cfg.Updates.AutoCheckInterval, 0) // 0: the app's default, a day
|
||||
if !set["icons"] {
|
||||
switch {
|
||||
case os.Getenv("LAZYSECRETS_ICONS") != "":
|
||||
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
|
||||
case cfg.UI.Icons != nil:
|
||||
*icons = *cfg.UI.Icons
|
||||
case cfg.Icons != nil:
|
||||
*icons = *cfg.Icons
|
||||
}
|
||||
}
|
||||
|
||||
recentIcon := valueOr(cfg.UI.RecentIcon, false)
|
||||
|
||||
startDir := *dir
|
||||
if startDir == "" {
|
||||
startDir, _ = os.Getwd()
|
||||
}
|
||||
// What was loaded last time.
|
||||
cacheFile := ""
|
||||
if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) {
|
||||
cacheFile = filepath.Join(d, "cache.json")
|
||||
}
|
||||
|
||||
// Stars, notes and the layout. Demo mode keeps them in memory, so
|
||||
// sample vaults never end up in the real file.
|
||||
stateDir, err := config.StateDir()
|
||||
@@ -299,23 +235,16 @@ Flags:
|
||||
statePath = filepath.Join(stateDir, "state.json")
|
||||
}
|
||||
st, err := store.Load(statePath)
|
||||
if errors.Is(err, store.ErrNewer) {
|
||||
warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets")
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var providers []provider.Provider
|
||||
if *demo {
|
||||
// Two Azure accounts, like a personal and an admin login in az.
|
||||
alice, admin := "Azure:alice@example.com/demo", "Azure:admin.alice@example.com/demo"
|
||||
fake.Seed(st, alice, admin)
|
||||
az := fake.Azure(400*time.Millisecond).As(alice, "Azure · alice")
|
||||
// Due soon whenever the demo runs (the tests' dates are fixed).
|
||||
az.Expire("kv-app-prod", "api-key", time.Now().AddDate(0, 0, 9).Truncate(24*time.Hour))
|
||||
providers = []provider.Provider{
|
||||
az,
|
||||
fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
|
||||
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"),
|
||||
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"),
|
||||
fake.Databricks(600 * time.Millisecond),
|
||||
fake.Repos(),
|
||||
}
|
||||
@@ -344,10 +273,17 @@ Flags:
|
||||
providers = append(providers, dbx)
|
||||
}
|
||||
if config.On(pc.Repos.Enabled) {
|
||||
start := *dir
|
||||
if start == "" {
|
||||
start, _ = os.Getwd()
|
||||
}
|
||||
roots := []string{"~/git"}
|
||||
if pc.Repos.Roots != nil {
|
||||
roots = *pc.Repos.Roots
|
||||
}
|
||||
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},
|
||||
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st),
|
||||
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
|
||||
})
|
||||
// The repo it started in (shown on top as Repo) and the
|
||||
// others, checked for secret files in the background like
|
||||
@@ -363,23 +299,10 @@ Flags:
|
||||
// the standard logger, which would draw over the TUI.
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
switch sub {
|
||||
case "get":
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
|
||||
if getRef != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
return get(ctx, providers, st, getRef)
|
||||
case "list":
|
||||
return listCmd(providers, st, cacheFile, timeoutFor, theme, *icons, recentIcon, expiryWarning, subArgs, os.Stdout)
|
||||
case "share":
|
||||
// The files from a repo, the key from whichever provider it's
|
||||
// in: the longest timeout.
|
||||
d := *timeout
|
||||
for _, t := range timeouts {
|
||||
d = max(d, t)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), d)
|
||||
defer cancel()
|
||||
return shareFiles(ctx, providers, st, cfg.Sharing.Key, shareRules(cfg), subArgs, os.Stdout)
|
||||
return get(ctx, providers, st, *getRef)
|
||||
}
|
||||
|
||||
// Links opened elsewhere (lazysecrets launch) can come here.
|
||||
@@ -413,10 +336,16 @@ Flags:
|
||||
onActive = srv.Touch
|
||||
}
|
||||
|
||||
// Where it was left in this folder.
|
||||
placeDir := ""
|
||||
// What was loaded last time, and where it was left in this folder.
|
||||
var cacheFile, placeDir string
|
||||
if !*demo {
|
||||
placeDir = startDir
|
||||
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) {
|
||||
cacheFile = filepath.Join(d, "cache.json")
|
||||
}
|
||||
placeDir = *dir
|
||||
if placeDir == "" {
|
||||
placeDir, _ = os.Getwd()
|
||||
}
|
||||
if abs, err := filepath.Abs(placeDir); err == nil {
|
||||
placeDir = abs
|
||||
}
|
||||
@@ -424,40 +353,32 @@ Flags:
|
||||
|
||||
m := app.New(providers, app.Options{
|
||||
Timeout: *timeout,
|
||||
Timeouts: timeouts,
|
||||
Icons: *icons,
|
||||
ClipboardClear: *clipClear,
|
||||
FlatSources: cfg.UI.SourcesView == "flat",
|
||||
CollapseGroups: config.On(cfg.UI.CollapseGroups),
|
||||
FlatSources: cfg.SourcesView == "flat",
|
||||
CollapseGroups: config.On(cfg.CollapseGroups),
|
||||
LoadDelay: loadDelay,
|
||||
NoHistory: !config.On(cfg.Storage.History),
|
||||
NoHistory: !config.On(cfg.History),
|
||||
OnActive: onActive,
|
||||
RevealTimeout: revealTimeout,
|
||||
ExpiryWarning: expiryWarning,
|
||||
HideOnBlur: config.On(cfg.Values.HideOnBlur),
|
||||
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour,
|
||||
HideOnBlur: config.On(cfg.HideOnBlur),
|
||||
HandlerSetup: setup,
|
||||
Updater: updater(cfg.Updates.Sources),
|
||||
UpdateCheck: config.On(cfg.Updates.AutoCheck),
|
||||
UpdateEvery: updateEvery,
|
||||
Updater: updater(cfg.Releases),
|
||||
UpdateCheck: config.On(cfg.UpdateCheck),
|
||||
Keys: &keymap,
|
||||
Commands: cfg.CustomCommands,
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
Warning: strings.Join(warnings, " · "),
|
||||
CacheFile: cacheFile,
|
||||
ConfigFiles: configFiles(*configPath),
|
||||
StatePath: statePath,
|
||||
Changelog: lazysecrets.Changelog,
|
||||
Dir: placeDir,
|
||||
ShareKey: cfg.Sharing.Key,
|
||||
ShareRules: shareRules(cfg),
|
||||
Open: open,
|
||||
OpenAction: openAction,
|
||||
RecentIcon: recentIcon,
|
||||
Styles: &theme,
|
||||
DefaultFg: defaultFg,
|
||||
})
|
||||
blockingStdin()
|
||||
p = tea.NewProgram(m)
|
||||
final, err := p.Run()
|
||||
if fm, ok := final.(app.Model); ok {
|
||||
@@ -466,14 +387,6 @@ Flags:
|
||||
return err
|
||||
}
|
||||
|
||||
// valueOr is *p, or def when p is nil (not set in the config).
|
||||
func valueOr[T any](p *T, def T) T {
|
||||
if p == nil {
|
||||
return def
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// configFiles are the config files given, for the status pane: those
|
||||
// that exist, or the first if none does (where to create one).
|
||||
func configFiles(list string) []string {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// shareFiles prints a share link to env files of one repo, encrypted with
|
||||
// the --key secret's value, else the repo's or the config's sharing.key:
|
||||
//
|
||||
// lazysecrets share .env apps/api/.env
|
||||
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, rules share.Rules, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("share", flag.ContinueOnError)
|
||||
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: sharing.key)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() == 0 {
|
||||
return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
|
||||
}
|
||||
|
||||
var root, id string
|
||||
var files []share.File
|
||||
for _, name := range fs.Args() {
|
||||
abs, err := filepath.Abs(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, i, err := repo.Root(filepath.Dir(abs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if root == "" {
|
||||
root, id = r, i
|
||||
} else if r != root {
|
||||
return fmt.Errorf("%s isn't in %s: share files of one repo at a time", name, root)
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, real)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := share.File{Path: filepath.ToSlash(rel)}
|
||||
if err := share.CheckPath(f.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Data, err = os.ReadFile(real); err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
|
||||
key, ok, err := share.DefaultKey(root, general)
|
||||
if *keyLink != "" {
|
||||
key, err = share.ParseKey(*keyLink)
|
||||
ok = err == nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("which secret to encrypt with? --key <secret>, or sharing.key in the repo's .lazysecrets.yaml or the config")
|
||||
}
|
||||
v, version, err := readKey(ctx, providers, st, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the key %s: %w", key, err)
|
||||
}
|
||||
link, err := share.Seal(share.Header{Key: share.Pin(key, version), Repo: id}, []byte(v.Reveal()), files, rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(out, link)
|
||||
return err
|
||||
}
|
||||
|
||||
// shareRules are the config's sharing.keyRules.
|
||||
func shareRules(cfg config.Config) share.Rules {
|
||||
return share.Rules{MinLength: cfg.Sharing.KeyRules.MinLength, MinClasses: cfg.Sharing.KeyRules.MinClasses}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// demoRules take the demo key, made up to be remembered.
|
||||
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
|
||||
|
||||
func TestShareFiles(t *testing.T) {
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", "git@git.example.com:team/app.git"}} {
|
||||
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
|
||||
t.Skip("git:", string(out))
|
||||
}
|
||||
}
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("A=1\n"), 0o600)
|
||||
os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644)
|
||||
providers := []provider.Provider{fake.Azure(0)}
|
||||
st, _ := store.Load("")
|
||||
ctx := context.Background()
|
||||
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password"
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := shareFiles(ctx, providers, st, key, demoRules, []string{filepath.Join(root, ".env")}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := ref.Parse(strings.TrimSpace(out.String()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := share.Read(r)
|
||||
if err != nil || s.Repo != "git.example.com/team/app" || s.Key.Name != "db-password" {
|
||||
t.Fatalf("%+v, %v", s.Header, err)
|
||||
}
|
||||
files, err := s.Open([]byte("correct-horse-battery-staple"))
|
||||
if err != nil || len(files) != 1 || files[0].Path != ".env" || string(files[0].Data) != "A=1\n" {
|
||||
t.Errorf("%+v, %v", files, err)
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
general string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"", []string{filepath.Join(root, ".env")}, "which secret"},
|
||||
{key, []string{filepath.Join(root, "main.go")}, "isn't an env file"},
|
||||
{key, nil, "usage"},
|
||||
{"", []string{"--key", "lazysecrets://azure/kv-app-prod.vault.azure.net", filepath.Join(root, ".env")}, "isn't a secret"},
|
||||
} {
|
||||
if err := shareFiles(ctx, providers, st, c.general, demoRules, c.args, &out); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%v: %v, want %q", c.args, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !unix
|
||||
|
||||
package main
|
||||
|
||||
func blockingStdin() {}
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// blockingStdin makes a terminal on stdin blocking again. fzf's become
|
||||
// hands on its /dev/tty non-blocking; Go can't poll /dev/tty on macOS,
|
||||
// so it reads it directly, and the TUI's first read would fail with
|
||||
// EAGAIN ("resource temporarily unavailable").
|
||||
func blockingStdin() {
|
||||
if fd := int(os.Stdin.Fd()); term.IsTerminal(fd) {
|
||||
_ = syscall.SetNonblock(fd, false)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -33,12 +32,8 @@ func updater(servers []string) *app.Updater {
|
||||
// updateCmd is lazysecrets update [--check]: the newer release's
|
||||
// changelog, then (unless --check) install it.
|
||||
func updateCmd(configPath string, args []string) error {
|
||||
fs := flag.NewFlagSet("update", flag.ContinueOnError)
|
||||
check := fs.Bool("check", false, "just say if there's a newer release")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
check := len(args) == 1 && args[0] == "--check"
|
||||
if len(args) > 0 && !check {
|
||||
return errors.New("usage: lazysecrets update [--check]")
|
||||
}
|
||||
cfg, err := config.LoadAll(strings.Split(configPath, ","))
|
||||
@@ -48,20 +43,20 @@ func updateCmd(configPath string, args []string) error {
|
||||
current := versionString()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
c := &update.Client{Servers: cfg.Updates.Sources}
|
||||
c := &update.Client{Servers: cfg.Releases}
|
||||
r, err := c.Latest(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking for a newer lazysecrets: %w", err)
|
||||
}
|
||||
if !update.Newer(current, r.Tag) {
|
||||
if strings.HasPrefix(current, "dev") {
|
||||
if !update.Newer(current, "v999.0.0") {
|
||||
return fmt.Errorf("this is a development build (%s); the latest release is %s: install it with install.sh, or go install …@%s", current, r.Tag, r.Tag)
|
||||
}
|
||||
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
|
||||
if *check {
|
||||
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes()))
|
||||
if check {
|
||||
fmt.Println("lazysecrets update installs it.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
)
|
||||
import "runtime/debug"
|
||||
|
||||
// version is set by release builds:
|
||||
//
|
||||
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
|
||||
var version string
|
||||
|
||||
// pseudo matches a Go pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507,
|
||||
// maybe +dirty): its commit.
|
||||
var pseudo = regexp.MustCompile(`[-.]\d{14}-([0-9a-f]{12})(\+.*)?$`)
|
||||
|
||||
// versionString is the release version, or for other builds what Go
|
||||
// recorded: the module version (go install …@v1.2.3), else the commit
|
||||
// (dev-c1a4f90, -dirty with changes not committed).
|
||||
// recorded: the module version (go install …@v1.2.3), else the commit.
|
||||
func versionString() string {
|
||||
if version != "" {
|
||||
return version
|
||||
@@ -26,15 +18,6 @@ func versionString() string {
|
||||
return "dev"
|
||||
}
|
||||
if v := bi.Main.Version; v != "" && v != "(devel)" {
|
||||
// A commit, not a release: a local build (Go stamps those too) or
|
||||
// go install …@<commit>.
|
||||
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||
dirty := ""
|
||||
if m[2] != "" {
|
||||
dirty = "-dirty"
|
||||
}
|
||||
return "dev-" + m[1][:7] + dirty
|
||||
}
|
||||
return v
|
||||
}
|
||||
var rev, dirty string
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPseudoVersions(t *testing.T) {
|
||||
for v, want := range map[string]string{
|
||||
"v0.1.3-0.20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||
"v0.1.3-0.20261014174204-1c31a75d0507+dirty": "1c31a75d0507",
|
||||
"v0.0.0-20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||
"v0.2.0-rc.1.0.20261014174204-1c31a75d0507": "1c31a75d0507",
|
||||
"v0.1.2": "",
|
||||
"v0.2.0-rc.1": "",
|
||||
} {
|
||||
got := ""
|
||||
if m := pseudo.FindStringSubmatch(v); m != nil {
|
||||
got = m[1]
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s: %q, want %q", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 232 KiB |
+5
-4
@@ -16,7 +16,7 @@ A service principal is used instead when `AZURE_CLIENT_ID`,
|
||||
On an account's group row, the fourth pane shows who it signs in as: name,
|
||||
user, tenant, object ID, whether the session used MFA, directory roles
|
||||
(built-in ones by name) and its subscriptions, read from az's account list
|
||||
and the token lazysecrets already has. `y` on
|
||||
and the token lazysecrets already has: no requests of their own. `y` on
|
||||
the row copies the object ID, the one access policies and role
|
||||
assignments ask for; `l` goes into the pane, where `j`/`k` pick any
|
||||
field, `/` filters them (`/tenant`) and `y` copies the selected one.
|
||||
@@ -63,12 +63,13 @@ Secrets, keys and certificates, each on their own tab of the Secrets pane
|
||||
(`[`/`]`). `space` reveals a secret's value, or the public material of a
|
||||
key (PEM public key) or certificate; private key material is never
|
||||
fetched. `v` lists an item's versions. Items expiring within
|
||||
`ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||
|
||||
The status pane (`0`) lists them across every vault loaded, opened or
|
||||
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
|
||||
on one goes to it. Expiry dates come with the lists. It says how many
|
||||
vaults it covers; `ctrl+a` in search loads the rest.
|
||||
on one goes to it. That makes no requests of its own: expiry dates come
|
||||
with the lists. It says how many vaults it covers; `ctrl+a` in search
|
||||
loads the rest.
|
||||
|
||||
On a vault, the right pane shows its settings: resource group, location,
|
||||
SKU, whether access is by Azure roles or access policies, soft delete and
|
||||
|
||||
+50
-60
@@ -1,45 +1,44 @@
|
||||
# Config
|
||||
|
||||
Optional, at `~/.config/lazysecrets/config.yaml` (or
|
||||
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted
|
||||
below) override it. Unknown keys are an error, so typos don't go
|
||||
unnoticed. `e` on the status pane (`0`) opens it in your editor, as in
|
||||
lazygit (making it if it isn't there yet); changes apply when lazysecrets
|
||||
starts again.
|
||||
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override
|
||||
it. Unknown keys are an error, so typos don't go unnoticed.
|
||||
|
||||
```yaml
|
||||
ui:
|
||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||
recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list
|
||||
sourcesView: grouped # or flat; ` toggles it
|
||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
||||
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d; ⚠ in lazysecrets list); expired ones get ✗
|
||||
values:
|
||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables; also --clipboard-clear
|
||||
storage:
|
||||
cache: true # keep what was loaded (never values) for the next start
|
||||
history: true # count uses of items for Recent (R)
|
||||
updates:
|
||||
autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way)
|
||||
autoCheckInterval: 24h # at most this often
|
||||
sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
|
||||
- https://github.com/karleinarb/lazysecrets
|
||||
- https://git.kebcloud.no/keb/lazysecrets
|
||||
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0
|
||||
clipboardClear: 30s # clear copied values from the clipboard after this long; 0s disables
|
||||
revealTimeout: 60s # hide a revealed value after this long; 0s keeps it shown
|
||||
hideOnBlur: true # hide a revealed value when the terminal loses focus (tmux: focus-events on)
|
||||
expiryWarningDays: 30 # mark secrets, keys and certificates expiring this soon (⚠ 12d); expired ones get ✗
|
||||
timeout: 30s # per backend request
|
||||
loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
|
||||
sourcesView: grouped # or flat; ` toggles it
|
||||
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one
|
||||
updateCheck: true # look for a newer release once a day (U and lazysecrets update work either way)
|
||||
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server)
|
||||
- https://github.com/karleinarb/lazysecrets
|
||||
- https://git.kebcloud.no/keb/lazysecrets
|
||||
history: true # count uses of items for Recent (R)
|
||||
cache: true # keep what was loaded (never values) for the next start
|
||||
providers:
|
||||
timeout: 30s # per backend request; a provider's own timeout overrides it; also --timeout
|
||||
azure: # likewise databricks and repos; azure covers every az account
|
||||
enabled: true # default
|
||||
timeout: 30s
|
||||
azure: # applies to every az account
|
||||
enabled: true # default
|
||||
hideSubscriptions: [platform-mgmt] # by name or ID
|
||||
vaults: # listed under "Pinned vaults", for vaults
|
||||
- https://kv-foo.vault.azure.net/ # you can read but not discover
|
||||
databricks:
|
||||
enabled: true
|
||||
hideProfiles: [test]
|
||||
repos:
|
||||
enabled: true
|
||||
roots: [~/git]
|
||||
paths: [~/work/infra]
|
||||
include: ["secrets/**"]
|
||||
exclude: ["**/testdata/**"]
|
||||
```
|
||||
|
||||
More, each on its own page: [Azure](azure.md#vaults-you-cant-discover)
|
||||
(`providers.azure`), [Databricks](databricks.md) (`providers.databricks`),
|
||||
[repos](repos.md#which-files) (`providers.repos`),
|
||||
[sharing env files](sharing.md#sharing-env-files) (`sharing`), the
|
||||
[link handler](sharing.md#from-the-browser-and-chat) (`linkHandler`),
|
||||
More, each on its own page: [repos](repos.md) (`providers.repos`), the
|
||||
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`),
|
||||
[keybindings](keys.md#rebinding-keys) (`keybinding`) and
|
||||
[custom commands](custom-commands.md) (`customCommands`).
|
||||
|
||||
@@ -74,23 +73,18 @@ 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
|
||||
skipped with a warning.
|
||||
|
||||
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
|
||||
as boxes, turn them off with `ui.icons: false`.
|
||||
as boxes, turn them off with `icons: false`.
|
||||
|
||||
## Layering
|
||||
|
||||
Several config files can be layered, later ones winning (theme roles and
|
||||
keybindings merge one by one), with a comma-separated `--config` or
|
||||
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
|
||||
config plus an editor-generated theme:
|
||||
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor
|
||||
plugin can so add a generated theme file to your own config, the way
|
||||
snacks.nvim themes lazygit from Neovim's highlight groups:
|
||||
|
||||
```sh
|
||||
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
|
||||
@@ -103,15 +97,14 @@ Stars, notes, usage and the layout are kept in
|
||||
by you: one record per source, vault or secret, keyed by its
|
||||
`lazysecrets://` link, never with values. A star belongs to the account you
|
||||
starred the item under (a vault two of your accounts can see can be starred
|
||||
for one); a note describes the item, so it shows under every account.
|
||||
Several lazysecrets windows can save to it without losing each other's
|
||||
changes. One written by a newer lazysecrets is read but not saved to,
|
||||
with a warning, until you update.
|
||||
for one); a note describes the item, so it shows under every account. It's
|
||||
JSON so an editor plugin can read it with a built-in parser; several
|
||||
lazysecrets windows can save to it without losing each other's changes.
|
||||
|
||||
Revealing, copying, opening in the browser, sharing and jumping to an item
|
||||
count as using it: its record keeps when it was last used and how often,
|
||||
for Recent (`R`). Records kept only for usage are dropped after 90 days
|
||||
unused, and beyond the 100 most recent. `storage.history: false` stops counting.
|
||||
unused, and beyond the 100 most recent. `history: false` stops counting.
|
||||
|
||||
It also keeps where you left lazysecrets in each folder (the selected
|
||||
source, vault or secret), and goes back there the next time it starts in
|
||||
@@ -126,25 +119,22 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata
|
||||
once, and anything older than two minutes reloads in the background when
|
||||
you go to it, or when Go to searches it. While it does, the pane's
|
||||
bottom border says how old what it shows is (`cached 3h ago`). Revealing
|
||||
and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers)
|
||||
reads it and adds to it too.
|
||||
and copying always fetch the value live.
|
||||
|
||||
Lists not loaded for 30 days are dropped, and so is everything from an
|
||||
account you're no longer signed in to. Repos aren't cached: their files
|
||||
are read from disk anyway. Deleting the file is always safe, and
|
||||
`storage.cache: false` stops using it.
|
||||
`cache: false` stops using it.
|
||||
|
||||
## Updates
|
||||
|
||||
At startup, in the background, lazysecrets asks for the latest release
|
||||
(`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
|
||||
until then it reuses the answer from the state file. Releases are
|
||||
published on GitHub and on git.kebcloud.no, and it asks them in that
|
||||
order, the next when one doesn't answer within 5 seconds;
|
||||
`updates.sources` lists others, such as a mirror of your own. Offline, or with
|
||||
every server down, it says nothing. A newer release shows in the status
|
||||
pane, `↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what
|
||||
changed since your version, across the last 10 releases.
|
||||
Once a day (`updateCheck`), in the background, lazysecrets asks for the
|
||||
latest release; within the day it reuses the answer from the state file.
|
||||
Releases are published on GitHub and on git.kebcloud.no, and it asks
|
||||
them in that order, the next when one doesn't answer within 5 seconds;
|
||||
`releases` lists others, such as a mirror of your own. Offline, or with
|
||||
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
|
||||
`lazysecrets update --check`) shows its changelog.
|
||||
|
||||
Updating (`u` there, or `lazysecrets update`) downloads the release for
|
||||
your OS and CPU from the first server that has it, checks it against
|
||||
|
||||
+8
-15
@@ -7,12 +7,7 @@ go test ./...
|
||||
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
|
||||
```
|
||||
|
||||
In `--demo`, sharing env files works end to end: `S` → `f` on a demo
|
||||
repo, pick `kv-shared-prod / env-share` (the second account's) as the
|
||||
key, then paste the link back. The demo repos aren't on disk, so opening
|
||||
asks where to save, as for a repo you haven't cloned.
|
||||
|
||||
A stripped binary is a third smaller:
|
||||
A stripped binary is about 21 MB instead of 30 MB:
|
||||
|
||||
```sh
|
||||
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
|
||||
@@ -22,7 +17,7 @@ Development builds show `dev-<commit>` as their version (`-dirty` with
|
||||
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
|
||||
version they were built for.
|
||||
|
||||
## How changes land
|
||||
## Pull requests
|
||||
|
||||
Pull requests into `main` are squash-merged, so each is one commit titled
|
||||
like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
|
||||
@@ -35,8 +30,10 @@ subject, checked by `pr-title.yml`:
|
||||
| `fix` | a bug fix | Fixed |
|
||||
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no |
|
||||
|
||||
`feat!:` marks a breaking change, `feat(scope):` names a part.
|
||||
`[skip changelog]` in the description leaves a pull request out.
|
||||
`feat!:` marks a breaking change, `feat(scope):` names a part. Brackets
|
||||
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all
|
||||
become `feat: x` in the changelog. `[skip changelog]` in the description
|
||||
leaves a pull request out.
|
||||
|
||||
CI (`ci.yml`) runs gofmt, vet, staticcheck, the tests and a build for each
|
||||
platform on pull requests into `main`. `main` is protected: a pull request
|
||||
@@ -49,14 +46,10 @@ 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 minor # the tag and notes the release would have
|
||||
scripts/release.sh minor # CHANGELOG.md, commit, tag, push
|
||||
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
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Integration
|
||||
|
||||
lazysecrets works outside its own window too: `lazysecrets get` reads a
|
||||
value for scripts, and `lazysecrets list` lists everything for fzf, editor
|
||||
pickers and your own tools. Both take the [links](sharing.md) lazysecrets
|
||||
copies and shares. For Neovim, there's
|
||||
[lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim).
|
||||
|
||||
## Reading a value
|
||||
|
||||
`lazysecrets get <link>` prints the value, for scripts and pipes:
|
||||
|
||||
```sh
|
||||
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
|
||||
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
|
||||
```
|
||||
|
||||
It reads the value directly, with your az accounts (the one that last read
|
||||
it first), the Databricks profile for the workspace, or sops in your clone
|
||||
of the repo; a version in the link reads that version. `--account` tries
|
||||
that account first, as [`list`](#listing-for-fzf-and-pickers) prints it.
|
||||
The value ends with a newline only on a terminal. When it can't read the
|
||||
value, it says why on stderr and exits with status 1.
|
||||
|
||||
## Listing, for fzf and pickers
|
||||
|
||||
`lazysecrets list` prints every source, vault and secret, one per line,
|
||||
tab-separated:
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| kind | `source`, `vault`, `secret`, `key` or `certificate` |
|
||||
| mark | ✗ when something's wrong (expired, or couldn't be read) or ⚠ when it expires soon; ★ when starred; ◷ when used lately (with `--recent`); and the provider's icon |
|
||||
| name | `platform-prod / kv-app-prod / db-password` |
|
||||
| status | why a vault or source couldn't be read, or that it has `expired` or `expires in 12d` (within `ui.expiryWarningDays`), or empty |
|
||||
| note | your note on it (`n` in lazysecrets), after a note icon (✎ without icons), or empty |
|
||||
| account | what it's listed through: `Azure` (`Azure:<user>/<tenant>` with several az accounts), `Databricks` or `Repos` |
|
||||
| link | its `lazysecrets://` link |
|
||||
|
||||
What's cached prints at once, then what loads follows as it arrives: read
|
||||
it as a stream. Values are never listed. It stops quietly when the reader
|
||||
goes, like fzf after a pick. Kind stays the first field, and account and
|
||||
link the last two; new fields only ever go between. The mark has a field of
|
||||
its own so a search can leave it out: in fzf, `--nth` searches the name,
|
||||
so `^prod` finds names starting with prod.
|
||||
|
||||
| Flag | |
|
||||
|---|---|
|
||||
| `--format json` | an object per line instead, for editor plugins (below) |
|
||||
| `--kind secret,vault` | only these kinds: `source`, `vault` (also scopes and repo files), `secret`, `key`, `certificate`; lists they don't need aren't loaded |
|
||||
| `--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`) |
|
||||
| `--notes=false` | no note icon before a note |
|
||||
| `--recent` | ◷ before names used lately, in `mutedColor`; default `ui.recentIcon` |
|
||||
| `--expiry=false` | no expiry: not in the status, and no ✗ or ⚠ for it |
|
||||
|
||||
With `--format json`, each line is an object with these fields; new ones
|
||||
may be added:
|
||||
|
||||
| Field | |
|
||||
|---|---|
|
||||
| `link`, `kind`, `account` | as above |
|
||||
| `error` | why it couldn't be read, only then |
|
||||
| `provider` | `azure`, `databricks` or `repo` |
|
||||
| `path` | the names: `["platform-prod", "kv-app-prod", "db-password"]` |
|
||||
| `accountLabel` | the account's short name, when several az accounts are signed in |
|
||||
| `starred` | starred for this account |
|
||||
| `note` | your note on it, if any |
|
||||
| `reference` | how config refers to a secret: `@Microsoft.KeyVault(SecretUri=…)` or `{{secrets/scope/key}}` (`Y` in lazysecrets) |
|
||||
| `url` | its web page, in the Azure portal or the Databricks workspace (`o` in lazysecrets) |
|
||||
| `recent` | used lately, as Recent (`R`) lists: in the last 90 days |
|
||||
| `lastUsed`, `uses` | when it was last used, and how often; only when `recent`, to sort by |
|
||||
| `expires` | when a secret, key or certificate expires, if it does (Azure) |
|
||||
| `enabled` | `false` for a disabled one; only where the provider says (Azure) |
|
||||
|
||||
[`lazysecrets open --action go --account <account> <link>`](sharing.md#opening-a-link)
|
||||
opens one in lazysecrets without asking what to do or which account.
|
||||
|
||||
### An fzf picker
|
||||
|
||||
```sh
|
||||
lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 \
|
||||
--header 'enter: link · ctrl-o: open · ctrl-y: copy value · ctrl-x: copy and close' \
|
||||
--bind 'ctrl-o:become(lazysecrets open --action go --account {-2} {-1})' \
|
||||
--bind 'ctrl-y:execute-silent(lazysecrets get --account {-2} {-1} | pbcopy)+change-header(Copied the value of {3})' \
|
||||
--bind 'ctrl-x:become(lazysecrets get --account {-2} {-1} | pbcopy)'
|
||||
```
|
||||
|
||||

|
||||
|
||||
+22
-21
@@ -20,7 +20,6 @@ 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 |
|
||||
|
||||
@@ -50,12 +49,12 @@ match case exactly.
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||
| `y` | copy the value without revealing it. On an Azure account's row: its object ID; in its pane, the selected field |
|
||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
|
||||
| `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, `.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)) |
|
||||
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) |
|
||||
| `s` / `S` | copy a [link](links.md) to the selected item / share menu |
|
||||
| `ctrl+o` | open a link or reference (or just paste one) |
|
||||
| `x` | [custom commands](custom-commands.md) |
|
||||
| `L` | sign in again (shown when a login has expired) |
|
||||
@@ -64,8 +63,10 @@ match case exactly.
|
||||
A revealed value is shown as it reads best: JSON indented, and PEM
|
||||
certificates as their subject, issuer, dates, names and thumbprint, with
|
||||
any other PEM block (a private key) named but not shown. `y` still copies
|
||||
it exactly as stored. It hides again after a while, and when the terminal
|
||||
loses focus ([config](config.md)).
|
||||
it exactly as stored.
|
||||
|
||||
A revealed value hides again after `revealTimeout` (60s) and when the
|
||||
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
||||
|
||||
## Stars and notes
|
||||
|
||||
@@ -74,7 +75,8 @@ loses focus ([config](config.md)).
|
||||
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list |
|
||||
| `n` | write a short note on it, shown dimmed on its row and in the detail pane |
|
||||
|
||||
Both are kept in the [state file](config.md#state).
|
||||
A star belongs to the account you starred the item under; a note shows
|
||||
under every account. Both are kept in the [state file](config.md#state).
|
||||
|
||||
## Go to
|
||||
|
||||
@@ -120,18 +122,16 @@ Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
|
||||
it's hidden.
|
||||
|
||||
On top, the status pane (`0`) shows the version, a newer release and how
|
||||
your accounts are doing; `e` there opens your [config](config.md) in your
|
||||
editor, as in lazygit. Focused, the right pane lists more under the
|
||||
logo; `y` copies any row:
|
||||
|
||||
- the version and updates (`U`)
|
||||
- each account (`L` on one that needs it signs in)
|
||||
- what's expiring (`enter` goes to it)
|
||||
- this session's errors, newest first (not kept after quitting)
|
||||
- the config, state and cache files (`e` opens one in your editor)
|
||||
- links to the docs, source, issues, releases and changelog (`o` opens one)
|
||||
- the changelog of every release up to yours, built in: `/` filters it
|
||||
(`/fixed`, `/search`)
|
||||
your accounts are doing. Focused, the right pane lists more under the
|
||||
logo (when there's room): the version and updates (`U`), each account
|
||||
(`L` on one that needs it signs in), what's expiring (`enter` goes to
|
||||
it), this session's errors, newest first (what a pane showed, or the
|
||||
footer flashed before you moved on; not kept after quitting), the config,
|
||||
state and cache files (`e` opens one in your editor), and links to the
|
||||
docs, source, issues, releases and changelog (`o` opens one; on the
|
||||
version or update, its release), then the changelog of every release up
|
||||
to yours, built in so it needs no network: `/` filters it (`/fixed`,
|
||||
`/search`). `y` copies any row.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
@@ -140,8 +140,9 @@ logo; `y` copies any row:
|
||||
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
|
||||
| `=` | reset the layout (divider, heights, zoom) |
|
||||
|
||||
The divider and pane heights are remembered (zoom isn't). `<>{}` are for
|
||||
macOS, where ctrl+arrows switch desktops.
|
||||
The divider and pane heights are remembered (zoom isn't). macOS uses
|
||||
ctrl+arrows for Mission Control and switching desktops by default, hence
|
||||
the `<` `>` `{` `}` alternatives.
|
||||
|
||||
## Rebinding keys
|
||||
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
# Links
|
||||
|
||||
Every subscription, workspace, vault, scope, repo, file and secret has a
|
||||
`lazysecrets://` link. Share one in chat or a ticket, and whoever opens it
|
||||
lands on that item, reading it with their own account and permissions:
|
||||
links carry names, never values.
|
||||
|
||||
```
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
|
||||
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
|
||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||
```
|
||||
|
||||
## Sharing
|
||||
|
||||
`s` copies a link to the selected item (from the versions view, pinned to
|
||||
that version). `S` opens the share menu:
|
||||
|
||||
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config
|
||||
reference (`y`) or the web page (`b`)
|
||||
- vaults and scopes: a link (`o`), a link that opens on the current tab
|
||||
(`t`), the plain vault URL (`u`) or the web page (`b`)
|
||||
- subscriptions and workspaces: a link (`o`) or the web page (`b`)
|
||||
|
||||
## Opening
|
||||
|
||||
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
|
||||
and fills in any reference it finds there. Key Vault URLs,
|
||||
`@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
|
||||
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
|
||||
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
|
||||
set up.
|
||||
|
||||
If you copy a `lazysecrets://` link elsewhere and switch back, lazysecrets
|
||||
offers to open it, once per link. It looks at the clipboard at startup,
|
||||
when the terminal regains focus (in tmux: `set -g focus-events on`) and,
|
||||
for terminals that don't report focus, on a key press at most every 3
|
||||
seconds. It keeps nothing but `lazysecrets://` links, doesn't offer a link
|
||||
to what's already selected (like one you just copied with `s`), and can't
|
||||
read the clipboard over SSH.
|
||||
|
||||
lazysecrets finds the vault in your accounts (one Resource Graph query per
|
||||
account) and jumps to it, asking which account to use when several can see
|
||||
it. Links say what, not what to do: opening a link to a secret asks whether
|
||||
to just go to it, also copy or show its value, or (`y`) copy its value
|
||||
without going there.
|
||||
|
||||
## In scripts
|
||||
|
||||
`lazysecrets get <link>` prints the value, for scripts and pipes:
|
||||
|
||||
```sh
|
||||
export DB_PASSWORD=$(lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password)
|
||||
lazysecrets get lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password | kubectl create secret generic db --from-file=password=/dev/stdin
|
||||
```
|
||||
|
||||
It reads the value directly, with your az accounts (the one that last read
|
||||
it first), the Databricks profile for the workspace, or sops in your clone
|
||||
of the repo; a version in the link reads that version. The value ends with
|
||||
a newline only on a terminal.
|
||||
|
||||
## From the browser and chat
|
||||
|
||||
```sh
|
||||
lazysecrets handler install # run it in the terminal links should open in
|
||||
lazysecrets handler uninstall
|
||||
```
|
||||
|
||||
Use an installed binary (`go run`'s is temporary). lazysecrets offers to do
|
||||
this at startup when links don't open it (or open a lazysecrets that no
|
||||
longer exists): set it up, not now, or don't ask again, which is
|
||||
remembered in the state file. `linkHandler.prompt: false` in the config
|
||||
turns the question off too.
|
||||
|
||||
`handler install` registers `lazysecrets://` with the system: clicking a
|
||||
link then opens a new window of the terminal you ran it in, with
|
||||
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
|
||||
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
|
||||
tmux; `--terminal NAME` picks another. It records your `PATH` and config
|
||||
locations, since apps start without your shell's environment (and so
|
||||
without `az`): run it again after moving lazysecrets or changing those.
|
||||
|
||||
- macOS: a small background app, `~/Applications/Lazysecrets Links.app`
|
||||
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
|
||||
the default with `xdg-mime` (desktops following freedesktop.org)
|
||||
- Windows: not yet
|
||||
|
||||
The config can override the terminal, or run any other:
|
||||
|
||||
```yaml
|
||||
linkHandler:
|
||||
terminal: ghostty # instead of the one install detected
|
||||
command: [foot, --title, secrets, "{cmd}"] # any terminal; {cmd} is the lazysecrets command
|
||||
reuse: true # hand links to a running lazysecrets first
|
||||
prompt: true # offer to set up the handler at startup
|
||||
```
|
||||
|
||||
### Where a clicked link opens
|
||||
|
||||
- **In a terminal** (a link in a terminal app): a lazysecrets that
|
||||
terminal shows (or whose tmux session one of its tmux clients can switch
|
||||
to) gets it, never one elsewhere; otherwise a new window opens.
|
||||
- **Anywhere else** (Slack, a browser): the lazysecrets you used last (key
|
||||
press or focus), preferring one already on screen; a tmux client is
|
||||
switched to its session if needed, its terminal comes to the front, and
|
||||
a hidden dropdown terminal (kitty's quick access terminal) is shown.
|
||||
With none running, a new window opens.
|
||||
|
||||
The link is then offered as if pasted. `linkHandler.reuse: false` always
|
||||
opens a new window.
|
||||
|
||||
Running instances listen on a socket in `$XDG_RUNTIME_DIR/lazysecrets`, or
|
||||
`run/` in the state directory, readable only by you. A link from a web page
|
||||
can only make lazysecrets go somewhere: it asks before copying or showing
|
||||
anything.
|
||||
+24
-127
@@ -1,19 +1,16 @@
|
||||
# SOPS, .env, Spring, compose and .NET files in git repos
|
||||
# SOPS and .env files in git repos
|
||||
|
||||
lazysecrets shows git repositories under **Repos**: their
|
||||
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
|
||||
`.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.
|
||||
`.env`, INI) and plain env files as vaults, and the keys in them
|
||||
(`db.password`, `list[0]`) as secrets.
|
||||
|
||||
## Which repos
|
||||
|
||||
**Repos** lists every repo with secret files under the `roots` (by
|
||||
default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`,
|
||||
`~/source` and `~/src` exist; one or two levels down, `repo` or
|
||||
`org/repo`), any listed in `paths`, and any you starred something in
|
||||
(the repo, a file or a key), wherever it is. They're checked in the background.
|
||||
**Repos** lists every repo with secret files under the `roots` (`~/git`
|
||||
by default; one or two levels down, `repo` or `org/repo`), any listed in
|
||||
`paths`, and any you starred something in (the repo, a file or a key),
|
||||
wherever it is. They're checked in the background.
|
||||
|
||||
Started inside a repo, that repo gets a row of its own at the top,
|
||||
`Repo · <name>`, and lazysecrets opens on it, with the files under the
|
||||
@@ -30,9 +27,8 @@ 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.
|
||||
`.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
|
||||
git-ignored (as they usually are), in the folders git tracks files in;
|
||||
not `.env.example`, `.sample`, `.template` and the like. They're read
|
||||
directly; a file with SOPS metadata is always treated as SOPS, whatever
|
||||
its name.
|
||||
|
||||
@@ -48,111 +44,24 @@ 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, Spring, compose and .NET files too.
|
||||
`include`/`exclude` apply to env files too.
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
repos:
|
||||
roots: [~/git] # folders of repos (replaces the defaults; [] turns it off)
|
||||
roots: [~/git] # folders of repos (the default; [] turns it off)
|
||||
paths: [~/work/infra] # single repos elsewhere
|
||||
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
|
||||
exclude: ["**/testdata/**"]
|
||||
```
|
||||
|
||||
A repo's `.lazysecrets.yaml` can also name the secret its env files are
|
||||
[shared](sharing.md#sharing-env-files) with:
|
||||
|
||||
```yaml
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -161,24 +70,6 @@ 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:
|
||||
@@ -199,5 +90,11 @@ sops 3.8 or later.
|
||||
|
||||
## Links
|
||||
|
||||
Repo links name the repo by its git remote, so they open in a colleague's
|
||||
clone wherever it is ([sharing](sharing.md)).
|
||||
Links name the repo by its git remote, so they open in a colleague's clone
|
||||
wherever it is:
|
||||
|
||||
```
|
||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||
```
|
||||
|
||||
See [links](links.md).
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
# Sharing
|
||||
|
||||
Subscriptions, workspaces, vaults, scopes, repos, files and secrets are
|
||||
shared as a `lazysecrets://` link, in chat or a ticket. Whoever opens
|
||||
it lands on that item, reading it with their own account and
|
||||
permissions: the link names the item, never its value. Env files can
|
||||
also be [shared encrypted](#sharing-env-files), in the link itself.
|
||||
|
||||
```
|
||||
lazysecrets://azure/subscriptions/<subscription-id> subscription
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net vault
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net/keys vault, Keys tab
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password secret
|
||||
lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0 one version
|
||||
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net workspace
|
||||
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app scope
|
||||
lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key
|
||||
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
|
||||
```
|
||||
|
||||
## Sharing a link
|
||||
|
||||
`s` copies a link to the selected item (from the versions view, pinned to
|
||||
that version). `S` opens the share menu:
|
||||
|
||||
- secrets: the link (`o`), the plain Key Vault URL (`u`), the config
|
||||
reference (`y`) or the web page (`b`); in an env file, also the file as
|
||||
an [encrypted link](#sharing-env-files) (`f`)
|
||||
- vaults and scopes: a link (`o`), a link that opens on the current tab
|
||||
(`t`), the plain vault URL (`u`) or the web page (`b`)
|
||||
- subscriptions and workspaces: a link (`o`) or the web page (`b`)
|
||||
- repos and env files: also their env files as an
|
||||
[encrypted link](#sharing-env-files) (`f`)
|
||||
|
||||
## Sharing env files
|
||||
|
||||
`.env` files usually live only on your machine. `S` → `f` shares them as
|
||||
an encrypted link, on a repo (all its plain env files), an env file, or a
|
||||
key in one (its file):
|
||||
|
||||
```
|
||||
lazysecrets://share/AQ…
|
||||
```
|
||||
|
||||
The files are encrypted with a secret's value, so whoever can read that
|
||||
secret (in Key Vault, Databricks or a repo's SOPS file) can open the link,
|
||||
and nobody else: who may open it is up to the vault's access, as for
|
||||
everything else. Make a secret for it once, random:
|
||||
|
||||
```sh
|
||||
az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
|
||||
uses it) or your config (for repos that don't name one), by its
|
||||
`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
|
||||
reference:
|
||||
|
||||
```yaml
|
||||
sharing:
|
||||
key: https://kv-team.vault.azure.net/secrets/env-share
|
||||
```
|
||||
|
||||
With neither, sharing asks which secret, in [Go to](keys.md#go-to): it
|
||||
lists only secrets, and `enter` picks one (pasting a reference picks it
|
||||
too).
|
||||
|
||||
Anyone with a link can try to guess the key, so sharing refuses one that
|
||||
isn't long and varied enough, saying how (never showing it). By default
|
||||
that's 32 characters of two kinds; your config can ask for more, or less:
|
||||
|
||||
```yaml
|
||||
sharing:
|
||||
keyRules:
|
||||
minLength: 32 # characters; 16 at least
|
||||
minClasses: 2 # kinds of character: lowercase, uppercase, digits, symbols
|
||||
```
|
||||
|
||||
Opening a link doesn't check them.
|
||||
|
||||
On the command line, `lazysecrets share [--key <secret>] <env file>...`
|
||||
prints the link.
|
||||
|
||||
Opening a share link says which repo it's from, how many files and which
|
||||
secret it's encrypted with, and decrypts only when asked: lazysecrets
|
||||
reads the secret with your access, then shows where each file goes in
|
||||
your clone of the repo (or asks for a folder, if it isn't here), which
|
||||
ones would replace a file there and which ones git doesn't ignore. It
|
||||
saves them all, only the new ones, or none. New files are readable by you
|
||||
only.
|
||||
|
||||
A link holds the files' paths and contents encrypted ([age](https://age-encryption.org),
|
||||
the secret's value as passphrase); the repo, the secret and the number of
|
||||
files are in the clear. Links stay under 16 KB, which fits chat and
|
||||
tickets.
|
||||
|
||||
A link stays readable as long as its secret is. On Azure it's pinned to
|
||||
the secret's version, so adding a new version (rotating) doesn't break
|
||||
old links, and disabling the old version, or taking away someone's
|
||||
access, shuts them out; Databricks secrets have no versions, so changing
|
||||
the value breaks every old link. Whoever has read the secret's value
|
||||
before can decrypt links made with it without asking the vault, so rotate
|
||||
it now and then: new links use the newest version.
|
||||
|
||||
## Opening a link
|
||||
|
||||
- Paste it into lazysecrets, or press `ctrl+o`, which reads the clipboard
|
||||
and fills in any reference it finds there. Key Vault URLs, Azure portal
|
||||
links, `@Microsoft.KeyVault(...)` and `{{secrets/scope/key}}` work too.
|
||||
- `lazysecrets <link>` (or `lazysecrets open <link>`) in a terminal.
|
||||
- Click it anywhere, once the [link handler](#from-the-browser-and-chat) is
|
||||
set up.
|
||||
|
||||
If you copy a `lazysecrets://` link elsewhere and switch back, lazysecrets
|
||||
offers to open it, once per link. It looks at the clipboard at startup,
|
||||
when the terminal regains focus (in tmux: `set -g focus-events on`) and,
|
||||
for terminals that don't report focus, on a key press at most every 3
|
||||
seconds. It keeps nothing but `lazysecrets://` links, doesn't offer a link
|
||||
to what's already selected (like one you just copied with `s`), and can't
|
||||
read the clipboard over SSH.
|
||||
|
||||
lazysecrets finds the vault in your accounts (one Resource Graph query per
|
||||
account) and jumps to it, asking which account to use when several can see
|
||||
it, ending on a secret's details as `enter` on it would. Links say what,
|
||||
not what to do: opening a link to a secret asks whether to just go to it,
|
||||
also copy or show its value, or (`y`) copy its value without going there.
|
||||
`lazysecrets open --action go|copy|show <link>` does one without asking
|
||||
and ends in the list, like Go to,
|
||||
and `--account <account>` picks the account (as [`list`](integration.md#listing-for-fzf-and-pickers)
|
||||
prints it) when several can see it.
|
||||
|
||||
## From the browser and chat
|
||||
|
||||
```sh
|
||||
lazysecrets handler install # run it in the terminal links should open in
|
||||
lazysecrets handler uninstall
|
||||
```
|
||||
|
||||
Use an installed binary (`go run`'s is temporary). lazysecrets offers to do
|
||||
this at startup when links don't open it (or open a lazysecrets that no
|
||||
longer exists): set it up, not now, or don't ask again, which is
|
||||
remembered in the state file. `linkHandler.prompt: false` in the config
|
||||
turns the question off too.
|
||||
|
||||
`handler install` registers `lazysecrets://` with the system: clicking a
|
||||
link then opens a new window of the terminal you ran it in, with
|
||||
lazysecrets on that link. It recognises kitty, Ghostty, WezTerm, iTerm2,
|
||||
Terminal.app, Alacritty, GNOME Terminal, Konsole and foot, also inside
|
||||
tmux; `--terminal NAME` picks another. It records your `PATH`, config
|
||||
locations and XDG directories, since apps start without your shell's
|
||||
environment (and so without `az`): run it again after moving lazysecrets
|
||||
or changing those.
|
||||
|
||||
- macOS: a small background app, `~/Applications/Lazysecrets Links.app`
|
||||
- Linux: a desktop entry claiming `x-scheme-handler/lazysecrets`, set as
|
||||
the default with `xdg-mime` (desktops following freedesktop.org)
|
||||
- Windows: not yet
|
||||
|
||||
The config can override the terminal, or run any other:
|
||||
|
||||
```yaml
|
||||
linkHandler:
|
||||
terminal: ghostty # instead of the one install detected
|
||||
command: [foot, --title, secrets, "{cmd}"] # any terminal; {cmd} is the lazysecrets command
|
||||
reuse: true # hand links to a running lazysecrets first
|
||||
prompt: true # offer to set up the handler at startup
|
||||
```
|
||||
|
||||
### Where a clicked link opens
|
||||
|
||||
- **In a terminal** (a link in a terminal app): a lazysecrets that
|
||||
terminal shows (or whose tmux session one of its tmux clients can switch
|
||||
to) gets it, never one elsewhere; otherwise a new window opens.
|
||||
- **Anywhere else** (Slack, a browser): the lazysecrets you used last (key
|
||||
press or focus), preferring one already on screen; a tmux client is
|
||||
switched to its session if needed, its terminal comes to the front, and
|
||||
a hidden dropdown terminal (kitty's quick access terminal) is shown.
|
||||
With none running, a new window opens.
|
||||
|
||||
The link is then offered as if pasted. `linkHandler.reuse: false` always
|
||||
opens a new window.
|
||||
|
||||
Running instances listen on a socket in `$XDG_RUNTIME_DIR/lazysecrets`, or
|
||||
`run/` in the state directory, readable only by you. A link from a web page
|
||||
can only make lazysecrets go somewhere: it asks before copying or showing
|
||||
anything.
|
||||
@@ -1,36 +1,34 @@
|
||||
module git.kebcloud.no/keb/lazysecrets
|
||||
|
||||
go 1.27.2
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/bubbletea/v2 v2.1.0
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
filippo.io/age v1.3.2
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/x/ansi v0.11.9
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
|
||||
github.com/databricks/databricks-sdk-go v0.188.0
|
||||
github.com/junegunn/fzf v0.74.5
|
||||
github.com/databricks/databricks-sdk-go v0.187.0
|
||||
github.com/junegunn/fzf v0.74.4
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
golang.org/x/mod v0.42.0
|
||||
golang.org/x/mod v0.41.0
|
||||
golang.org/x/term v0.47.0
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/auth v0.4.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
|
||||
@@ -47,11 +45,12 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
@@ -61,21 +60,22 @@ require (
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect
|
||||
golang.org/x/net v0.60.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/api v0.264.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.83.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/api v0.182.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
@@ -1,21 +1,16 @@
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
|
||||
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/auth v0.4.2 h1:sb0eyLkhRtpq5jA+a8KWw0W70YcdVca7KJ8TM0AFYDg=
|
||||
cloud.google.com/go/auth v0.4.2/go.mod h1:Kqvlz1cf1sNA0D+sYJnkPQOP+JMHkuHeIgVmCRtZOLc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
|
||||
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
|
||||
@@ -24,18 +19,18 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDH
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0 h1:Terj1CMW4+NY5CpWjSRkqLoxh2DkYDdv/FH2z0897Uc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.2.0 h1:+lnLQhKh3cgSOIOVH61UZ3s/l9d+bAZp5d/spt1+7UI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.2.0/go.mod h1:tStOHrivWUrcBolspvKV70Us1ckESYGYSHdG4LX8zyY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0 h1:4dT2wTR+ViaGvwcAoMjqt0BvgwBkT6nXxg8flJhnLfw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0/go.mod h1:7fgu6AtY2gkWhtZt9SEHK/MkTVUn2HPvAZjv6XsrjuI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0 h1:+1fJwTilk/X7inNqwREnYEOgFCdg8ut7GULxARDbu34=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0/go.mod h1:EGwSLlGqrrfYQhtCi9JcIkPQKl9WxsL6ZPJd+63Vy1A=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0 h1:67nFqWXpo0x5Nz0XEb1yI7s8D+EHy8NsTinYw9sZnLk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeployments v1.0.0/go.mod h1:fewgRjNVE84QVVh798sIMFb7gPXPp7NmnekGnboSnXk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0 h1:TPaLKUUZw3XUaQy5aU+ApHXFKB2VnTWUA0xufkvFDE8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.0/go.mod h1:Rn/a97OKDAoOGvk5/OFjqObHHqWyoMMVSGtikqbKcAg=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0/go.mod h1:TpiwjwnW/khS0LKs4vW5UmmT9OWcxaveS8U7+tlknzo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0 h1:15MiSirdxWpIKFEHwfwfMWYieETCUnPTERPIlnytQeo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates v1.5.0/go.mod h1:0AAf/fHAqhIsvD6XGKlCsDO8u+cCiYBBuro+lWS6os4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM=
|
||||
@@ -48,10 +43,12 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 h1:Nljr4q1GRA/5vCrMONS+g4u4LRHNgOXVSh3O43J2CnI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0/go.mod h1:Y33QHnf0FfdVewFFISOGe20mkZbxX4H839o955/PoeI=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
@@ -68,14 +65,20 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/databricks/databricks-sdk-go v0.188.0 h1:1nxBqlctGnPzlReyziG199abHs9cR/KNjsCnSM+oDGA=
|
||||
github.com/databricks/databricks-sdk-go v0.188.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8=
|
||||
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -85,23 +88,44 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
|
||||
github.com/junegunn/fzf v0.74.5/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
|
||||
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
|
||||
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso=
|
||||
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM=
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
|
||||
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c=
|
||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
@@ -119,6 +143,7 @@ github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIf
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -127,64 +152,115 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ=
|
||||
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
|
||||
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
|
||||
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.182.0 h1:if5fPvudRQ78GeRx3RayIoiuV7modtErPIZC/T2bIvE=
|
||||
google.golang.org/api v0.182.0/go.mod h1:cGhjy4caqA5yXRzEhkHI8Y9mfyC2VLTlER2l08xaqtM=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
@@ -1,172 +0,0 @@
|
||||
// Package cachefile is the cache of what lazysecrets has loaded
|
||||
// (cache.json): the Sources lists, vaults, secrets' metadata and where
|
||||
// the cursor was in each, never values. The TUI and lazysecrets list
|
||||
// both read it and save to it; a save merges with what's on disk, so
|
||||
// neither undoes what the other loaded.
|
||||
package cachefile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Version is the file's format; a file of another version is ignored,
|
||||
// and replaced on the next save.
|
||||
const Version = 1
|
||||
|
||||
// MaxAge drops what hasn't been loaded again for this long.
|
||||
const MaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// File is the cache. Every key starts with the provider ID: Sources are
|
||||
// keyed by it, the rest by Row, Objects' keys and the like.
|
||||
type File struct {
|
||||
Version int `json:"version"`
|
||||
Sources map[string]Stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
|
||||
Containers map[string]Stamped[[]provider.Container] `json:"containers,omitempty"`
|
||||
Objects map[string]Stamped[[]provider.Object] `json:"objects,omitempty"`
|
||||
Details map[string]Stamped[provider.Object] `json:"details,omitempty"`
|
||||
Cursor map[string]string `json:"cursor,omitempty"`
|
||||
// Dropped are keys deleted since the file was read (a list that
|
||||
// failed to load): Save deletes them from the file too, rather than
|
||||
// merging the old entry back. Not saved.
|
||||
Dropped map[string]bool `json:"-"`
|
||||
}
|
||||
|
||||
// Stamped is a list or detail with when it was loaded.
|
||||
type Stamped[T any] struct {
|
||||
At time.Time `json:"at"`
|
||||
Value T `json:"value"`
|
||||
}
|
||||
|
||||
// New is an empty cache.
|
||||
func New() File {
|
||||
return File{
|
||||
Version: Version,
|
||||
Sources: map[string]Stamped[[]provider.Source]{},
|
||||
Containers: map[string]Stamped[[]provider.Container]{},
|
||||
Objects: map[string]Stamped[[]provider.Object]{},
|
||||
Details: map[string]Stamped[provider.Object]{},
|
||||
Cursor: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// Row keys a source's vaults or scopes.
|
||||
func Row(providerID, sourceID string) string { return providerID + "|" + sourceID }
|
||||
|
||||
// ContainerKey keys what's cached about a vault or scope.
|
||||
func ContainerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
|
||||
|
||||
// ObjectsKey keys a vault's secrets, keys or certificates.
|
||||
func ObjectsKey(c provider.Container, t provider.ObjectType) string {
|
||||
return ContainerKey(c) + "|" + string(t)
|
||||
}
|
||||
|
||||
// ProviderOf is the provider ID a key starts with.
|
||||
func ProviderOf(key string) string {
|
||||
id, _, _ := strings.Cut(key, "|")
|
||||
return id
|
||||
}
|
||||
|
||||
// Keep is Save's keep for these providers: their lists are kept, but not
|
||||
// repos', whose files are read from disk anyway, nor other accounts'.
|
||||
func Keep(providers []provider.Provider) func(providerID string) bool {
|
||||
keep := map[string]bool{}
|
||||
for _, p := range providers {
|
||||
keep[p.ID()] = p.Kind() != provider.KindRepo
|
||||
}
|
||||
return func(id string) bool { return keep[id] }
|
||||
}
|
||||
|
||||
// Read reads path; a missing, unreadable or other version's file is an
|
||||
// empty cache.
|
||||
func Read(path string) File {
|
||||
f := New()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return f
|
||||
}
|
||||
var d File
|
||||
if json.Unmarshal(b, &d) != nil || d.Version != Version {
|
||||
return f
|
||||
}
|
||||
maps.Copy(f.Sources, d.Sources)
|
||||
maps.Copy(f.Containers, d.Containers)
|
||||
maps.Copy(f.Objects, d.Objects)
|
||||
maps.Copy(f.Details, d.Details)
|
||||
maps.Copy(f.Cursor, d.Cursor)
|
||||
return f
|
||||
}
|
||||
|
||||
// Save writes f to path, merged with the file there: of a list in both,
|
||||
// the one loaded last wins, and f's cursors win (a nil Cursor keeps the
|
||||
// file's). Entries older than MaxAge, those of providers keep says no to
|
||||
// (accounts signed out of), and f.Dropped are dropped, from the file's
|
||||
// side as well. Saves from several lazysecrets take turns (a lock file),
|
||||
// so none undoes another's.
|
||||
func Save(path string, f File, keep func(providerID string) bool, now time.Time) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
unlock, err := lock(path + ".lock")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
out := New()
|
||||
ok := func(key string, at time.Time) bool { return keep(ProviderOf(key)) && now.Sub(at) < MaxAge }
|
||||
disk := Read(path)
|
||||
merge(out.Sources, disk.Sources, f.Sources, ok, f.Dropped)
|
||||
merge(out.Containers, disk.Containers, f.Containers, ok, f.Dropped)
|
||||
merge(out.Objects, disk.Objects, f.Objects, ok, f.Dropped)
|
||||
merge(out.Details, disk.Details, f.Details, ok, f.Dropped)
|
||||
for _, m := range []map[string]string{disk.Cursor, f.Cursor} {
|
||||
for k, v := range m {
|
||||
if keep(ProviderOf(k)) {
|
||||
out.Cursor[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return write(path, out)
|
||||
}
|
||||
|
||||
// merge puts disk's entries in out, but not those dropped, then ours
|
||||
// where they're newer.
|
||||
func merge[T any](out, disk, ours map[string]Stamped[T], ok func(string, time.Time) bool, dropped map[string]bool) {
|
||||
for k, e := range disk {
|
||||
if ok(k, e.At) && !dropped[k] {
|
||||
out[k] = e
|
||||
}
|
||||
}
|
||||
for k, e := range ours {
|
||||
if old, seen := out[k]; ok(k, e.At) && (!seen || !e.At.Before(old.At)) {
|
||||
out[k] = e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// write writes f atomically, readable by the user only.
|
||||
func write(path string, f File) error {
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package cachefile
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func list(at time.Time, names ...string) Stamped[[]provider.Container] {
|
||||
var cs []provider.Container
|
||||
for _, n := range names {
|
||||
cs = append(cs, provider.Container{Name: n})
|
||||
}
|
||||
return Stamped[[]provider.Container]{At: at, Value: cs}
|
||||
}
|
||||
|
||||
func TestSaveMerges(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
all := func(string) bool { return true }
|
||||
|
||||
// The TUI saved two lists.
|
||||
tui := New()
|
||||
tui.Containers["az|a"] = list(now.Add(-time.Hour), "kv-a-old")
|
||||
tui.Containers["az|b"] = list(now.Add(-time.Minute), "kv-b")
|
||||
tui.Cursor["az|b"] = "kv-b"
|
||||
if err := Save(path, tui, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// lazysecrets list loads a again, and c.
|
||||
listed := Read(path)
|
||||
listed.Containers["az|a"] = list(now, "kv-a-new")
|
||||
listed.Containers["az|c"] = list(now, "kv-c")
|
||||
if err := Save(path, listed, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The TUI saves what it has, older: list's lists stay.
|
||||
if err := Save(path, tui, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := Read(path)
|
||||
for key, want := range map[string]string{"az|a": "kv-a-new", "az|b": "kv-b", "az|c": "kv-c"} {
|
||||
if got := f.Containers[key].Value; len(got) != 1 || got[0].Name != want {
|
||||
t.Errorf("%s = %v, want %s", key, got, want)
|
||||
}
|
||||
}
|
||||
if f.Cursor["az|b"] != "kv-b" {
|
||||
t.Error("cursor lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDrops(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
f := New()
|
||||
f.Containers["az|old"] = list(now.Add(-MaxAge-time.Hour), "kv-old")
|
||||
f.Containers["gone|x"] = list(now, "kv-gone")
|
||||
f.Containers["az|x"] = list(now, "kv-x")
|
||||
if err := Save(path, f, func(id string) bool { return true }, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Signed out of "gone" since: dropped from the file too.
|
||||
if err := Save(path, New(), func(id string) bool { return id == "az" }, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := Read(path).Containers
|
||||
if _, ok := got["az|old"]; ok {
|
||||
t.Error("kept a list older than MaxAge")
|
||||
}
|
||||
if _, ok := got["gone|x"]; ok {
|
||||
t.Error("kept a signed-out account's list")
|
||||
}
|
||||
if _, ok := got["az|x"]; !ok {
|
||||
t.Error("dropped a list on disk the save didn't have")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOtherVersion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
f := New()
|
||||
f.Version = Version + 1
|
||||
f.Containers["az|x"] = list(time.Now(), "kv-x")
|
||||
if err := write(path, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := Read(path); len(got.Containers) != 0 || got.Version != Version {
|
||||
t.Errorf("read another version's file: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDroppedAndCursors(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
now := time.Now()
|
||||
all := func(string) bool { return true }
|
||||
f := New()
|
||||
f.Containers["az|bad"] = list(now, "kv-old")
|
||||
f.Cursor["az|x"] = "kv-tui"
|
||||
if err := Save(path, f, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The TUI fails to load it again and drops it: it stays gone.
|
||||
f = New()
|
||||
f.Dropped = map[string]bool{"az|bad": true}
|
||||
if err := Save(path, f, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := Read(path).Containers["az|bad"]; ok {
|
||||
t.Error("a dropped list came back")
|
||||
}
|
||||
// lazysecrets list saves without cursors: the TUI's stay.
|
||||
listed := New()
|
||||
listed.Cursor = nil
|
||||
listed.Containers["az|y"] = list(now, "kv-y")
|
||||
if err := Save(path, listed, all, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Read(path).Cursor["az|x"] != "kv-tui" {
|
||||
t.Error("the TUI's cursor was lost")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//go:build !unix
|
||||
|
||||
package cachefile
|
||||
|
||||
// lock does nothing here: saves can then race, losing lists that load
|
||||
// again.
|
||||
func lock(string) (func(), error) { return func() {}, nil }
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build unix
|
||||
|
||||
package cachefile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// lock takes path as a lock file, waiting while another lazysecrets has
|
||||
// it, and returns how to let go.
|
||||
func lock(path string) (unlock func(), err error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
@@ -42,6 +42,9 @@ func New() Clipboard {
|
||||
}
|
||||
}
|
||||
|
||||
// Native reports whether the system clipboard is used rather than OSC52.
|
||||
func (c Clipboard) Native() bool { return c.native }
|
||||
|
||||
// Write puts s on the clipboard.
|
||||
func (c Clipboard) Write(s string) tea.Cmd {
|
||||
if !c.native {
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -61,7 +60,10 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
|
||||
// Validate checks a command from the config.
|
||||
func (c Command) Validate() error {
|
||||
name := cmp.Or(c.Description, c.Command)
|
||||
name := c.Description
|
||||
if name == "" {
|
||||
name = c.Command
|
||||
}
|
||||
switch {
|
||||
case c.Command == "":
|
||||
return fmt.Errorf("custom command %q has no command", c.Description)
|
||||
|
||||
+57
-141
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,24 +14,46 @@ 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"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
UI UI `yaml:"ui"`
|
||||
// Values keeps revealed and copied values from lingering.
|
||||
Values Values `yaml:"values"`
|
||||
// Storage is what's kept between runs.
|
||||
Storage Storage `yaml:"storage"`
|
||||
// Updates sets how lazysecrets looks for newer releases.
|
||||
Updates Updates `yaml:"updates"`
|
||||
Providers Providers `yaml:"providers"`
|
||||
Icons *bool `yaml:"icons"`
|
||||
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
||||
// RevealTimeout hides a revealed value after this long; 0s never.
|
||||
// Defaults to 60s.
|
||||
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
||||
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
||||
// within this many days; 0 marks only expired ones. Defaults to 30.
|
||||
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
|
||||
// HideOnBlur hides a revealed value when the terminal loses focus.
|
||||
// Defaults to true.
|
||||
HideOnBlur *bool `yaml:"hideOnBlur"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// LoadDelay is how long the cursor must rest on a row before its
|
||||
// contents load; 0s loads at once. Defaults to 150ms.
|
||||
LoadDelay *time.Duration `yaml:"loadDelay"`
|
||||
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
|
||||
SourcesView string `yaml:"sourcesView"`
|
||||
// History counts uses of items for the Recent list; defaults to true.
|
||||
History *bool `yaml:"history"`
|
||||
// UpdateCheck looks for a newer release once a day, in the
|
||||
// background; defaults to true. U and lazysecrets update work either
|
||||
// way.
|
||||
UpdateCheck *bool `yaml:"updateCheck"`
|
||||
// Releases are the repositories updates come from, tried in order:
|
||||
// on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, then
|
||||
// git.kebcloud.no (update.DefaultServers).
|
||||
Releases []string `yaml:"releases"`
|
||||
// Cache keeps what was loaded (never values) between runs, so lists
|
||||
// show at once and refresh in the background; defaults to true.
|
||||
Cache *bool `yaml:"cache"`
|
||||
// CollapseGroups starts the Sources pane with its groups folded;
|
||||
// defaults to true.
|
||||
CollapseGroups *bool `yaml:"collapseGroups"`
|
||||
Providers Providers `yaml:"providers"`
|
||||
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
|
||||
LinkHandler LinkHandler `yaml:"linkHandler"`
|
||||
// Sharing sets how env files are shared; see package share.
|
||||
Sharing Sharing `yaml:"sharing"`
|
||||
// CustomCommands run shell commands on the selected item; see
|
||||
// package commands.
|
||||
CustomCommands []commands.Command `yaml:"customCommands"`
|
||||
@@ -44,56 +65,6 @@ type Config struct {
|
||||
Theme map[string][]string `yaml:"theme"`
|
||||
}
|
||||
|
||||
type UI struct {
|
||||
Icons *bool `yaml:"icons"`
|
||||
// RecentIcon marks items used lately (Recent, R) with ◷, in the lists
|
||||
// and in lazysecrets list. Defaults to false.
|
||||
RecentIcon *bool `yaml:"recentIcon"`
|
||||
// SourcesView is "grouped" (default) or "flat"; the backtick key
|
||||
// toggles it.
|
||||
SourcesView string `yaml:"sourcesView"`
|
||||
// CollapseGroups starts the Sources pane with its groups folded;
|
||||
// defaults to true.
|
||||
CollapseGroups *bool `yaml:"collapseGroups"`
|
||||
// LoadDelay is how long the cursor must rest on a row before its
|
||||
// contents load; 0s loads at once. Defaults to 150ms.
|
||||
LoadDelay *time.Duration `yaml:"loadDelay"`
|
||||
// ExpiryWarningDays marks secrets, keys and certificates expiring
|
||||
// within this many days; 0 marks only expired ones. Defaults to 30.
|
||||
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
|
||||
}
|
||||
|
||||
type Values struct {
|
||||
// RevealTimeout hides a revealed value after this long; 0s never.
|
||||
// Defaults to 60s.
|
||||
RevealTimeout *time.Duration `yaml:"revealTimeout"`
|
||||
// HideOnBlur hides a revealed value when the terminal loses focus.
|
||||
// Defaults to true.
|
||||
HideOnBlur *bool `yaml:"hideOnBlur"`
|
||||
ClipboardClear *time.Duration `yaml:"clipboardClear"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
// Cache keeps what was loaded (never values) between runs, so lists
|
||||
// show at once and refresh in the background; defaults to true.
|
||||
Cache *bool `yaml:"cache"`
|
||||
// History counts uses of items for the Recent list; defaults to true.
|
||||
History *bool `yaml:"history"`
|
||||
}
|
||||
|
||||
type Updates struct {
|
||||
// AutoCheck looks for a newer release in the background at startup,
|
||||
// at most once per AutoCheckInterval; defaults to true. U and
|
||||
// lazysecrets update work either way.
|
||||
AutoCheck *bool `yaml:"autoCheck"`
|
||||
// AutoCheckInterval defaults to 24h.
|
||||
AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"`
|
||||
// Sources are the repositories updates come from, tried in
|
||||
// order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub,
|
||||
// then git.kebcloud.no (update.DefaultServers).
|
||||
Sources []string `yaml:"sources"`
|
||||
}
|
||||
|
||||
type LinkHandler struct {
|
||||
// Terminal to open links in, overriding the one handler install
|
||||
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
|
||||
@@ -111,60 +82,30 @@ type LinkHandler struct {
|
||||
Prompt *bool `yaml:"prompt"`
|
||||
}
|
||||
|
||||
type Sharing struct {
|
||||
// Key is the secret whose value encrypts shared files (its
|
||||
// lazysecrets:// link, Key Vault URL or reference), for repos whose
|
||||
// .lazysecrets.yaml doesn't name one.
|
||||
Key string `yaml:"key"`
|
||||
// KeyRules are what a key's value must be to share with it.
|
||||
KeyRules KeyRules `yaml:"keyRules"`
|
||||
}
|
||||
|
||||
// KeyRules default to 32 characters of 2 kinds (share.DefaultRules).
|
||||
type KeyRules struct {
|
||||
MinLength int `yaml:"minLength"` // at least 16
|
||||
// MinClasses is how many kinds of character: lowercase, uppercase,
|
||||
// digits, symbols.
|
||||
MinClasses int `yaml:"minClasses"`
|
||||
}
|
||||
|
||||
type Providers struct {
|
||||
// Timeout bounds each backend request; defaults to 30s. Each
|
||||
// provider's own timeout overrides it for that provider.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
Azure Azure `yaml:"azure"`
|
||||
Databricks Databricks `yaml:"databricks"`
|
||||
Repos Repos `yaml:"repos"`
|
||||
Azure Azure `yaml:"azure"`
|
||||
Databricks Databricks `yaml:"databricks"`
|
||||
Repos Repos `yaml:"repos"`
|
||||
}
|
||||
|
||||
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
|
||||
// that repo is the only one shown; elsewhere, the ones in Paths and under
|
||||
// Roots.
|
||||
type Repos struct {
|
||||
// Timeout overrides providers.timeout for these requests.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Paths []string `yaml:"paths"`
|
||||
// Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
|
||||
// ~/src, ~/code and the like); an empty list turns it off.
|
||||
// Roots are folders of repos. Defaults to ~/git; an empty list
|
||||
// turns it off.
|
||||
Roots *[]string `yaml:"roots"`
|
||||
// Include and Exclude are globs relative to a repo's root (** for any
|
||||
// depth), for repos without their own .lazysecrets.yaml. Without them,
|
||||
// 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 {
|
||||
// Timeout overrides providers.timeout for these requests.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
// HideSubscriptions lists subscriptions to leave out, by name or ID.
|
||||
@@ -175,8 +116,6 @@ type Azure struct {
|
||||
}
|
||||
|
||||
type Databricks struct {
|
||||
// Timeout overrides providers.timeout for these requests.
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
// Enabled defaults to true.
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
HideProfiles []string `yaml:"hideProfiles"`
|
||||
@@ -212,12 +151,17 @@ func xdgDir(env, fallback string) (string, error) {
|
||||
return filepath.Join(base, "lazysecrets"), nil
|
||||
}
|
||||
|
||||
// Load reads path. A missing file is an empty config. Unknown keys are an
|
||||
// error, so typos don't silently do nothing.
|
||||
func Load(path string) (Config, error) {
|
||||
return LoadAll([]string{path})
|
||||
}
|
||||
|
||||
// LoadAll reads several config files in order, each on top of the ones
|
||||
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
|
||||
// settings it sets, and theme roles merge one by one. That lets an editor
|
||||
// plugin add a generated theme file to the user's own config. Missing
|
||||
// files are skipped. Unknown keys are an error, so typos don't silently do
|
||||
// nothing.
|
||||
// files are skipped.
|
||||
func LoadAll(paths []string) (Config, error) {
|
||||
var c Config
|
||||
keybinding := map[string]map[string][]string{}
|
||||
@@ -232,7 +176,9 @@ func LoadAll(paths []string) (Config, error) {
|
||||
if keybinding[ctx] == nil {
|
||||
keybinding[ctx] = map[string][]string{}
|
||||
}
|
||||
maps.Copy(keybinding[ctx], actions)
|
||||
for name, keys := range actions {
|
||||
keybinding[ctx][name] = keys
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Keybinding = keybinding
|
||||
@@ -256,55 +202,25 @@ func decodeInto(c *Config, path string) error {
|
||||
}
|
||||
|
||||
func (c Config) validate(path string) error {
|
||||
if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
||||
return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
|
||||
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
|
||||
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
|
||||
}
|
||||
for _, cmd := range c.CustomCommands {
|
||||
if err := cmd.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
|
||||
return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
|
||||
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
|
||||
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
|
||||
}
|
||||
if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||
return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
|
||||
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
|
||||
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
|
||||
}
|
||||
if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 {
|
||||
return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d)
|
||||
}
|
||||
for i, r := range c.Updates.Sources {
|
||||
for i, r := range c.Releases {
|
||||
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
|
||||
return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo)
|
||||
}
|
||||
}
|
||||
if k := c.Sharing.Key; k != "" {
|
||||
if _, err := share.ParseKey(k); err != nil {
|
||||
return fmt.Errorf("%s: sharing.key: %w", path, err)
|
||||
}
|
||||
}
|
||||
if n := c.Sharing.KeyRules.MinLength; n != 0 && n < 16 {
|
||||
return fmt.Errorf("%s: sharing.keyRules.minLength: %d is under 16", path, n)
|
||||
}
|
||||
if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 {
|
||||
return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n)
|
||||
}
|
||||
for name, d := range map[string]time.Duration{
|
||||
"providers.timeout": c.Providers.Timeout,
|
||||
"providers.azure.timeout": c.Providers.Azure.Timeout,
|
||||
"providers.databricks.timeout": c.Providers.Databricks.Timeout,
|
||||
"providers.repos.timeout": c.Providers.Repos.Timeout,
|
||||
} {
|
||||
if d < 0 {
|
||||
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)
|
||||
|
||||
@@ -18,34 +18,29 @@ func write(t *testing.T, content string) string {
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
c, err := LoadAll([]string{write(t, `
|
||||
ui:
|
||||
icons: false
|
||||
sourcesView: flat
|
||||
values:
|
||||
clipboardClear: 45s
|
||||
storage:
|
||||
history: false
|
||||
c, err := Load(write(t, `
|
||||
icons: false
|
||||
sourcesView: flat
|
||||
clipboardClear: 45s
|
||||
timeout: 1m
|
||||
providers:
|
||||
timeout: 1m
|
||||
azure:
|
||||
timeout: 2m
|
||||
hideSubscriptions: [platform-mgmt]
|
||||
vaults:
|
||||
- https://kv-foo.vault.azure.net/
|
||||
databricks:
|
||||
enabled: false
|
||||
hideProfiles: [test]
|
||||
`)})
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
|
||||
c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
|
||||
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second ||
|
||||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
|
||||
t.Errorf("config = %+v", c)
|
||||
}
|
||||
if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
|
||||
t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
|
||||
if c.SourcesView != "flat" {
|
||||
t.Errorf("sourcesView = %q", c.SourcesView)
|
||||
}
|
||||
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
|
||||
t.Error("enabled flags wrong")
|
||||
@@ -61,8 +56,8 @@ func TestLoadMissingAndEmpty(t *testing.T) {
|
||||
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
|
||||
"empty": write(t, ""),
|
||||
} {
|
||||
c, err := LoadAll([]string{path})
|
||||
if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
|
||||
c, err := Load(path)
|
||||
if err != nil || c.Icons != nil || c.ClipboardClear != nil {
|
||||
t.Errorf("%s: %+v, %v", name, c, err)
|
||||
}
|
||||
}
|
||||
@@ -73,21 +68,12 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
"typo": "icon: true\n",
|
||||
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
|
||||
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
|
||||
"flat keys": "icons: false\n",
|
||||
"duration": "providers:\n timeout: soon\n",
|
||||
"negative": "providers:\n repos:\n timeout: -1s\n",
|
||||
"view": "ui:\n sourcesView: tree\n",
|
||||
"sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n",
|
||||
"http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n",
|
||||
"interval": "updates:\n autoCheckInterval: 0s\n",
|
||||
"share key": "sharing:\n key: https://kv.vault.azure.net/\n",
|
||||
"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",
|
||||
"duration": "timeout: soon\n",
|
||||
"view": "sourcesView: tree\n",
|
||||
"releases": "releases: [git.example.com/team/lazysecrets]\n",
|
||||
"http": "releases: [http://git.example.com/team/lazysecrets]\n",
|
||||
} {
|
||||
if _, err := LoadAll([]string{write(t, content)}); err == nil {
|
||||
if _, err := Load(write(t, content)); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
} else if !strings.Contains(err.Error(), "config.yaml") {
|
||||
t.Errorf("%s: error doesn't name the file: %v", name, err)
|
||||
@@ -97,8 +83,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
|
||||
|
||||
func TestLoadAllLayers(t *testing.T) {
|
||||
user := write(t, `
|
||||
ui:
|
||||
icons: false
|
||||
icons: false
|
||||
theme:
|
||||
azureColor: [blue]
|
||||
errorColor: [red, bold]
|
||||
@@ -107,8 +92,6 @@ providers:
|
||||
hideSubscriptions: [a]
|
||||
`)
|
||||
theme := write(t, `
|
||||
ui:
|
||||
sourcesView: flat
|
||||
theme:
|
||||
azureColor: ["#82aaff"]
|
||||
activeBorderColor: ["#ff966c", bold]
|
||||
@@ -117,8 +100,7 @@ theme:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A group set in both files merges: icons from one, sourcesView the other.
|
||||
if c.UI.Icons == nil || *c.UI.Icons || c.UI.SourcesView != "flat" || len(c.Providers.Azure.HideSubscriptions) != 1 {
|
||||
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
|
||||
t.Errorf("user settings lost: %+v", c)
|
||||
}
|
||||
want := map[string][]string{
|
||||
@@ -133,19 +115,19 @@ theme:
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdates(t *testing.T) {
|
||||
c, err := LoadAll([]string{write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")})
|
||||
if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
|
||||
t.Errorf("updates = %+v, %v", c.Updates, err)
|
||||
func TestReleases(t *testing.T) {
|
||||
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n"))
|
||||
if err != nil || len(c.Releases) != 1 {
|
||||
t.Errorf("releases = %v, %v", c.Releases, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDelay(t *testing.T) {
|
||||
c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
|
||||
if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
|
||||
t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
|
||||
c, err := Load(write(t, "loadDelay: 300ms\n"))
|
||||
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
|
||||
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err)
|
||||
}
|
||||
if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
|
||||
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil {
|
||||
t.Error("a minute's delay accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Package expiry says whether a secret, key or certificate has expired
|
||||
// or will soon, and how long it has left, the same way everywhere: the
|
||||
// TUI's lists and status pane, and lazysecrets list.
|
||||
package expiry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
None State = iota // no expiry, or not within the warning period
|
||||
Soon // within the warning period
|
||||
Expired // already
|
||||
)
|
||||
|
||||
// Of tells whether something expiring at expires (zero: never) has
|
||||
// expired by now or will within warn, and how long it has left.
|
||||
func Of(expires, now time.Time, warn time.Duration) (State, time.Duration) {
|
||||
if expires.IsZero() {
|
||||
return None, 0
|
||||
}
|
||||
left := expires.Sub(now)
|
||||
switch {
|
||||
case left <= 0:
|
||||
return Expired, 0
|
||||
case left <= warn:
|
||||
return Soon, left
|
||||
}
|
||||
return None, left
|
||||
}
|
||||
|
||||
// Left is a short "12d", or "5h" on the last day.
|
||||
func Left(d time.Duration) string {
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package expiry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOf(t *testing.T) {
|
||||
now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)
|
||||
warn := 30 * 24 * time.Hour
|
||||
for _, c := range []struct {
|
||||
expires time.Time
|
||||
want State
|
||||
left string
|
||||
}{
|
||||
{time.Time{}, None, ""},
|
||||
{now.Add(-time.Hour), Expired, ""},
|
||||
{now.Add(12*24*time.Hour + time.Hour), Soon, "12d"},
|
||||
{now.Add(5 * time.Hour), Soon, "5h"},
|
||||
{now.Add(60 * 24 * time.Hour), None, ""},
|
||||
} {
|
||||
state, left := Of(c.expires, now, warn)
|
||||
if state != c.want || (c.left != "" && Left(left) != c.left) {
|
||||
t.Errorf("%v: %v %s, want %v %s", c.expires, state, Left(left), c.want, c.left)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -35,18 +36,15 @@ func Dir(stateDir string) string {
|
||||
type Status struct {
|
||||
PID int `json:"pid"`
|
||||
LastActive time.Time `json:"lastActive"` // last key press or focus
|
||||
Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView
|
||||
// Reach is how readily it can be shown: 2 in view (outside tmux, or
|
||||
// in a tmux session a client shows), 1 in a tmux session that a tmux
|
||||
// client can be switched to, 0 not at all.
|
||||
Reach int `json:"reach"`
|
||||
// App is the terminal app it would show in (macOS), preferring the
|
||||
// app the link was clicked in.
|
||||
App string `json:"app,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
unreachable = 0
|
||||
switchable = 1 // in a tmux session that a tmux client can be switched to
|
||||
inView = 2 // outside tmux, or in a tmux session a client shows
|
||||
)
|
||||
|
||||
type request struct {
|
||||
Op string `json:"op"` // "status" or "open"
|
||||
Link string `json:"link,omitempty"`
|
||||
@@ -116,7 +114,7 @@ func (s *Server) handle(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
var req request
|
||||
if err := json.NewDecoder(c).Decode(&req); err != nil {
|
||||
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
|
||||
return
|
||||
}
|
||||
var resp response
|
||||
@@ -190,7 +188,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, best.Reach > unreachable
|
||||
return best, best.Reach > 0
|
||||
}
|
||||
|
||||
// Open hands link, clicked in front, to f.
|
||||
|
||||
+26
-25
@@ -1,7 +1,6 @@
|
||||
package instance
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
@@ -26,10 +25,9 @@ func tmux(args ...string) (string, error) {
|
||||
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
|
||||
|
||||
type client struct {
|
||||
name, session string
|
||||
activity int // Unix seconds
|
||||
pid int
|
||||
app string
|
||||
name, session, activity string // activity: Unix seconds
|
||||
pid int
|
||||
app string
|
||||
}
|
||||
|
||||
// clients lists the tmux server's clients, the one last typed in first.
|
||||
@@ -44,31 +42,30 @@ func clients() []client {
|
||||
if len(f) < 4 {
|
||||
continue
|
||||
}
|
||||
activity, err1 := strconv.Atoi(f[0])
|
||||
pid, err2 := strconv.Atoi(f[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
pid, err := strconv.Atoi(f[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)})
|
||||
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
|
||||
}
|
||||
slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) })
|
||||
// Activity has the same width for centuries: strings compare like
|
||||
// numbers.
|
||||
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
|
||||
return cs
|
||||
}
|
||||
|
||||
// place is where this instance would show: the tmux client (zero outside
|
||||
// tmux), the app, and its reach.
|
||||
// tmux), the app, and reach: 2 already on screen there (outside tmux, or
|
||||
// a client on its session), 1 after switching a client, 0 nowhere.
|
||||
func place(front string) (client, string, int) {
|
||||
if !inTmux() {
|
||||
return client{}, linkhandler.HostApp(os.Getpid()), inView
|
||||
return client{}, linkhandler.HostApp(os.Getpid()), 2
|
||||
}
|
||||
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
|
||||
if err != nil {
|
||||
return client{}, "", unreachable
|
||||
return client{}, "", 0
|
||||
}
|
||||
cs := clients()
|
||||
if len(cs) == 0 {
|
||||
return client{}, "", unreachable
|
||||
}
|
||||
// Best first: in the front app on its session, in the front app,
|
||||
// on its session, any.
|
||||
rank := func(c client) int {
|
||||
@@ -81,16 +78,20 @@ func place(front string) (client, string, int) {
|
||||
}
|
||||
return r
|
||||
}
|
||||
best := cs[0]
|
||||
for _, c := range cs[1:] {
|
||||
if rank(c) > rank(best) {
|
||||
best = c
|
||||
var best client
|
||||
found := false
|
||||
for _, c := range cs {
|
||||
if !found || rank(c) > rank(best) {
|
||||
best, found = c, true
|
||||
}
|
||||
}
|
||||
if best.session == session {
|
||||
return best, best.app, inView
|
||||
if !found {
|
||||
return client{}, "", 0
|
||||
}
|
||||
return best, best.app, switchable
|
||||
if best.session == session {
|
||||
return best, best.app, 2
|
||||
}
|
||||
return best, best.app, 1
|
||||
}
|
||||
|
||||
// Raise brings this instance into view for a link clicked in front: in
|
||||
@@ -98,14 +99,14 @@ func place(front string) (client, string, int) {
|
||||
// then its app to the front, showing a hidden dropdown terminal.
|
||||
func Raise(front string) {
|
||||
c, app, reach := place(front)
|
||||
if reach == unreachable {
|
||||
if reach == 0 {
|
||||
return
|
||||
}
|
||||
if inTmux() {
|
||||
pane := os.Getenv("TMUX_PANE")
|
||||
_, _ = tmux("select-window", "-t", pane)
|
||||
_, _ = tmux("select-pane", "-t", pane)
|
||||
if reach == switchable {
|
||||
if reach == 1 {
|
||||
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,15 +114,19 @@ func Check() string {
|
||||
}
|
||||
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
|
||||
if err != nil {
|
||||
return "" // can't tell: assume it works
|
||||
return "" // installed before the key was recorded: assume it works
|
||||
}
|
||||
exe := strings.TrimSpace(string(out))
|
||||
if _, err := os.Stat(exe); err != nil {
|
||||
if exe := strings.TrimSpace(string(out)); !exists(exe) {
|
||||
return "lazysecrets:// links run " + exe + ", which no longer exists"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Uninstall removes the applet and its registration.
|
||||
func Uninstall() (string, error) {
|
||||
app, err := AppPath()
|
||||
|
||||
@@ -140,10 +140,11 @@ func appleString(s string) string {
|
||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||
}
|
||||
|
||||
// Launch opens a new terminal window running argv, without waiting for
|
||||
// Launch opens a new terminal window running inner, without waiting for
|
||||
// it.
|
||||
func Launch(argv []string) error {
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,11 +166,9 @@ func Executable() (string, error) {
|
||||
|
||||
// passEnv lists the variables carried from the install's environment to
|
||||
// lazysecrets in the new window: apps start with a minimal environment,
|
||||
// without Homebrew's PATH (so no az) or the user's XDG directories, so
|
||||
// the window would use another cache and miss running instances' sockets.
|
||||
// without Homebrew's PATH (so no az) or the user's config locations.
|
||||
var passEnv = []string{
|
||||
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
|
||||
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
|
||||
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
|
||||
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
// Package listing lists what lazysecrets can find, for lazysecrets list:
|
||||
// what Go to searches, without the TUI. What's cached comes first, at
|
||||
// once; then lists that are missing or older than MaxAge load in the
|
||||
// background, a few at a time, and what's new in them follows as they
|
||||
// arrive. Repos are always read, from disk. Values are never listed.
|
||||
package listing
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// Workers is how many lists load at once, as in Go to.
|
||||
const Workers = 4
|
||||
|
||||
// Kinds are the kinds of entry, from sources down.
|
||||
var Kinds = []string{"source", "vault", "secret", "key", "certificate"}
|
||||
|
||||
// Entry is a source, vault or item, by its lazysecrets:// link.
|
||||
type Entry struct {
|
||||
Link string `json:"link"`
|
||||
// Kind is source (a subscription, workspace or repo), vault (a Key
|
||||
// Vault, secret scope or repo file), secret, key or certificate.
|
||||
Kind string `json:"kind"`
|
||||
// Provider is azure, databricks or repo, as in links.
|
||||
Provider string `json:"provider"`
|
||||
// Path names it: its source's, vault's and own names.
|
||||
Path []string `json:"path"`
|
||||
// Account is the provider it's listed through: an az account
|
||||
// (Azure:<user>/<tenant>), Databricks or Repos. lazysecrets open and
|
||||
// get take it (--account) to use that account when several could.
|
||||
Account string `json:"account"`
|
||||
// AccountLabel names the az account, when there are several.
|
||||
AccountLabel string `json:"accountLabel,omitempty"`
|
||||
// Starred is starred for this account; Recent is used lately (Recent,
|
||||
// R, in the TUI): LastUsed is when, Uses how often.
|
||||
Starred bool `json:"starred"`
|
||||
Recent bool `json:"recent"`
|
||||
LastUsed time.Time `json:"lastUsed,omitzero"`
|
||||
Uses int `json:"uses,omitempty"`
|
||||
// Expires is when a secret, key or certificate expires, if it does;
|
||||
// Enabled whether it's enabled, where the provider says (Azure).
|
||||
Expires time.Time `json:"expires,omitzero"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// Note is the user's note on it, if any.
|
||||
Note string `json:"note,omitempty"`
|
||||
// Reference is how config refers to a secret, where the provider has
|
||||
// a way (@Microsoft.KeyVault(...), {{secrets/scope/key}}); URL its
|
||||
// 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.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Providers []provider.Provider
|
||||
// Cache is what was loaded before; Run adds what it loads.
|
||||
Cache cachefile.File
|
||||
// MaxAge: cached lists loaded longer ago are loaded again.
|
||||
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.
|
||||
Kinds []string
|
||||
// Mark tells what's remembered about the entry with link, seen
|
||||
// through account; nil marks nothing.
|
||||
Mark func(link, account string) Marks
|
||||
Timeout func(provider.Kind) time.Duration // nil: 30s
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Marks are what's remembered about an entry: starred for the account,
|
||||
// used lately (Recent) and when and how often, and its note.
|
||||
type Marks struct {
|
||||
Starred, Recent bool
|
||||
LastUsed time.Time
|
||||
Uses int
|
||||
Note string
|
||||
}
|
||||
|
||||
// Run lists everything, calling emit for each entry once, from this
|
||||
// goroutine, and for each list that can't be loaded, as it fails. An
|
||||
// error from emit (the reader went away) stops it. It returns the cache
|
||||
// with what was loaded, and whether anything was.
|
||||
func Run(ctx context.Context, o Options, emit func(Entry) error) (cachefile.File, bool) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
l := &lister{o: o, emit: emit, seen: map[string]bool{}, pending: map[string]bool{}, failed: map[string]bool{},
|
||||
results: make(chan result)}
|
||||
if l.o.Now == nil {
|
||||
l.o.Now = time.Now
|
||||
}
|
||||
if l.o.Timeout == nil {
|
||||
l.o.Timeout = func(provider.Kind) time.Duration { return 30 * time.Second }
|
||||
}
|
||||
for _, p := range o.Providers {
|
||||
if l.stopped != nil {
|
||||
break
|
||||
}
|
||||
l.provider(p)
|
||||
}
|
||||
l.start(ctx)
|
||||
for l.active > 0 {
|
||||
r := <-l.results
|
||||
l.active--
|
||||
if l.stopped == nil {
|
||||
l.handle(r)
|
||||
l.start(ctx)
|
||||
}
|
||||
if l.stopped != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
return l.o.Cache, l.loaded
|
||||
}
|
||||
|
||||
type job struct {
|
||||
p provider.Provider
|
||||
src *provider.Source // list its vaults
|
||||
c *provider.Container // list its secrets, keys or certificates (t)
|
||||
t provider.ObjectType
|
||||
key string // where the list is cached
|
||||
}
|
||||
|
||||
type result struct {
|
||||
job
|
||||
sources []provider.Source
|
||||
containers []provider.Container
|
||||
objects []provider.Object
|
||||
err error
|
||||
}
|
||||
|
||||
type lister struct {
|
||||
o Options
|
||||
emit func(Entry) error
|
||||
seen map[string]bool // provider ID + link of entries emitted
|
||||
pending map[string]bool // cache keys queued or loading
|
||||
failed map[string]bool // cache keys that couldn't be loaded: not again
|
||||
loaded bool // something was cached
|
||||
queue []job
|
||||
active int
|
||||
results chan result
|
||||
stopped error // emit's error
|
||||
}
|
||||
|
||||
func (l *lister) cacheable(p provider.Provider) bool { return p.Kind() != provider.KindRepo }
|
||||
|
||||
// load reports whether to load a list: a repo's always (from disk), else
|
||||
// unless Cached, when it isn't cached or is older than MaxAge.
|
||||
func (l *lister) load(p provider.Provider, cached bool, at time.Time) bool {
|
||||
if !l.cacheable(p) {
|
||||
return true
|
||||
}
|
||||
return !l.o.Cached && (!cached || l.o.Now().Sub(at) >= l.o.MaxAge)
|
||||
}
|
||||
|
||||
func (l *lister) provider(p provider.Provider) {
|
||||
e, ok := l.o.Cache.Sources[p.ID()]
|
||||
if l.cacheable(p) && ok {
|
||||
for _, src := range sorted(e.Value, func(s provider.Source) string { return s.Name }) {
|
||||
l.source(p, src)
|
||||
}
|
||||
}
|
||||
if l.load(p, ok, e.At) {
|
||||
l.enqueue(job{p: p, key: p.ID()})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
e, ok := l.o.Cache.Containers[row]
|
||||
if l.cacheable(p) && ok {
|
||||
l.containers(p, src, e.Value)
|
||||
}
|
||||
if l.load(p, ok, e.At) {
|
||||
if l.wants("vault", "secret", "key", "certificate") {
|
||||
l.enqueue(job{p: p, src: &src, key: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
key := cachefile.ObjectsKey(c, t)
|
||||
e, ok := l.o.Cache.Objects[key]
|
||||
if l.cacheable(p) {
|
||||
l.objects(p, c, e.Value)
|
||||
}
|
||||
// Keys and certificates load only when asked for; else,
|
||||
// like Go to, only as far as the TUI opened them.
|
||||
asked := l.wants(strings.ToLower(string(t)))
|
||||
if t != provider.TypeSecret {
|
||||
asked = len(l.o.Kinds) > 0 && asked
|
||||
}
|
||||
if asked && l.load(p, ok, e.At) {
|
||||
l.enqueue(job{p: p, c: &c, t: t, key: key})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lister) objects(p provider.Provider, c provider.Container, objs []provider.Object) {
|
||||
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
|
||||
o.Container = c
|
||||
r, err := ref.ForObject(o, "")
|
||||
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, 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, Hidden: src.Hidden}
|
||||
if r, err := ref.ForSource(src); err == nil {
|
||||
e.Link = r.String()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
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, Hidden: c.Hidden}
|
||||
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
||||
e.Link = r.String()
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// put emits e, unless it was already (or emitting has failed). Entries
|
||||
// without a link (one couldn't be made) are left out, but not errors.
|
||||
func (l *lister) put(p provider.Provider, e Entry) {
|
||||
if (e.Link == "" || !l.wants(e.Kind)) && e.Error == "" {
|
||||
return
|
||||
}
|
||||
e.Account = p.ID()
|
||||
if l.o.Mark != nil && e.Link != "" {
|
||||
m := l.o.Mark(e.Link, e.Account)
|
||||
e.Starred, e.Recent, e.Note = m.Starred, m.Recent, m.Note
|
||||
if m.Recent {
|
||||
e.LastUsed, e.Uses = m.LastUsed, m.Uses
|
||||
}
|
||||
}
|
||||
id := p.ID() + "\x00" + e.Link + "\x00" + e.Error
|
||||
if l.stopped != nil || l.seen[id] {
|
||||
return
|
||||
}
|
||||
l.seen[id] = true
|
||||
l.stopped = l.emit(e)
|
||||
}
|
||||
|
||||
// wants reports whether any of kinds is listed.
|
||||
func (l *lister) wants(kinds ...string) bool {
|
||||
if len(l.o.Kinds) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, k := range kinds {
|
||||
if slices.Contains(l.o.Kinds, k) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *lister) enqueue(j job) {
|
||||
if l.pending[j.key] || l.failed[j.key] {
|
||||
return
|
||||
}
|
||||
l.pending[j.key] = true
|
||||
l.queue = append(l.queue, j)
|
||||
}
|
||||
|
||||
func (l *lister) start(ctx context.Context) {
|
||||
for l.stopped == nil && l.active < Workers && len(l.queue) > 0 {
|
||||
j := l.queue[0]
|
||||
l.queue = l.queue[1:]
|
||||
l.active++
|
||||
go func() { l.results <- l.fetch(ctx, j) }()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lister) fetch(ctx context.Context, j job) result {
|
||||
kind := j.p.Kind()
|
||||
ctx, cancel := context.WithTimeout(ctx, l.o.Timeout(kind))
|
||||
defer cancel()
|
||||
r := result{job: j}
|
||||
switch {
|
||||
case j.c != nil:
|
||||
r.objects, r.err = j.p.Objects(ctx, *j.c, j.t)
|
||||
case j.src != nil:
|
||||
r.containers, r.err = j.p.Containers(ctx, *j.src)
|
||||
default:
|
||||
r.sources, r.err = j.p.Sources(ctx)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (l *lister) handle(r result) {
|
||||
delete(l.pending, r.key)
|
||||
now := l.o.Now()
|
||||
if r.err != nil {
|
||||
l.failed[r.key] = true
|
||||
l.put(r.p, r.failed())
|
||||
return
|
||||
}
|
||||
l.loaded = l.loaded || l.cacheable(r.p)
|
||||
cache := l.cacheable(r.p)
|
||||
switch {
|
||||
case r.c != nil:
|
||||
if cache {
|
||||
l.o.Cache.Objects[r.key] = cachefile.Stamped[[]provider.Object]{At: now, Value: r.objects}
|
||||
}
|
||||
l.objects(r.p, *r.c, r.objects)
|
||||
case r.src != nil:
|
||||
r.containers = slices.Clone(r.containers) // the provider's may be shared
|
||||
for i := range r.containers {
|
||||
r.containers[i].Source = *r.src
|
||||
}
|
||||
if cache {
|
||||
l.o.Cache.Containers[r.key] = cachefile.Stamped[[]provider.Container]{At: now, Value: r.containers}
|
||||
}
|
||||
l.containers(r.p, *r.src, r.containers)
|
||||
default:
|
||||
if cache {
|
||||
l.o.Cache.Sources[r.key] = cachefile.Stamped[[]provider.Source]{At: now, Value: r.sources}
|
||||
}
|
||||
for _, src := range r.sources {
|
||||
l.source(r.p, src)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failed is the entry for a list that couldn't be loaded.
|
||||
func (r result) failed() Entry {
|
||||
var e Entry
|
||||
switch {
|
||||
case r.c != nil:
|
||||
e = containerEntry(*r.c)
|
||||
case r.src != nil:
|
||||
src := *r.src
|
||||
src.Provider, src.ProviderLabel = r.p.ID(), r.p.Label()
|
||||
e = sourceEntry(src)
|
||||
default:
|
||||
e = Entry{Kind: "source", Provider: kindOf(r.p.Kind()), Path: []string{r.p.Label()}}
|
||||
}
|
||||
e.Error = firstLine(r.err.Error())
|
||||
return e
|
||||
}
|
||||
|
||||
// firstLine is an error's first line, the summary (Azure's go on with
|
||||
// the caller's IDs and more): an entry is one line. Tabs, which would
|
||||
// split it into columns, are spaces.
|
||||
func firstLine(s string) string {
|
||||
for line := range strings.Lines(s) {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
return strings.ReplaceAll(line, "\t", " ")
|
||||
}
|
||||
}
|
||||
return "unreadable"
|
||||
}
|
||||
|
||||
func kindOf(k provider.Kind) string { return strings.ToLower(string(k)) }
|
||||
|
||||
// accountOf is the account part of a source's provider label ("alice" in
|
||||
// "Azure · alice"), or "" when there's a single account.
|
||||
func accountOf(src provider.Source) string {
|
||||
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
func sorted[T any](s []T, name func(T) string) []T {
|
||||
s = slices.Clone(s)
|
||||
slices.SortStableFunc(s, func(a, b T) int {
|
||||
return cmp.Compare(strings.ToLower(name(a)), strings.ToLower(name(b)))
|
||||
})
|
||||
return s
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
package listing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
)
|
||||
|
||||
// counting counts a provider's requests.
|
||||
type counting struct {
|
||||
provider.Provider
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (c *counting) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
c.calls.Add(1)
|
||||
return c.Provider.Sources(ctx)
|
||||
}
|
||||
|
||||
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
|
||||
c.calls.Add(1)
|
||||
return c.Provider.Containers(ctx, src)
|
||||
}
|
||||
|
||||
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
|
||||
c.calls.Add(1)
|
||||
return c.Provider.Objects(ctx, ct, t)
|
||||
}
|
||||
|
||||
// run lists, returning the entries and, apart, the lists that failed.
|
||||
func run(t *testing.T, o Options) ([]Entry, cachefile.File, []Entry) {
|
||||
t.Helper()
|
||||
if o.Cache.Version == 0 {
|
||||
o.Cache = cachefile.New()
|
||||
}
|
||||
o.Timeout = func(provider.Kind) time.Duration { return 5 * time.Second }
|
||||
var got, failed []Entry
|
||||
f, _ := Run(context.Background(), o, func(e Entry) error {
|
||||
if e.Error != "" {
|
||||
failed = append(failed, e)
|
||||
} else {
|
||||
got = append(got, e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return got, f, failed
|
||||
}
|
||||
|
||||
func has(es []Entry, kind, path string) bool {
|
||||
for _, e := range es {
|
||||
if e.Kind == kind && strings.Join(e.Path, "/") == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestListLoadsAndCaches(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
got, f, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, MaxAge: time.Minute})
|
||||
for _, want := range [][2]string{
|
||||
{"source", "platform-prod"}, {"vault", "platform-prod/kv-app-prod"}, {"secret", "platform-prod/kv-app-prod/db-password"},
|
||||
{"source", "lazysecrets-demo"}, {"secret", "lazysecrets-demo/.env/DATABASE_URL"},
|
||||
} {
|
||||
if !has(got, want[0], want[1]) {
|
||||
t.Errorf("no %s %s", want[0], want[1])
|
||||
}
|
||||
}
|
||||
if len(errs) != 1 || errs[0].Kind != "vault" || errs[0].Path[1] != "kv-restricted" || !strings.Contains(errs[0].Error, "403") || errs[0].Link == "" {
|
||||
t.Errorf("failed = %+v", errs)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, e := range got {
|
||||
if seen[e.Link] {
|
||||
t.Errorf("%s listed twice", e.Link)
|
||||
}
|
||||
seen[e.Link] = true
|
||||
if e.Provider != "azure" && e.Provider != "repo" {
|
||||
t.Errorf("provider %q", e.Provider)
|
||||
}
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Kind == "secret" && e.Provider == "azure" && (!strings.HasPrefix(e.Reference, "@Microsoft.KeyVault(") || e.URL == "") {
|
||||
t.Errorf("%s: reference %q, url %q", e.Link, e.Reference, e.URL)
|
||||
}
|
||||
}
|
||||
if len(f.Sources) != 1 || len(f.Containers) == 0 || len(f.Objects) == 0 {
|
||||
t.Errorf("not cached: %d sources, %d containers, %d objects", len(f.Sources), len(f.Containers), len(f.Objects))
|
||||
}
|
||||
for k := range f.Containers {
|
||||
if cachefile.ProviderOf(k) != az.ID() {
|
||||
t.Errorf("cached a repo: %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Again with that cache: the same, without asking Azure.
|
||||
before := az.calls.Load()
|
||||
again, _, _ := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, MaxAge: time.Minute})
|
||||
if len(again) != len(got) {
|
||||
t.Errorf("%d entries from the cache, %d loaded", len(again), len(got))
|
||||
}
|
||||
// The vault that couldn't be read wasn't cached: it's asked again.
|
||||
if n := az.calls.Load() - before; n != 1 {
|
||||
t.Errorf("%d requests with a fresh cache, want 1 (kv-restricted)", n)
|
||||
}
|
||||
|
||||
// Stale: loaded again, but each entry is still listed once.
|
||||
before = az.calls.Load()
|
||||
stale, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
|
||||
Now: func() time.Time { return time.Now().Add(time.Hour) }})
|
||||
if az.calls.Load()-before < 3 {
|
||||
t.Error("stale lists weren't loaded again")
|
||||
}
|
||||
links := map[string]bool{}
|
||||
for _, e := range stale {
|
||||
if links[e.Link] {
|
||||
t.Errorf("%s listed twice", e.Link)
|
||||
}
|
||||
links[e.Link] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCachedOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||
before := az.calls.Load()
|
||||
got, _, errs := run(t, Options{Providers: []provider.Provider{az, fake.Repos()}, Cache: f, Cached: true,
|
||||
Now: func() time.Time { return time.Now().Add(24 * time.Hour) }})
|
||||
if az.calls.Load() != before || len(errs) != 0 {
|
||||
t.Errorf("--cached asked Azure: %d requests, %v", az.calls.Load()-before, errs)
|
||||
}
|
||||
if !has(got, "secret", "platform-prod/kv-app-prod/db-password") || !has(got, "secret", "lazysecrets-demo/.env/DATABASE_URL") {
|
||||
t.Error("cached secrets or repos missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCachedFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := fake.Azure(0)
|
||||
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||
// Forget one vault's secrets: they load, after everything cached.
|
||||
var dropped string
|
||||
for k := range f.Objects {
|
||||
if strings.Contains(k, "kv-app-prod") && strings.HasSuffix(k, "|Secret") {
|
||||
dropped = k
|
||||
delete(f.Objects, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
if dropped == "" {
|
||||
t.Fatalf("no kv-app-prod in %v", f.Objects)
|
||||
}
|
||||
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute})
|
||||
last := got[len(got)-1]
|
||||
if last.Kind != "secret" || last.Path[1] != "kv-app-prod" {
|
||||
t.Errorf("last = %+v, want a kv-app-prod secret, loaded after the cache", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStopsWhenTheReaderGoes(t *testing.T) {
|
||||
t.Parallel()
|
||||
gone := errors.New("broken pipe")
|
||||
n := 0
|
||||
var errs []Entry
|
||||
Run(context.Background(), Options{
|
||||
Providers: []provider.Provider{fake.Azure(0), fake.Repos()}, Cache: cachefile.New(), MaxAge: time.Minute,
|
||||
Timeout: func(provider.Kind) time.Duration { return time.Second },
|
||||
}, func(e Entry) error {
|
||||
if e.Error != "" {
|
||||
errs = append(errs, e)
|
||||
}
|
||||
n++
|
||||
if n == 2 {
|
||||
return gone
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if n != 2 || len(errs) != 0 {
|
||||
t.Errorf("emitted %d after the reader went, errs %v", n, errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Sources only: nothing under them is asked for.
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"source"}})
|
||||
if len(got) == 0 || az.calls.Load() != 1 {
|
||||
t.Errorf("%d sources, %d requests, want just the sources'", len(got), az.calls.Load())
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Kind != "source" {
|
||||
t.Errorf("listed a %s", e.Kind)
|
||||
}
|
||||
}
|
||||
// Keys, asked for: loaded, though Go to wouldn't; no secrets.
|
||||
got, _, failed := run(t, Options{Providers: []provider.Provider{fake.Azure(0)}, MaxAge: time.Minute, Kinds: []string{"key"}})
|
||||
if !has(got, "key", "platform-prod/kv-app-prod/signing-key") {
|
||||
t.Errorf("no keys: %+v", got)
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Kind != "key" {
|
||||
t.Errorf("listed a %s", e.Kind)
|
||||
}
|
||||
}
|
||||
if len(failed) == 0 {
|
||||
t.Error("a list that failed wasn't listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsAreOneLine(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"403 Forbidden: Caller is not authorized\nCaller: appid=x;oid=y\nVault: kv;location=z": "403 Forbidden: Caller is not authorized",
|
||||
"\n timeout\tafter 30s\n": "timeout after 30s",
|
||||
"": "unreadable",
|
||||
} {
|
||||
if got := firstLine(in); got != want {
|
||||
t.Errorf("firstLine(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMarks(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := fake.Azure(0)
|
||||
var starredLink string
|
||||
got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"},
|
||||
Mark: func(link, account string) Marks {
|
||||
if starredLink == "" && strings.HasSuffix(link, "/db-password") {
|
||||
starredLink = link
|
||||
}
|
||||
if strings.HasSuffix(link, "/api-key") {
|
||||
return Marks{Recent: true, LastUsed: time.Now(), Uses: 3, Note: "rotate in May"}
|
||||
}
|
||||
if strings.HasSuffix(link, "/legacy-token") { // used, but too long ago
|
||||
return Marks{LastUsed: time.Now().AddDate(-2, 0, 0), Uses: 9}
|
||||
}
|
||||
return Marks{Starred: link == starredLink && account == az.ID()}
|
||||
}})
|
||||
var starred, recent int
|
||||
for _, e := range got {
|
||||
if e.Starred {
|
||||
starred++
|
||||
if e.Link != starredLink {
|
||||
t.Errorf("%s starred", e.Link)
|
||||
}
|
||||
}
|
||||
if (e.Note != "") != strings.HasSuffix(e.Link, "/api-key") {
|
||||
t.Errorf("%s: note %q", e.Link, e.Note)
|
||||
}
|
||||
if e.Recent {
|
||||
recent++
|
||||
if e.Uses != 3 || e.LastUsed.IsZero() {
|
||||
t.Errorf("%s: %d uses, last %v", e.Link, e.Uses, e.LastUsed)
|
||||
}
|
||||
} else if e.Uses != 0 || !e.LastUsed.IsZero() {
|
||||
t.Errorf("%s not recent, but %d uses, last %v", e.Link, e.Uses, e.LastUsed)
|
||||
}
|
||||
}
|
||||
if starred != 1 || recent == 0 {
|
||||
t.Errorf("%d starred, %d recent", starred, recent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedListNotRetried(t *testing.T) {
|
||||
t.Parallel()
|
||||
az := &counting{Provider: fake.Azure(0)}
|
||||
_, f, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute})
|
||||
// Everything stale: sources reload, and their vaults are queued again
|
||||
// as each list arrives. kv-restricted fails once, and isn't asked again.
|
||||
before := az.calls.Load()
|
||||
_, _, failed := run(t, Options{Providers: []provider.Provider{az}, Cache: f, MaxAge: time.Minute,
|
||||
Now: func() time.Time { return time.Now().Add(time.Hour) }})
|
||||
if len(failed) != 1 {
|
||||
t.Errorf("failed: %+v", failed)
|
||||
}
|
||||
fresh := &counting{Provider: fake.Azure(0)}
|
||||
run(t, Options{Providers: []provider.Provider{fresh}, MaxAge: time.Minute})
|
||||
if n := az.calls.Load() - before; n > fresh.calls.Load() {
|
||||
t.Errorf("%d requests with a stale cache, more than %d from scratch: something was asked twice", n, fresh.calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithoutTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
Run(context.Background(), Options{Providers: []provider.Provider{fake.Repos()}, Cache: cachefile.New()},
|
||||
func(Entry) error { return nil }) // no Timeout: a default, not a panic
|
||||
}
|
||||
@@ -107,6 +107,9 @@ func parseTerm(tok string) (term, bool) {
|
||||
return t, true
|
||||
}
|
||||
|
||||
// Empty reports whether the query has no terms: everything matches.
|
||||
func (q Query) Empty() bool { return len(q.groups) == 0 }
|
||||
|
||||
// Item is a row to match: Text is everything searched, and runes
|
||||
// NameStart up to NameEnd of it are the row's name (the rest is context,
|
||||
// like a path or a note). Without a name, nothing gets the name bonus.
|
||||
|
||||
@@ -99,3 +99,9 @@ func TestAnchorsOnTheName(t *testing.T) {
|
||||
t.Errorf("password$: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmpty(t *testing.T) {
|
||||
if !Parse(" | ").Empty() || Parse("x").Empty() {
|
||||
t.Error("Empty")
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,10 @@ import (
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
@@ -100,7 +99,13 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
|
||||
// Sources lists the subscriptions the signed-in identity can see, skipping
|
||||
// disabled and deleted ones.
|
||||
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []provider.Source) {
|
||||
var out []provider.Source
|
||||
pager := p.subs.NewListPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
switch deref(s.State) {
|
||||
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
|
||||
@@ -113,7 +118,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
p.mu.Lock()
|
||||
p.tenants[id] = tenant
|
||||
p.mu.Unlock()
|
||||
srcs = append(srcs, provider.Source{
|
||||
out = append(out, provider.Source{
|
||||
Kind: provider.KindAzure,
|
||||
ID: id,
|
||||
Name: deref(s.DisplayName),
|
||||
@@ -121,10 +126,6 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
|
||||
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
|
||||
})
|
||||
}
|
||||
return srcs
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
if len(p.opts.Vaults) > 0 {
|
||||
@@ -229,29 +230,22 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
|
||||
for _, v := range page.Value {
|
||||
if v.Properties == nil || v.Properties.VaultURI == nil {
|
||||
continue
|
||||
}
|
||||
vaults = append(vaults, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||
})
|
||||
}
|
||||
return vaults
|
||||
})
|
||||
}
|
||||
|
||||
// all lists every page, items picking out what to keep of each.
|
||||
func all[P, T any](ctx context.Context, pager *runtime.Pager[P], items func(P) []T) ([]T, error) {
|
||||
var out []T
|
||||
var out []provider.Container
|
||||
pager := client.NewListBySubscriptionPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
out = append(out, items(page)...)
|
||||
for _, v := range page.Value {
|
||||
if v.Properties == nil || v.Properties.VaultURI == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, provider.Container{
|
||||
Source: src, ID: deref(v.ID), Name: deref(v.Name),
|
||||
Detail: *v.Properties.VaultURI, Types: objectTypes, Attrs: armInfo(v).attrs(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -294,40 +288,51 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
||||
var out []provider.Object
|
||||
switch t {
|
||||
case provider.TypeSecret:
|
||||
tenant := p.tenant(c.Source.ID)
|
||||
out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
|
||||
pager := vc.secrets.NewListSecretPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
// Certificate-backing secrets appear under Certificates.
|
||||
if s.ID != nil && !deref(s.Managed) {
|
||||
objs = append(objs, secretObject(c, s, tenant))
|
||||
if s.ID == nil || deref(s.Managed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, secretObject(c, s))
|
||||
}
|
||||
return objs
|
||||
})
|
||||
}
|
||||
case provider.TypeKey:
|
||||
out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
|
||||
pager := vc.keys.NewListKeyPropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, k := range page.Value {
|
||||
if k.KID != nil && !deref(k.Managed) {
|
||||
objs = append(objs, keyObject(c, k))
|
||||
if k.KID == nil || deref(k.Managed) {
|
||||
continue
|
||||
}
|
||||
out = append(out, keyObject(c, k))
|
||||
}
|
||||
return objs
|
||||
})
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID != nil {
|
||||
objs = append(objs, certObject(c, cp))
|
||||
}
|
||||
pager := vc.certs.NewListCertificatePropertiesPager(nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
return objs
|
||||
})
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, certObject(c, cp))
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
|
||||
return out, nil
|
||||
}
|
||||
@@ -348,7 +353,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
|
||||
if resp.Key != nil {
|
||||
if resp.Key.KID != nil {
|
||||
o.Version = resp.Key.KID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
|
||||
}
|
||||
o.Attrs = keyAttrs(resp.Key)
|
||||
}
|
||||
@@ -359,7 +364,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
|
||||
}
|
||||
if resp.ID != nil {
|
||||
o.Version = resp.ID.Version()
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
|
||||
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
|
||||
}
|
||||
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
|
||||
o.ContentType = deref(pol.SecretProperties.ContentType)
|
||||
@@ -376,47 +381,52 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenant := p.tenant(o.Container.Source.ID)
|
||||
var out []provider.Object
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
|
||||
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, s := range page.Value {
|
||||
if s.ID != nil {
|
||||
objs = append(objs, secretObject(o.Container, s, tenant))
|
||||
out = append(out, secretObject(o.Container, s))
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
}
|
||||
case provider.TypeKey:
|
||||
out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
|
||||
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, k := range page.Value {
|
||||
if k.KID != nil {
|
||||
objs = append(objs, keyObject(o.Container, k))
|
||||
out = append(out, keyObject(o.Container, k))
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
}
|
||||
case provider.TypeCertificate:
|
||||
out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
|
||||
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, cleanError(err)
|
||||
}
|
||||
for _, cp := range page.Value {
|
||||
if cp.ID != nil {
|
||||
objs = append(objs, certObject(o.Container, cp))
|
||||
out = append(out, certObject(o.Container, cp))
|
||||
}
|
||||
}
|
||||
return objs
|
||||
})
|
||||
}
|
||||
default:
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Reference = versionedReference(out[i])
|
||||
if o.Type != provider.TypeSecret { // secretObject's is the version's already
|
||||
out[i].URL = portalAssetURL(tenant, o.Type, out[i].ID+"/"+out[i].Version) // the version's page
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
|
||||
return out, nil
|
||||
@@ -437,55 +447,41 @@ func versionedReference(o provider.Object) string {
|
||||
// Value returns a secret's value, a key's public key or a certificate's
|
||||
// public certificate, as of o.Version (latest if empty).
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
v, _, err := p.VersionedValue(ctx, o)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// VersionedValue is Value, with the version read.
|
||||
func (p *Provider) VersionedValue(ctx context.Context, o provider.Object) (provider.SecretValue, string, error) {
|
||||
vc, err := p.clients(o.Container.Detail)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", err
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
var text string
|
||||
switch o.Type {
|
||||
case provider.TypeSecret:
|
||||
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
version := o.Version
|
||||
if resp.ID != nil {
|
||||
version = resp.ID.Version()
|
||||
}
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
|
||||
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
|
||||
case provider.TypeKey:
|
||||
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
if text, err = publicKeyPEM(resp.Key); err != nil {
|
||||
return provider.SecretValue{}, "", err
|
||||
pemKey, err := publicKeyPEM(resp.Key)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
return provider.NewSecretValue([]byte(pemKey)), nil
|
||||
case provider.TypeCertificate:
|
||||
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, "", cleanError(err)
|
||||
return provider.SecretValue{}, cleanError(err)
|
||||
}
|
||||
if len(resp.CER) == 0 {
|
||||
return provider.SecretValue{}, "", errors.New("certificate has no public certificate yet (pending issuance?)")
|
||||
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
|
||||
}
|
||||
text = certPEM(resp.CER)
|
||||
default:
|
||||
return provider.SecretValue{}, "", provider.ErrUnsupported
|
||||
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
|
||||
}
|
||||
return provider.NewSecretValue([]byte(text)), o.Version, nil
|
||||
return provider.SecretValue{}, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
var (
|
||||
_ provider.Locator = (*Provider)(nil)
|
||||
_ provider.Versioned = (*Provider)(nil)
|
||||
)
|
||||
var _ provider.Locator = (*Provider)(nil)
|
||||
|
||||
// validHost guards the Resource Graph query against odd input.
|
||||
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
@@ -335,21 +334,6 @@ func TestPortalURLs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretPages(t *testing.T) {
|
||||
c := provider.Container{Detail: "https://kv-app.vault.azure.net/"}
|
||||
base := "https://portal.azure.com/#@contoso.onmicrosoft.com/asset/Microsoft_Azure_KeyVault/Secret/https://kv-app.vault.azure.net/secrets/db"
|
||||
for id, want := range map[string]string{
|
||||
"https://kv-app.vault.azure.net/secrets/db": base, // listed: the secret's page
|
||||
"https://kv-app.vault.azure.net/secrets/db/06140fa2b9a64869a96ef040f6d4dcab": base + "/06140fa2b9a64869a96ef040f6d4dcab", // a version's
|
||||
} {
|
||||
sid := azsecrets.ID(id)
|
||||
o := secretObject(c, &azsecrets.SecretProperties{ID: &sid}, "contoso.onmicrosoft.com")
|
||||
if o.URL != want {
|
||||
t.Errorf("%s: %q, want %q", id, o.URL, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionedReference(t *testing.T) {
|
||||
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
|
||||
|
||||
@@ -53,10 +53,10 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
|
||||
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
|
||||
}
|
||||
|
||||
// portalAssetURL links to a Key Vault object's page: a version's with
|
||||
// one in objectID; a secret's own (its versions) without.
|
||||
func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
|
||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
|
||||
// portalAssetURL links to a Key Vault object's page; objectID must include
|
||||
// the version.
|
||||
func portalAssetURL(tenant, kind, objectID string) string {
|
||||
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
|
||||
}
|
||||
|
||||
func tenantPrefix(tenant string) string {
|
||||
@@ -73,21 +73,24 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return base + "/" + t.Collection()
|
||||
switch t {
|
||||
case provider.TypeKey:
|
||||
return base + "/keys"
|
||||
case provider.TypeCertificate:
|
||||
return base + "/certificates"
|
||||
}
|
||||
return base + "/secrets"
|
||||
}
|
||||
|
||||
// objectURL is the data-plane URL of an object, e.g.
|
||||
// https://kv.vault.azure.net/secrets/name.
|
||||
func objectURL(c provider.Container, t provider.ObjectType, name string) string {
|
||||
return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
|
||||
func objectURL(c provider.Container, collection, name string) string {
|
||||
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
|
||||
}
|
||||
|
||||
// secretObject is a secret, or a version of one, from a listing. Its URL
|
||||
// is its page in the portal: the secret's (with its versions), or with
|
||||
// a version, that version's.
|
||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant string) provider.Object {
|
||||
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, provider.TypeSecret, name)
|
||||
url := objectURL(c, "secrets", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeSecret,
|
||||
@@ -97,7 +100,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
|
||||
ContentType: deref(p.ContentType),
|
||||
Tags: tags(p.Tags),
|
||||
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
|
||||
URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
|
||||
URL: ObjectListURL(c, provider.TypeSecret),
|
||||
}
|
||||
if a := p.Attributes; a != nil {
|
||||
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
|
||||
@@ -107,7 +110,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties, tenant st
|
||||
|
||||
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
||||
name := p.KID.Name()
|
||||
url := objectURL(c, provider.TypeKey, name)
|
||||
url := objectURL(c, "keys", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeKey,
|
||||
@@ -126,7 +129,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
|
||||
|
||||
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
|
||||
name := p.ID.Name()
|
||||
url := objectURL(c, provider.TypeCertificate, name)
|
||||
url := objectURL(c, "certificates", name)
|
||||
o := provider.Object{
|
||||
Container: c,
|
||||
Type: provider.TypeCertificate,
|
||||
|
||||
@@ -2,12 +2,11 @@ package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
@@ -71,10 +70,13 @@ func graphInfo(m map[string]any) vaultInfo {
|
||||
|
||||
// armInfo reads a vault's settings from the management API.
|
||||
func armInfo(v *armkeyvault.Vault) vaultInfo {
|
||||
info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
|
||||
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}}
|
||||
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
|
||||
info.group = id.ResourceGroupName
|
||||
}
|
||||
for k, t := range v.Tags {
|
||||
info.tags[k] = deref(t)
|
||||
}
|
||||
pr := v.Properties
|
||||
if pr == nil {
|
||||
return info
|
||||
@@ -120,11 +122,7 @@ func (v vaultInfo) attrs() []provider.Attr {
|
||||
} else {
|
||||
add("soft delete", "off")
|
||||
}
|
||||
if on(v.purge, false) {
|
||||
add("purge prot.", "on")
|
||||
} else {
|
||||
add("purge prot.", "off")
|
||||
}
|
||||
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)])
|
||||
switch {
|
||||
case strings.EqualFold(v.publicAccess, "disabled"):
|
||||
add("network", "private endpoints only")
|
||||
@@ -133,13 +131,15 @@ func (v vaultInfo) attrs() []provider.Attr {
|
||||
default:
|
||||
add("network", "all networks")
|
||||
}
|
||||
switch {
|
||||
case v.endpoints == 1:
|
||||
add("private", "1 endpoint")
|
||||
case v.endpoints > 1:
|
||||
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
|
||||
if v.endpoints > 0 {
|
||||
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1]))
|
||||
}
|
||||
for i, k := range slices.Sorted(maps.Keys(v.tags)) {
|
||||
keys := make([]string, 0, len(v.tags))
|
||||
for k := range v.tags {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for i, k := range keys {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
label = "tags"
|
||||
|
||||
@@ -203,20 +203,17 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fallbackVault is the backing vault of an AKV-backed scope, to read it
|
||||
// from when Databricks fails, or "" when there's none to fall back to. It
|
||||
// falls back to the scope's Detail, so a scope remembered from an earlier
|
||||
// session (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) fallbackVault(ctx context.Context, c provider.Container) string {
|
||||
if p.keyVault == nil || ctx.Err() != nil {
|
||||
return ""
|
||||
}
|
||||
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
|
||||
// back to the scope's Detail, so a scope remembered from an earlier session
|
||||
// (a favourite) works before its workspace's scopes are listed.
|
||||
func (p *Provider) vaultURL(c provider.Container) string {
|
||||
p.mu.Lock()
|
||||
url := p.vaults[c.ID]
|
||||
p.mu.Unlock()
|
||||
if url == "" {
|
||||
if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
|
||||
url = detail
|
||||
url, _ = strings.CutPrefix(c.Detail, akvDetail)
|
||||
if url == c.Detail {
|
||||
url = ""
|
||||
}
|
||||
}
|
||||
return url
|
||||
@@ -267,8 +264,8 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
|
||||
|
||||
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
|
||||
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
|
||||
url := p.fallbackVault(ctx, c)
|
||||
if url == "" {
|
||||
url := p.vaultURL(c)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return nil, errNoFallback
|
||||
}
|
||||
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
|
||||
@@ -305,8 +302,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
}
|
||||
dbxErr := classify(pr, err)
|
||||
|
||||
url := p.fallbackVault(ctx, o.Container)
|
||||
if url == "" {
|
||||
url := p.vaultURL(o.Container)
|
||||
if url == "" || p.keyVault == nil || ctx.Err() != nil {
|
||||
return provider.SecretValue{}, dbxErr
|
||||
}
|
||||
v, kvErr := p.keyVault.Value(ctx, provider.Object{
|
||||
|
||||
@@ -48,8 +48,19 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
|
||||
|
||||
func (p *Provider) Kind() provider.Kind { return p.kind }
|
||||
|
||||
func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
|
||||
func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
|
||||
func (p *Provider) ID() string {
|
||||
if p.id != "" {
|
||||
return p.id
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
func (p *Provider) Label() string {
|
||||
if p.label != "" {
|
||||
return p.label
|
||||
}
|
||||
return string(p.kind)
|
||||
}
|
||||
|
||||
// As renames the provider, to simulate several accounts of one kind.
|
||||
func (p *Provider) As(id, label string) *Provider {
|
||||
@@ -123,14 +134,13 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return provider.NewSecretValue([]byte(v)), nil
|
||||
}
|
||||
|
||||
// Locate finds the source holding the vault (by host), the workspace or
|
||||
// the repo (by its remote).
|
||||
// Locate finds the source holding the vault (by host) or the workspace.
|
||||
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
|
||||
if err := p.wait(ctx); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, src := range p.sources {
|
||||
if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) {
|
||||
if p.kind == provider.KindDatabricks && src.Detail == host {
|
||||
return src.ID, true, nil
|
||||
}
|
||||
for _, c := range p.containers[src.ID] {
|
||||
@@ -207,25 +217,14 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
|
||||
return c
|
||||
}
|
||||
|
||||
// Expire sets when the object name in vault expires, for --demo: a date
|
||||
// relative to today in the test data would change the tests' screenshots
|
||||
// from day to day.
|
||||
func (p *Provider) Expire(vault, name string, at time.Time) {
|
||||
for _, objs := range p.objects {
|
||||
for i := range objs {
|
||||
if objs[i].Container.Name == vault && objs[i].Name == name {
|
||||
objs[i].Expires = at
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
|
||||
o.Container = c
|
||||
o.Type = t
|
||||
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
|
||||
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
|
||||
o.URL = base + "/" + t.Collection()
|
||||
o.URL = base + map[provider.ObjectType]string{
|
||||
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
|
||||
}[t]
|
||||
}
|
||||
k := listKey{c.ID, t}
|
||||
p.objects[k] = append(p.objects[k], o)
|
||||
@@ -239,7 +238,11 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
|
||||
}
|
||||
}
|
||||
|
||||
var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
||||
var (
|
||||
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
|
||||
yes = true
|
||||
no = false
|
||||
)
|
||||
|
||||
// ErrForbidden mimics a data-plane permission error from a backend.
|
||||
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
|
||||
@@ -254,13 +257,12 @@ const (
|
||||
|
||||
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
|
||||
|
||||
func secretRef(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
|
||||
// Azure returns a fake Azure provider with two subscriptions.
|
||||
func Azure(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
objectURL := func(vault, kind, name string) string {
|
||||
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
|
||||
}
|
||||
@@ -268,32 +270,28 @@ func Azure(delay time.Duration) *Provider {
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
|
||||
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
|
||||
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
|
||||
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
|
||||
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
|
||||
}, "correct-horse-battery-staple")
|
||||
p.versions[dbPassword.ID] = []provider.Object{
|
||||
{Version: "3f9c2a", Enabled: new(true), Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
||||
{Version: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
||||
{Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
|
||||
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
|
||||
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
|
||||
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
|
||||
}
|
||||
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
|
||||
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
|
||||
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Reference: secretRef("kv-app-prod", "api-key"),
|
||||
}, "sk_live_51HxDEMO0000000000000000")
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
|
||||
Reference: secretRef("kv-app-prod", "legacy-token"),
|
||||
}, "deprecated")
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Version: "7d2e90", Enabled: new(false), Updated: base.AddDate(0, -2, 0),
|
||||
Reference: secretRef("kv-app-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
|
||||
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "RSA 2048"},
|
||||
{Label: "ops", Value: "sign, verify"},
|
||||
@@ -301,7 +299,7 @@ func Azure(delay time.Duration) *Provider {
|
||||
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
|
||||
}, demoPublicKey)
|
||||
p.add(kv, provider.TypeKey, provider.Object{
|
||||
Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
|
||||
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "key type", Value: "EC P-256"},
|
||||
{Label: "ops", Value: "wrapKey, unwrapKey"},
|
||||
@@ -310,7 +308,7 @@ func Azure(delay time.Duration) *Provider {
|
||||
}, demoPublicKey)
|
||||
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
|
||||
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
|
||||
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=app.example.com"},
|
||||
@@ -322,7 +320,7 @@ func Azure(delay time.Duration) *Provider {
|
||||
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
|
||||
}, demoCert)
|
||||
p.add(kv, provider.TypeCertificate, provider.Object{
|
||||
Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
|
||||
Attrs: []provider.Attr{
|
||||
{Label: "subject", Value: "CN=old.example.com"},
|
||||
@@ -333,12 +331,8 @@ func Azure(delay time.Duration) *Provider {
|
||||
}, demoCert)
|
||||
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
// A key to share env files with (S → f): random, as sharing.keyRules wants.
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "env-share", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
||||
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
|
||||
|
||||
@@ -348,21 +342,8 @@ func Azure(delay time.Duration) *Provider {
|
||||
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
|
||||
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
|
||||
}, "dev-password")
|
||||
// Left behind, as dev vaults are: expired, disabled, or both.
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "stripe-webhook-secret", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
|
||||
Expires: base.AddDate(0, -2, 0), Reference: secretRef("kv-app-dev", "stripe-webhook-secret"),
|
||||
}, "whsec_demo_expired")
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "test-user-password", Enabled: new(false), Updated: base.AddDate(0, -5, 0),
|
||||
Reference: secretRef("kv-app-dev", "test-user-password"),
|
||||
}, "disabled-demo-password")
|
||||
p.add(kvDev, provider.TypeSecret, provider.Object{
|
||||
Name: "vendor-token", Enabled: new(false), Updated: base.AddDate(-2, 0, 0),
|
||||
Expires: base.AddDate(-1, 0, 0), Reference: secretRef("kv-app-dev", "vendor-token"),
|
||||
}, "expired-and-disabled")
|
||||
|
||||
return p
|
||||
}
|
||||
@@ -372,21 +353,23 @@ func Azure(delay time.Duration) *Provider {
|
||||
// its own, like an admin account next to a personal one.
|
||||
func AzureOps(delay time.Duration) *Provider {
|
||||
p := newProvider(provider.KindAzure, delay)
|
||||
secretRef := func(vault, name string) string {
|
||||
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
|
||||
}
|
||||
|
||||
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
|
||||
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
|
||||
p.add(breakGlass, provider.TypeSecret, provider.Object{
|
||||
Name: "emergency-admin", Enabled: new(true), Updated: base.AddDate(0, -4, 0),
|
||||
Expires: time.Now().AddDate(0, 0, 5).Truncate(24 * time.Hour), // soon, whenever the demo runs
|
||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
|
||||
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
|
||||
}, "break-glass-demo-password")
|
||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||
p.add(shared, provider.TypeSecret, provider.Object{
|
||||
Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||
}, "smtp-demo-password")
|
||||
// Unlike the first account, ops may read keys here.
|
||||
p.add(shared, provider.TypeKey, provider.Object{
|
||||
Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
|
||||
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
|
||||
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
|
||||
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
|
||||
}, demoPublicKey)
|
||||
@@ -394,14 +377,8 @@ func AzureOps(delay time.Duration) *Provider {
|
||||
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
|
||||
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
|
||||
}, "pd-demo-token")
|
||||
// Expiring soon whenever the demo runs: the other dates are fixed,
|
||||
// and would have passed. Tests use the first account only.
|
||||
p.add(kv, provider.TypeSecret, provider.Object{
|
||||
Name: "grafana-api-key", Enabled: new(true), Updated: base.AddDate(0, -11, 0),
|
||||
Expires: time.Now().AddDate(0, 0, 12).Truncate(24 * time.Hour), Reference: secretRef("kv-ops-tools", "grafana-api-key"),
|
||||
}, "glsa_demo_expiring")
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
@@ -19,9 +18,8 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
return attrs, ctx.Err()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Repos returns a fake repo provider: three git repos with SOPS and plain
|
||||
// .env files, the first one the repo lazysecrets was started in.
|
||||
func Repos() *Provider {
|
||||
p := newProvider(provider.KindRepo, 0)
|
||||
p.id, p.label = "Repos", "Repos"
|
||||
@@ -37,7 +35,7 @@ func Repos() *Provider {
|
||||
// commit are what git says of it: "tracked", "ignored" or "untracked".
|
||||
file := func(src provider.Source, path, method, git, commit string, kv ...string) {
|
||||
sops := method != ""
|
||||
detail := provider.DetailPlainEnv
|
||||
detail := "env · plain text"
|
||||
if sops {
|
||||
detail = "sops · " + method
|
||||
}
|
||||
@@ -96,7 +94,7 @@ func Repos() *Provider {
|
||||
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
|
||||
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
|
||||
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
|
||||
"github.client_secret", "8f3c2a9d7e1b6f4a0c5d2e9b8a7f6c3d1e0b4a5f",
|
||||
"github.client_secret", "ghs_demo0000000000000000000000",
|
||||
"google.client_secret", "GOCSPX-demo-000000000000")
|
||||
file(api, ".env", "", "ignored", "",
|
||||
"DATABASE_URL", "postgres://app:local@localhost:5432/app",
|
||||
@@ -115,90 +113,6 @@ 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",
|
||||
@@ -220,28 +134,3 @@ func format(path string) string {
|
||||
}
|
||||
return "dotenv"
|
||||
}
|
||||
|
||||
var _ provider.Contents = (*Provider)(nil)
|
||||
|
||||
// Contents is a plain env file as KEY=value lines, from its keys, so the
|
||||
// demo repos' env files can be shared.
|
||||
func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte, error) {
|
||||
if c.Detail != provider.DetailPlainEnv {
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, o := range p.objects[listKey{c.ID, provider.TypeSecret}] {
|
||||
b.WriteString(o.Name + "=" + p.values[o.ID] + "\n")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// Seed stars, notes and uses a few of the demo's secrets, so --demo
|
||||
// shows Favourites (F), Recent (R), notes, and stars in lazysecrets list. alice and
|
||||
// admin are the two Azure accounts' provider IDs.
|
||||
func Seed(st *store.Store, alice, admin string) {
|
||||
type item struct {
|
||||
link, account, source string
|
||||
name string // else the link's last part
|
||||
note string
|
||||
path []string
|
||||
star bool
|
||||
uses int
|
||||
ago time.Duration
|
||||
}
|
||||
const azure, dbx = "lazysecrets://azure/", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/"
|
||||
for _, it := range []item{
|
||||
{link: azure + "kv-app-prod.vault.azure.net/secrets/db-password", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-app-prod"}, star: true, uses: 12, ago: 20 * time.Minute,
|
||||
note: "rotated by the data team each quarter"},
|
||||
{link: azure + "kv-app-prod.vault.azure.net/secrets/api-key", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-app-prod"}, uses: 4, ago: 2 * time.Hour},
|
||||
{link: azure + "kv-breakglass.vault.azure.net/secrets/emergency-admin", account: admin, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-breakglass"}, star: true, note: "incidents only: tell #ops when used"},
|
||||
{link: azure + "kv-shared-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-shared-prod"}, uses: 2, ago: 26 * time.Hour},
|
||||
{link: azure + "kv-app-prod.vault.azure.net/secrets/smtp-password", account: alice, source: "sub-prod",
|
||||
path: []string{"platform-prod", "kv-app-prod"}, note: "migrated to Microsoft Graph sendMail; delete after Q4"},
|
||||
{link: dbx + "app/storage-key", account: "Databricks", source: "dbx-prod",
|
||||
path: []string{"prod", "app"}, star: true, uses: 3, ago: 5 * time.Hour},
|
||||
// A subscription, for both accounts, and a repo's file.
|
||||
{link: azure + "subscriptions/sub-prod", account: alice, source: "sub-prod",
|
||||
name: "platform-prod", star: true},
|
||||
{link: azure + "subscriptions/sub-prod", account: admin, source: "sub-prod",
|
||||
name: "platform-prod", star: true},
|
||||
{link: "lazysecrets://repo/github.com%2Facme%2Flazysecrets-demo/deploy%2Fprod%2Fsecrets.yaml", account: "Repos",
|
||||
source: "/home/dev/git/lazysecrets-demo", name: "deploy/prod/secrets.yaml",
|
||||
path: []string{"lazysecrets-demo"}, star: true},
|
||||
} {
|
||||
context := func(n *store.Node) {
|
||||
n.Name = cmp.Or(it.name, it.link[strings.LastIndex(it.link, "/")+1:])
|
||||
n.Path, n.Source, n.Account = it.path, it.source, it.account
|
||||
}
|
||||
if it.star || it.note != "" {
|
||||
_ = st.Update(it.link, func(n *store.Node) {
|
||||
context(n)
|
||||
if it.star {
|
||||
n.ToggleStar(it.account)
|
||||
}
|
||||
n.Note = it.note
|
||||
})
|
||||
}
|
||||
for range it.uses {
|
||||
_ = st.Use(it.link, time.Now().Add(-it.ago), context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,6 @@ 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.
|
||||
@@ -88,20 +84,6 @@ const (
|
||||
TypeCertificate ObjectType = "Certificate"
|
||||
)
|
||||
|
||||
// Collection is the type's path segment in Key Vault URLs and links, e.g.
|
||||
// "secrets".
|
||||
func (t ObjectType) Collection() string {
|
||||
switch t {
|
||||
case TypeSecret:
|
||||
return "secrets"
|
||||
case TypeKey:
|
||||
return "keys"
|
||||
case TypeCertificate:
|
||||
return "certificates"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Container holds objects: a Key Vault or a Databricks secret scope.
|
||||
type Container struct {
|
||||
Source Source
|
||||
@@ -113,8 +95,7 @@ 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"`
|
||||
Hidden bool `json:",omitempty"` // see Source.Hidden
|
||||
Attrs []Attr `json:",omitempty"`
|
||||
}
|
||||
|
||||
// ObjectTypes returns the container's object types, defaulting to secrets.
|
||||
@@ -138,8 +119,6 @@ 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
|
||||
@@ -188,13 +167,6 @@ type Current interface {
|
||||
Current(ctx context.Context) (Source, bool)
|
||||
}
|
||||
|
||||
// Versioned is implemented by providers whose secrets have versions: it
|
||||
// is Value, also returning the version read (the current one when
|
||||
// o.Version is empty), so a link can pin it.
|
||||
type Versioned interface {
|
||||
VersionedValue(ctx context.Context, o Object) (SecretValue, string, error)
|
||||
}
|
||||
|
||||
// Locator is implemented by providers that can find where a container lives,
|
||||
// to open links. address is a vault host (kv.vault.azure.net) or a
|
||||
// workspace host. It returns the ID of the source holding it.
|
||||
@@ -210,12 +182,6 @@ type Files interface {
|
||||
File(c Container, o *Object) (File, error)
|
||||
}
|
||||
|
||||
// Contents is implemented by providers whose containers are files, so
|
||||
// they can be shared (package share): the file as it is.
|
||||
type Contents interface {
|
||||
Contents(ctx context.Context, c Container) ([]byte, error)
|
||||
}
|
||||
|
||||
// File is a container's file, to open.
|
||||
type File struct {
|
||||
Path string
|
||||
@@ -254,10 +220,6 @@ func (d *Denied) Error() string {
|
||||
|
||||
func (d *Denied) Unwrap() error { return d.Err }
|
||||
|
||||
// DetailPlainEnv is the Detail of a container that is a plain (not
|
||||
// encrypted) env file in a repo.
|
||||
const DetailPlainEnv = "env · plain text"
|
||||
|
||||
// ContainerDetails is implemented by providers that can say more about a
|
||||
// container than its listing does: how a file is encrypted and what git
|
||||
// knows of it. ErrUnsupported means there's nothing more for c.
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
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,8 +2,6 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -24,12 +22,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
}
|
||||
var out []provider.Attr
|
||||
meta, isSops := readMeta(c.ID)
|
||||
// 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 {
|
||||
if isSops {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
|
||||
for i, k := range meta.recipients {
|
||||
label := ""
|
||||
@@ -42,7 +35,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, plain = envDetails(c.ID)
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
||||
}
|
||||
|
||||
git := func(args ...string) (string, bool) {
|
||||
@@ -50,7 +43,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 && plain:
|
||||
case tracked && !isSops:
|
||||
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"})
|
||||
@@ -58,7 +51,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: plain})
|
||||
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -67,28 +60,3 @@ 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)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+80
-215
@@ -1,7 +1,6 @@
|
||||
// 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, plain env files and
|
||||
// config files: Spring Boot's, Docker Compose's and .NET's (configfile.go).
|
||||
// their keys the secrets. SOPS files are the one type so far.
|
||||
//
|
||||
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
|
||||
// metadata are read directly. A value is decrypted when it's revealed or
|
||||
@@ -32,7 +31,6 @@ import (
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -51,11 +49,6 @@ 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
|
||||
}
|
||||
@@ -87,10 +80,6 @@ type repoInfo struct {
|
||||
here bool // the repo lazysecrets started in
|
||||
}
|
||||
|
||||
// DefaultRoots are the usual folders of repos, used when the config
|
||||
// doesn't set roots. Ones that don't exist cost a stat each.
|
||||
var DefaultRoots = []string{"~/git", "~/repo", "~/repos", "~/projects", "~/code", "~/source", "~/src"}
|
||||
|
||||
func New(s Settings) *Provider {
|
||||
if s.Sops == "" {
|
||||
s.Sops = "sops"
|
||||
@@ -113,6 +102,8 @@ func (p *Provider) load() {
|
||||
roots = append(roots, root)
|
||||
}
|
||||
}
|
||||
// The repo lazysecrets started in, if any: marked, so the UI can
|
||||
// put it on top, with its files under Dir first.
|
||||
here, inRepo := p.current()
|
||||
if inRepo {
|
||||
seen[here.root] = true
|
||||
@@ -125,23 +116,16 @@ func (p *Provider) load() {
|
||||
}
|
||||
add(root)
|
||||
}
|
||||
// Missing roots are skipped (most of the defaults are), and one
|
||||
// that's a symlink to another is scanned once.
|
||||
scanned := map[string]bool{}
|
||||
for _, dir := range p.s.Roots {
|
||||
base, err := filepath.EvalSymlinks(expandHome(dir))
|
||||
if err != nil || scanned[base] {
|
||||
continue
|
||||
}
|
||||
scanned[base] = true
|
||||
for _, root := range reposIn(base, 2) {
|
||||
// A repo under two roots keeps the first one's name.
|
||||
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") && names[root] == "" {
|
||||
base, _ := filepath.EvalSymlinks(expandHome(dir))
|
||||
for _, root := range reposIn(expandHome(dir), 2) {
|
||||
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
|
||||
names[root] = filepath.ToSlash(name)
|
||||
}
|
||||
add(root)
|
||||
}
|
||||
}
|
||||
// Repos you starred something in, wherever they are.
|
||||
for _, dir := range p.s.Starred {
|
||||
if root, err := gitRoot(dir); err == nil {
|
||||
add(root)
|
||||
@@ -192,19 +176,12 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
|
||||
return provider.Source{}, false
|
||||
}
|
||||
src := p.source(r)
|
||||
files, err := p.Containers(ctx, src)
|
||||
if err == nil && len(files) == 0 {
|
||||
if files, err := p.Containers(ctx, src); 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(),
|
||||
@@ -217,11 +194,13 @@ func parallel(n int, f func(i int)) {
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, max(8, runtime.NumCPU()))
|
||||
for i := range n {
|
||||
wg.Go(func() {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
f(i)
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -273,7 +252,6 @@ 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 {
|
||||
@@ -333,13 +311,8 @@ 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 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)
|
||||
case isEnv(f):
|
||||
c.Detail = "env · plain text"
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -376,29 +349,30 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, isSops := parseMeta(c.ID, b)
|
||||
if t, ok := configTypeOf(c.ID); ok && !isSops {
|
||||
return p.configObjects(t, c, b)
|
||||
}
|
||||
meta, isSops := readMeta(c.ID)
|
||||
keys, err := parseKeys(c.ID, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", c.Name, err)
|
||||
}
|
||||
attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
|
||||
if isSops {
|
||||
attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
|
||||
if !isSops { // a plain env file
|
||||
out := make([]provider.Object, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, provider.Object{
|
||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
|
||||
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
out := make([]provider.Object, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
o := provider.Object{
|
||||
Container: c, Type: provider.TypeSecret,
|
||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
|
||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
|
||||
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
|
||||
}
|
||||
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"
|
||||
if !k.encrypted {
|
||||
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
@@ -413,29 +387,17 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
// Value reads one key: from a plain file, decrypting a Jasypt value; from
|
||||
// a SOPS file, with sops.
|
||||
// Value decrypts one key with sops.
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
file, extract, ok := strings.Cut(o.ID, "\x00")
|
||||
if !ok { // made from a link: the repo's root, the file's path, the key
|
||||
file = filePath(o.Container)
|
||||
file, extract = o.Container.ID, extractFor(o.Name)
|
||||
if file == "" {
|
||||
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
|
||||
}
|
||||
}
|
||||
if _, isSops := readMeta(file); !isSops {
|
||||
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
|
||||
}
|
||||
return plainValue(file, o.Name)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
|
||||
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
|
||||
@@ -450,27 +412,12 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return provider.NewSecretValue(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
// Contents reads the file c is.
|
||||
func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) {
|
||||
f, err := p.File(c, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(f.Path)
|
||||
}
|
||||
|
||||
// filePath is the file c is. A container made from a link has no ID: the
|
||||
// file is its path in the repo.
|
||||
func filePath(c provider.Container) string {
|
||||
if c.ID != "" {
|
||||
return c.ID
|
||||
}
|
||||
return filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
||||
}
|
||||
|
||||
// File is the file c is, and the line o's key is on.
|
||||
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
|
||||
path := filePath(c)
|
||||
path := c.ID
|
||||
if path == "" { // made from a link
|
||||
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
||||
}
|
||||
f := provider.File{Path: path}
|
||||
if _, isSops := readMeta(path); isSops {
|
||||
f.Sops = p.s.Sops
|
||||
@@ -478,12 +425,6 @@ 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
|
||||
@@ -523,16 +464,6 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
// Root is the root of the git repo dir is in, and the repo's ID: its
|
||||
// remote without scheme or .git (a repo link's host), else the root.
|
||||
func Root(dir string) (root, id string, err error) {
|
||||
root, err = gitRoot(dir)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s isn't in a git repository", dir)
|
||||
}
|
||||
return root, info(root, "").id, nil
|
||||
}
|
||||
|
||||
// --- git ---
|
||||
|
||||
func gitRoot(dir string) (string, error) {
|
||||
@@ -635,57 +566,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"`
|
||||
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)
|
||||
return f.Sharing.Key, err
|
||||
}
|
||||
|
||||
// readLazysecretsFile reads the repo's .lazysecrets.yaml; without one, it's
|
||||
// empty.
|
||||
func readLazysecretsFile(root string) (lazysecretsFile, error) {
|
||||
var f lazysecretsFile
|
||||
b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return f, nil
|
||||
}
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||
return f, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
}
|
||||
return f, nil
|
||||
Include []string `yaml:"include"`
|
||||
Exclude []string `yaml:"exclude"`
|
||||
}
|
||||
|
||||
// selector says which files to look at: user (the include/exclude of the
|
||||
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
|
||||
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
|
||||
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
|
||||
f, err := readLazysecretsFile(root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
include, exclude := p.s.Include, p.s.Exclude
|
||||
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
||||
include, exclude = f.Include, f.Exclude
|
||||
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
|
||||
var f lazysecretsFile
|
||||
if err := yaml.Unmarshal(b, &f); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
|
||||
}
|
||||
if len(f.Include) > 0 || len(f.Exclude) > 0 {
|
||||
include, exclude = f.Include, f.Exclude
|
||||
}
|
||||
}
|
||||
inc, err := globs(include)
|
||||
if err != nil {
|
||||
@@ -721,7 +618,7 @@ func envFiles(root string, tracked []string) []string {
|
||||
dirs := map[string]bool{".": true}
|
||||
for _, f := range tracked {
|
||||
isTracked[f] = true
|
||||
if isEnvNamed(f) {
|
||||
if isEnv(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
|
||||
@@ -735,7 +632,7 @@ func envFiles(root string, tracked []string) []string {
|
||||
}
|
||||
for _, e := range entries {
|
||||
f := path.Join(d, e.Name())
|
||||
if e.Type().IsRegular() && !isTracked[f] && isEnvNamed(f) {
|
||||
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
@@ -766,33 +663,20 @@ func inPackages(f string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsEnv reports whether a file is named like an env file with real values:
|
||||
// isEnv reports whether a file is named like an env file with real values:
|
||||
// .env, .env.local, prod.env; not .env.example and the like, nor one in
|
||||
// dependencies.
|
||||
func IsEnv(f string) bool {
|
||||
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 {
|
||||
func isEnv(f string) bool {
|
||||
if inPackages(f) {
|
||||
return false
|
||||
}
|
||||
base := strings.ToLower(path.Base(f))
|
||||
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
|
||||
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
|
||||
if strings.HasSuffix(base, ext) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
|
||||
}
|
||||
|
||||
// plainValue reads a key from a plain env file.
|
||||
@@ -970,21 +854,12 @@ func readMeta(file string) (sopsMeta, bool) {
|
||||
return sopsMeta{}, false // not a format sops writes: skip reading
|
||||
}
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return sopsMeta{}, false
|
||||
}
|
||||
return parseMeta(file, b)
|
||||
}
|
||||
|
||||
// parseMeta is readMeta, of the file's contents b.
|
||||
func parseMeta(file string, b []byte) (sopsMeta, bool) {
|
||||
format := formatOf(file)
|
||||
if len(b) > maxFileSize || format == "" || !bytes.Contains(b, []byte("sops")) {
|
||||
if err != nil || !bytes.Contains(b, []byte("sops")) {
|
||||
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
|
||||
}
|
||||
var m sopsMeta
|
||||
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
|
||||
switch format {
|
||||
switch formatOf(file) {
|
||||
case "yaml", "json":
|
||||
var doc struct {
|
||||
Sops map[string]any `yaml:"sops"`
|
||||
@@ -1085,11 +960,10 @@ func formatOf(file string) string {
|
||||
}
|
||||
|
||||
type key struct {
|
||||
name string // db.password, list[0], with . [ ] \ in keys escaped
|
||||
name string // db.password, list[0]
|
||||
extract string // sops --extract: ["db"]["password"]
|
||||
encrypted bool
|
||||
jasypt bool // a plain env file's ENC(...) value
|
||||
line int // 1-based
|
||||
line int // 1-based
|
||||
}
|
||||
|
||||
// parseKeys lists a sops file's keys without decrypting anything.
|
||||
@@ -1113,8 +987,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
if !ok || strings.HasPrefix(k, "sops_") {
|
||||
continue
|
||||
}
|
||||
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})
|
||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
}
|
||||
return out, nil
|
||||
case "ini":
|
||||
@@ -1132,7 +1005,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -1149,9 +1022,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
||||
if top && k == "sops" {
|
||||
continue
|
||||
}
|
||||
child := escape(k)
|
||||
child := k
|
||||
if name != "" {
|
||||
child = name + "." + child
|
||||
child = name + "." + k
|
||||
}
|
||||
walk(n.Content[i+1], child, extract+quote(k), false, out)
|
||||
}
|
||||
@@ -1173,29 +1046,21 @@ func quote(k string) string {
|
||||
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
|
||||
}
|
||||
|
||||
// escape keeps a key's dots and brackets from reading as nesting in a
|
||||
// name: app\.name is one key, app.name two.
|
||||
func escape(k string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
keys, err := parseKeys(file, b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.name == name {
|
||||
return k.extract, nil
|
||||
// extractFor rebuilds the extract path from a key name, for objects made
|
||||
// from a link (db.password, list[0]). Names with dots in keys can't be
|
||||
// told apart from nesting: those come from the listing instead.
|
||||
func extractFor(name string) string {
|
||||
var b strings.Builder
|
||||
for _, part := range strings.Split(name, ".") {
|
||||
base, idx, _ := strings.Cut(part, "[")
|
||||
if base != "" {
|
||||
b.WriteString(quote(base))
|
||||
}
|
||||
if idx != "" {
|
||||
b.WriteString("[" + idx)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func absPath(p string) string {
|
||||
|
||||
@@ -237,25 +237,6 @@ 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") {
|
||||
@@ -292,12 +273,9 @@ 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) != 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)
|
||||
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)
|
||||
}
|
||||
|
||||
var env provider.Container
|
||||
@@ -322,7 +300,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", ".env.example"}) {
|
||||
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
|
||||
t.Errorf("with rules elsewhere: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -358,30 +336,6 @@ func TestRoots(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootsDeduped(t *testing.T) {
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
os.Rename(gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}), filepath.Join(root, "infra"))
|
||||
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
|
||||
os.Rename(gitRepo(t, map[string]string{"prod.env": sopsEnv}), filepath.Join(root, "acme", "billing"))
|
||||
|
||||
// ~/src → ~/git, and a root reaching one repo at another depth.
|
||||
links := t.TempDir()
|
||||
alias := filepath.Join(links, "src")
|
||||
os.Symlink(root, alias)
|
||||
missing := filepath.Join(links, "nope")
|
||||
acme := filepath.Join(root, "acme")
|
||||
|
||||
srcs, err := New(Settings{Dir: links, Roots: []string{missing, root, alias, acme}}).Sources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := names(srcs, func(s provider.Source) string { return s.Name })
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
|
||||
t.Errorf("repos: %v, want each once, named from the first root", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarredReposAlongside(t *testing.T) {
|
||||
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
|
||||
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
|
||||
@@ -567,31 +521,3 @@ func TestFileDetails(t *testing.T) {
|
||||
t.Errorf("untracked env: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyNamesEscaped(t *testing.T) {
|
||||
keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, k := range keys {
|
||||
got = append(got, k.name+" "+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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ func NewSecretValue(b []byte) SecretValue {
|
||||
// Reveal returns the plaintext. Call it only to display or copy the value.
|
||||
func (v SecretValue) Reveal() string { return string(v.b) }
|
||||
|
||||
func (v SecretValue) Len() int { return len(v.b) }
|
||||
|
||||
func (v SecretValue) String() string { return masked }
|
||||
func (v SecretValue) GoString() string { return masked }
|
||||
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package ref
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Read reads what r points at without listing its vault: the vault and
|
||||
// name are enough for Azure, tried with each account (account, the one
|
||||
// that last read it, first, in source); for Databricks and repos the
|
||||
// profile or repo is found by its host. It also returns the version read,
|
||||
// when the provider tells (provider.Versioned).
|
||||
func Read(ctx context.Context, providers []provider.Provider, r Ref, account, source string) (provider.SecretValue, string, error) {
|
||||
if r.IsShare() || r.IsSource() || r.IsContainer() {
|
||||
what := "vault or scope"
|
||||
switch {
|
||||
case r.IsShare():
|
||||
what = "share link"
|
||||
case r.IsSource():
|
||||
what = "subscription or workspace"
|
||||
}
|
||||
return provider.SecretValue{}, "", fmt.Errorf("%s points at a %s, not a secret", r, what)
|
||||
}
|
||||
if r.Kind != provider.KindAzure && r.Host == "" {
|
||||
return provider.SecretValue{}, "", errors.New("{{secrets/…}} names no workspace: use its lazysecrets:// link (s in lazysecrets)")
|
||||
}
|
||||
|
||||
type try struct {
|
||||
p provider.Provider
|
||||
o provider.Object
|
||||
}
|
||||
var tries []try
|
||||
for _, p := range providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
|
||||
if r.Kind != provider.KindAzure { // by host: workspaces, repos
|
||||
loc, ok := p.(provider.Locator)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
profile, found, err := loc.Locate(ctx, r.Host)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: profile}
|
||||
o.Container = provider.Container{Source: src, Name: r.Scope}
|
||||
// Listing the scopes tells a Key Vault-backed one, whose value
|
||||
// can then be read from the vault if Databricks refuses.
|
||||
if cs, err := p.Containers(ctx, src); err == nil {
|
||||
for _, c := range cs {
|
||||
if c.Name == r.Scope {
|
||||
c.Source = src
|
||||
o.Container = c
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: source}
|
||||
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
|
||||
}
|
||||
t := try{p, o}
|
||||
if p.ID() == account {
|
||||
tries = append([]try{t}, tries...)
|
||||
} else {
|
||||
tries = append(tries, t)
|
||||
}
|
||||
}
|
||||
if len(tries) == 0 {
|
||||
switch r.Kind {
|
||||
case provider.KindDatabricks:
|
||||
return provider.SecretValue{}, "", fmt.Errorf("no Databricks profile for workspace %s", r.Host)
|
||||
case provider.KindRepo:
|
||||
return provider.SecretValue{}, "", fmt.Errorf("repo %s isn't here: run it inside the repo, or list it under providers.repos.paths", r.Host)
|
||||
}
|
||||
return provider.SecretValue{}, "", fmt.Errorf("no %s account to read %s with", r.Kind, r)
|
||||
}
|
||||
var errs []error
|
||||
for _, t := range tries {
|
||||
v, version, err := value(ctx, t.p, t.o)
|
||||
if err == nil {
|
||||
return v, version, nil
|
||||
}
|
||||
if len(tries) == 1 || ctx.Err() != nil {
|
||||
return provider.SecretValue{}, "", err
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
|
||||
}
|
||||
return provider.SecretValue{}, "", errors.Join(errs...)
|
||||
}
|
||||
|
||||
func value(ctx context.Context, p provider.Provider, o provider.Object) (provider.SecretValue, string, error) {
|
||||
if vp, ok := p.(provider.Versioned); ok {
|
||||
return vp.VersionedValue(ctx, o)
|
||||
}
|
||||
v, err := p.Value(ctx, o)
|
||||
return v, o.Version, err
|
||||
}
|
||||
+15
-37
@@ -10,15 +10,11 @@
|
||||
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
|
||||
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
|
||||
// lazysecrets://repo/<repo>[/<file>[/<key>]]
|
||||
// lazysecrets://share/<sealed files>
|
||||
//
|
||||
// A repo is its git remote without scheme or .git
|
||||
// (git.kebcloud.no/keb/infra), the file its path in the repo and the key
|
||||
// its path in the file (db.password); slashes inside them are escaped.
|
||||
//
|
||||
// A share link carries files, encrypted (package share), instead of
|
||||
// naming something.
|
||||
//
|
||||
// Vault hosts always contain dots, so "subscriptions" can't be one.
|
||||
//
|
||||
// Every path segment is percent-encoded, so names may hold any character.
|
||||
@@ -53,23 +49,10 @@ type Ref struct {
|
||||
Type provider.ObjectType
|
||||
Name string // object name; empty for a container link
|
||||
Version string
|
||||
// Share is a share link's sealed files, as in the link; the other
|
||||
// fields are empty. Package share reads it.
|
||||
Share string
|
||||
// Account, when set, is the provider (its ID) to open or read it
|
||||
// with when several could, from lazysecrets list. It isn't part of
|
||||
// the link.
|
||||
Account string
|
||||
}
|
||||
|
||||
// IsShare reports whether r is a share link.
|
||||
func (r Ref) IsShare() bool { return r.Share != "" }
|
||||
|
||||
// IsSource reports whether r points at a subscription or workspace.
|
||||
func (r Ref) IsSource() bool {
|
||||
if r.IsShare() {
|
||||
return false
|
||||
}
|
||||
if r.Kind != provider.KindAzure {
|
||||
return r.Scope == ""
|
||||
}
|
||||
@@ -77,7 +60,7 @@ func (r Ref) IsSource() bool {
|
||||
}
|
||||
|
||||
// IsContainer reports whether r points at a vault or scope.
|
||||
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() }
|
||||
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() }
|
||||
|
||||
// ContainerName is the vault name (from its host) or the scope.
|
||||
func (r Ref) ContainerName() string {
|
||||
@@ -92,9 +75,15 @@ func (r Ref) ContainerName() string {
|
||||
// Container.Detail.
|
||||
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
|
||||
|
||||
var collections = map[provider.ObjectType]string{
|
||||
provider.TypeSecret: "secrets",
|
||||
provider.TypeKey: "keys",
|
||||
provider.TypeCertificate: "certificates",
|
||||
}
|
||||
|
||||
func typeOf(collection string) (provider.ObjectType, bool) {
|
||||
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
|
||||
if strings.EqualFold(t.Collection(), collection) {
|
||||
for t, c := range collections {
|
||||
if strings.EqualFold(c, collection) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
@@ -112,7 +101,7 @@ func (r Ref) IdentifierURL() string {
|
||||
if r.Type == "" {
|
||||
return u + "/"
|
||||
}
|
||||
for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
|
||||
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
|
||||
if seg != "" {
|
||||
u += "/" + url.PathEscape(seg)
|
||||
}
|
||||
@@ -122,9 +111,6 @@ func (r Ref) IdentifierURL() string {
|
||||
|
||||
// String is the lazysecrets:// link.
|
||||
func (r Ref) String() string {
|
||||
if r.IsShare() {
|
||||
return Scheme + "://share/" + r.Share
|
||||
}
|
||||
if r.Kind == provider.KindAzure && r.Subscription != "" {
|
||||
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
|
||||
}
|
||||
@@ -134,7 +120,7 @@ func (r Ref) String() string {
|
||||
segs = append(segs, r.Scope, r.Name)
|
||||
default:
|
||||
if r.Type != "" {
|
||||
segs = append(segs, r.Type.Collection())
|
||||
segs = append(segs, collections[r.Type])
|
||||
}
|
||||
segs = append(segs, r.Name, r.Version)
|
||||
}
|
||||
@@ -222,8 +208,6 @@ var (
|
||||
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
|
||||
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
|
||||
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
|
||||
// a share link's sealed files: unpadded base64url
|
||||
sealed = regexp.MustCompile(`^[A-Za-z0-9_-]{16,}$`)
|
||||
)
|
||||
|
||||
// Parse reads any supported reference.
|
||||
@@ -254,12 +238,6 @@ func parseLink(s string) (Ref, error) {
|
||||
if err != nil {
|
||||
return Ref{}, err
|
||||
}
|
||||
if strings.EqualFold(u.Host, "share") {
|
||||
if len(segs) != 1 || !sealed.MatchString(segs[0]) {
|
||||
return Ref{}, errors.New("share link is cut short or garbled: copy all of it")
|
||||
}
|
||||
return Ref{Share: segs[0]}, nil
|
||||
}
|
||||
var r Ref
|
||||
if len(segs) == 0 || segs[0] == "" {
|
||||
return Ref{}, errors.New("link has no vault or workspace")
|
||||
@@ -267,7 +245,7 @@ func parseLink(s string) (Ref, error) {
|
||||
r.Host = strings.ToLower(segs[0])
|
||||
rest := segs[1:]
|
||||
|
||||
switch host := strings.ToLower(u.Host); host {
|
||||
switch strings.ToLower(u.Host) {
|
||||
case "azure":
|
||||
r.Kind = provider.KindAzure
|
||||
if r.Host == "subscriptions" {
|
||||
@@ -294,11 +272,11 @@ func parseLink(s string) (Ref, error) {
|
||||
}
|
||||
case "databricks", "repo":
|
||||
r.Kind = provider.KindDatabricks
|
||||
if host == "repo" {
|
||||
if strings.EqualFold(u.Host, "repo") {
|
||||
r.Kind = provider.KindRepo
|
||||
}
|
||||
if len(rest) > 2 {
|
||||
return Ref{}, fmt.Errorf("%s link has too many parts", host)
|
||||
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
r.Scope = rest[0]
|
||||
@@ -308,7 +286,7 @@ func parseLink(s string) (Ref, error) {
|
||||
}
|
||||
|
||||
default:
|
||||
return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
|
||||
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Rules are what a key's value must be to share with it: random enough
|
||||
// that guessing it is out of reach, since anyone with a link can try.
|
||||
type Rules struct {
|
||||
MinLength int // characters
|
||||
MinClasses int // kinds of character: lowercase, uppercase, digits, symbols
|
||||
}
|
||||
|
||||
// DefaultRules take a random base64 or hex value (openssl rand -base64
|
||||
// 32, or -hex 32) and refuse passwords made up to be remembered.
|
||||
var DefaultRules = Rules{MinLength: 32, MinClasses: 2}
|
||||
|
||||
// Check reports what key lacks, without showing it.
|
||||
func (r Rules) Check(key []byte) error {
|
||||
r = r.withDefaults()
|
||||
s := string(key)
|
||||
var lower, upper, digit, symbol bool
|
||||
for _, c := range s {
|
||||
switch {
|
||||
case unicode.IsLower(c):
|
||||
lower = true
|
||||
case unicode.IsUpper(c):
|
||||
upper = true
|
||||
case unicode.IsDigit(c):
|
||||
digit = true
|
||||
default:
|
||||
symbol = true
|
||||
}
|
||||
}
|
||||
kinds := 0
|
||||
for _, has := range []bool{lower, upper, digit, symbol} {
|
||||
if has {
|
||||
kinds++
|
||||
}
|
||||
}
|
||||
n := utf8.RuneCountInString(s)
|
||||
var has, needs []string
|
||||
if n < r.MinLength {
|
||||
has = append(has, fmt.Sprintf("%d characters", n))
|
||||
needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength))
|
||||
}
|
||||
if kinds < r.MinClasses {
|
||||
kind := "kinds"
|
||||
if kinds == 1 {
|
||||
kind = "kind"
|
||||
}
|
||||
has = append(has, fmt.Sprintf("%d %s of character", kinds, kind))
|
||||
needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses))
|
||||
}
|
||||
if len(has) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("its value has %s; sharing.keyRules needs %s",
|
||||
strings.Join(has, " and "), strings.Join(needs, " and "))
|
||||
}
|
||||
|
||||
func (r Rules) withDefaults() Rules {
|
||||
if r.MinLength == 0 {
|
||||
r.MinLength = DefaultRules.MinLength
|
||||
}
|
||||
if r.MinClasses == 0 {
|
||||
r.MinClasses = DefaultRules.MinClasses
|
||||
}
|
||||
r.MinLength = max(r.MinLength, MinKey)
|
||||
return r
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
// Package share seals env files into a lazysecrets://share link, so they
|
||||
// can be passed on in chat or a ticket. They're encrypted with the value of
|
||||
// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can
|
||||
// read that secret can open the link, nobody else.
|
||||
//
|
||||
// A link says in the clear which secret it's encrypted with, the repo the
|
||||
// files are from and how many there are, which is what's needed to open
|
||||
// it. The files' paths and contents are encrypted, with age
|
||||
// (https://age-encryption.org), the secret's value as its passphrase.
|
||||
//
|
||||
// The link's payload is unpadded base64url of:
|
||||
//
|
||||
// version (1) | uvarint length of the header | header (JSON) | age file
|
||||
//
|
||||
// The age file holds the files as deflated JSON.
|
||||
//
|
||||
// A change to the format gets the next version, and Read keeps reading
|
||||
// every earlier one: links stay in chat and tickets for years. A
|
||||
// version it doesn't know is from a newer lazysecrets.
|
||||
package share
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
const (
|
||||
// version is the format's, written first; see the package doc.
|
||||
version = 1
|
||||
// MaxLink keeps a link short enough for chat and tickets (Teams takes
|
||||
// about 28 KB) and for passing it to a terminal window.
|
||||
MaxLink = 16 << 10
|
||||
// MinKey is the shortest key the rules can ask for.
|
||||
MinKey = 16
|
||||
// maxUnpacked caps the bytes opening a link unpacks.
|
||||
maxUnpacked = 1 << 20
|
||||
)
|
||||
|
||||
// workFactor is the scrypt work factor for sealing: 2^15, about 40 ms
|
||||
// each way, instead of age's 2^18 (a third of a second). scrypt makes
|
||||
// guessing a weak passphrase slow; a key keeping to the rules is random
|
||||
// and long enough not to need that. maxWorkFactor is the most opening
|
||||
// accepts, so a link can't make it work for minutes.
|
||||
var workFactor, maxWorkFactor = 15, 20
|
||||
|
||||
// File is a shared file.
|
||||
type File struct {
|
||||
Path string // relative to the repo's root, with slashes
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Header is what a link says in the clear.
|
||||
type Header struct {
|
||||
// Key is the secret whose value the files are encrypted with.
|
||||
Key ref.Ref
|
||||
// Repo is the repo the files are from: its remote without scheme or
|
||||
// .git (a repo link's host).
|
||||
Repo string
|
||||
// Files is how many files the link holds.
|
||||
Files int
|
||||
}
|
||||
|
||||
// Sealed is a share link read, its files still encrypted.
|
||||
type Sealed struct {
|
||||
Header
|
||||
box []byte
|
||||
}
|
||||
|
||||
type header struct {
|
||||
Key string `json:"key"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
Files int `json:"files"`
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Path string `json:"path"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Seal encrypts files with key, the value of h.Key, into a share link,
|
||||
// if key keeps to rules (*KeyError if not).
|
||||
func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
|
||||
if _, err := ParseKey(h.Key.String()); err != nil {
|
||||
return ref.Ref{}, fmt.Errorf("the key: %w", err)
|
||||
}
|
||||
if err := rules.Check(key); err != nil {
|
||||
return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: err}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return ref.Ref{}, errors.New("no files to share")
|
||||
}
|
||||
plain := make([]file, len(files))
|
||||
for i, f := range files {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if !utf8.Valid(f.Data) {
|
||||
return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path)
|
||||
}
|
||||
plain[i] = file{Path: f.Path, Data: string(f.Data)}
|
||||
}
|
||||
b, err := json.Marshal(plain)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
|
||||
rcpt, err := age.NewScryptRecipient(string(key))
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
rcpt.SetWorkFactor(workFactor)
|
||||
var box bytes.Buffer
|
||||
enc, err := age.Encrypt(&box, rcpt)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
z, _ := flate.NewWriter(enc, flate.BestCompression)
|
||||
if _, err := z.Write(b); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if err := z.Close(); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
|
||||
hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)})
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
payload := binary.AppendUvarint([]byte{version}, uint64(len(hb)))
|
||||
payload = append(append(payload, hb...), box.Bytes()...)
|
||||
r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)}
|
||||
if n := len(r.String()); n > MaxLink {
|
||||
return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// KeyError is a key that can't be used: it can't be read, or doesn't
|
||||
// keep to the rules.
|
||||
type KeyError struct {
|
||||
Name string // the secret's
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
|
||||
func (e *KeyError) Unwrap() error { return e.Err }
|
||||
|
||||
// Read reads a share link's header.
|
||||
func Read(r ref.Ref) (Sealed, error) {
|
||||
bad := errors.New("share link is cut short or garbled: copy all of it")
|
||||
b, err := base64.RawURLEncoding.DecodeString(r.Share)
|
||||
if err != nil || len(b) < 2 {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
if b[0] != version {
|
||||
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
||||
}
|
||||
n, size := binary.Uvarint(b[1:])
|
||||
if size <= 0 {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
rest := b[1+size:]
|
||||
if n > uint64(len(rest)) {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
var h header
|
||||
if err := json.Unmarshal(rest[:n], &h); err != nil {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
key, err := ParseKey(h.Key)
|
||||
if err != nil {
|
||||
return Sealed{}, fmt.Errorf("share link's key: %w", err)
|
||||
}
|
||||
return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil
|
||||
}
|
||||
|
||||
// Open decrypts the files with key, the value of s.Key.
|
||||
func (s Sealed) Open(key []byte) ([]File, error) {
|
||||
id, err := age.NewScryptIdentity(string(key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id.SetMaxWorkFactor(maxWorkFactor)
|
||||
dec, err := age.Decrypt(bytes.NewReader(s.box), id)
|
||||
if err != nil {
|
||||
var wrong *age.NoIdentityMatchError
|
||||
if errors.As(err, &wrong) {
|
||||
return nil, s.wrongKey()
|
||||
}
|
||||
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxUnpacked+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||
}
|
||||
if len(b) > maxUnpacked {
|
||||
return nil, errors.New("the link holds more than lazysecrets unpacks")
|
||||
}
|
||||
var plain []file
|
||||
if err := json.Unmarshal(b, &plain); err != nil {
|
||||
return nil, fmt.Errorf("can't read the link's files: %w", err)
|
||||
}
|
||||
out := make([]File, len(plain))
|
||||
for i, f := range plain {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = File{Path: f.Path, Data: []byte(f.Data)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// wrongKey says why the key, read fine, doesn't decrypt the link: its
|
||||
// value isn't the one the link was made with.
|
||||
func (s Sealed) wrongKey() error {
|
||||
name := s.Key.Name
|
||||
switch {
|
||||
case s.Key.Kind == provider.KindAzure && s.Key.Version != "":
|
||||
// A version's value never changes.
|
||||
return fmt.Errorf("%s at version %s doesn't decrypt it: the link may be damaged", name, s.Key.Version)
|
||||
case s.Key.Kind == provider.KindAzure:
|
||||
return fmt.Errorf("%s's current version isn't the one the link was made with, so it can't decrypt it: ask for a new link", name)
|
||||
}
|
||||
// Databricks secrets and SOPS values are changed in place.
|
||||
return fmt.Errorf("%s has changed since the link was made, so it can't decrypt it: ask for a new link", name)
|
||||
}
|
||||
|
||||
// CheckPath accepts a path relative to a repo's root, with slashes, of an
|
||||
// env file (.env, .env.local, prod.env) outside .git: what a link may
|
||||
// write.
|
||||
func CheckPath(p string) error {
|
||||
switch {
|
||||
case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p ||
|
||||
p == ".." || strings.HasPrefix(p, "../"):
|
||||
return fmt.Errorf("%q isn't a path inside a repo", p)
|
||||
case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"):
|
||||
return fmt.Errorf("%q is inside .git", p)
|
||||
case !repo.IsEnv(p):
|
||||
return fmt.Errorf("%q isn't an env file: only those can be shared", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultKey is the key for a repo's files: sharing.key in the repo's
|
||||
// .lazysecrets.yaml, else general (sharing.key in the config). ok is false
|
||||
// when neither names one.
|
||||
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
|
||||
link, err := repo.ShareKey(root)
|
||||
if err != nil {
|
||||
return ref.Ref{}, false, err
|
||||
}
|
||||
where := root + "/.lazysecrets.yaml"
|
||||
if link == "" {
|
||||
link, where = general, "the config"
|
||||
}
|
||||
if link == "" {
|
||||
return ref.Ref{}, false, nil
|
||||
}
|
||||
key, err = ParseKey(link)
|
||||
if err != nil {
|
||||
return ref.Ref{}, false, fmt.Errorf("sharing.key in %s: %w", where, err)
|
||||
}
|
||||
return key, true, nil
|
||||
}
|
||||
|
||||
// ParseKey reads which secret is the key: its lazysecrets:// link, Key
|
||||
// Vault URL or @Microsoft.KeyVault(...) reference.
|
||||
func ParseKey(s string) (ref.Ref, error) {
|
||||
key, err := ref.Parse(s)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
switch {
|
||||
case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret):
|
||||
return ref.Ref{}, fmt.Errorf("%s isn't a secret", s)
|
||||
case key.Kind == provider.KindDatabricks && key.Host == "":
|
||||
return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Pin is key at version, the version its value was read at, for Azure,
|
||||
// whose old versions stay readable: a link keeps working when the key
|
||||
// is rotated, until that version is disabled.
|
||||
func Pin(key ref.Ref, version string) ref.Ref {
|
||||
if key.Kind == provider.KindAzure && key.Version == "" {
|
||||
key.Version = version
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor
|
||||
|
||||
var (
|
||||
key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"}
|
||||
pass = []byte("0123456789abcdef0123456789abcdef")
|
||||
)
|
||||
|
||||
func TestSealOpen(t *testing.T) {
|
||||
files := []File{
|
||||
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
|
||||
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
|
||||
}
|
||||
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") {
|
||||
t.Errorf("link shows what it shares: %s", link)
|
||||
}
|
||||
// It goes through ref.Parse like any link.
|
||||
r, err := ref.Parse(link.String())
|
||||
if err != nil || !r.IsShare() {
|
||||
t.Fatalf("parse: %v, %+v", err, r)
|
||||
}
|
||||
s, err := Read(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 {
|
||||
t.Errorf("header %+v", s.Header)
|
||||
}
|
||||
got, err := s.Open(pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
|
||||
t.Errorf("opened %+v", got)
|
||||
}
|
||||
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "env-share at version 91d3e0 doesn't decrypt it") {
|
||||
t.Errorf("wrong key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeySaysWhy(t *testing.T) {
|
||||
files := []File{{Path: ".env", Data: []byte("A=1\n")}}
|
||||
for link, want := range map[string]string{
|
||||
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share": "env-share has changed since the link was made",
|
||||
"lazysecrets://repo/git.example.com%2Fteam%2Fapp/secrets.yaml/share": "share has changed since the link was made",
|
||||
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share": "env-share's current version isn't the one the link was made with",
|
||||
} {
|
||||
k, err := ParseKey(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Seal(Header{Key: k}, pass, files, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _ := Read(r)
|
||||
if _, err := s.Open([]byte("rotated since, and long enough to be a key")); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", link, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealRefuses(t *testing.T) {
|
||||
ok := []File{{Path: ".env", Data: []byte("A=1")}}
|
||||
for name, c := range map[string]struct {
|
||||
h Header
|
||||
pass []byte
|
||||
files []File
|
||||
want string
|
||||
}{
|
||||
"short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"},
|
||||
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"},
|
||||
"no files": {Header{Key: key}, pass, nil, "no files"},
|
||||
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
|
||||
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
|
||||
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
|
||||
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
|
||||
} {
|
||||
if _, err := Seal(c.h, c.pass, c.files, Rules{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// random is text that doesn't compress.
|
||||
func random(n int) string {
|
||||
var b strings.Builder
|
||||
x := uint32(1)
|
||||
for b.Len() < n {
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestRules(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
r Rules
|
||||
key string
|
||||
pass bool
|
||||
}{
|
||||
{Rules{}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", true}, // base64-like, 32
|
||||
{Rules{}, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", true}, // hex
|
||||
{Rules{}, "correct-horse-battery-staple", false}, // 28: short
|
||||
{Rules{}, strings.Repeat("a", 40), false}, // one kind
|
||||
{Rules{MinLength: 16, MinClasses: 1}, strings.Repeat("a", 16), true},
|
||||
{Rules{MinLength: 4}, "abc123", false}, // never under MinKey
|
||||
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5Ue", false},
|
||||
{Rules{MinClasses: 4}, "q2Hd8bT4Lw1sZp9Kf6Vx3Nc7Rm0Ya5U+", true},
|
||||
} {
|
||||
if err := c.r.Check([]byte(c.key)); (err == nil) != c.pass {
|
||||
t.Errorf("%+v %q: %v", c.r, c.key, err)
|
||||
} else if err != nil && strings.Contains(err.Error(), c.key) {
|
||||
t.Errorf("the error shows the key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadGarbled(t *testing.T) {
|
||||
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}}, Rules{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := ref.Ref{Share: link.Share[:len(link.Share)/2]}
|
||||
s, err := Read(cut)
|
||||
if err == nil {
|
||||
_, err = s.Open(pass)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("a link cut in half opened")
|
||||
}
|
||||
if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") {
|
||||
t.Errorf("version 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPath(t *testing.T) {
|
||||
for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} {
|
||||
if err := CheckPath(p); err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} {
|
||||
if err := CheckPath(p); err == nil {
|
||||
t.Errorf("%q accepted", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
|
||||
t.Skip("git:", string(out))
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644)
|
||||
targets, err := Plan(dir, []File{
|
||||
{Path: ".env", Data: []byte("NEW=1\n")},
|
||||
{Path: "apps/api/.env.local", Data: []byte("B=2\n")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !targets[0].Exists || targets[0].Exposed {
|
||||
t.Errorf(".env: %+v", targets[0])
|
||||
}
|
||||
if targets[1].Exists || !targets[1].Exposed {
|
||||
t.Errorf("apps/api/.env.local: %+v", targets[1])
|
||||
}
|
||||
for _, tg := range targets {
|
||||
if err := tg.Write(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" {
|
||||
t.Errorf(".env is %q", b)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local"))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("new file: %v, %v", info.Mode(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSymlinks(t *testing.T) {
|
||||
dir, outside := t.TempDir(), t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") {
|
||||
t.Errorf("through a linked folder: %v", err)
|
||||
}
|
||||
os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env"))
|
||||
if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("onto a symlink: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKey(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share",
|
||||
"https://kv-team.vault.azure.net/secrets/env-share",
|
||||
"@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)",
|
||||
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share",
|
||||
} {
|
||||
if k, err := ParseKey(s); err != nil || k.Name != "env-share" {
|
||||
t.Errorf("%s: %+v, %v", s, k, err)
|
||||
}
|
||||
}
|
||||
for s, want := range map[string]string{
|
||||
"{{secrets/team/env-share}}": "names no workspace",
|
||||
"https://kv-team.vault.azure.net/keys/sops": "isn't a secret",
|
||||
"https://kv-team.vault.azure.net/": "isn't a secret",
|
||||
"lazysecrets://azure/subscriptions/0000-1111": "isn't a secret",
|
||||
} {
|
||||
if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", s, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
|
||||
k, ok, err := DefaultKey(dir, general)
|
||||
if err != nil || !ok || k.Name != "env-share" {
|
||||
t.Errorf("general: %+v %v %v", k, ok, err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("sharing:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
|
||||
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
|
||||
t.Errorf("the repo's should win: %+v", k)
|
||||
}
|
||||
if _, ok, _ := DefaultKey(t.TempDir(), ""); ok {
|
||||
t.Error("a key with none set")
|
||||
}
|
||||
unpinned := key
|
||||
unpinned.Version = ""
|
||||
if p := Pin(unpinned, "abc"); p.Version != "abc" {
|
||||
t.Errorf("pin: %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Target is where an opened file goes.
|
||||
type Target struct {
|
||||
File
|
||||
Abs string
|
||||
Exists bool
|
||||
// Exposed is set when dir is a git repo that doesn't ignore the file:
|
||||
// its values could end up in a commit.
|
||||
Exposed bool
|
||||
}
|
||||
|
||||
// Plan says where files go under dir, without writing anything. A path
|
||||
// that leads out of dir through a symlink, or is one, is an error.
|
||||
func Plan(dir string, files []File) ([]Target, error) {
|
||||
root, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Target, len(files))
|
||||
for i, f := range files {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := Target{File: f, Abs: filepath.Join(root, filepath.FromSlash(f.Path))}
|
||||
if err := inside(root, filepath.Dir(t.Abs)); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
switch info, err := os.Lstat(t.Abs); {
|
||||
case err == nil && info.Mode()&fs.ModeSymlink != 0:
|
||||
return nil, fmt.Errorf("%s is a symlink: not writing through it", f.Path)
|
||||
case err == nil && !info.Mode().IsRegular():
|
||||
return nil, fmt.Errorf("%s isn't a file", f.Path)
|
||||
case err == nil:
|
||||
t.Exists = true
|
||||
case !errors.Is(err, fs.ErrNotExist):
|
||||
return nil, err
|
||||
}
|
||||
t.Exposed = exposed(root, f.Path)
|
||||
out[i] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inside checks that dir, or the part of it that exists, is under root
|
||||
// once symlinks are followed.
|
||||
func inside(root, dir string) error {
|
||||
for {
|
||||
real, err := filepath.EvalSymlinks(dir)
|
||||
if errors.Is(err, fs.ErrNotExist) && dir != root {
|
||||
dir = filepath.Dir(dir) // created when writing
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
|
||||
return errors.New("leads out of the folder through a symlink")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// exposed reports whether root is in a git repo that doesn't ignore p.
|
||||
func exposed(root, p string) bool {
|
||||
err := exec.Command("git", "-C", root, "check-ignore", "-q", "--", p).Run()
|
||||
var exit *exec.ExitError
|
||||
return errors.As(err, &exit) && exit.ExitCode() == 1 // 0: ignored; 128: no repo
|
||||
}
|
||||
|
||||
// Write writes the file, readable by the user only when it's new.
|
||||
func (t Target) Write() error {
|
||||
if err := os.MkdirAll(filepath.Dir(t.Abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(t.Abs, t.Data, 0o600)
|
||||
}
|
||||
+6
-20
@@ -43,13 +43,6 @@ type Node struct {
|
||||
// alone isn't.
|
||||
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
|
||||
|
||||
// UsedLately reports whether the item was used within the last 90 days
|
||||
// (what Recent lists). Starred and noted records are kept longer, so a
|
||||
// use alone doesn't say.
|
||||
func (n Node) UsedLately(now time.Time) bool {
|
||||
return !n.LastUsed.IsZero() && now.Sub(n.LastUsed) <= usageMaxAge
|
||||
}
|
||||
|
||||
// StarredFor reports whether the item is starred for account.
|
||||
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
|
||||
|
||||
@@ -152,23 +145,16 @@ type Store struct {
|
||||
data file
|
||||
}
|
||||
|
||||
// ErrNewer is a state file written by a newer lazysecrets.
|
||||
var ErrNewer = errors.New("written by a newer lazysecrets")
|
||||
|
||||
// Load reads path; a missing file is an empty store. path "" gives a store
|
||||
// that never saves, for tests and --demo. A file from a newer lazysecrets
|
||||
// is read but never saved, so nothing it keeps is lost: Load returns the
|
||||
// store with an ErrNewer error.
|
||||
// that never saves, for tests and --demo.
|
||||
func Load(path string) (*Store, error) {
|
||||
s := &Store{path: path}
|
||||
d, err := read(path)
|
||||
if errors.Is(err, ErrNewer) {
|
||||
s.path = ""
|
||||
} else if err != nil {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.data = d
|
||||
return s, err
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func read(path string) (file, error) {
|
||||
@@ -186,12 +172,12 @@ func read(path string) (file, error) {
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return d, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if d.Version > version {
|
||||
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
|
||||
}
|
||||
if d.Nodes == nil {
|
||||
d.Nodes = map[string]Node{}
|
||||
}
|
||||
if d.Version > version {
|
||||
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -66,17 +65,11 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerVersionReadOnly(t *testing.T) {
|
||||
func TestNewerVersionRefused(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
|
||||
_ = os.WriteFile(path, []byte(newer), 0o600)
|
||||
s, err := Load(path)
|
||||
if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" {
|
||||
t.Fatalf("Load = %v, %v", s, err)
|
||||
}
|
||||
_ = s.SetFlag("x", true)
|
||||
if b, _ := os.ReadFile(path); string(b) != newer {
|
||||
t.Errorf("the newer file was overwritten: %s", b)
|
||||
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Error("a newer file was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,15 +112,3 @@ func TestStarsPerAccount(t *testing.T) {
|
||||
t.Errorf("unstar alice: %v", n.StarredBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsedLately(t *testing.T) {
|
||||
now := time.Now()
|
||||
for ago, want := range map[time.Duration]bool{time.Hour: true, 89 * 24 * time.Hour: true, 2 * 365 * 24 * time.Hour: false} {
|
||||
if got := (Node{LastUsed: now.Add(-ago)}).UsedLately(now); got != want {
|
||||
t.Errorf("used %v ago: %v", ago, got)
|
||||
}
|
||||
}
|
||||
if (Node{}).UsedLately(now) {
|
||||
t.Error("never used is lately")
|
||||
}
|
||||
}
|
||||
+58
-129
@@ -3,11 +3,11 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
@@ -65,8 +64,6 @@ const (
|
||||
type Options struct {
|
||||
// Timeout bounds every provider call. Defaults to 30s.
|
||||
Timeout time.Duration
|
||||
// Timeouts override Timeout for the providers of a kind.
|
||||
Timeouts map[provider.Kind]time.Duration
|
||||
// Icons shows Nerd Font glyphs next to backends.
|
||||
Icons bool
|
||||
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
|
||||
@@ -79,19 +76,11 @@ type Options struct {
|
||||
// and keeps its state, for the status pane.
|
||||
ConfigFiles []string
|
||||
StatePath string
|
||||
// Warning shows when the window opens: what lazysecrets skipped
|
||||
// instead of failing to start.
|
||||
Warning string
|
||||
// Changelog is CHANGELOG.md, for the status pane.
|
||||
Changelog string
|
||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||
// run only.
|
||||
CacheFile string
|
||||
// ShareKey is the config's sharing.key: the secret that encrypts shared
|
||||
// env files, for repos whose .lazysecrets.yaml names none.
|
||||
ShareKey string
|
||||
// ShareRules are what a key's value must be to share with it.
|
||||
ShareRules share.Rules
|
||||
// Dir is the folder lazysecrets runs in: it goes back to where it was
|
||||
// left there, unless it was started on a link. "" doesn't.
|
||||
Dir string
|
||||
@@ -122,24 +111,15 @@ type Options struct {
|
||||
// Keys, if set, replaces the default keymap (keys.Map.Apply).
|
||||
Keys *keys.Map
|
||||
// Updater, if set, finds and installs newer releases (U); with
|
||||
// UpdateCheck, lazysecrets also looks on its own at startup, at most
|
||||
// once per UpdateEvery (0 for a day).
|
||||
// UpdateCheck, lazysecrets also looks once a day on its own.
|
||||
Updater *Updater
|
||||
UpdateCheck bool
|
||||
UpdateEvery time.Duration
|
||||
// HandlerSetup, if set, offers to set up the link handler at startup.
|
||||
HandlerSetup *HandlerSetup
|
||||
// NoHistory stops counting uses of items for the Recent list.
|
||||
NoHistory bool
|
||||
// RecentIcon marks items used lately with ◷ in the lists
|
||||
// (ui.recentIcon).
|
||||
RecentIcon bool
|
||||
// Open is a reference to open at startup (lazysecrets open <ref>).
|
||||
Open *ref.Ref
|
||||
// OpenAction, if set, is what to do with Open, rather than asking:
|
||||
// go to it, and maybe copy or show its value (OpenActions). It ends in
|
||||
// the list, like Go to, rather than on the details.
|
||||
OpenAction string
|
||||
// Styles, if set, replaces the default theme (styles.FromTheme), and
|
||||
// DefaultFg the terminal's default text colour.
|
||||
Styles *styles.Styles
|
||||
@@ -158,7 +138,7 @@ type Clipboard interface {
|
||||
type Model struct {
|
||||
providers []provider.Provider
|
||||
byID map[string]provider.Provider
|
||||
timeout timeouts
|
||||
timeout time.Duration
|
||||
|
||||
keys keys.Map
|
||||
st styles.Styles
|
||||
@@ -213,12 +193,9 @@ type Model struct {
|
||||
statusW int // the status pane's width: the left column's, zoomed or not
|
||||
changelog []panes.Field
|
||||
cacheDirty bool
|
||||
// cacheDropped are cache keys deleted since the last save, so saving
|
||||
// deletes them from the file too.
|
||||
cacheDropped map[string]bool
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
sourcesAt map[string]time.Time
|
||||
placeDir string
|
||||
savedPlace string // the link last saved as the place
|
||||
|
||||
// A pending clear of a copied value, counted down in the footer.
|
||||
// clipToken identifies the latest copy so older ticks are ignored.
|
||||
@@ -232,7 +209,6 @@ 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()
|
||||
@@ -254,7 +230,6 @@ type Model struct {
|
||||
|
||||
updater *Updater
|
||||
updateCheck bool
|
||||
updateEvery time.Duration
|
||||
fetchOnStart bool // look for a newer release in Init
|
||||
newer *update.Release // a newer release to offer
|
||||
updated string // the release installed, waiting for a restart
|
||||
@@ -294,11 +269,6 @@ type Model struct {
|
||||
prompt *modal // popup that has the keyboard
|
||||
linked []provider.Container // vaults opened from links, this session
|
||||
open *ref.Ref // reference to open at startup
|
||||
recentIcon bool // mark items used lately (◷)
|
||||
openAction string // and what to do with it, or ask
|
||||
|
||||
shareKey string // the config's sharing.key
|
||||
shareRules share.Rules // and sharing.keyRules
|
||||
|
||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||
dismissedLink string // the last link offered: not offered again
|
||||
@@ -396,7 +366,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m := Model{
|
||||
providers: providers,
|
||||
byID: map[string]provider.Provider{},
|
||||
timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
|
||||
timeout: opts.Timeout,
|
||||
keys: keys.Default(),
|
||||
st: styles.Default(),
|
||||
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
|
||||
@@ -428,8 +398,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
if m.store == nil {
|
||||
m.store, _ = store.Load("")
|
||||
}
|
||||
m.recentIcon = opts.RecentIcon
|
||||
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
|
||||
m.sources = newSourcesList(iconSet, m.store)
|
||||
m.sources.Empty = "no sources found"
|
||||
m.collapsed = map[string]bool{}
|
||||
m.flatSources = opts.FlatSources
|
||||
@@ -449,10 +418,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
|
||||
m.expiryWarning = opts.ExpiryWarning
|
||||
m.handlerSetup = opts.HandlerSetup
|
||||
m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
|
||||
if m.updateEvery == 0 {
|
||||
m.updateEvery = updateEvery
|
||||
}
|
||||
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck
|
||||
m.startUpdateCheck()
|
||||
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
|
||||
m.settle = opts.LoadDelay
|
||||
@@ -461,7 +427,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
}
|
||||
m.defaultFg = opts.DefaultFg
|
||||
m.help = newHelp(m.st)
|
||||
m.open, m.openAction = opts.Open, opts.OpenAction
|
||||
m.open = opts.Open
|
||||
saved := m.store.Layout()
|
||||
m.split, m.heights = saved.Split, saved.Heights
|
||||
|
||||
@@ -470,7 +436,6 @@ 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]{
|
||||
@@ -480,7 +445,6 @@ 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
|
||||
@@ -491,11 +455,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.spinning = true
|
||||
|
||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
||||
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
|
||||
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
||||
if opts.Warning != "" {
|
||||
m.flash = warning(opts.Warning)
|
||||
}
|
||||
if m.cachePath != "" {
|
||||
m.loadCacheFile()
|
||||
if len(m.sourceResults) > 0 {
|
||||
@@ -537,26 +497,18 @@ func (m Model) Init() tea.Cmd {
|
||||
cmds = append(cmds, m.fetchLatest(false))
|
||||
}
|
||||
if m.open != nil {
|
||||
msg := openRefMsg{ref: *m.open, action: m.openAction}
|
||||
cmds = append(cmds, func() tea.Msg { return msg })
|
||||
r := *m.open
|
||||
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} })
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// openRefMsg starts opening a reference from outside the key handler:
|
||||
// asking what to do with it, or doing action (OpenActions).
|
||||
type openRefMsg struct {
|
||||
ref ref.Ref
|
||||
action string
|
||||
}
|
||||
|
||||
// OpenActions are what lazysecrets open --action takes, the Open link
|
||||
// menu's choices that go there.
|
||||
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
|
||||
// openRefMsg starts opening a reference from outside the key handler.
|
||||
type openRefMsg struct{ ref ref.Ref }
|
||||
|
||||
// OpenLink is a message offering r in the Open link popup, for links
|
||||
// handed over by the link handler (Program.Send).
|
||||
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
|
||||
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
@@ -613,24 +565,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case openRefMsg:
|
||||
m.deferHandlerSetup() // the link first
|
||||
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
|
||||
cmds = append(cmds, m.findRef(msg.ref, act))
|
||||
} else {
|
||||
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
||||
}
|
||||
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
||||
|
||||
case resolvedMsg:
|
||||
m.handleResolved(msg)
|
||||
|
||||
case shareFilesMsg:
|
||||
cmds = append(cmds, m.handleShareFiles(msg))
|
||||
|
||||
case sealedMsg:
|
||||
cmds = append(cmds, m.handleSealed(msg))
|
||||
|
||||
case shareOpenedMsg:
|
||||
cmds = append(cmds, m.handleShareOpened(msg))
|
||||
|
||||
case tea.PasteMsg:
|
||||
cmds = append(cmds, m.handlePaste(msg))
|
||||
|
||||
@@ -657,7 +596,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case detailsMsg:
|
||||
if msg.req == m.reqID[slotDetails] {
|
||||
m.cancelReq(slotDetails)
|
||||
m.done(slotDetails)
|
||||
}
|
||||
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
||||
delete(m.details, msg.key) // tried again when shown again
|
||||
@@ -688,13 +627,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.req != m.reqID[slotContainers] {
|
||||
break
|
||||
}
|
||||
m.cancelReq(slotContainers)
|
||||
m.done(slotContainers)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
if src, ok := m.selectedSource(); ok {
|
||||
m.logErrOf(src.Name, msg.err)
|
||||
}
|
||||
uncache(&m, m.cache.containers, msg.key)
|
||||
delete(m.cache.containers, msg.key)
|
||||
m.containers.SetError(msg.err)
|
||||
} else {
|
||||
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
|
||||
@@ -706,7 +645,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.req != m.reqID[slotObjects] {
|
||||
break
|
||||
}
|
||||
m.cancelReq(slotObjects)
|
||||
m.done(slotObjects)
|
||||
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
|
||||
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
|
||||
m.closeVersions()
|
||||
@@ -715,20 +654,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
m.logErrOf(c.Name, msg.err)
|
||||
}
|
||||
uncache(&m, m.cache.objects, msg.key)
|
||||
delete(m.cache.objects, msg.key)
|
||||
m.secrets.SetError(msg.err)
|
||||
} else {
|
||||
if msg.bound != nil {
|
||||
msg.key = m.rebind(*msg.bound)
|
||||
}
|
||||
before := m.cache.objects[msg.key].value
|
||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||
m.cacheDirty = true
|
||||
if m.versionsOf != nil {
|
||||
m.setVersions(msg.items)
|
||||
} else {
|
||||
m.secrets.SetItems(m.starredObjectsFirst(msg.items))
|
||||
cmds = append(cmds, m.refreshShown(before))
|
||||
m.secrets.SetItems(m.starredFirst(msg.items))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,7 +673,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.req != m.reqID[slotValue] {
|
||||
break
|
||||
}
|
||||
m.cancelReq(slotValue)
|
||||
m.done(slotValue)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||
@@ -751,7 +688,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.req != m.reqID[slotVersions] {
|
||||
break
|
||||
}
|
||||
m.cancelReq(slotVersions)
|
||||
m.done(slotVersions)
|
||||
// Errors and unsupported providers just leave the count out.
|
||||
if msg.err == nil {
|
||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||
@@ -763,7 +700,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.req != m.reqID[slotDescribe] {
|
||||
break
|
||||
}
|
||||
m.cancelReq(slotDescribe)
|
||||
m.done(slotDescribe)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||
@@ -778,7 +715,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.req != m.reqID[slotCopy] {
|
||||
break
|
||||
}
|
||||
m.cancelReq(slotCopy)
|
||||
m.done(slotCopy)
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
|
||||
@@ -890,7 +827,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
case key.Matches(msg, k.Quit):
|
||||
return nil, true
|
||||
case key.Matches(msg, k.Help):
|
||||
return m.showKeybindingsFor(""), false
|
||||
return m.showKeybindings(), false
|
||||
case key.Matches(msg, k.Up):
|
||||
m.move(-1)
|
||||
case key.Matches(msg, k.Down):
|
||||
@@ -982,8 +919,6 @@ 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()
|
||||
@@ -1172,11 +1107,14 @@ func (m *Model) syncSelection() tea.Cmd {
|
||||
if m.onLinks() {
|
||||
m.showLinkedContainers()
|
||||
} else if src, ok := m.selectedSource(); ok {
|
||||
m.containers.Title = "[2] Vaults"
|
||||
m.containers.Empty = "no vaults found"
|
||||
switch src.Kind {
|
||||
case provider.KindDatabricks:
|
||||
m.containers.Title = "[2] Scopes"
|
||||
m.containers.Empty = "no secret scopes"
|
||||
case provider.KindRepo:
|
||||
m.containers.Title = "[2] Files"
|
||||
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
|
||||
}
|
||||
cmds = append(cmds, m.showContainers(src, id))
|
||||
@@ -1261,7 +1199,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
|
||||
m.secrets.SetLoading()
|
||||
return m.loadObjects(c, m.objType, key)
|
||||
}
|
||||
m.secrets.SetItems(m.starredObjectsFirst(e.value))
|
||||
m.secrets.SetItems(m.starredFirst(e.value))
|
||||
m.secrets.Select(m.cache.cursor[key])
|
||||
if e.fresh(m.now()) {
|
||||
return nil
|
||||
@@ -1281,7 +1219,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
|
||||
}
|
||||
m.secrets.SetLoading()
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotObjects)
|
||||
return func() tea.Msg {
|
||||
items, err := p.Versions(ctx, o)
|
||||
for i := range items {
|
||||
@@ -1311,7 +1249,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
|
||||
}
|
||||
}
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotVersions)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
items, err := p.Versions(ctx, o)
|
||||
for i := range items {
|
||||
@@ -1321,7 +1259,8 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
|
||||
})
|
||||
}
|
||||
|
||||
// closeVersions returns the Secrets pane to the object list.
|
||||
// closeVersions returns the Secrets pane to the object list, with the
|
||||
// cursor on the object whose versions were shown.
|
||||
func (m *Model) closeVersions() {
|
||||
m.versionsOf = nil
|
||||
}
|
||||
@@ -1461,26 +1400,11 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
|
||||
return m.crawlStart()
|
||||
}
|
||||
|
||||
// timeouts bound each provider request: its kind's, else the default.
|
||||
type timeouts struct {
|
||||
def time.Duration
|
||||
byKind map[provider.Kind]time.Duration
|
||||
}
|
||||
|
||||
// of is how long a request to a provider of kind k may take.
|
||||
func (t timeouts) of(k provider.Kind) time.Duration {
|
||||
if d, ok := t.byKind[k]; ok {
|
||||
return d
|
||||
}
|
||||
return t.def
|
||||
}
|
||||
|
||||
// begin starts a cancellable request for pane p to a provider of kind k,
|
||||
// cancelling any in flight.
|
||||
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
|
||||
// begin starts a cancellable request for pane p, cancelling any in flight.
|
||||
func (m *Model) begin(p slot) (context.Context, int) {
|
||||
m.cancelReq(p)
|
||||
m.seq++
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
||||
m.reqID[p], m.cancel[p] = m.seq, cancel
|
||||
return ctx, m.seq
|
||||
}
|
||||
@@ -1518,6 +1442,9 @@ func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// done releases the context of a completed request.
|
||||
func (m *Model) done(p slot) { m.cancelReq(p) }
|
||||
|
||||
// loadSources starts a new generation of source loads, superseding any in
|
||||
// flight.
|
||||
func (m *Model) loadSources() tea.Cmd {
|
||||
@@ -1531,7 +1458,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
||||
gen := m.sourcesGen
|
||||
var cmds []tea.Cmd
|
||||
for _, p := range m.providers {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
defer cancel()
|
||||
s, err := p.Sources(ctx)
|
||||
@@ -1542,7 +1469,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
||||
})
|
||||
// The source it started in, quickly, on its own.
|
||||
if c, ok := p.(provider.Current); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
defer cancel()
|
||||
src, ok := c.Current(ctx)
|
||||
@@ -1559,7 +1486,7 @@ func (m Model) sourcesCmd() tea.Cmd {
|
||||
|
||||
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
|
||||
p := m.providerFor(src)
|
||||
ctx, req := m.begin(slotContainers, src.Kind)
|
||||
ctx, req := m.begin(slotContainers)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
items, err := p.Containers(ctx, src)
|
||||
// Carry the source as given, with its provider ID, so later
|
||||
@@ -1578,7 +1505,7 @@ func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
|
||||
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
|
||||
cands := m.candidates(c.Source)
|
||||
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
|
||||
ctx, req := m.begin(slotObjects, c.Source.Kind)
|
||||
ctx, req := m.begin(slotObjects)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
var errs []error
|
||||
for _, p := range cands {
|
||||
@@ -1606,7 +1533,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
|
||||
|
||||
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
|
||||
p := m.providerFor(s.Container.Source)
|
||||
ctx, req := m.begin(slotValue, s.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotValue)
|
||||
m.detail.SetLoading()
|
||||
return func() tea.Msg {
|
||||
v, err := p.Value(ctx, s)
|
||||
@@ -1625,7 +1552,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
||||
m.detail.SetDescribing()
|
||||
}
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotDescribe)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
obj, err := p.Describe(ctx, o)
|
||||
return describeMsg{req: req, key: key, obj: obj, err: err}
|
||||
@@ -1637,7 +1564,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
|
||||
func (m *Model) copyValue() tea.Cmd {
|
||||
if m.focus == paneDetail && m.fields.Shown() {
|
||||
if f, ok := m.fields.Selected(); ok {
|
||||
return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
|
||||
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1653,7 +1580,7 @@ func (m *Model) copyValue() tea.Cmd {
|
||||
return m.copyText(o.Name, v.Reveal(), true)
|
||||
}
|
||||
p := m.providerFor(o.Container.Source)
|
||||
ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
|
||||
ctx, req := m.begin(slotCopy)
|
||||
m.flash = flash{text: "copying " + o.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
v, err := p.Value(ctx, o)
|
||||
@@ -1680,8 +1607,12 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
|
||||
|
||||
// clipTick fires when the countdown's displayed second next changes.
|
||||
func (m *Model) clipTick() tea.Cmd {
|
||||
wait := m.clipDeadline.Sub(m.now()) % time.Second
|
||||
if wait <= 0 {
|
||||
wait = time.Second
|
||||
}
|
||||
token := m.clipToken
|
||||
return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
|
||||
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} })
|
||||
}
|
||||
|
||||
func (m *Model) cancelClipClear() {
|
||||
@@ -1788,15 +1719,13 @@ func (m Model) footerView() string {
|
||||
} else if l != nil && l.Filtering() {
|
||||
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
||||
} else {
|
||||
reveal := "show"
|
||||
if m.detail.State() == panes.ValueShown {
|
||||
reveal = "hide"
|
||||
}
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), reveal: reveal}.ShortHelp(), 2)
|
||||
revealed := m.detail.State() == panes.ValueShown
|
||||
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.ShortHelp(), 2)
|
||||
}
|
||||
var lines []string
|
||||
if !m.clipDeadline.IsZero() {
|
||||
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, secondsLeft(m.clipDeadline, m.now())))))
|
||||
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
|
||||
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
|
||||
}
|
||||
if m.flash.text != "" {
|
||||
lines = append(lines, m.flashLine(m.flash))
|
||||
|
||||
+19
-52
@@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) {
|
||||
}
|
||||
// While typing a filter, backspace deletes a character instead.
|
||||
d.keys("/")
|
||||
d.typeText("api-x")
|
||||
d.typeText("apx")
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
|
||||
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 {
|
||||
t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len())
|
||||
@@ -666,7 +666,7 @@ func TestFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("api-k")
|
||||
d.typeText("api")
|
||||
if d.m.secrets.Len() != 1 {
|
||||
t.Fatalf("visible = %d, want 1", d.m.secrets.Len())
|
||||
}
|
||||
@@ -684,9 +684,9 @@ func TestFilter(t *testing.T) {
|
||||
}
|
||||
d.keys("esc") // leave filter mode, clearing the query
|
||||
d.keys("/")
|
||||
d.typeText("api-k")
|
||||
d.typeText("api")
|
||||
d.keys("enter", "esc") // apply, then esc clears the applied filter
|
||||
if d.m.secrets.Len() != 4 {
|
||||
if d.m.secrets.Len() != 3 {
|
||||
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
|
||||
}
|
||||
if _, _, s := d.selected(); s != "api-key" {
|
||||
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("a") // all four secrets contain an a
|
||||
if d.m.secrets.Len() != 4 {
|
||||
d.typeText("a") // all three secrets contain an a
|
||||
if d.m.secrets.Len() != 3 {
|
||||
t.Fatalf("visible = %d", d.m.secrets.Len())
|
||||
}
|
||||
_, _, initial := d.selected() // kept from before filtering: the middle row
|
||||
var seen []string
|
||||
for _, k := range []rune{'j', 'j', 'j', 'k'} {
|
||||
for _, k := range []rune{'j', 'j', 'k'} {
|
||||
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
|
||||
_, _, s := d.selected()
|
||||
seen = append(seen, s)
|
||||
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
|
||||
if !d.m.secrets.Filtering() {
|
||||
t.Fatal("ctrl+j/k left filter mode")
|
||||
}
|
||||
// down to the bottom, stay there, back up one
|
||||
if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
|
||||
t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
|
||||
// down to the bottom, stay there, back up to where it started
|
||||
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial {
|
||||
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "ctrl+j/n down") {
|
||||
t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
|
||||
@@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
d.typeText("api-k")
|
||||
d.typeText("api")
|
||||
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
|
||||
if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
|
||||
t.Errorf("ctrl+y copied %q", d.clip().content)
|
||||
@@ -991,11 +991,11 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
||||
if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") {
|
||||
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
||||
}
|
||||
if !strings.Contains(lastLines(scr, 1), "z show") {
|
||||
t.Errorf("help bar: %q", lastLines(scr, 1))
|
||||
if !strings.Contains(lastLine(scr), "z show") {
|
||||
t.Errorf("help bar: %q", lastLine(scr))
|
||||
}
|
||||
d.keys("z")
|
||||
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") {
|
||||
t.Errorf("help bar with the value shown: %q", bar)
|
||||
}
|
||||
d.keys("z")
|
||||
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
|
||||
if !strings.Contains(scr, "Alice Example") {
|
||||
t.Errorf("account not shown:\n%s", scr)
|
||||
}
|
||||
bar := lastLines(scr, 1)
|
||||
bar := lastLine(scr)
|
||||
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
|
||||
t.Errorf("help bar: %q", bar)
|
||||
}
|
||||
@@ -1187,8 +1187,7 @@ func TestDetailsFollowFocus(t *testing.T) {
|
||||
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 · 4 repos") {
|
||||
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 repos") {
|
||||
t.Errorf("status line: %q", line)
|
||||
}
|
||||
d = newDriver(t, fake.Azure(0))
|
||||
@@ -1203,8 +1202,8 @@ func TestStatusPane(t *testing.T) {
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
|
||||
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
||||
fake.Azure(0), signedOut{fake.Azure(0)})
|
||||
if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
|
||||
t.Errorf("status line: %q", line)
|
||||
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
}
|
||||
d.keys("0")
|
||||
if d.m.focus != paneStatus {
|
||||
@@ -1304,8 +1303,7 @@ func TestExpiringInStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
|
||||
d.keys("3", "]", "]") // load kv-app-prod's certificates
|
||||
// old-tls and legacy-token.
|
||||
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
|
||||
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
}
|
||||
d.keys("0")
|
||||
@@ -1344,34 +1342,3 @@ 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())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -52,44 +48,3 @@ func (c cache) rememberCursor(parent, child string) {
|
||||
c.cursor[parent] = child
|
||||
}
|
||||
}
|
||||
|
||||
// refreshShown shows the selected secret's newer metadata when its list
|
||||
// reloaded with it changed from before (a cached list refreshed in the
|
||||
// background), and describes it afresh: its cached details came from
|
||||
// the old listing.
|
||||
func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
||||
s, ok := m.secrets.Selected()
|
||||
if !ok || m.secrets.SelectedID() != m.selSecret {
|
||||
return nil // a new selection is shown as usual
|
||||
}
|
||||
i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID })
|
||||
if i < 0 || sameListing(before[i], s) {
|
||||
return nil
|
||||
}
|
||||
m.detail.SetDescribed(s)
|
||||
uncache(m, m.cache.details, detailKey(s))
|
||||
return m.describe(s)
|
||||
}
|
||||
|
||||
// uncache deletes key from a cache, and, at the next save, from the file.
|
||||
func uncache[T any](m *Model, c map[string]entry[T], key string) {
|
||||
if _, ok := c[key]; !ok {
|
||||
return
|
||||
}
|
||||
delete(c, key)
|
||||
if m.cacheDropped == nil {
|
||||
m.cacheDropped = map[string]bool{}
|
||||
}
|
||||
m.cacheDropped[key] = true
|
||||
m.cacheDirty = true
|
||||
}
|
||||
|
||||
// sameListing reports whether a and b list the same: what a list
|
||||
// says of an object, not what describing it adds.
|
||||
func sameListing(a, b provider.Object) bool {
|
||||
enabled := func(e *bool) bool { return e == nil || *e }
|
||||
return a.Name == b.Name && a.Version == b.Version && enabled(a.Enabled) == enabled(b.Enabled) &&
|
||||
a.Updated.Equal(b.Updated) && a.Expires.Equal(b.Expires) && a.ContentType == b.ContentType &&
|
||||
maps.Equal(a.Tags, b.Tags) && a.Reference == b.Reference && a.URL == b.URL &&
|
||||
slices.Equal(a.Attrs, b.Attrs)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -160,33 +159,3 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
|
||||
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadedListRefreshesShownSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, _, clock := newCachingDriver(t)
|
||||
d.keys("3") // kv-app-prod's secrets, db-password selected
|
||||
shown, _ := d.m.detail.Secret()
|
||||
// What an older lazysecrets listed and cached for it.
|
||||
const old = "https://portal.example/old"
|
||||
key := d.m.selContainer
|
||||
e := d.m.cache.objects[key]
|
||||
items := slices.Clone(e.value)
|
||||
for i := range items {
|
||||
if items[i].ID == shown.ID {
|
||||
items[i].URL = old
|
||||
}
|
||||
}
|
||||
d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at}
|
||||
stale := shown
|
||||
stale.URL = old
|
||||
d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock}
|
||||
|
||||
// Away and back once the list is stale: it's shown from the cache,
|
||||
// then reloaded, and the details pane follows.
|
||||
d.keys("1", "j")
|
||||
*clock = clock.Add(cacheTTL + time.Second)
|
||||
d.keys("k", "3")
|
||||
if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old {
|
||||
t.Errorf("shown %s with %q after the reload", got.Name, got.URL)
|
||||
}
|
||||
}
|
||||
+21
-11
@@ -1,7 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -85,7 +84,10 @@ func (m *Model) showCommands() tea.Cmd {
|
||||
}
|
||||
var opts []option
|
||||
for _, c := range cmds {
|
||||
label := cmp.Or(c.Description, c.Command)
|
||||
label := c.Description
|
||||
if label == "" {
|
||||
label = c.Command
|
||||
}
|
||||
if c.Key != "" {
|
||||
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
|
||||
}
|
||||
@@ -120,7 +122,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
|
||||
in.SetValue(p.Default)
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
title: cmp.Or(c.Description, "Command"), input: &in,
|
||||
title: cmpOr(c.Description, "Command"), input: &in,
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
it.Prompt[p.Name] = commands.Q(text)
|
||||
return m.askPrompt(c, it, obj, i+1)
|
||||
@@ -149,7 +151,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
|
||||
case strings.HasPrefix(c.Value, "env:"):
|
||||
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
|
||||
}
|
||||
m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
|
||||
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
|
||||
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
|
||||
})
|
||||
return nil
|
||||
@@ -163,7 +165,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
|
||||
o := *obj
|
||||
p := m.providerFor(o.Container.Source)
|
||||
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
|
||||
timeout := m.timeout.of(o.Container.Source.Kind)
|
||||
timeout := m.timeout
|
||||
m.usedObject(o)
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
@@ -175,7 +177,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
|
||||
|
||||
// execCommand runs the command as its output mode says.
|
||||
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
|
||||
shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
|
||||
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
|
||||
setValue := func(cmd *exec.Cmd) {
|
||||
switch {
|
||||
case c.Value == "stdin":
|
||||
@@ -193,7 +195,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
|
||||
}
|
||||
m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
|
||||
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
|
||||
defer cancel()
|
||||
@@ -217,7 +219,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
|
||||
const maxOutputLines = 30
|
||||
|
||||
func (m *Model) handleCommandDone(msg commandDoneMsg) {
|
||||
name := cmp.Or(msg.cmd.Description, "command")
|
||||
name := cmpOr(msg.cmd.Description, "command")
|
||||
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
@@ -296,7 +298,11 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
|
||||
if r, err := ref.ForObject(o, version); err == nil {
|
||||
it.Link = commands.Q(r.String())
|
||||
}
|
||||
o.Version = version // "": the current value, like copying it
|
||||
if version != "" {
|
||||
o.Version = version
|
||||
} else {
|
||||
o.Version = "" // the current value, like copying it
|
||||
}
|
||||
return it, &o, true
|
||||
}
|
||||
|
||||
@@ -307,5 +313,9 @@ func newTextInput(prompt string, width int) textinput.Model {
|
||||
return in
|
||||
}
|
||||
|
||||
// pathInputWidth fits a link or a folder path in a prompt, in the window.
|
||||
func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
|
||||
func cmpOr(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
|
||||
@@ -35,7 +34,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
|
||||
// temporary path's length.
|
||||
shown := ""
|
||||
if d.m.prompt != nil {
|
||||
shown = ansi.Strip(strings.Join(d.m.prompt.lines, ""))
|
||||
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
|
||||
}
|
||||
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
|
||||
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
|
||||
|
||||
+5
-41
@@ -3,7 +3,6 @@ package app
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -14,20 +13,18 @@ import (
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
||||
)
|
||||
|
||||
// Opening a repo's file (e), or on the status pane the config: SOPS files with sops edit, which decrypts
|
||||
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
|
||||
// them for the editor and encrypts them again after. Other files, inside
|
||||
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
|
||||
// $EDITOR, coming back after; else in the system's default app. On a
|
||||
// secret, editors that can open at a line start on its key.
|
||||
|
||||
type editDoneMsg struct {
|
||||
c provider.Container
|
||||
name string
|
||||
config bool // the config file: read at startup
|
||||
err error
|
||||
c provider.Container
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
|
||||
@@ -47,8 +44,6 @@ func (m *Model) edit() tea.Cmd {
|
||||
var c provider.Container
|
||||
var o *provider.Object
|
||||
switch m.focus {
|
||||
case paneStatus: // like lazygit's status panel
|
||||
return m.editConfig()
|
||||
case paneSources:
|
||||
m.flash = warning("select a file to open it")
|
||||
return nil
|
||||
@@ -98,33 +93,6 @@ func (m *Model) edit() tea.Cmd {
|
||||
return m.openInEditor(f, finished)
|
||||
}
|
||||
|
||||
// configHeader starts a config file made to be edited.
|
||||
const configHeader = "# lazysecrets config: " + update.DefaultRepo + "/src/branch/main/docs/config.md\n"
|
||||
|
||||
// editConfig opens the user's config file (the first of them), as e
|
||||
// opens a file, making it if it isn't there yet: e on the status pane.
|
||||
func (m *Model) editConfig() tea.Cmd {
|
||||
if len(m.configFiles) == 0 {
|
||||
m.flash = warning("no config file")
|
||||
return nil
|
||||
}
|
||||
path := m.configFiles[0]
|
||||
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
|
||||
err := os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err == nil {
|
||||
err = os.WriteFile(path, []byte(configHeader), 0o644)
|
||||
}
|
||||
if err != nil {
|
||||
m.flash = flash{text: "edit config: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
return m.openInEditor(provider.File{Path: path}, func(err error) tea.Msg {
|
||||
return editDoneMsg{name: name, config: true, err: err}
|
||||
})
|
||||
}
|
||||
|
||||
// plusLine are editors that open at a line with +N before the file.
|
||||
var plusLine = map[string]bool{
|
||||
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
|
||||
@@ -174,10 +142,6 @@ func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if msg.config {
|
||||
m.flash = done("closed " + msg.name + ": restart lazysecrets to use it")
|
||||
return nil
|
||||
}
|
||||
m.flash = done("closed " + msg.name)
|
||||
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
|
||||
m.hideValue()
|
||||
@@ -242,7 +206,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
|
||||
return tea.QuitMsg{}
|
||||
}
|
||||
}
|
||||
if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||
m.flash = flash{}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
@@ -30,8 +28,8 @@ func (m Model) expiringItems() []expiringItem {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
for _, t := range c.ObjectTypes() {
|
||||
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
|
||||
if state, _ := m.expiry(o); state != expiry.None {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
if state, _ := m.expiry(o); state != notExpiring {
|
||||
out = append(out, expiringItem{o, row})
|
||||
}
|
||||
}
|
||||
@@ -52,11 +50,11 @@ func (m Model) expiringFields() []panes.Field {
|
||||
var out []panes.Field
|
||||
for _, it := range items {
|
||||
state, left := m.expiry(it.o)
|
||||
mark := "⚠ " + expiry.Left(left)
|
||||
if state == expiry.Expired {
|
||||
mark := "⚠ " + daysLeft(left)
|
||||
if state == expired {
|
||||
mark = "✗ expired"
|
||||
}
|
||||
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
|
||||
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
|
||||
if r, err := ref.ForObject(it.o, ""); err == nil {
|
||||
f.Link = r.String()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -11,19 +11,45 @@ import (
|
||||
// list once they have expired (✗ expired) or will within the configured
|
||||
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
|
||||
|
||||
type expiryState int
|
||||
|
||||
const (
|
||||
notExpiring expiryState = iota
|
||||
expiresSoon
|
||||
expired
|
||||
)
|
||||
|
||||
// expiry tells whether o has expired or will within the warning period,
|
||||
// and how long it has left.
|
||||
func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
|
||||
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
|
||||
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
|
||||
if o.Expires.IsZero() {
|
||||
return notExpiring, 0
|
||||
}
|
||||
left := o.Expires.Sub(m.now())
|
||||
switch {
|
||||
case left <= 0:
|
||||
return expired, 0
|
||||
case left <= m.expiryWarning:
|
||||
return expiresSoon, left
|
||||
}
|
||||
return notExpiring, left
|
||||
}
|
||||
|
||||
// expiryWord is matched by the filter: "expired" or "expiring".
|
||||
func (m Model) expiryWord(o provider.Object) string {
|
||||
switch state, _ := m.expiry(o); state {
|
||||
case expiry.Expired:
|
||||
case expired:
|
||||
return "expired"
|
||||
case expiry.Soon:
|
||||
case expiresSoon:
|
||||
return "expiring"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// daysLeft is a short "12d", or "5h" on the last day.
|
||||
func daysLeft(d time.Duration) string {
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
|
||||
}
|
||||
return fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
@@ -3,13 +3,9 @@ package app
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"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"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
@@ -23,22 +19,6 @@ import (
|
||||
|
||||
const star = "★ "
|
||||
|
||||
// recentMark marks items used lately (Recent), with ui.recentIcon.
|
||||
const recentMark = "◷ "
|
||||
|
||||
// marks is what goes before a list row's name: ★ when it's starred for
|
||||
// account and, with recent, ◷ when it was used lately.
|
||||
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
|
||||
var s string
|
||||
if n.StarredFor(account) {
|
||||
s = st.Favourite.Render(star)
|
||||
}
|
||||
if recent && n.UsedLately(now) {
|
||||
s += st.Muted.Render(recentMark)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sourceKey(s provider.Source) string {
|
||||
if r, err := ref.ForSource(s); err == nil {
|
||||
return r.String()
|
||||
@@ -97,22 +77,29 @@ 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 := m.store
|
||||
st, iconSet := m.store, m.icons
|
||||
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: its icon, and its type after it ("sops ·
|
||||
// age", "env · plain text").
|
||||
if icon := m.fileIcon(sty, c); icon != "" {
|
||||
line = icon + " " + line
|
||||
// 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
|
||||
}
|
||||
line += sty.Muted.Render(" " + c.Detail)
|
||||
}
|
||||
line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
|
||||
if n.StarredFor(c.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
if withSource {
|
||||
src := c.Source.Name
|
||||
if account := accountOf(c.Source); account != "" {
|
||||
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
|
||||
src = account + " / " + src
|
||||
}
|
||||
line += sty.Muted.Render(" · " + src)
|
||||
@@ -129,84 +116,37 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
||||
if o.Enabled != nil && !*o.Enabled {
|
||||
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)
|
||||
if n.StarredFor(o.Container.Source.Provider) {
|
||||
line = sty.Favourite.Render(star) + line
|
||||
}
|
||||
line = dimIf(sty, o.Hidden, line)
|
||||
switch state, left := m.expiry(o); state {
|
||||
case expiry.Expired:
|
||||
case expired:
|
||||
line += " " + sty.Error.Render("✗ expired")
|
||||
case expiry.Soon:
|
||||
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
|
||||
case expiresSoon:
|
||||
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
|
||||
}
|
||||
return withNote(sty, line, n.Note, width)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
rank := func(x T) int {
|
||||
if starred(x) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// starredContainersFirst moves starred vaults and scopes to the front,
|
||||
// keeping the order otherwise. It doesn't modify items, which may be cached.
|
||||
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
|
||||
out := slices.Clone(items)
|
||||
slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
|
||||
slices.SortStableFunc(out, func(a, b provider.Container) int {
|
||||
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
|
||||
return starredFirst(sources, func(s provider.Source) bool {
|
||||
return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
|
||||
return starredFirst(items, func(c provider.Container) bool {
|
||||
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
|
||||
return starredFirst(items, func(o provider.Object) bool {
|
||||
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
|
||||
// starredFirst moves starred objects to the front, keeping the order
|
||||
// otherwise. It doesn't modify items, which may be cached.
|
||||
func (m Model) starredFirst(items []provider.Object) []provider.Object {
|
||||
out := slices.Clone(items)
|
||||
slices.SortStableFunc(out, func(a, b provider.Object) int {
|
||||
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// showLinkedContainers lists the vaults opened from links.
|
||||
@@ -227,7 +167,7 @@ func (m *Model) rebind(c provider.Container) string {
|
||||
m.linked[i].Source = c.Source
|
||||
}
|
||||
}
|
||||
key := cachefile.ObjectsKey(c, m.objType)
|
||||
key := containerKey(c) + "|" + string(m.objType)
|
||||
if m.onLinks() {
|
||||
m.containers.SetItems(m.linked)
|
||||
m.containers.Select(containerKey(c))
|
||||
@@ -244,7 +184,7 @@ func (m *Model) resortStarred() {
|
||||
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
|
||||
}
|
||||
if m.versionsOf == nil {
|
||||
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
|
||||
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +230,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
|
||||
c := o.Container
|
||||
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
|
||||
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
|
||||
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
|
||||
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// jump opens the favourites list, filters it and jumps to the first match.
|
||||
@@ -396,7 +394,7 @@ func TestStarWhileFiltering(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.keys("3", "/")
|
||||
for _, r := range "api-k" {
|
||||
for _, r := range "api" {
|
||||
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
|
||||
@@ -430,26 +428,9 @@ func TestStarIsPerAccount(t *testing.T) {
|
||||
d.keys("F")
|
||||
var labels []string
|
||||
for _, o := range d.m.search.shown {
|
||||
labels = append(labels, ansi.Strip(o.label))
|
||||
labels = append(labels, ansiStrip(o.label))
|
||||
}
|
||||
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
|
||||
t.Errorf("favourites should list alice's star only: %q", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentIcon(t *testing.T) {
|
||||
t.Parallel()
|
||||
mem, _ := store.Load("")
|
||||
_ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {})
|
||||
d := newDriverOpts(t, Options{Store: mem, RecentIcon: true})
|
||||
d.keys("3")
|
||||
scr := d.screen()
|
||||
if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") {
|
||||
t.Errorf("want ◷ on api-key only:\n%s", scr)
|
||||
}
|
||||
off := newDriverOpts(t, Options{Store: mem})
|
||||
off.keys("3")
|
||||
if strings.Contains(off.screen(), "◷") {
|
||||
t.Errorf("◷ without ui.recentIcon:\n%s", off.screen())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
@@ -62,7 +60,7 @@ func (m *Model) syncFields() tea.Cmd {
|
||||
case !ok:
|
||||
m.fields.Show("", nil)
|
||||
case m.selectedProblem() != "":
|
||||
m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
||||
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
|
||||
case r.kind == rowGroup:
|
||||
m.showAccount(r)
|
||||
case r.kind == rowSource:
|
||||
@@ -135,7 +133,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
|
||||
add("web", src.URL)
|
||||
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
|
||||
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
|
||||
add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
|
||||
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -159,10 +157,10 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
|
||||
case provider.KindRepo:
|
||||
add("format", c.Detail)
|
||||
}
|
||||
add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
add("web", c.URL)
|
||||
for _, t := range c.ObjectTypes() {
|
||||
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
|
||||
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
|
||||
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
|
||||
}
|
||||
}
|
||||
@@ -198,7 +196,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
|
||||
}
|
||||
}
|
||||
m.details[key] = detailsState{loading: true}
|
||||
ctx, req := m.begin(slotDetails, c.Source.Kind)
|
||||
ctx, req := m.begin(slotDetails)
|
||||
return m.settled(ctx, func() tea.Msg {
|
||||
attrs, err := d.Details(ctx, c)
|
||||
if ctx.Err() != nil {
|
||||
@@ -222,5 +220,5 @@ func (m Model) problemFields(problem string) []panes.Field {
|
||||
// fieldName names a copied field: its label, or a row of several under
|
||||
// one label (a tag), its value.
|
||||
func fieldName(f panes.Field) string {
|
||||
return cmp.Or(f.Label, f.Value)
|
||||
return cmpOr(f.Label, f.Value)
|
||||
}
|
||||
+19
-11
@@ -1,7 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -17,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// The help bar shows the keys that matter in the focused pane; ? lists all
|
||||
// of them in a menu (showKeybindingsFor).
|
||||
// of them in a menu (showKeybindings).
|
||||
|
||||
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
|
||||
// descriptions in the normal text colour, only the separators dimmed. The
|
||||
@@ -39,9 +38,10 @@ type contextHelp struct {
|
||||
commands bool // custom commands apply here
|
||||
account bool // the fourth pane shows Fields (fields.go)
|
||||
of pane // what the fourth pane describes
|
||||
// reveal, when set, is what the reveal key will do, "show" or "hide":
|
||||
// the same width so the bar doesn't shift.
|
||||
reveal string
|
||||
// revealed, when set, says whether the selected value is shown, so
|
||||
// the reveal key says just what it'll do: show or hide, the same
|
||||
// width so the bar doesn't shift.
|
||||
revealed *bool
|
||||
}
|
||||
|
||||
func (c contextHelp) ShortHelp() []key.Binding {
|
||||
@@ -54,8 +54,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
switch {
|
||||
case c.focus == paneStatus:
|
||||
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
|
||||
config := key.NewBinding(key.WithKeys(k.Edit.Keys()...), key.WithHelp(k.Edit.Help().Key, "edit config"))
|
||||
return append(b, details, config, k.Update, k.Help, k.Quit)
|
||||
return append(b, details, k.Update, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account && c.of == paneStatus:
|
||||
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
|
||||
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
|
||||
@@ -69,8 +68,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
|
||||
default:
|
||||
reveal := k.Reveal
|
||||
if c.reveal != "" {
|
||||
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
|
||||
if c.revealed != nil {
|
||||
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
|
||||
}
|
||||
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
|
||||
}
|
||||
@@ -84,6 +83,11 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
|
||||
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
||||
|
||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, the focused pane's first, filterable by key, description
|
||||
// and group. Enter runs the selected one.
|
||||
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
|
||||
|
||||
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, filterable by key, description and group; enter runs the
|
||||
// selected one. Opened from the panes, it lists the focused pane's keys
|
||||
@@ -149,7 +153,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
|
||||
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
|
||||
opts = append(opts, option{label: "Custom commands", header: true})
|
||||
for _, c := range cmds {
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(c.Description, "command")) + m.st.Muted.Render("custom")
|
||||
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
|
||||
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
|
||||
}
|
||||
}
|
||||
@@ -258,7 +262,11 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
|
||||
|
||||
// move is "↑/↓ move" for an up and a down binding.
|
||||
func move(up, down key.Binding) keyHint {
|
||||
return keyHint{joinKeys(up, down), "move"}
|
||||
u, d := keys.Name(up), keys.Name(down)
|
||||
if u == "" || d == "" {
|
||||
return keyHint{u + d, "move"}
|
||||
}
|
||||
return keyHint{u + "/" + d, "move"}
|
||||
}
|
||||
|
||||
// withKey fills a hint's format with the binding's key ("· %s loads
|
||||
|
||||
+16
-63
@@ -38,18 +38,15 @@ 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, m.refFileIcon(r)), note: n.Note, mark: " ",
|
||||
label: m.pathLabel(r.Kind, path, account), 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 {
|
||||
m.used(key)
|
||||
return run(m)
|
||||
@@ -109,7 +106,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
||||
if m.history {
|
||||
var used []string
|
||||
for _, k := range keys {
|
||||
if nodes[k].UsedLately(m.now()) {
|
||||
if !nodes[k].LastUsed.IsZero() {
|
||||
used = append(used, k)
|
||||
}
|
||||
}
|
||||
@@ -128,15 +125,12 @@ 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, fileIcon string) string {
|
||||
func (m Model) pathLabel(kind provider.Kind, path []string, account 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)
|
||||
@@ -144,58 +138,6 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account, fileIcon st
|
||||
return label
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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 storeKey(c) == file.String() {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
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...), "", "")
|
||||
}
|
||||
|
||||
// sourceMatches reports whether src is the subscription or workspace r
|
||||
// links to.
|
||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||
@@ -232,14 +174,25 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
||||
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
|
||||
return nil
|
||||
}
|
||||
choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
|
||||
var choices []choice
|
||||
for _, p := range m.providers {
|
||||
if p.Kind() != r.Kind {
|
||||
continue
|
||||
}
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
if src.ID == n.Source && n.Source != "" {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
|
||||
}
|
||||
}
|
||||
}
|
||||
switch len(choices) {
|
||||
case 0:
|
||||
return m.openRef(r, actGo)
|
||||
case 1:
|
||||
m.startNav(r, actGo, choices[0].sourceRow)
|
||||
default:
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false)
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
|
||||
func TestContextHelpBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
|
||||
t.Errorf("Sources help bar: %q", bar)
|
||||
}
|
||||
d.keys("3")
|
||||
if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
||||
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
|
||||
t.Errorf("Secrets help bar: %q", bar)
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,11 @@ func TestKeyPress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
return lines[len(lines)-1]
|
||||
}
|
||||
|
||||
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
@@ -259,7 +264,7 @@ func TestVersionInStatus(t *testing.T) {
|
||||
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
||||
}
|
||||
if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help line %q", bar)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user