Hello world 👋
Release / Build and publish (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-10 23:58:25 +02:00
commit 6fab41703e
111 files changed
+22988

No files matched your search

+189
View File
@@ -0,0 +1,189 @@
package azure
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// account is one az CLI identity in one tenant.
type account struct {
user, tenant string
tenantName string
anchor string // a subscription az maps to this account, to pin it
anchorEnabled bool
isDefault bool
subscriptions int // how many subscriptions az maps to it
}
// azProfilePath is az's account list. It holds no tokens.
func azProfilePath() (string, error) {
dir := os.Getenv("AZURE_CONFIG_DIR")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir = filepath.Join(home, ".azure")
}
return filepath.Join(dir, "azureProfile.json"), nil
}
// readAccounts lists the accounts signed in to az, default first.
func readAccounts(path string) ([]account, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) // az writes a BOM
var profile struct {
Subscriptions []struct {
ID string `json:"id"`
State string `json:"state"`
IsDefault bool `json:"isDefault"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantDisplayName"`
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"subscriptions"`
}
if err := json.Unmarshal(b, &profile); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
var out []account
for _, s := range profile.Subscriptions {
if s.User.Name == "" || s.TenantID == "" {
continue
}
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
if i < 0 {
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
i = len(out) - 1
}
a := &out[i]
a.subscriptions++
// Anchor on az's default subscription if it is this account's,
// otherwise on an enabled one.
enabled := s.State == "Enabled"
switch {
case s.IsDefault:
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
case a.isDefault:
case a.anchor == "" || enabled && !a.anchorEnabled:
a.anchor, a.anchorEnabled = s.ID, enabled
}
}
slices.SortStableFunc(out, func(a, b account) int {
if a.isDefault != b.isDefault {
if a.isDefault {
return -1
}
return 1
}
return cmp.Compare(a.user, b.user)
})
return out, nil
}
// accountLabels names accounts as briefly as stays unambiguous: the user
// name before the @, the full user if another user shares that, plus the
// tenant if one user is signed in to several.
func accountLabels(accounts []account) []string {
short := func(u string) string { s, _, _ := strings.Cut(u, "@"); return s }
labels := make([]string, len(accounts))
for i, a := range accounts {
l, tenants := short(a.user), 0
for _, b := range accounts {
if b.user != a.user && short(b.user) == short(a.user) {
l = a.user
}
if b.user == a.user {
tenants++
}
}
if tenants > 1 {
l += " · " + cmp.Or(a.tenantName, a.tenant)
}
labels[i] = l
}
return labels
}
// NewAccounts returns one provider per account signed in to az, the default
// first, or a single provider if AZURE_* configures a service principal or
// az has no accounts yet. Pinned vaults go to the first.
func NewAccounts(s Settings) ([]*Provider, error) {
if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_TENANT_ID") != "" {
cred, err := azidentity.NewEnvironmentCredential(nil)
if err != nil {
return nil, err
}
p, err := newWithOptions(authCredential{cred: cred}, options{Settings: s})
if err != nil {
return nil, err
}
return []*Provider{p}, nil
}
path, err := azProfilePath()
if err != nil {
return nil, err
}
accounts, err := readAccounts(path)
if err != nil {
return nil, err
}
if len(accounts) == 0 {
// Not signed in yet: the first call fails with an AuthError and
// the UI offers az login.
cred := authCredential{cred: newCLICredential("", ""), login: []string{"az", "login"}}
p, err := newWithOptions(cred, options{Settings: s})
if err != nil {
return nil, err
}
return []*Provider{p}, nil
}
labels := accountLabels(accounts)
var out []*Provider
for i, a := range accounts {
settings := s
if i > 0 {
settings.Vaults = nil
}
label := ""
if len(accounts) > 1 {
label = string(provider.KindAzure) + " · " + labels[i]
}
cred := authCredential{
cred: newCLICredential(a.anchor, a.tenant),
login: []string{"az", "login", "--tenant", a.tenant},
account: label, // so "sign-in required" says which account
}
p, err := newWithOptions(cred, options{Settings: settings})
if err != nil {
return nil, err
}
if label != "" {
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
p.label = label
}
out = append(out, p)
}
return out, nil
}
+121
View File
@@ -0,0 +1,121 @@
package azure
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)
const profileJSON = "\xef\xbb\xbf" + `{"installationId": "x", "subscriptions": [
{"id": "s-admin-1", "name": "platform", "state": "Enabled", "isDefault": false, "tenantId": "t1", "tenantDisplayName": "Contoso",
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
{"id": "s-admin-2", "name": "old", "state": "Disabled", "isDefault": false, "tenantId": "t1",
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
{"id": "s-alice-1", "name": "app-dev", "state": "Enabled", "isDefault": false, "tenantId": "t1",
"user": {"name": "alice@contoso.com", "type": "user"}},
{"id": "s-alice-2", "name": "app-prod", "state": "Enabled", "isDefault": true, "tenantId": "t1",
"user": {"name": "alice@contoso.com", "type": "user"}}
]}`
func TestReadAccounts(t *testing.T) {
path := filepath.Join(t.TempDir(), "azureProfile.json")
if err := os.WriteFile(path, []byte(profileJSON), 0o600); err != nil {
t.Fatal(err)
}
got, err := readAccounts(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("accounts = %+v", got)
}
// Default account first, anchored on the default subscription.
if got[0].user != "alice@contoso.com" || got[0].anchor != "s-alice-2" || !got[0].isDefault {
t.Errorf("default account = %+v", got[0])
}
// Anchored on an enabled subscription, not the disabled one.
if got[1].user != "admin.alice@contoso.onmicrosoft.com" || got[1].anchor != "s-admin-1" || got[1].subscriptions != 2 {
t.Errorf("second account = %+v", got[1])
}
if accts, err := readAccounts(filepath.Join(t.TempDir(), "missing.json")); err != nil || accts != nil {
t.Errorf("missing profile: %v, %v", accts, err)
}
}
func TestAccountLabels(t *testing.T) {
got := accountLabels([]account{
{user: "alice@contoso.com", tenant: "t1"},
{user: "admin@a.example", tenant: "t1"},
{user: "admin@b.example", tenant: "t1"},
{user: "guest@x.example", tenant: "t1", tenantName: "Home"},
{user: "guest@x.example", tenant: "t2", tenantName: "Partner"},
})
want := []string{"alice", "admin@a.example", "admin@b.example", "guest · Home", "guest · Partner"}
if !reflect.DeepEqual(got, want) {
t.Errorf("labels = %q\nwant %q", got, want)
}
}
func TestCLICredentialPinsAccount(t *testing.T) {
var calls [][]string
c := newCLICredential("s-alice-2", "t1")
c.run = func(_ context.Context, args ...string) ([]byte, error) {
calls = append(calls, args)
exp := time.Now().Add(time.Hour).Unix()
return []byte(fmt.Sprintf(`{"accessToken": "tok", "expires_on": %d}`, exp)), nil
}
ctx := context.Background()
vault := policy.TokenRequestOptions{Scopes: []string{"https://vault.azure.net/.default"}}
// Same tenant (Key Vault's challenge names it): pin with --subscription only.
vault.TenantID = "t1"
if tok, err := c.GetToken(ctx, vault); err != nil || tok.Token != "tok" {
t.Fatalf("token: %v, %v", tok, err)
}
if got := strings.Join(calls[0], " "); !strings.Contains(got, "--subscription s-alice-2") || strings.Contains(got, "--tenant") {
t.Errorf("same tenant args: %s", got)
}
// Cached: no second az call.
if _, err := c.GetToken(ctx, vault); err != nil || len(calls) != 1 {
t.Errorf("token not cached: %d calls", len(calls))
}
// Another tenant: --tenant only, since az refuses both.
vault.TenantID = "t2"
if _, err := c.GetToken(ctx, vault); err != nil {
t.Fatal(err)
}
if got := strings.Join(calls[1], " "); !strings.Contains(got, "--tenant t2") || strings.Contains(got, "--subscription") {
t.Errorf("other tenant args: %s", got)
}
}
func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
n := 0
c := newCLICredential("", "")
c.run = func(context.Context, ...string) ([]byte, error) {
n++
if n == 2 {
return nil, errors.New("AADSTS700082: The refresh token has expired. Please run 'az login'")
}
// Expires inside the refresh margin, so the next call renews.
return []byte(`{"accessToken": "tok", "expiresOn": "` + time.Now().Add(time.Minute).Format("2006-01-02 15:04:05.000000") + `"}`), nil
}
opts := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
if _, err := c.GetToken(context.Background(), opts); err != nil {
t.Fatal(err)
}
if _, err := c.GetToken(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "AADSTS700082") {
t.Errorf("err = %v", err)
}
}
+27
View File
@@ -0,0 +1,27 @@
package azure
import (
"context"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// authCredential turns every token failure into a *provider.AuthError, so
// the UI can tell "log in again" apart from other failures however deep in
// the SDK the token was requested.
type authCredential struct {
cred azcore.TokenCredential
login []string
account string // label when there are several accounts
}
func (c authCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
tok, err := c.cred.GetToken(ctx, opts)
if err != nil && ctx.Err() == nil {
err = &provider.AuthError{Kind: provider.KindAzure, Account: c.account, Login: c.login, Err: err}
}
return tok, err
}
+524
View File
@@ -0,0 +1,524 @@
// Package azure is the Azure Key Vault provider. Sources are subscriptions,
// containers are vaults (found via Resource Graph), and objects are secrets,
// keys and certificates.
package azure
import (
"cmp"
"context"
"crypto/x509"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"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/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
// Provider serves one Azure identity: an az CLI account or a service
// principal. Creating it doesn't contact Azure; authentication problems
// surface as *provider.AuthError from the first call.
type Provider struct {
id, label string // "" for the single-account defaults
cred azcore.TokenCredential
opts options
subs *armsubscriptions.Client
graph *armresourcegraph.Client
mu sync.Mutex
vaults map[string]*vaultClients // by vault URL
tenants map[string]string // tenant ID by subscription ID, for portal links
}
type vaultClients struct {
secrets *azsecrets.Client
keys *azkeys.Client
certs *azcertificates.Client
}
var _ provider.Provider = (*Provider)(nil)
// Settings come from the config file.
type Settings struct {
// HideSubscriptions lists subscriptions to leave out, by name or ID.
HideSubscriptions []string
// Vaults are listed under a "Pinned vaults" source.
Vaults []string
}
// pinnedID identifies the source holding vaults from the config file.
const pinnedID = "pinned-vaults"
// options lets tests point the SDK clients at a local server.
type options struct {
Settings
arm *arm.ClientOptions
client azcore.ClientOptions
skipChallengeValidation bool
}
func newWithOptions(cred azcore.TokenCredential, opts options) (*Provider, error) {
subs, err := armsubscriptions.NewClient(cred, opts.arm)
if err != nil {
return nil, err
}
graph, err := armresourcegraph.NewClient(cred, opts.arm)
if err != nil {
return nil, err
}
return &Provider{
cred: cred, opts: opts, subs: subs, graph: graph,
vaults: map[string]*vaultClients{}, tenants: map[string]string{},
}, nil
}
func (p *Provider) Kind() provider.Kind { return provider.KindAzure }
func (p *Provider) ID() string { return cmp.Or(p.id, string(provider.KindAzure)) }
func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindAzure)) }
// Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
var out []provider.Source
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
continue
}
id, tenant := deref(s.SubscriptionID), deref(s.TenantID)
if config.Hidden(p.opts.HideSubscriptions, deref(s.DisplayName), id) {
continue
}
p.mu.Lock()
p.tenants[id] = tenant
p.mu.Unlock()
out = append(out, provider.Source{
Kind: provider.KindAzure,
ID: id,
Name: deref(s.DisplayName),
Detail: "subscription " + id,
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
})
}
}
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 {
out = append(out, provider.Source{
Kind: provider.KindAzure, ID: pinnedID, Name: "Pinned vaults", Detail: "from the config file",
})
}
return out, nil
}
// pinnedVaults lists the vaults from the config file. Without a resource ID
// they have no portal link.
func (p *Provider) pinnedVaults(src provider.Source) []provider.Container {
out := make([]provider.Container, 0, len(p.opts.Vaults))
for _, u := range p.opts.Vaults {
if !strings.HasSuffix(u, "/") {
u += "/"
}
name, _, _ := strings.Cut(strings.TrimPrefix(u, "https://"), ".")
out = append(out, provider.Container{Source: src, ID: u, Name: name, Detail: u, Types: objectTypes})
}
return out
}
// Containers finds the subscription's vaults with Resource Graph, falling
// back to the Key Vault management API if the query fails.
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
if src.ID == pinnedID {
return p.pinnedVaults(src), nil
}
vaults, err := p.graphVaults(ctx, src)
if err != nil {
var auth *provider.AuthError
if errors.As(err, &auth) || ctx.Err() != nil {
return nil, err
}
vaults, err = p.armVaults(ctx, src)
if err != nil {
return nil, err
}
}
tenant := p.tenant(src.ID)
for i := range vaults {
vaults[i].URL = PortalResourceURL(tenant, vaults[i].ID, "overview")
}
slices.SortFunc(vaults, func(a, b provider.Container) int {
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return vaults, nil
}
func (p *Provider) tenant(subscriptionID string) string {
p.mu.Lock()
defer p.mu.Unlock()
return p.tenants[subscriptionID]
}
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri)`
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
var out []provider.Container
req := armresourcegraph.QueryRequest{
Query: to.Ptr(vaultQuery),
Subscriptions: []*string{to.Ptr(src.ID)},
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
}
for {
resp, err := p.graph.Resources(ctx, req, nil)
if err != nil {
return nil, cleanError(err)
}
rows, ok := resp.Data.([]any)
if !ok {
return nil, fmt.Errorf("resource graph: unexpected result %T", resp.Data)
}
for _, r := range rows {
if c, ok := graphVault(src, r); ok {
out = append(out, c)
}
}
if resp.SkipToken == nil || *resp.SkipToken == "" {
return out, nil
}
req.Options.SkipToken = resp.SkipToken
}
}
func graphVault(src provider.Source, row any) (provider.Container, bool) {
m, ok := row.(map[string]any)
if !ok {
return provider.Container{}, false
}
id, _ := m["id"].(string)
name, _ := m["name"].(string)
uri, _ := m["uri"].(string)
if id == "" || name == "" || uri == "" {
return provider.Container{}, false
}
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
}
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
client, err := armkeyvault.NewVaultsClient(src.ID, p.cred, p.opts.arm)
if err != nil {
return nil, err
}
var out []provider.Container
pager := client.NewListBySubscriptionPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes,
})
}
}
return out, nil
}
func (p *Provider) clients(vaultURL string) (*vaultClients, error) {
p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.vaults[vaultURL]; ok {
return c, nil
}
skip := p.opts.skipChallengeValidation
s, err := azsecrets.NewClient(vaultURL, p.cred, &azsecrets.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
k, err := azkeys.NewClient(vaultURL, p.cred, &azkeys.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
c, err := azcertificates.NewClient(vaultURL, p.cred, &azcertificates.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
vc := &vaultClients{secrets: s, keys: k, certs: c}
p.vaults[vaultURL] = vc
return vc, nil
}
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
vc, err := p.clients(c.Detail)
if err != nil {
return nil, err
}
var out []provider.Object
switch t {
case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates.
if s.ID == nil || deref(s.Managed) {
continue
}
out = append(out, secretObject(c, s))
}
}
case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID == nil || deref(k.Managed) {
continue
}
out = append(out, keyObject(c, k))
}
}
case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID == nil {
continue
}
out = append(out, certObject(c, cp))
}
}
default:
return nil, provider.ErrUnsupported
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
// Describe fetches key and certificate details. Secrets are returned as-is:
// the only way to get more is GetSecret, which returns the value.
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return o, err
}
switch o.Type {
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return o, cleanError(err)
}
if resp.Key != nil {
if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
}
o.Attrs = keyAttrs(resp.Key)
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return o, cleanError(err)
}
if resp.ID != nil {
o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
}
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType)
}
if cert, err := x509.ParseCertificate(resp.CER); err == nil {
o.Attrs = certAttrs(cert, resp.X509Thumbprint)
}
}
return o, nil
}
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return nil, err
}
var out []provider.Object
switch o.Type {
case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
if s.ID != nil {
out = append(out, secretObject(o.Container, s))
}
}
}
case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil {
out = append(out, keyObject(o.Container, k))
}
}
}
case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
out = append(out, certObject(o.Container, cp))
}
}
}
default:
return nil, provider.ErrUnsupported
}
for i := range out {
out[i].Reference = versionedReference(out[i])
}
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
return out, nil
}
// versionedReference pins an object's reference to its version, e.g.
// @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/name/v1).
func versionedReference(o provider.Object) string {
if o.Version == "" {
return o.Reference
}
if ref, ok := strings.CutSuffix(o.Reference, ")"); ok && o.Type == provider.TypeSecret {
return ref + "/" + o.Version + ")"
}
return o.Reference + "/" + o.Version
}
// Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
}
var _ provider.Locator = (*Provider)(nil)
// validHost guards the Resource Graph query against odd input.
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
// Locate finds the subscription of the vault with the given host: a pinned
// vault, or one Resource Graph query across the account's subscriptions.
// Vaults in hidden subscriptions aren't found.
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
host = strings.ToLower(host)
if !validHost.MatchString(host) {
return "", false, fmt.Errorf("not a vault host: %q", host)
}
url := "https://" + host + "/"
for _, v := range p.opts.Vaults {
if strings.EqualFold(strings.TrimSuffix(v, "/")+"/", url) {
return pinnedID, true, nil
}
}
resp, err := p.graph.Resources(ctx, armresourcegraph.QueryRequest{
Query: to.Ptr(`resources
| where type =~ 'microsoft.keyvault/vaults' and tostring(properties.vaultUri) =~ '` + url + `'
| project subscriptionId`),
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
}, nil)
if err != nil {
return "", false, cleanError(err)
}
rows, _ := resp.Data.([]any)
for _, r := range rows {
m, _ := r.(map[string]any)
sub, _ := m["subscriptionId"].(string)
if sub != "" && !config.Hidden(p.opts.HideSubscriptions, "", sub) {
return sub, true, nil
}
}
return "", false, nil
}
+419
View File
@@ -0,0 +1,419 @@
package azure
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"net"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"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/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
type staticCred struct{ err error }
func (c staticCred) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
if c.err != nil {
return azcore.AccessToken{}, c.err
}
return azcore.AccessToken{Token: "token", ExpiresOn: time.Now().Add(time.Hour)}, nil
}
// fakeAzure serves just enough of ARM and the Key Vault data plane over TLS,
// including Key Vault's 401 auth challenge.
func fakeAzure(t *testing.T, cred azcore.TokenCredential, handler http.HandlerFunc) (*Provider, *httptest.Server) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
w.Header().Set("WWW-Authenticate",
`Bearer authorization="https://login.microsoftonline.com/tenant", resource="https://vault.azure.net"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
handler(w, r)
}))
t.Cleanup(srv.Close)
client := azcore.ClientOptions{
Transport: srv.Client(),
Retry: policy.RetryOptions{MaxRetries: -1},
Cloud: cloud.Configuration{
ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/",
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
cloud.ResourceManager: {Endpoint: srv.URL, Audience: "https://management.azure.com"},
},
},
}
p, err := newWithOptions(cred, options{
arm: &arm.ClientOptions{ClientOptions: client},
client: client,
skipChallengeValidation: true,
})
if err != nil {
t.Fatal(err)
}
return p, srv
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func vault(srv *httptest.Server) provider.Container {
return provider.Container{ID: "/subscriptions/s/vault", Name: "kv", Detail: srv.URL + "/", Types: objectTypes}
}
func TestTokenFailureIsAuthError(t *testing.T) {
login := []string{"az", "login"}
cred := authCredential{cred: staticCred{err: errors.New("AADSTS700082: refresh token expired")}, login: login}
p, srv := fakeAzure(t, cred, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("request sent without a token: %s", r.URL)
})
check := func(name string, err error) {
t.Helper()
var auth *provider.AuthError
if !errors.As(err, &auth) {
t.Fatalf("%s: error %v is not an AuthError", name, err)
}
if !reflect.DeepEqual(auth.Login, login) || !strings.Contains(auth.Error(), "AADSTS700082") {
t.Errorf("%s: unexpected AuthError %+v", name, auth)
}
}
_, err := p.Sources(context.Background())
check("Sources (ARM)", err)
_, err = p.Objects(context.Background(), vault(srv), provider.TypeSecret)
check("Objects (Key Vault)", err)
_, err = p.Containers(context.Background(), provider.Source{ID: "s"})
check("Containers (no ARM fallback on auth errors)", err)
}
func TestAuthErrorNamesAccount(t *testing.T) {
cred := authCredential{cred: staticCred{err: errors.New("AADSTS53003")}, account: "Azure · admin"}
_, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{"x"}})
var auth *provider.AuthError
if !errors.As(err, &auth) || auth.Who() != "Azure · admin" || !strings.HasPrefix(auth.Error(), "Azure · admin:") {
t.Errorf("err = %v", err)
}
}
func TestListSecretsSkipsManagedAndPages(t *testing.T) {
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/secrets" && r.URL.Query().Get("page") == "":
writeJSON(w, 200, map[string]any{
"value": []any{
map[string]any{"id": srvURL + "/secrets/b-secret", "contentType": "text/plain",
"attributes": map[string]any{"enabled": true, "updated": 1767225600}, "tags": map[string]any{"env": "prod"}},
map[string]any{"id": srvURL + "/secrets/tls-cert", "managed": true},
},
"nextLink": srvURL + "/secrets?page=2&api-version=" + r.URL.Query().Get("api-version"),
})
case r.URL.Path == "/secrets":
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": srvURL + "/secrets/a-secret"}}})
default:
http.NotFound(w, r)
}
})
srvURL = srv.URL
got, err := p.Objects(context.Background(), vault(srv), provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
var names []string
for _, o := range got {
names = append(names, o.Name)
}
if !reflect.DeepEqual(names, []string{"a-secret", "b-secret"}) {
t.Fatalf("names = %v, want sorted and without the managed secret", names)
}
b := got[1]
if b.ContentType != "text/plain" || b.Tags["env"] != "prod" || b.Enabled == nil || !*b.Enabled ||
b.Updated.IsZero() || b.Type != provider.TypeSecret {
t.Errorf("metadata not mapped: %+v", b)
}
if want := "@Microsoft.KeyVault(SecretUri=" + srv.URL + "/secrets/b-secret)"; b.Reference != want {
t.Errorf("Reference = %q, want %q", b.Reference, want)
}
}
func TestForbiddenIsShortened(t *testing.T) {
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 403, map[string]any{"error": map[string]any{
"code": "Forbidden", "message": "Caller is not authorized to perform action on resource.",
}})
})
_, err := p.Objects(context.Background(), vault(srv), provider.TypeKey)
if err == nil {
t.Fatal("expected error")
}
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
t.Errorf("error = %q, want %q", got, want)
}
var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 {
t.Error("original ResponseError lost from chain")
}
}
func TestShortenDenied(t *testing.T) {
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
for _, variant := range []string{msg, strings.ReplaceAll(msg, "\r\n", " ")} {
if got := shortenDenied(variant); got != want {
t.Errorf("got %q", got)
}
}
if got := shortenDenied("Public network access is disabled."); got != "Public network access is disabled." {
t.Errorf("unrelated message changed: %q", got)
}
}
func TestSecretValue(t *testing.T) {
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/secrets/db-password/v2" && r.URL.Path != "/secrets/db-password/" {
http.NotFound(w, r)
return
}
writeJSON(w, 200, map[string]any{"id": srvURL + r.URL.Path, "value": "value-of-" + r.URL.Path})
})
srvURL = srv.URL
o := provider.Object{Container: vault(srv), Type: provider.TypeSecret, Name: "db-password"}
for version, want := range map[string]string{"": "value-of-/secrets/db-password/", "v2": "value-of-/secrets/db-password/v2"} {
o.Version = version
v, err := p.Value(context.Background(), o)
if err != nil {
t.Fatal(err)
}
if v.Reveal() != want {
t.Errorf("version %q: got %q", version, v.Reveal())
}
}
}
func TestDescribeCertificate(t *testing.T) {
der, cert := selfSigned(t)
thumb := sha1.Sum(der)
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"id": srvURL + "/certificates/app-tls/abc123",
"cer": base64.StdEncoding.EncodeToString(der),
"x5t": base64.RawURLEncoding.EncodeToString(thumb[:]),
"policy": map[string]any{"secret_props": map[string]any{"contentType": "application/x-pem-file"}},
})
})
srvURL = srv.URL
o, err := p.Describe(context.Background(), provider.Object{Container: vault(srv), Type: provider.TypeCertificate, Name: "app-tls"})
if err != nil {
t.Fatal(err)
}
attrs := map[string]string{}
for _, a := range o.Attrs {
attrs[a.Label] = a.Value
}
if o.Version != "abc123" || o.ContentType != "application/x-pem-file" {
t.Errorf("version/content type = %q/%q", o.Version, o.ContentType)
}
if attrs["subject"] != "CN=app.example.com" || attrs["SANs"] != "app.example.com, 10.0.0.1" ||
!strings.HasPrefix(attrs["thumbprint"], hexColon(thumb[:2])) {
t.Errorf("attrs = %v", attrs)
}
v, err := p.Value(context.Background(), o)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(v.Reveal()))
if block == nil || block.Type != "CERTIFICATE" || !cert.Equal(mustParse(t, block.Bytes)) {
t.Errorf("certificate PEM does not round-trip: %q", v.Reveal())
}
}
func TestPublicKeyPEM(t *testing.T) {
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
ecKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
ecX, ecY := ecPoint(t, ecKey)
for name, tc := range map[string]struct {
jwk azkeys.JSONWebKey
pub any
wantDesc string
}{
"rsa": {
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeRSAHSM), N: rsaKey.N.Bytes(),
E: big.NewInt(int64(rsaKey.E)).Bytes(), KeyOps: []*azkeys.KeyOperation{to.Ptr(azkeys.KeyOperationSign)}},
pub: &rsaKey.PublicKey, wantDesc: "RSA-HSM 2048",
},
"ec": {
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeEC), Crv: to.Ptr(azkeys.CurveNameP384), X: ecX, Y: ecY},
pub: &ecKey.PublicKey, wantDesc: "EC P-384",
},
} {
t.Run(name, func(t *testing.T) {
out, err := publicKeyPEM(&tc.jwk)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(out))
got, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
t.Fatal(err)
}
if !got.(interface{ Equal(crypto.PublicKey) bool }).Equal(tc.pub) {
t.Error("public key does not round-trip")
}
if desc := keyAttrs(&tc.jwk)[0].Value; desc != tc.wantDesc {
t.Errorf("key type = %q, want %q", desc, tc.wantDesc)
}
})
}
if _, err := publicKeyPEM(&azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeOct)}); err == nil {
t.Error("symmetric key should have no public key")
}
}
func TestPortalURLs(t *testing.T) {
id := "/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv"
c := provider.Container{URL: PortalResourceURL("t1", id, "overview")}
if want := "https://portal.azure.com/#@t1/resource" + id + "/overview"; c.URL != want {
t.Errorf("vault URL = %q", c.URL)
}
if got := ObjectListURL(c, provider.TypeCertificate); got != "https://portal.azure.com/#@t1/resource"+id+"/certificates" {
t.Errorf("certificates URL = %q", got)
}
if got := ObjectListURL(provider.Container{}, provider.TypeSecret); got != "" {
t.Errorf("URL without vault page = %q", got)
}
if got := PortalResourceURL("", "", "overview"); got != "" {
t.Errorf("URL without resource = %q", got)
}
if got := portalAssetURL("", "Key", "https://kv.vault.azure.net/keys/k/v1"); got !=
"https://portal.azure.com/#asset/Microsoft_Azure_KeyVault/Key/https://kv.vault.azure.net/keys/k/v1" {
t.Errorf("asset URL = %q", got)
}
}
func TestVersionedReference(t *testing.T) {
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
if got := versionedReference(sec); got != "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s/v1)" {
t.Errorf("secret: %q", got)
}
key := provider.Object{Type: provider.TypeKey, Version: "v2", Reference: "https://kv.vault.azure.net/keys/k"}
if got := versionedReference(key); got != "https://kv.vault.azure.net/keys/k/v2" {
t.Errorf("key: %q", got)
}
}
func TestHiddenSubscriptionsAndPinnedVaults(t *testing.T) {
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"value": []any{
map[string]any{"subscriptionId": "s1", "displayName": "keep-me", "state": "Enabled", "tenantId": "t"},
map[string]any{"subscriptionId": "s2", "displayName": "platform-mgmt", "state": "Enabled", "tenantId": "t"},
map[string]any{"subscriptionId": "s3", "displayName": "by-id", "state": "Enabled", "tenantId": "t"},
}})
})
p.opts.Settings = Settings{
HideSubscriptions: []string{"PLATFORM-MGMT", "s3"},
Vaults: []string{"https://kv-pinned.vault.azure.net"},
}
srcs, err := p.Sources(context.Background())
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range srcs {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"keep-me", "Pinned vaults"}) {
t.Fatalf("sources = %v", names)
}
vaults, err := p.Containers(context.Background(), srcs[1])
if err != nil {
t.Fatal(err)
}
if len(vaults) != 1 || vaults[0].Name != "kv-pinned" || vaults[0].Detail != "https://kv-pinned.vault.azure.net/" ||
len(vaults[0].Types) != 3 {
t.Errorf("pinned vaults = %+v", vaults)
}
}
func TestGraphVault(t *testing.T) {
src := provider.Source{ID: "sub"}
c, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/"})
if !ok || c.Name != "kv" || c.Detail != "https://kv.vault.azure.net/" || len(c.Types) != 3 {
t.Errorf("graphVault = %+v, %v", c, ok)
}
if _, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv"}); ok {
t.Error("row without uri accepted")
}
}
func selfSigned(t *testing.T) ([]byte, *x509.Certificate) {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "app.example.com"},
DNSNames: []string{"app.example.com"},
IPAddresses: []net.IP{net.ParseIP("10.0.0.1")},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return der, mustParse(t, der)
}
func mustParse(t *testing.T, der []byte) *x509.Certificate {
t.Helper()
c, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
return c
}
func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
t.Helper()
b, err := k.PublicKey.Bytes()
if err != nil {
t.Fatal(err)
}
size := (len(b) - 1) / 2
return b[1 : 1+size], b[1+size:]
}
+123
View File
@@ -0,0 +1,123 @@
package azure
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)
// cliCredential gets tokens from the az CLI for one signed-in account.
//
// az can hold several accounts, but `az account get-access-token` can't be
// told which user to use: it picks the account a subscription belongs to.
// So each account is pinned with --subscription. az refuses --subscription
// together with --tenant, which the SDK's AzureCLICredential would pass for
// every Key Vault request (the vault's auth challenge names a tenant), so
// this sends --tenant only for vaults in another tenant.
//
// Tokens are cached in memory until shortly before they expire, so the
// many SDK clients don't each start an az process.
type cliCredential struct {
subscription string // pins the account; "" for az's default account
tenant string // the account's tenant, if known
run func(ctx context.Context, args ...string) ([]byte, error)
mu sync.Mutex
cache map[string]azcore.AccessToken // by resource and tenant argument
}
func newCLICredential(subscription, tenant string) *cliCredential {
return &cliCredential{subscription: subscription, tenant: tenant, run: runAz, cache: map[string]azcore.AccessToken{}}
}
// refreshMargin renews tokens this long before they expire.
const refreshMargin = 5 * time.Minute
func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
if len(opts.Scopes) != 1 {
return azcore.AccessToken{}, errors.New("az: exactly one scope is supported")
}
if opts.Claims != "" {
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
}
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
switch {
case opts.TenantID != "" && opts.TenantID != c.tenant:
// Another tenant: az picks the account itself.
args = append(args, "--tenant", opts.TenantID)
case c.subscription != "":
args = append(args, "--subscription", c.subscription)
}
key := strings.Join(args[5:], " ")
c.mu.Lock()
defer c.mu.Unlock()
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
return tok, nil
}
out, err := c.run(ctx, args...)
if err != nil {
return azcore.AccessToken{}, err
}
tok, err := parseCLIToken(out)
if err != nil {
return azcore.AccessToken{}, err
}
c.cache[key] = tok
return tok, nil
}
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
var t struct {
AccessToken string `json:"accessToken"`
ExpiresOn int64 `json:"expires_on"` // az 2.54+
ExpiresOnS string `json:"expiresOn"` // older az: local time
}
if err := json.Unmarshal(b, &t); err != nil {
return azcore.AccessToken{}, fmt.Errorf("az: unexpected output: %w", err)
}
if t.AccessToken == "" {
return azcore.AccessToken{}, errors.New("az: no access token in output")
}
exp := time.Unix(t.ExpiresOn, 0)
if t.ExpiresOn == 0 {
var err error
exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOnS, time.Local)
if err != nil {
return azcore.AccessToken{}, fmt.Errorf("az: unexpected expiry %q", t.ExpiresOnS)
}
}
return azcore.AccessToken{Token: t.AccessToken, ExpiresOn: exp}, nil
}
// azTimeout bounds one az call; a hung az would otherwise block a pane.
const azTimeout = 30 * time.Second
func runAz(ctx context.Context, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, azTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "az", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if errors.Is(err, exec.ErrNotFound) {
return nil, errors.New("az CLI not found; install it or set AZURE_* for a service principal")
}
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return nil, errors.New(msg)
}
return nil, err
}
return out, nil
}
+320
View File
@@ -0,0 +1,320 @@
package azure
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func deref[T any](p *T) T {
var zero T
if p == nil {
return zero
}
return *p
}
func tags(m map[string]*string) map[string]string {
if len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = deref(v)
}
return out
}
const portal = "https://portal.azure.com/#"
// PortalResourceURL links to a blade of an Azure resource in the portal,
// e.g. "overview" or "secrets". tenant may be empty.
func PortalResourceURL(tenant, resourceID, blade string) string {
if resourceID == "" {
return ""
}
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
}
// portalAssetURL links to a Key Vault object's page; objectID must include
// the version.
func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
}
func tenantPrefix(tenant string) string {
if tenant == "" {
return ""
}
return "@" + tenant + "/"
}
// ObjectListURL links to the vault's list of the given object type,
// derived from the container's overview URL.
func ObjectListURL(c provider.Container, t provider.ObjectType) string {
base, ok := strings.CutSuffix(c.URL, "/overview")
if !ok {
return ""
}
switch t {
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
}
// objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
}
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, "secrets", name)
o := provider.Object{
Container: c,
Type: provider.TypeSecret,
ID: url,
Name: name,
Version: p.ID.Version(),
ContentType: deref(p.ContentType),
Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: ObjectListURL(c, provider.TypeSecret),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
return o
}
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name()
url := objectURL(c, "keys", name)
o := provider.Object{
Container: c,
Type: provider.TypeKey,
ID: url,
Name: name,
Version: p.KID.Version(),
Tags: tags(p.Tags),
Reference: url,
URL: ObjectListURL(c, provider.TypeKey),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
return o
}
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, "certificates", name)
o := provider.Object{
Container: c,
Type: provider.TypeCertificate,
ID: url,
Name: name,
Version: p.ID.Version(),
Tags: tags(p.Tags),
Reference: url,
URL: ObjectListURL(c, provider.TypeCertificate),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
if len(p.X509Thumbprint) > 0 {
o.Attrs = []provider.Attr{{Label: "thumbprint", Value: hexColon(p.X509Thumbprint)}}
}
return o
}
// keyAttrs describes a JSON Web Key, e.g. "RSA-HSM 3072" or "EC P-256".
func keyAttrs(k *azkeys.JSONWebKey) []provider.Attr {
if k == nil {
return nil
}
kty := string(deref(k.Kty))
desc := kty
switch {
case len(k.N) > 0:
desc = fmt.Sprintf("%s %d", kty, new(big.Int).SetBytes(k.N).BitLen())
case k.Crv != nil:
desc = kty + " " + string(*k.Crv)
}
attrs := []provider.Attr{{Label: "key type", Value: desc}}
var ops []string
for _, op := range k.KeyOps {
ops = append(ops, string(deref(op)))
}
if len(ops) > 0 {
attrs = append(attrs, provider.Attr{Label: "ops", Value: strings.Join(ops, ", ")})
}
return attrs
}
// publicKeyPEM encodes the public half of a JSON Web Key as PEM. Key Vault
// never returns private material for keys, and this ignores it regardless.
func publicKeyPEM(k *azkeys.JSONWebKey) (string, error) {
if k == nil {
return "", errors.New("key has no key material")
}
var pub any
switch {
case len(k.N) > 0 && len(k.E) > 0:
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(k.N), E: int(new(big.Int).SetBytes(k.E).Int64())}
case len(k.X) > 0 && len(k.Y) > 0:
var curve elliptic.Curve
switch deref(k.Crv) {
case azkeys.CurveNameP256:
curve = elliptic.P256()
case azkeys.CurveNameP384:
curve = elliptic.P384()
case azkeys.CurveNameP521:
curve = elliptic.P521()
default:
return "", fmt.Errorf("cannot encode curve %s as PEM", deref(k.Crv))
}
size := (curve.Params().BitSize + 7) / 8
point := make([]byte, 1+2*size)
point[0] = 4 // uncompressed
copy(point[1:], leftPad(k.X, size))
copy(point[1+size:], leftPad(k.Y, size))
ec, err := ecdsa.ParseUncompressedPublicKey(curve, point)
if err != nil {
return "", err
}
pub = ec
default:
return "", fmt.Errorf("%s keys have no public key", deref(k.Kty))
}
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", err
}
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))), nil
}
func leftPad(b []byte, n int) []byte {
if len(b) >= n {
return b
}
return append(make([]byte, n-len(b)), b...)
}
// certAttrs describes an X.509 certificate.
func certAttrs(cert *x509.Certificate, thumbprint []byte) []provider.Attr {
attrs := []provider.Attr{
{Label: "subject", Value: cert.Subject.String()},
{Label: "issuer", Value: cert.Issuer.String()},
}
if len(thumbprint) > 0 {
attrs = append(attrs, provider.Attr{Label: "thumbprint", Value: hexColon(thumbprint)})
}
var sans []string
sans = append(sans, cert.DNSNames...)
sans = append(sans, cert.EmailAddresses...)
for _, ip := range cert.IPAddresses {
sans = append(sans, ip.String())
}
for _, u := range cert.URIs {
sans = append(sans, u.String())
}
if len(sans) > 0 {
attrs = append(attrs, provider.Attr{Label: "SANs", Value: strings.Join(sans, ", ")})
}
attrs = append(attrs, provider.Attr{Label: "valid from", Value: cert.NotBefore.Local().Format("2006-01-02 15:04")})
return attrs
}
func certPEM(der []byte) string {
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
}
func hexColon(b []byte) string {
parts := make([]string, len(b))
for i, x := range b {
parts[i] = fmt.Sprintf("%02X", x)
}
return strings.Join(parts, ":")
}
// cleanError shortens the SDK's multi-line HTTP errors to "403 Forbidden:
// <service message>", keeping the original in the chain.
func cleanError(err error) error {
var re *azcore.ResponseError
if !errors.As(err, &re) {
return err
}
msg := re.ErrorCode
if re.RawResponse != nil {
if body, rerr := runtime.Payload(re.RawResponse); rerr == nil {
var payload struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &payload) == nil && payload.Error.Message != "" {
msg = payload.Error.Message
}
}
}
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
}
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
// sentences plus caller and resource IDs, to its first sentence and the
// missing action.
func shortenDenied(msg string) string {
const marker = "Action: '"
i := strings.Index(msg, marker)
if i < 0 {
return msg
}
action, _, ok := strings.Cut(msg[i+len(marker):], "'")
if !ok || action == "" {
return msg
}
first := msg
if k := strings.IndexAny(first, "\r\n"); k >= 0 {
first = first[:k]
}
first, _, _ = strings.Cut(first, ". ")
return strings.TrimSuffix(first, ".") + "; missing " + action
}
type serviceError struct {
status int
msg string
err error
}
func (e *serviceError) Error() string {
s := fmt.Sprintf("%d %s", e.status, http.StatusText(e.status))
if e.msg != "" {
s += ": " + e.msg
}
return s
}
func (e *serviceError) Unwrap() error { return e.err }
+367
View File
@@ -0,0 +1,367 @@
// Package databricks is the Databricks provider. Sources are workspace
// profiles from ~/.databrickscfg, containers are secret scopes, and objects
// are secrets. Secrets in Azure Key Vault-backed scopes are read through
// Databricks, falling back to the vault itself.
package databricks
import (
"cmp"
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/client"
dbconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/service/workspace"
"gopkg.in/ini.v1"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
)
// KeyVault reads Azure Key Vault secrets directly; the Azure provider
// implements it. Containers passed to it only have Detail (the vault URL)
// set.
type KeyVault interface {
Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error)
Value(ctx context.Context, o provider.Object) (provider.SecretValue, error)
}
type profile struct {
name string
host string
authType string
}
type Provider struct {
profiles []profile
keyVault KeyVault // nil disables the fallback
newClient func(profile) (*workspace.SecretsAPI, error)
mu sync.Mutex
clients map[string]*workspace.SecretsAPI // by profile name
vaults map[string]string // AKV-backed scope ID → vault URL
}
var _ provider.Provider = (*Provider)(nil)
// New reads workspace profiles from the Databricks config file
// ($DATABRICKS_CONFIG_FILE or ~/.databrickscfg), leaving out hideProfiles.
// A missing file yields a provider with no sources.
func New(kv KeyVault, hideProfiles []string) (*Provider, error) {
path := os.Getenv("DATABRICKS_CONFIG_FILE")
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
path = filepath.Join(home, ".databrickscfg")
}
all, err := readProfiles(path)
if err != nil {
return nil, err
}
var profiles []profile
for _, pr := range all {
if !config.Hidden(hideProfiles, pr.name, pr.name) {
profiles = append(profiles, pr)
}
}
return &Provider{
profiles: profiles,
keyVault: kv,
newClient: profileClient,
clients: map[string]*workspace.SecretsAPI{},
vaults: map[string]string{},
}, nil
}
// profileClient is a client for the secrets API alone. The SDK's
// WorkspaceClient would link in every Databricks service, some 10 MB.
func profileClient(p profile) (*workspace.SecretsAPI, error) {
return secretsClient(&dbconfig.Config{Profile: p.name})
}
func secretsClient(cfg *dbconfig.Config) (*workspace.SecretsAPI, error) {
c, err := client.New(cfg)
if err != nil {
return nil, err
}
return workspace.NewSecrets(c), nil
}
// readProfiles returns the workspace profiles in file order, skipping
// account-console profiles, which have no secret scopes.
func readProfiles(path string) ([]profile, error) {
f, err := ini.Load(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var out []profile
for _, s := range f.Sections() {
host := s.Key("host").String()
if host == "" || s.HasKey("account_id") || strings.Contains(host, "accounts.") {
continue
}
out = append(out, profile{name: s.Name(), host: host, authType: s.Key("auth_type").String()})
}
return out, nil
}
// loginCommand is what signs the user back in for a profile, or nil if
// there is nothing to run (e.g. a personal access token).
func loginCommand(p profile) []string {
switch p.authType {
case "azure-cli":
return []string{"az", "login"}
case "pat":
return nil
default:
return []string{"databricks", "auth", "login", "--profile", p.name}
}
}
func (p *Provider) Kind() provider.Kind { return provider.KindDatabricks }
func (p *Provider) ID() string { return string(provider.KindDatabricks) }
func (p *Provider) Label() string { return string(provider.KindDatabricks) }
func (p *Provider) Sources(context.Context) ([]provider.Source, error) {
out := make([]provider.Source, 0, len(p.profiles))
for _, pr := range p.profiles {
host := strings.TrimPrefix(pr.host, "https://")
out = append(out, provider.Source{
Kind: provider.KindDatabricks, ID: pr.name, Name: pr.name,
Detail: host, URL: "https://" + host,
})
}
return out, nil
}
func (p *Provider) profile(name string) (profile, bool) {
for _, pr := range p.profiles {
if pr.name == name {
return pr, true
}
}
return profile{}, false
}
func (p *Provider) client(name string) (*workspace.SecretsAPI, profile, error) {
pr, ok := p.profile(name)
if !ok {
return nil, pr, fmt.Errorf("unknown profile %q", name)
}
p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.clients[name]; ok {
return c, pr, nil
}
c, err := p.newClient(pr)
if err != nil {
return nil, pr, classify(pr, err)
}
p.clients[name] = c
return c, pr, nil
}
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
w, pr, err := p.client(src.ID)
if err != nil {
return nil, err
}
scopes, err := w.ListScopesAll(ctx)
if err != nil {
return nil, classify(pr, err)
}
out := make([]provider.Container, 0, len(scopes))
p.mu.Lock()
defer p.mu.Unlock()
for _, s := range scopes {
c := provider.Container{Source: src, ID: src.ID + "/" + s.Name, Name: s.Name, Detail: "Databricks-backed"}
if s.BackendType == workspace.ScopeBackendTypeAzureKeyvault && s.KeyvaultMetadata != nil {
c.Detail = akvDetail + s.KeyvaultMetadata.DnsName
c.URL = azure.PortalResourceURL("", s.KeyvaultMetadata.ResourceId, "overview")
p.vaults[c.ID] = s.KeyvaultMetadata.DnsName
}
out = append(out, c)
}
slices.SortFunc(out, func(a, b provider.Container) int {
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return out, nil
}
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
// back to the scope's Detail, so a scope remembered from an earlier session
// (a favourite) works before its workspace's scopes are listed.
func (p *Provider) vaultURL(c provider.Container) string {
p.mu.Lock()
url := p.vaults[c.ID]
p.mu.Unlock()
if url == "" {
url, _ = strings.CutPrefix(c.Detail, akvDetail)
if url == c.Detail {
url = ""
}
}
return url
}
const akvDetail = "Azure Key Vault-backed: "
func secretObject(c provider.Container, key string, updated time.Time) provider.Object {
return provider.Object{
Container: c,
Type: provider.TypeSecret,
ID: c.ID + "/" + key,
Name: key,
Updated: updated,
Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
URL: azure.ObjectListURL(c, provider.TypeSecret), // AKV-backed only
}
}
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
if t != provider.TypeSecret {
return nil, provider.ErrUnsupported
}
w, pr, err := p.client(c.Source.ID)
if err != nil {
return nil, err
}
var out []provider.Object
secrets, err := w.ListSecretsAll(ctx, workspace.ListSecretsRequest{Scope: c.Name})
if err == nil {
for _, s := range secrets {
var updated time.Time
if s.LastUpdatedTimestamp > 0 {
updated = time.UnixMilli(s.LastUpdatedTimestamp)
}
out = append(out, secretObject(c, s.Key, updated))
}
} else {
dbxErr := classify(pr, err)
out, err = p.vaultObjects(ctx, c)
if err != nil {
return nil, combine(dbxErr, err)
}
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.vaultURL(c)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return nil, errNoFallback
}
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
if err != nil {
return nil, err
}
out := make([]provider.Object, 0, len(secrets))
for _, s := range secrets {
out = append(out, secretObject(c, s.Name, s.Updated))
}
return out, nil
}
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
return o, ctx.Err()
}
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
return nil, provider.ErrUnsupported
}
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
w, pr, err := p.client(o.Container.Source.ID)
if err != nil {
return provider.SecretValue{}, err
}
resp, err := w.GetSecret(ctx, workspace.GetSecretRequest{Scope: o.Container.Name, Key: o.Name})
if err == nil {
b, derr := base64.StdEncoding.DecodeString(resp.Value)
if derr != nil {
return provider.SecretValue{}, fmt.Errorf("decoding secret: %w", derr)
}
return provider.NewSecretValue(b), nil
}
dbxErr := classify(pr, err)
url := p.vaultURL(o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return provider.SecretValue{}, dbxErr
}
v, kvErr := p.keyVault.Value(ctx, provider.Object{
Container: provider.Container{Detail: url},
Type: provider.TypeSecret,
Name: o.Name,
})
if kvErr != nil {
return provider.SecretValue{}, combine(dbxErr, kvErr)
}
return v, nil
}
var errNoFallback = errors.New("no Key Vault fallback")
// combine reports both the Databricks and the Key Vault failure. An auth
// error from either stays reachable with errors.As.
func combine(dbxErr, kvErr error) error {
if errors.Is(kvErr, errNoFallback) {
return dbxErr
}
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
}
// classify turns authentication failures into *provider.AuthError: a 401
// from the API, or failing to get a token before the request was sent.
func classify(pr profile, err error) error {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
var api *apierr.APIError
if errors.As(err, &api) {
if api.StatusCode != 401 {
return err
}
} else if !strings.Contains(err.Error(), "cannot get access token") &&
!strings.Contains(err.Error(), " auth: ") {
return err
}
return &provider.AuthError{Kind: provider.KindDatabricks, Login: loginCommand(pr), Err: err}
}
var _ provider.Locator = (*Provider)(nil)
// Locate finds the profile for a workspace host. It doesn't contact the
// workspace.
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
want := normalizeHost(host)
for _, pr := range p.profiles {
if normalizeHost(pr.host) == want {
return pr.name, true, nil
}
}
return "", false, nil
}
func normalizeHost(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
h = strings.TrimPrefix(h, "https://")
return strings.TrimSuffix(h, "/")
}
@@ -0,0 +1,293 @@
package databricks
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
dbconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/service/workspace"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestReadProfiles(t *testing.T) {
path := filepath.Join(t.TempDir(), "databrickscfg")
cfg := `[DEFAULT]
host = https://adb-1.1.azuredatabricks.net
auth_type = azure-cli
[INFRA-DEV]
host = adb-2.2.azuredatabricks.net
auth_type = azure-cli
[ACCOUNT]
host = https://accounts.azuredatabricks.net
auth_type = azure-cli
[acct-by-id]
host = https://example.cloud.databricks.com
account_id = 123
[oauth]
host = https://adb-3.3.azuredatabricks.net
auth_type = databricks-cli
[no-host]
token = x
`
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
got, err := readProfiles(path)
if err != nil {
t.Fatal(err)
}
want := []profile{
{name: "DEFAULT", host: "https://adb-1.1.azuredatabricks.net", authType: "azure-cli"},
{name: "INFRA-DEV", host: "adb-2.2.azuredatabricks.net", authType: "azure-cli"},
{name: "oauth", host: "https://adb-3.3.azuredatabricks.net", authType: "databricks-cli"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("profiles = %+v\nwant %+v", got, want)
}
if got, err := readProfiles(filepath.Join(t.TempDir(), "missing")); err != nil || got != nil {
t.Errorf("missing file: %v, %v", got, err)
}
}
func TestLoginCommand(t *testing.T) {
for authType, want := range map[string][]string{
"azure-cli": {"az", "login"},
"databricks-cli": {"databricks", "auth", "login", "--profile", "p"},
"": {"databricks", "auth", "login", "--profile", "p"},
"pat": nil,
} {
if got := loginCommand(profile{name: "p", authType: authType}); !reflect.DeepEqual(got, want) {
t.Errorf("%q: %v, want %v", authType, got, want)
}
}
}
// fakeKeyVault stands in for the Azure provider.
type fakeKeyVault struct {
secrets map[string]string // name → value, for vault URL kvURL
err error
calls int
}
const kvURL = "https://kv-app-prod.vault.azure.net/"
func (k *fakeKeyVault) Objects(_ context.Context, c provider.Container, _ provider.ObjectType) ([]provider.Object, error) {
k.calls++
if k.err != nil {
return nil, k.err
}
if c.Detail != kvURL {
return nil, errors.New("wrong vault " + c.Detail)
}
var out []provider.Object
for name := range k.secrets {
out = append(out, provider.Object{Name: name})
}
return out, nil
}
func (k *fakeKeyVault) Value(_ context.Context, o provider.Object) (provider.SecretValue, error) {
k.calls++
if k.err != nil {
return provider.SecretValue{}, k.err
}
if o.Container.Detail != kvURL {
return provider.SecretValue{}, errors.New("wrong vault " + o.Container.Detail)
}
return provider.NewSecretValue([]byte(k.secrets[o.Name])), nil
}
// workspaceAPI serves the secrets API. The "akv" scope behaves like an Azure
// Key Vault-backed scope whose secrets Databricks refuses to read.
func workspaceAPI(t *testing.T, status int) *httptest.Server {
t.Helper()
write := func(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status != 0 {
write(w, status, map[string]any{"error_code": "UNAUTHENTICATED", "message": "Token is expired"})
return
}
q := r.URL.Query()
switch {
case r.URL.Path == "/api/2.0/secrets/scopes/list":
write(w, 200, map[string]any{"scopes": []any{
map[string]any{"name": "app", "backend_type": "DATABRICKS"},
map[string]any{"name": "akv", "backend_type": "AZURE_KEYVAULT",
"keyvault_metadata": map[string]any{"dns_name": kvURL, "resource_id": "/x/kv-app-prod"}},
}})
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "app":
write(w, 200, map[string]any{"secrets": []any{
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
map[string]any{"key": "client-secret"},
}})
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
write(w, 200, map[string]any{"key": q.Get("key"),
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
case strings.HasPrefix(r.URL.Path, "/api/2.0/secrets/") && q.Get("scope") == "akv":
write(w, 400, map[string]any{"error_code": "BAD_REQUEST",
"message": "Reading secrets from Azure KeyVault-backed scopes is not supported"})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func newTestProvider(t *testing.T, srv *httptest.Server, kv KeyVault) *Provider {
t.Helper()
return &Provider{
profiles: []profile{{name: "prod", host: srv.URL, authType: "azure-cli"}},
keyVault: kv,
newClient: func(profile) (*workspace.SecretsAPI, error) {
return secretsClient(&dbconfig.Config{Host: srv.URL, Token: "token"})
},
clients: map[string]*workspace.SecretsAPI{},
vaults: map[string]string{},
}
}
func scopes(t *testing.T, p *Provider) map[string]provider.Container {
t.Helper()
srcs, _ := p.Sources(context.Background())
cs, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Container{}
for _, c := range cs {
out[c.Name] = c
}
return out
}
func TestScopesSecretsAndValues(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 0), nil)
sc := scopes(t, p)
if sc["app"].Detail != "Databricks-backed" || sc["akv"].Detail != "Azure Key Vault-backed: "+kvURL {
t.Errorf("scope details = %q / %q", sc["app"].Detail, sc["akv"].Detail)
}
objs, err := p.Objects(context.Background(), sc["app"], provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
if len(objs) != 2 || objs[0].Name != "client-secret" || objs[1].Name != "storage-key" {
t.Fatalf("objects = %+v", objs)
}
if objs[1].Updated.IsZero() || objs[1].Reference != "{{secrets/app/storage-key}}" {
t.Errorf("metadata = %+v", objs[1])
}
v, err := p.Value(context.Background(), objs[1])
if err != nil || v.Reveal() != "value-of-storage-key" {
t.Errorf("value = %q, %v", v.Reveal(), err)
}
}
func TestAKVScopeFallsBackToKeyVault(t *testing.T) {
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
p := newTestProvider(t, workspaceAPI(t, 0), kv)
akv := scopes(t, p)["akv"]
objs, err := p.Objects(context.Background(), akv, provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
if len(objs) != 1 || objs[0].Name != "db-password" || objs[0].Reference != "{{secrets/akv/db-password}}" {
t.Fatalf("objects = %+v", objs)
}
v, err := p.Value(context.Background(), objs[0])
if err != nil || v.Reveal() != "from-the-vault" {
t.Errorf("value = %q, %v", v.Reveal(), err)
}
}
func TestAKVFallbackForRememberedScope(t *testing.T) {
// A favourite from an earlier session: scopes not listed yet.
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
p := newTestProvider(t, workspaceAPI(t, 0), kv)
akv := provider.Container{
Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod"},
ID: "prod/akv", Name: "akv", Detail: "Azure Key Vault-backed: " + kvURL,
}
v, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
if err != nil || v.Reveal() != "from-the-vault" {
t.Errorf("value = %q, %v", v.Reveal(), err)
}
}
func TestAKVFallbackFailureReportsBoth(t *testing.T) {
kvAuth := &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("expired")}
p := newTestProvider(t, workspaceAPI(t, 0), &fakeKeyVault{err: kvAuth})
akv := scopes(t, p)["akv"]
_, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "Key Vault fallback") {
t.Fatalf("err = %v", err)
}
var auth *provider.AuthError
if !errors.As(err, &auth) {
t.Error("Key Vault auth error not reachable for the sign-in prompt")
}
}
func TestNoFallbackForDatabricksBackedScopes(t *testing.T) {
kv := &fakeKeyVault{}
p := newTestProvider(t, workspaceAPI(t, 0), kv)
app := scopes(t, p)["app"]
if _, err := p.Value(context.Background(), provider.Object{Container: app, Name: "missing"}); err != nil {
// app/get returns a value for any key; just make sure the vault wasn't asked
t.Fatal(err)
}
if kv.calls != 0 {
t.Errorf("Key Vault consulted for a Databricks-backed scope")
}
}
func TestUnauthorizedIsAuthError(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 401), nil)
srcs, _ := p.Sources(context.Background())
_, err := p.Containers(context.Background(), srcs[0])
var auth *provider.AuthError
if !errors.As(err, &auth) {
t.Fatalf("err = %v, want AuthError", err)
}
if auth.Kind != provider.KindDatabricks || !reflect.DeepEqual(auth.Login, []string{"az", "login"}) {
t.Errorf("auth = %+v", auth)
}
}
func TestClassify(t *testing.T) {
pr := profile{name: "p", authType: "databricks-cli"}
var auth *provider.AuthError
if !errors.As(classify(pr, errors.New("default auth: azure-cli: cannot get access token: run az login")), &auth) {
t.Error("token failure not classified as auth")
}
if errors.As(classify(pr, errors.New("dial tcp: no such host")), &auth) {
t.Error("network error classified as auth")
}
if errors.As(classify(pr, context.Canceled), &auth) {
t.Error("cancellation classified as auth")
}
}
+407
View File
@@ -0,0 +1,407 @@
// Package fake is an in-memory provider used by tests and --demo mode.
package fake
import (
"cmp"
"context"
"errors"
"fmt"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Provider serves a fixed tree of sources, containers and objects,
// optionally sleeping before every call to simulate network latency.
type Provider struct {
kind provider.Kind
id, label string
delay time.Duration
sources []provider.Source
containers map[string][]provider.Container // by source ID
objects map[listKey][]provider.Object
errs map[listKey]error
values map[string]string // by object ID
versions map[string][]provider.Object // by object ID
}
type listKey struct {
container string
typ provider.ObjectType
}
var _ provider.Provider = (*Provider)(nil)
func newProvider(kind provider.Kind, delay time.Duration) *Provider {
return &Provider{
kind: kind,
delay: delay,
containers: map[string][]provider.Container{},
objects: map[listKey][]provider.Object{},
errs: map[listKey]error{},
values: map[string]string{},
versions: map[string][]provider.Object{},
}
}
func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string {
if p.id != "" {
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider {
p.id, p.label = id, label
return p
}
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
return p.sources, nil
}
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
return p.containers[src.ID], nil
}
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
k := listKey{p.containerID(c), t}
if err := p.errs[k]; err != nil {
return nil, err
}
return p.objects[k], nil
}
// Describe returns o unchanged; the fixtures already carry their details.
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
return o, ctx.Err()
}
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
if p.kind == provider.KindDatabricks {
return nil, provider.ErrUnsupported
}
vs := p.versions[o.ID]
if len(vs) == 0 { // every object has at least its current version
return []provider.Object{o}, nil
}
out := make([]provider.Object, len(vs))
for i, v := range vs {
v.Container, v.Type, v.ID, v.Name, v.Reference, v.URL = o.Container, o.Type, o.ID, o.Name, o.Reference, o.URL
out[i] = v
}
return out, nil
}
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
if err := p.wait(ctx); err != nil {
return provider.SecretValue{}, err
}
if o.ID == "" { // built from a link, like the real providers read it
o.ID = p.containerID(o.Container) + "/" + strings.ToLower(string(cmp.Or(o.Type, provider.TypeSecret))) + "/" + o.Name
}
v, ok := p.values[o.ID+"@"+o.Version]
if !ok {
v, ok = p.values[o.ID]
}
if !ok {
return provider.SecretValue{}, fmt.Errorf("%s %q not found", strings.ToLower(string(o.Type)), o.Name)
}
return provider.NewSecretValue([]byte(v)), nil
}
// Locate finds the source holding the vault (by host) or the workspace.
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
if err := p.wait(ctx); err != nil {
return "", false, err
}
for _, src := range p.sources {
if p.kind == provider.KindDatabricks && src.Detail == host {
return src.ID, true, nil
}
for _, c := range p.containers[src.ID] {
if c.Detail == "https://"+host {
return src.ID, true, nil
}
}
}
return "", false, nil
}
func (p *Provider) wait(ctx context.Context) error {
if p.delay <= 0 {
return ctx.Err()
}
t := time.NewTimer(p.delay)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
const portal = "https://portal.azure.com/#resource"
// containerID finds a container by ID, or by vault URL like the real Azure
// provider, which only needs the URL (vaults opened from links).
func (p *Provider) containerID(c provider.Container) string {
for _, cs := range p.containers {
for _, x := range cs {
if x.ID == c.ID {
return x.ID
}
}
}
// Not one listed here (built from a link): by vault URL, or a scope
// by name in its workspace.
for _, cs := range p.containers {
for _, x := range cs {
if c.Detail != "" && strings.TrimSuffix(x.Detail, "/") == strings.TrimSuffix(c.Detail, "/") {
return x.ID
}
if c.Detail == "" && c.Name == x.Name && c.Source.ID != "" && c.Source.ID == x.Source.ID {
return x.ID
}
}
}
return c.ID
}
func (p *Provider) addSource(id, name, detail string) provider.Source {
s := provider.Source{Kind: p.kind, ID: id, Name: name, Detail: detail}
if p.kind == provider.KindAzure {
s.URL = portal + "/subscriptions/" + id + "/overview"
} else {
s.URL = "https://" + detail
}
p.sources = append(p.sources, s)
return s
}
func (p *Provider) addContainer(src provider.Source, name, detail string, types ...provider.ObjectType) provider.Container {
c := provider.Container{Source: src, ID: src.ID + "/" + name, Name: name, Detail: detail, Types: types}
vault := name
if p.kind == provider.KindDatabricks {
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
}
if p.kind == provider.KindAzure || 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)
return c
}
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c
o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + map[provider.ObjectType]string{
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
}
k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o)
p.values[o.ID] = value
return o
}
func (p *Provider) fail(c provider.Container, err error, types ...provider.ObjectType) {
for _, t := range types {
p.errs[listKey{c.ID, t}] = err
}
}
var (
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
// ErrForbiddenKeys mimics a vault where the caller can read secrets but not keys.
var ErrForbiddenKeys = errors.New("403 Forbidden: caller lacks permission to list keys")
const (
demoPublicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAdemo\n-----END PUBLIC KEY-----"
demoCert = "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUDEMO\nDEMODEMODEMO\n-----END CERTIFICATE-----"
)
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
// Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
}
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated")
p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"},
},
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"},
},
Reference: objectURL("kv-app-prod", "keys", "data-encryption"),
}, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"},
{Label: "issuer", Value: "CN=Example Issuing CA"},
{Label: "thumbprint", Value: "3B:1F:9A:0C:44:E2:7D:15"},
{Label: "SANs", Value: "app.example.com, www.app.example.com"},
{Label: "valid from", Value: "2026-08-01 09:30"},
},
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"},
{Label: "issuer", Value: "CN=Example Issuing CA"},
{Label: "thumbprint", Value: "A0:77:12:DE:9B:03:C6:58"},
},
Reference: objectURL("kv-app-prod", "certificates", "old-tls"),
}, demoCert)
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
locked := p.addContainer(prod, "kv-restricted", "https://kv-restricted.vault.azure.net", allTypes...)
p.fail(locked, ErrForbidden, allTypes...)
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password")
return p
}
// AzureOps returns a second fake Azure account. It shares platform-prod
// with Azure but sees a different vault there, and has a subscription of
// its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
// Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey)
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token")
return p
}
// Databricks returns a fake Databricks provider with two workspace profiles,
// including an Azure Key Vault-backed scope. Scopes hold secrets only.
func Databricks(delay time.Duration) *Provider {
p := newProvider(provider.KindDatabricks, delay)
secret := func(c provider.Container, key, value string) {
p.add(c, provider.TypeSecret, provider.Object{
Name: key, Updated: base, Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
}, value)
}
prod := p.addSource("dbx-prod", "prod", "adb-1111111111111111.11.azuredatabricks.net")
app := p.addContainer(prod, "app", "Databricks-backed")
secret(app, "storage-key", "storage-demo-key")
secret(app, "sp-client-secret", "sp-demo-secret")
akv := p.addContainer(prod, "kv-app-prod", "Azure Key Vault-backed: kv-app-prod")
secret(akv, "db-password", "correct-horse-battery-staple")
dev := p.addSource("dbx-dev", "dev", "adb-2222222222222222.2.azuredatabricks.net")
devApp := p.addContainer(dev, "app", "Databricks-backed")
secret(devApp, "storage-key", "dev-storage-key")
return p
}
+171
View File
@@ -0,0 +1,171 @@
// Package provider defines the read-only interface every secret backend
// implements, and the types the UI works with.
//
// All backends map onto the same hierarchy:
//
// Source Azure subscription | Databricks workspace profile
// Container Key Vault | secret scope
// Object secret, key or certificate | secret
package provider
import (
"context"
"errors"
"fmt"
"time"
)
// Kind identifies a backend.
type Kind string
const (
KindAzure Kind = "Azure"
KindDatabricks Kind = "Databricks"
// KindRepo is a git repository's encrypted secret files (SOPS).
KindRepo Kind = "Repo"
)
// AuthError reports that the user must (re)authenticate with a backend, for
// example because their az CLI login has expired.
type AuthError struct {
Kind Kind
// Account names the identity when a provider has several, e.g.
// "Azure · admin.alice"; empty otherwise.
Account string
// Login is the command that signs the user in, e.g. ["az", "login"].
Login []string
Err error
}
func (e *AuthError) Error() string {
return fmt.Sprintf("%s: not signed in or session expired: %v", e.Who(), e.Err)
}
// Who is the account, or the kind if there is just one.
func (e *AuthError) Who() string {
if e.Account != "" {
return e.Account
}
return string(e.Kind)
}
func (e *AuthError) Unwrap() error { return e.Err }
// ErrUnsupported is returned by optional operations a backend cannot perform,
// such as listing versions of a Databricks secret.
var ErrUnsupported = errors.New("not supported by this provider")
// Source is a top-level account: an Azure subscription or a Databricks
// workspace profile.
type Source struct {
Kind Kind
// Provider is the ID of the provider instance the source came from.
// The app sets it; it routes requests when several providers share a
// Kind, e.g. one per Azure account.
Provider string
// ProviderLabel is that provider's Label, also set by the app.
ProviderLabel string
ID string
Name string
Detail string // e.g. tenant or workspace host
URL string // web page for the source, if any
// Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled.
Here bool
}
// ObjectType is the kind of object stored in a container.
type ObjectType string
const (
TypeSecret ObjectType = "Secret"
TypeKey ObjectType = "Key"
TypeCertificate ObjectType = "Certificate"
)
// Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct {
Source Source
ID string
Name string
Detail string // e.g. vault URL, or "Azure Key Vault-backed: kv-name"
// Types lists the object types the container holds; empty means
// secrets only.
Types []ObjectType
URL string // web page for the container, if any
}
// ObjectTypes returns the container's object types, defaulting to secrets.
func (c Container) ObjectTypes() []ObjectType {
if len(c.Types) == 0 {
return []ObjectType{TypeSecret}
}
return c.Types
}
// Attr is a type-specific metadata field, shown in provider order.
type Attr struct {
Label string
Value string
}
// Object describes a secret, key or certificate without its value.
type Object struct {
Container Container
Type ObjectType
ID string
Name string
Version string
Enabled *bool
Updated time.Time
Expires time.Time
ContentType string
Tags map[string]string
// Attrs holds type-specific metadata, e.g. key type and size, or a
// certificate's subject, issuer and thumbprint.
Attrs []Attr
// Reference is how the object is referenced from config, e.g.
// "@Microsoft.KeyVault(SecretUri=...)" or "{{secrets/scope/key}}".
Reference string
URL string // web page for the object (or its list), if any
}
// Provider is a read-only secret backend. Every method may block on the
// network and must honour ctx cancellation.
type Provider interface {
Kind() Kind
// ID identifies the instance, stable across runs, e.g. "Azure" or
// "Azure:user@example.com/tenant".
ID() string
// Label names the instance in the UI, e.g. "Azure" or "Azure · user".
Label() string
Sources(ctx context.Context) ([]Source, error)
Containers(ctx context.Context, src Source) ([]Container, error)
// Objects lists objects of type t, one of c.ObjectTypes(). It returns
// metadata only; values are fetched with Value.
Objects(ctx context.Context, c Container, t ObjectType) ([]Object, error)
// Describe returns o with details that listing doesn't include, such as
// a key's type or a certificate's subject. It never fetches secret
// values; it may return o unchanged.
Describe(ctx context.Context, o Object) (Object, error)
// Versions may return ErrUnsupported.
Versions(ctx context.Context, o Object) ([]Object, error)
// Value returns a secret's value, or the public material of a key (PEM
// public key) or certificate (PEM certificate). Private key material is
// never returned for keys.
Value(ctx context.Context, o Object) (SecretValue, error)
}
// Current is implemented by providers that know the source lazysecrets
// was started in (the current git repo) and can find it faster than
// Sources lists everything, so it shows first.
type Current interface {
Current(ctx context.Context) (Source, bool)
}
// Locator is implemented by providers that can find where a container lives,
// to open links. address is a vault host (kv.vault.azure.net) or a
// workspace host. It returns the ID of the source holding it.
type Locator interface {
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
}
+987
View File
@@ -0,0 +1,987 @@
// Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. SOPS files are the one type so far.
//
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or
// copied, one at a time, by the sops binary, with the keys sops itself
// uses (age, Azure Key Vault through az, AWS/GCP KMS, PGP).
//
// Which files: tracked by git, then, in order of preference, the include
// and exclude globs of the repo's .lazysecrets.yaml, those of the config,
// or where the repo's .sops.yaml creation_rules say SOPS files go; a repo
// with none of those is searched whole.
package repo
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
type Settings struct {
// Dir is where lazysecrets started: the repo containing it, if any,
// is listed first and marked Here, with its files under Dir first.
Dir string
// Paths are repos to show.
Paths []string
// Roots are folders of repos (like ~/git): their repos, and repos one
// level further down (org/repo).
Roots []string
// Starred are repos the user starred something in, shown wherever
// they are.
Starred []string
// Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string
// Sops is the sops binary; "sops" (on PATH) when empty.
Sops string
}
type Provider struct {
s Settings
repos []repoInfo
err error
once sync.Once
// here is the repo containing Dir, found on its own so it can show
// before the roots are scanned.
here *repoInfo
hereOnce sync.Once
}
var (
_ provider.Provider = (*Provider)(nil)
_ provider.Locator = (*Provider)(nil)
_ provider.Current = (*Provider)(nil)
)
type repoInfo struct {
root string // absolute
name string // the folder, or its path under a root (org/repo)
id string // the remote without scheme or .git, else the root path
web string // the remote's web page, if it has one
prefix string // Dir relative to root ("" when Dir isn't in it)
here bool // the repo lazysecrets started in
}
func New(s Settings) *Provider {
if s.Sops == "" {
s.Sops = "sops"
}
return &Provider{s: s}
}
func (p *Provider) Kind() provider.Kind { return provider.KindRepo }
func (p *Provider) ID() string { return "Repos" }
func (p *Provider) Label() string { return "Repos" }
func (p *Provider) load() {
p.once.Do(func() {
seen := map[string]bool{}
var roots []string
names := map[string]string{} // path under a root (org/repo)
add := func(root string) {
if !seen[root] {
seen[root] = true
roots = append(roots, root)
}
}
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current()
if inRepo {
seen[here.root] = true
}
for _, dir := range p.s.Paths {
root, err := gitRoot(expandHome(dir))
if err != nil {
p.err = errors.Join(p.err, fmt.Errorf("%s: not a git repository", dir))
continue
}
add(root)
}
for _, dir := range p.s.Roots {
base, _ := filepath.EvalSymlinks(expandHome(dir))
for _, root := range reposIn(expandHome(dir), 2) {
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
names[root] = filepath.ToSlash(name)
}
add(root)
}
}
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil {
add(root)
}
}
// A git call each for the remote: dozens under ~/git, so several
// at a time.
infos := make([]repoInfo, len(roots))
parallel(len(roots), func(i int) {
infos[i] = info(roots[i], "")
if n := names[roots[i]]; n != "" {
infos[i].name = n
}
})
if inRepo {
infos = append([]repoInfo{here}, infos...)
}
p.repos = infos
})
}
// current is the repo lazysecrets started in.
func (p *Provider) current() (repoInfo, bool) {
p.hereOnce.Do(func() {
root, err := gitRoot(p.s.Dir)
if err != nil {
return
}
rel, _ := filepath.Rel(root, absPath(p.s.Dir))
if rel == "." {
rel = ""
}
r := info(root, filepath.ToSlash(rel))
r.here = true
p.here = &r
})
if p.here == nil {
return repoInfo{}, false
}
return *p.here, true
}
// Current is the repo lazysecrets started in, if it has secret files:
// one repo to check, so it can show before the others are.
func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
r, ok := p.current()
if !ok {
return provider.Source{}, false
}
src := p.source(r)
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
return provider.Source{}, false
}
return src, true
}
func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
ID: r.root, Name: r.name, Detail: r.id, URL: r.web, Here: r.here,
}
}
// parallel runs f for 0..n-1, several at a time.
func parallel(n int, f func(i int)) {
var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
f(i)
}()
}
wg.Wait()
}
// reposIn finds git repositories in dir, depth levels down at most,
// without looking inside a repo or hidden folders.
func reposIn(dir string, depth int) []string {
entries, err := os.ReadDir(dir)
if err != nil || depth == 0 {
return nil
}
var out []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
path := filepath.Join(dir, e.Name())
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
if abs, err := filepath.EvalSymlinks(path); err == nil {
path = abs
}
out = append(out, path)
continue
}
out = append(out, reposIn(path, depth-1)...)
}
return out
}
func info(root, prefix string) repoInfo {
id, web := remoteID(root)
if id == "" {
id = root
}
return repoInfo{root: root, name: filepath.Base(root), id: id, web: web, prefix: prefix}
}
// Sources lists the repos that have secret files: one without any isn't
// worth a row.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.load()
// Each repo is checked for secret files, several at a time: under
// ~/git that's dozens of repos.
keep := make([]bool, len(p.repos))
srcs := make([]provider.Source, len(p.repos))
for i, r := range p.repos {
srcs[i] = p.source(r)
}
parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0
})
var out []provider.Source
for i, src := range srcs {
if keep[i] {
out = append(out, src)
}
}
if len(out) == 0 && p.err != nil {
return nil, p.err
}
return out, nil
}
func (p *Provider) repo(id string) (repoInfo, bool) {
if r, ok := p.current(); ok && r.root == id {
return r, true // without waiting for the others
}
p.load()
for _, r := range p.repos {
if r.root == id {
return r, true
}
}
return repoInfo{}, false
}
// Containers lists a repo's secret files: those under where lazysecrets
// started first, then the rest, each by path.
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
r, ok := p.repo(src.ID)
if !ok {
return nil, fmt.Errorf("unknown repo %s", src.ID)
}
files, err := tracked(ctx, r.root)
if err != nil {
return nil, err
}
user, sopsWhere, err := p.selector(r.root, files)
if err != nil {
return nil, err
}
// SOPS files are tracked, where .sops.yaml says they go. Env files
// usually aren't tracked (they're in .gitignore), so they're looked
// for by name among untracked and ignored files too.
envs := envFiles(r.root, files)
seen := map[string]bool{}
var out []provider.Container
add := func(f string) {
if seen[f] || !user(f) {
return
}
seen[f] = true
path := filepath.Join(r.root, f)
c := provider.Container{Source: src, ID: path, Name: f, Types: []provider.ObjectType{provider.TypeSecret}}
switch meta, isSops := readMeta(path); {
case isSops && sopsWhere(f):
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go
case isEnv(f):
c.Detail = "env · plain text"
default:
return
}
out = append(out, c)
}
for _, f := range envs {
add(f)
}
for _, f := range files {
if !inPackages(f) {
add(f)
}
}
under := func(c provider.Container) bool {
return r.prefix == "" || strings.HasPrefix(c.Name, r.prefix+"/")
}
slices.SortStableFunc(out, func(a, b provider.Container) int {
if ua, ub := under(a), under(b); ua != ub {
if ua {
return -1
}
return 1
}
return strings.Compare(a.Name, b.Name)
})
return out, nil
}
func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
if t != provider.TypeSecret {
return nil, nil
}
b, err := os.ReadFile(c.ID)
if err != nil {
return nil, err
}
meta, isSops := readMeta(c.ID)
keys, err := parseKeys(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
if !isSops { // a plain env file
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
}
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
o := provider.Object{
Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
}
if !k.encrypted {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
}
out = append(out, o)
}
return out, nil
}
func (p *Provider) Describe(_ context.Context, o provider.Object) (provider.Object, error) {
return o, nil
}
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
return nil, provider.ErrUnsupported
}
// Value decrypts one key with sops.
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file, extract = o.Container.ID, extractFor(o.Name)
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
}
if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name)
}
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return provider.SecretValue{}, errors.New("sops isn't installed: decrypting needs it (brew install sops)")
}
return provider.SecretValue{}, errors.New(sopsError(stderr.String(), err))
}
return provider.NewSecretValue(stdout.Bytes()), nil
}
// sopsError is sops' own message, whole: which keys it tried and why
// each failed can all matter.
func sopsError(stderr string, err error) string {
msg := strings.TrimSpace(stderr)
if msg == "" {
msg = err.Error()
}
return "sops couldn't decrypt it:\n" + msg
}
// Locate finds the repo a link names, by its remote (or root path).
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
p.load()
for _, r := range p.repos {
if strings.EqualFold(r.id, host) {
return r.root, true, nil
}
}
return "", false, nil
}
// --- git ---
func gitRoot(dir string) (string, error) {
if dir == "" {
return "", errors.New("no directory")
}
out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func tracked(ctx context.Context, root string) ([]string, error) {
out, err := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "-z").Output()
if err != nil {
return nil, fmt.Errorf("git ls-files in %s: %w", root, err)
}
var files []string
for _, f := range strings.Split(string(out), "\x00") {
if f != "" {
files = append(files, f)
}
}
return files, nil
}
// remoteID turns the origin remote into an ID ("git.kebcloud.no/keb/repo")
// and a web URL, both empty without a remote.
func remoteID(root string) (string, string) {
// From .git/config directly: a git process per repo is most of what
// listing dozens of them costs. git answers for worktrees and
// submodules (.git is a file) and remotes it rewrites (insteadOf).
if url, ok := originURL(filepath.Join(root, ".git", "config")); ok {
if url == "" {
return "", ""
}
return normalizeRemote(url)
}
out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output()
if err != nil {
return "", ""
}
return normalizeRemote(strings.TrimSpace(string(out)))
}
// originURL reads [remote "origin"] url from a git config file. It isn't
// ok when the file is missing or uses insteadOf, which only git resolves.
func originURL(config string) (string, bool) {
b, err := os.ReadFile(config)
if err != nil || bytes.Contains(b, []byte("insteadOf")) || bytes.Contains(b, []byte("insteadof")) {
return "", false
}
inOrigin := false
for line := range strings.SplitSeq(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
inOrigin = line == `[remote "origin"]`
continue
}
if k, v, ok := strings.Cut(line, "="); ok && inOrigin && strings.TrimSpace(k) == "url" {
return strings.TrimSpace(v), true
}
}
return "", true // no origin
}
// normalizeRemote handles https://host/path(.git), ssh://user@host[:port]/path
// and scp-style user@host:path.
func normalizeRemote(remote string) (string, string) {
u := strings.TrimSuffix(remote, ".git")
scheme := false
for _, s := range []string{"https://", "http://", "ssh://", "git://"} {
if rest, ok := strings.CutPrefix(u, s); ok {
u, scheme = rest, true
}
}
host, rest, _ := strings.Cut(u, "/")
if !scheme {
// scp-style: user@host:path
if h, r, ok := strings.Cut(u, ":"); ok {
host, rest = h, r
}
}
if at := strings.LastIndex(host, "@"); at >= 0 {
host = host[at+1:]
}
host, _, _ = strings.Cut(host, ":") // a port
if host == "" || rest == "" {
return "", ""
}
id := strings.ToLower(host + "/" + strings.Trim(rest, "/"))
if strings.Contains(host, ".") {
return id, "https://" + host + "/" + strings.Trim(rest, "/")
}
return id, ""
}
// --- which files ---
// lazysecretsFile is a repo's own .lazysecrets.yaml.
type lazysecretsFile struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
}
// selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
include, exclude := p.s.Include, p.s.Exclude
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
}
inc, err := globs(include)
if err != nil {
return nil, nil, err
}
exc, err := globs(exclude)
if err != nil {
return nil, nil, err
}
var rules []sopsRule
if len(inc) == 0 {
rules = sopsRules(root, files)
}
user = func(f string) bool {
return !anyMatch(exc, f) && (len(inc) == 0 || anyMatch(inc, f))
}
sopsWhere = func(f string) bool {
return len(rules) == 0 || slices.ContainsFunc(rules, func(r sopsRule) bool { return r.matches(f) })
}
return user, sopsWhere, nil
}
// envFiles lists env files by name (.env, .env.*, *.env), tracked or
// not, ignored ones included, leaving out examples and dependencies.
// tracked is the repo's tracked files. Untracked ones are looked for in
// the folders that have tracked files, not by walking the whole tree: an
// ignored .env sits next to the code it's for, and the walk would go
// through node_modules and the like, which is most of the time a scan
// takes.
func envFiles(root string, tracked []string) []string {
var out []string
isTracked := make(map[string]bool, len(tracked))
dirs := map[string]bool{".": true}
for _, f := range tracked {
isTracked[f] = true
if isEnv(f) {
out = append(out, f)
}
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
dirs[d] = true
}
}
for d := range dirs {
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(d)))
if err != nil {
continue
}
for _, e := range entries {
f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
out = append(out, f)
}
}
}
return out
}
// packages are folders of dependencies, caches and build output: never
// looked in for secret files.
var packages = map[string]bool{
"node_modules": true, "bower_components": true, "jspm_packages": true,
"vendor": true, "Pods": true, "Carthage": true,
".venv": true, "venv": true, "__pycache__": true, ".tox": true, ".nox": true,
".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true,
".terraform": true, ".terragrunt-cache": true,
".gradle": true, ".m2": true, ".yarn": true, ".pnpm-store": true,
".npm": true, ".cache": true, ".next": true, ".nuxt": true, ".svelte-kit": true,
".git": true,
}
// inPackages reports whether f is inside one of the packages folders.
func inPackages(f string) bool {
for d := range strings.SplitSeq(path.Dir(f), "/") {
if packages[d] {
return true
}
}
return false
}
// isEnv reports whether a file is named like an env file with real values:
// .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies.
func isEnv(f string) bool {
if inPackages(f) {
return false
}
base := strings.ToLower(path.Base(f))
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
if strings.HasSuffix(base, ext) {
return false
}
}
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
}
// plainValue reads a key from a plain env file.
func plainValue(file, name string) (provider.SecretValue, error) {
b, err := os.ReadFile(file)
if err != nil {
return provider.SecretValue{}, err
}
for _, line := range strings.Split(string(b), "\n") {
if k, v, ok := envLine(line); ok && k == name {
return provider.NewSecretValue([]byte(v)), nil
}
}
return provider.SecretValue{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
// envLine reads KEY=value (or export KEY=value), unquoting the value and
// dropping a comment after an unquoted one.
func envLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
line = strings.TrimPrefix(line, "export ")
k, v, ok := strings.Cut(line, "=")
if !ok {
return "", "", false
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') {
if end := strings.LastIndexByte(v, v[0]); end > 0 {
inner := v[1:end]
if v[0] == '"' {
inner = strings.NewReplacer(`\n`, "\n", `\"`, `"`, `\\`, `\`).Replace(inner)
}
return k, inner, true
}
}
if i := strings.Index(v, " #"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return k, v, true
}
// sopsRule is a creation rule of a .sops.yaml, which applies to files
// under the directory it's in.
type sopsRule struct {
dir string // relative to the repo root, "" for the root
re *regexp.Regexp
}
func (r sopsRule) matches(f string) bool {
rel := f
if r.dir != "" {
var ok bool
if rel, ok = strings.CutPrefix(f, r.dir+"/"); !ok {
return false
}
}
return r.re == nil || r.re.MatchString(rel)
}
func sopsRules(root string, files []string) []sopsRule {
var rules []sopsRule
for _, f := range files {
if path.Base(f) != ".sops.yaml" {
continue
}
b, err := os.ReadFile(filepath.Join(root, f))
if err != nil {
continue
}
var cfg struct {
CreationRules []struct {
PathRegex string `yaml:"path_regex"`
} `yaml:"creation_rules"`
}
if yaml.Unmarshal(b, &cfg) != nil {
continue
}
dir := path.Dir(f)
if dir == "." {
dir = ""
}
for _, cr := range cfg.CreationRules {
r := sopsRule{dir: dir}
if cr.PathRegex != "" {
re, err := regexp.Compile(cr.PathRegex)
if err != nil {
continue
}
r.re = re
}
rules = append(rules, r)
}
}
return rules
}
// globs compiles globs: * and ? within a path segment, ** across them.
func globs(patterns []string) ([]*regexp.Regexp, error) {
var out []*regexp.Regexp
for _, g := range patterns {
var b strings.Builder
b.WriteString("^")
for i := 0; i < len(g); i++ {
switch c := g[i]; {
case strings.HasPrefix(g[i:], "**/"):
b.WriteString("(.*/)?")
i += 2
case strings.HasPrefix(g[i:], "**"):
b.WriteString(".*")
i++
case c == '*':
b.WriteString("[^/]*")
case c == '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(string(c)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return nil, fmt.Errorf("glob %q: %w", g, err)
}
out = append(out, re)
}
return out, nil
}
func anyMatch(res []*regexp.Regexp, f string) bool {
return slices.ContainsFunc(res, func(re *regexp.Regexp) bool { return re.MatchString(f) })
}
// --- sops files ---
type sopsMeta struct {
methods []string // age, azure_kv, …
modified time.Time
}
const maxFileSize = 4 << 20
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
// file of a supported format.
func readMeta(file string) (sopsMeta, bool) {
fi, err := os.Stat(file)
if err != nil || fi.Size() > maxFileSize {
return sopsMeta{}, false
}
if formatOf(file) == "" {
return sopsMeta{}, false // not a format sops writes: skip reading
}
b, err := os.ReadFile(file)
if err != nil || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
}
var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch formatOf(file) {
case "yaml", "json":
var doc struct {
Sops map[string]any `yaml:"sops"`
}
if yaml.Unmarshal(b, &doc) != nil || doc.Sops == nil || doc.Sops["mac"] == nil {
return m, false
}
for _, k := range known {
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
m.methods = append(m.methods, k)
}
}
if s, ok := doc.Sops["lastmodified"].(string); ok {
m.modified, _ = time.Parse(time.RFC3339, s)
}
if t, ok := doc.Sops["lastmodified"].(time.Time); ok {
m.modified = t
}
case "dotenv", "ini":
text := string(b)
if !strings.Contains(text, "sops_mac") && !strings.Contains(text, "sops_version") {
return m, false
}
for _, k := range known {
if strings.Contains(text, "sops_"+k+"__") {
m.methods = append(m.methods, k)
}
}
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
line, _, _ := strings.Cut(text[i:], "\n")
if _, v, ok := strings.Cut(line, "="); ok {
m.modified, _ = time.Parse(time.RFC3339, strings.TrimSpace(v))
}
}
default:
return m, false
}
if len(m.methods) == 0 {
m.methods = []string{"unknown keys"}
}
return m, true
}
func formatOf(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".yaml", ".yml":
return "yaml"
case ".json":
return "json"
case ".env":
return "dotenv"
case ".ini":
return "ini"
}
if strings.HasPrefix(filepath.Base(file), ".env") {
return "dotenv"
}
return ""
}
type key struct {
name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"]
encrypted bool
}
// parseKeys lists a sops file's keys without decrypting anything.
func parseKeys(file string, b []byte) ([]key, error) {
switch formatOf(file) {
case "yaml", "json":
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 []key
walk(doc.Content[0], "", "", true, &out)
return out, nil
case "dotenv":
var out []key
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := envLine(line)
if !ok || strings.HasPrefix(k, "sops_") {
continue
}
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
}
return out, nil
case "ini":
var out []key
section := ""
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
switch {
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"):
section = strings.TrimSpace(line[1 : len(line)-1])
case section != "sops":
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
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[")})
}
}
return out, nil
}
return nil, fmt.Errorf("unsupported format")
}
// walk collects the leaves of a YAML/JSON tree, skipping sops' own block.
func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
k := n.Content[i].Value
if top && k == "sops" {
continue
}
child := k
if name != "" {
child = name + "." + k
}
walk(n.Content[i+1], child, extract+quote(k), false, out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
walk(c, fmt.Sprintf("%s[%d]", name, i), fmt.Sprintf("%s[%d]", extract, i), false, out)
}
case yaml.AliasNode:
if n.Alias != nil {
walk(n.Alias, name, extract, false, out)
}
default:
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC[")})
}
}
// quote is a sops --extract path element.
func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
}
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0]). Names with dots in keys can't be
// told apart from nesting: those come from the listing instead.
func extractFor(name string) string {
var b strings.Builder
for _, part := range strings.Split(name, ".") {
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
}
if idx != "" {
b.WriteString("[" + idx)
}
}
return b.String()
}
func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil {
return a
}
return p
}
func expandHome(p string) string {
if rest, ok := strings.CutPrefix(p, "~/"); ok {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, rest)
}
}
return p
}
+437
View File
@@ -0,0 +1,437 @@
package repo
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// A sops-shaped YAML file: listing reads its keys and metadata only.
const sopsYAML = `db:
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
port: ENC[AES256_GCM,data:x,iv:x,tag:x,type:int]
list:
- ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
public_unencrypted: hello
sops:
age:
- recipient: age1xxxx
enc: x
lastmodified: "2026-10-09T10:05:29Z"
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
version: 3.13.3
`
const sopsEnv = "API_KEY=ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]\n" +
"sops_age__list_0__map_recipient=age1xxxx\nsops_mac=ENC[x]\nsops_lastmodified=2026-10-09T10:05:29Z\nsops_version=3.13.3\n"
// gitRepo makes a git repository with files, all committed, and returns
// its root.
func gitRepo(t *testing.T, files map[string]string) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root) // macOS: /var is /private/var
for name, body := range files {
p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{
{"init", "-q"}, {"add", "."},
{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "files"},
{"remote", "add", "origin", "git@git.example.com:team/infra.git"},
} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
return root
}
func names[T any](items []T, name func(T) string) []string {
var out []string
for _, it := range items {
out = append(out, name(it))
}
return out
}
func TestRepoFilesAndKeys(t *testing.T) {
root := gitRepo(t, map[string]string{
"secrets/app.yaml": sopsYAML,
"svc/billing/prod.env": sopsEnv,
"svc/billing/plain.yaml": "not: sops\n",
"README.md": "# hi\n",
})
p := New(Settings{Dir: filepath.Join(root, "svc", "billing")})
ctx := context.Background()
srcs, err := p.Sources(ctx)
if err != nil || len(srcs) != 1 {
t.Fatalf("sources %v, %v", srcs, err)
}
src := srcs[0]
if src.Name != filepath.Base(root) || src.Detail != "git.example.com/team/infra" || src.URL != "https://git.example.com/team/infra" {
t.Errorf("source %+v", src)
}
files, err := p.Containers(ctx, src)
if err != nil {
t.Fatal(err)
}
// Only sops files; those under where it started first.
if got := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(got, []string{"svc/billing/prod.env", "secrets/app.yaml"}) {
t.Errorf("files %v", got)
}
if files[1].Detail != "sops · age" {
t.Errorf("detail %q", files[1].Detail)
}
keys, err := p.Objects(ctx, files[1], provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
if got := names(keys, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"db.password", "db.port", "list[0]", "public_unencrypted"}) {
t.Errorf("keys %v", got)
}
if keys[0].Updated.IsZero() {
t.Error("lastmodified not read")
}
if _, extract, _ := strings.Cut(keys[2].ID, "\x00"); extract != `["list"][0]` {
t.Errorf("extract %q", extract)
}
env, _ := p.Objects(ctx, files[0], provider.TypeSecret)
if got := names(env, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"API_KEY"}) {
t.Errorf("env keys %v", got)
}
if id, ok, _ := p.Locate(ctx, "git.example.com/team/infra"); !ok || id != root {
t.Errorf("locate: %q %v", id, ok)
}
}
func TestWhichFiles(t *testing.T) {
files := map[string]string{
"secrets/a.yaml": sopsYAML,
"other/b.yaml": sopsYAML,
"other/testdata/c.yaml": sopsYAML,
}
list := func(root string, s Settings) []string {
s.Dir = root
p := New(s)
srcs, _ := p.Sources(context.Background())
cs, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
got := names(cs, func(c provider.Container) string { return c.Name })
slices.Sort(got)
return got
}
// No rules: every tracked sops file.
root := gitRepo(t, files)
if got := list(root, Settings{}); len(got) != 3 {
t.Errorf("whole repo: %v", got)
}
// The config's globs.
if got := list(root, Settings{Include: []string{"**/*.yaml"}, Exclude: []string{"**/testdata/**"}}); !slices.Equal(got, []string{"other/b.yaml", "secrets/a.yaml"}) {
t.Errorf("config globs: %v", got)
}
// .sops.yaml says where sops files go.
withRules := map[string]string{".sops.yaml": "creation_rules:\n - path_regex: secrets/.*\\.yaml$\n age: age1x\n"}
for k, v := range files {
withRules[k] = v
}
root = gitRepo(t, withRules)
if got := list(root, Settings{}); !slices.Equal(got, []string{"secrets/a.yaml"}) {
t.Errorf(".sops.yaml rules: %v", got)
}
// The repo's own .lazysecrets.yaml wins over both.
withRules[".lazysecrets.yaml"] = "include: [\"other/**\"]\nexclude: [\"**/testdata/**\"]\n"
root = gitRepo(t, withRules)
if got := list(root, Settings{Include: []string{"secrets/**"}}); !slices.Equal(got, []string{"other/b.yaml"}) {
t.Errorf(".lazysecrets.yaml: %v", got)
}
}
func TestNormalizeRemote(t *testing.T) {
for in, want := range map[string]string{
"https://git.kebcloud.no/keb/nix-config.git": "git.kebcloud.no/keb/nix-config",
"git@github.com:Team/Repo.git": "github.com/team/repo",
"ssh://git@git.example.com:2222/team/infra": "git.example.com/team/infra",
"https://user@dev.azure.com/org/proj/_git/x": "dev.azure.com/org/proj/_git/x",
} {
if got, _ := normalizeRemote(in); got != want {
t.Errorf("%s: %q, want %q", in, got, want)
}
}
}
// With sops and age installed: real encryption and decryption, with a
// throwaway key.
func TestDecrypt(t *testing.T) {
for _, bin := range []string{"sops", "age-keygen"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skip("needs " + bin)
}
}
dir := t.TempDir()
keyFile := filepath.Join(dir, "key.txt")
if out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput(); err != nil {
t.Fatalf("age-keygen: %v %s", err, out)
}
b, _ := os.ReadFile(keyFile)
var recipient string
for _, f := range strings.Fields(string(b)) {
if strings.HasPrefix(f, "age1") {
recipient = f
}
}
plain := filepath.Join(dir, "plain.yaml")
os.WriteFile(plain, []byte("db:\n password: hunter2\nlist:\n - first\n"), 0o600)
enc, err := exec.Command("sops", "encrypt", "--age", recipient, plain).Output()
if err != nil {
t.Fatal(err)
}
root := gitRepo(t, map[string]string{"secrets.yaml": string(enc)})
t.Setenv("SOPS_AGE_KEY_FILE", keyFile)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
cs, _ := p.Containers(ctx, srcs[0])
keys, _ := p.Objects(ctx, cs[0], provider.TypeSecret)
for _, k := range keys {
v, err := p.Value(ctx, k)
if err != nil {
t.Fatalf("%s: %v", k.Name, err)
}
want := map[string]string{"db.password": "hunter2", "list[0]": "first"}[k.Name]
if v.Reveal() != want {
t.Errorf("%s = %q, want %q", k.Name, v.Reveal(), want)
}
}
// From a link, without the listing: the extract path from the name.
v, err := p.Value(ctx, provider.Object{Container: cs[0], Name: "list[0]"})
if err != nil || v.Reveal() != "first" {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
// Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
t.Errorf("no key: %v", err)
}
}
func TestRepoWithoutSecretsIsntListed(t *testing.T) {
root := gitRepo(t, map[string]string{"README.md": "# no secrets\n", "config.yaml": "plain: yaml\n"})
srcs, err := New(Settings{Dir: root}).Sources(context.Background())
if err != nil || len(srcs) != 0 {
t.Errorf("sources %v, %v", srcs, err)
}
}
func TestPlainEnvFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
".gitignore": ".env\n.env.local\n",
".env.example": "TOKEN=changeme\n",
"secrets/app.yaml": sopsYAML,
})
// Ignored, as they usually are: written after the commit.
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nexport TOKEN=\"s3cr3t value\"\nURL=https://x.example # the API\nQUOTED='a#b'\n"), 0o600)
os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755)
os.WriteFile(filepath.Join(root, "node_modules", "pkg", ".env"), []byte("X=1\n"), 0o600)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
files, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, c := range files {
got[c.Name] = c.Detail
}
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
}
var env provider.Container
for _, c := range files {
if c.Name == ".env" {
env = c
}
}
keys, _ := p.Objects(ctx, env, provider.TypeSecret)
want := map[string]string{"TOKEN": "s3cr3t value", "URL": "https://x.example", "QUOTED": "a#b"}
if len(keys) != len(want) {
t.Fatalf("keys %v", names(keys, func(o provider.Object) string { return o.Name }))
}
for _, k := range keys {
v, err := p.Value(ctx, k) // no sops involved
if err != nil || v.Reveal() != want[k.Name] {
t.Errorf("%s = %q, %v; want %q", k.Name, v.Reveal(), err, want[k.Name])
}
}
// .sops.yaml rules limit SOPS files, not plain env files.
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
t.Errorf("with rules elsewhere: %v", names)
}
}
func TestRoots(t *testing.T) {
withSecrets := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
without := gitRepo(t, map[string]string{"README.md": "# none\n"})
nested := gitRepo(t, map[string]string{".env.example": "X=y\n", "prod.env": sopsEnv})
// A folder of repos: two at the top, one under an org folder.
root, _ := filepath.EvalSymlinks(t.TempDir())
os.Rename(withSecrets, filepath.Join(root, "infra"))
os.Rename(without, filepath.Join(root, "docs"))
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
os.Rename(nested, filepath.Join(root, "acme", "billing"))
outside := t.TempDir() // not in a repo
srcs, err := New(Settings{Dir: outside, Roots: []string{root}}).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 under the root: %v (want the two with secret files)", got)
}
// Inside a repo: the same repos, that one first and marked Here.
srcs, _ = New(Settings{Dir: filepath.Join(root, "acme", "billing"), Roots: []string{root}}).Sources(context.Background())
got = names(srcs, func(s provider.Source) string { return s.Name })
if !slices.Equal(got, []string{"billing", "infra"}) || !srcs[0].Here || srcs[1].Here {
t.Errorf("inside a repo: %v, want billing (here) then infra", got)
}
}
func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
other := gitRepo(t, map[string]string{"secrets/b.yaml": sopsYAML})
_ = other
srcs, err := New(Settings{Dir: here, Starred: []string{starred, here}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.ID })
if !slices.Equal(got, []string{here, starred}) {
t.Errorf("sources %v, want this repo then the starred one (not under a root)", got)
}
}
// sops' output when no key opens the file, as sops 3.13 prints it.
const noKeyStderr = `Failed to get the data key required to decrypt the SOPS file.
Group 0: FAILED
age1el8zh94re2488t5rrpqxk5xcxhs2cg6xfcccntz6l4ggv0rsf9yq44c0cs: FAILED
- | failed to create reader for decrypting sops data key with
| age: no identity matched any of the recipients. Did not find
| keys in locations 'SOPS_AGE_SSH_PRIVATE_KEY_FILE'.
https://kv-app.vault.azure.net/keys/sops/abc: FAILED
- | failed to decrypt sops data key with Azure Key Vault key
| 'https://kv-app.vault.azure.net/keys/sops/abc': DefaultAzureCredential: failed to acquire a token.
Recovery failed because no master key was able to decrypt the file. In
order for SOPS to recover the file, at least one key has to be successful,
but none were.
`
func TestSopsError(t *testing.T) {
got := sopsError(noKeyStderr, errors.New("exit status 128"))
if got != "sops couldn't decrypt it:\n"+strings.TrimSpace(noKeyStderr) {
t.Errorf("not sops' whole message:\n%s", got)
}
if got := sopsError("", errors.New("exit status 1")); got != "sops couldn't decrypt it:\nexit status 1" {
t.Errorf("without output: %q", got)
}
}
func TestOriginURL(t *testing.T) {
dir := t.TempDir()
write := func(s string) string {
p := filepath.Join(dir, "config")
os.WriteFile(p, []byte(s), 0o644)
return p
}
for _, c := range []struct {
config, url string
ok bool
}{
{"[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@git.example.com:team/infra.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", "git@git.example.com:team/infra.git", true},
{"[remote \"upstream\"]\n\turl = https://a/b\n[remote \"origin\"]\n\turl=https://git.example.com/x.git\n", "https://git.example.com/x.git", true},
{"[core]\n\tbare = false\n", "", true}, // no origin
{"[url \"git@host:\"]\n\tinsteadOf = https://host/\n[remote \"origin\"]\n\turl = https://host/x\n", "", false},
} {
url, ok := originURL(write(c.config))
if url != c.url || ok != c.ok {
t.Errorf("%q: %q %v, want %q %v", c.config, url, ok, c.url, c.ok)
}
}
if _, ok := originURL(filepath.Join(dir, "missing")); ok {
t.Error("a missing config should leave it to git")
}
}
func TestEnvFilesSkipPackages(t *testing.T) {
root := gitRepo(t, map[string]string{
"app/main.go": "package main\n",
"vendor/lib/x.go": "package lib\n",
"web/node_modules/pkg/index.js": "x\n",
"secrets/sops.yaml": sopsYAML,
"web/node_modules/pkg/sops.yaml": sopsYAML,
})
// Untracked, as .env files usually are: next to tracked code, in a
// package folder, and in a folder with nothing tracked.
for f, body := range map[string]string{
"app/.env": "A=1\n",
"web/node_modules/pkg/.env": "B=2\n",
"vendor/lib/.env": "C=3\n",
"scratch/.env": "D=4\n",
} {
os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755)
os.WriteFile(filepath.Join(root, f), []byte(body), 0o644)
}
p := New(Settings{Dir: root})
srcs, _ := p.Sources(context.Background())
files, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
got := names(files, func(c provider.Container) string { return c.Name })
slices.Sort(got)
if want := []string{"app/.env", "secrets/sops.yaml"}; !slices.Equal(got, want) {
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
}
}
+34
View File
@@ -0,0 +1,34 @@
package provider
import (
"fmt"
"log/slog"
)
const masked = "••••••"
// SecretValue holds a secret's plaintext. Every formatting, logging and
// encoding path renders it masked; the plaintext is only available through
// Reveal, so it cannot leak through a stray Printf, slog call or error.
type SecretValue struct {
b []byte
}
func NewSecretValue(b []byte) SecretValue {
return SecretValue{b: append([]byte(nil), b...)}
}
// Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked }
// Format covers every fmt verb, including %#v and %x.
func (v SecretValue) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(masked)) }
func (v SecretValue) LogValue() slog.Value { return slog.StringValue(masked) }
func (v SecretValue) MarshalText() ([]byte, error) { return []byte(masked), nil }
+51
View File
@@ -0,0 +1,51 @@
package provider
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"strings"
"testing"
)
func TestSecretValueNeverFormatsPlaintext(t *testing.T) {
const plain = "hunter2"
v := NewSecretValue([]byte(plain))
wrapped := struct{ V SecretValue }{v}
var outputs []string
for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d"} {
outputs = append(outputs, fmt.Sprintf(verb, v), fmt.Sprintf(verb, wrapped))
}
outputs = append(outputs, fmt.Sprint(v), fmt.Errorf("failed: %v", v).Error())
js, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}
outputs = append(outputs, string(js))
var buf bytes.Buffer
slog.New(slog.NewJSONHandler(&buf, nil)).Info("x", "value", v)
slog.New(slog.NewTextHandler(&buf, nil)).Info("x", "value", v)
outputs = append(outputs, buf.String())
for _, out := range outputs {
if strings.Contains(out, plain) || strings.Contains(out, fmt.Sprintf("%x", plain)) {
t.Errorf("plaintext leaked: %q", out)
}
}
if v.Reveal() != plain {
t.Errorf("Reveal() = %q, want %q", v.Reveal(), plain)
}
}
func TestNewSecretValueCopiesInput(t *testing.T) {
b := []byte("abc")
v := NewSecretValue(b)
b[0] = 'x'
if v.Reveal() != "abc" {
t.Errorf("value aliased caller's slice: %q", v.Reveal())
}
}