50 Commits
Author SHA1 Message Date
keb c1dcda6c2b chore(release): v0.3.0
Release / Build and publish (push) Successful in 53s
2026-10-15 16:13:49 +02:00
keb 27d6251579 chore: release.sh takes patch, minor or major (#87)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 16:13:07 +02:00
keb b3a3c59159 chore: a Spring and compose repo in the demo (#86)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 14:46:23 +02:00
keb e2f289da5c feat: Go to shows a repo file's icon before its name (#85)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 14:31:10 +02:00
keb 0f5248d8f6 feat: Docker Compose files in repos (#84)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 14:20:17 +02:00
keb 580c7ef985 feat: example env files are listed hidden, not skipped (#83)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 14:07:02 +02:00
keb 4bac06ae5b feat: secret patterns for all of a repo's config files, with per-type overrides (#82)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 14:06:02 +02:00
keb 913b3db6a9 feat: H shows what's hidden; Spring keys that don't look secret are hidden (#81)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 13:44:29 +02:00
keb 17da9eaa32 feat: decrypt Jasypt values in .env and Spring files (#80)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 13:31:58 +02:00
keb 96395cb6a2 feat: Spring Boot config files in repos (#79)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 13:11:02 +02:00
keb 39cbac9aab fix: decrypt SOPS .env keys written with export or spaces (#78)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 12:18:09 +02:00
keb 637bf6a253 chore(release): v0.2.1
Release / Build and publish (push) Successful in 51s
2026-10-15 10:52:09 +02:00
keb 29b0c76f40 chore: updated main doc image 2026-10-15 10:49:28 +02:00
keb df429002c9 refactor: one copy of the UI's repeated helpers (#77)
- Text inputs come from newTextInput; the link and folder prompts share one width.
- One starred-first sort for sources, vaults and secrets; accountOf where the account was cut from the label by hand.
- One helper each for the reveal and clipboard countdowns' tick and seconds, the "Open with" choices, the vaults-loaded count, and Go to's and the panes' tabs.
- The details pane builds its label prefix, indent, note and source once; list rows mark matches with MarkMatches.
- The release page opens through openPage, so it now says it opened, as other pages do.
- The cache's account filter is built once per load or save, not per key.

Reviewed-on: #77
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:43:42 +02:00
keb 0ff5204d4d refactor: one place for Key Vault collections and paging in the providers (#75)
- ObjectType.Collection names a type's segment in Key Vault URLs and links (secrets, keys, certificates), instead of four copies in ref, azure and fake.
- Azure lists through one pager helper instead of eight hand-written page loops; Value is VersionedValue without the version, rather than the two calling each other.
- Databricks' Key Vault fallback check lives in fallbackVault.
- The repo provider reads .lazysecrets.yaml in one place, builds plain and sops objects in one loop, and parses a file's sops metadata from the bytes Objects already read.

Reviewed-on: #75
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:41:43 +02:00
keb 6842c72662 refactor: tidy the command's setup, the update command and instance reach (#73)
- main: the subcommand is a name rather than nil-or-empty argument slices; the cache file and start directory are worked out once; config values with a default go through one valueOr helper.
- The link handler's --config argument comes from one configArgs helper.
- lazysecrets update parses its flags like the other subcommands, so -check and --check=true work too, and tells a development build by its dev version.
- Instance reach is named (unreachable, switchable, inView), tmux client activity is compared as a number, and the best client loop starts from the first.
- update: serverTimeout (it bounds the checksums request too), and parentheses in the platform check.

Reviewed-on: #73
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:40:40 +02:00
keb fae8e1cbb4 refactor: drop dead code and use the standard library where it already does the job (#76)
No behaviour change.

- Deleted, unused or one-line wrappers: panes.RuneIndexes, Detail.Refresh (SetDescribed does it), SecretValue.Len, Query.Empty, Clipboard.Native, config.Load, get's value, Model.done, providerOf (cachefile.ProviderOf), contextWithTimeout, searchCoverage, startCrawl, showKeybindings, the expiry aliases and daysLeft.
- cmp.Or instead of the two cmpOr copies; slices.Sorted(maps.Keys(…)), maps.Copy, wg.Go, utf8.RuneCountInString and new(true) instead of loops and helpers that do the same.
- A container's pane title is set once, not twice; a redundant length check in share links and a no-op assignment in linkhandler.Launch are gone.
- Tests call ansi.Strip and lastLines directly; gofmt -s on links.go.

Reviewed-on: #76
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:39:38 +02:00
keb 1753630c73 chore: tidy comments that repeat the code or no longer match it (#72)
- Comments that only restate a name (sgr, fg, mdLink, the repo provider's starred and current-repo notes) are gone.
- Stale ones match the code again: the repo package covers plain env files too, Launch runs argv, closeVersions no longer claims to move the cursor, and Check's fallback says why rather than naming a state no release had.
- The doc for versionString was attached to pseudo; the backtick in SourcesView's doc was a string-concatenation leftover; openFailed's doc was garbled.
- recentReleases sits next to latest, its user.
- .gitignore drops the Go template's boilerplate and patterns for files this project doesn't make.

Reviewed-on: #72
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:38:56 +02:00
keb b773dfe296 docs: fix what no longer matches lazysecrets, and say each thing once (#74)
Fixes:
- A newer release shows in the status pane (`↑ v0.2.0`), not the footer.
- The account in `lazysecrets list` is plain `Azure` with one az account; `Azure:<user>/<tenant>` only with several.
- Sharing lists subscription, workspace and scope links, and opening takes Azure portal links.
- `--timeout` and `--clipboard-clear` are documented, next to their config keys.
- `--kind`'s description reads, and says `vault` covers scopes and repo files.
- `lazysecrets -h` lists `list`'s `--stars`, `--notes`, `--recent` and `--expiry`.
- README typos; "Supported platforms" is "Sources".

Trimmed:
- An option is documented on its topic's page; config.md's example keeps the general ones and links the rest.
- The fzf picker lives in docs/integration.md; the README keeps a link and the screenshot.
- The clipboard clear, reveal timeout, per-account stars, Azure's "no extra requests" and `open --action` are each said once.
- The status pane's contents are a list; build sizes and PR-title spellings are gone from development.md.

Reviewed-on: #74
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:38:14 +02:00
keb bb41767cba fix: a link opens lazysecrets with your cache and next to your running instances (#71)
The link handler's window starts with an app's minimal environment, and only PATH, the config locations and XDG_CONFIG_HOME/XDG_STATE_HOME were carried into it. With XDG_CACHE_HOME or XDG_RUNTIME_DIR set, lazysecrets opened from a link used another cache file and didn't see running instances' sockets; on Linux, without XDG_DATA_HOME it looked for its desktop entry in the wrong place. `handler install` now records those three too: run it again to pick them up.

Reviewed-on: #71
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-15 10:37:14 +02:00
keb b1eda53d5d chore(release): v0.2.0
Release / Build and publish (push) Successful in 53s
2026-10-14 21:43:27 +02:00
keb 39454d47a8 feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers (#70)
`lazysecrets list` prints every source, vault and secret lazysecrets can find, one per line, for fzf, editor pickers and scripts: what Go to searches, without the TUI. What's cached prints at once; what's missing or older than `--max-age` loads in the background and streams in. It stops when the reader goes (a pick in fzf), saving what it loaded.

- Text: kind, mark (✗/⚠, ★, ◷ and the provider icon), name, status (why it couldn't be read, or expired / expires in 12d), note, account and link, tab-separated; kind first and account and link last, so new fields go between. Coloured from the theme; `--color never`, `--icons=false`, `--stars=false`, `--recent`, `--notes=false`, `--expiry=false`.
- `--format json`: an object per line, with starred, recent, lastUsed and uses, expires, enabled, note, reference and url too.
- `--kind` lists only some kinds, and doesn't load lists only other kinds need; `--cached` loads nothing.
- `lazysecrets open --action go|copy|show` skips the "what to do" menu and ends in the list, like Go to; `open` and `get` take `--account`, the account list printed, rather than asking which.
- The cache file moved to `internal/cachefile`: saves merge with the file under a lock, so list and the TUI don't undo each other.
- `ui.recentIcon` marks items used lately with ◷ in the TUI's lists too; Recent now means the last 90 days.
- --demo has stars, notes, recent use, and expired, expiring and disabled secrets.
- docs/integration.md documents get, list and an fzf picker; docs/links.md is docs/sharing.md.

Reviewed-on: #70
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:30:44 +02:00
keb dbb0eb2bbd fix: a wrapped value in the details keeps its colour on every line (#69)
A long value in the details pane (a ref, a note, a vault's detail, an error) was coloured, then wrapped: the colour's reset only came on the last line, and each line is drawn on its own, so the lines after the first lost the colour. Each wrapped line now starts with the styles still on at the end of the one before.

Reviewed-on: #69
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:23:53 +02:00
keb e825a51b30 fix: local builds are dev-<commit> again, not a Go pseudo-version (#68)
Go now stamps local builds with a module pseudo-version (v0.1.3-0.20261014174204-1c31a75d0507+dirty), so the version showed that instead of dev-<commit> as the docs say. The update check also took it for a pre-release of 0.1.3, a version that doesn't exist, rather than a development build. A pseudo-version is now dev-<its commit>, -dirty with uncommitted changes; real versions are unchanged.

Reviewed-on: #68
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:23:06 +02:00
keb 4480e8fed0 fix: lazysecrets starts from fzf's become, on a non-blocking terminal (#67)
fzf's become hands lazysecrets its /dev/tty still non-blocking. Go can't poll /dev/tty on macOS, so it reads it directly, and the TUI's first read failed with "resource temporarily unavailable" (EAGAIN). lazysecrets now makes a terminal on stdin blocking before the TUI starts (Unix; nothing on Windows).

Reviewed-on: #67
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:22:19 +02:00
keb 3e6db064ab chore(release): v0.1.2
Release / Build and publish (push) Successful in 53s
2026-10-13 23:40:42 +02:00
keb a81fcaa5a6 fix: unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one (#66)
Changes that would break configs or links once people use lazysecrets, made while nobody does yet.

- **Repo key names**: a dot or bracket inside a key is escaped (`app\.name`), so links to it resolve to that key and not to `app` → `name`. Dotenv files are flat, so their names stay as they are.
- **Config**: `updates.updateSources` is now `updates.sources`. A negative provider timeout, or `--timeout` of 0 or less, is an error instead of making every request fail.
- **Keybindings**: `lists.share` is now `lists.copyLink`, `lists.search` is `lists.goTo` and `lists.enter` is `lists.open`, matching what they do.
- **Version skew**, e.g. a lazysecrets.nvim theme file made for a newer lazysecrets:
  - an unknown theme role is skipped with a warning instead of stopping startup;
  - a state file from a newer version is read but not saved to, with a warning;
  - a link with an unknown provider suggests `lazysecrets update`.

Reviewed-on: #66
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 23:28:44 +02:00
keb e28328ef82 docs: SECURITY.md says how to report a vulnerability (#65)
SECURITY.md asks for vulnerabilities to be reported privately, through GitHub's private vulnerability reporting or by email to karl-einarb@outlook.com.

docs/development.md: the "Pull requests" heading is now "How changes land". The section covers squash merges, PR titles and branch protection, not how to send one.

Reviewed-on: #65
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 23:27:43 +02:00
keb 6b3edd45d8 fix: help and errors name sharing.key and sharing.keyRules, not share.* (#64)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 22:57:49 +02:00
keb 5cf9ecc0e2 chore: install segment on readme (#63)
Reviewed-on: #63
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 20:54:10 +02:00
keb a46fc9fa19 chore: .mailmap shows Gitea's old noreply.localhost addresses as noreply.git.kebcloud.no (#62)
Gitea wrote merges as 1+keb@noreply.localhost and 3+renovate-bot@noreply.localhost until NO_REPLY_ADDRESS was set. git log, shortlog and blame now show those as noreply.git.kebcloud.no, without rewriting history.

Reviewed-on: #62
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 20:41:17 +02:00
Karl-Einar Bergstrøm 75bab25286 feat: the update changelog covers every version you missed (#61)
Latest reads the last 10 releases in one request instead of only the
newest, so U and lazysecrets update --check show the changes of every
release since the running version, each under its version. Drafts and
pre-releases are skipped. A changelog taller than the screen is cut
with a pointer to the release pages.

Reviewed-on: #61
2026-10-13 20:25:28 +02:00
keb 10d1fc63c7 feat: config grouped by area, with per-provider timeouts and an update interval (#60)
Regroups the config, while there are no users to migrate:

- `ui`: icons, sourcesView, collapseGroups, loadDelay, expiryWarningDays
- `values`: revealTimeout, hideOnBlur, clipboardClear
- `storage`: cache, history
- `updates`: autoCheck (was updateCheck), autoCheckInterval (new, 24h by default), updateSources (was releases)
- `providers.timeout` (was timeout), plus `timeout` on azure, databricks and repos overriding it for that provider; `--timeout` still sets them all
- `sharing` (was share), in the config and in a repo's `.lazysecrets.yaml`

docs/config.md and the pages naming these keys are updated.

Reviewed-on: #60
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 20:23:55 +02:00
renovate-bot Bot 5cd6d90555 chore(deps): update non-major (#59)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-13 17:19:14 +02:00
keb dc99779885 docs: the README shows the GitHub install URL too (#58)
Reviewed-on: #58
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 15:47:21 +02:00
keb 47ef86293d feat: look for repos in the usual folders, not just ~/git (#57)
Without roots in the config, repos are found under whichever of ~/git,
~/repo, ~/repos, ~/projects, ~/code, ~/source and ~/src exist. Missing
ones cost a stat each. A root that's a symlink to another is scanned
once, and a repo under two roots keeps the first one's name.

Reviewed-on: #57
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 15:37:55 +02:00
keb 87141cb53b chore(release): v0.1.1
Release / Build and publish (push) Successful in 1m13s
2026-10-13 14:57:31 +02:00
keb e9366faa9b chore: the changelog shows #55's key as a key (#56)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 14:57:24 +02:00
Karl-Einar Bergstrøm 4377eb98bf feat: share env files as encrypted links (#54)
Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link.

**Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file).
- **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference.
- **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels.
- **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening.
- **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them.
- **The command line:** `lazysecrets share [--key <secret>] <env files>`.

**Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`.
1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`.
2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore.
3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written.
4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged.

**Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable.

**Safety:**
- A link can only write env files, inside the folder: never in `.git`, never through a symlink.
- New files are created with mode 0600.
- Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB.

**Along the way:**
- Opening a link to a secret ends on its details, as `enter` would.
- `o` on an Azure secret opens its portal page, and in the versions view, that version's page.
- The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address.
- `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning.
- `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there.
- New dependency: `filippo.io/age`.

Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #54
2026-10-13 14:55:22 +02:00
keb 2044341b20 feat: e on the status pane edits the config (#55)
`e` on the status pane (`0`) opens your config file, as in lazygit's status panel. The status footer shows it as `e edit config`. There's no global key. It opens the config the same way `e` opens a local file:
- inside nvim, in that nvim (lazysecrets quits);
- otherwise in `$VISUAL` or `$EDITOR`, coming back after;
- otherwise in the system's default app.

It opens the first config file: your own, also when `--config` layers several. A config that doesn't exist yet is created with a comment linking to its docs. Closing the editor says "restart lazysecrets to use it", since the config is read at startup.

Docs: keys.md (status pane) and config.md. Tested: the default app with a new config, nvim with an existing one left unchanged, the restart message, and the footer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewed-on: #55
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 13:03:43 +02:00
renovate-bot Bot 1a5e40a012 chore(deps): update go module directive to v1.27.2 (#51)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-13 10:55:33 +02:00
keb 394dd85105 ci: staticcheck v0.8.2, which reads Go 1.27 (#53)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-13 10:53:01 +02:00
renovate-bot Bot 199fa039e4 chore(deps): update non-major (#50)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 23:18:55 +02:00
renovate-bot Bot a2ece5bf4f chore(deps): update azure-sdk-for-go monorepo to v2 (#52)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault](https://github.com/Azure/azure-sdk-for-go) | `v1.5.0` → `v2.1.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v2.1.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v1.5.0/v2.1.0?slim=true) |
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions](https://github.com/Azure/azure-sdk-for-go) | `v1.3.0` → `v2.0.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v2.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v1.3.0/v2.0.0?slim=true) |

---

### Release Notes

<details>
<summary>Azure/azure-sdk-for-go (github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault)</summary>

### [`v2.1.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.1.0)

#### 2.1.0 (2026-10-06)

##### Features Added

- New value `JSONWebKeyTypeOctHSM` added to enum type `JSONWebKeyType`
- New value `ManagedHsmSKUNameStandardB10V2`, `ManagedHsmSKUNameStandardB15V2`, `ManagedHsmSKUNameStandardB1V2`, `ManagedHsmSKUNameStandardB20V2`, `ManagedHsmSKUNameStandardB5V2` added to enum type `ManagedHsmSKUName`

### [`v2.0.2`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.2)

#### 2.0.2 (2026-03-17)

##### Other Changes

- Upgrade to service API version 2026-02-01

### [`v2.0.1`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.1)

#### 2.0.1 (2025-11-05)

##### Breaking Changes

- Field `RetryAfter` of struct `SecretsClientCreateOrUpdateResponse` has been removed

### [`v2.0.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/resources/armsubscriptions/v2.0.0)

#### 2.0.0 (2026-06-24)

##### Breaking Changes

- Struct `ErrorAdditionalInfo` has been removed
- Struct `ErrorDetail` has been removed
- Struct `ErrorResponse` has been removed
- Struct `ErrorResponseAutoGenerated` has been removed
- Struct `OperationAutoGenerated` has been removed
- Struct `OperationDisplayAutoGenerated` has been removed
- Struct `OperationListResultAutoGenerated` has been removed

##### Features Added

- New field `NextLink` in struct `LocationListResult`

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: #52
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 22:29:31 +02:00
renovate-bot Bot 6d1c26639e chore(deps): update go module directive to v1.26.9 (#49)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 22:10:21 +02:00
renovate-bot BotandKarl-Einar Bergstrøm d32bb63a9e chore(deps): update azure-sdk-for-go monorepo to v2 (#40)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault](https://github.com/Azure/azure-sdk-for-go) | `v1.5.0` → `v2.1.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v2.1.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fkeyvault%2farmkeyvault/v1.5.0/v2.1.0?slim=true) |
| [github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions](https://github.com/Azure/azure-sdk-for-go) | `v1.3.0` → `v2.0.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v2.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/github.com%2fAzure%2fazure-sdk-for-go%2fsdk%2fresourcemanager%2fresources%2farmsubscriptions/v1.3.0/v2.0.0?slim=true) |

---

### Release Notes

<details>
<summary>Azure/azure-sdk-for-go (github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault)</summary>

### [`v2.1.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.1.0)

#### 2.1.0 (2026-10-06)

##### Features Added

- New value `JSONWebKeyTypeOctHSM` added to enum type `JSONWebKeyType`
- New value `ManagedHsmSKUNameStandardB10V2`, `ManagedHsmSKUNameStandardB15V2`, `ManagedHsmSKUNameStandardB1V2`, `ManagedHsmSKUNameStandardB20V2`, `ManagedHsmSKUNameStandardB5V2` added to enum type `ManagedHsmSKUName`

### [`v2.0.2`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.2)

#### 2.0.2 (2026-03-17)

##### Other Changes

- Upgrade to service API version 2026-02-01

### [`v2.0.1`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/keyvault/armkeyvault/v2.0.1)

#### 2.0.1 (2025-11-05)

##### Breaking Changes

- Field `RetryAfter` of struct `SecretsClientCreateOrUpdateResponse` has been removed

### [`v2.0.0`](https://github.com/Azure/azure-sdk-for-go/releases/tag/sdk/resourcemanager/resources/armsubscriptions/v2.0.0)

#### 2.0.0 (2026-06-24)

##### Breaking Changes

- Struct `ErrorAdditionalInfo` has been removed
- Struct `ErrorDetail` has been removed
- Struct `ErrorResponse` has been removed
- Struct `ErrorResponseAutoGenerated` has been removed
- Struct `OperationAutoGenerated` has been removed
- Struct `OperationDisplayAutoGenerated` has been removed
- Struct `OperationListResultAutoGenerated` has been removed

##### Features Added

- New field `NextLink` in struct `LocationListResult`

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #40
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:15:45 +02:00
renovate-bot BotandKarl-Einar Bergstrøm 9de519d515 chore(deps): update actions/setup-go action to v7 (#39)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/setup-go](https://github.com/actions/setup-go) | action | major | `v5` → `v7` |

---

### Release Notes

<details>
<summary>actions/setup-go</summary>

### [`v7.0.0`](https://github.com/actions/setup-go/releases/tag/v7.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.5.0...v7.0.0)

##### What's Changed

- Migrate to ESM and upgrade dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;763](https://github.com/actions/setup-go/pull/763)
- chore(deps): bump [@&#8203;actions/cache](https://github.com/actions/cache) to 6.2.0 by [@&#8203;philip-gai](https://github.com/philip-gai) in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

##### New Contributors

- [@&#8203;philip-gai](https://github.com/philip-gai) made their first contribution in [#&#8203;771](https://github.com/actions/setup-go/pull/771)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v7.0.0>

### [`v6.5.0`](https://github.com/actions/setup-go/releases/tag/v6.5.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.4.0...v6.5.0)

##### What's Changed

##### Dependency update

- Upgrade actions dependencies by [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- Upgrade [@&#8203;types/node](https://github.com/types/node) and typescript-eslint dependencies to resolve npm audit findings by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;755](https://github.com/actions/setup-go/pull/755)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to 5.1.0, log cache write denied by [@&#8203;jasongin](https://github.com/jasongin) in [#&#8203;758](https://github.com/actions/setup-go/pull/758)
- Upgrade version to 6.5.0 in package.json and package-lock.json by [@&#8203;HarithaVattikuti](https://github.com/HarithaVattikuti) in [#&#8203;762](https://github.com/actions/setup-go/pull/762)

##### New Contributors

- [@&#8203;priyagupta108](https://github.com/priyagupta108) with [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;744](https://github.com/actions/setup-go/pull/744)
- [@&#8203;jasongin](https://github.com/jasongin) made their first contribution in [#&#8203;758](https://github.com/actions/setup-go/pull/758)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.5.0>

### [`v6.4.0`](https://github.com/actions/setup-go/releases/tag/v6.4.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.3.0...v6.4.0)

##### What's Changed

##### Enhancement

- Add go-download-base-url input for custom Go distributions by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

##### Dependency update

- Upgrade minimatch from 3.1.2 to 3.1.5 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;727](https://github.com/actions/setup-go/pull/727)

##### Documentation update

- Rearrange README.md, add advanced-usage.md by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;724](https://github.com/actions/setup-go/pull/724)
- Fix Microsoft build of Go link by [@&#8203;gdams](https://github.com/gdams) in [#&#8203;734](https://github.com/actions/setup-go/pull/734)

##### New Contributors

- [@&#8203;gdams](https://github.com/gdams) made their first contribution in [#&#8203;721](https://github.com/actions/setup-go/pull/721)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.4.0>

### [`v6.3.0`](https://github.com/actions/setup-go/releases/tag/v6.3.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.2.0...v6.3.0)

##### What's Changed

- Update default Go module caching to use go.mod by [@&#8203;priyagupta108](https://github.com/priyagupta108) in [#&#8203;705](https://github.com/actions/setup-go/pull/705)
- Fix golang download url to go.dev by [@&#8203;178inaba](https://github.com/178inaba) in [#&#8203;469](https://github.com/actions/setup-go/pull/469)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.3.0>

### [`v6.2.0`](https://github.com/actions/setup-go/releases/tag/v6.2.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.1.0...v6.2.0)

##### What's Changed

##### Enhancements

- Example for restore-only cache in documentation  by [@&#8203;aparnajyothi-y](https://github.com/aparnajyothi-y) in [#&#8203;696](https://github.com/actions/setup-go/pull/696)
- Update Node.js version in action.yml by [@&#8203;ccoVeille](https://github.com/ccoVeille) in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- Documentation update of actions/checkout by [@&#8203;deining](https://github.com/deining) in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

##### Dependency updates

- Upgrade js-yaml from 3.14.1 to 3.14.2 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;682](https://github.com/actions/setup-go/pull/682)
- Upgrade [@&#8203;actions/cache](https://github.com/actions/cache) to v5 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;695](https://github.com/actions/setup-go/pull/695)
- Upgrade actions/checkout from 5 to 6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;686](https://github.com/actions/setup-go/pull/686)
- Upgrade qs from 6.14.0 to 6.14.1 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;703](https://github.com/actions/setup-go/pull/703)

##### New Contributors

- [@&#8203;ccoVeille](https://github.com/ccoVeille) made their first contribution in [#&#8203;691](https://github.com/actions/setup-go/pull/691)
- [@&#8203;deining](https://github.com/deining) made their first contribution in [#&#8203;683](https://github.com/actions/setup-go/pull/683)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.2.0>

### [`v6.1.0`](https://github.com/actions/setup-go/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/setup-go/compare/v6.0.0...v6.1.0)

##### What's Changed

##### Enhancements

- Fall back to downloading from go.dev/dl instead of storage.googleapis.com/golang by [@&#8203;nicholasngai](https://github.com/nicholasngai) in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- Add support for .tool-versions file and update workflow by [@&#8203;priya-kinthali](https://github.com/priya-kinthali) in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- Add comprehensive breaking changes documentation for v6 by [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

##### Dependency updates

- Upgrade eslint-config-prettier from 10.0.1 to 10.1.8 and document breaking changes in v6 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;617](https://github.com/actions/setup-go/pull/617)
- Upgrade actions/publish-action from 0.3.0 to 0.4.0 by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;641](https://github.com/actions/setup-go/pull/641)
- Upgrade semver and [@&#8203;types/semver](https://github.com/types/semver) by [@&#8203;dependabot](https://github.com/dependabot) in [#&#8203;652](https://github.com/actions/setup-go/pull/652)

##### New Contributors

- [@&#8203;nicholasngai](https://github.com/nicholasngai) made their first contribution in [#&#8203;665](https://github.com/actions/setup-go/pull/665)
- [@&#8203;priya-kinthali](https://github.com/priya-kinthali) made their first contribution in [#&#8203;673](https://github.com/actions/setup-go/pull/673)
- [@&#8203;mahabaleshwars](https://github.com/mahabaleshwars) made their first contribution in [#&#8203;674](https://github.com/actions/setup-go/pull/674)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v6...v6.1.0>

### [`v6.0.0`](https://github.com/actions/setup-go/releases/tag/v6.0.0)

[Compare Source](https://github.com/actions/setup-go/compare/v5.6.0...v6.0.0)

##### What's Changed

##### Breaking Changes

- Improve toolchain handling to ensure more reliable and consistent toolchain selection and management by [@&#8203;matthewhughes934](https://github.com/matthewhughes934) in [#&#8203;460](https://github.com/actions/setup-go/pull/460)
- Upgrade Nodejs runtime from node20 to node 24 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;624](https://github.com/actions/setup-go/pull/624)

Make sure your runner is on version v2.327.1 or later to ensure compatibility with this release. [See Release Notes](https://github.com/actions/runner/releases/tag/v2.327.1)

##### Dependency Upgrades

- Upgrade [@&#8203;types/jest](https://github.com/types/jest) from 29.5.12 to 29.5.14 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;589](https://github.com/actions/setup-go/pull/589)
- Upgrade [@&#8203;actions/tool-cache](https://github.com/actions/tool-cache) from 2.0.1 to 2.0.2 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;591](https://github.com/actions/setup-go/pull/591)
- Upgrade [@&#8203;typescript-eslint/parser](https://github.com/typescript-eslint/parser) from 8.31.1 to 8.35.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;590](https://github.com/actions/setup-go/pull/590)
- Upgrade undici from 5.28.5 to 5.29.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;594](https://github.com/actions/setup-go/pull/594)
- Upgrade typescript from 5.4.2 to 5.8.3 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;538](https://github.com/actions/setup-go/pull/538)
- Upgrade eslint-plugin-jest from 28.11.0 to 29.0.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;603](https://github.com/actions/setup-go/pull/603)
- Upgrade `form-data` to bring in fix for critical vulnerability by [@&#8203;matthewhughes934](https://github.com/matthewhughes934) in [#&#8203;618](https://github.com/actions/setup-go/pull/618)
- Upgrade actions/checkout from 4 to 5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;631](https://github.com/actions/setup-go/pull/631)

##### New Contributors

- [@&#8203;matthewhughes934](https://github.com/matthewhughes934) made their first contribution in [#&#8203;618](https://github.com/actions/setup-go/pull/618)
- [@&#8203;salmanmkc](https://github.com/salmanmkc) made their first contribution in [#&#8203;624](https://github.com/actions/setup-go/pull/624)

**Full Changelog**: <https://github.com/actions/setup-go/compare/v5...v6.0.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #39
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:14:42 +02:00
renovate-bot BotandKarl-Einar Bergstrøm 548f19b292 chore(deps): update actions/checkout action to v7 (#38)
This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [actions/checkout](https://github.com/actions/checkout) | action | major | `v4` → `v7` |

---

### Release Notes

<details>
<summary>actions/checkout</summary>

### [`v7.0.1`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v701)

[Compare Source](https://github.com/actions/checkout/compare/v7.0.0...v7.0.1)

- Skip running unsafe pr check if input is default by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2518](https://github.com/actions/checkout/pull/2518)
- Trim only ascii whitespace for branch by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2521](https://github.com/actions/checkout/pull/2521)
- Escape values passed to --unset by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2530](https://github.com/actions/checkout/pull/2530)
- Various dependency updates

### [`v7.0.0`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v700)

[Compare Source](https://github.com/actions/checkout/compare/v6.1.0...v7.0.0)

- Block checking out fork PR for pull_request_target and workflow_run by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2454](https://github.com/actions/checkout/pull/2454)
- Various dependency updates

### [`v6.1.0`](https://github.com/actions/checkout/releases/tag/v6.1.0)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.3...v6.1.0)

##### What's Changed

- **\[BREAKING]** backport `allow-unsafe-pr-checkout` to v6 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2500](https://github.com/actions/checkout/pull/2500)
- backport fixes to releases-v6 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2527](https://github.com/actions/checkout/pull/2527)

<https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/> for more details about this breaking change

**Full Changelog**: <https://github.com/actions/checkout/compare/v6.0.3...v6.1.0>

### [`v6.0.3`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v603)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.2...v6.0.3)

- Fix checkout init for SHA-256 repositories by [@&#8203;yaananth](https://github.com/yaananth) in [#&#8203;2439](https://github.com/actions/checkout/pull/2439)
- fix: expand merge commit SHA regex and add SHA-256 test cases by [@&#8203;yaananth](https://github.com/yaananth) in [#&#8203;2414](https://github.com/actions/checkout/pull/2414)

### [`v6.0.2`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v602)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.1...v6.0.2)

- Fix tag handling: preserve annotations and explicit fetch-tags by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2356](https://github.com/actions/checkout/pull/2356)

### [`v6.0.1`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v601)

[Compare Source](https://github.com/actions/checkout/compare/v6.0.0...v6.0.1)

- Add worktree support for persist-credentials includeIf by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2327](https://github.com/actions/checkout/pull/2327)

### [`v6.0.0`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v600)

[Compare Source](https://github.com/actions/checkout/compare/v5.1.0...v6.0.0)

- Persist creds to a separate file by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2286](https://github.com/actions/checkout/pull/2286)
- Update README to include Node.js 24 support details and requirements by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;2248](https://github.com/actions/checkout/pull/2248)

### [`v5.1.0`](https://github.com/actions/checkout/releases/tag/v5.1.0)

[Compare Source](https://github.com/actions/checkout/compare/v5.0.1...v5.1.0)

##### What's Changed

- **\[BREAKING]** backport `allow-unsafe-pr-checkout` to v5 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2501](https://github.com/actions/checkout/pull/2501)
- backport fixes to releases-v5 by [@&#8203;aiqiaoy](https://github.com/aiqiaoy) in [#&#8203;2523](https://github.com/actions/checkout/pull/2523)

<https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/> for more details about this breaking change

**Full Changelog**: <https://github.com/actions/checkout/compare/v5.0.1...v5.1.0>

### [`v5.0.1`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v501)

[Compare Source](https://github.com/actions/checkout/compare/v5.0.0...v5.0.1)

- Port v6 cleanup to v5 by [@&#8203;ericsciple](https://github.com/ericsciple) in [#&#8203;2301](https://github.com/actions/checkout/pull/2301)

### [`v5.0.0`](https://github.com/actions/checkout/blob/HEAD/CHANGELOG.md#v500)

[Compare Source](https://github.com/actions/checkout/compare/v4.4.0...v5.0.0)

- Update actions checkout to use node 24 by [@&#8203;salmanmkc](https://github.com/salmanmkc) in [#&#8203;2226](https://github.com/actions/checkout/pull/2226)

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #38
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:12:22 +02:00
renovate-bot BotandKarl-Einar Bergstrøm 51beea9d78 chore(deps): update goreleaser/goreleaser-action action to v7 (#41)
This PR contains the following updates:

| Package | Type | Update | Change | Pending |
|---|---|---|---|---|
| [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) | action | major | `v6` → `v7` | `v7.3.0` |

---

### Release Notes

<details>
<summary>goreleaser/goreleaser-action</summary>

### [`v7.2.3`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.3)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.2.2...v7.2.3)

#### What's Changed

- ci(deps): bump the actions group with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;563](https://github.com/goreleaser/goreleaser-action/pull/563)
- chore(deps): bump undici from 6.24.1 to 6.27.0 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;565](https://github.com/goreleaser/goreleaser-action/pull/565)
- build: regenerate dist after undici 6.27.0 bump by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;567](https://github.com/goreleaser/goreleaser-action/pull/567)
- ci: auto-rebuild dist on dependabot PRs by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;568](https://github.com/goreleaser/goreleaser-action/pull/568)
- ci: use a GitHub App token to rebuild dist on dependabot PRs by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;569](https://github.com/goreleaser/goreleaser-action/pull/569)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7.2.2...v7.2.3>

### [`v7.2.2`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.2)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.2.1...v7.2.2)

#### What's Changed

- ci(deps): bump the actions group with 3 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;560](https://github.com/goreleaser/goreleaser-action/pull/560)
- fix: nightly resolution to select newest published release by [@&#8203;Copilot](https://github.com/Copilot) in [#&#8203;562](https://github.com/goreleaser/goreleaser-action/pull/562)

#### New Contributors

- [@&#8203;Copilot](https://github.com/Copilot) made their first contribution in [#&#8203;562](https://github.com/goreleaser/goreleaser-action/pull/562)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7...v7.2.2>

### [`v7.2.1`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.1)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.2.0...v7.2.1)

This fully removes the usage of the old `nightly` moving tag.

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7.2.0...v7.2.1>

### [`v7.2.0`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.2.0)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.1.0...v7.2.0)

#### What's Changed

- test: cover install across release eras by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;555](https://github.com/goreleaser/goreleaser-action/pull/555)
- feat: add `version-file` input by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;556](https://github.com/goreleaser/goreleaser-action/pull/556)
- feat: resolve nightly to latest vX.Y.Z-<sha>-nightly release by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;558](https://github.com/goreleaser/goreleaser-action/pull/558)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7...v7.2.0>

### [`v7.1.0`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.1.0)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v7.0.0...v7.1.0)

#### What's Changed

- feat: verify release checksum and cosign signature by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;550](https://github.com/goreleaser/goreleaser-action/pull/550)
- docs: document cosign verification in README by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;553](https://github.com/goreleaser/goreleaser-action/pull/553)
- docs: Upgrade import GPG action version by [@&#8203;flecno](https://github.com/flecno) in [#&#8203;547](https://github.com/goreleaser/goreleaser-action/pull/547)
- ci: drop docker-bake in favor of plain npm by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;551](https://github.com/goreleaser/goreleaser-action/pull/551)
- ci: add release-major-tag workflow by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;552](https://github.com/goreleaser/goreleaser-action/pull/552)
- ci: drop pre-cosign-v3 goreleaser versions from tests by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;554](https://github.com/goreleaser/goreleaser-action/pull/554)
- ci(deps): bump the actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;543](https://github.com/goreleaser/goreleaser-action/pull/543)
- ci(deps): bump the actions group with 5 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;546](https://github.com/goreleaser/goreleaser-action/pull/546)
- chore(deps): bump undici from 6.23.0 to 6.24.1 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;545](https://github.com/goreleaser/goreleaser-action/pull/545)

#### New Contributors

- [@&#8203;flecno](https://github.com/flecno) made their first contribution in [#&#8203;547](https://github.com/goreleaser/goreleaser-action/pull/547)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v7...v7.1.0>

### [`v7.0.0`](https://github.com/goreleaser/goreleaser-action/releases/tag/v7.0.0)

[Compare Source](https://github.com/goreleaser/goreleaser-action/compare/v6.4.0...v7.0.0)

#### What's Changed

- feat!: node 24, update deps, rm yarn, ESM by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;533](https://github.com/goreleaser/goreleaser-action/pull/533)
- sec: pin github action versions by [@&#8203;caarlos0](https://github.com/caarlos0) in [#&#8203;514](https://github.com/goreleaser/goreleaser-action/pull/514)
- docs: Upgrade checkout GitHub Action in README.md by [@&#8203;dunglas](https://github.com/dunglas) in [#&#8203;507](https://github.com/goreleaser/goreleaser-action/pull/507)
- chore(deps): bump actions/checkout from 4 to 5 by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;504](https://github.com/goreleaser/goreleaser-action/pull/504)
- ci(deps): bump the actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;517](https://github.com/goreleaser/goreleaser-action/pull/517)
- ci(deps): bump the actions group with 2 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;523](https://github.com/goreleaser/goreleaser-action/pull/523)
- ci(deps): bump docker/bake-action from 6.9.0 to 6.10.0 in the actions group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;526](https://github.com/goreleaser/goreleaser-action/pull/526)
- ci(deps): bump the actions group across 1 directory with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;532](https://github.com/goreleaser/goreleaser-action/pull/532)
- ci(deps): bump actions/checkout from 6.0.1 to 6.0.2 in the actions group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;534](https://github.com/goreleaser/goreleaser-action/pull/534)
- chore(deps): bump the npm group across 1 directory with 4 updates by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;536](https://github.com/goreleaser/goreleaser-action/pull/536)
- chore(deps): bump [@&#8203;actions/http-client](https://github.com/actions/http-client) from 3.0.2 to 4.0.0 in the npm group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;537](https://github.com/goreleaser/goreleaser-action/pull/537)
- ci(deps): bump docker/setup-buildx-action from 3.10.0 to 3.12.0 in the actions group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;538](https://github.com/goreleaser/goreleaser-action/pull/538)
- chore(deps): bump semver from 7.7.3 to 7.7.4 in the npm group by [@&#8203;dependabot](https://github.com/dependabot)\[bot] in [#&#8203;539](https://github.com/goreleaser/goreleaser-action/pull/539)

**Full Changelog**: <https://github.com/goreleaser/goreleaser-action/compare/v6...v7.0.0>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - "before 6am on monday"
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xNTQuMSIsInVwZGF0ZWRJblZlciI6IjQ0LjE1NC4xIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

---------

Co-authored-by: Karl-Einar Bergstrøm <1+keb@noreply.localhost>
Reviewed-on: #41
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:10:27 +02:00
renovate-bot Bot 00924a4a1e chore(deps): update module google.golang.org/grpc to v1.83.2 [security] (#48)
Co-authored-by: Renovate Bot <renovate-bot@noreply.git.kebcloud.no>
2026-10-12 21:02:00 +02:00
135 changed files with 8089 additions and 1551 deletions

No files matched your search

+3 -3
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
# The runner mounts Go, the module cache and the build cache as # The runner mounts Go, the module cache and the build cache as
@@ -40,7 +40,7 @@ jobs:
- name: staticcheck - name: staticcheck
# Pinned, so a new release can't fail CI on its own. # Pinned, so a new release can't fail CI on its own.
run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.1 ./... run: go run honnef.co/go/tools/cmd/staticcheck@v0.8.2 ./...
- name: go test - name: go test
run: go test ./... run: go test ./...
+3 -3
View File
@@ -23,12 +23,12 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout (full history, for the changelog) - name: Checkout (full history, for the changelog)
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version-file: go.mod go-version-file: go.mod
# The runner mounts Go, the module cache and the build cache as # The runner mounts Go, the module cache and the build cache as
@@ -75,7 +75,7 @@ jobs:
cat "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md"
- name: GoReleaser - name: GoReleaser
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
distribution: goreleaser distribution: goreleaser
version: v2.18.3 version: v2.18.3
+8 -28
View File
@@ -1,33 +1,13 @@
# ---> 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 # Build output
/lazysecrets /lazysecrets
# Release builds (GoReleaser, see .goreleaser.yaml) # Release builds (GoReleaser, see .goreleaser.yaml)
/dist/ /dist/
# go test -c, coverage profiles
*.test
*.out
go.work
go.work.sum
.env
+2
View File
@@ -0,0 +1,2 @@
<1+keb@noreply.git.kebcloud.no> <1+keb@noreply.localhost>
<3+renovate-bot@noreply.git.kebcloud.no> <3+renovate-bot@noreply.localhost>
+54
View File
@@ -2,6 +2,60 @@
Every release of lazysecrets, newest first. 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 ## v0.1.0 – 2026-10-12
Hello world 👋 Hello world 👋
+27 -21
View File
@@ -7,47 +7,45 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
## Main features ## Main features
- **Read-only**: lazysecrets never creates, changes or deletes a secret. - **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 - **Zero setup**: uses the logins you already have (`az`, the Databricks
CLI, `sops`, `git`). No config file needed. CLI, `sops`, `git`). No config file needed.
- **Search everything**: `ctrl+s` searches every source at once, with - **Search everything**: `ctrl+s` searches every source at once, with
fzf-style matching. fzf-style matching.
- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it - **Share links**: Share `lazysecrets://` links with colleagues.
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 - **Careful with values**: values stay hidden until you reveal them, and
copied values are cleared from the clipboard after 30 seconds. copied values are cleared from the clipboard after 30 seconds.
- **Scriptable**: `lazysecrets get <link>` prints a value for scripts and - **Scriptable**: `get`, `list` and `open` for scripts, fzf and editor
pipes. pickers; [lazysecrets.nvim](https://github.com/karleinarb/lazysecrets.nvim)
for Neovim.
- **Favourites**: star the secrets you use most for easy access. - **Favourites**: star the secrets you use most for easy access.
- **Expiry warnings**: secrets, keys and certificates that expire soon are - **Expiry warnings**: secrets, keys and certificates that expire soon are
marked, listed together in the status pane. marked, listed together in the status pane.
- **Your way**: rebind any key, pick a theme, and add custom commands to run - **Your way**: rebind any key, pick a theme, and add custom commands to run
on a secret. on a secret.
- **Try it first**: `lazysecrets --demo` runs on built-in sample data. - **Try it first**: `lazysecrets --demo` runs on built-in sample data.
- **Supported platforms**: Azure Key Vault, Databricks secret scopes, and SOPS and `.env` files in your git repos. - **Sources**: Azure Key Vault, Databricks secret scopes, and SOPS, `.env`, Spring Boot and Docker Compose files in your git repos.
> **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new. > **Early development:** expect rough edges. See [CHANGELOG.md](CHANGELOG.md) for what's new.
## Install ## Install
On macOS or Linux (arm64 or x86_64): On macOS or Linux (arm64 or x86_64):
```sh
curl -fsSL https://raw.githubusercontent.com/karleinarb/lazysecrets/main/install.sh | sh
```
Or
```sh ```sh
curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh curl -fsSL https://git.kebcloud.no/keb/lazysecrets/raw/branch/main/install.sh | sh
``` ```
It installs the latest release, from GitHub or else Both approaches will install the latest release from [GitHub](https://github.com/karleinarb/lazysecrets/releases) with fallback to
[git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into [git.kebcloud.no](https://git.kebcloud.no/keb/lazysecrets/releases), into
`~/.local/bin` after checking it against the release's checksums. `~/.local/bin` after checking it against the release's checksums.
`sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR` `sh -s -- v0.2.0` installs a given version, `LAZYSECRETS_INSTALL_DIR`
somewhere else. somewhere else.
`U` (or `lazysecrets update`) updates in place when there's a newer
lazysecrets looks for a newer release once a day: the footer then shows release.
`↑ v0.2.0`, and `U` shows its changelog and updates in place (or run
`lazysecrets update`).
Or build it yourself: Or build it yourself:
`go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`. `go install git.kebcloud.no/keb/lazysecrets/cmd/lazysecrets@latest`.
@@ -70,36 +68,44 @@ use most:
| `l` / `h` | the details of what's selected / back | | `l` / `h` | the details of what's selected / back |
| `/` | filter the list you're in | | `/` | filter the list you're in |
| `space` | reveal / hide the value | | `space` | reveal / hide the value |
| `y` | copy the value (cleared from the clipboard after 30s) | | `y` | copy the value |
| `ctrl+s` | Go to: search everything and jump there | | `ctrl+s` | Go to: search everything and jump there |
| `f` / `F` | star / your favourites | | `f` / `F` | star / your favourites |
| `s` | copy a link to share | | `s` | copy a link to share |
| `?` | every key, searchable | | `?` | every key, searchable |
| `q` | quit | | `q` | quit |
## Integration
`lazysecrets list`, `get` and `open` plug lazysecrets into fzf, editor
pickers and scripts: see [Integration](docs/integration.md#an-fzf-picker).
![fzf-example](docs/assets/fzf-example.png)
## Docs ## Docs
- [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys - [Keys](docs/keys.md): every key, Go to, the layout, and rebinding keys
- [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults - [Azure Key Vault](docs/azure.md): accounts, permissions, pinned vaults
- [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes - [Databricks](docs/databricks.md): profiles and Key Vault-backed scopes
- [SOPS and .env files](docs/repos.md): which repos and files, decrypting - [SOPS, .env, Spring and compose files](docs/repos.md): which repos and files, decrypting
- [Links](docs/links.md): sharing, opening, `lazysecrets get` in scripts, - [Sharing](docs/sharing.md): links to secrets, encrypted env files, and
and opening links from the browser and chat opening links, also from the browser and chat
- [Integration](docs/integration.md): how lazysecrets can integrate into other systems
- [Custom commands](docs/custom-commands.md): run your own commands on a - [Custom commands](docs/custom-commands.md): run your own commands on a
secret secret
- [Config](docs/config.md): every option, themes, layered configs, the - [Config](docs/config.md): every option, themes, layered configs, the
state file state file
- [Development](docs/development.md): building, tests, pull requests and - [Development](docs/development.md): building, tests, how changes land and
releasing releasing
## Privacy ## Privacy
lazysecrets never stores, caches or sends your secret values. It keeps the 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 names and metadata of what you've loaded in a cache file readable only by
you, to show it at once next time (`cache: false` turns it off). It talks you, to show it at once next time (`storage.cache: false` turns it off). It talks
only to the services you use it with, and collects no usage data. The one 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 call of its own is a daily check for a new release, on GitHub or else
git.kebcloud.no, which `updateCheck: false` turns off. git.kebcloud.no, which `updates.autoCheck: false` turns off.
## Repositories ## Repositories
+11
View File
@@ -0,0 +1,11 @@
# 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.
+2
View File
@@ -51,6 +51,8 @@ commit_preprocessors = [
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" }, { pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
# #11 was squash-merged with #10 (the status pane) in it: name both. # #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)" }, { 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 # Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
# pr-title.yml); all become "feat: x". # 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}: " }, { pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
+9 -78
View File
@@ -1,10 +1,7 @@
package main package main
import ( import (
"cmp"
"context" "context"
"errors"
"fmt"
"os" "os"
"golang.org/x/term" "golang.org/x/term"
@@ -21,7 +18,7 @@ import (
// It ends with a newline only when printing to a terminal, so command // It ends with a newline only when printing to a terminal, so command
// substitution and pipes get the value exactly. // substitution and pipes get the value exactly.
func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error { func get(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) error {
v, err := value(ctx, providers, st, r) v, _, err := readKey(ctx, providers, st, r)
if err != nil { if err != nil {
return err return err
} }
@@ -33,82 +30,16 @@ func get(ctx context.Context, providers []provider.Provider, st *store.Store, r
return err return err
} }
// value reads what r points at without listing its vault: the vault and // readKey reads what r points at (ref.Read), and the version read. The
// name are enough for Azure, tried with each account (the one that last // account asked for (--account) goes first, else the one that last read
// read it first); for Databricks the workspace's profile is found by its // it.
// host. func readKey(ctx context.Context, providers []provider.Provider, st *store.Store, r ref.Ref) (provider.SecretValue, string, error) {
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 := r
key.Version = "" key.Version = ""
node := st.Node(key.String()) node := st.Node(key.String())
account, source := node.Account, node.Source
type try struct { if r.Account != "" && r.Account != account {
p provider.Provider account, source = r.Account, ""
o provider.Object
} }
var tries []try return ref.Read(ctx, providers, r, account, source)
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...)
} }
+2 -2
View File
@@ -20,7 +20,7 @@ func TestValue(t *testing.T) {
"lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key", "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key": "storage-demo-key",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
v, err := value(context.Background(), providers, st, r) v, _, err := readKey(context.Background(), providers, st, r)
if err != nil || v.Reveal() != want { if err != nil || v.Reveal() != want {
t.Errorf("%s: %q, %v", link, v.Reveal(), err) 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", "{{secrets/app/storage-key}}": "names no workspace",
} { } {
r, _ := ref.Parse(link) r, _ := ref.Parse(link)
if _, err := value(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) { if _, _, err := readKey(context.Background(), providers, st, r); err == nil || !strings.Contains(err.Error(), wantErr) {
t.Errorf("%s: %v, want %q", link, err, wantErr) t.Errorf("%s: %v, want %q", link, err, wantErr)
} }
} }
+10 -8
View File
@@ -69,11 +69,7 @@ func installHandler(cfg config.Config, configPath, term string) (string, string,
if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil { if _, err := launchCommand(cfg, configPath, term, exe, "lazysecrets://check"); err != nil {
return "", "", err return "", "", err
} }
launch := []string{exe} launch := append(append([]string{exe}, configArgs(configPath)...), "launch")
if configPath != defaultConfigPath() {
launch = append(launch, "--config", configPath)
}
launch = append(launch, "launch")
if term != "" { if term != "" {
launch = append(launch, "--terminal", term) launch = append(launch, "--terminal", term)
} }
@@ -188,8 +184,14 @@ func launchCommand(cfg config.Config, configPath, term, exe, link string) ([]str
// innerCommand runs lazysecrets on link, with the environment it needs. // innerCommand runs lazysecrets on link, with the environment it needs.
func innerCommand(configPath, exe, link string) []string { func innerCommand(configPath, exe, link string) []string {
inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe) inner := append(append([]string{"/usr/bin/env"}, linkhandler.Env(os.Getenv)...), exe)
if configPath != defaultConfigPath() { inner = append(inner, configArgs(configPath)...)
inner = append(inner, "--config", configPath)
}
return append(inner, "open", link) 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}
}
+270
View File
@@ -0,0 +1,270 @@
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: Spring and compose keys that don't look secret, example files, and files and repos with only those (json: \"hidden\")")
if err := fs.Parse(args); err != nil {
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)
}
}
+152
View File
@@ -0,0 +1,152 @@
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)
}
}
+155 -68
View File
@@ -3,7 +3,9 @@
package main package main
import ( import (
"cmp"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -66,25 +68,44 @@ func checkCommandKeys(km *keys.Map, cmds []commands.Command) error {
if c.Key == "" { if c.Key == "" {
continue continue
} }
name := fmt.Sprintf("custom command %q", cmpOr(c.Description, c.Command)) name := fmt.Sprintf("custom command %q", cmp.Or(c.Description, c.Command))
if err := km.CheckExtra(c.Key, name); err != nil { if err := km.CheckExtra(c.Key, name); err != nil {
return err return err
} }
for _, d := range cmds[:i] { for _, d := range cmds[:i] {
overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global overlap := c.Context == d.Context || c.Context == commands.Global || d.Context == commands.Global
if d.Key == c.Key && overlap { if d.Key == c.Key && overlap {
return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmpOr(d.Description, d.Command)) return fmt.Errorf("%s: %q is also the key of custom command %q", name, c.Key, cmp.Or(d.Description, d.Command))
} }
} }
} }
return nil return nil
} }
func cmpOr(a, b string) string { // refArg parses get's and open's arguments: a reference, and optionally
if a != "" { // the account to use when several could (lazysecrets list's column) and,
return a // 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>"
} }
return b 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
} }
// defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the // defaultConfigPath is $LAZYSECRETS_CONFIG_FILE, else config.yaml in the
@@ -106,18 +127,29 @@ func run() error {
showVersion := flag.Bool("version", false, "print the version and exit") 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)") 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") 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") timeout := flag.Duration("timeout", 30*time.Second, "timeout for each backend request, every provider's (overrides the config)")
icons := flag.Bool("icons", true, icons := flag.Bool("icons", true,
"show Nerd Font icons (needs a Nerd Font v3+); disable with --icons=false or LAZYSECRETS_ICONS=0") "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)") clipClear := flag.Duration("clipboard-clear", 30*time.Second, "clear copied values from the clipboard after this long (0 disables)")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), `Usage: fmt.Fprintf(flag.CommandLine.Output(), `Usage:
lazysecrets [flags] browse secrets lazysecrets [flags] browse secrets
lazysecrets [flags] [open] <ref> lazysecrets [flags] [open [--account <account>] [--action go|copy|show]] <ref>
open a lazysecrets:// link, Key Vault URL, open a lazysecrets:// link (also a share link),
@Microsoft.KeyVault(...) or {{secrets/scope/key}} Key Vault URL, @Microsoft.KeyVault(...) or
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at, {{secrets/scope/key}}
lazysecrets [flags] get [--account <account>] <ref>
print the value a lazysecrets:// link points at,
for scripts: VAR=$(lazysecrets get <ref>) 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 lazysecrets [flags] keybindings print every action and its keys (with your
config applied), as config to copy and edit config applied), as config to copy and edit
lazysecrets handler install [--terminal NAME] lazysecrets handler install [--terminal NAME]
@@ -139,17 +171,22 @@ Flags:
return nil return nil
} }
var open, getRef *ref.Ref sub := "" // the subcommand; "" browses
printKeys := false var subArgs []string
var open *ref.Ref
var getRef ref.Ref
var openAction string
switch args := flag.Args(); { 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": case len(args) == 1 && args[0] == "keybindings":
printKeys = true sub = "keybindings"
case len(args) == 2 && args[0] == "get": case len(args) > 0 && args[0] == "get":
r, err := ref.Parse(args[1]) r, _, err := refArg("get", args[1:])
if err != nil { if err != nil {
return err return err
} }
getRef = &r sub, getRef = "get", r
case len(args) > 0 && args[0] == "handler": case len(args) > 0 && args[0] == "handler":
return handler(*configPath, args[1:]) return handler(*configPath, args[1:])
case len(args) > 0 && args[0] == "launch": case len(args) > 0 && args[0] == "launch":
@@ -157,12 +194,12 @@ Flags:
case len(args) > 0 && args[0] == "update": case len(args) > 0 && args[0] == "update":
return updateCmd(*configPath, args[1:]) return updateCmd(*configPath, args[1:])
case len(args) == 0: case len(args) == 0:
case args[0] == "open" && len(args) == 2: case args[0] == "open" && len(args) >= 2:
r, err := ref.Parse(args[1]) r, act, err := refArg("open", args[1:])
if err != nil { if err != nil {
return err return err
} }
open = &r open, openAction = &r, act
case len(args) == 1 && looksLikeRef(args[0]): case len(args) == 1 && looksLikeRef(args[0]):
// lazysecrets <link>, like lazysecrets open <link> // lazysecrets <link>, like lazysecrets open <link>
r, err := ref.Parse(args[0]) r, err := ref.Parse(args[0])
@@ -183,6 +220,11 @@ Flags:
if err != nil { if err != nil {
return err 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() keymap := keys.Default()
if err := keymap.Apply(cfg.Keybinding); err != nil { if err := keymap.Apply(cfg.Keybinding); err != nil {
return err return err
@@ -190,40 +232,62 @@ Flags:
if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil { if err := checkCommandKeys(&keymap, cfg.CustomCommands); err != nil {
return err return err
} }
if printKeys { if sub == "keybindings" {
fmt.Print(keymap.YAML()) fmt.Print(keymap.YAML())
return nil return nil
} }
// Precedence: flag > environment > config file > default. // Precedence: flag > environment > config file > default.
set := map[string]bool{} set := map[string]bool{}
flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) flag.Visit(func(f *flag.Flag) { set[f.Name] = true })
if !set["timeout"] && cfg.Timeout > 0 { if *timeout <= 0 {
*timeout = cfg.Timeout return fmt.Errorf("--timeout: %s is not positive", *timeout)
} }
if !set["clipboard-clear"] && cfg.ClipboardClear != nil { // Each provider's own timeout, unless --timeout sets them all.
*clipClear = *cfg.ClipboardClear timeouts := map[provider.Kind]time.Duration{}
if !set["timeout"] {
if cfg.Providers.Timeout > 0 {
*timeout = cfg.Providers.Timeout
} }
expiryDays := 30 for k, d := range map[provider.Kind]time.Duration{
if cfg.ExpiryWarningDays != nil { provider.KindAzure: cfg.Providers.Azure.Timeout,
expiryDays = *cfg.ExpiryWarningDays provider.KindDatabricks: cfg.Providers.Databricks.Timeout,
provider.KindRepo: cfg.Providers.Repos.Timeout,
} {
if d > 0 {
timeouts[k] = d
} }
revealTimeout := 60 * time.Second
if cfg.RevealTimeout != nil {
revealTimeout = *cfg.RevealTimeout
} }
loadDelay := 150 * time.Millisecond
if cfg.LoadDelay != nil {
loadDelay = *cfg.LoadDelay
} }
// 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)
}
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"] { if !set["icons"] {
switch { switch {
case os.Getenv("LAZYSECRETS_ICONS") != "": case os.Getenv("LAZYSECRETS_ICONS") != "":
*icons = os.Getenv("LAZYSECRETS_ICONS") != "0" *icons = os.Getenv("LAZYSECRETS_ICONS") != "0"
case cfg.Icons != nil: case cfg.UI.Icons != nil:
*icons = *cfg.Icons *icons = *cfg.UI.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 // Stars, notes and the layout. Demo mode keeps them in memory, so
// sample vaults never end up in the real file. // sample vaults never end up in the real file.
stateDir, err := config.StateDir() stateDir, err := config.StateDir()
@@ -235,16 +299,23 @@ Flags:
statePath = filepath.Join(stateDir, "state.json") statePath = filepath.Join(stateDir, "state.json")
} }
st, err := store.Load(statePath) st, err := store.Load(statePath)
if err != nil { 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 {
return err return err
} }
var providers []provider.Provider var providers []provider.Provider
if *demo { if *demo {
// Two Azure accounts, like a personal and an admin login in az. // 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{ providers = []provider.Provider{
fake.Azure(400*time.Millisecond).As("Azure:alice@example.com/demo", "Azure · alice"), az,
fake.AzureOps(700*time.Millisecond).As("Azure:admin.alice@example.com/demo", "Azure · admin.alice"), fake.AzureOps(700*time.Millisecond).As(admin, "Azure · admin.alice"),
fake.Databricks(600 * time.Millisecond), fake.Databricks(600 * time.Millisecond),
fake.Repos(), fake.Repos(),
} }
@@ -273,17 +344,10 @@ Flags:
providers = append(providers, dbx) providers = append(providers, dbx)
} }
if config.On(pc.Repos.Enabled) { 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{ repos := repo.New(repo.Settings{
Dir: start, Paths: pc.Repos.Paths, Roots: roots, Starred: starredRepos(st), Dir: startDir, Paths: pc.Repos.Paths, Roots: valueOr(pc.Repos.Roots, repo.DefaultRoots),
Include: pc.Repos.Include, Exclude: pc.Repos.Exclude, Starred: starredRepos(st), Include: pc.Repos.Include, Exclude: pc.Repos.Exclude,
SecretPatterns: pc.Repos.SecretPatterns, Types: map[string]repo.FileType{"spring": pc.Repos.Spring, "compose": pc.Repos.Compose},
}) })
// The repo it started in (shown on top as Repo) and the // The repo it started in (shown on top as Repo) and the
// others, checked for secret files in the background like // others, checked for secret files in the background like
@@ -299,10 +363,23 @@ Flags:
// the standard logger, which would draw over the TUI. // the standard logger, which would draw over the TUI.
log.SetOutput(io.Discard) log.SetOutput(io.Discard)
if getRef != nil { switch sub {
ctx, cancel := context.WithTimeout(context.Background(), *timeout) case "get":
ctx, cancel := context.WithTimeout(context.Background(), timeoutFor(getRef.Kind))
defer cancel() defer cancel()
return get(ctx, providers, st, *getRef) 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)
} }
// Links opened elsewhere (lazysecrets launch) can come here. // Links opened elsewhere (lazysecrets launch) can come here.
@@ -336,16 +413,10 @@ Flags:
onActive = srv.Touch onActive = srv.Touch
} }
// What was loaded last time, and where it was left in this folder. // Where it was left in this folder.
var cacheFile, placeDir string placeDir := ""
if !*demo { if !*demo {
if d, err := config.CacheDir(); err == nil && config.On(cfg.Cache) { placeDir = startDir
cacheFile = filepath.Join(d, "cache.json")
}
placeDir = *dir
if placeDir == "" {
placeDir, _ = os.Getwd()
}
if abs, err := filepath.Abs(placeDir); err == nil { if abs, err := filepath.Abs(placeDir); err == nil {
placeDir = abs placeDir = abs
} }
@@ -353,32 +424,40 @@ Flags:
m := app.New(providers, app.Options{ m := app.New(providers, app.Options{
Timeout: *timeout, Timeout: *timeout,
Timeouts: timeouts,
Icons: *icons, Icons: *icons,
ClipboardClear: *clipClear, ClipboardClear: *clipClear,
FlatSources: cfg.SourcesView == "flat", FlatSources: cfg.UI.SourcesView == "flat",
CollapseGroups: config.On(cfg.CollapseGroups), CollapseGroups: config.On(cfg.UI.CollapseGroups),
LoadDelay: loadDelay, LoadDelay: loadDelay,
NoHistory: !config.On(cfg.History), NoHistory: !config.On(cfg.Storage.History),
OnActive: onActive, OnActive: onActive,
RevealTimeout: revealTimeout, RevealTimeout: revealTimeout,
ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, ExpiryWarning: expiryWarning,
HideOnBlur: config.On(cfg.HideOnBlur), HideOnBlur: config.On(cfg.Values.HideOnBlur),
HandlerSetup: setup, HandlerSetup: setup,
Updater: updater(cfg.Releases), Updater: updater(cfg.Updates.Sources),
UpdateCheck: config.On(cfg.UpdateCheck), UpdateCheck: config.On(cfg.Updates.AutoCheck),
UpdateEvery: updateEvery,
Keys: &keymap, Keys: &keymap,
Commands: cfg.CustomCommands, Commands: cfg.CustomCommands,
Version: versionString(), Version: versionString(),
Store: st, Store: st,
Warning: strings.Join(warnings, " · "),
CacheFile: cacheFile, CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath), ConfigFiles: configFiles(*configPath),
StatePath: statePath, StatePath: statePath,
Changelog: lazysecrets.Changelog, Changelog: lazysecrets.Changelog,
Dir: placeDir, Dir: placeDir,
ShareKey: cfg.Sharing.Key,
ShareRules: shareRules(cfg),
Open: open, Open: open,
OpenAction: openAction,
RecentIcon: recentIcon,
Styles: &theme, Styles: &theme,
DefaultFg: defaultFg, DefaultFg: defaultFg,
}) })
blockingStdin()
p = tea.NewProgram(m) p = tea.NewProgram(m)
final, err := p.Run() final, err := p.Run()
if fm, ok := final.(app.Model); ok { if fm, ok := final.(app.Model); ok {
@@ -387,6 +466,14 @@ Flags:
return err 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 // configFiles are the config files given, for the status pane: those
// that exist, or the first if none does (where to create one). // that exist, or the first if none does (where to create one).
func configFiles(list string) []string { func configFiles(list string) []string {
+93
View File
@@ -0,0 +1,93 @@
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}
}
+67
View File
@@ -0,0 +1,67 @@
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)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !unix
package main
func blockingStdin() {}
+20
View File
@@ -0,0 +1,20 @@
//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)
}
}
+11 -6
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"flag"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -32,8 +33,12 @@ func updater(servers []string) *app.Updater {
// updateCmd is lazysecrets update [--check]: the newer release's // updateCmd is lazysecrets update [--check]: the newer release's
// changelog, then (unless --check) install it. // changelog, then (unless --check) install it.
func updateCmd(configPath string, args []string) error { func updateCmd(configPath string, args []string) error {
check := len(args) == 1 && args[0] == "--check" fs := flag.NewFlagSet("update", flag.ContinueOnError)
if len(args) > 0 && !check { 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 {
return errors.New("usage: lazysecrets update [--check]") return errors.New("usage: lazysecrets update [--check]")
} }
cfg, err := config.LoadAll(strings.Split(configPath, ",")) cfg, err := config.LoadAll(strings.Split(configPath, ","))
@@ -43,20 +48,20 @@ func updateCmd(configPath string, args []string) error {
current := versionString() current := versionString()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel() defer cancel()
c := &update.Client{Servers: cfg.Releases} c := &update.Client{Servers: cfg.Updates.Sources}
r, err := c.Latest(ctx) r, err := c.Latest(ctx)
if err != nil { if err != nil {
return fmt.Errorf("looking for a newer lazysecrets: %w", err) return fmt.Errorf("looking for a newer lazysecrets: %w", err)
} }
if !update.Newer(current, r.Tag) { if !update.Newer(current, r.Tag) {
if !update.Newer(current, "v999.0.0") { if strings.HasPrefix(current, "dev") {
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) 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) fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil return nil
} }
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes())) fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
if check { if *check {
fmt.Println("lazysecrets update installs it.") fmt.Println("lazysecrets update installs it.")
return nil return nil
} }
+19 -2
View File
@@ -1,14 +1,22 @@
package main package main
import "runtime/debug" import (
"regexp"
"runtime/debug"
)
// version is set by release builds: // version is set by release builds:
// //
// go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets // go build -ldflags "-X main.version=1.2.3" ./cmd/lazysecrets
var version string 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 // versionString is the release version, or for other builds what Go
// recorded: the module version (go install …@v1.2.3), else the commit. // recorded: the module version (go install …@v1.2.3), else the commit
// (dev-c1a4f90, -dirty with changes not committed).
func versionString() string { func versionString() string {
if version != "" { if version != "" {
return version return version
@@ -18,6 +26,15 @@ func versionString() string {
return "dev" return "dev"
} }
if v := bi.Main.Version; v != "" && v != "(devel)" { 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 return v
} }
var rev, dirty string var rev, dirty string
+22
View File
@@ -0,0 +1,22 @@
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.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 113 KiB

+4 -5
View File
@@ -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, 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 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 (built-in ones by name) and its subscriptions, read from az's account list
and the token lazysecrets already has: no requests of their own. `y` on and the token lazysecrets already has. `y` on
the row copies the object ID, the one access policies and role 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 assignments ask for; `l` goes into the pane, where `j`/`k` pick any
field, `/` filters them (`/tenant`) and `y` copies the selected one. field, `/` filters them (`/tenant`) and `y` copies the selected one.
@@ -63,13 +63,12 @@ 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 (`[`/`]`). `space` reveals a secret's value, or the public material of a
key (PEM public key) or certificate; private key material is never key (PEM public key) or certificate; private key material is never
fetched. `v` lists an item's versions. Items expiring within fetched. `v` lists an item's versions. Items expiring within
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`. `ui.expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
The status pane (`0`) lists them across every vault loaded, opened or The status pane (`0`) lists them across every vault loaded, opened or
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter` cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
on one goes to it. That makes no requests of its own: expiry dates come on one goes to it. Expiry dates come with the lists. It says how many
with the lists. It says how many vaults it covers; `ctrl+a` in search vaults it covers; `ctrl+a` in search loads the rest.
loads the rest.
On a vault, the right pane shows its settings: resource group, location, 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 SKU, whether access is by Azure roles or access policies, soft delete and
+53 -44
View File
@@ -1,44 +1,45 @@
# Config # Config
Optional, at `~/.config/lazysecrets/config.yaml` (or Optional, at `~/.config/lazysecrets/config.yaml` (or
`$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags override `$XDG_CONFIG_HOME/lazysecrets/config.yaml`, or `--config`). Flags (noted
it. Unknown keys are an error, so typos don't go unnoticed. 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.
```yaml ```yaml
ui:
icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 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 recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list
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 sourcesView: grouped # or flat; ` toggles it
collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one 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) loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once
releases: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) 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://github.com/karleinarb/lazysecrets
- https://git.kebcloud.no/keb/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: providers:
azure: # applies to every az account 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 enabled: true # default
hideSubscriptions: [platform-mgmt] # by name or ID timeout: 30s
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: [repos](repos.md) (`providers.repos`), the More, each on its own page: [Azure](azure.md#vaults-you-cant-discover)
[link handler](links.md#from-the-browser-and-chat) (`linkHandler`), (`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`),
[keybindings](keys.md#rebinding-keys) (`keybinding`) and [keybindings](keys.md#rebinding-keys) (`keybinding`) and
[custom commands](custom-commands.md) (`customCommands`). [custom commands](custom-commands.md) (`customCommands`).
@@ -73,18 +74,22 @@ theme:
azureColor: ["#82aaff"] azureColor: ["#82aaff"]
databricksColor: ["#ff757f"] databricksColor: ["#ff757f"]
repoColor: ["#c099ff"] repoColor: ["#c099ff"]
springColor: ["#c3e88d"] # Spring Boot config files
composeColor: ["#82aaff"] # Docker Compose files
``` ```
A role lazysecrets doesn't know, a typo or one from a newer version, is
skipped with a warning.
Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render
as boxes, turn them off with `icons: false`. as boxes, turn them off with `ui.icons: false`.
## Layering ## Layering
Several config files can be layered, later ones winning (theme roles and Several config files can be layered, later ones winning (theme roles and
keybindings merge one by one), with a comma-separated `--config` or keybindings merge one by one), with a comma-separated `--config` or
`LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`. An editor `LAZYSECRETS_CONFIG_FILE`, like lazygit's `LG_CONFIG_FILE`; e.g. your
plugin can so add a generated theme file to your own config, the way config plus an editor-generated theme:
snacks.nvim themes lazygit from Neovim's highlight groups:
```sh ```sh
LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets LAZYSECRETS_CONFIG_FILE=~/.config/lazysecrets/config.yaml,~/.cache/nvim/lazysecrets-theme.yml lazysecrets
@@ -97,14 +102,15 @@ Stars, notes, usage and the layout are kept in
by you: one record per source, vault or secret, keyed by its by you: one record per source, vault or secret, keyed by its
`lazysecrets://` link, never with values. A star belongs to the account you `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 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. It's for one); a note describes the item, so it shows under every account.
JSON so an editor plugin can read it with a built-in parser; several Several lazysecrets windows can save to it without losing each other's
lazysecrets windows can save to it without losing each other's changes. changes. One written by a newer lazysecrets is read but not saved to,
with a warning, until you update.
Revealing, copying, opening in the browser, sharing and jumping to an item 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, 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 for Recent (`R`). Records kept only for usage are dropped after 90 days
unused, and beyond the 100 most recent. `history: false` stops counting. unused, and beyond the 100 most recent. `storage.history: false` stops counting.
It also keeps where you left lazysecrets in each folder (the selected 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 source, vault or secret), and goes back there the next time it starts in
@@ -119,22 +125,25 @@ you: the Sources lists, vaults and scopes, and secrets' names and metadata
once, and anything older than two minutes reloads in the background when 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 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 bottom border says how old what it shows is (`cached 3h ago`). Revealing
and copying always fetch the value live. and copying always fetch the value live. [`lazysecrets list`](integration.md#listing-for-fzf-and-pickers)
reads it and adds to it too.
Lists not loaded for 30 days are dropped, and so is everything from an 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 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 are read from disk anyway. Deleting the file is always safe, and
`cache: false` stops using it. `storage.cache: false` stops using it.
## Updates ## Updates
Once a day (`updateCheck`), in the background, lazysecrets asks for the At startup, in the background, lazysecrets asks for the latest release
latest release; within the day it reuses the answer from the state file. (`updates.autoCheck`), once a day or every `updates.autoCheckInterval`;
Releases are published on GitHub and on git.kebcloud.no, and it asks until then it reuses the answer from the state file. Releases are
them in that order, the next when one doesn't answer within 5 seconds; published on GitHub and on git.kebcloud.no, and it asks them in that
`releases` lists others, such as a mirror of your own. Offline, or with order, the next when one doesn't answer within 5 seconds;
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or `updates.sources` lists others, such as a mirror of your own. Offline, or with
`lazysecrets update --check`) shows its changelog. 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.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against your OS and CPU from the first server that has it, checks it against
+15 -8
View File
@@ -7,7 +7,12 @@ go test ./...
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
``` ```
A stripped binary is about 21 MB instead of 30 MB: 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:
```sh ```sh
go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets go build -trimpath -ldflags "-s -w" -o lazysecrets ./cmd/lazysecrets
@@ -17,7 +22,7 @@ Development builds show `dev-<commit>` as their version (`-dirty` with
uncommitted changes); `go install …@v0.2.0` shows the tag, releases the uncommitted changes); `go install …@v0.2.0` shows the tag, releases the
version they were built for. version they were built for.
## Pull requests ## How changes land
Pull requests into `main` are squash-merged, so each is one commit titled 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) like the PR. The title is a [conventional commit](https://www.conventionalcommits.org)
@@ -30,10 +35,8 @@ subject, checked by `pr-title.yml`:
| `fix` | a bug fix | Fixed | | `fix` | a bug fix | Fixed |
| `refactor`, `docs`, `test`, `ci`, `chore`, `build`, `style`, `revert` | everything users don't need to hear about | no | | `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. Brackets `feat!:` marks a breaking change, `feat(scope):` names a part.
and a missing space work too: `[feat] x`, `[feat]: x` and `feat:x` all `[skip changelog]` in the description leaves a pull request out.
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 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 platform on pull requests into `main`. `main` is protected: a pull request
@@ -46,10 +49,14 @@ what its pull request tested, and CI doesn't run again there.
Merge as many pull requests as you like, then release `main`: Merge as many pull requests as you like, then release `main`:
```sh ```sh
scripts/release.sh --dry-run v0.2.0 # the notes the release would have scripts/release.sh --dry-run minor # the tag and notes the release would have
scripts/release.sh v0.2.0 # CHANGELOG.md, commit, tag, push scripts/release.sh minor # CHANGELOG.md, commit, tag, push
``` ```
`patch`, `minor` and `major` bump the latest release (`v0.2.1` → `v0.2.2`,
`v0.3.0`, `v1.0.0`; pre-releases don't count); a tag (`v0.3.0`,
`v0.3.0-rc.1`) is taken as it is.
It refuses unless you're on a clean `main` that matches `origin/main`. It refuses unless you're on a clean `main` that matches `origin/main`.
[git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md` [git-cliff](https://git-cliff.org) (`cliff.toml`) writes `CHANGELOG.md`
from the pull request titles. The tag runs the release workflow from the pull request titles. The tag runs the release workflow
+95
View File
@@ -0,0 +1,95 @@
# 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)'
```
![fzf-example](assets/fzf-example.png)
+21 -22
View File
@@ -20,6 +20,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets | | `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars | | `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it | | `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
| `H` | show / hide what's hidden: [Spring and compose keys](repos.md#spring-boot-config) that don't look secret, example files, and files and repos with only those; shown dimmed |
| `r` | refresh the focused pane | | `r` | refresh the focused pane |
| `q`, `ctrl+c` | quit | | `q`, `ctrl+c` | quit |
@@ -49,12 +50,12 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `space` | reveal / hide the selected value (public material for keys and certificates) |
| `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 value without revealing it. 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}}`) | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
| `o` | open the selected item in the browser (over SSH: copy the link) | | `o` | open the selected item in the browser (over SSH: copy the link) |
| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | | `e` | open the selected SOPS, `.env`, Spring or compose 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 | | `s` / `S` | copy a [link](sharing.md) to the selected item / share menu (also env files, [encrypted](sharing.md#sharing-env-files)) |
| `ctrl+o` | open a link or reference (or just paste one) | | `ctrl+o` | open a link or reference (or just paste one) |
| `x` | [custom commands](custom-commands.md) | | `x` | [custom commands](custom-commands.md) |
| `L` | sign in again (shown when a login has expired) | | `L` | sign in again (shown when a login has expired) |
@@ -63,10 +64,8 @@ match case exactly.
A revealed value is shown as it reads best: JSON indented, and PEM A revealed value is shown as it reads best: JSON indented, and PEM
certificates as their subject, issuer, dates, names and thumbprint, with certificates as their subject, issuer, dates, names and thumbprint, with
any other PEM block (a private key) named but not shown. `y` still copies any other PEM block (a private key) named but not shown. `y` still copies
it exactly as stored. it exactly as stored. It hides again after a while, and when the terminal
loses focus ([config](config.md)).
A revealed value hides again after `revealTimeout` (60s) and when the
terminal loses focus (`hideOnBlur`); see [config](config.md).
## Stars and notes ## Stars and notes
@@ -75,8 +74,7 @@ terminal loses focus (`hideOnBlur`); see [config](config.md).
| `f` | star the selected source, vault or secret: it gets a ★ and sorts first in its list | | `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 | | `n` | write a short note on it, shown dimmed on its row and in the detail pane |
A star belongs to the account you starred the item under; a note shows Both are kept in the [state file](config.md#state).
under every account. Both are kept in the [state file](config.md#state).
## Go to ## Go to
@@ -122,16 +120,18 @@ Secrets. Going into it (`l`, or `4`) keeps it on that; there `j`/`k`,
it's hidden. it's hidden.
On top, the status pane (`0`) shows the version, a newer release and how On top, the status pane (`0`) shows the version, a newer release and how
your accounts are doing. Focused, the right pane lists more under the your accounts are doing; `e` there opens your [config](config.md) in your
logo (when there's room): the version and updates (`U`), each account editor, as in lazygit. Focused, the right pane lists more under the
(`L` on one that needs it signs in), what's expiring (`enter` goes to logo; `y` copies any row:
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, - the version and updates (`U`)
state and cache files (`e` opens one in your editor), and links to the - each account (`L` on one that needs it signs in)
docs, source, issues, releases and changelog (`o` opens one; on the - what's expiring (`enter` goes to it)
version or update, its release), then the changelog of every release up - this session's errors, newest first (not kept after quitting)
to yours, built in so it needs no network: `/` filters it (`/fixed`, - the config, state and cache files (`e` opens one in your editor)
`/search`). `y` copies any row. - 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`)
| Key | Action | | Key | Action |
|---|---| |---|---|
@@ -140,9 +140,8 @@ to yours, built in so it needs no network: `/` filters it (`/fixed`,
| `+` / `_` | zoom the focused pane: normal → half → full screen, and back | | `+` / `_` | zoom the focused pane: normal → half → full screen, and back |
| `=` | reset the layout (divider, heights, zoom) | | `=` | reset the layout (divider, heights, zoom) |
The divider and pane heights are remembered (zoom isn't). macOS uses The divider and pane heights are remembered (zoom isn't). `<>{}` are for
ctrl+arrows for Mission Control and switching desktops by default, hence macOS, where ctrl+arrows switch desktops.
the `<` `>` `{` `}` alternatives.
## Rebinding keys ## Rebinding keys
-118
View File
@@ -1,118 +0,0 @@
# 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.
+115 -24
View File
@@ -1,16 +1,18 @@
# SOPS and .env files in git repos # SOPS, .env, Spring and compose files in git repos
lazysecrets shows git repositories under **Repos**: their lazysecrets shows git repositories under **Repos**: their
[SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON,
`.env`, INI) and plain env files as vaults, and the keys in them `.env`, INI), plain env files, [Spring Boot config](#spring-boot-config)
(`db.password`, `list[0]`) as secrets. and [Docker Compose files](#docker-compose) as vaults, and the keys in them (`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is
escaped, `app\.name`, so it doesn't read as nesting.
## Which repos ## Which repos
**Repos** lists every repo with secret files under the `roots` (`~/git` **Repos** lists every repo with secret files under the `roots` (by
by default; one or two levels down, `repo` or `org/repo`), any listed in default whichever of `~/git`, `~/repo`, `~/repos`, `~/projects`, `~/code`,
`paths`, and any you starred something in (the repo, a file or a key), `~/source` and `~/src` exist; one or two levels down, `repo` or
wherever it is. They're checked in the background. `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, 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 `Repo · <name>`, and lazysecrets opens on it, with the files under the
@@ -27,8 +29,9 @@ anything by hand.
## Which files ## Which files
**Env files**: `.env`, `.env.local`, `prod.env` and the like, also when **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; git-ignored (as they usually are), in the folders git tracks files in.
not `.env.example`, `.sample`, `.template` and the like. They're read `.env.example`, `.sample`, `.template` and the like are listed hidden
(`H` shows them): someone may have put a real value in one. They're read
directly; a file with SOPS metadata is always treated as SOPS, whatever directly; a file with SOPS metadata is always treated as SOPS, whatever
its name. its name.
@@ -44,24 +47,100 @@ Folders of dependencies and caches are never looked in, tracked or not:
3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go, 3. where the repo's `.sops.yaml` `creation_rules` say SOPS files go,
4. the whole repo. 4. the whole repo.
`include`/`exclude` apply to env files too. `include`/`exclude` apply to env, Spring and compose files too.
```yaml ```yaml
providers: providers:
repos: repos:
roots: [~/git] # folders of repos (the default; [] turns it off) roots: [~/git] # folders of repos (replaces the defaults; [] turns it off)
paths: [~/work/infra] # single repos elsewhere paths: [~/work/infra] # single repos elsewhere
include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth include: ["secrets/**", "**/*.sops.yaml"] # globs from the repo root; ** for any depth
exclude: ["**/testdata/**"] 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: secretPatterns:`, `compose:
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.
## Decrypting ## Decrypting
Listing never decrypts: SOPS encrypts values only, so keys and metadata Listing never decrypts: SOPS and Jasypt encrypt values only, so keys and
(encryption, last modified) are read straight from the files. Revealing, metadata (encryption, last modified) are read straight from the files.
copying or `lazysecrets get` decrypts one value with your `sops` binary, so Revealing, copying or `lazysecrets get` decrypts one value; SOPS's with
whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault
KMS, PGP). When sops can't decrypt, its whole error message is shown. 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 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 encrypted for (public: age recipients, Key Vault keys, KMS ARNs, PGP
@@ -70,6 +149,24 @@ tracked, ignored or neither, and its last commit. A plain `.env` file that
git tracks, or that isn't ignored, is marked in red: its values are in the git tracks, or that isn't ignored, is marked in red: its values are in the
repo's history, or easily could be. repo's history, or easily could be.
### Jasypt
A value like `ENC(...)` in a plain `.env`, Spring or compose file is
[Jasypt](http://www.jasypt.org)'s, decrypted when it's revealed or copied
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 ## Opening files
`e` opens the selected file, or on a secret, its file at that key's line: `e` opens the selected file, or on a secret, its file at that key's line:
@@ -90,11 +187,5 @@ sops 3.8 or later.
## Links ## Links
Links name the repo by its git remote, so they open in a colleague's clone Repo links name the repo by its git remote, so they open in a colleague's
wherever it is: clone wherever it is ([sharing](sharing.md)).
```
lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password
```
See [links](links.md).
+186
View File
@@ -0,0 +1,186 @@
# 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.
+9 -7
View File
@@ -1,26 +1,27 @@
module git.kebcloud.no/keb/lazysecrets module git.kebcloud.no/keb/lazysecrets
go 1.26.4 go 1.27.2
require ( require (
charm.land/bubbles/v2 v2.2.1 charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.1.0 charm.land/bubbletea/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.6 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/azcore v1.23.3
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2 v2.1.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.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 v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2 v2.0.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/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/azkeys v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets 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/atotto/clipboard v0.1.4
github.com/charmbracelet/x/ansi v0.11.9 github.com/charmbracelet/x/ansi v0.11.9
github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f
github.com/databricks/databricks-sdk-go v0.187.0 github.com/databricks/databricks-sdk-go v0.188.0
github.com/junegunn/fzf v0.74.4 github.com/junegunn/fzf v0.74.5
go.yaml.in/yaml/v3 v3.0.5 go.yaml.in/yaml/v3 v3.0.5
golang.org/x/mod v0.41.0 golang.org/x/mod v0.42.0
golang.org/x/term v0.47.0 golang.org/x/term v0.47.0
gopkg.in/ini.v1 v1.67.3 gopkg.in/ini.v1 v1.67.3
) )
@@ -29,6 +30,7 @@ require (
cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // 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/internal v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.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 github.com/AzureAD/microsoft-authentication-library-for-go v1.8.0 // indirect
@@ -74,6 +76,6 @@ require (
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.264.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/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.1 // indirect google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
+24 -18
View File
@@ -1,3 +1,5 @@
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 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= 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 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
@@ -10,6 +12,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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 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/azcore v1.23.3/go.mod h1:XtxD48QPInqoj+GMlcRlXHEYsoOxFnP6w2JgIZ9rDS4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.1 h1:u93s+zU2JD62im61Bm5CZIc1ZrOJaIAWEg0WOrMVkEo=
@@ -18,18 +24,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/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 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/internal v1.13.0/go.mod h1:7Ut2Qw9vcwpLW5Q4MBy43uSxXS7tK2yjs94OJ6j5thE=
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/v3 v3.2.0 h1:+lnLQhKh3cgSOIOVH61UZ3s/l9d+bAZp5d/spt1+7UI=
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.2.0/go.mod h1:tStOHrivWUrcBolspvKV70Us1ckESYGYSHdG4LX8zyY=
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/keyvault/armkeyvault/v2 v2.1.0 h1:4dT2wTR+ViaGvwcAoMjqt0BvgwBkT6nXxg8flJhnLfw=
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/v2 v2.1.0/go.mod h1:7fgu6AtY2gkWhtZt9SEHK/MkTVUn2HPvAZjv6XsrjuI=
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 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/resourcegraph/armresourcegraph v0.10.0/go.mod h1:EGwSLlGqrrfYQhtCi9JcIkPQKl9WxsL6ZPJd+63Vy1A=
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/armdeployments v1.0.0 h1:67nFqWXpo0x5Nz0XEb1yI7s8D+EHy8NsTinYw9sZnLk=
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/armdeployments v1.0.0/go.mod h1:fewgRjNVE84QVVh798sIMFb7gPXPp7NmnekGnboSnXk=
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/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE=
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/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/security/keyvault/azcertificates v1.5.0 h1:15MiSirdxWpIKFEHwfwfMWYieETCUnPTERPIlnytQeo= 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/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= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM=
@@ -66,8 +72,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= 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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/databricks/databricks-sdk-go v0.187.0 h1:V1rGbEUaEdGS7tTZSyplDLnE2Vp5N1aIPiIh/LzjOC8= github.com/databricks/databricks-sdk-go v0.188.0 h1:1nxBqlctGnPzlReyziG199abHs9cR/KNjsCnSM+oDGA=
github.com/databricks/databricks-sdk-go v0.187.0/go.mod h1:udBw6nxkaqa0fgPQ7ZW1kGFkltQncYx+qFJeVFmxtz8= github.com/databricks/databricks-sdk-go v0.188.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.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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -94,8 +100,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= 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 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/junegunn/fzf v0.74.4 h1:DSqIW3ZxtkuuPzeLKrVnHdoWqT9VCIGRbjc/lXiGCso= github.com/junegunn/fzf v0.74.5 h1:mpJlCdgGuddKerHrdjvV3cnezEBgyjZAVn8KCo/4OMc=
github.com/junegunn/fzf v0.74.4/go.mod h1:TBRY54j3mGjAm4JcpDyiUIuDq38QlHvAzrB3GLKxQXM= github.com/junegunn/fzf v0.74.5/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 h1:7dYDtfMDfKzjT+DVfIS4iqknSEKtZpEcXtu6vuaasHs=
github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741/go.mod h1:6EILKtGpo5t+KLb85LNZLAF6P9LKp78hJI80PXMcn3c= 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= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
@@ -149,8 +155,8 @@ 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.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= 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/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= 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 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= 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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -173,8 +179,8 @@ google.golang.org/api v0.264.0 h1:+Fo3DQXBK8gLdf8rFZ3uLu39JpOnhvzJrLMQSoSYZJM=
google.golang.org/api v0.264.0/go.mod h1:fAU1xtNNisHgOF5JooAs8rRaTkl2rT3uaoNGo9NS3R8= 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 h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+172
View File
@@ -0,0 +1,172 @@
// 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)
}
+122
View File
@@ -0,0 +1,122 @@
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")
}
}
+7
View File
@@ -0,0 +1,7 @@
//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 }
+25
View File
@@ -0,0 +1,25 @@
//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
}
-3
View File
@@ -42,9 +42,6 @@ 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. // Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd { func (c Clipboard) Write(s string) tea.Cmd {
if !c.native { if !c.native {
+2 -4
View File
@@ -16,6 +16,7 @@
package commands package commands
import ( import (
"cmp"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -60,10 +61,7 @@ var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config. // Validate checks a command from the config.
func (c Command) Validate() error { func (c Command) Validate() error {
name := c.Description name := cmp.Or(c.Description, c.Command)
if name == "" {
name = c.Command
}
switch { switch {
case c.Command == "": case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description) return fmt.Errorf("custom command %q has no command", c.Description)
+135 -53
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -14,46 +15,24 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
type Config struct { type Config struct {
Icons *bool `yaml:"icons"` UI UI `yaml:"ui"`
ClipboardClear *time.Duration `yaml:"clipboardClear"` // Values keeps revealed and copied values from lingering.
// RevealTimeout hides a revealed value after this long; 0s never. Values Values `yaml:"values"`
// Defaults to 60s. // Storage is what's kept between runs.
RevealTimeout *time.Duration `yaml:"revealTimeout"` Storage Storage `yaml:"storage"`
// ExpiryWarningDays marks secrets, keys and certificates expiring // Updates sets how lazysecrets looks for newer releases.
// within this many days; 0 marks only expired ones. Defaults to 30. Updates Updates `yaml:"updates"`
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"` Providers Providers `yaml:"providers"`
// LinkHandler sets how lazysecrets:// links open; see linkhandler. // LinkHandler sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"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 // CustomCommands run shell commands on the selected item; see
// package commands. // package commands.
CustomCommands []commands.Command `yaml:"customCommands"` CustomCommands []commands.Command `yaml:"customCommands"`
@@ -65,6 +44,56 @@ type Config struct {
Theme map[string][]string `yaml:"theme"` 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 { type LinkHandler struct {
// Terminal to open links in, overriding the one handler install // Terminal to open links in, overriding the one handler install
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty, // detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
@@ -82,7 +111,27 @@ type LinkHandler struct {
Prompt *bool `yaml:"prompt"` 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 { 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"` Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"` Databricks Databricks `yaml:"databricks"`
Repos Repos `yaml:"repos"` Repos Repos `yaml:"repos"`
@@ -92,20 +141,29 @@ type Providers struct {
// that repo is the only one shown; elsewhere, the ones in Paths and under // that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots. // Roots.
type Repos struct { type Repos struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"` Paths []string `yaml:"paths"`
// Roots are folders of repos. Defaults to ~/git; an empty list // Roots are folders of repos. Defaults to repo.DefaultRoots (~/git,
// turns it off. // ~/src, ~/code and the like); an empty list turns it off.
Roots *[]string `yaml:"roots"` Roots *[]string `yaml:"roots"`
// Include and Exclude are globs relative to a repo's root (** for any // Include and Exclude are globs relative to a repo's root (** for any
// depth), for repos without their own .lazysecrets.yaml. Without them, // depth), for repos without their own .lazysecrets.yaml. Without them,
// a repo's .sops.yaml creation_rules say where to look. // a repo's .sops.yaml creation_rules say where to look.
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
// SecretPatterns say which keys of config files are secrets; Spring
// and Compose override them for their files, list by list.
SecretPatterns repo.SecretPatterns `yaml:"secretPatterns"`
Spring repo.FileType `yaml:"spring"`
Compose repo.FileType `yaml:"compose"`
} }
type Azure struct { type Azure struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID. // HideSubscriptions lists subscriptions to leave out, by name or ID.
@@ -116,6 +174,8 @@ type Azure struct {
} }
type Databricks struct { type Databricks struct {
// Timeout overrides providers.timeout for these requests.
Timeout time.Duration `yaml:"timeout"`
// Enabled defaults to true. // Enabled defaults to true.
Enabled *bool `yaml:"enabled"` Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"` HideProfiles []string `yaml:"hideProfiles"`
@@ -151,17 +211,12 @@ func xdgDir(env, fallback string) (string, error) {
return filepath.Join(base, "lazysecrets"), nil 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 // 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 // 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 // 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 // plugin add a generated theme file to the user's own config. Missing
// files are skipped. // files are skipped. Unknown keys are an error, so typos don't silently do
// nothing.
func LoadAll(paths []string) (Config, error) { func LoadAll(paths []string) (Config, error) {
var c Config var c Config
keybinding := map[string]map[string][]string{} keybinding := map[string]map[string][]string{}
@@ -176,9 +231,7 @@ func LoadAll(paths []string) (Config, error) {
if keybinding[ctx] == nil { if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{} keybinding[ctx] = map[string][]string{}
} }
for name, keys := range actions { maps.Copy(keybinding[ctx], actions)
keybinding[ctx][name] = keys
}
} }
} }
c.Keybinding = keybinding c.Keybinding = keybinding
@@ -202,25 +255,54 @@ func decodeInto(c *Config, path string) error {
} }
func (c Config) validate(path string) error { func (c Config) validate(path string) error {
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" { if v := c.UI.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v) return fmt.Errorf("%s: ui.sourcesView: %q is not grouped or flat", path, v)
} }
for _, cmd := range c.CustomCommands { for _, cmd := range c.CustomCommands {
if err := cmd.Validate(); err != nil { if err := cmd.Validate(); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
} }
if d := c.ExpiryWarningDays; d != nil && *d < 0 { if d := c.UI.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d) return fmt.Errorf("%s: ui.expiryWarningDays: %d is negative", path, *d)
} }
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) { if d := c.UI.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d) return fmt.Errorf("%s: ui.loadDelay: %s is not between 0s and 5s", path, *d)
} }
for i, r := range c.Releases { 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 {
if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") {
return fmt.Errorf("%s: releases[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) return fmt.Errorf("%s: updates.sources[%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")); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
for i, v := range c.Providers.Azure.Vaults { for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") { if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
+41 -23
View File
@@ -18,29 +18,34 @@ func write(t *testing.T, content string) string {
} }
func TestLoad(t *testing.T) { func TestLoad(t *testing.T) {
c, err := Load(write(t, ` c, err := LoadAll([]string{write(t, `
ui:
icons: false icons: false
sourcesView: flat sourcesView: flat
values:
clipboardClear: 45s clipboardClear: 45s
timeout: 1m storage:
history: false
providers: providers:
timeout: 1m
azure: azure:
timeout: 2m
hideSubscriptions: [platform-mgmt] hideSubscriptions: [platform-mgmt]
vaults: vaults:
- https://kv-foo.vault.azure.net/ - https://kv-foo.vault.azure.net/
databricks: databricks:
enabled: false enabled: false
hideProfiles: [test] hideProfiles: [test]
`)) `)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second || if c.UI.Icons == nil || *c.UI.Icons || c.Values.ClipboardClear == nil || *c.Values.ClipboardClear != 45*time.Second ||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" { c.Providers.Timeout != time.Minute || c.Providers.Azure.Timeout != 2*time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
t.Errorf("config = %+v", c) t.Errorf("config = %+v", c)
} }
if c.SourcesView != "flat" { if c.UI.SourcesView != "flat" || On(c.Storage.History) || !On(c.Storage.Cache) {
t.Errorf("sourcesView = %q", c.SourcesView) t.Errorf("ui = %+v, storage = %+v", c.UI, c.Storage)
} }
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) { if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
t.Error("enabled flags wrong") t.Error("enabled flags wrong")
@@ -56,8 +61,8 @@ func TestLoadMissingAndEmpty(t *testing.T) {
"missing": filepath.Join(t.TempDir(), "nope.yaml"), "missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""), "empty": write(t, ""),
} { } {
c, err := Load(path) c, err := LoadAll([]string{path})
if err != nil || c.Icons != nil || c.ClipboardClear != nil { if err != nil || c.UI.Icons != nil || c.Values.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err) t.Errorf("%s: %+v, %v", name, c, err)
} }
} }
@@ -68,12 +73,21 @@ func TestLoadRejectsMistakes(t *testing.T) {
"typo": "icon: true\n", "typo": "icon: true\n",
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n", "bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"duration": "timeout: soon\n", "flat keys": "icons: false\n",
"view": "sourcesView: tree\n", "duration": "providers:\n timeout: soon\n",
"releases": "releases: [git.example.com/team/lazysecrets]\n", "negative": "providers:\n repos:\n timeout: -1s\n",
"http": "releases: [http://git.example.com/team/lazysecrets]\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",
} { } {
if _, err := Load(write(t, content)); err == nil { if _, err := LoadAll([]string{write(t, content)}); err == nil {
t.Errorf("%s: no error", name) t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") { } else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err) t.Errorf("%s: error doesn't name the file: %v", name, err)
@@ -83,6 +97,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
func TestLoadAllLayers(t *testing.T) { func TestLoadAllLayers(t *testing.T) {
user := write(t, ` user := write(t, `
ui:
icons: false icons: false
theme: theme:
azureColor: [blue] azureColor: [blue]
@@ -92,6 +107,8 @@ providers:
hideSubscriptions: [a] hideSubscriptions: [a]
`) `)
theme := write(t, ` theme := write(t, `
ui:
sourcesView: flat
theme: theme:
azureColor: ["#82aaff"] azureColor: ["#82aaff"]
activeBorderColor: ["#ff966c", bold] activeBorderColor: ["#ff966c", bold]
@@ -100,7 +117,8 @@ theme:
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 { // 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 {
t.Errorf("user settings lost: %+v", c) t.Errorf("user settings lost: %+v", c)
} }
want := map[string][]string{ want := map[string][]string{
@@ -115,19 +133,19 @@ theme:
} }
} }
func TestReleases(t *testing.T) { func TestUpdates(t *testing.T) {
c, err := Load(write(t, "releases: [https://git.example.com/team/lazysecrets]\n")) 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 || len(c.Releases) != 1 { if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 {
t.Errorf("releases = %v, %v", c.Releases, err) t.Errorf("updates = %+v, %v", c.Updates, err)
} }
} }
func TestLoadDelay(t *testing.T) { func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "loadDelay: 300ms\n")) c, err := LoadAll([]string{write(t, "ui:\n loadDelay: 300ms\n")})
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond { if err != nil || c.UI.LoadDelay == nil || *c.UI.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err) t.Errorf("loadDelay = %v, %v", c.UI.LoadDelay, err)
} }
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil { if _, err := LoadAll([]string{write(t, "ui:\n loadDelay: 1m\n")}); err == nil {
t.Error("a minute's delay accepted") t.Error("a minute's delay accepted")
} }
} }
+41
View File
@@ -0,0 +1,41 @@
// 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))
}
+27
View File
@@ -0,0 +1,27 @@
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 -7
View File
@@ -9,7 +9,6 @@
package instance package instance
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -36,15 +35,18 @@ func Dir(stateDir string) string {
type Status struct { type Status struct {
PID int `json:"pid"` PID int `json:"pid"`
LastActive time.Time `json:"lastActive"` // last key press or focus LastActive time.Time `json:"lastActive"` // last key press or focus
// Reach is how readily it can be shown: 2 in view (outside tmux, or Reach int `json:"reach"` // how readily it can be shown: unreachable, switchable or inView
// 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 is the terminal app it would show in (macOS), preferring the
// app the link was clicked in. // app the link was clicked in.
App string `json:"app,omitempty"` 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 { type request struct {
Op string `json:"op"` // "status" or "open" Op string `json:"op"` // "status" or "open"
Link string `json:"link,omitempty"` Link string `json:"link,omitempty"`
@@ -114,7 +116,7 @@ func (s *Server) handle(c net.Conn) {
defer c.Close() defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second)) _ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request var req request
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil { if err := json.NewDecoder(c).Decode(&req); err != nil {
return return
} }
var resp response var resp response
@@ -188,7 +190,7 @@ func Best(found []Found, front string, terminal bool) (Found, bool) {
best = f best = f
} }
} }
return best, best.Reach > 0 return best, best.Reach > unreachable
} }
// Open hands link, clicked in front, to f. // Open hands link, clicked in front, to f.
+22 -23
View File
@@ -1,6 +1,7 @@
package instance package instance
import ( import (
"cmp"
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
@@ -25,7 +26,8 @@ func tmux(args ...string) (string, error) {
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" } func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
type client struct { type client struct {
name, session, activity string // activity: Unix seconds name, session string
activity int // Unix seconds
pid int pid int
app string app string
} }
@@ -42,30 +44,31 @@ func clients() []client {
if len(f) < 4 { if len(f) < 4 {
continue continue
} }
pid, err := strconv.Atoi(f[1]) activity, err1 := strconv.Atoi(f[0])
if err != nil { pid, err2 := strconv.Atoi(f[1])
if err1 != nil || err2 != nil {
continue continue
} }
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)}) cs = append(cs, client{name: f[2], session: f[3], activity: activity, pid: pid, app: linkhandler.HostApp(pid)})
} }
// Activity has the same width for centuries: strings compare like slices.SortStableFunc(cs, func(a, b client) int { return cmp.Compare(b.activity, a.activity) })
// numbers.
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
return cs return cs
} }
// place is where this instance would show: the tmux client (zero outside // place is where this instance would show: the tmux client (zero outside
// tmux), the app, and reach: 2 already on screen there (outside tmux, or // tmux), the app, and its reach.
// a client on its session), 1 after switching a client, 0 nowhere.
func place(front string) (client, string, int) { func place(front string) (client, string, int) {
if !inTmux() { if !inTmux() {
return client{}, linkhandler.HostApp(os.Getpid()), 2 return client{}, linkhandler.HostApp(os.Getpid()), inView
} }
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}") session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
if err != nil { if err != nil {
return client{}, "", 0 return client{}, "", unreachable
} }
cs := clients() cs := clients()
if len(cs) == 0 {
return client{}, "", unreachable
}
// Best first: in the front app on its session, in the front app, // Best first: in the front app on its session, in the front app,
// on its session, any. // on its session, any.
rank := func(c client) int { rank := func(c client) int {
@@ -78,20 +81,16 @@ func place(front string) (client, string, int) {
} }
return r return r
} }
var best client best := cs[0]
found := false for _, c := range cs[1:] {
for _, c := range cs { if rank(c) > rank(best) {
if !found || rank(c) > rank(best) { best = c
best, found = c, true
} }
} }
if !found {
return client{}, "", 0
}
if best.session == session { if best.session == session {
return best, best.app, 2 return best, best.app, inView
} }
return best, best.app, 1 return best, best.app, switchable
} }
// Raise brings this instance into view for a link clicked in front: in // Raise brings this instance into view for a link clicked in front: in
@@ -99,14 +98,14 @@ func place(front string) (client, string, int) {
// then its app to the front, showing a hidden dropdown terminal. // then its app to the front, showing a hidden dropdown terminal.
func Raise(front string) { func Raise(front string) {
c, app, reach := place(front) c, app, reach := place(front)
if reach == 0 { if reach == unreachable {
return return
} }
if inTmux() { if inTmux() {
pane := os.Getenv("TMUX_PANE") pane := os.Getenv("TMUX_PANE")
_, _ = tmux("select-window", "-t", pane) _, _ = tmux("select-window", "-t", pane)
_, _ = tmux("select-pane", "-t", pane) _, _ = tmux("select-pane", "-t", pane)
if reach == 1 { if reach == switchable {
_, _ = tmux("switch-client", "-c", c.name, "-t", pane) _, _ = tmux("switch-client", "-c", c.name, "-t", pane)
} }
} }
+67
View File
@@ -0,0 +1,67 @@
// Package jasypt decrypts values encrypted with Jasypt the way
// jasypt-spring-boot 3.0 and later does by default: ENC(...) around the
// base64 of a random salt, a random IV and the ciphertext, with
// PBEWITHHMACSHA512ANDAES_256 (PBKDF2-HMAC-SHA512, 1000 iterations,
// AES-256-CBC).
package jasypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/pbkdf2"
"crypto/sha512"
"encoding/base64"
"errors"
"strings"
"unicode/utf8"
)
const iterations = 1000
// ErrDecrypt is a value that doesn't decrypt with the password: a wrong
// password, or one encrypted with another algorithm.
var ErrDecrypt = errors.New("doesn't decrypt: a wrong password, or not Jasypt's default algorithm (PBEWITHHMACSHA512ANDAES_256)")
// Is reports whether v is a Jasypt value, ENC(...).
func Is(v string) bool {
return len(v) > len("ENC()") && strings.HasPrefix(v, "ENC(") && strings.HasSuffix(v, ")")
}
// Decrypt decrypts an ENC(...) value.
func Decrypt(v, password string) (string, error) {
b, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(v, "ENC("), ")"))
if err != nil {
return "", errors.New("not Jasypt: ENC(...) isn't base64")
}
const size = aes.BlockSize // the salt's and the IV's too
if len(b) < 3*size || len(b)%size != 0 {
return "", ErrDecrypt
}
salt, iv, data := b[:size], b[size:2*size], b[2*size:]
key, err := pbkdf2.Key(sha512.New, password, salt, iterations, 32)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
plain := make([]byte, len(data))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
// A wrong password decrypts to noise: its padding is almost never
// valid, and the rest rarely UTF-8.
n := int(plain[len(plain)-1])
if n == 0 || n > size {
return "", ErrDecrypt
}
for _, c := range plain[len(plain)-n:] {
if int(c) != n {
return "", ErrDecrypt
}
}
plain = plain[:len(plain)-n]
if !utf8.Valid(plain) {
return "", ErrDecrypt
}
return string(plain), nil
}
+37
View File
@@ -0,0 +1,37 @@
package jasypt
import (
"errors"
"testing"
)
// Encrypted by Jasypt 1.9.3 itself, set up as jasypt-spring-boot's default
// encryptor, with the password "test-password".
var vectors = map[string]string{
"ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)": "hunter2",
"ENC(w4pm2JVZcbG5R+Ud9uKHg8VhJ/0aAK7sFXtNBv+Vo450SAchTQtfhw0TnIJ9gN+D0VFbwqJzfXU369coTJz2rw==)": "blåbærsyltetøy ✓",
}
func TestDecrypt(t *testing.T) {
for enc, want := range vectors {
if !Is(enc) {
t.Errorf("%s: not Jasypt", enc)
}
if got, err := Decrypt(enc, "test-password"); err != nil || got != want {
t.Errorf("%s: %q %v, want %q", enc, got, err, want)
}
if _, err := Decrypt(enc, "wrong"); !errors.Is(err, ErrDecrypt) {
t.Errorf("%s with a wrong password: %v", enc, err)
}
}
for _, v := range []string{"ENC(not base64!)", "ENC(aGVsbG8=)"} {
if _, err := Decrypt(v, "test-password"); err == nil {
t.Errorf("%s: no error", v)
}
}
for _, v := range []string{"ENC()", "hunter2", "ENC[AES256_GCM,data:x]", "${DB_PASSWORD}"} {
if Is(v) {
t.Errorf("%s: Jasypt", v)
}
}
}
+3 -7
View File
@@ -114,19 +114,15 @@ func Check() string {
} }
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output() out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil { if err != nil {
return "" // installed before the key was recorded: assume it works return "" // can't tell: assume it works
} }
if exe := strings.TrimSpace(string(out)); !exists(exe) { exe := strings.TrimSpace(string(out))
if _, err := os.Stat(exe); err != nil {
return "lazysecrets:// links run " + exe + ", which no longer exists" return "lazysecrets:// links run " + exe + ", which no longer exists"
} }
return "" return ""
} }
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration. // Uninstall removes the applet and its registration.
func Uninstall() (string, error) { func Uninstall() (string, error) {
app, err := AppPath() app, err := AppPath()
+5 -4
View File
@@ -140,11 +140,10 @@ func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
} }
// Launch opens a new terminal window running inner, without waiting for // Launch opens a new terminal window running argv, without waiting for
// it. // it.
func Launch(argv []string) error { func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...) cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return err return err
} }
@@ -166,9 +165,11 @@ func Executable() (string, error) {
// passEnv lists the variables carried from the install's environment to // passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment, // lazysecrets in the new window: apps start with a minimal environment,
// without Homebrew's PATH (so no az) or the user's config locations. // 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.
var passEnv = []string{ var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS",
"XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "XDG_DATA_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
} }
+410
View File
@@ -0,0 +1,410 @@
// 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
}
+297
View File
@@ -0,0 +1,297 @@
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
}
-3
View File
@@ -107,9 +107,6 @@ func parseTerm(tok string) (term, bool) {
return t, true 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 // 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, // 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. // like a path or a note). Without a name, nothing gets the name bonus.
-6
View File
@@ -99,9 +99,3 @@ func TestAnchorsOnTheName(t *testing.T) {
t.Errorf("password$: %v", got) t.Errorf("password$: %v", got)
} }
} }
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+94 -90
View File
@@ -16,10 +16,11 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore" "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/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/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions/v2"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates" "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/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
@@ -99,13 +100,7 @@ func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindA
// Sources lists the subscriptions the signed-in identity can see, skipping // Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones. // disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) { func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
var out []provider.Source out, err := all(ctx, p.subs.NewListPager(nil), func(page armsubscriptions.ClientListResponse) (srcs []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 { for _, s := range page.Value {
switch deref(s.State) { switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted: case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
@@ -118,7 +113,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.mu.Lock() p.mu.Lock()
p.tenants[id] = tenant p.tenants[id] = tenant
p.mu.Unlock() p.mu.Unlock()
out = append(out, provider.Source{ srcs = append(srcs, provider.Source{
Kind: provider.KindAzure, Kind: provider.KindAzure,
ID: id, ID: id,
Name: deref(s.DisplayName), Name: deref(s.DisplayName),
@@ -126,6 +121,10 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"), 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)) }) 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 { if len(p.opts.Vaults) > 0 {
@@ -230,22 +229,29 @@ func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provid
if err != nil { if err != nil {
return nil, err return nil, err
} }
var out []provider.Container return all(ctx, client.NewListBySubscriptionPager(nil), func(page armkeyvault.VaultsClientListBySubscriptionResponse) (vaults []provider.Container) {
pager := client.NewListBySubscriptionPager(nil) 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
for pager.More() { for pager.More() {
page, err := pager.NextPage(ctx) page, err := pager.NextPage(ctx)
if err != nil { if err != nil {
return nil, cleanError(err) return nil, cleanError(err)
} }
for _, v := range page.Value { out = append(out, items(page)...)
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 return out, nil
} }
@@ -288,51 +294,40 @@ func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider
var out []provider.Object var out []provider.Object
switch t { switch t {
case provider.TypeSecret: case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesPager(nil) tenant := p.tenant(c.Source.ID)
for pager.More() { out, err = all(ctx, vc.secrets.NewListSecretPropertiesPager(nil), func(page azsecrets.ListSecretPropertiesResponse) (objs []provider.Object) {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates. // Certificate-backing secrets appear under Certificates.
if s.ID == nil || deref(s.Managed) { if s.ID != nil && !deref(s.Managed) {
continue objs = append(objs, secretObject(c, s, tenant))
}
out = append(out, secretObject(c, s))
} }
} }
return objs
})
case provider.TypeKey: case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesPager(nil) out, err = all(ctx, vc.keys.NewListKeyPropertiesPager(nil), func(page azkeys.ListKeyPropertiesResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value { for _, k := range page.Value {
if k.KID == nil || deref(k.Managed) { if k.KID != nil && !deref(k.Managed) {
continue objs = append(objs, keyObject(c, k))
}
out = append(out, keyObject(c, k))
} }
} }
return objs
})
case provider.TypeCertificate: case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesPager(nil) out, err = all(ctx, vc.certs.NewListCertificatePropertiesPager(nil), func(page azcertificates.ListCertificatePropertiesResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value { for _, cp := range page.Value {
if cp.ID == nil { if cp.ID != nil {
continue objs = append(objs, certObject(c, cp))
}
out = append(out, certObject(c, cp))
} }
} }
return objs
})
default: default:
return nil, provider.ErrUnsupported 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)) }) slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil return out, nil
} }
@@ -353,7 +348,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
if resp.Key != nil { if resp.Key != nil {
if resp.Key.KID != nil { if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version() o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.Key.KID))
} }
o.Attrs = keyAttrs(resp.Key) o.Attrs = keyAttrs(resp.Key)
} }
@@ -364,7 +359,7 @@ func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Ob
} }
if resp.ID != nil { if resp.ID != nil {
o.Version = resp.ID.Version() o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID)) o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), o.Type, string(*resp.ID))
} }
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil { if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType) o.ContentType = deref(pol.SecretProperties.ContentType)
@@ -381,52 +376,47 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.
if err != nil { if err != nil {
return nil, err return nil, err
} }
tenant := p.tenant(o.Container.Source.ID)
var out []provider.Object var out []provider.Object
switch o.Type { switch o.Type {
case provider.TypeSecret: case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil), func(page azsecrets.ListSecretPropertiesVersionsResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value { for _, s := range page.Value {
if s.ID != nil { if s.ID != nil {
out = append(out, secretObject(o.Container, s)) objs = append(objs, secretObject(o.Container, s, tenant))
}
} }
} }
return objs
})
case provider.TypeKey: case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil), func(page azkeys.ListKeyPropertiesVersionsResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value { for _, k := range page.Value {
if k.KID != nil { if k.KID != nil {
out = append(out, keyObject(o.Container, k)) objs = append(objs, keyObject(o.Container, k))
}
} }
} }
return objs
})
case provider.TypeCertificate: case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil) out, err = all(ctx, vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil), func(page azcertificates.ListCertificatePropertiesVersionsResponse) (objs []provider.Object) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value { for _, cp := range page.Value {
if cp.ID != nil { if cp.ID != nil {
out = append(out, certObject(o.Container, cp)) objs = append(objs, certObject(o.Container, cp))
}
} }
} }
return objs
})
default: default:
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
if err != nil {
return nil, err
}
for i := range out { for i := range out {
out[i].Reference = versionedReference(out[i]) 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) }) slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
return out, nil return out, nil
@@ -447,41 +437,55 @@ func versionedReference(o provider.Object) string {
// Value returns a secret's value, a key's public key or a certificate's // Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty). // public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { 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) vc, err := p.clients(o.Container.Detail)
if err != nil { if err != nil {
return provider.SecretValue{}, err return provider.SecretValue{}, "", err
} }
var text string
switch o.Type { switch o.Type {
case provider.TypeSecret: case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil) resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil { if err != nil {
return provider.SecretValue{}, cleanError(err) return provider.SecretValue{}, "", cleanError(err)
} }
return provider.NewSecretValue([]byte(deref(resp.Value))), nil version := o.Version
if resp.ID != nil {
version = resp.ID.Version()
}
return provider.NewSecretValue([]byte(deref(resp.Value))), version, nil
case provider.TypeKey: case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil) resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil { if err != nil {
return provider.SecretValue{}, cleanError(err) return provider.SecretValue{}, "", cleanError(err)
} }
pemKey, err := publicKeyPEM(resp.Key) if text, err = publicKeyPEM(resp.Key); err != nil {
if err != nil { return provider.SecretValue{}, "", err
return provider.SecretValue{}, err
} }
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate: case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil) resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil { if err != nil {
return provider.SecretValue{}, cleanError(err) return provider.SecretValue{}, "", cleanError(err)
} }
if len(resp.CER) == 0 { 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?)")
} }
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil text = certPEM(resp.CER)
default:
return provider.SecretValue{}, "", provider.ErrUnsupported
} }
return provider.SecretValue{}, provider.ErrUnsupported return provider.NewSecretValue([]byte(text)), o.Version, nil
} }
var _ provider.Locator = (*Provider)(nil) var (
_ provider.Locator = (*Provider)(nil)
_ provider.Versioned = (*Provider)(nil)
)
// validHost guards the Resource Graph query against odd input. // validHost guards the Resource Graph query against odd input.
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`) var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
+16
View File
@@ -30,6 +30,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "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/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys" "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" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -334,6 +335,21 @@ 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) { func TestVersionedReference(t *testing.T) {
sec := provider.Object{Type: provider.TypeSecret, Version: "v1", sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"} Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
+15 -18
View File
@@ -53,10 +53,10 @@ func PortalResourceURL(tenant, resourceID, blade string) string {
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
} }
// portalAssetURL links to a Key Vault object's page; objectID must include // portalAssetURL links to a Key Vault object's page: a version's with
// the version. // one in objectID; a secret's own (its versions) without.
func portalAssetURL(tenant, kind, objectID string) string { func portalAssetURL(tenant string, t provider.ObjectType, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + string(t) + "/" + objectID
} }
func tenantPrefix(tenant string) string { func tenantPrefix(tenant string) string {
@@ -73,24 +73,21 @@ func ObjectListURL(c provider.Container, t provider.ObjectType) string {
if !ok { if !ok {
return "" return ""
} }
switch t { return base + "/" + t.Collection()
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. // objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name. // https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, collection, name string) string { func objectURL(c provider.Container, t provider.ObjectType, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name return strings.TrimSuffix(c.Detail, "/") + "/" + t.Collection() + "/" + name
} }
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object { // 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 {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, "secrets", name) url := objectURL(c, provider.TypeSecret, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeSecret, Type: provider.TypeSecret,
@@ -100,7 +97,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.
ContentType: deref(p.ContentType), ContentType: deref(p.ContentType),
Tags: tags(p.Tags), Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")", Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: ObjectListURL(c, provider.TypeSecret), URL: portalAssetURL(tenant, provider.TypeSecret, strings.TrimSuffix(url+"/"+p.ID.Version(), "/")),
} }
if a := p.Attributes; a != nil { if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires) o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
@@ -110,7 +107,7 @@ func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object { func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name() name := p.KID.Name()
url := objectURL(c, "keys", name) url := objectURL(c, provider.TypeKey, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeKey, Type: provider.TypeKey,
@@ -129,7 +126,7 @@ func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object { func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name() name := p.ID.Name()
url := objectURL(c, "certificates", name) url := objectURL(c, provider.TypeCertificate, name)
o := provider.Object{ o := provider.Object{
Container: c, Container: c,
Type: provider.TypeCertificate, Type: provider.TypeCertificate,
+14 -14
View File
@@ -2,11 +2,12 @@ package azure
import ( import (
"fmt" "fmt"
"maps"
"slices" "slices"
"strings" "strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -70,13 +71,10 @@ func graphInfo(m map[string]any) vaultInfo {
// armInfo reads a vault's settings from the management API. // armInfo reads a vault's settings from the management API.
func armInfo(v *armkeyvault.Vault) vaultInfo { func armInfo(v *armkeyvault.Vault) vaultInfo {
info := vaultInfo{location: deref(v.Location), tags: map[string]string{}} info := vaultInfo{location: deref(v.Location), tags: tags(v.Tags)}
if id, err := arm.ParseResourceID(deref(v.ID)); err == nil { if id, err := arm.ParseResourceID(deref(v.ID)); err == nil {
info.group = id.ResourceGroupName info.group = id.ResourceGroupName
} }
for k, t := range v.Tags {
info.tags[k] = deref(t)
}
pr := v.Properties pr := v.Properties
if pr == nil { if pr == nil {
return info return info
@@ -122,7 +120,11 @@ func (v vaultInfo) attrs() []provider.Attr {
} else { } else {
add("soft delete", "off") add("soft delete", "off")
} }
add("purge prot.", map[bool]string{true: "on", false: "off"}[on(v.purge, false)]) if on(v.purge, false) {
add("purge prot.", "on")
} else {
add("purge prot.", "off")
}
switch { switch {
case strings.EqualFold(v.publicAccess, "disabled"): case strings.EqualFold(v.publicAccess, "disabled"):
add("network", "private endpoints only") add("network", "private endpoints only")
@@ -131,15 +133,13 @@ func (v vaultInfo) attrs() []provider.Attr {
default: default:
add("network", "all networks") add("network", "all networks")
} }
if v.endpoints > 0 { switch {
add("private", fmt.Sprintf("%d endpoint%s", v.endpoints, map[bool]string{true: "s"}[v.endpoints > 1])) case v.endpoints == 1:
add("private", "1 endpoint")
case v.endpoints > 1:
add("private", fmt.Sprintf("%d endpoints", v.endpoints))
} }
keys := make([]string, 0, len(v.tags)) for i, k := range slices.Sorted(maps.Keys(v.tags)) {
for k := range v.tags {
keys = append(keys, k)
}
slices.Sort(keys)
for i, k := range keys {
label := "" label := ""
if i == 0 { if i == 0 {
label = "tags" label = "tags"
+14 -11
View File
@@ -203,17 +203,20 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
return out, nil return out, nil
} }
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls // fallbackVault is the backing vault of an AKV-backed scope, to read it
// back to the scope's Detail, so a scope remembered from an earlier session // from when Databricks fails, or "" when there's none to fall back to. It
// (a favourite) works before its workspace's scopes are listed. // falls back to the scope's Detail, so a scope remembered from an earlier
func (p *Provider) vaultURL(c provider.Container) string { // 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 ""
}
p.mu.Lock() p.mu.Lock()
url := p.vaults[c.ID] url := p.vaults[c.ID]
p.mu.Unlock() p.mu.Unlock()
if url == "" { if url == "" {
url, _ = strings.CutPrefix(c.Detail, akvDetail) if detail, ok := strings.CutPrefix(c.Detail, akvDetail); ok {
if url == c.Detail { url = detail
url = ""
} }
} }
return url return url
@@ -264,8 +267,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. // 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) { func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.vaultURL(c) url := p.fallbackVault(ctx, c)
if url == "" || p.keyVault == nil || ctx.Err() != nil { if url == "" {
return nil, errNoFallback return nil, errNoFallback
} }
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret) secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
@@ -302,8 +305,8 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
} }
dbxErr := classify(pr, err) dbxErr := classify(pr, err)
url := p.vaultURL(o.Container) url := p.fallbackVault(ctx, o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil { if url == "" {
return provider.SecretValue{}, dbxErr return provider.SecretValue{}, dbxErr
} }
v, kvErr := p.keyVault.Value(ctx, provider.Object{ v, kvErr := p.keyVault.Value(ctx, provider.Object{
+69 -46
View File
@@ -48,19 +48,8 @@ func newProvider(kind provider.Kind, delay time.Duration) *Provider {
func (p *Provider) Kind() provider.Kind { return p.kind } func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string { func (p *Provider) ID() string { return cmp.Or(p.id, string(p.kind)) }
if p.id != "" { func (p *Provider) Label() string { return cmp.Or(p.label, string(p.kind)) }
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. // As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider { func (p *Provider) As(id, label string) *Provider {
@@ -134,13 +123,14 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue([]byte(v)), nil return provider.NewSecretValue([]byte(v)), nil
} }
// Locate finds the source holding the vault (by host) or the workspace. // Locate finds the source holding the vault (by host), the workspace or
// the repo (by its remote).
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) { func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
if err := p.wait(ctx); err != nil { if err := p.wait(ctx); err != nil {
return "", false, err return "", false, err
} }
for _, src := range p.sources { for _, src := range p.sources {
if p.kind == provider.KindDatabricks && src.Detail == host { if (p.kind == provider.KindDatabricks || p.kind == provider.KindRepo) && strings.EqualFold(src.Detail, host) {
return src.ID, true, nil return src.ID, true, nil
} }
for _, c := range p.containers[src.ID] { for _, c := range p.containers[src.ID] {
@@ -217,14 +207,25 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types
return c 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 { func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c o.Container = c
o.Type = t o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok { if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + map[provider.ObjectType]string{ o.URL = base + "/" + t.Collection()
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
} }
k := listKey{c.ID, t} k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o) p.objects[k] = append(p.objects[k], o)
@@ -238,11 +239,7 @@ func (p *Provider) fail(c provider.Container, err error, types ...provider.Objec
} }
} }
var ( var base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
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. // ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets") var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
@@ -257,12 +254,13 @@ const (
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} 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. // Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider { func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) 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 { objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name) return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
} }
@@ -270,28 +268,32 @@ func Azure(delay time.Duration) *Provider {
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com") 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...) kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{ dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base, Name: "db-password", Version: "3f9c2a", Enabled: new(true), Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain", Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"), Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple") }, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{ p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"}, {Version: "3f9c2a", Enabled: new(true), 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: "91d3e0", Enabled: new(true), Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)}, {Version: "0a77c4", Enabled: new(false), Updated: base.AddDate(-1, 0, 0)},
} }
p.values[dbPassword.ID+"@91d3e0"] = "previous-password" p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password" p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "api-key", Version: "a17b04", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"), Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000") }, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0), Name: "legacy-token", Version: "0c11de", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Reference: secretRef("kv-app-prod", "legacy-token"), Expires: base.AddDate(0, -3, 0), Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated") }, "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{ p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0), Name: "signing-key", Version: "5e21b7", Enabled: new(true), Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"}, {Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"}, {Label: "ops", Value: "sign, verify"},
@@ -299,7 +301,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "keys", "signing-key"), Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{ p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0), Name: "data-encryption", Version: "c40f19", Enabled: new(true), Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"}, {Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"}, {Label: "ops", Value: "wrapKey, unwrapKey"},
@@ -308,7 +310,7 @@ func Azure(delay time.Duration) *Provider {
}, demoPublicKey) }, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Name: "app-tls", Version: "77e0aa", Enabled: new(true), Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file", Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"}, {Label: "subject", Value: "CN=app.example.com"},
@@ -320,7 +322,7 @@ func Azure(delay time.Duration) *Provider {
Reference: objectURL("kv-app-prod", "certificates", "app-tls"), Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert) }, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{ p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0), Name: "old-tls", Version: "19ab3c", Enabled: new(true), Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12", Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{ Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"}, {Label: "subject", Value: "CN=old.example.com"},
@@ -331,8 +333,12 @@ func Azure(delay time.Duration) *Provider {
}, demoCert) }, demoCert)
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) 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{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), 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"),
}, "smtp-demo-password") }, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey) p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
@@ -342,8 +348,21 @@ func Azure(delay time.Duration) *Provider {
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com") 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...) kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{ p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"), Name: "db-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-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 return p
} }
@@ -353,23 +372,21 @@ func Azure(delay time.Duration) *Provider {
// its own, like an admin account next to a personal one. // its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider { func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay) 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") prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...) breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{ p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0), 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"), Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password") }, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...) shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{ p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"), Name: "smtp-password", Enabled: new(true), Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password") }, "smtp-demo-password")
// Unlike the first account, ops may read keys here. // Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{ p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0), Name: "backup-encryption", Version: "e9a102", Enabled: new(true), Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}}, 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", Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey) }, demoPublicKey)
@@ -377,8 +394,14 @@ func AzureOps(delay time.Duration) *Provider {
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com") 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...) kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{ p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"), Name: "pagerduty-token", Enabled: new(true), Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-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 return p
} }
+115 -4
View File
@@ -2,6 +2,7 @@ package fake
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -18,8 +19,9 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
return attrs, ctx.Err() return attrs, ctx.Err()
} }
// Repos returns a fake repo provider: three git repos with SOPS and plain // Repos returns a fake repo provider: four git repos with SOPS, plain
// .env files, the first one the repo lazysecrets was started in. // .env, Spring and compose files, the first one the repo lazysecrets was
// started in.
func Repos() *Provider { func Repos() *Provider {
p := newProvider(provider.KindRepo, 0) p := newProvider(provider.KindRepo, 0)
p.id, p.label = "Repos", "Repos" p.id, p.label = "Repos", "Repos"
@@ -35,7 +37,7 @@ func Repos() *Provider {
// commit are what git says of it: "tracked", "ignored" or "untracked". // commit are what git says of it: "tracked", "ignored" or "untracked".
file := func(src provider.Source, path, method, git, commit string, kv ...string) { file := func(src provider.Source, path, method, git, commit string, kv ...string) {
sops := method != "" sops := method != ""
detail := "env · plain text" detail := provider.DetailPlainEnv
if sops { if sops {
detail = "sops · " + method detail = "sops · " + method
} }
@@ -94,7 +96,7 @@ func Repos() *Provider {
"database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app", "database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app",
"stripe.secret_key", "sk_test_demo_51NxDemoKey0000") "stripe.secret_key", "sk_test_demo_51NxDemoKey0000")
file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago", file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago",
"github.client_secret", "ghs_demo0000000000000000000000", "github.client_secret", "8f3c2a9d7e1b6f4a0c5d2e9b8a7f6c3d1e0b4a5f",
"google.client_secret", "GOCSPX-demo-000000000000") "google.client_secret", "GOCSPX-demo-000000000000")
file(api, ".env", "", "ignored", "", file(api, ".env", "", "ignored", "",
"DATABASE_URL", "postgres://app:local@localhost:5432/app", "DATABASE_URL", "postgres://app:local@localhost:5432/app",
@@ -113,6 +115,90 @@ func Repos() *Provider {
"AZURE_CLIENT_SECRET", "azure-demo-client-secret", "AZURE_CLIENT_SECRET", "azure-demo-client-secret",
"ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000") "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) web := repo("web-frontend", false)
file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago", file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago",
"SENTRY_AUTH_TOKEN", "sntrys_demo_token", "SENTRY_AUTH_TOKEN", "sntrys_demo_token",
@@ -134,3 +220,28 @@ func format(path string) string {
} }
return "dotenv" 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
}
}
}
+65
View File
@@ -0,0 +1,65 @@
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)
}
}
}
+38
View File
@@ -73,6 +73,10 @@ type Source struct {
// Here marks the source lazysecrets was started in (the current git // Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled. // repo): listed first, starred or not, and labelled.
Here bool 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. // ObjectType is the kind of object stored in a container.
@@ -84,6 +88,20 @@ const (
TypeCertificate ObjectType = "Certificate" 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. // Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct { type Container struct {
Source Source Source Source
@@ -96,6 +114,7 @@ type Container struct {
URL string // web page for the container, if any URL string // web page for the container, if any
// Attrs describe it further, from the listing (a vault's settings). // Attrs describe it further, from the listing (a vault's settings).
Attrs []Attr `json:",omitempty"` Attrs []Attr `json:",omitempty"`
Hidden bool `json:",omitempty"` // see Source.Hidden
} }
// ObjectTypes returns the container's object types, defaulting to secrets. // ObjectTypes returns the container's object types, defaulting to secrets.
@@ -119,6 +138,8 @@ type Object struct {
Type ObjectType Type ObjectType
ID string ID string
Name 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 Version string
Enabled *bool Enabled *bool
Updated time.Time Updated time.Time
@@ -167,6 +188,13 @@ type Current interface {
Current(ctx context.Context) (Source, bool) 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, // 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 // 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. // workspace host. It returns the ID of the source holding it.
@@ -182,6 +210,12 @@ type Files interface {
File(c Container, o *Object) (File, error) 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. // File is a container's file, to open.
type File struct { type File struct {
Path string Path string
@@ -220,6 +254,10 @@ func (d *Denied) Error() string {
func (d *Denied) Unwrap() error { return d.Err } 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 // ContainerDetails is implemented by providers that can say more about a
// container than its listing does: how a file is encrypted and what git // container than its listing does: how a file is encrypted and what git
// knows of it. ErrUnsupported means there's nothing more for c. // knows of it. ErrUnsupported means there's nothing more for c.
+141
View File
@@ -0,0 +1,141 @@
package repo
import (
"path"
"regexp"
"strings"
"go.yaml.in/yaml/v3"
)
// Docker Compose files: compose.yaml, docker-compose.yml and their
// variants (docker-compose.test.yml). Their keys are the services'
// environment variables, named service.VAR.
var composeType = configType{
name: "compose", is: IsCompose, format: func(string) string { return "yaml" }, parse: parseCompose,
jasyptSetting: func(name string) string {
service, _, _ := strings.Cut(name, ".")
return service + "." + jasyptEnv
},
placeholder: composePlaceholder,
}
var composeName = regexp.MustCompile(`^(docker-)?compose([.-][^/]+)?\.ya?ml$`)
// IsCompose reports whether f is named like a Docker Compose file.
func IsCompose(f string) bool {
return composeName.MatchString(strings.ToLower(path.Base(f)))
}
// composePlaceholderRe is compose's ${VAR}, ${VAR:-default}, ${VAR-default},
// ${VAR:?error}, ${VAR?error} or $VAR.
var composePlaceholderRe = regexp.MustCompile(`^\$(?:\{(\w+)(?:(:?-)([^}]*)|:?\?[^}]*)?\}|(\w+))$`)
func composePlaceholder(v string) (name, def string, hasDef, ok bool) {
m := composePlaceholderRe.FindStringSubmatch(v)
if m == nil {
return "", "", false, false
}
return m[1] + m[4], m[3], m[2] != "", true
}
func parseCompose(_ string, b []byte) ([]configKey, error) {
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
if len(doc.Content) == 0 {
return nil, nil
}
var out []configKey
for _, service := range pairs(field(doc.Content[0], "services")) {
for _, k := range environment(field(service.value, "environment")) {
k.name = service.key.Value + "." + k.key
out = append(out, k)
}
}
return out, nil
}
// environment reads a service's environment, a map (KEY: value) or a list
// (- KEY=value). A variable without a value (KEY:, - KEY) is taken from
// where compose runs: a reference.
func environment(n *yaml.Node) []configKey {
var out []configKey
switch n = deref(n); {
case n == nil:
case n.Kind == yaml.MappingNode:
for _, p := range pairs(n) {
v := deref(p.value)
ref := v.Tag == "!!null" || reference(composePlaceholder, v.Value)
out = append(out, configKey{key: p.key.Value, value: v.Value, ref: ref, line: p.key.Line})
}
case n.Kind == yaml.SequenceNode:
for _, item := range n.Content {
item = deref(item)
k, v, ok := strings.Cut(item.Value, "=")
out = append(out, configKey{key: k, value: v, ref: !ok || reference(composePlaceholder, v), line: item.Line})
}
}
return out
}
type pair struct{ key, value *yaml.Node }
// pairs are a mapping's entries, with those merged in by << (anchors
// shared between services) first and its own over them.
func pairs(n *yaml.Node) []pair {
n = deref(n)
if n == nil || n.Kind != yaml.MappingNode {
return nil
}
var merged, own []pair
for i := 0; i+1 < len(n.Content); i += 2 {
k, v := n.Content[i], n.Content[i+1]
if k.Value != "<<" {
own = append(own, pair{k, v})
continue
}
from := []*yaml.Node{v}
if v.Kind == yaml.SequenceNode {
from = v.Content
}
for _, m := range from {
merged = append(merged, pairs(m)...)
}
}
var out []pair
for _, p := range append(merged, own...) {
if i := indexOf(out, p.key.Value); i >= 0 {
out[i] = p
} else {
out = append(out, p)
}
}
return out
}
func indexOf(ps []pair, key string) int {
for i, p := range ps {
if p.key.Value == key {
return i
}
}
return -1
}
func field(n *yaml.Node, key string) *yaml.Node {
ps := pairs(n)
if i := indexOf(ps, key); i >= 0 {
return ps[i].value
}
return nil
}
func deref(n *yaml.Node) *yaml.Node {
for n != nil && n.Kind == yaml.AliasNode {
n = n.Alias
}
return n
}
+140
View File
@@ -0,0 +1,140 @@
package repo
import (
"context"
"maps"
"os"
"path/filepath"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsCompose(t *testing.T) {
for f, want := range map[string]bool{
"docker-compose.yml": true,
"deploy/compose.yaml": true,
"docker-compose.test.yml": true,
"docker-compose-dev.yaml": true,
"docker-compose.example.yml": true, // listed hidden
"applications.yaml": false,
"composer.json": false,
"compose.yml.bak": false,
} {
if IsCompose(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func TestComposePlaceholder(t *testing.T) {
for v, want := range map[string]bool{
"${DB_PASSWORD}": true, "$DB_PASSWORD": true, "${DB_PASSWORD:?set it}": true, "${DB_PASSWORD?}": true,
"${DB_PASSWORD:-hunter2}": false, "${DB_PASSWORD-hunter2}": false, // the default is in the file
"hunter2": false, "pre-${DB_PASSWORD}": false, "$": false,
} {
if got := reference(composePlaceholder, v); got != want {
t.Errorf("%s: reference %t", v, got)
}
}
if name, def, hasDef, _ := composePlaceholder("${PW:-x}"); name != "PW" || def != "x" || !hasDef {
t.Errorf("default: %s %s %t", name, def, hasDef)
}
}
func TestParseCompose(t *testing.T) {
keys, err := parseCompose("", []byte(`x-db: &db
DB_USER: app
DB_PASSWORD: shared
services:
api:
image: api
environment:
<<: *db
DB_PASSWORD: own
API_KEY: ${API_KEY}
FROM_HOST:
worker:
environment:
- QUEUE_PASSWORD=hunter2
- TOKEN
- EMPTY=
web:
image: web
`))
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, k := range keys {
ref := ""
if k.ref {
ref = " (ref)"
}
got[k.name] = k.value + ref
}
want := map[string]string{
"api.DB_USER": "app", "api.DB_PASSWORD": "own", "api.API_KEY": "${API_KEY} (ref)", "api.FROM_HOST": " (ref)",
"worker.QUEUE_PASSWORD": "hunter2", "worker.TOKEN": " (ref)", "worker.EMPTY": "",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
for _, k := range keys {
if k.name == "worker.QUEUE_PASSWORD" && (k.line != 14 || k.key != "QUEUE_PASSWORD") {
t.Errorf("%s: line %d, key %s", k.name, k.line, k.key)
}
}
}
func TestComposeFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"docker-compose.yml": `services:
api:
environment:
PORT: "8080"
DB_PASSWORD: ${DB_PASSWORD}
JASYPT_ENCRYPTOR_PASSWORD: test-password
API_TOKEN: ` + jasyptHunter2 + `
`,
"docker-compose.example.yml": "services:\n api:\n environment:\n DB_PASSWORD: changeme\n",
})
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil || len(cs) != 2 {
t.Fatalf("files: %+v %v", cs, err)
}
for _, c := range cs {
if c.Detail != "compose · yaml" || c.Hidden != (c.Name == "docker-compose.example.yml") {
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
}
}
compose := cs[1] // after docker-compose.example.yml
keys, _ := p.Objects(ctx, compose, provider.TypeSecret)
got := map[string]string{}
for _, k := range shown(keys) {
got[k.Name] = k.Detail
}
if want := map[string]string{"api.DB_PASSWORD": "reference", "api.JASYPT_ENCRYPTOR_PASSWORD": "", "api.API_TOKEN": "jasypt"}; !maps.Equal(got, want) {
t.Errorf("keys %v, want %v", got, want)
}
// The service's JASYPT_ENCRYPTOR_PASSWORD decrypts its values.
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "docker-compose.yml"}, Name: "api.API_TOKEN"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "hunter2" {
t.Errorf("jasypt: %q %v", v.Reveal(), err)
}
if f, _ := p.File(compose, &link); f.Line != 7 {
t.Errorf("line %d", f.Line)
}
// compose.secretPatterns over the shared ones.
os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte("secretPatterns:\n keys: [\"^nothing$\"]\ncompose:\n secretPatterns:\n keys: [\"^PORT$\"]\n values: []\n"), 0o644)
keys, _ = p.Objects(ctx, compose, provider.TypeSecret)
if s := shown(keys); len(s) != 1 || s[0].Name != "api.PORT" {
t.Errorf("compose's patterns: %v", s)
}
}
+170
View File
@@ -0,0 +1,170 @@
package repo
import (
"fmt"
"os"
"path/filepath"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Config files (Spring's, compose's) are read directly, like env files,
// but most of their keys aren't secret: those the secret patterns
// (patterns.go) don't match are hidden, and a file with none of them is.
// configType is a type of config file: what it's called (in settings, as
// spring:, and on the file's row) and how its keys are read.
type configType struct {
name string
is func(file string) bool
format func(file string) string // yaml, properties
parse func(file string, b []byte) ([]configKey, error)
// jasyptSetting is the key that holds the Jasypt password for the
// key named name.
jasyptSetting func(name string) string
// placeholder reads a value that only refers to another,
// ${DB_PASSWORD}: what it names, and its default if it has one.
placeholder func(v string) (name, def string, hasDef, ok bool)
}
var configTypes = []configType{springType, composeType}
func isConfig(f string) bool {
_, ok := configTypeOf(f)
return ok
}
// configTypeOf is the type of config file f is, if it's one.
func configTypeOf(f string) (configType, bool) {
for _, t := range configTypes {
if t.is(f) {
return t, true
}
}
return configType{}, false
}
// detail is the file's row: "spring · yaml".
func (t configType) detail(file string) string { return t.name + " · " + t.format(file) }
// configKey is a key of a config file, with its value.
type configKey struct {
key string // as the app sees it: spring.datasource.password, DB_PASSWORD
name string // one key in the file: spring.datasource.password (prod), api.DB_PASSWORD
value string
ref bool // the value only refers to the secret, set elsewhere: ${DB_PASSWORD}
line int // 1-based
}
func (k configKey) jasypt() bool { return jasypt.Is(k.value) }
// plain reports whether the file holds the value itself, readable.
func (k configKey) plain() bool { return k.value != "" && !k.ref && !k.jasypt() }
// reference reports whether v is only a placeholder without a default:
// one with a default has the default in the file.
func reference(placeholder func(string) (string, string, bool, bool), v string) bool {
_, _, hasDef, ok := placeholder(v)
return ok && !hasDef
}
func (t configType) read(file string) ([]configKey, error) {
b, err := os.ReadFile(file)
if err != nil {
return nil, err
}
return t.parse(file, b)
}
func (t configType) keyNamed(file, name string) (configKey, error) {
keys, err := t.read(file)
if err != nil {
return configKey{}, err
}
for _, k := range keys {
if k.name == name {
return k, nil
}
}
return configKey{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
func (t configType) value(file, name string) (provider.SecretValue, error) {
k, err := t.keyNamed(file, name)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(k.value)), nil
}
// configHidden reports whether a config file has no secret keys. One that
// can't be read isn't hidden: its error is worth seeing.
func (p *Provider) configHidden(t configType, root, file string) bool {
keys, err := t.read(file)
if err != nil {
return false
}
filter, err := p.filterFor(root, t.name)
if err != nil {
return false
}
return !slices.ContainsFunc(keys, func(k configKey) bool { return filter.secret(k.key, k.value) })
}
// configObjects lists the file's keys, those that don't look like secrets
// hidden.
func (p *Provider) configObjects(t configType, c provider.Container, b []byte) ([]provider.Object, error) {
keys, err := t.parse(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
filter, err := p.filterFor(c.Source.ID, t.name)
if err != nil {
return nil, err
}
format := provider.Attr{Label: "format", Value: t.name + " " + t.format(c.ID)}
var out []provider.Object
for _, k := range keys {
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
switch {
case k.ref:
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
case k.jasypt():
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
}
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
Hidden: !filter.secret(k.key, k.value), Attrs: []provider.Attr{format, state},
})
}
return out, nil
}
// configDetails says how many of the file's keys aren't listed, and
// whether any listed one is in plain text.
func (p *Provider) configDetails(t configType, c provider.Container) (attrs []provider.Attr, plain bool) {
keys, err := t.read(c.ID)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
filter, err := p.filterFor(c.Source.ID, t.name)
if err != nil {
return []provider.Attr{{Label: "keys", Value: err.Error(), Warn: true}}, false
}
secrets, jasypt := 0, 0
for _, k := range keys {
if filter.secret(k.key, k.value) {
secrets++
plain = plain || k.plain()
}
if k.jasypt() {
jasypt++
}
}
return []provider.Attr{
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
{Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
}, plain
}
+36 -4
View File
@@ -2,6 +2,8 @@ package repo
import ( import (
"context" "context"
"fmt"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -22,7 +24,12 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
var out []provider.Attr var out []provider.Attr
meta, isSops := readMeta(c.ID) meta, isSops := readMeta(c.ID)
if isSops { // A plain file's values are at risk in git, unless they're all Jasypt;
// a config file's only when one of its secrets is in plain text.
plain := !isSops
if t, ok := configTypeOf(c.ID); ok && !isSops {
out, plain = p.configDetails(t, c)
} else if isSops {
out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")}) out = append(out, provider.Attr{Label: "encryption", Value: "sops · " + strings.Join(meta.methods, ", ")})
for i, k := range meta.recipients { for i, k := range meta.recipients {
label := "" label := ""
@@ -35,7 +42,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")}) out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
} }
} else { } else {
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"}) out, plain = envDetails(c.ID)
} }
git := func(args ...string) (string, bool) { git := func(args ...string) (string, bool) {
@@ -43,7 +50,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
return strings.TrimSpace(string(b)), err == nil return strings.TrimSpace(string(b)), err == nil
} }
switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); { switch _, tracked := git("ls-files", "--error-unmatch", "--", rel); {
case tracked && !isSops: case tracked && plain:
out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true}) out = append(out, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true})
case tracked: case tracked:
out = append(out, provider.Attr{Label: "git", Value: "tracked"}) out = append(out, provider.Attr{Label: "git", Value: "tracked"})
@@ -51,7 +58,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
if _, ignored := git("check-ignore", "-q", "--", rel); ignored { if _, ignored := git("check-ignore", "-q", "--", rel); ignored {
out = append(out, provider.Attr{Label: "git", Value: "ignored"}) out = append(out, provider.Attr{Label: "git", Value: "ignored"})
} else { } else {
out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !isSops}) out = append(out, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: plain})
} }
return out, nil return out, nil
} }
@@ -60,3 +67,28 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
} }
return out, nil 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)
}
+66
View File
@@ -0,0 +1,66 @@
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 {
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)
}
+102
View File
@@ -0,0 +1,102 @@
package repo
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// hunter2, encrypted by Jasypt with the password "test-password".
const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)"
func TestJasyptPassword(t *testing.T) {
dir := t.TempDir()
value := func(name, body string) (string, error) {
file := filepath.Join(dir, name)
if err := os.WriteFile(file, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
key := "app.token"
if !IsSpring(name) {
key = "TOKEN"
}
v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key})
return v.Reveal(), err
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
t.Setenv("MY_PASSWORD", "")
spring := func(password string) string {
return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n"
}
for _, c := range []struct{ name, file, body, env, want string }{
{"the file's own", "application.yml", spring("test-password"), "", "hunter2"},
{"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"},
{"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"},
{"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"},
{"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"},
{"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"},
} {
t.Setenv("MY_PASSWORD", c.env)
if got, err := value(c.file, c.body); err != nil || got != c.want {
t.Errorf("%s: %q %v", c.name, got, err)
}
}
// $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring.
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" {
t.Errorf("environment first: %q %v", got, err)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong")
if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") {
t.Errorf("a wrong one says where it's from: %v", err)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
t.Setenv("MY_PASSWORD", "")
for body, want := range map[string]string{
spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set",
"app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml",
} {
if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("no password: %v, want %q", err, want)
}
}
}
func TestJasyptEnvFile(t *testing.T) {
root := gitRepo(t, map[string]string{
"jasypt.env": "TOKEN=" + jasyptHunter2 + "\n",
"mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n",
})
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, _ := p.Containers(ctx, srcs[0])
for _, c := range cs {
attrs, _ := p.Details(ctx, c)
got := map[string]provider.Attr{}
for _, a := range attrs {
got[a.Label] = a
}
switch c.Name {
case "jasypt.env":
if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn {
t.Errorf("all jasypt: %+v", got)
}
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" {
t.Errorf("keys: %+v", keys)
}
case "mixed.env":
if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn {
t.Errorf("mixed: %+v", got)
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
package repo
import (
"errors"
"fmt"
"path/filepath"
"regexp"
)
// Config files (Spring's) hold all of an app's config, most of it not
// secret: of their keys, those named like a secret or with a value that
// looks like one are listed, the rest hidden (H shows them). The patterns
// can be set for all of a repo's files and per file type, in the config
// and in a repo's .lazysecrets.yaml.
// DefaultSecretKeys and DefaultSecretValues are the patterns used when
// nothing sets them. The key pattern is anchored at the end of the name:
// spring.datasource.password, client-secret, DB_PASSWORD and checkoutKey,
// not the oauth2.*, keycloak.* and token-uri keys every Spring app has.
// passord is Norwegian. They err on listing too much: a row too many is
// cheap, a secret missed isn't.
var (
DefaultSecretKeys = []string{
`(?i)(pass(word|wd|phrase|ord)?|pwd|secret|token|key|credentials?|salt|connection-?string)$`,
}
DefaultSecretValues = []string{
`^ENC\(.+\)$`, // Jasypt
`://[^/\s:@]*:[^/\s@]+@`, // a URL with a password: jdbc:postgresql://user:pass@host
`(?i)(AccountKey|SharedAccessKey|Password|Pwd)=`, // a connection string with one
}
)
// SecretPatterns are regexps matched against a key's name and its value:
// a key is a secret when either matches. A list that isn't set is taken
// from the next place that sets it (see filterFor).
type SecretPatterns struct {
Keys *[]string `yaml:"keys"`
Values *[]string `yaml:"values"`
}
// FileType holds the settings of one type of file (spring:), for now its
// own secret patterns.
type FileType struct {
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
}
// Check says which pattern doesn't compile; field is where they're set
// (spring.secretPatterns), for the message.
func (p SecretPatterns) Check(field string) error {
var errs []error
for name, list := range map[string]*[]string{"keys": p.Keys, "values": p.Values} {
if list == nil {
continue
}
for i, s := range *list {
if _, err := regexp.Compile(s); err != nil {
errs = append(errs, fmt.Errorf("%s.%s[%d]: %w", field, name, i, err))
}
}
}
return errors.Join(errs...)
}
type secretFilter struct{ keys, values []*regexp.Regexp }
func (f secretFilter) secret(key, value string) bool {
return anyMatch(f.keys, key) || anyMatch(f.values, value)
}
// filterFor is the patterns for a repo's files of one type (spring), each
// list from the first that sets it: the repo's .lazysecrets.yaml for the
// type, then for all its files, the config for the type, then for all
// repos, then the defaults.
func (p *Provider) filterFor(root, fileType string) (secretFilter, error) {
f, err := readLazysecretsFile(root)
if err != nil {
return secretFilter{}, err
}
own := filepath.Join(root, ".lazysecrets.yaml") + ": "
type source struct {
field string
patterns SecretPatterns
}
sources := []source{
{own + fileType + ".secretPatterns", f.types()[fileType].SecretPatterns},
{own + "secretPatterns", f.SecretPatterns},
{"providers.repos." + fileType + ".secretPatterns", p.s.Types[fileType].SecretPatterns},
{"providers.repos.secretPatterns", p.s.SecretPatterns},
}
pick := func(name string, list func(SecretPatterns) *[]string, defaults []string) ([]*regexp.Regexp, error) {
for _, s := range sources {
if l := list(s.patterns); l != nil {
return compileAll(*l, s.field+"."+name)
}
}
return compileAll(defaults, "default "+name)
}
keys, kerr := pick("keys", func(p SecretPatterns) *[]string { return p.Keys }, DefaultSecretKeys)
values, verr := pick("values", func(p SecretPatterns) *[]string { return p.Values }, DefaultSecretValues)
return secretFilter{keys, values}, errors.Join(kerr, verr)
}
func compileAll(patterns []string, field string) ([]*regexp.Regexp, error) {
out := make([]*regexp.Regexp, 0, len(patterns))
for i, s := range patterns {
re, err := regexp.Compile(s)
if err != nil {
return nil, fmt.Errorf("%s[%d]: %w", field, i, err)
}
out = append(out, re)
}
return out, nil
}
+49
View File
@@ -0,0 +1,49 @@
package repo
import (
"os"
"path/filepath"
"testing"
)
// Each list comes from the first that sets it: the repo's spring, the
// repo's, the config's spring, the config's, the defaults.
func TestSecretPatternsOrder(t *testing.T) {
list := func(s ...string) *[]string { return &s }
root := t.TempDir()
write := func(body string) {
if err := os.WriteFile(filepath.Join(root, ".lazysecrets.yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
p := New(Settings{
SecretPatterns: SecretPatterns{Keys: list("^config$"), Values: list("^config$")},
Types: map[string]FileType{"spring": {SecretPatterns: SecretPatterns{Keys: list("^config-spring$")}}},
})
secret := func(key, value string) bool {
t.Helper()
f, err := p.filterFor(root, "spring")
if err != nil {
t.Fatal(err)
}
return f.secret(key, value)
}
if !secret("config-spring", "") || secret("config", "") || !secret("", "config") {
t.Error("the config's spring keys, its shared values")
}
write("secretPatterns:\n keys: [\"^repo$\"]\n")
if !secret("repo", "") || secret("config-spring", "") || !secret("", "config") {
t.Error("the repo's shared keys over the config's")
}
write("secretPatterns:\n keys: [\"^repo$\"]\nspring:\n secretPatterns:\n keys: [\"^repo-spring$\"]\n values: []\n")
if !secret("repo-spring", "") || secret("repo", "") || secret("", "config") {
t.Error("the repo's spring over everything; an empty list matches nothing")
}
p = New(Settings{})
write("")
if !secret("spring.datasource.password", "") || secret("server.port", "") {
t.Error("the defaults")
}
}
+208 -74
View File
@@ -1,6 +1,7 @@
// Package repo browses the encrypted secret files in git repositories: // Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and // a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. SOPS files are the one type so far. // their keys the secrets. Secret files are SOPS files, plain env files and
// config files: Spring Boot's and Docker Compose's (configfile.go).
// //
// Listing never decrypts: SOPS encrypts values only, so a file's keys and // Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or // metadata are read directly. A value is decrypted when it's revealed or
@@ -31,6 +32,7 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -49,6 +51,11 @@ type Settings struct {
// Include and Exclude are globs relative to a repo's root (** matches // Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml. // any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string Include, Exclude []string
// 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 is the sops binary; "sops" (on PATH) when empty.
Sops string Sops string
} }
@@ -80,6 +87,10 @@ type repoInfo struct {
here bool // the repo lazysecrets started in 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 { func New(s Settings) *Provider {
if s.Sops == "" { if s.Sops == "" {
s.Sops = "sops" s.Sops = "sops"
@@ -102,8 +113,6 @@ func (p *Provider) load() {
roots = append(roots, root) 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() here, inRepo := p.current()
if inRepo { if inRepo {
seen[here.root] = true seen[here.root] = true
@@ -116,16 +125,23 @@ func (p *Provider) load() {
} }
add(root) 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 { for _, dir := range p.s.Roots {
base, _ := filepath.EvalSymlinks(expandHome(dir)) base, err := filepath.EvalSymlinks(expandHome(dir))
for _, root := range reposIn(expandHome(dir), 2) { if err != nil || scanned[base] {
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") { 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] == "" {
names[root] = filepath.ToSlash(name) names[root] = filepath.ToSlash(name)
} }
add(root) add(root)
} }
} }
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred { for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil { if root, err := gitRoot(dir); err == nil {
add(root) add(root)
@@ -176,12 +192,19 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
return provider.Source{}, false return provider.Source{}, false
} }
src := p.source(r) src := p.source(r)
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 { files, err := p.Containers(ctx, src)
if err == nil && len(files) == 0 {
return provider.Source{}, false return provider.Source{}, false
} }
src.Hidden = err == nil && allHidden(files)
return src, true 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 { func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{ return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(), Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
@@ -194,13 +217,11 @@ func parallel(n int, f func(i int)) {
var wg sync.WaitGroup var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU())) sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n { for i := range n {
wg.Add(1) wg.Go(func() {
go func() {
defer wg.Done()
sem <- struct{}{} sem <- struct{}{}
defer func() { <-sem }() defer func() { <-sem }()
f(i) f(i)
}() })
} }
wg.Wait() wg.Wait()
} }
@@ -252,6 +273,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
parallel(len(srcs), func(i int) { parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i]) files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0 keep[i] = err != nil || len(files) > 0
srcs[i].Hidden = err == nil && allHidden(files)
}) })
var out []provider.Source var out []provider.Source
for i, src := range srcs { for i, src := range srcs {
@@ -311,8 +333,13 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
c.Detail = "sops · " + strings.Join(meta.methods, ", ") c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops: case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go return // a SOPS file where .sops.yaml says SOPS files don't go
case isEnv(f): case isEnvNamed(f):
c.Detail = "env · plain text" c.Detail = provider.DetailPlainEnv
c.Hidden = isExample(f)
case isConfig(f):
t, _ := configTypeOf(f)
c.Detail = t.detail(f)
c.Hidden = isExample(f) || p.configHidden(t, r.root, path)
default: default:
return return
} }
@@ -349,30 +376,29 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
if err != nil { if err != nil {
return nil, err return nil, err
} }
meta, isSops := readMeta(c.ID) meta, isSops := parseMeta(c.ID, b)
if t, ok := configTypeOf(c.ID); ok && !isSops {
return p.configObjects(t, c, b)
}
keys, err := parseKeys(c.ID, b) keys, err := parseKeys(c.ID, b)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err) return nil, fmt.Errorf("%s: %w", c.Name, err)
} }
if !isSops { // a plain env file attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}}
out := make([]provider.Object, 0, len(keys)) if isSops {
for _, k := range keys { attrs = []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}}
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)) out := make([]provider.Object, 0, len(keys))
for _, k := range keys { for _, k := range keys {
o := provider.Object{ o := provider.Object{
Container: c, Type: provider.TypeSecret, Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
} }
if !k.encrypted { switch {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"}) case isSops && !k.encrypted:
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
case !isSops && k.jasypt:
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
} }
out = append(out, o) out = append(out, o)
} }
@@ -387,17 +413,29 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
return nil, provider.ErrUnsupported return nil, provider.ErrUnsupported
} }
// Value decrypts one key with sops. // Value reads one key: from a plain file, decrypting a Jasypt value; from
// a SOPS file, with sops.
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00") file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key if !ok { // made from a link: the repo's root, the file's path, the key
file, extract = o.Container.ID, extractFor(o.Name) file = filePath(o.Container)
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
} }
if _, isSops := readMeta(file); !isSops { if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name) read := plainValue
if t, ok := configTypeOf(file); ok {
read = t.value
}
v, err := read(file, o.Name)
if err != nil {
return v, err
}
return jasyptValue(file, o.Name, v)
}
if !ok {
var err error
if extract, err = extractOf(file, o.Name); err != nil {
return provider.SecretValue{}, err
}
} }
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file) cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
@@ -412,12 +450,27 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
return provider.NewSecretValue(stdout.Bytes()), nil 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. // 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) { func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
path := c.ID path := filePath(c)
if path == "" { // made from a link
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
}
f := provider.File{Path: path} f := provider.File{Path: path}
if _, isSops := readMeta(path); isSops { if _, isSops := readMeta(path); isSops {
f.Sops = p.s.Sops f.Sops = p.s.Sops
@@ -425,6 +478,12 @@ func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File
if o == nil { if o == nil {
return f, nil return f, nil
} }
if 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) b, err := os.ReadFile(path)
if err != nil { if err != nil {
return f, err return f, err
@@ -464,6 +523,16 @@ func (p *Provider) Locate(_ context.Context, host string) (string, bool, error)
return "", false, nil 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 --- // --- git ---
func gitRoot(dir string) (string, error) { func gitRoot(dir string) (string, error) {
@@ -568,22 +637,55 @@ func normalizeRemote(remote string) (string, string) {
type lazysecretsFile struct { type lazysecretsFile struct {
Include []string `yaml:"include"` Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"` Exclude []string `yaml:"exclude"`
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
Spring FileType `yaml:"spring"`
Compose FileType `yaml:"compose"`
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}
}
// 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
} }
// selector says which files to look at: user (the include/exclude of the // 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 // 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. // (.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) { func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
include, exclude := p.s.Include, p.s.Exclude f, err := readLazysecretsFile(root)
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil { if err != nil {
var f lazysecretsFile return nil, nil, err
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
} }
include, exclude := p.s.Include, p.s.Exclude
if len(f.Include) > 0 || len(f.Exclude) > 0 { if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude include, exclude = f.Include, f.Exclude
} }
}
inc, err := globs(include) inc, err := globs(include)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -618,7 +720,7 @@ func envFiles(root string, tracked []string) []string {
dirs := map[string]bool{".": true} dirs := map[string]bool{".": true}
for _, f := range tracked { for _, f := range tracked {
isTracked[f] = true isTracked[f] = true
if isEnv(f) { if isEnvNamed(f) {
out = append(out, f) out = append(out, f)
} }
if d := path.Dir(f); !dirs[d] && !inPackages(f) { if d := path.Dir(f); !dirs[d] && !inPackages(f) {
@@ -632,7 +734,7 @@ func envFiles(root string, tracked []string) []string {
} }
for _, e := range entries { for _, e := range entries {
f := path.Join(d, e.Name()) f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) { if e.Type().IsRegular() && !isTracked[f] && isEnvNamed(f) {
out = append(out, f) out = append(out, f)
} }
} }
@@ -663,21 +765,34 @@ func inPackages(f string) bool {
return false 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 // .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies. // dependencies.
func isEnv(f string) bool { func IsEnv(f string) bool {
if inPackages(f) { return isEnvNamed(f) && !isExample(f)
return false
} }
// isEnvNamed reports whether a file outside dependencies is named like an
// env file, examples too.
func isEnvNamed(f string) bool {
base := strings.ToLower(path.Base(f)) base := strings.ToLower(path.Base(f))
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} { return !inPackages(f) && (base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env"))
if strings.HasSuffix(base, ext) { }
var exampleWords = []string{"example", "sample", "template", "dist", "tmpl"}
// isExample reports whether a file is an example or template of another:
// .env.example, docker-compose.example.yml. They're listed hidden: someone
// may have put a real value in one.
func isExample(f string) bool {
base := strings.ToLower(path.Base(f))
for _, w := range exampleWords {
if strings.HasSuffix(base, "."+w) || strings.Contains(base, "."+w+".") {
return true
}
}
return false return false
} }
}
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
}
// plainValue reads a key from a plain env file. // plainValue reads a key from a plain env file.
func plainValue(file, name string) (provider.SecretValue, error) { func plainValue(file, name string) (provider.SecretValue, error) {
@@ -854,12 +969,21 @@ func readMeta(file string) (sopsMeta, bool) {
return sopsMeta{}, false // not a format sops writes: skip reading return sopsMeta{}, false // not a format sops writes: skip reading
} }
b, err := os.ReadFile(file) b, err := os.ReadFile(file)
if err != nil || !bytes.Contains(b, []byte("sops")) { 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")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing return sopsMeta{}, false // cheap: most files aren't sops, no parsing
} }
var m sopsMeta var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"} known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch formatOf(file) { switch format {
case "yaml", "json": case "yaml", "json":
var doc struct { var doc struct {
Sops map[string]any `yaml:"sops"` Sops map[string]any `yaml:"sops"`
@@ -960,9 +1084,10 @@ func formatOf(file string) string {
} }
type key struct { type key struct {
name string // db.password, list[0] name string // db.password, list[0], with . [ ] \ in keys escaped
extract string // sops --extract: ["db"]["password"] extract string // sops --extract: ["db"]["password"]
encrypted bool encrypted bool
jasypt bool // a plain env file's ENC(...) value
line int // 1-based line int // 1-based
} }
@@ -987,7 +1112,8 @@ func parseKeys(file string, b []byte) ([]key, error) {
if !ok || strings.HasPrefix(k, "sops_") { if !ok || strings.HasPrefix(k, "sops_") {
continue continue
} }
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
} }
return out, nil return out, nil
case "ini": case "ini":
@@ -1005,7 +1131,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
continue continue
} }
k, v = strings.TrimSpace(k), strings.TrimSpace(v) k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
} }
} }
return out, nil return out, nil
@@ -1022,9 +1148,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
if top && k == "sops" { if top && k == "sops" {
continue continue
} }
child := k child := escape(k)
if name != "" { if name != "" {
child = name + "." + k child = name + "." + child
} }
walk(n.Content[i+1], child, extract+quote(k), false, out) walk(n.Content[i+1], child, extract+quote(k), false, out)
} }
@@ -1046,21 +1172,29 @@ func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]` return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
} }
// extractFor rebuilds the extract path from a key name, for objects made // escape keeps a key's dots and brackets from reading as nesting in a
// from a link (db.password, list[0]). Names with dots in keys can't be // name: app\.name is one key, app.name two.
// told apart from nesting: those come from the listing instead. func escape(k string) string {
func extractFor(name string) string { return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k)
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) // 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
} }
} }
return b.String() return "", fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
} }
func absPath(p string) string { func absPath(p string) string {
+77 -3
View File
@@ -237,6 +237,25 @@ func TestDecrypt(t *testing.T) {
t.Errorf("from a link: %q %v", v.Reveal(), err) 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. // Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt")) 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") { if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
@@ -273,9 +292,12 @@ func TestPlainEnvFiles(t *testing.T) {
got := map[string]string{} got := map[string]string{}
for _, c := range files { for _, c := range files {
got[c.Name] = c.Detail got[c.Name] = c.Detail
if c.Hidden != (c.Name == ".env.example") {
t.Errorf("%s: hidden %t", c.Name, c.Hidden)
} }
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" { }
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got) if len(got) != 3 || got[".env"] != "env · plain text" || got[".env.example"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env, the example, hidden, and the sops file; not node_modules)", got)
} }
var env provider.Container var env provider.Container
@@ -300,7 +322,7 @@ func TestPlainEnvFiles(t *testing.T) {
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644) 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() exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0]) files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) { if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env", ".env.example"}) {
t.Errorf("with rules elsewhere: %v", names) t.Errorf("with rules elsewhere: %v", names)
} }
} }
@@ -336,6 +358,30 @@ 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) { func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML}) here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv}) starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
@@ -521,3 +567,31 @@ func TestFileDetails(t *testing.T) {
t.Errorf("untracked env: %+v", d) 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)
}
}
+236
View File
@@ -0,0 +1,236 @@
package repo
import (
"bytes"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
"go.yaml.in/yaml/v3"
)
// Spring Boot config files: application*.properties/.yml and bootstrap*,
// their keys filtered by the secret patterns (patterns.go).
// springName is application.yml, application-prod.properties, bootstrap.yaml
// and the like; not applications.yaml (Argo CD's) or application.yml.example.
var springName = regexp.MustCompile(`^(application|bootstrap)(-[^.]+)?\.(properties|ya?ml)$`)
// IsSpring reports whether f is named like a Spring Boot config file, and
// isn't a Helm chart's template of one.
func IsSpring(f string) bool {
return springName.MatchString(strings.ToLower(path.Base(f))) && path.Base(path.Dir(f)) != "templates"
}
func springFormat(f string) string {
if strings.ToLower(filepath.Ext(f)) == ".properties" {
return "properties"
}
return "yaml"
}
var springType = configType{
name: "spring", is: IsSpring, format: springFormat, parse: parseSpring,
jasyptSetting: func(string) string { return "jasypt.encryptor.password" },
placeholder: springPlaceholder,
}
// springPlaceholderRe is Spring's ${name} or ${name:default}.
var springPlaceholderRe = regexp.MustCompile(`^\$\{([^:}]+)(:([^}]*))?\}$`)
func springPlaceholder(v string) (name, def string, hasDef, ok bool) {
m := springPlaceholderRe.FindStringSubmatch(v)
if m == nil {
return "", "", false, false
}
return m[1], m[3], m[2] != "", true
}
func parseSpring(file string, b []byte) ([]configKey, error) {
var keys []configKey
var err error
if springFormat(file) == "properties" {
keys = parseProperties(b)
} else {
keys, err = parseSpringYAML(b)
}
for i := range keys {
keys[i].ref = reference(springPlaceholder, keys[i].value)
}
return keys, err
}
// springDocs names the keys of a multi-document file's later documents
// after the profile they're for, so each name is one key:
// spring.datasource.password (prod).
func springDocs(docs [][]configKey) []configKey {
var out []configKey
for i, doc := range docs {
suffix := ""
if i > 0 {
suffix = fmt.Sprintf(" (document %d)", i+1)
for _, k := range doc {
if k.key == "spring.config.activate.on-profile" || k.key == "spring.profiles" {
suffix = " (" + k.value + ")"
}
}
}
for _, k := range doc {
k.name = k.key + suffix
out = append(out, k)
}
}
return out
}
// mavenFiltered is a value Maven fills in at build time, @project.version@:
// not YAML until it's quoted.
var mavenFiltered = regexp.MustCompile(`(?m)(:[ \t]+|^[ \t]*-[ \t]+)(@[^\n]*?)[ \t]*$`)
func parseSpringYAML(b []byte) ([]configKey, error) {
b = mavenFiltered.ReplaceAllFunc(b, func(m []byte) []byte {
sub := mavenFiltered.FindSubmatch(m)
return fmt.Appendf(nil, "%s'%s'", sub[1], bytes.ReplaceAll(sub[2], []byte("'"), []byte("''")))
})
dec := yaml.NewDecoder(bytes.NewReader(b))
var docs [][]configKey
for {
var doc yaml.Node
err := dec.Decode(&doc)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
var keys []configKey
if len(doc.Content) > 0 {
springWalk(doc.Content[0], "", &keys)
}
docs = append(docs, keys)
}
return springDocs(docs), nil
}
// springWalk names keys the way Spring does: nested keys joined by dots,
// list items by index (servers[0]).
func springWalk(n *yaml.Node, name string, out *[]configKey) {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
child := n.Content[i].Value
if name != "" {
child = name + "." + child
}
springWalk(n.Content[i+1], child, out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
springWalk(c, fmt.Sprintf("%s[%d]", name, i), out)
}
case yaml.AliasNode:
if n.Alias != nil {
springWalk(n.Alias, name, out)
}
case yaml.ScalarNode:
if name != "" {
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
}
}
}
// parseProperties reads a Java .properties file: key=value, key: value or
// key value, lines continued with a trailing backslash, # and ! comments,
// and #--- between documents (Spring Boot 2.4+).
func parseProperties(b []byte) []configKey {
lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n")
var docs [][]configKey
var doc []configKey
for i := 0; i < len(lines); i++ {
start := i + 1
line := strings.TrimLeftFunc(lines[i], unicode.IsSpace)
if line == "#---" || line == "!---" {
docs, doc = append(docs, doc), nil
continue
}
if line == "" || line[0] == '#' || line[0] == '!' {
continue
}
for continued(line) && i+1 < len(lines) {
i++
line = line[:len(line)-1] + strings.TrimLeftFunc(lines[i], unicode.IsSpace)
}
k, v := splitProperty(line)
doc = append(doc, configKey{key: unescapeProperty(k), value: unescapeProperty(v), line: start})
}
return springDocs(append(docs, doc))
}
// continued reports whether the line ends in an odd number of backslashes.
func continued(line string) bool {
n := len(line) - len(strings.TrimRight(line, `\`))
return n%2 == 1
}
// splitProperty cuts a logical line at its first unescaped =, : or
// whitespace; whitespace around the separator isn't part of either side.
func splitProperty(line string) (key, value string) {
const space = " \t\f"
for i := 0; i < len(line); i++ {
switch c := line[i]; {
case c == '\\':
i++
case c == '=' || c == ':':
return line[:i], strings.TrimLeft(line[i+1:], space)
case strings.IndexByte(space, c) >= 0:
rest := strings.TrimLeft(line[i:], space)
if rest != "" && (rest[0] == '=' || rest[0] == ':') {
rest = strings.TrimLeft(rest[1:], space)
}
return line[:i], rest
}
}
return line, ""
}
func unescapeProperty(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\\' || i+1 == len(s) {
b.WriteByte(s[i])
continue
}
i++
switch s[i] {
case 't':
b.WriteByte('\t')
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 'f':
b.WriteByte('\f')
case 'u':
if i+5 <= len(s) {
if r, err := strconv.ParseUint(s[i+1:i+5], 16, 32); err == nil {
b.WriteRune(rune(r))
i += 4
continue
}
}
b.WriteByte('u')
default:
b.WriteByte(s[i])
}
}
return b.String()
}
+255
View File
@@ -0,0 +1,255 @@
package repo
import (
"context"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestIsSpring(t *testing.T) {
for f, want := range map[string]bool{
"src/main/resources/application.yml": true,
"application-prod.properties": true,
"config/application-local.yaml": true,
"bootstrap.yml": true,
"applicationset/dev/applications.yaml": false, // Argo CD's
"chart/templates/application.yaml": false, // Helm's
"application.yml.example": false,
"application.json": false,
"src/main/resources/applicationContext.xml": false,
} {
if IsSpring(f) != want {
t.Errorf("%s: %t", f, !want)
}
}
}
func springNames(keys []configKey) map[string]string {
out := map[string]string{}
for _, k := range keys {
out[k.name] = k.value
}
return out
}
func TestParseProperties(t *testing.T) {
got := springNames(parseProperties([]byte(`# a comment
! another
a=1
b : 2
c 3
d\:e=4
long=one, \
two
unicode=café
empty=
#---
spring.config.activate.on-profile=prod
a=prod-1
`)))
want := map[string]string{
"a": "1", "b": "2", "c": "3", "d:e": "4", "long": "one, two", "unicode": "café", "empty": "",
"spring.config.activate.on-profile (prod)": "prod", "a (prod)": "prod-1",
}
if !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestParseSpringYAML(t *testing.T) {
keys, err := parseSpringYAML([]byte(`info.version: @project.version@
artifacts:
- @project.artifactId@
spring:
datasource:
password: ${DB_PASSWORD}
servers:
- name: a
secret: x
logging.level:
com.example: DEBUG
---
spring:
config.activate.on-profile: prod
datasource:
password: hunter2
---
other: y
`))
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"info.version": "@project.version@", "artifacts[0]": "@project.artifactId@",
"spring.datasource.password": "${DB_PASSWORD}", "servers[0].name": "a", "servers[0].secret": "x",
"logging.level.com.example": "DEBUG",
"spring.config.activate.on-profile (prod)": "prod", "spring.datasource.password (prod)": "hunter2",
"other (document 3)": "y",
}
if got := springNames(keys); !maps.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
func TestSpringDefaults(t *testing.T) {
f, err := (&Provider{}).filterFor(t.TempDir(), "spring")
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]string{
"spring.datasource.password": "x",
"spring.security.oauth2.client.registration.a.client-secret": "x",
"profapp.payment.checkoutKey": "x",
"app.clients.sgig.sgig-passord": "x",
"jasypt.encryptor.password": "${JASYPT_ENCRYPTOR_PASSWORD}",
"spring.datasource.url": "jdbc:postgresql://app:hunter2@db:5432/app",
"app.servicebus.connection": "Endpoint=sb://x/;SharedAccessKeyName=a;SharedAccessKey=b",
"app.name": "ENC(abc==)",
} {
if !f.secret(key, value) {
t.Errorf("%s=%s isn't listed", key, value)
}
}
for key, value := range map[string]string{
"spring.security.oauth2.client.provider.a.token-uri": "https://idp/token",
"spring.security.oauth2.client.registration.a.authorization-grant-type": "client_credentials",
"app.frontend.keycloak.url": "https://keycloak",
"server.port": "8080",
"spring.datasource.url": "jdbc:postgresql://db:5432/app",
} {
if f.secret(key, value) {
t.Errorf("%s=%s is listed", key, value)
}
}
}
func TestSpringFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"src/main/resources/application.yml": `server:
port: 8080
spring:
datasource:
url: jdbc:postgresql://db/app
password: ${DB_PASSWORD}
jasypt:
encryptor:
password: ${JASYPT_ENCRYPTOR_PASSWORD}
app:
api-key: ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)
`,
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
"applicationset/applications.yaml": "kind: ApplicationSet\n",
})
ctx := context.Background()
details := func(p *Provider, c provider.Container) map[string]provider.Attr {
attrs, err := p.Details(ctx, c)
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Attr{}
for _, a := range attrs {
out[a.Label] = a
}
return out
}
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
cs, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
var files []string
for _, c := range cs {
files = append(files, c.Name+" · "+c.Detail)
}
if want := []string{
"src/main/resources/application-local.properties · spring · properties",
"src/main/resources/application.yml · spring · yaml",
}; !slices.Equal(files, want) {
t.Fatalf("files %v\nwant %v", files, want)
}
local, yml := cs[0], cs[1]
listed, _ := p.Objects(ctx, yml, provider.TypeSecret)
keys := shown(listed)
var names []string
for _, k := range keys {
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
}
if len(listed) != 5 || !listed[0].Hidden || listed[0].Name != "server.port" {
t.Errorf("hidden keys: %+v", listed)
}
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
t.Errorf("keys %v, want %v", names, want)
}
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" {
t.Errorf("value: %q %v", v.Reveal(), err)
}
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "${DB_PASSWORD}" {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
if f, _ := p.File(yml, &keys[1]); f.Line != 9 || f.Sops != "" {
t.Errorf("file: %+v", f)
}
// References and Jasypt aren't plain text in git; a password is.
if d := details(p, yml); d["hidden keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" {
t.Errorf("yml: %+v", d)
}
if d := details(p, local); !d["git"].Warn {
t.Errorf("local: %+v", d)
}
// The config's patterns, and the repo's over them.
all := []string{".*"}
p = New(Settings{Dir: root, SecretPatterns: SecretPatterns{Keys: &all}})
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 {
t.Errorf("every key: %d", len(shown(keys)))
}
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"url$\"]\n values: []\n"), 0o644)
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 1 || shown(keys)[0].Name != "spring.datasource.url" {
t.Errorf("repo's patterns: %v", keys)
}
os.WriteFile(lazysecrets, []byte("spring:\n secretPatterns:\n keys: [\"(\"]\n"), 0o644)
if _, err := p.Objects(ctx, yml, provider.TypeSecret); err == nil || !strings.Contains(err.Error(), ".lazysecrets.yaml: spring.secretPatterns.keys[0]") {
t.Errorf("bad pattern: %v", err)
}
}
func shown(objects []provider.Object) []provider.Object {
return slices.DeleteFunc(slices.Clone(objects), func(o provider.Object) bool { return o.Hidden })
}
func TestHiddenFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"a/application.yml": "server:\n port: 8080\n",
"b/application.yml": "spring.datasource.password: x\n",
})
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
if len(srcs) != 1 || srcs[0].Hidden {
t.Fatalf("sources: %+v", srcs)
}
cs, _ := p.Containers(ctx, srcs[0])
if len(cs) != 2 || !cs[0].Hidden || cs[1].Hidden {
t.Errorf("files: %+v", cs)
}
// A repo with only hidden files is hidden too.
root = gitRepo(t, map[string]string{"application.yml": "server:\n port: 8080\n"})
srcs, _ = New(Settings{Dir: root}).Sources(ctx)
if len(srcs) != 1 || !srcs[0].Hidden {
t.Errorf("hidden repo: %+v", srcs)
}
}
-2
View File
@@ -21,8 +21,6 @@ func NewSecretValue(b []byte) SecretValue {
// Reveal returns the plaintext. Call it only to display or copy the value. // 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) 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) String() string { return masked }
func (v SecretValue) GoString() string { return masked } func (v SecretValue) GoString() string { return masked }
+103
View File
@@ -0,0 +1,103 @@
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
}
+37 -15
View File
@@ -10,11 +10,15 @@
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]] // lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]] // lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
// lazysecrets://repo/<repo>[/<file>[/<key>]] // lazysecrets://repo/<repo>[/<file>[/<key>]]
// lazysecrets://share/<sealed files>
// //
// A repo is its git remote without scheme or .git // 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 // (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. // 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. // Vault hosts always contain dots, so "subscriptions" can't be one.
// //
// Every path segment is percent-encoded, so names may hold any character. // Every path segment is percent-encoded, so names may hold any character.
@@ -49,10 +53,23 @@ type Ref struct {
Type provider.ObjectType Type provider.ObjectType
Name string // object name; empty for a container link Name string // object name; empty for a container link
Version string 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. // IsSource reports whether r points at a subscription or workspace.
func (r Ref) IsSource() bool { func (r Ref) IsSource() bool {
if r.IsShare() {
return false
}
if r.Kind != provider.KindAzure { if r.Kind != provider.KindAzure {
return r.Scope == "" return r.Scope == ""
} }
@@ -60,7 +77,7 @@ func (r Ref) IsSource() bool {
} }
// IsContainer reports whether r points at a vault or scope. // IsContainer reports whether r points at a vault or scope.
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() } func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() && !r.IsShare() }
// ContainerName is the vault name (from its host) or the scope. // ContainerName is the vault name (from its host) or the scope.
func (r Ref) ContainerName() string { func (r Ref) ContainerName() string {
@@ -75,15 +92,9 @@ func (r Ref) ContainerName() string {
// Container.Detail. // Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" } 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) { func typeOf(collection string) (provider.ObjectType, bool) {
for t, c := range collections { for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
if strings.EqualFold(c, collection) { if strings.EqualFold(t.Collection(), collection) {
return t, true return t, true
} }
} }
@@ -101,7 +112,7 @@ func (r Ref) IdentifierURL() string {
if r.Type == "" { if r.Type == "" {
return u + "/" return u + "/"
} }
for _, seg := range []string{collections[r.Type], r.Name, r.Version} { for _, seg := range []string{r.Type.Collection(), r.Name, r.Version} {
if seg != "" { if seg != "" {
u += "/" + url.PathEscape(seg) u += "/" + url.PathEscape(seg)
} }
@@ -111,6 +122,9 @@ func (r Ref) IdentifierURL() string {
// String is the lazysecrets:// link. // String is the lazysecrets:// link.
func (r Ref) String() string { func (r Ref) String() string {
if r.IsShare() {
return Scheme + "://share/" + r.Share
}
if r.Kind == provider.KindAzure && r.Subscription != "" { if r.Kind == provider.KindAzure && r.Subscription != "" {
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription) return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
} }
@@ -120,7 +134,7 @@ func (r Ref) String() string {
segs = append(segs, r.Scope, r.Name) segs = append(segs, r.Scope, r.Name)
default: default:
if r.Type != "" { if r.Type != "" {
segs = append(segs, collections[r.Type]) segs = append(segs, r.Type.Collection())
} }
segs = append(segs, r.Name, r.Version) segs = append(segs, r.Name, r.Version)
} }
@@ -208,6 +222,8 @@ var (
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`) portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL> // …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`) 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. // Parse reads any supported reference.
@@ -238,6 +254,12 @@ func parseLink(s string) (Ref, error) {
if err != nil { if err != nil {
return Ref{}, err 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 var r Ref
if len(segs) == 0 || segs[0] == "" { if len(segs) == 0 || segs[0] == "" {
return Ref{}, errors.New("link has no vault or workspace") return Ref{}, errors.New("link has no vault or workspace")
@@ -245,7 +267,7 @@ func parseLink(s string) (Ref, error) {
r.Host = strings.ToLower(segs[0]) r.Host = strings.ToLower(segs[0])
rest := segs[1:] rest := segs[1:]
switch strings.ToLower(u.Host) { switch host := strings.ToLower(u.Host); host {
case "azure": case "azure":
r.Kind = provider.KindAzure r.Kind = provider.KindAzure
if r.Host == "subscriptions" { if r.Host == "subscriptions" {
@@ -272,11 +294,11 @@ func parseLink(s string) (Ref, error) {
} }
case "databricks", "repo": case "databricks", "repo":
r.Kind = provider.KindDatabricks r.Kind = provider.KindDatabricks
if strings.EqualFold(u.Host, "repo") { if host == "repo" {
r.Kind = provider.KindRepo r.Kind = provider.KindRepo
} }
if len(rest) > 2 { if len(rest) > 2 {
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host)) return Ref{}, fmt.Errorf("%s link has too many parts", host)
} }
if len(rest) > 0 { if len(rest) > 0 {
r.Scope = rest[0] r.Scope = rest[0]
@@ -286,7 +308,7 @@ func parseLink(s string) (Ref, error) {
} }
default: default:
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host) return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host)
} }
return r, nil return r, nil
} }
+74
View File
@@ -0,0 +1,74 @@
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
}
+308
View File
@@ -0,0 +1,308 @@
// 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
}
+258
View File
@@ -0,0 +1,258 @@
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)
}
}
+87
View File
@@ -0,0 +1,87 @@
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)
}
+20 -6
View File
@@ -43,6 +43,13 @@ type Node struct {
// alone isn't. // alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 } 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. // StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) } func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
@@ -145,16 +152,23 @@ type Store struct {
data file 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 // Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo. // 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.
func Load(path string) (*Store, error) { func Load(path string) (*Store, error) {
s := &Store{path: path} s := &Store{path: path}
d, err := read(path) d, err := read(path)
if err != nil { if errors.Is(err, ErrNewer) {
s.path = ""
} else if err != nil {
return nil, err return nil, err
} }
s.data = d s.data = d
return s, nil return s, err
} }
func read(path string) (file, error) { func read(path string) (file, error) {
@@ -172,12 +186,12 @@ func read(path string) (file, error) {
if err := json.Unmarshal(b, &d); err != nil { if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err) 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 { if d.Nodes == nil {
d.Nodes = map[string]Node{} d.Nodes = map[string]Node{}
} }
if d.Version > version {
return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version)
}
return d, nil return d, nil
} }
+23 -4
View File
@@ -1,6 +1,7 @@
package store package store
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -65,11 +66,17 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
} }
} }
func TestNewerVersionRefused(t *testing.T) { func TestNewerVersionReadOnly(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json") path := filepath.Join(t.TempDir(), "state.json")
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600) newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}`
if _, err := Load(path); err == nil { _ = os.WriteFile(path, []byte(newer), 0o600)
t.Error("a newer file was accepted") 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)
} }
} }
@@ -112,3 +119,15 @@ func TestStarsPerAccount(t *testing.T) {
t.Errorf("unstar alice: %v", n.StarredBy) 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")
}
}
+125 -54
View File
@@ -3,11 +3,11 @@
package app package app
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"image/color" "image/color"
"math"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -24,6 +24,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "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/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
@@ -64,6 +65,8 @@ const (
type Options struct { type Options struct {
// Timeout bounds every provider call. Defaults to 30s. // Timeout bounds every provider call. Defaults to 30s.
Timeout time.Duration 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 shows Nerd Font glyphs next to backends.
Icons bool Icons bool
// Clipboard defaults to the system clipboard, or OSC52 over SSH. // Clipboard defaults to the system clipboard, or OSC52 over SSH.
@@ -76,11 +79,19 @@ type Options struct {
// and keeps its state, for the status pane. // and keeps its state, for the status pane.
ConfigFiles []string ConfigFiles []string
StatePath 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 is CHANGELOG.md, for the status pane.
Changelog string Changelog string
// CacheFile keeps what was loaded between runs; "" keeps it for this // CacheFile keeps what was loaded between runs; "" keeps it for this
// run only. // run only.
CacheFile string 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 // 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. // left there, unless it was started on a link. "" doesn't.
Dir string Dir string
@@ -111,15 +122,24 @@ type Options struct {
// Keys, if set, replaces the default keymap (keys.Map.Apply). // Keys, if set, replaces the default keymap (keys.Map.Apply).
Keys *keys.Map Keys *keys.Map
// Updater, if set, finds and installs newer releases (U); with // Updater, if set, finds and installs newer releases (U); with
// UpdateCheck, lazysecrets also looks once a day on its own. // UpdateCheck, lazysecrets also looks on its own at startup, at most
// once per UpdateEvery (0 for a day).
Updater *Updater Updater *Updater
UpdateCheck bool UpdateCheck bool
UpdateEvery time.Duration
// HandlerSetup, if set, offers to set up the link handler at startup. // HandlerSetup, if set, offers to set up the link handler at startup.
HandlerSetup *HandlerSetup HandlerSetup *HandlerSetup
// NoHistory stops counting uses of items for the Recent list. // NoHistory stops counting uses of items for the Recent list.
NoHistory bool 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 is a reference to open at startup (lazysecrets open <ref>).
Open *ref.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 // Styles, if set, replaces the default theme (styles.FromTheme), and
// DefaultFg the terminal's default text colour. // DefaultFg the terminal's default text colour.
Styles *styles.Styles Styles *styles.Styles
@@ -138,7 +158,7 @@ type Clipboard interface {
type Model struct { type Model struct {
providers []provider.Provider providers []provider.Provider
byID map[string]provider.Provider byID map[string]provider.Provider
timeout time.Duration timeout timeouts
keys keys.Map keys keys.Map
st styles.Styles st styles.Styles
@@ -193,6 +213,9 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field changelog []panes.Field
cacheDirty bool 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 sourcesAt map[string]time.Time
placeDir string placeDir string
savedPlace string // the link last saved as the place savedPlace string // the link last saved as the place
@@ -209,6 +232,7 @@ type Model struct {
// provider finds it (provider.Current), before Sources lists them all. // provider finds it (provider.Current), before Sources lists them all.
current *provider.Source current *provider.Source
flatSources bool // Sources pane without provider groups 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 sourcesFavs bool // Sources pane on its Favourites tab
history bool // count uses of items (usage.go) history bool // count uses of items (usage.go)
onActive func() onActive func()
@@ -230,6 +254,7 @@ type Model struct {
updater *Updater updater *Updater
updateCheck bool updateCheck bool
updateEvery time.Duration
fetchOnStart bool // look for a newer release in Init fetchOnStart bool // look for a newer release in Init
newer *update.Release // a newer release to offer newer *update.Release // a newer release to offer
updated string // the release installed, waiting for a restart updated string // the release installed, waiting for a restart
@@ -269,6 +294,11 @@ type Model struct {
prompt *modal // popup that has the keyboard prompt *modal // popup that has the keyboard
linked []provider.Container // vaults opened from links, this session linked []provider.Container // vaults opened from links, this session
open *ref.Ref // reference to open at startup 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. // A lazysecrets:// link found on the clipboard, offered in the footer.
dismissedLink string // the last link offered: not offered again dismissedLink string // the last link offered: not offered again
@@ -366,7 +396,7 @@ func New(providers []provider.Provider, opts Options) Model {
m := Model{ m := Model{
providers: providers, providers: providers,
byID: map[string]provider.Provider{}, byID: map[string]provider.Provider{},
timeout: opts.Timeout, timeout: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
keys: keys.Default(), keys: keys.Default(),
st: styles.Default(), st: styles.Default(),
spin: spinner.New(spinner.WithSpinner(spinner.Dot)), spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
@@ -398,7 +428,8 @@ func New(providers []provider.Provider, opts Options) Model {
if m.store == nil { if m.store == nil {
m.store, _ = store.Load("") m.store, _ = store.Load("")
} }
m.sources = newSourcesList(iconSet, m.store) m.recentIcon = opts.RecentIcon
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
m.sources.Empty = "no sources found" m.sources.Empty = "no sources found"
m.collapsed = map[string]bool{} m.collapsed = map[string]bool{}
m.flatSources = opts.FlatSources m.flatSources = opts.FlatSources
@@ -418,7 +449,10 @@ func New(providers []provider.Provider, opts Options) Model {
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
m.expiryWarning = opts.ExpiryWarning m.expiryWarning = opts.ExpiryWarning
m.handlerSetup = opts.HandlerSetup m.handlerSetup = opts.HandlerSetup
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck m.updater, m.updateCheck, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
if m.updateEvery == 0 {
m.updateEvery = updateEvery
}
m.startUpdateCheck() m.startUpdateCheck()
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt) m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
m.settle = opts.LoadDelay m.settle = opts.LoadDelay
@@ -427,7 +461,7 @@ func New(providers []provider.Provider, opts Options) Model {
} }
m.defaultFg = opts.DefaultFg m.defaultFg = opts.DefaultFg
m.help = newHelp(m.st) m.help = newHelp(m.st)
m.open = opts.Open m.open, m.openAction = opts.Open, opts.OpenAction
saved := m.store.Layout() saved := m.store.Layout()
m.split, m.heights = saved.Split, saved.Heights m.split, m.heights = saved.Split, saved.Heights
@@ -436,6 +470,7 @@ func New(providers []provider.Provider, opts Options) Model {
Filter: func(c provider.Container) string { return c.Name }, Filter: func(c provider.Container) string { return c.Name },
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note }, FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
Render: m.containerRender(false), Render: m.containerRender(false),
Hidden: func(c provider.Container) bool { return c.Hidden },
}) })
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{ m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
@@ -445,6 +480,7 @@ func New(providers []provider.Provider, opts Options) Model {
// Typing "expir" finds what has expired or soon will. // 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) }, FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
Render: m.objectRender(), Render: m.objectRender(),
Hidden: func(o provider.Object) bool { return o.Hidden },
}) })
m.secrets.Empty = "no secrets" m.secrets.Empty = "no secrets"
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
@@ -455,7 +491,11 @@ func New(providers []provider.Provider, opts Options) Model {
m.spinning = true m.spinning = true
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} 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) m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
if m.cachePath != "" { if m.cachePath != "" {
m.loadCacheFile() m.loadCacheFile()
if len(m.sourceResults) > 0 { if len(m.sourceResults) > 0 {
@@ -497,18 +537,26 @@ func (m Model) Init() tea.Cmd {
cmds = append(cmds, m.fetchLatest(false)) cmds = append(cmds, m.fetchLatest(false))
} }
if m.open != nil { if m.open != nil {
r := *m.open msg := openRefMsg{ref: *m.open, action: m.openAction}
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} }) cmds = append(cmds, func() tea.Msg { return msg })
} }
return tea.Batch(cmds...) return tea.Batch(cmds...)
} }
// openRefMsg starts opening a reference from outside the key handler. // openRefMsg starts opening a reference from outside the key handler:
type openRefMsg struct{ ref ref.Ref } // 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}
// OpenLink is a message offering r in the Open link popup, for links // OpenLink is a message offering r in the Open link popup, for links
// handed over by the link handler (Program.Send). // handed over by the link handler (Program.Send).
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} } func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{ref: r} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd var cmds []tea.Cmd
@@ -565,11 +613,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case openRefMsg: case openRefMsg:
m.deferHandlerSetup() // the link first 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: case resolvedMsg:
m.handleResolved(msg) 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: case tea.PasteMsg:
cmds = append(cmds, m.handlePaste(msg)) cmds = append(cmds, m.handlePaste(msg))
@@ -596,7 +657,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case detailsMsg: case detailsMsg:
if msg.req == m.reqID[slotDetails] { if msg.req == m.reqID[slotDetails] {
m.done(slotDetails) m.cancelReq(slotDetails)
} }
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) { if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again delete(m.details, msg.key) // tried again when shown again
@@ -627,13 +688,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotContainers] { if msg.req != m.reqID[slotContainers] {
break break
} }
m.done(slotContainers) m.cancelReq(slotContainers)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
if src, ok := m.selectedSource(); ok { if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err) m.logErrOf(src.Name, msg.err)
} }
delete(m.cache.containers, msg.key) uncache(&m, m.cache.containers, msg.key)
m.containers.SetError(msg.err) m.containers.SetError(msg.err)
} else { } else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()} m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
@@ -645,7 +706,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotObjects] { if msg.req != m.reqID[slotObjects] {
break break
} }
m.done(slotObjects) m.cancelReq(slotObjects)
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil { 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.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
m.closeVersions() m.closeVersions()
@@ -654,18 +715,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c, ok := m.containers.Selected(); ok { if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err) m.logErrOf(c.Name, msg.err)
} }
delete(m.cache.objects, msg.key) uncache(&m, m.cache.objects, msg.key)
m.secrets.SetError(msg.err) m.secrets.SetError(msg.err)
} else { } else {
if msg.bound != nil { if msg.bound != nil {
msg.key = m.rebind(*msg.bound) 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.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true m.cacheDirty = true
if m.versionsOf != nil { if m.versionsOf != nil {
m.setVersions(msg.items) m.setVersions(msg.items)
} else { } else {
m.secrets.SetItems(m.starredFirst(msg.items)) m.secrets.SetItems(m.starredObjectsFirst(msg.items))
cmds = append(cmds, m.refreshShown(before))
} }
} }
@@ -673,7 +736,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotValue] { if msg.req != m.reqID[slotValue] {
break break
} }
m.done(slotValue) m.cancelReq(slotValue)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -688,7 +751,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotVersions] { if msg.req != m.reqID[slotVersions] {
break break
} }
m.done(slotVersions) m.cancelReq(slotVersions)
// Errors and unsupported providers just leave the count out. // Errors and unsupported providers just leave the count out.
if msg.err == nil { if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
@@ -700,7 +763,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotDescribe] { if msg.req != m.reqID[slotDescribe] {
break break
} }
m.done(slotDescribe) m.cancelReq(slotDescribe)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
@@ -715,7 +778,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.req != m.reqID[slotCopy] { if msg.req != m.reqID[slotCopy] {
break break
} }
m.done(slotCopy) m.cancelReq(slotCopy)
if msg.err != nil { if msg.err != nil {
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true} m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
@@ -827,7 +890,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Quit): case key.Matches(msg, k.Quit):
return nil, true return nil, true
case key.Matches(msg, k.Help): case key.Matches(msg, k.Help):
return m.showKeybindings(), false return m.showKeybindingsFor(""), false
case key.Matches(msg, k.Up): case key.Matches(msg, k.Up):
m.move(-1) m.move(-1)
case key.Matches(msg, k.Down): case key.Matches(msg, k.Down):
@@ -919,6 +982,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.cycleZoom(-1) m.cycleZoom(-1)
case key.Matches(msg, k.SourcesView): case key.Matches(msg, k.SourcesView):
m.toggleSourcesView() m.toggleSourcesView()
case key.Matches(msg, k.ToggleHidden):
m.toggleHidden()
case key.Matches(msg, k.Versions): case key.Matches(msg, k.Versions):
if m.versionsOf != nil { if m.versionsOf != nil {
m.closeVersions() m.closeVersions()
@@ -1107,14 +1172,11 @@ func (m *Model) syncSelection() tea.Cmd {
if m.onLinks() { if m.onLinks() {
m.showLinkedContainers() m.showLinkedContainers()
} else if src, ok := m.selectedSource(); ok { } else if src, ok := m.selectedSource(); ok {
m.containers.Title = "[2] Vaults"
m.containers.Empty = "no vaults found" m.containers.Empty = "no vaults found"
switch src.Kind { switch src.Kind {
case provider.KindDatabricks: case provider.KindDatabricks:
m.containers.Title = "[2] Scopes"
m.containers.Empty = "no secret scopes" m.containers.Empty = "no secret scopes"
case provider.KindRepo: case provider.KindRepo:
m.containers.Title = "[2] Files"
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)" m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
} }
cmds = append(cmds, m.showContainers(src, id)) cmds = append(cmds, m.showContainers(src, id))
@@ -1199,7 +1261,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
m.secrets.SetLoading() m.secrets.SetLoading()
return m.loadObjects(c, m.objType, key) return m.loadObjects(c, m.objType, key)
} }
m.secrets.SetItems(m.starredFirst(e.value)) m.secrets.SetItems(m.starredObjectsFirst(e.value))
m.secrets.Select(m.cache.cursor[key]) m.secrets.Select(m.cache.cursor[key])
if e.fresh(m.now()) { if e.fresh(m.now()) {
return nil return nil
@@ -1219,7 +1281,7 @@ func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
} }
m.secrets.SetLoading() m.secrets.SetLoading()
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotObjects) ctx, req := m.begin(slotObjects, o.Container.Source.Kind)
return func() tea.Msg { return func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1249,7 +1311,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
} }
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotVersions) ctx, req := m.begin(slotVersions, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Versions(ctx, o) items, err := p.Versions(ctx, o)
for i := range items { for i := range items {
@@ -1259,8 +1321,7 @@ func (m *Model) countVersions(o provider.Object) tea.Cmd {
}) })
} }
// closeVersions returns the Secrets pane to the object list, with the // closeVersions returns the Secrets pane to the object list.
// cursor on the object whose versions were shown.
func (m *Model) closeVersions() { func (m *Model) closeVersions() {
m.versionsOf = nil m.versionsOf = nil
} }
@@ -1400,11 +1461,26 @@ func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
return m.crawlStart() return m.crawlStart()
} }
// begin starts a cancellable request for pane p, cancelling any in flight. // timeouts bound each provider request: its kind's, else the default.
func (m *Model) begin(p slot) (context.Context, int) { 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) {
m.cancelReq(p) m.cancelReq(p)
m.seq++ m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
m.reqID[p], m.cancel[p] = m.seq, cancel m.reqID[p], m.cancel[p] = m.seq, cancel
return ctx, m.seq return ctx, m.seq
} }
@@ -1442,9 +1518,6 @@ 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 // loadSources starts a new generation of source loads, superseding any in
// flight. // flight.
func (m *Model) loadSources() tea.Cmd { func (m *Model) loadSources() tea.Cmd {
@@ -1458,7 +1531,7 @@ func (m Model) sourcesCmd() tea.Cmd {
gen := m.sourcesGen gen := m.sourcesGen
var cmds []tea.Cmd var cmds []tea.Cmd
for _, p := range m.providers { for _, p := range m.providers {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
s, err := p.Sources(ctx) s, err := p.Sources(ctx)
@@ -1469,7 +1542,7 @@ func (m Model) sourcesCmd() tea.Cmd {
}) })
// The source it started in, quickly, on its own. // The source it started in, quickly, on its own.
if c, ok := p.(provider.Current); ok { if c, ok := p.(provider.Current); ok {
ctx, cancel := context.WithTimeout(context.Background(), m.timeout) ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(p.Kind()))
cmds = append(cmds, func() tea.Msg { cmds = append(cmds, func() tea.Msg {
defer cancel() defer cancel()
src, ok := c.Current(ctx) src, ok := c.Current(ctx)
@@ -1486,7 +1559,7 @@ func (m Model) sourcesCmd() tea.Cmd {
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd { func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
p := m.providerFor(src) p := m.providerFor(src)
ctx, req := m.begin(slotContainers) ctx, req := m.begin(slotContainers, src.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
items, err := p.Containers(ctx, src) items, err := p.Containers(ctx, src)
// Carry the source as given, with its provider ID, so later // Carry the source as given, with its provider ID, so later
@@ -1505,7 +1578,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 { func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
cands := m.candidates(c.Source) cands := m.candidates(c.Source)
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
ctx, req := m.begin(slotObjects) ctx, req := m.begin(slotObjects, c.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
var errs []error var errs []error
for _, p := range cands { for _, p := range cands {
@@ -1533,7 +1606,7 @@ func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key str
func (m *Model) fetchValue(s provider.Object) tea.Cmd { func (m *Model) fetchValue(s provider.Object) tea.Cmd {
p := m.providerFor(s.Container.Source) p := m.providerFor(s.Container.Source)
ctx, req := m.begin(slotValue) ctx, req := m.begin(slotValue, s.Container.Source.Kind)
m.detail.SetLoading() m.detail.SetLoading()
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, s) v, err := p.Value(ctx, s)
@@ -1552,7 +1625,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
m.detail.SetDescribing() m.detail.SetDescribing()
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotDescribe) ctx, req := m.begin(slotDescribe, o.Container.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
obj, err := p.Describe(ctx, o) obj, err := p.Describe(ctx, o)
return describeMsg{req: req, key: key, obj: obj, err: err} return describeMsg{req: req, key: key, obj: obj, err: err}
@@ -1564,7 +1637,7 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
func (m *Model) copyValue() tea.Cmd { func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() { if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok { if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false) return m.copyText(fieldName(f), cmp.Or(f.URL, f.Value), false)
} }
return nil return nil
} }
@@ -1580,7 +1653,7 @@ func (m *Model) copyValue() tea.Cmd {
return m.copyText(o.Name, v.Reveal(), true) return m.copyText(o.Name, v.Reveal(), true)
} }
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
ctx, req := m.begin(slotCopy) ctx, req := m.begin(slotCopy, o.Container.Source.Kind)
m.flash = flash{text: "copying " + o.Name + "…"} m.flash = flash{text: "copying " + o.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
v, err := p.Value(ctx, o) v, err := p.Value(ctx, o)
@@ -1607,12 +1680,8 @@ func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
// clipTick fires when the countdown's displayed second next changes. // clipTick fires when the countdown's displayed second next changes.
func (m *Model) clipTick() tea.Cmd { func (m *Model) clipTick() tea.Cmd {
wait := m.clipDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.clipToken token := m.clipToken
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} }) return tea.Tick(untilNextSecond(m.clipDeadline, m.now()), func(time.Time) tea.Msg { return clipTickMsg{token: token} })
} }
func (m *Model) cancelClipClear() { func (m *Model) cancelClipClear() {
@@ -1719,13 +1788,15 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } else {
revealed := m.detail.State() == panes.ValueShown reveal := "show"
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) 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)
} }
var lines []string var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
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, secondsLeft(m.clipDeadline, m.now())))))
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
} }
if m.flash.text != "" { if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash)) lines = append(lines, m.flashLine(m.flash))
+52 -19
View File
@@ -536,7 +536,7 @@ func TestBackspaceGoesBack(t *testing.T) {
} }
// While typing a filter, backspace deletes a character instead. // While typing a filter, backspace deletes a character instead.
d.keys("/") d.keys("/")
d.typeText("apx") d.typeText("api-x")
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 { 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()) 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() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("api") d.typeText("api-k")
if d.m.secrets.Len() != 1 { if d.m.secrets.Len() != 1 {
t.Fatalf("visible = %d, want 1", d.m.secrets.Len()) 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("esc") // leave filter mode, clearing the query
d.keys("/") d.keys("/")
d.typeText("api") d.typeText("api-k")
d.keys("enter", "esc") // apply, then esc clears the applied filter d.keys("enter", "esc") // apply, then esc clears the applied filter
if d.m.secrets.Len() != 3 { if d.m.secrets.Len() != 4 {
t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len()) t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len())
} }
if _, _, s := d.selected(); s != "api-key" { if _, _, s := d.selected(); s != "api-key" {
@@ -698,13 +698,13 @@ func TestMoveWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("a") // all three secrets contain an a d.typeText("a") // all four secrets contain an a
if d.m.secrets.Len() != 3 { if d.m.secrets.Len() != 4 {
t.Fatalf("visible = %d", d.m.secrets.Len()) t.Fatalf("visible = %d", d.m.secrets.Len())
} }
_, _, initial := d.selected() // kept from before filtering: the middle row _, _, initial := d.selected() // kept from before filtering: the middle row
var seen []string var seen []string
for _, k := range []rune{'j', 'j', 'k'} { for _, k := range []rune{'j', 'j', 'j', 'k'} {
d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl}) d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl})
_, _, s := d.selected() _, _, s := d.selected()
seen = append(seen, s) seen = append(seen, s)
@@ -712,9 +712,9 @@ func TestMoveWhileFiltering(t *testing.T) {
if !d.m.secrets.Filtering() { if !d.m.secrets.Filtering() {
t.Fatal("ctrl+j/k left filter mode") t.Fatal("ctrl+j/k left filter mode")
} }
// down to the bottom, stay there, back up to where it started // down to the bottom, stay there, back up one
if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial { if seen[0] == initial || seen[2] != seen[1] || seen[3] != seen[0] {
t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen) t.Errorf("from %q, ctrl+j ×3, ctrl+k selected %v", initial, seen)
} }
if !strings.Contains(d.screen(), "ctrl+j/n down") { if !strings.Contains(d.screen(), "ctrl+j/n down") {
t.Errorf("filter help missing ctrl+j:\n%s", d.screen()) t.Errorf("filter help missing ctrl+j:\n%s", d.screen())
@@ -848,7 +848,7 @@ func TestCopyAndRevealWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.typeText("api") d.typeText("api-k")
d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
if d.clip().content != "sk_live_51HxDEMO0000000000000000" { if d.clip().content != "sk_live_51HxDEMO0000000000000000" {
t.Errorf("ctrl+y copied %q", d.clip().content) 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") { 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) t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
} }
if !strings.Contains(lastLine(scr), "z show") { if !strings.Contains(lastLines(scr, 1), "z show") {
t.Errorf("help bar: %q", lastLine(scr)) t.Errorf("help bar: %q", lastLines(scr, 1))
} }
d.keys("z") d.keys("z")
if bar := lastLine(d.screen()); !strings.Contains(bar, "z hide") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "z hide") {
t.Errorf("help bar with the value shown: %q", bar) t.Errorf("help bar with the value shown: %q", bar)
} }
d.keys("z") d.keys("z")
@@ -1125,7 +1125,7 @@ func TestAccountPane(t *testing.T) {
if !strings.Contains(scr, "Alice Example") { if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr) t.Errorf("account not shown:\n%s", scr)
} }
bar := lastLine(scr) bar := lastLines(scr, 1)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar) t.Errorf("help bar: %q", bar)
} }
@@ -1187,7 +1187,8 @@ func TestDetailsFollowFocus(t *testing.T) {
func TestStatusLineCountsRepos(t *testing.T) { func TestStatusLineCountsRepos(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t, fake.Azure(0), fake.Repos()) d := newDriver(t, fake.Azure(0), fake.Repos())
if line := strings.Split(d.screen(), "\n")[1]; !strings.Contains(line, "1 account · 3 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") {
t.Errorf("status line: %q", line) t.Errorf("status line: %q", line)
} }
d = newDriver(t, fake.Azure(0)) d = newDriver(t, fake.Azure(0))
@@ -1202,8 +1203,8 @@ func TestStatusPane(t *testing.T) {
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json", 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 }}, OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), signedOut{fake.Azure(0)}) fake.Azure(0), signedOut{fake.Azure(0)})
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") { if line := ansi.Strip(d.m.statusLine()); !strings.Contains(line, "2 accounts · 1 need sign-in") {
t.Errorf("status line:\n%s", d.screen()) t.Errorf("status line: %q", line)
} }
d.keys("0") d.keys("0")
if d.m.focus != paneStatus { if d.m.focus != paneStatus {
@@ -1303,7 +1304,8 @@ func TestExpiringInStatus(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0)) d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
d.keys("3", "]", "]") // load kv-app-prod's certificates d.keys("3", "]", "]") // load kv-app-prod's certificates
if !strings.Contains(d.screen(), "⚠ 1 expiring") { // old-tls and legacy-token.
if !strings.Contains(d.screen(), "⚠ 2 expiring") {
t.Errorf("status line:\n%s", d.screen()) t.Errorf("status line:\n%s", d.screen())
} }
d.keys("0") d.keys("0")
@@ -1342,3 +1344,34 @@ func TestErrorLog(t *testing.T) {
t.Errorf("log %+v", d.m.errLog) 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())
}
}
+45
View File
@@ -1,8 +1,12 @@
package app package app
import ( import (
"maps"
"slices"
"time" "time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -48,3 +52,44 @@ func (c cache) rememberCursor(parent, child string) {
c.cursor[parent] = child 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)
}
+31
View File
@@ -2,6 +2,7 @@ package app
import ( import (
"context" "context"
"slices"
"testing" "testing"
"time" "time"
@@ -159,3 +160,33 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before) 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)
}
}
+11 -21
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"context" "context"
"fmt" "fmt"
"os" "os"
@@ -84,10 +85,7 @@ func (m *Model) showCommands() tea.Cmd {
} }
var opts []option var opts []option
for _, c := range cmds { for _, c := range cmds {
label := c.Description label := cmp.Or(c.Description, c.Command)
if label == "" {
label = c.Command
}
if c.Key != "" { if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
} }
@@ -122,7 +120,7 @@ func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Ob
in.SetValue(p.Default) in.SetValue(p.Default)
in.CursorEnd() in.CursorEnd()
m.prompt = &modal{ m.prompt = &modal{
title: cmpOr(c.Description, "Command"), input: &in, title: cmp.Or(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd { submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text) it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1) return m.askPrompt(c, it, obj, i+1)
@@ -151,7 +149,7 @@ func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provid
case strings.HasPrefix(c.Value, "env:"): case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".") lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
} }
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{ m.showMenu("Run "+cmp.Or(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }}, {key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
}) })
return nil return nil
@@ -165,7 +163,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
o := *obj o := *obj
p := m.providerFor(o.Container.Source) p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"} m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout timeout := m.timeout.of(o.Container.Source.Kind)
m.usedObject(o) m.usedObject(o)
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
@@ -177,7 +175,7 @@ func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Ob
// execCommand runs the command as its output mode says. // execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd { func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh") shell := cmp.Or(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) { setValue := func(cmd *exec.Cmd) {
switch { switch {
case c.Value == "stdin": case c.Value == "stdin":
@@ -195,7 +193,7 @@ func (m *Model) execCommand(c commands.Command, rendered string, value provider.
m.flash = flash{} m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} }) return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
} }
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"} m.flash = flash{text: "running " + cmp.Or(c.Description, "command") + "…"}
return func() tea.Msg { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout) ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel() defer cancel()
@@ -219,7 +217,7 @@ func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
const maxOutputLines = 30 const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) { func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmpOr(msg.cmd.Description, "command") name := cmp.Or(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil { if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch { switch {
case msg.err != nil: case msg.err != nil:
@@ -298,11 +296,7 @@ func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool)
if r, err := ref.ForObject(o, version); err == nil { if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String()) it.Link = commands.Q(r.String())
} }
if version != "" { o.Version = version // "": the current value, like copying it
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true return it, &o, true
} }
@@ -313,9 +307,5 @@ func newTextInput(prompt string, width int) textinput.Model {
return in return in
} }
func cmpOr(a, b string) string { // pathInputWidth fits a link or a folder path in a prompt, in the window.
if a != "" { func (m Model) pathInputWidth() int { return max(20, min(m.w-12, 64)) }
return a
}
return b
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"github.com/charmbracelet/x/ansi"
) )
func commandDriver(t *testing.T, cmds ...commands.Command) *driver { func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
@@ -34,7 +35,7 @@ func TestCommandGetsValueOnInput(t *testing.T) {
// temporary path's length. // temporary path's length.
shown := "" shown := ""
if d.m.prompt != nil { if d.m.prompt != nil {
shown = ansiStrip(strings.Join(d.m.prompt.lines, "")) shown = ansi.Strip(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") { 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) t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
+38 -2
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"cmp" "cmp"
"errors" "errors"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -13,9 +14,10 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/browser" "git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/update"
) )
// Opening a repo's file (e): SOPS files with sops edit, which decrypts // Opening a repo's file (e), or on the status pane the config: SOPS files with sops edit, which decrypts
// them for the editor and encrypts them again after. Other files, inside // them for the editor and encrypts them again after. Other files, inside
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or // 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 // $EDITOR, coming back after; else in the system's default app. On a
@@ -24,6 +26,7 @@ import (
type editDoneMsg struct { type editDoneMsg struct {
c provider.Container c provider.Container
name string name string
config bool // the config file: read at startup
err error err error
} }
@@ -44,6 +47,8 @@ func (m *Model) edit() tea.Cmd {
var c provider.Container var c provider.Container
var o *provider.Object var o *provider.Object
switch m.focus { switch m.focus {
case paneStatus: // like lazygit's status panel
return m.editConfig()
case paneSources: case paneSources:
m.flash = warning("select a file to open it") m.flash = warning("select a file to open it")
return nil return nil
@@ -93,6 +98,33 @@ func (m *Model) edit() tea.Cmd {
return m.openInEditor(f, finished) 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. // plusLine are editors that open at a line with +N before the file.
var plusLine = map[string]bool{ var plusLine = map[string]bool{
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true, "vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
@@ -142,6 +174,10 @@ func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true} m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
return nil return nil
} }
if msg.config {
m.flash = done("closed " + msg.name + ": restart lazysecrets to use it")
return nil
}
m.flash = done("closed " + msg.name) m.flash = done("closed " + msg.name)
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) { if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
m.hideValue() m.hideValue()
@@ -206,7 +242,7 @@ func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.
return tea.QuitMsg{} return tea.QuitMsg{}
} }
} }
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" { if editor := cmp.Or(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w"). // Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...) args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{} m.flash = flash{}
+7 -5
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"slices" "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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -28,8 +30,8 @@ func (m Model) expiringItems() []expiringItem {
row := sourceRowKey(p.ID(), src.ID) row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring { if state, _ := m.expiry(o); state != expiry.None {
out = append(out, expiringItem{o, row}) out = append(out, expiringItem{o, row})
} }
} }
@@ -50,11 +52,11 @@ func (m Model) expiringFields() []panes.Field {
var out []panes.Field var out []panes.Field
for _, it := range items { for _, it := range items {
state, left := m.expiry(it.o) state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left) mark := "⚠ " + expiry.Left(left)
if state == expired { if state == expiry.Expired {
mark = "✗ expired" mark = "✗ expired"
} }
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row} f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expiry.Expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil { if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String() f.Link = r.String()
} }
+5 -31
View File
@@ -1,9 +1,9 @@
package app package app
import ( import (
"fmt"
"time" "time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -11,45 +11,19 @@ import (
// list once they have expired (✗ expired) or will within the configured // list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them. // 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, // expiry tells whether o has expired or will within the warning period,
// and how long it has left. // and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) { func (m Model) expiry(o provider.Object) (expiry.State, time.Duration) {
if o.Expires.IsZero() { return expiry.Of(o.Expires, m.now(), m.expiryWarning)
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". // expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string { func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state { switch state, _ := m.expiry(o); state {
case expired: case expiry.Expired:
return "expired" return "expired"
case expiresSoon: case expiry.Soon:
return "expiring" return "expiring"
} }
return "" 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))
}
+93 -35
View File
@@ -3,9 +3,13 @@ package app
import ( import (
"slices" "slices"
"strings" "strings"
"time"
tea "charm.land/bubbletea/v2" 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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -19,6 +23,22 @@ import (
const star = "★ " 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 { func sourceKey(s provider.Source) string {
if r, err := ref.ForSource(s); err == nil { if r, err := ref.ForSource(s); err == nil {
return r.String() return r.String()
@@ -77,29 +97,22 @@ func (m *Model) refreshContext(key, name string, path []string, src provider.Sou
// containerRender draws a vault or scope, starred or not. In the // containerRender draws a vault or scope, starred or not. In the
// Opened links list it also names the source, since they come from many. // 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 { func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
st, iconSet := m.store, m.icons st := m.store
return func(sty styles.Styles, c provider.Container, width int) string { return func(sty styles.Styles, c provider.Container, width int) string {
n := st.Node(storeKey(c)) n := st.Node(storeKey(c))
line := c.Name line := c.Name
if c.Source.Kind == provider.KindRepo { if c.Source.Kind == provider.KindRepo {
// A secret file: a lock for an encrypted one, a file in the // A secret file: its icon, and its type after it ("sops ·
// warning colour for plain text, and its type after it // age", "env · plain text").
// ("sops · age", "env · plain text"). if icon := m.fileIcon(sty, c); icon != "" {
icon, style := iconSet.Encrypted, sty.KindRepo line = icon + " " + line
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 += sty.Muted.Render(" " + c.Detail)
} }
if n.StarredFor(c.Source.Provider) { line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
line = sty.Favourite.Render(star) + line
}
if withSource { if withSource {
src := c.Source.Name src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { if account := accountOf(c.Source); account != "" {
src = account + " / " + src src = account + " / " + src
} }
line += sty.Muted.Render(" · " + src) line += sty.Muted.Render(" · " + src)
@@ -116,37 +129,82 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled { if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)") line = sty.Muted.Render(o.Name + " (disabled)")
} }
if n.StarredFor(o.Container.Source.Provider) { line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
line = sty.Favourite.Render(star) + line if o.Detail != "" {
line += sty.Muted.Render(" " + o.Detail)
} }
line = dimIf(sty, o.Hidden, line)
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expired: case expiry.Expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
case expiresSoon: case expiry.Soon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left)) line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
} }
return withNote(sty, line, n.Note, width) return withNote(sty, line, n.Note, width)
} }
} }
// starredContainersFirst moves starred vaults and scopes to the front, // fileIcon is a repo file's icon, in its colour: a lock for an encrypted
// keeping the order otherwise. It doesn't modify items, which may be cached. // one, a leaf for Spring config, a whale for compose, a file in the
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container { // 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, "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
}
out := slices.Clone(items) out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Container) int { slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out return out
} }
// starredFirst moves starred objects to the front, keeping the order func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
// otherwise. It doesn't modify items, which may be cached. return starredFirst(sources, func(s provider.Source) bool {
func (m Model) starredFirst(items []provider.Object) []provider.Object { return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
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))
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)
}) })
return out
} }
// showLinkedContainers lists the vaults opened from links. // showLinkedContainers lists the vaults opened from links.
@@ -167,7 +225,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source m.linked[i].Source = c.Source
} }
} }
key := containerKey(c) + "|" + string(m.objType) key := cachefile.ObjectsKey(c, m.objType)
if m.onLinks() { if m.onLinks() {
m.containers.SetItems(m.linked) m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c)) m.containers.Select(containerKey(c))
@@ -184,7 +242,7 @@ func (m *Model) resortStarred() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items())) m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
} }
if m.versionsOf == nil { if m.versionsOf == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
@@ -230,7 +288,7 @@ func (m *Model) toggleFavourite() tea.Cmd {
c := o.Container c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) } 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 { if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items())) m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
} }
} }
switch { switch {
+21 -2
View File
@@ -6,6 +6,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
@@ -13,6 +14,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store" "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. // jump opens the favourites list, filters it and jumps to the first match.
@@ -394,7 +396,7 @@ func TestStarWhileFiltering(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
for _, r := range "api" { for _, r := range "api-k" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
} }
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}) d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
@@ -428,9 +430,26 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("F") d.keys("F")
var labels []string var labels []string
for _, o := range d.m.search.shown { for _, o := range d.m.search.shown {
labels = append(labels, ansiStrip(o.label)) labels = append(labels, ansi.Strip(o.label))
} }
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") { if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels) 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())
}
}
+8 -6
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -8,6 +9,7 @@ import (
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi" "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/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
) )
@@ -60,7 +62,7 @@ func (m *Model) syncFields() tea.Cmd {
case !ok: case !ok:
m.fields.Show("", nil) m.fields.Show("", nil)
case m.selectedProblem() != "": case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) m.fields.Show(cmp.Or(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup: case r.kind == rowGroup:
m.showAccount(r) m.showAccount(r)
case r.kind == rowSource: case r.kind == rowSource:
@@ -133,7 +135,7 @@ func (m Model) sourceFields(src provider.Source) []panes.Field {
add("web", src.URL) add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { 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] label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value))) add(cmp.Or(label, "vaults"), fmt.Sprint(len(e.value)))
} }
return out return out
} }
@@ -157,10 +159,10 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
case provider.KindRepo: case provider.KindRepo:
add("format", c.Detail) add("format", c.Detail)
} }
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) add("source", cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL) add("web", c.URL)
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok { if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
} }
} }
@@ -196,7 +198,7 @@ func (m *Model) loadDetails(c provider.Container) tea.Cmd {
} }
} }
m.details[key] = detailsState{loading: true} m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails) ctx, req := m.begin(slotDetails, c.Source.Kind)
return m.settled(ctx, func() tea.Msg { return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c) attrs, err := d.Details(ctx, c)
if ctx.Err() != nil { if ctx.Err() != nil {
@@ -220,5 +222,5 @@ func (m Model) problemFields(problem string) []panes.Field {
// fieldName names a copied field: its label, or a row of several under // fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value. // one label (a tag), its value.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value) return cmp.Or(f.Label, f.Value)
} }
+11 -19
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
@@ -16,7 +17,7 @@ import (
) )
// The help bar shows the keys that matter in the focused pane; ? lists all // The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindings). // of them in a menu (showKeybindingsFor).
// newHelp styles the help bar to read clearly: keys like labels elsewhere, // newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The // descriptions in the normal text colour, only the separators dimmed. The
@@ -38,10 +39,9 @@ type contextHelp struct {
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go) account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes of pane // what the fourth pane describes
// revealed, when set, says whether the selected value is shown, so // reveal, when set, is what the reveal key will do, "show" or "hide":
// the reveal key says just what it'll do: show or hide, the same // the same width so the bar doesn't shift.
// width so the bar doesn't shift. reveal string
revealed *bool
} }
func (c contextHelp) ShortHelp() []key.Binding { func (c contextHelp) ShortHelp() []key.Binding {
@@ -54,7 +54,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
switch { switch {
case c.focus == paneStatus: case c.focus == paneStatus:
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details")) details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
return append(b, details, k.Update, k.Help, k.Quit) 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)
case c.focus == paneDetail && c.account && c.of == paneStatus: 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")) 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) return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
@@ -68,8 +69,8 @@ func (c contextHelp) ShortHelp() []key.Binding {
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter) b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default: default:
reveal := k.Reveal reveal := k.Reveal
if c.revealed != nil { if c.reveal != "" {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed])) reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, c.reveal))
} }
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter) b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
} }
@@ -83,11 +84,6 @@ func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.Short
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"} 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 // showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the // 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 // selected one. Opened from the panes, it lists the focused pane's keys
@@ -153,7 +149,7 @@ func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 { if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true}) opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds { for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom") label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmp.Or(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) }}) opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
} }
} }
@@ -262,11 +258,7 @@ func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), des
// move is "↑/↓ move" for an up and a down binding. // move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint { func move(up, down key.Binding) keyHint {
u, d := keys.Name(up), keys.Name(down) return keyHint{joinKeys(up, down), "move"}
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 // withKey fills a hint's format with the binding's key ("· %s loads
+63 -16
View File
@@ -38,15 +38,18 @@ func (m *Model) storeEntries() (favs, recent []option) {
return nil return nil
} }
var out []entry var out []entry
detail := m.objectDetail(key)
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) { add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := "" account := ""
if p, ok := m.byID[acct]; ok { if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()}) account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
} }
account = strings.Trim(detail+" · "+account, " ·")
sort := strings.ToLower(strings.Join(path, "/")) + " " + account sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{ out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ", label: m.pathLabel(r.Kind, path, account, m.refFileIcon(r)), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct), id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd { run: func(m *Model) tea.Cmd {
m.used(key) m.used(key)
return run(m) return run(m)
@@ -106,7 +109,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history { if m.history {
var used []string var used []string
for _, k := range keys { for _, k := range keys {
if !nodes[k].LastUsed.IsZero() { if nodes[k].UsedLately(m.now()) {
used = append(used, k) used = append(used, k)
} }
} }
@@ -125,12 +128,15 @@ const recentMax = 20
// pathLabel draws a jump list entry: the provider's icon and the path in // pathLabel draws a jump list entry: the provider's icon and the path in
// its colour, then the account. // its colour, then the account.
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string { func (m Model) pathLabel(kind provider.Kind, path []string, account, fileIcon string) string {
style := m.st.Kind(kind) style := m.st.Kind(kind)
parts := make([]string, len(path)) parts := make([]string, len(path))
for i, p := range path { for i, p := range path {
parts[i] = style.Render(p) 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(" / ")) label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
if account != "" { if account != "" {
label += m.st.Muted.Render(" · " + account) label += m.st.Muted.Render(" · " + account)
@@ -138,6 +144,58 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
return label 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 // sourceMatches reports whether src is the subscription or workspace r
// links to. // links to.
func sourceMatches(r ref.Ref, src provider.Source) bool { func sourceMatches(r ref.Ref, src provider.Source) bool {
@@ -174,25 +232,14 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source)) m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil return nil
} }
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return src.ID == n.Source && n.Source != "" })
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) { switch len(choices) {
case 0: case 0:
return m.openRef(r, actGo) return m.openRef(r, actGo)
case 1: case 1:
m.startNav(r, actGo, choices[0].sourceRow) m.startNav(r, actGo, choices[0].sourceRow)
default: default:
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices) m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false)
} }
return nil return nil
} }
+3 -8
View File
@@ -136,11 +136,11 @@ func TestLayoutIsSaved(t *testing.T) {
func TestContextHelpBar(t *testing.T) { func TestContextHelpBar(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") { if bar := lastLines(d.screen(), 1); !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) t.Errorf("Sources help bar: %q", bar)
} }
d.keys("3") d.keys("3")
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") { if bar := lastLines(d.screen(), 1); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar) t.Errorf("Secrets help bar: %q", bar)
} }
} }
@@ -225,11 +225,6 @@ 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) { func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
@@ -264,7 +259,7 @@ func TestVersionInStatus(t *testing.T) {
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") { if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen()) t.Errorf("no version in the status pane:\n%s", d.screen())
} }
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") { if bar := lastLines(d.screen(), 1); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar) t.Errorf("help line %q", bar)
} }
} }
+109 -24
View File
@@ -8,9 +8,9 @@ import (
"strings" "strings"
"time" "time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -46,6 +46,9 @@ type nav struct {
// quiet gives up without a word: going back to where lazysecrets // quiet gives up without a word: going back to where lazysecrets
// was left, which may be gone. // was left, which may be gone.
quiet bool quiet bool
// link ends on the object's details, as enter on it would: opening
// a link to it opens it, rather than just finding it in its list.
link bool
} }
type resolvedMsg struct { type resolvedMsg struct {
@@ -53,6 +56,9 @@ type resolvedMsg struct {
act linkAction act linkAction
sourceRow string sourceRow string
others []string // other accounts' rows that also found the vault others []string // other accounts' rows that also found the vault
// find ends in the list, like Go to, not on the details: opened by a
// tool that found it (lazysecrets open --action, from fzf).
find bool
choices []choice // several candidates: ask choices []choice // several candidates: ask
linked *provider.Container linked *provider.Container
err error err error
@@ -78,6 +84,9 @@ const (
// just open it. // just open it.
// With ask false, vault and scope links open straight away. // With ask false, vault and scope links open straight away.
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd { func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
if r.IsShare() {
return m.offerShare(r)
}
if r.IsContainer() || r.IsSource() { if r.IsContainer() || r.IsSource() {
if !ask { if !ask {
return m.openRef(r, actGo) return m.openRef(r, actGo)
@@ -152,7 +161,7 @@ func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true} m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil return nil
} }
ctx, req := m.begin(slotCopy) ctx, req := m.begin(slotCopy, r.Kind)
m.flash = flash{text: "copying " + r.Name + "…"} m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
var errs []error var errs []error
@@ -200,17 +209,9 @@ const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account // openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup. // when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) { func (m *Model) openSource(r ref.Ref) {
var choices []choice choices := m.openWith(r.Kind, func(src provider.Source) bool { return sourceMatches(r, src) })
for _, p := range m.providers { if row, ok := preferredRow(r, choices); ok {
if p.Kind() != r.Kind { choices = []choice{{sourceRow: row}}
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
} }
switch len(choices) { switch len(choices) {
case 0: case 0:
@@ -230,6 +231,24 @@ func (m *Model) openSource(r ref.Ref) {
} }
} }
// openWith offers each loaded source of kind that matches, one per
// account that lists it.
func (m Model) openWith(kind provider.Kind, matches func(provider.Source) bool) []choice {
var choices []choice
for _, p := range m.providers {
if p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if matches(src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
return choices
}
// refLabel draws what a link points at like the favourites list: the // refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod / // provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The // kv-app-prod / api-key", with the type and version after it. The
@@ -250,7 +269,7 @@ func (m Model) refLabel(r ref.Ref) string {
default: default:
path = append(path, r.ContainerName(), r.Name) path = append(path, r.ContainerName(), r.Name)
} }
label := m.pathLabel(r.Kind, path, "") label := m.pathLabel(r.Kind, path, "", m.refFileIcon(r))
var extra []string var extra []string
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret { if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
if r.IsContainer() { if r.IsContainer() {
@@ -398,23 +417,71 @@ func (m *Model) handleResolved(msg resolvedMsg) {
for i := range msg.choices { for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label msg.choices[i].label = "Open in " + msg.choices[i].label
} }
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices) m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, !msg.find)
case len(msg.others) > 0: case len(msg.others) > 0:
var choices []choice var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) { for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row}) choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
} }
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices) if row, ok := preferredRow(msg.ref, choices); ok {
m.navResolved(msg, row)
return
}
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, !msg.find)
case msg.linked != nil: case msg.linked != nil:
m.addLinked(*msg.linked) m.addLinked(*msg.linked)
m.startNav(msg.ref, msg.act, linksRowID) m.navResolved(msg, linksRowID)
default: default:
m.startNav(msg.ref, msg.act, msg.sourceRow) m.navResolved(msg, msg.sourceRow)
} }
} }
// preferredRow is the choice under the account the reference asks for
// (ref.Ref.Account), if it's one of them.
func preferredRow(r ref.Ref, choices []choice) (string, bool) {
for _, c := range choices {
if r.Account != "" && cachefile.ProviderOf(c.sourceRow) == r.Account {
return c.sourceRow, true
}
}
return "", false
}
// navResolved goes to what a resolved link points at, under row.
func (m *Model) navResolved(msg resolvedMsg, row string) {
if msg.find {
m.startNav(msg.ref, msg.act, row)
return
}
m.startLinkNav(msg.ref, msg.act, row)
}
// findRef is openRef ending in the list, like Go to: for a link a tool
// found (lazysecrets open --action).
func (m *Model) findRef(r ref.Ref, act linkAction) tea.Cmd {
cmd := m.openRef(r, act)
if cmd == nil {
return nil
}
return func() tea.Msg {
msg := cmd()
if rm, ok := msg.(resolvedMsg); ok {
rm.find = true
return rm
}
return msg
}
}
// startLinkNav opens a link: like startNav, ending on its object's
// details.
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) {
m.startNav(r, act, sourceRow)
m.nav.link = true
}
// chooseSource asks where to open r. // chooseSource asks where to open r.
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) { func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) {
var opts []option var opts []option
for i, c := range choices { for i, c := range choices {
key := "" key := ""
@@ -424,6 +491,7 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
row := c.sourceRow row := c.sourceRow
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd { opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
m.startNav(r, act, row) m.startNav(r, act, row)
m.nav.link = link
return nil return nil
}}) }})
} }
@@ -574,6 +642,9 @@ func (m *Model) advanceNav() bool {
m.pendingCmd = m.toggleReveal() m.pendingCmd = m.toggleReveal()
} }
} }
if n.link {
m.focus = paneDetail // after: copying acts on the focused pane
}
return true return true
} }
return false return false
@@ -607,6 +678,9 @@ func (m *Model) share(menu bool) tea.Cmd {
if s.URL != "" { if s.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
} }
if m.canShareFiles(s) {
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
}
m.showMenu("Share "+s.Name, nil, opts) m.showMenu("Share "+s.Name, nil, opts)
case paneContainers: case paneContainers:
c, ok := m.containers.Selected() c, ok := m.containers.Selected()
@@ -619,7 +693,7 @@ func (m *Model) share(menu bool) tea.Cmd {
share := func(k string) func(*Model) tea.Cmd { share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) } return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
} }
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}} opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret { if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")}) opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
} }
@@ -629,6 +703,11 @@ func (m *Model) share(menu bool) tea.Cmd {
if c.URL != "" { if c.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
} }
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+c.Name, nil, opts) m.showMenu("Share "+c.Name, nil, opts)
default: default:
o, ok := m.detail.Secret() o, ok := m.detail.Secret()
@@ -642,7 +721,7 @@ func (m *Model) share(menu bool) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) } return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
} }
opts := []option{ opts := []option{
option{key: "o", label: "Lazysecrets link", run: share("o")}, {key: "o", label: "Lazysecrets link", run: share("o")},
} }
if o.Container.Source.Kind == provider.KindAzure { if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")}) opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
@@ -653,6 +732,11 @@ func (m *Model) share(menu bool) tea.Cmd {
if o.URL != "" { if o.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")}) opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
} }
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
return m.shareFiles(c.Source, []provider.Container{c})
}})
}
m.showMenu("Share "+o.Name, nil, opts) m.showMenu("Share "+o.Name, nil, opts)
} }
return nil return nil
@@ -753,10 +837,8 @@ func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
} }
func (m *Model) startOpenPrompt() tea.Cmd { func (m *Model) startOpenPrompt() tea.Cmd {
in := textinput.New() in := newTextInput("open: ", m.pathInputWidth())
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference" in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{ m.prompt = &modal{
title: "Open a link", input: &in, title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")}, lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
@@ -802,6 +884,9 @@ func (m *Model) openText(s string) tea.Cmd {
// handlePaste opens a pasted reference, unless a text input has focus. // handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if m.search != nil && m.search.picker != nil {
return m.pickPasted(msg.Content) // a reference to the secret
}
if p := m.prompt; p != nil && p.input != nil { if p := m.prompt; p != nil && p.input != nil {
in, cmd := p.input.Update(msg) in, cmd := p.input.Update(msg)
p.input = &in p.input = &in
+32 -4
View File
@@ -40,7 +40,8 @@ func TestOpenSecretLink(t *testing.T) {
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" { if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Fatalf("opened %s/%s/%s", src, c, s) t.Fatalf("opened %s/%s/%s", src, c, s)
} }
if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden { // Opened: on its details, as enter on it would; its value hidden.
if d.m.focus != paneDetail || d.m.detail.State() != panes.ValueHidden {
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State()) t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
} }
} }
@@ -180,7 +181,7 @@ func TestPasteWhileFilteringFilters(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3", "/") d.keys("3", "/")
d.send(tea.PasteMsg{Content: "api"}) d.send(tea.PasteMsg{Content: "api-k"})
if d.m.secrets.Len() != 1 || d.m.nav != nil { if d.m.secrets.Len() != 1 || d.m.nav != nil {
t.Errorf("paste in filter: visible %d", d.m.secrets.Len()) t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
} }
@@ -202,8 +203,21 @@ func TestOpenPromptAndStartupOpen(t *testing.T) {
t.Fatal("open at startup didn't ask what to do") t.Fatal("open at startup didn't ask what to do")
} }
d2.keys("o") d2.keys("o")
if _, _, s := d2.selected(); s != "api-key" { if _, _, s := d2.selected(); s != "api-key" || d2.m.focus != paneDetail {
t.Errorf("open at startup: selected %q", s) t.Errorf("open at startup: selected %q, focus %v, want the details", s, d2.m.focus)
}
// --action: no question, it does that.
d3 := newDriverOpts(t, Options{Open: &r, OpenAction: "go"})
if d3.m.prompt != nil {
t.Fatalf("--action go asked: %q", d3.m.prompt.title)
}
if _, _, s := d3.selected(); s != "api-key" || d3.m.focus != paneSecrets {
t.Errorf("--action go: selected %q, focus %v, want the secrets list, like Go to", s, d3.m.focus)
}
d4 := newDriverOpts(t, Options{Open: &r, OpenAction: "copy"})
if _, _, s := d4.selected(); s != "api-key" || d4.clip().content == "" {
t.Errorf("--action copy: selected %q, copied %q", s, d4.clip().content)
} }
} }
@@ -304,6 +318,20 @@ func TestOpenAsksWhichAccount(t *testing.T) {
t.Errorf("j enter picked %q", c.Source.Provider) t.Errorf("j enter picked %q", c.Source.Provider)
} }
// lazysecrets open --account: no question, that account.
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
r.Account = "Azure:bob@example.com/t1"
d.send(OpenLink(r))
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
d.keys("o")
}
if strings.Contains(d.screen(), "Several accounts can see") {
t.Fatalf("asked, with an account given:\n%s", d.screen())
}
if c, _ := d.m.containers.Selected(); c.Source.Provider != r.Account {
t.Errorf("--account opened via %q", c.Source.Provider)
}
// Opening into a folded account unfolds it. // Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header d.keys("1", "g") // alice's header
d.keys("enter") // fold it d.keys("enter") // fold it
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more