feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists
#17
No files matched your search
@@ -23,7 +23,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti
|
||||
- **Favourites**: star the secrets you use most and get back to them in one
|
||||
key.
|
||||
- **Expiry warnings**: secrets, keys and certificates that expire soon are
|
||||
marked.
|
||||
marked, listed together in the status pane, and `lazysecrets expiring`
|
||||
checks every vault from a pipeline.
|
||||
- **Your way**: rebind any key, pick a theme, and add custom commands to run
|
||||
on a secret.
|
||||
- **Try it first**: `lazysecrets --demo` runs on built-in sample data.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
// lazysecrets expiring lists what has expired, or will within --days,
|
||||
// in every vault of every account: for a pipeline or a cron job. Expiry
|
||||
// dates come with the lists, so it's one request per vault and type,
|
||||
// a few at a time. Only Key Vault has expiry dates.
|
||||
|
||||
// errExpiring makes the exit status 1 when anything was listed.
|
||||
var errExpiring = errors.New("expiring")
|
||||
|
||||
// listLimit is how many lists load at once.
|
||||
const listLimit = 8
|
||||
|
||||
type expiringOpts struct {
|
||||
days int
|
||||
timeout time.Duration
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func parseExpiring(args []string, days int) (int, error) {
|
||||
fs := flag.NewFlagSet("expiring", flag.ContinueOnError)
|
||||
n := fs.Int("days", days, "list what expires within this many days (default: expiryWarningDays, else 30)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if fs.NArg() > 0 || *n < 0 {
|
||||
return 0, fmt.Errorf("usage: lazysecrets expiring [--days N]")
|
||||
}
|
||||
return *n, nil
|
||||
}
|
||||
|
||||
func expiring(providers []provider.Provider, o expiringOpts, out, errs io.Writer) error {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
found []provider.Object
|
||||
seen = map[string]bool{}
|
||||
failed int
|
||||
vaults int
|
||||
wg sync.WaitGroup
|
||||
limit = make(chan struct{}, listLimit)
|
||||
within = o.now.AddDate(0, 0, o.days)
|
||||
warnErr = func(what string, err error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
failed++
|
||||
fmt.Fprintf(errs, "lazysecrets: %s: %v\n", what, err)
|
||||
}
|
||||
)
|
||||
run := func(f func(ctx context.Context)) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
limit <- struct{}{}
|
||||
defer func() { <-limit }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
|
||||
defer cancel()
|
||||
f(ctx)
|
||||
}()
|
||||
}
|
||||
var vaultsDone sync.WaitGroup
|
||||
for _, p := range providers {
|
||||
if p.Kind() != provider.KindAzure {
|
||||
continue
|
||||
}
|
||||
vaultsDone.Add(1)
|
||||
run(func(ctx context.Context) {
|
||||
defer vaultsDone.Done()
|
||||
srcs, err := p.Sources(ctx)
|
||||
if err != nil {
|
||||
warnErr(p.Label(), err)
|
||||
return
|
||||
}
|
||||
for _, src := range srcs {
|
||||
vaultsDone.Add(1)
|
||||
run(func(ctx context.Context) {
|
||||
defer vaultsDone.Done()
|
||||
cs, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
warnErr(src.Name, err)
|
||||
return
|
||||
}
|
||||
for _, c := range cs {
|
||||
mu.Lock()
|
||||
fresh := !seen[c.ID]
|
||||
seen[c.ID] = true
|
||||
if fresh {
|
||||
vaults++
|
||||
}
|
||||
mu.Unlock()
|
||||
if !fresh { // the same vault through another account
|
||||
continue
|
||||
}
|
||||
for _, t := range c.ObjectTypes() {
|
||||
run(func(ctx context.Context) {
|
||||
objs, err := p.Objects(ctx, c, t)
|
||||
if err != nil {
|
||||
warnErr(c.Name+" "+strings.ToLower(string(t))+"s", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, o := range objs {
|
||||
if !o.Expires.IsZero() && o.Expires.Before(within) {
|
||||
found = append(found, o)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
// Listing vaults queues the lists of their secrets, so wait for both.
|
||||
vaultsDone.Wait()
|
||||
wg.Wait()
|
||||
|
||||
slices.SortFunc(found, func(a, b provider.Object) int { return a.Expires.Compare(b.Expires) })
|
||||
if len(found) == 0 {
|
||||
fmt.Fprintf(errs, "nothing expires within %d days (%d vaults)\n", o.days, vaults)
|
||||
if failed > 0 {
|
||||
return fmt.Errorf("%d lists couldn't be read", failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "LEFT\tEXPIRES\tTYPE\tVAULT\tNAME\tLINK")
|
||||
for _, x := range found {
|
||||
left := "expired"
|
||||
if d := x.Expires.Sub(o.now); d > 0 {
|
||||
left = fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
}
|
||||
link := ""
|
||||
if r, err := ref.ForObject(x, ""); err == nil {
|
||||
link = r.String()
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", left, x.Expires.Local().Format("2006-01-02"),
|
||||
strings.ToLower(string(x.Type)), x.Container.Name, x.Name, link)
|
||||
}
|
||||
if err := tw.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if failed > 0 {
|
||||
fmt.Fprintf(errs, "lazysecrets: %d lists couldn't be read: the list may be incomplete\n", failed)
|
||||
}
|
||||
return errExpiring
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
)
|
||||
|
||||
func TestExpiring(t *testing.T) {
|
||||
providers := []provider.Provider{fake.Azure(0), fake.Databricks(0)}
|
||||
now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
var out, errs bytes.Buffer
|
||||
err := expiring(providers, expiringOpts{days: 30, timeout: time.Minute, now: now}, &out, &errs)
|
||||
if !errors.Is(err, errExpiring) {
|
||||
t.Fatalf("err %v (want errExpiring: something listed)", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if len(lines) != 2 || !strings.HasPrefix(lines[1], "expired 2026-03-01 certificate kv-app-prod old-tls") ||
|
||||
!strings.Contains(lines[1], "lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls") {
|
||||
t.Errorf("out:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(errs.String(), "kv-restricted") {
|
||||
t.Errorf("unreadable vaults not said:\n%s", errs.String())
|
||||
}
|
||||
|
||||
// Further ahead, the soonest first.
|
||||
out.Reset()
|
||||
expiring(providers, expiringOpts{days: 400, timeout: time.Minute, now: now}, &out, &errs)
|
||||
if got := strings.Count(out.String(), "\n"); got != 4 || !strings.Contains(out.String(), "app-tls") {
|
||||
t.Errorf("400 days:\n%s", out.String())
|
||||
}
|
||||
|
||||
if _, err := parseExpiring([]string{"--days", "-1"}, 30); err == nil {
|
||||
t.Error("negative days accepted")
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -4,6 +4,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -33,7 +34,9 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
if err := run(); errors.Is(err, errExpiring) {
|
||||
os.Exit(1) // listed: a pipeline fails on it
|
||||
} else if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "lazysecrets:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -118,6 +121,9 @@ func run() error {
|
||||
@Microsoft.KeyVault(...) or {{secrets/scope/key}}
|
||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||
for scripts: VAR=$(lazysecrets get <ref>)
|
||||
lazysecrets [flags] expiring [--days N]
|
||||
list what has expired or will within N days
|
||||
(default 30) in every vault; exits 1 if any
|
||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||
config applied), as config to copy and edit
|
||||
lazysecrets handler install [--terminal NAME]
|
||||
@@ -140,6 +146,7 @@ Flags:
|
||||
}
|
||||
|
||||
var open, getRef *ref.Ref
|
||||
var expiringArgs []string
|
||||
printKeys := false
|
||||
switch args := flag.Args(); {
|
||||
case len(args) == 1 && args[0] == "keybindings":
|
||||
@@ -150,6 +157,8 @@ Flags:
|
||||
return err
|
||||
}
|
||||
getRef = &r
|
||||
case len(args) > 0 && args[0] == "expiring":
|
||||
expiringArgs = append([]string{}, args[1:]...)
|
||||
case len(args) > 0 && args[0] == "handler":
|
||||
return handler(*configPath, args[1:])
|
||||
case len(args) > 0 && args[0] == "launch":
|
||||
@@ -298,6 +307,14 @@ Flags:
|
||||
// the standard logger, which would draw over the TUI.
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
if expiringArgs != nil {
|
||||
days, err := parseExpiring(expiringArgs, expiryDays)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expiring(providers, expiringOpts{days: days, timeout: *timeout, now: time.Now()}, os.Stdout, os.Stderr)
|
||||
}
|
||||
|
||||
if getRef != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -65,6 +65,26 @@ key (PEM public key) or certificate; private key material is never
|
||||
fetched. `v` lists an item's versions. Items expiring within
|
||||
`expiryWarningDays` (30 by default) are marked `⚠ 12d`, expired ones `✗`.
|
||||
|
||||
The status pane (`0`) lists them across every vault loaded, opened or
|
||||
cached, soonest first, and its line counts them (`⚠ 3 expiring`); `enter`
|
||||
on one goes to it. That makes no requests of its own: expiry dates come
|
||||
with the lists. It says how many vaults it covers; `ctrl+a` in search
|
||||
loads the rest.
|
||||
|
||||
For a pipeline or a cron job, `lazysecrets expiring` lists them in every
|
||||
vault of every account, loading each vault's lists (a few at a time), and
|
||||
exits 1 when it lists anything:
|
||||
|
||||
```sh
|
||||
$ lazysecrets expiring --days 30
|
||||
LEFT EXPIRES TYPE VAULT NAME LINK
|
||||
expired 2026-03-01 certificate kv-app-prod old-tls lazysecrets://azure/kv-app-prod.vault.azure.net/certificates/old-tls
|
||||
12d 2026-10-23 secret kv-app-prod api-key lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key
|
||||
```
|
||||
|
||||
`--days` defaults to `expiryWarningDays`. Lists it can't read are named on
|
||||
stderr.
|
||||
|
||||
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
|
||||
purge protection, which networks can reach it, private endpoints and tags.
|
||||
|
||||
@@ -858,6 +858,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
m.focus = paneDetail // its rows are on the right
|
||||
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
|
||||
// folded or unfolded a provider group
|
||||
case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "":
|
||||
r, err := ref.Parse(m.fieldLink())
|
||||
if err == nil {
|
||||
f, _ := m.fields.Selected()
|
||||
m.startNav(r, actGo, f.Row)
|
||||
}
|
||||
case key.Matches(msg, k.Enter):
|
||||
if m.focus < paneDetail {
|
||||
m.focus++
|
||||
@@ -1778,6 +1784,14 @@ func (m Model) problemText(providerID, err string) string {
|
||||
return err
|
||||
}
|
||||
|
||||
// fieldLink is the link of the selected Fields row, if it has one.
|
||||
func (m Model) fieldLink() string {
|
||||
if f, ok := m.fields.Selected(); ok && m.fields.Shown() {
|
||||
return f.Link
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loginFor is the sign-in L runs: the selected account's, if it needs
|
||||
// one, else the last one asked for.
|
||||
func (m Model) loginFor() *provider.AuthError {
|
||||
|
||||
@@ -1290,3 +1290,25 @@ func TestContainerDetails(t *testing.T) {
|
||||
t.Errorf("state %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiringInStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
|
||||
d.keys("3", "]", "]") // load kv-app-prod's certificates
|
||||
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
}
|
||||
d.keys("0")
|
||||
scr := d.screen()
|
||||
for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} {
|
||||
if !strings.Contains(scr, want) {
|
||||
t.Errorf("missing %q:\n%s", want, scr)
|
||||
}
|
||||
}
|
||||
d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane
|
||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" })
|
||||
d.keys("enter")
|
||||
if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" {
|
||||
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
// The status pane lists what has expired or will soon, across every vault
|
||||
// loaded (opened, searched, or from the cache): expiry dates come with
|
||||
// the lists, so this makes no requests. enter on one goes to it.
|
||||
|
||||
type expiringItem struct {
|
||||
o provider.Object
|
||||
row string // its source's row key, to go to it
|
||||
}
|
||||
|
||||
// expiringItems are the loaded objects that have expired or will within
|
||||
// the warning period, soonest first.
|
||||
func (m Model) expiringItems() []expiringItem {
|
||||
var out []expiringItem
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
row := sourceRowKey(p.ID(), src.ID)
|
||||
for _, c := range m.cache.containers[row].value {
|
||||
for _, t := range c.ObjectTypes() {
|
||||
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
|
||||
if state, _ := m.expiry(o); state != notExpiring {
|
||||
out = append(out, expiringItem{o, row})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b expiringItem) int {
|
||||
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// expiringFields are the status pane's rows for them, and how much of
|
||||
// everything they cover.
|
||||
func (m Model) expiringFields() []panes.Field {
|
||||
items := m.expiringItems()
|
||||
var out []panes.Field
|
||||
for _, it := range items {
|
||||
state, left := m.expiry(it.o)
|
||||
mark := "⚠ " + daysLeft(left)
|
||||
if state == expired {
|
||||
mark = "✗ expired"
|
||||
}
|
||||
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
|
||||
if r, err := ref.ForObject(it.o, ""); err == nil {
|
||||
f.Link = r.String()
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
cov := m.coverageOf(provider.KindAzure)
|
||||
switch {
|
||||
case cov.vaults == 0 && len(items) == 0:
|
||||
return nil
|
||||
case !cov.complete():
|
||||
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
|
||||
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
|
||||
case len(items) == 0:
|
||||
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
|
||||
}
|
||||
out[0].Label = "expiring"
|
||||
return out
|
||||
}
|
||||
@@ -38,6 +38,9 @@ func (m Model) statusLine() string {
|
||||
failing++
|
||||
}
|
||||
}
|
||||
if n := len(m.expiringItems()); n > 0 {
|
||||
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
|
||||
}
|
||||
if accounts > 0 {
|
||||
s := plural(accounts, "account")
|
||||
if failing > 0 {
|
||||
@@ -88,6 +91,8 @@ func (m Model) statusFields() []panes.Field {
|
||||
add(f)
|
||||
}
|
||||
|
||||
out = append(out, m.expiringFields()...)
|
||||
|
||||
for i, path := range m.configFiles {
|
||||
label := ""
|
||||
if i == 0 {
|
||||
|
||||
@@ -20,12 +20,15 @@ type Fields struct {
|
||||
|
||||
// Field is a row: a value under a label. Info rows (loading, errors)
|
||||
// can't be copied; Err ones are errors. A row can also be opened: URL in
|
||||
// the browser (o), Path in the editor (e), or Provider signed in to (L).
|
||||
// the browser (o), Path in the editor (e), Provider signed in to (L), or
|
||||
// Link gone to (enter).
|
||||
type Field struct {
|
||||
Label, Value string
|
||||
Info, Err bool
|
||||
|
||||
URL, Path, Provider string
|
||||
// Link is a lazysecrets link enter goes to, in the source of row Row.
|
||||
Link, Row string
|
||||
}
|
||||
|
||||
func NewFields() Fields {
|
||||
|
||||
Reference in new issue
Block a user