diff --git a/README.md b/README.md index 5e239b4..d0ef4fe 100644 --- a/README.md +++ b/README.md @@ -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 -- ` to populate envvars and execute the command. @@ -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) @@ -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. @@ -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. diff --git a/pkg/providers/keychain/keychain.go b/pkg/providers/keychain/keychain.go new file mode 100644 index 0000000..3980aa1 --- /dev/null +++ b/pkg/providers/keychain/keychain.go @@ -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 { + // 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) + + 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 +} diff --git a/pkg/providers/keychain/keychain_test.go b/pkg/providers/keychain/keychain_test.go new file mode 100644 index 0000000..5a8b9d1 --- /dev/null +++ b/pkg/providers/keychain/keychain_test.go @@ -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) + } + }) + } +} diff --git a/vals.go b/vals.go index fdb23a7..54bc3de 100644 --- a/vals.go +++ b/vals.go @@ -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" @@ -91,6 +92,7 @@ const ( ProviderTFStateRemote = "tfstateremote" ProviderAzureKeyVault = "azurekeyvault" ProviderEnvSubst = "envsubst" + ProviderKeychain = "keychain" ProviderOnePassword = "op" ProviderOnePasswordConnect = "onepasswordconnect" ProviderDoppler = "doppler" @@ -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 @@ -491,6 +496,7 @@ var KnownValuesTypes = []string{ ProviderTFState, ProviderFile, ProviderEcho, + ProviderKeychain, ProviderEnvSubst, ProviderPulumiStateAPI, }