From 80cfb3b425b533d83106c8d888073854cb9a8ff1 Mon Sep 17 00:00:00 2001 From: keb Date: Sun, 11 Oct 2026 23:29:06 +0200 Subject: [PATCH] feat: repos with SOPS and .env files in `--demo`, and a shorter feature list (#21) Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 16 ++-- cmd/lazysecrets/main.go | 1 + internal/provider/fake/fake.go | 5 +- internal/provider/fake/repos.go | 136 ++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 internal/provider/fake/repos.go diff --git a/README.md b/README.md index 87132ff..ac2a714 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,22 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti ## Main features -- **Read-only**: lazysecrets never creates, changes or deletes a secret, so - it's safe to point at production. That's by design and may stay that way. - (`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`; - any change is yours.) +- **Read-only**: lazysecrets never creates, changes or deletes a secret. + That's by design and may stay that way. - **Zero setup**: uses the logins you already have (`az`, the Databricks CLI, `sops`, `git`). No config file needed. - **Search everything**: `ctrl+s` searches every source at once, with - fzf-style matching (`'exact`, `^prefix`, `suffix$`, `!not`), and jumps - straight to the result. -- **Share links**: `s` copies a `lazysecrets://` link. A colleague opens it + fzf-style matching. +- **Share links**: Share `lazysecrets://` links with colleagues. A colleague opens it with their own access, from the terminal or with a click in the browser or chat. - **Careful with values**: values stay hidden until you reveal them, and copied values are cleared from the clipboard after 30 seconds. - **Scriptable**: `lazysecrets get ` prints a value for scripts and pipes. -- **Favourites**: star the secrets you use most and get back to them in one - key. +- **Favourites**: star the secrets you use most for easy access. - **Expiry warnings**: secrets, keys and certificates that expire soon are - marked, and 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 on a secret. - **Try it first**: `lazysecrets --demo` runs on built-in sample data. diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 571de9e..9044960 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -246,6 +246,7 @@ Flags: fake.Azure(400*time.Millisecond).As("Azure:dev@example.com/demo", "Azure · dev"), fake.AzureOps(700*time.Millisecond).As("Azure:ops@example.com/demo", "Azure · ops"), fake.Databricks(600 * time.Millisecond), + fake.Repos(), } } else { pc := cfg.Providers diff --git a/internal/provider/fake/fake.go b/internal/provider/fake/fake.go index 816437a..cd86071 100644 --- a/internal/provider/fake/fake.go +++ b/internal/provider/fake/fake.go @@ -24,6 +24,7 @@ type Provider struct { errs map[listKey]error values map[string]string // by object ID versions map[string][]provider.Object // by object ID + details map[string][]provider.Attr // by container ID } type listKey struct { @@ -101,7 +102,7 @@ func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider. if err := p.wait(ctx); err != nil { return nil, err } - if p.kind == provider.KindDatabricks { + if p.kind != provider.KindAzure { // only Key Vault keeps versions return nil, provider.ErrUnsupported } vs := p.versions[o.ID] @@ -209,7 +210,7 @@ func (p *Provider) addContainer(src provider.Source, name, detail string, types if p.kind == provider.KindDatabricks { vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ") } - if p.kind == provider.KindAzure || vault != detail { + if p.kind == provider.KindAzure || (p.kind == provider.KindDatabricks && vault != detail) { c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview" } p.containers[src.ID] = append(p.containers[src.ID], c) diff --git a/internal/provider/fake/repos.go b/internal/provider/fake/repos.go new file mode 100644 index 0000000..bed7936 --- /dev/null +++ b/internal/provider/fake/repos.go @@ -0,0 +1,136 @@ +package fake + +import ( + "context" + "strings" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" +) + +var _ provider.ContainerDetails = (*Provider)(nil) + +// Details returns a file's details, like the repo provider's. +func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { + attrs, ok := p.details[c.ID] + if !ok { + return nil, provider.ErrUnsupported + } + return attrs, ctx.Err() +} + +// Repos returns a fake repo provider: three git repos with SOPS and plain +// .env files, the first one the repo lazysecrets was started in. +func Repos() *Provider { + p := newProvider(provider.KindRepo, 0) + p.id, p.label = "Repos", "Repos" + p.details = map[string][]provider.Attr{} + + repo := func(name string, here bool) provider.Source { + s := p.addSource("/home/dev/git/"+name, name, "github.com/acme/"+name) + s.Here = here + p.sources[len(p.sources)-1] = s + return s + } + // file adds a file and its keys, alternating name and value. git and + // commit are what git says of it: "tracked", "ignored" or "untracked". + file := func(src provider.Source, path, method, git, commit string, kv ...string) { + sops := method != "" + detail := "env · plain text" + if sops { + detail = "sops · " + method + } + c := p.addContainer(src, path, detail, provider.TypeSecret) + attrs := []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}} + if sops { + attrs = []provider.Attr{{Label: "format", Value: "sops " + format(path)}, {Label: "encryption", Value: method}} + } + for i := 0; i+1 < len(kv); i += 2 { + o := provider.Object{Name: kv[i], Attrs: attrs} + if sops { + o.Updated = base + } + p.add(c, provider.TypeSecret, o, kv[i+1]) + } + + d := []provider.Attr{{Label: "encryption", Value: "none: plain text"}} + if sops { + d = []provider.Attr{{Label: "encryption", Value: detail}} + keys := map[string]string{ + "age": "age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p", + "azure_kv": "azure_kv https://kv-sops-prod.vault.azure.net/keys/sops/1f2e3d4c", + } + for i, m := range strings.Split(method, ", ") { + label := "" + if i == 0 { + label = "keys" // on the first only, like the repo provider + } + d = append(d, provider.Attr{Label: label, Value: keys[m]}) + } + d = append(d, provider.Attr{Label: "encrypted", Value: base.Local().Format("2006-01-02 15:04")}) + } + switch { + case git == "tracked" && !sops: + d = append(d, provider.Attr{Label: "git", Value: "tracked: its values are in the repo's history", Warn: true}) + case git == "untracked": + d = append(d, provider.Attr{Label: "git", Value: "untracked and not ignored: easy to commit by mistake", Warn: !sops}) + default: + d = append(d, provider.Attr{Label: "git", Value: git}) + } + if commit != "" { + d = append(d, provider.Attr{Label: "commit", Value: commit}) + } + p.details[c.ID] = d + } + + api := repo("lazysecrets-demo", true) + file(api, "deploy/prod/secrets.yaml", "age, azure_kv", "tracked", "4f9c2ab · 3 days ago", + "database.password", "pr0d-Pg-7f3k9QwZ", + "database.url", "postgres://app:pr0d-Pg-7f3k9QwZ@pg-demo-prod:5432/app", + "stripe.secret_key", "sk_live_demo_51NxDemoKey0000", + "jwt.signing_key", "c2lnbmluZy1rZXktZm9yLXRoZS1kZW1v", + "gcp.service_account", `{"type":"service_account","project_id":"lazysecrets-demo","client_email":"app@lazysecrets-demo.iam.gserviceaccount.com","private_key_id":"demo1234"}`) + file(api, "deploy/staging/secrets.yaml", "age", "tracked", "a71e05d · 2 weeks ago", + "database.password", "stg-Pg-2b8d1L", + "database.url", "postgres://app:stg-Pg-2b8d1L@pg-demo-stg:5432/app", + "stripe.secret_key", "sk_test_demo_51NxDemoKey0000") + file(api, "config/oauth.enc.json", "age", "tracked", "e03b6f1 · 2 months ago", + "github.client_secret", "ghs_demo0000000000000000000000", + "google.client_secret", "GOCSPX-demo-000000000000") + file(api, ".env", "", "ignored", "", + "DATABASE_URL", "postgres://app:local@localhost:5432/app", + "STRIPE_SECRET_KEY", "sk_test_demo_local", + "LOG_LEVEL", "debug") + + infra := repo("platform-infra", false) + file(infra, "terraform/prod/secrets.enc.yaml", "azure_kv", "tracked", "9d40c3e · yesterday", + "postgres.admin_password", "infra-Pg-admin-0Xq", + "grafana.admin_password", "grafana-demo-admin", + "acr.pull_token", "acr-demo-pull-token") + file(infra, "k8s/monitoring/alertmanager.enc.yaml", "age", "tracked", "1c58e7a · 5 days ago", + "slack.webhook_url", "https://hooks.slack.com/services/T000/B000/demo", + "pagerduty.routing_key", "pd-demo-routing-key") + file(infra, "scripts/.env", "", "tracked", "77b2d90 · 8 months ago", + "AZURE_CLIENT_SECRET", "azure-demo-client-secret", + "ARM_SUBSCRIPTION_ID", "00000000-0000-0000-0000-000000000000") + + web := repo("web-frontend", false) + file(web, ".env.production", "age", "tracked", "b2e91f4 · 4 days ago", + "SENTRY_AUTH_TOKEN", "sntrys_demo_token", + "NEXT_PUBLIC_API_URL", "https://api.acme.example") + file(web, ".env.local", "", "untracked", "", + "NEXT_PUBLIC_API_URL", "http://localhost:8080", + "ANALYTICS_KEY", "demo-analytics-key") + + return p +} + +// format is a SOPS file's format, by its name. +func format(path string) string { + switch { + case strings.HasSuffix(path, ".json"): + return "json" + case strings.HasSuffix(path, ".yaml"): + return "yaml" + } + return "dotenv" +}