Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add keychain provider for reading text/yaml/json secrets #585

Merged
merged 4 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ It supports various backends including:
- HCP Vault Secrets
- Bitwarden
- HTTP JSON
- Keychain

- Use `vals eval -f refs.yaml` to replace all the `ref`s in the file to actual values and secrets.
- Use `vals exec -f env.yaml -- <COMMAND>` to populate envvars and execute the command.
Expand Down Expand Up @@ -216,6 +217,7 @@ Please see the [relevant unit test cases](https://github.com/helmfile/vals/blob/
- [Google GCS](#google-gcs)
- [SOPS](#sops) powered by [sops](https://github.com/getsops/sops)
- [Terraform (tfstate)](#terraform-tfstate) powered by [tfstate-lookup](https://github.com/fujiwara/tfstate-lookup)
- [Keychain](#keychain)
- [Echo](#echo)
- [File](#file)
- [Azure Key Vault](#azure-key-vault)
Expand Down Expand Up @@ -592,6 +594,18 @@ Examples:
- `ref+sops://path/to/file#/foo/bar` reads `path/to/file` as a `yaml` file and returns the value at `foo.bar`.
- `ref+sops://path/to/file?format=json#/foo/bar` reads `path/to/file` as a `json` file and returns the value at `foo.bar`.

### Keychain

Keychain provider is going to be available on macOS only. It reads a secret from the macOS Keychain.

- `ref+keychain://KEY1/[#/path/to/the/value]`

Examples:

- `security add-generic-password -U -a ${USER} -s "secret-name" -D "vals-secret" -w '{"foo":{"bar":"baz"}}'` - will create a secret in the Keychain with the name `secret-name` and the value `{"foo":{"bar":"baz"}}`, `vals-secret` is required to be able to find the secret in the Keychain.
- `echo 'foo: ref+keychain://secret-name' | vals eval -f -` - will read the secret from the Keychain with the name `secret-name` and replace the `foo` with the secret value.
- `echo 'foo: ref+keychain://secret-name#/foo/bar' | vals eval -f -` - will read the secret from the Keychain with the name `secret-name` and replace the `foo` with the value at the path `$.foo.bar`.

### Echo

Echo provider echoes the string for testing purpose. Please read [the original proposal](https://github.com/roboll/helmfile/pull/920#issuecomment-548213738) to get why we might need this.
Expand All @@ -603,7 +617,6 @@ Examples:
- `ref+echo://foo/bar` generates `foo/bar`
- `ref+echo://foo/bar/baz#/foo/bar` generates `baz`. This works by the host and the path part `foo/bar/baz` generating an object `{"foo":{"bar":"baz"}}` and the fragment part `#/foo/bar` results in digging the object to obtain the value at `$.foo.bar`.


### File

File provider reads a local text file, or the value for the specific path in a YAML/JSON file.
Expand Down
96 changes: 96 additions & 0 deletions pkg/providers/keychain/keychain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package keychain

import (
"encoding/hex"
"errors"
"os/exec"
"runtime"
"strings"

"gopkg.in/yaml.v3"

"github.com/helmfile/vals/pkg/api"
)

const keychainKind = "vals-secret"

type provider struct {
}

func New(cfg api.StaticConfig) *provider {
p := &provider{}
return p
}

// isHex checks if a string is a valid hexadecimal string
func isHex(s string) bool {
anisimovdk marked this conversation as resolved.
Show resolved Hide resolved
// Check if the string length is even
if len(s)%2 != 0 {
return false
}

// Attempt to decode the string
_, err := hex.DecodeString(s)
return err == nil // If no error, it's valid hex
}

// isDarwin checks if the current OS is macOS
func isDarwin() bool {
return runtime.GOOS == "darwin"
}

// getKeychainSecret retrieves a secret from the macOS keychain with security find-generic-password
func getKeychainSecret(key string) ([]byte, error) {
if !isDarwin() {
return nil, errors.New("keychain provider is only supported on macOS")
}

// Get the secret from the keychain with 'security find-generic-password' command
getKeyCmd := exec.Command("security", "find-generic-password", "-w", "-D", keychainKind, "-s", key)
anisimovdk marked this conversation as resolved.
Show resolved Hide resolved

result, err := getKeyCmd.Output()
if err != nil {
return nil, err
}

stringResult := string(result)
stringResult = strings.TrimSpace(stringResult)

// If the result is a hexadecimal string, decode it.
if isHex(stringResult) {
result, err = hex.DecodeString(stringResult)
if err != nil {
return nil, err
}
}

return result, nil
}

func (p *provider) GetString(key string) (string, error) {
key = strings.TrimSuffix(key, "/")
key = strings.TrimSpace(key)

secret, err := getKeychainSecret(key)
if err != nil {
return "", err
}

return string(secret), err
}

func (p *provider) GetStringMap(key string) (map[string]interface{}, error) {
key = strings.TrimSuffix(key, "/")
key = strings.TrimSpace(key)

secret, err := getKeychainSecret(key)
if err != nil {
return nil, err
}

m := map[string]interface{}{}
if err := yaml.Unmarshal(secret, &m); err != nil {
return nil, err
}
return m, nil
}
35 changes: 35 additions & 0 deletions pkg/providers/keychain/keychain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package keychain

import (
"testing"
)

func Test_isHex(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"a1b2c3", true},
{"A1B2C3", true},
{"1234567890abcdef", true},
{"12345", false}, // Odd length
{"g1h2", false}, // Non-hex characters
{"!@#$", false}, // Special characters
{"abcdefa", false}, // Odd length with valid hex characters
{"ABCDEF", true},
{"abcdef", true},
{"1234abcd", true},
{"1234abcg", false}, // Contains 'g'
{"12 34", false}, // Contains space
{"", true}, // Empty string
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := isHex(tt.input)
if result != tt.expected {
t.Errorf("isHex(%q) = %v; want %v", tt.input, result, tt.expected)
}
})
}
}
6 changes: 6 additions & 0 deletions vals.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/helmfile/vals/pkg/providers/hcpvaultsecrets"
"github.com/helmfile/vals/pkg/providers/httpjson"
"github.com/helmfile/vals/pkg/providers/k8s"
"github.com/helmfile/vals/pkg/providers/keychain"
"github.com/helmfile/vals/pkg/providers/onepassword"
"github.com/helmfile/vals/pkg/providers/onepasswordconnect"
"github.com/helmfile/vals/pkg/providers/pulumi"
Expand Down Expand Up @@ -91,6 +92,7 @@ const (
ProviderTFStateRemote = "tfstateremote"
ProviderAzureKeyVault = "azurekeyvault"
ProviderEnvSubst = "envsubst"
ProviderKeychain = "keychain"
ProviderOnePassword = "op"
ProviderOnePasswordConnect = "onepasswordconnect"
ProviderDoppler = "doppler"
Expand Down Expand Up @@ -245,6 +247,9 @@ func (r *Runtime) prepare() (*expansion.ExpandRegexMatch, error) {
case ProviderKms:
p := awskms.New(conf)
return p, nil
case ProviderKeychain:
p := keychain.New(conf)
return p, nil
case ProviderEnvSubst:
p := envsubst.New(conf)
return p, nil
Expand Down Expand Up @@ -491,6 +496,7 @@ var KnownValuesTypes = []string{
ProviderTFState,
ProviderFile,
ProviderEcho,
ProviderKeychain,
ProviderEnvSubst,
ProviderPulumiStateAPI,
}
Expand Down
Loading