feat: decrypt Jasypt values in .env and Spring files (#80)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #80.
This commit is contained in:
1 parent
96395cb6a2
commit
17da9eaa32
13 files changed
+381
-29
No files matched your search
@@ -2,6 +2,8 @@ package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -22,8 +24,8 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
}
|
||||
var out []provider.Attr
|
||||
meta, isSops := readMeta(c.ID)
|
||||
// A plain file's values are at risk in git; a Spring file's only when
|
||||
// one of its secrets is in plain text.
|
||||
// A plain file's values are at risk in git, unless they're all Jasypt;
|
||||
// a Spring file's only when one of its secrets is in plain text.
|
||||
plain := !isSops
|
||||
if !isSops && IsSpring(c.ID) {
|
||||
out, plain = p.springDetails(c)
|
||||
@@ -40,7 +42,7 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
out = append(out, provider.Attr{Label: "encrypted", Value: meta.modified.Local().Format("2006-01-02 15:04")})
|
||||
}
|
||||
} else {
|
||||
out = append(out, provider.Attr{Label: "encryption", Value: "none: plain text"})
|
||||
out, plain = envDetails(c.ID)
|
||||
}
|
||||
|
||||
git := func(args ...string) (string, bool) {
|
||||
@@ -65,3 +67,28 @@ func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provide
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// envDetails says how a plain env file's values are encrypted, and whether
|
||||
// any is in plain text.
|
||||
func envDetails(file string) (attrs []provider.Attr, plain bool) {
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return []provider.Attr{{Label: "encryption", Value: err.Error(), Warn: true}}, true
|
||||
}
|
||||
keys, _ := parseKeys(file, b)
|
||||
n := 0
|
||||
for _, k := range keys {
|
||||
if k.jasypt {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return []provider.Attr{{Label: "encryption", Value: jasyptShare(n, len(keys))}}, n < len(keys)
|
||||
}
|
||||
|
||||
// jasyptShare is how many of a plain file's values are Jasypt's.
|
||||
func jasyptShare(n, of int) string {
|
||||
if n == 0 {
|
||||
return "none: plain text"
|
||||
}
|
||||
return fmt.Sprintf("jasypt: %d of %d values", n, of)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// jasyptEnv is where Spring looks for the password first, before the
|
||||
// config's jasypt.encryptor.password, and so does lazysecrets.
|
||||
const jasyptEnv = "JASYPT_ENCRYPTOR_PASSWORD"
|
||||
|
||||
// jasyptValue decrypts v when it's a Jasypt value, ENC(...), from a plain
|
||||
// file; other values are as they are.
|
||||
func jasyptValue(file string, v provider.SecretValue) (provider.SecretValue, error) {
|
||||
s := v.Reveal()
|
||||
if !jasypt.Is(s) {
|
||||
return v, nil
|
||||
}
|
||||
password, from, err := jasyptPassword(file)
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, err
|
||||
}
|
||||
plain, err := jasypt.Decrypt(s, password)
|
||||
if errors.Is(err, jasypt.ErrDecrypt) {
|
||||
return provider.SecretValue{}, fmt.Errorf("jasypt: with the password from %s: %w", from, err)
|
||||
}
|
||||
if err != nil {
|
||||
return provider.SecretValue{}, fmt.Errorf("jasypt: %w", err)
|
||||
}
|
||||
return provider.NewSecretValue([]byte(plain)), nil
|
||||
}
|
||||
|
||||
// jasyptPassword is the password for a file's Jasypt values, and where it's
|
||||
// from: $JASYPT_ENCRYPTOR_PASSWORD, else the file's own setting
|
||||
// (jasypt.encryptor.password in a Spring file, JASYPT_ENCRYPTOR_PASSWORD
|
||||
// in an env file), with a ${VAR} in it read from the environment.
|
||||
func jasyptPassword(file string) (password, from string, err error) {
|
||||
if v := os.Getenv(jasyptEnv); v != "" {
|
||||
return v, "$" + jasyptEnv, nil
|
||||
}
|
||||
setting, read := jasyptEnv, plainValue
|
||||
if IsSpring(file) {
|
||||
setting, read = "jasypt.encryptor.password", springValue
|
||||
}
|
||||
v, err := read(file, setting)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("jasypt: no password: set $%s, or %s in %s", jasyptEnv, setting, filepath.Base(file))
|
||||
}
|
||||
return resolvePlaceholder(v.Reveal(), setting+" in "+filepath.Base(file))
|
||||
}
|
||||
|
||||
// placeholder is Spring's ${VAR} or ${VAR:default}.
|
||||
var placeholder = regexp.MustCompile(`^\$\{([^:}]+)(:[^}]*)?\}$`)
|
||||
|
||||
// resolvePlaceholder reads a ${VAR} value from the environment, falling
|
||||
// back to its default; other values are as they are.
|
||||
func resolvePlaceholder(v, from string) (string, string, error) {
|
||||
m := placeholder.FindStringSubmatch(v)
|
||||
switch {
|
||||
case m == nil:
|
||||
return v, from, nil
|
||||
case os.Getenv(m[1]) != "":
|
||||
return os.Getenv(m[1]), "$" + m[1] + " (" + from + ")", nil
|
||||
case m[2] != "":
|
||||
return m[2][1:], "the default in " + from, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("jasypt: no password: %s is %s, and $%s isn't set", from, v, m[1])
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// hunter2, encrypted by Jasypt with the password "test-password".
|
||||
const jasyptHunter2 = "ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)"
|
||||
|
||||
func TestJasyptPassword(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
value := func(name, body string) (string, error) {
|
||||
file := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(file, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "app.token"
|
||||
if !IsSpring(name) {
|
||||
key = "TOKEN"
|
||||
}
|
||||
v, err := (&Provider{}).Value(context.Background(), provider.Object{ID: file + "\x00" + key, Name: key})
|
||||
return v.Reveal(), err
|
||||
}
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||
t.Setenv("MY_PASSWORD", "")
|
||||
spring := func(password string) string {
|
||||
return "jasypt.encryptor.password: " + password + "\napp.token: " + jasyptHunter2 + "\n"
|
||||
}
|
||||
|
||||
for _, c := range []struct{ name, file, body, env, want string }{
|
||||
{"the file's own", "application.yml", spring("test-password"), "", "hunter2"},
|
||||
{"a ${VAR}", "application.yml", spring("${MY_PASSWORD}"), "test-password", "hunter2"},
|
||||
{"a ${VAR}'s default", "application.yml", spring("${MY_PASSWORD:test-password}"), "", "hunter2"},
|
||||
{"properties", "application-local.properties", "jasypt.encryptor.password=test-password\napp.token=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||
{"env file", ".env", "JASYPT_ENCRYPTOR_PASSWORD=test-password\nTOKEN=" + jasyptHunter2 + "\n", "", "hunter2"},
|
||||
{"not jasypt", ".env", "TOKEN=hunter2\n", "", "hunter2"},
|
||||
} {
|
||||
t.Setenv("MY_PASSWORD", c.env)
|
||||
if got, err := value(c.file, c.body); err != nil || got != c.want {
|
||||
t.Errorf("%s: %q %v", c.name, got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// $JASYPT_ENCRYPTOR_PASSWORD wins over the file's, as in Spring.
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||
if got, err := value("application.yml", spring("wrong")); err != nil || got != "hunter2" {
|
||||
t.Errorf("environment first: %q %v", got, err)
|
||||
}
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "wrong")
|
||||
if _, err := value("application.yml", spring("test-password")); err == nil || !strings.Contains(err.Error(), "the password from $JASYPT_ENCRYPTOR_PASSWORD") {
|
||||
t.Errorf("a wrong one says where it's from: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "")
|
||||
t.Setenv("MY_PASSWORD", "")
|
||||
for body, want := range map[string]string{
|
||||
spring("${MY_PASSWORD}"): "$MY_PASSWORD isn't set",
|
||||
"app.token: " + jasyptHunter2: "set $JASYPT_ENCRYPTOR_PASSWORD, or jasypt.encryptor.password in application.yml",
|
||||
} {
|
||||
if _, err := value("application.yml", body); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("no password: %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJasyptEnvFile(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
"jasypt.env": "TOKEN=" + jasyptHunter2 + "\n",
|
||||
"mixed.env": "TOKEN=" + jasyptHunter2 + "\nURL=https://example.com\n",
|
||||
})
|
||||
ctx := context.Background()
|
||||
p := New(Settings{Dir: root})
|
||||
srcs, _ := p.Sources(ctx)
|
||||
cs, _ := p.Containers(ctx, srcs[0])
|
||||
for _, c := range cs {
|
||||
attrs, _ := p.Details(ctx, c)
|
||||
got := map[string]provider.Attr{}
|
||||
for _, a := range attrs {
|
||||
got[a.Label] = a
|
||||
}
|
||||
switch c.Name {
|
||||
case "jasypt.env":
|
||||
if got["encryption"].Value != "jasypt: 1 of 1 values" || got["git"].Warn {
|
||||
t.Errorf("all jasypt: %+v", got)
|
||||
}
|
||||
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
||||
if len(keys) != 1 || keys[0].Attrs[1].Value != "jasypt" || keys[0].Detail != "jasypt" {
|
||||
t.Errorf("keys: %+v", keys)
|
||||
}
|
||||
case "mixed.env":
|
||||
if got["encryption"].Value != "jasypt: 1 of 2 values" || !got["git"].Warn {
|
||||
t.Errorf("mixed: %+v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -380,8 +381,11 @@ func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.O
|
||||
Container: c, Type: provider.TypeSecret,
|
||||
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified, Attrs: attrs,
|
||||
}
|
||||
if isSops && !k.encrypted {
|
||||
switch {
|
||||
case isSops && !k.encrypted:
|
||||
o.Attrs = append(slices.Clip(attrs), provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
|
||||
case !isSops && k.jasypt:
|
||||
o.Attrs, o.Detail = []provider.Attr{attrs[0], {Label: "encrypted", Value: "jasypt"}}, "jasypt"
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
@@ -396,17 +400,23 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object
|
||||
return nil, provider.ErrUnsupported
|
||||
}
|
||||
|
||||
// Value decrypts one key with sops.
|
||||
// Value reads one key: from a plain file, decrypting a Jasypt value; from
|
||||
// a SOPS file, with sops.
|
||||
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
|
||||
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 = filePath(o.Container)
|
||||
}
|
||||
if _, isSops := readMeta(file); !isSops {
|
||||
read := plainValue
|
||||
if IsSpring(file) {
|
||||
return springValue(file, o.Name)
|
||||
read = springValue
|
||||
}
|
||||
return plainValue(file, o.Name)
|
||||
v, err := read(file, o.Name)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
return jasyptValue(file, v)
|
||||
}
|
||||
if !ok {
|
||||
var err error
|
||||
@@ -1045,7 +1055,8 @@ type key struct {
|
||||
name string // db.password, list[0], with . [ ] \ in keys escaped
|
||||
extract string // sops --extract: ["db"]["password"]
|
||||
encrypted bool
|
||||
line int // 1-based
|
||||
jasypt bool // a plain env file's ENC(...) value
|
||||
line int // 1-based
|
||||
}
|
||||
|
||||
// parseKeys lists a sops file's keys without decrypting anything.
|
||||
@@ -1070,7 +1081,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
continue
|
||||
}
|
||||
sopsKey, _, _ := strings.Cut(line, "=") // sops keeps export and spaces in the key
|
||||
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
out = append(out, key{name: k, extract: quote(sopsKey), encrypted: strings.HasPrefix(v, "ENC["), jasypt: jasypt.Is(v), line: i + 1})
|
||||
}
|
||||
return out, nil
|
||||
case "ini":
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/jasypt"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -137,7 +138,7 @@ func (k springKey) reference() bool {
|
||||
}
|
||||
|
||||
func (k springKey) jasypt() bool {
|
||||
return strings.HasPrefix(k.value, "ENC(") && strings.HasSuffix(k.value, ")")
|
||||
return jasypt.Is(k.value)
|
||||
}
|
||||
|
||||
// plain reports whether the file holds the value itself, readable.
|
||||
@@ -346,15 +347,15 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj
|
||||
if !filter.secret(k) {
|
||||
continue
|
||||
}
|
||||
state := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}
|
||||
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
|
||||
switch {
|
||||
case k.reference():
|
||||
state = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}
|
||||
state, detail = provider.Attr{Label: "value", Value: "a reference: the secret is set elsewhere"}, "reference"
|
||||
case k.jasypt():
|
||||
state = provider.Attr{Label: "encrypted", Value: "jasypt"}
|
||||
state, detail = provider.Attr{Label: "encrypted", Value: "jasypt"}, "jasypt"
|
||||
}
|
||||
out = append(out, provider.Object{
|
||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name,
|
||||
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
|
||||
Attrs: []provider.Attr{format, state},
|
||||
})
|
||||
}
|
||||
@@ -403,12 +404,8 @@ func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, p
|
||||
jasypt++
|
||||
}
|
||||
}
|
||||
encryption := "none: plain text"
|
||||
if jasypt > 0 {
|
||||
encryption = fmt.Sprintf("jasypt: %d of %d values", jasypt, len(keys))
|
||||
}
|
||||
return []provider.Attr{
|
||||
{Label: "encryption", Value: encryption},
|
||||
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
|
||||
{Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
|
||||
}, plain
|
||||
}
|
||||
@@ -141,7 +141,7 @@ jasypt:
|
||||
encryptor:
|
||||
password: ${JASYPT_ENCRYPTOR_PASSWORD}
|
||||
app:
|
||||
api-key: ENC(abc==)
|
||||
api-key: ENC(5IQUKT4B7j38TnZJ+BlWqo/zSkKHTjl4XRJyaQEHgkCy4r00avLt4dWqFB74X0Zw)
|
||||
`,
|
||||
"src/main/resources/application-local.properties": "server.port=8081\nspring.datasource.password=hunter2\n",
|
||||
"applicationset/applications.yaml": "kind: ApplicationSet\n",
|
||||
@@ -180,12 +180,13 @@ app:
|
||||
keys, _ := p.Objects(ctx, yml, provider.TypeSecret)
|
||||
var names []string
|
||||
for _, k := range keys {
|
||||
names = append(names, k.Name)
|
||||
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
|
||||
}
|
||||
if want := []string{"spring.datasource.password", "jasypt.encryptor.password", "app.api-key"}; !slices.Equal(names, want) {
|
||||
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
|
||||
t.Errorf("keys %v, want %v", names, want)
|
||||
}
|
||||
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "ENC(abc==)" {
|
||||
t.Setenv("JASYPT_ENCRYPTOR_PASSWORD", "test-password")
|
||||
if v, err := p.Value(ctx, keys[2]); err != nil || v.Reveal() != "hunter2" {
|
||||
t.Errorf("value: %q %v", v.Reveal(), err)
|
||||
}
|
||||
link := provider.Object{Container: provider.Container{Source: srcs[0], Name: "src/main/resources/application.yml"}, Name: "spring.datasource.password"}
|
||||
|
||||
Reference in new issue
Block a user