feat: .NET appsettings files in repos
appsettings.json and appsettings.<Environment>.json tracked by git are listed under Repos, keys named as .NET names them (ConnectionStrings:Default). - The secret patterns pick the keys, the rest hidden, with dotnet.secretPatterns for .NET files' own. - An App Service Key Vault reference, @Microsoft.KeyVault(…), is marked as a reference; a BOM at the start is fine. - A .NET icon, themeable with dotnetColor.
This commit is contained in:
17 files changed
+224
-22
No files matched your search
@@ -22,14 +22,14 @@ type configType struct {
|
||||
format func(file string) string // yaml, properties
|
||||
parse func(file string, b []byte) ([]configKey, error)
|
||||
// jasyptSetting is the key that holds the Jasypt password for the
|
||||
// key named name.
|
||||
// key named name; nil when only $JASYPT_ENCRYPTOR_PASSWORD can.
|
||||
jasyptSetting func(name string) string
|
||||
// placeholder reads a value that only refers to another,
|
||||
// ${DB_PASSWORD}: what it names, and its default if it has one.
|
||||
placeholder func(v string) (name, def string, hasDef, ok bool)
|
||||
}
|
||||
|
||||
var configTypes = []configType{springType, composeType}
|
||||
var configTypes = []configType{springType, composeType, dotnetType}
|
||||
|
||||
func isConfig(f string) bool {
|
||||
_, ok := configTypeOf(f)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// .NET config files: appsettings.json and appsettings.<Environment>.json.
|
||||
// Keys are named as .NET's configuration names them, sections joined by
|
||||
// colons: ConnectionStrings:Default, Servers:0:Name.
|
||||
|
||||
var dotnetType = configType{
|
||||
name: "dotnet", is: IsDotnet, format: func(string) string { return "json" }, parse: parseDotnet,
|
||||
placeholder: dotnetPlaceholder,
|
||||
}
|
||||
|
||||
var dotnetName = regexp.MustCompile(`^appsettings(\.[^/]+)?\.json$`)
|
||||
|
||||
// IsDotnet reports whether f is named like a .NET appsettings file.
|
||||
func IsDotnet(f string) bool {
|
||||
return dotnetName.MatchString(strings.ToLower(path.Base(f)))
|
||||
}
|
||||
|
||||
// dotnetPlaceholder reads an App Service Key Vault reference,
|
||||
// @Microsoft.KeyVault(SecretUri=…): the secret is in the vault.
|
||||
func dotnetPlaceholder(v string) (name, def string, hasDef, ok bool) {
|
||||
if strings.HasPrefix(v, "@Microsoft.KeyVault(") && strings.HasSuffix(v, ")") {
|
||||
return v, "", false, true
|
||||
}
|
||||
return "", "", false, false
|
||||
}
|
||||
|
||||
func parseDotnet(_ string, b []byte) ([]configKey, error) {
|
||||
var doc yaml.Node // JSON is YAML, and the YAML parser says where each key is
|
||||
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []configKey
|
||||
if len(doc.Content) > 0 {
|
||||
dotnetWalk(doc.Content[0], "", &out)
|
||||
}
|
||||
for i := range out {
|
||||
out[i].name = out[i].key
|
||||
out[i].ref = reference(dotnetPlaceholder, out[i].value)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dotnetWalk(n *yaml.Node, name string, out *[]configKey) {
|
||||
join := func(child string) string {
|
||||
if name == "" {
|
||||
return child
|
||||
}
|
||||
return name + ":" + child
|
||||
}
|
||||
switch n.Kind {
|
||||
case yaml.MappingNode:
|
||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||
dotnetWalk(n.Content[i+1], join(n.Content[i].Value), out)
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
for i, c := range n.Content {
|
||||
dotnetWalk(c, join(fmt.Sprint(i)), out)
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
if name != "" {
|
||||
*out = append(*out, configKey{key: name, value: n.Value, line: n.Line})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
func TestIsDotnet(t *testing.T) {
|
||||
for f, want := range map[string]bool{
|
||||
"appsettings.json": true,
|
||||
"src/Api/appsettings.Production.json": true,
|
||||
"appsettings.Example.json": true, // listed hidden
|
||||
"appsettings.json.bak": false,
|
||||
"settings.json": false,
|
||||
"package.json": false,
|
||||
} {
|
||||
if IsDotnet(f) != want {
|
||||
t.Errorf("%s: %t", f, !want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDotnet(t *testing.T) {
|
||||
keys, err := parseDotnet("", []byte("\xef\xbb\xbf"+`{
|
||||
"ConnectionStrings": { "Default": "Server=db;Password=hunter2" },
|
||||
"AzureAd": { "ClientId": "abc", "ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad)" },
|
||||
"Servers": [ { "Name": "a", "ApiKey": "k" } ],
|
||||
"Logging": { "LogLevel": { "Default": "Information" } }
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, k := range keys {
|
||||
ref := ""
|
||||
if k.ref {
|
||||
ref = " (ref)"
|
||||
}
|
||||
got[k.name] = k.value + ref
|
||||
}
|
||||
want := map[string]string{
|
||||
"ConnectionStrings:Default": "Server=db;Password=hunter2",
|
||||
"AzureAd:ClientId": "abc",
|
||||
"AzureAd:ClientSecret": "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/ad) (ref)",
|
||||
"Servers:0:Name": "a",
|
||||
"Servers:0:ApiKey": "k",
|
||||
"Logging:LogLevel:Default": "Information",
|
||||
}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Errorf("got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDotnetFiles(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"src/Api/appsettings.json": `{
|
||||
"Logging": { "LogLevel": { "Default": "Information" } },
|
||||
"ConnectionStrings": { "Default": "Server=db;User Id=app;Password=hunter2" },
|
||||
"AzureAd": { "ClientSecret": "@Microsoft.KeyVault(VaultName=kv;SecretName=ad)" }
|
||||
}`,
|
||||
"src/Api/appsettings.Development.json": `{ "Logging": { "LogLevel": { "Default": "Debug" } } }`,
|
||||
})
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||
ctx := context.Background()
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(ctx)
|
||||
cs, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil || len(cs) != 2 {
|
||||
t.Fatalf("files: %+v %v", cs, err)
|
||||
}
|
||||
for _, c := range cs {
|
||||
if c.Detail != "dotnet · json" || c.Hidden != (c.Name == "src/Api/appsettings.Development.json") {
|
||||
t.Errorf("%s: %q hidden %t", c.Name, c.Detail, c.Hidden)
|
||||
}
|
||||
}
|
||||
settings := cs[1]
|
||||
|
||||
keys, _ := p.Objects(ctx, settings, provider.TypeSecret)
|
||||
got := map[string]string{}
|
||||
for _, k := range shown(keys) {
|
||||
got[k.Name] = k.Detail
|
||||
}
|
||||
// The connection string by its value, the client secret by its name.
|
||||
if want := map[string]string{"ConnectionStrings:Default": "", "AzureAd:ClientSecret": "reference"}; !maps.Equal(got, want) {
|
||||
t.Errorf("keys %v, want %v", got, want)
|
||||
}
|
||||
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/Api/appsettings.json"}, Name: "ConnectionStrings:Default"}
|
||||
if v, err := p.Value(ctx, link); err != nil || v.Reveal() != "Server=db;User Id=app;Password=hunter2" {
|
||||
t.Errorf("value: %q %v", v.Reveal(), err)
|
||||
}
|
||||
if f, _ := p.File(settings, &link); f.Line != 3 {
|
||||
t.Errorf("line %d", f.Line)
|
||||
}
|
||||
if d, plain := p.configDetails(dotnetType, settings); !plain || d[1].Value != "1, not matched by the secret patterns" {
|
||||
t.Errorf("details: %+v %t", d, plain)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,9 @@ func jasyptPassword(file, name string) (password, from string, err error) {
|
||||
}
|
||||
setting, read, placeholder := jasyptEnv, plainValue, composePlaceholder // env files interpolate like compose
|
||||
if t, ok := configTypeOf(file); ok {
|
||||
if t.jasyptSetting == nil {
|
||||
return "", "", fmt.Errorf("jasypt: no password: set $%s", jasyptEnv)
|
||||
}
|
||||
setting, read, placeholder = t.jasyptSetting(name), t.value, t.placeholder
|
||||
}
|
||||
v, err := read(file, setting)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package repo browses the encrypted secret files in git repositories:
|
||||
// a repo is a source, its secret files the containers (vault level), and
|
||||
// their keys the secrets. Secret files are SOPS files, plain env files and
|
||||
// config files: Spring Boot's and Docker Compose's (configfile.go).
|
||||
// config files: Spring Boot's, Docker Compose's and .NET's (configfile.go).
|
||||
//
|
||||
// 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
|
||||
@@ -640,6 +640,7 @@ type lazysecretsFile struct {
|
||||
SecretPatterns SecretPatterns `yaml:"secretPatterns"`
|
||||
Spring FileType `yaml:"spring"`
|
||||
Compose FileType `yaml:"compose"`
|
||||
Dotnet FileType `yaml:"dotnet"`
|
||||
Sharing struct {
|
||||
// Key is the link to the secret that encrypts the repo's
|
||||
// shared env files (package share).
|
||||
@@ -648,7 +649,7 @@ type lazysecretsFile struct {
|
||||
}
|
||||
|
||||
func (f lazysecretsFile) types() map[string]FileType {
|
||||
return map[string]FileType{"spring": f.Spring, "compose": f.Compose}
|
||||
return map[string]FileType{"spring": f.Spring, "compose": f.Compose, "dotnet": f.Dotnet}
|
||||
}
|
||||
|
||||
// ShareKey is the sharing.key of the repo's .lazysecrets.yaml, if any.
|
||||
|
||||
Reference in new issue
Block a user