From 15a45e3965ec5387ae82c5e2150d6ecc3ce494e6 Mon Sep 17 00:00:00 2001 From: Hidde Beydals Date: Thu, 7 Apr 2022 00:02:53 +0200 Subject: [PATCH] controllers: improve decryptor and add tests - Refactored recursion while iterating over Kustomization files. References of files that have been visited are cached, and not visited again. In addition, symlinks are confirmed to not traverse outside the working directory. - Optimized various bits around (un)marshalling (encrypted) data, and YAML -> JSON -> YAML roundtrips are prevented where not required. - Added support for decrypting INI Kustomize EnvSource references using the dedicated SOPS store for the format. - Introduced support for decrypting Kustomize FileSources: https://pkg.go.dev/sigs.k8s.io/kustomize@v1.0.2/pkg/types#DataSources Signed-off-by: Hidde Beydals --- controllers/kustomization_controller.go | 10 +- controllers/kustomization_decryptor.go | 720 ++++++++-- controllers/kustomization_decryptor_test.go | 1417 ++++++++++++++++++- 3 files changed, 1988 insertions(+), 159 deletions(-) diff --git a/controllers/kustomization_controller.go b/controllers/kustomization_controller.go index f8bbfda37..b553995da 100644 --- a/controllers/kustomization_controller.go +++ b/controllers/kustomization_controller.go @@ -368,7 +368,7 @@ func (r *KustomizationReconciler) reconcile( } // build the kustomization - resources, err := r.build(ctx, kustomization, dirPath) + resources, err := r.build(ctx, tmpDir, kustomization, dirPath) if err != nil { return kustomizev1.KustomizationNotReady( kustomization, @@ -634,8 +634,8 @@ func (r *KustomizationReconciler) generate(kustomization kustomizev1.Kustomizati return gen.WriteFile(dirPath) } -func (r *KustomizationReconciler) build(ctx context.Context, kustomization kustomizev1.Kustomization, dirPath string) ([]byte, error) { - dec, cleanup, err := NewTempDecryptor(r.Client, kustomization) +func (r *KustomizationReconciler) build(ctx context.Context, workDir string, kustomization kustomizev1.Kustomization, dirPath string) ([]byte, error) { + dec, cleanup, err := NewTempDecryptor(workDir, r.Client, kustomization) if err != nil { return nil, err } @@ -649,7 +649,7 @@ func (r *KustomizationReconciler) build(ctx context.Context, kustomization kusto fs := filesys.MakeFsOnDisk() // decrypt .env files before building kustomization if kustomization.Spec.Decryption != nil { - if err = dec.decryptDotEnvFiles(dirPath); err != nil { + if err = dec.DecryptEnvSources(dirPath); err != nil { return nil, fmt.Errorf("error decrypting .env file: %w", err) } } @@ -666,7 +666,7 @@ func (r *KustomizationReconciler) build(ctx context.Context, kustomization kusto // check if resources are encrypted and decrypt them before generating the final YAML if kustomization.Spec.Decryption != nil { - outRes, err := dec.Decrypt(res) + outRes, err := dec.DecryptResource(res) if err != nil { return nil, fmt.Errorf("decryption failed for '%s': %w", res.GetName(), err) } diff --git a/controllers/kustomization_decryptor.go b/controllers/kustomization_decryptor.go index acfd47dc9..17609f12e 100644 --- a/controllers/kustomization_decryptor.go +++ b/controllers/kustomization_decryptor.go @@ -20,17 +20,24 @@ import ( "bytes" "context" "encoding/base64" + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" + "sync" + "time" + securejoin "github.com/cyphar/filepath-securejoin" + intkeyservice "github.com/fluxcd/kustomize-controller/internal/sops/keyservice" "go.mozilla.org/sops/v3" "go.mozilla.org/sops/v3/aes" "go.mozilla.org/sops/v3/cmd/sops/common" "go.mozilla.org/sops/v3/cmd/sops/formats" "go.mozilla.org/sops/v3/keyservice" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -42,273 +49,682 @@ import ( kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" "github.com/fluxcd/kustomize-controller/internal/sops/age" "github.com/fluxcd/kustomize-controller/internal/sops/azkv" - intkeyservice "github.com/fluxcd/kustomize-controller/internal/sops/keyservice" "github.com/fluxcd/kustomize-controller/internal/sops/pgp" ) const ( - // DecryptionProviderSOPS is the SOPS provider name + // DecryptionProviderSOPS is the SOPS provider name. DecryptionProviderSOPS = "sops" - // DecryptionVaultTokenFileName is the name of the file containing the Vault token + // DecryptionPGPExt is the extension of the file containing an armored PGP + //key. + DecryptionPGPExt = ".asc" + // DecryptionAgeExt is the extension of the file containing an age key + // file. + DecryptionAgeExt = ".agekey" + // DecryptionVaultTokenFileName is the name of the file containing the + // Hashicorp Vault token. DecryptionVaultTokenFileName = "sops.vault-token" - // DecryptionAzureAuthFile is the Azure authentication file + // DecryptionAzureAuthFile is the name of the file containing the Azure + // credentials. DecryptionAzureAuthFile = "sops.azure-kv" ) -type KustomizeDecryptor struct { - client.Client +var ( + // maxEncryptedFileSize is the max allowed file size in bytes of an encrypted + // file. + maxEncryptedFileSize int64 = 5 << 20 + // sopsFormatToString is the counterpart to + // https://github.com/mozilla/sops/blob/v3.7.2/cmd/sops/formats/formats.go#L16 + sopsFormatToString = map[formats.Format]string{ + formats.Binary: "binary", + formats.Dotenv: "dotenv", + formats.Ini: "INI", + formats.Json: "JSON", + formats.Yaml: "YAML", + } + // sopsFormatToMarkerBytes contains a list of formats and their byte + // order markers, used to detect if a Secret data field is SOPS' encrypted. + sopsFormatToMarkerBytes = map[formats.Format][]byte{ + // formats.Binary is a JSON envelop at encrypted rest + formats.Binary: []byte("\"mac\": \"ENC["), + formats.Dotenv: []byte("sops_mac=ENC["), + formats.Ini: []byte("[sops]"), + formats.Json: []byte("\"mac\": \"ENC["), + formats.Yaml: []byte("mac: ENC["), + } +) +// KustomizeDecryptor performs decryption operations for a +// v1beta2.Kustomization. +// The only supported decryption provider at present is +// DecryptionProviderSOPS. +type KustomizeDecryptor struct { + // root is the root for file system operations. Any (relative) path or + // symlink is not allowed to traverse outside this path. + root string + // client is the Kubernetes client used to e.g. retrieve Secrets with. + client client.Client + // kustomization is the v1beta2.Kustomization we are decrypting for. + // The v1beta2.Decryption of the object is used to ImportKeys(). kustomization kustomizev1.Kustomization - gnuPGHome pgp.GnuPGHome + // maxFileSize is the max size in bytes a file is allowed to have to be + // decrypted. Defaults to maxEncryptedFileSize. + maxFileSize int64 + // checkSopsMac instructs the decryptor to perform the SOPS data integrity + // check using the MAC. Not enabled by default, as arbitrary data gets + // injected into most resources, causing the integrity check to fail. + // Mostly kept around for feature completeness and documentation purposes. + checkSopsMac bool + + // gnuPGHome is the absolute path of the GnuPG home directory used to + // decrypt PGP data. When empty, the systems' GnuPG keyring is used. + // When set, ImportKeys() imports found PGP keys into this keyring. + gnuPGHome pgp.GnuPGHome + // ageIdentities is the set of age identities available to the decryptor. ageIdentities age.ParsedIdentities - vaultToken string - azureToken *azkv.Token + // vaultToken is the Hashicorp Vault token used to authenticate towards + // any Vault server. + vaultToken string + // azureToken is the Azure credential token used to authenticate towards + // any Azure Key Vault. + azureToken *azkv.Token + + // keyServices are the SOPS keyservice.KeyServiceClient's available to the + // decryptor. + keyServices []keyservice.KeyServiceClient + localServiceOnce sync.Once } -func NewDecryptor(kubeClient client.Client, - kustomization kustomizev1.Kustomization, gnuPGHome string) *KustomizeDecryptor { +// NewDecryptor creates a new KustomizeDecryptor for the given kustomization. +// gnuPGHome can be empty, in which case the systems' keyring is used. +func NewDecryptor(root string, client client.Client, kustomization kustomizev1.Kustomization, maxFileSize int64, gnuPGHome string) *KustomizeDecryptor { return &KustomizeDecryptor{ - Client: kubeClient, + root: root, + client: client, kustomization: kustomization, + maxFileSize: maxFileSize, gnuPGHome: pgp.GnuPGHome(gnuPGHome), } } -func NewTempDecryptor(kubeClient client.Client, - kustomization kustomizev1.Kustomization) (*KustomizeDecryptor, func(), error) { +// NewTempDecryptor creates a new KustomizeDecryptor, with a temporary GnuPG +// home directory to KustomizeDecryptor.ImportKeys() into. +func NewTempDecryptor(root string, client client.Client, kustomization kustomizev1.Kustomization) (*KustomizeDecryptor, func(), error) { gnuPGHome, err := pgp.NewGnuPGHome() if err != nil { return nil, nil, fmt.Errorf("cannot create decryptor: %w", err) } - cleanup := func() { os.RemoveAll(gnuPGHome.String()) } - return NewDecryptor(kubeClient, kustomization, gnuPGHome.String()), cleanup, nil + cleanup := func() { _ = os.RemoveAll(gnuPGHome.String()) } + return NewDecryptor(root, client, kustomization, maxEncryptedFileSize, gnuPGHome.String()), cleanup, nil } -func (kd *KustomizeDecryptor) Decrypt(res *resource.Resource) (*resource.Resource, error) { - out, err := res.AsYAML() - if err != nil { - return nil, err - } - - if kd.kustomization.Spec.Decryption != nil && kd.kustomization.Spec.Decryption.Provider == DecryptionProviderSOPS { - if bytes.Contains(out, []byte("sops:")) && bytes.Contains(out, []byte("mac: ENC[")) { - data, err := kd.DataWithFormat(out, formats.Yaml, formats.Yaml) - if err != nil { - return nil, fmt.Errorf("DataWithFormat: %w", err) - } - - jsonData, err := yaml.YAMLToJSON(data) - if err != nil { - return nil, fmt.Errorf("YAMLToJSON: %w", err) - } - - err = res.UnmarshalJSON(jsonData) - if err != nil { - return nil, fmt.Errorf("UnmarshalJSON: %w", err) - } - return res, nil - - } else if res.GetKind() == "Secret" { - - dataMap := res.GetDataMap() - - for key, value := range dataMap { - - data, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return nil, fmt.Errorf("Base64 Decode: %w", err) - } - - if bytes.Contains(data, []byte("sops")) && bytes.Contains(data, []byte("ENC[")) { - outputFormat := formats.FormatForPath(key) - out, err := kd.DataWithFormat(data, formats.Yaml, outputFormat) - if err != nil { - return nil, fmt.Errorf("DataWithFormat: %w", err) - } - - dataMap[key] = base64.StdEncoding.EncodeToString(out) - } - } - - res.SetDataMap(dataMap) - - return res, nil - +// IsEncryptedSecret checks if the given object is a Kubernetes Secret encrypted +// with Mozilla SOPS. +func IsEncryptedSecret(object *unstructured.Unstructured) bool { + if object.GetKind() == "Secret" && object.GetAPIVersion() == "v1" { + if _, found, _ := unstructured.NestedFieldNoCopy(object.Object, "sops"); found { + return true } } - return nil, nil + return false } -func (kd *KustomizeDecryptor) ImportKeys(ctx context.Context) error { - if kd.kustomization.Spec.Decryption != nil && kd.kustomization.Spec.Decryption.SecretRef != nil { +// ImportKeys imports the DecryptionProviderSOPS keys from the data values of +// the Secret referenced in the Kustomization's v1beta2.Decryption spec. +// It returns an error if the Secret cannot be retrieved, or if one of the +// imports fails. +// Imports do not have an effect after the first call to SopsDecryptWithFormat(), +// which initializes and caches SOPS' (local) key service server. +// For the import of PGP keys, the KustomizeDecryptor must be configured with +// an absolute GnuPG home directory path. +func (d *KustomizeDecryptor) ImportKeys(ctx context.Context) error { + if d.kustomization.Spec.Decryption == nil || d.kustomization.Spec.Decryption.SecretRef == nil { + return nil + } + + provider := d.kustomization.Spec.Decryption.Provider + switch provider { + case DecryptionProviderSOPS: secretName := types.NamespacedName{ - Namespace: kd.kustomization.GetNamespace(), - Name: kd.kustomization.Spec.Decryption.SecretRef.Name, + Namespace: d.kustomization.GetNamespace(), + Name: d.kustomization.Spec.Decryption.SecretRef.Name, } var secret corev1.Secret - if err := kd.Get(ctx, secretName, &secret); err != nil { - return fmt.Errorf("decryption secret error: %w", err) + if err := d.client.Get(ctx, secretName, &secret); err != nil { + if apierrors.IsNotFound(err) { + return err + } + return fmt.Errorf("cannot get %s decryption Secret '%s': %w", provider, secretName, err) } var err error for name, value := range secret.Data { switch filepath.Ext(name) { - case ".asc": - if err = kd.gnuPGHome.Import(value); err != nil { - return fmt.Errorf("failed to import '%s' data from Secret '%s': %w", name, secretName, err) + case DecryptionPGPExt: + if err = d.gnuPGHome.Import(value); err != nil { + return fmt.Errorf("failed to import '%s' data from %s decryption Secret '%s': %w", name, provider, secretName, err) } - case ".agekey": - if err = kd.ageIdentities.Import(string(value)); err != nil { - return fmt.Errorf("failed to import '%s' data from Secret '%s': %w", name, secretName, err) + case DecryptionAgeExt: + if err = d.ageIdentities.Import(string(value)); err != nil { + return fmt.Errorf("failed to import '%s' data from %s decryption Secret '%s': %w", name, provider, secretName, err) } case filepath.Ext(DecryptionVaultTokenFileName): // Make sure we have the absolute name if name == DecryptionVaultTokenFileName { token := string(value) token = strings.Trim(strings.TrimSpace(token), "\n") - kd.vaultToken = token + d.vaultToken = token } case filepath.Ext(DecryptionAzureAuthFile): // Make sure we have the absolute name if name == DecryptionAzureAuthFile { conf := azkv.AADConfig{} if err = azkv.LoadAADConfigFromBytes(value, &conf); err != nil { - return fmt.Errorf("failed to import '%s' data from Secret '%s': %w", name, secretName, err) + return fmt.Errorf("failed to import '%s' data from %s decryption Secret '%s': %w", name, provider, secretName, err) } - if kd.azureToken, err = azkv.TokenFromAADConfig(conf); err != nil { - return fmt.Errorf("failed to import '%s' data from Secret '%s': %w", name, secretName, err) + if d.azureToken, err = azkv.TokenFromAADConfig(conf); err != nil { + return fmt.Errorf("failed to import '%s' data from %s decryption Secret '%s': %w", name, provider, secretName, err) } } } } } - return nil } -func (kd *KustomizeDecryptor) decryptDotEnvFiles(dirpath string) error { - kustomizePath := filepath.Join(dirpath, konfig.DefaultKustomizationFileName()) - ksData, err := os.ReadFile(kustomizePath) +// SopsDecryptWithFormat attempts to load a SOPS encrypted file using the store +// for the input format, gathers the data key for it from the key service, +// and then decrypts the file data with the retrieved data key. +// It returns the decrypted bytes in the provided output format, or an error. +func (d *KustomizeDecryptor) SopsDecryptWithFormat(data []byte, inputFormat, outputFormat formats.Format) ([]byte, error) { + store := common.StoreForFormat(inputFormat) + + tree, err := store.LoadEncryptedFile(data) if err != nil { - return nil + return nil, sopsUserErr(fmt.Sprintf("failed to load encrypted %s data", sopsFormatToString[inputFormat]), err) } - kus := kustypes.Kustomization{ - TypeMeta: kustypes.TypeMeta{ - APIVersion: kustypes.KustomizationVersion, - Kind: kustypes.KustomizationKind, - }, + metadataKey, err := tree.Metadata.GetDataKeyWithKeyServices(d.keyServiceServer()) + if err != nil { + return nil, sopsUserErr("cannot get sops data key", err) } - if err := yaml.Unmarshal(ksData, &kus); err != nil { - return err + cipher := aes.NewCipher() + mac, err := tree.Decrypt(metadataKey, cipher) + if err != nil { + return nil, sopsUserErr("error decrypting sops tree", err) + } + + if d.checkSopsMac { + // Compute the hash of the cleartext tree and compare it with + // the one that was stored in the document. If they match, + // integrity was preserved + // Ref: go.mozilla.org/sops/v3/decrypt/decrypt.go + originalMac, err := cipher.Decrypt( + tree.Metadata.MessageAuthenticationCode, + metadataKey, + tree.Metadata.LastModified.Format(time.RFC3339), + ) + if err != nil { + return nil, sopsUserErr("failed to verify sops data integrity", err) + } + if originalMac != mac { + // If the file has an empty MAC, display "no MAC" + if originalMac == "" { + originalMac = "no MAC" + } + return nil, fmt.Errorf("failed to verify sops data integrity: expected mac '%s', got '%s'", originalMac, mac) + } } - // recursively decrypt .env files in directories in - for _, rsrc := range kus.Resources { - rsrcPath := filepath.Join(dirpath, rsrc) - isDir, err := isDir(rsrcPath) - if err == nil && isDir { - err := kd.decryptDotEnvFiles(rsrcPath) + outputStore := common.StoreForFormat(outputFormat) + out, err := outputStore.EmitPlainFile(tree.Branches) + if err != nil { + return nil, sopsUserErr(fmt.Sprintf("failed to emit encrypted %s file as decrypted %s", + sopsFormatToString[inputFormat], sopsFormatToString[outputFormat]), err) + } + return out, err +} + +// DecryptResource attempts to decrypt the provided resource with the +// decryption provider specified on the Kustomization, overwriting the resource +// with the decrypted data. +// It has special support for Kubernetes Secrets with encrypted data entries +// while decrypting with DecryptionProviderSOPS, to allow individual data entries +// injected by e.g. a Kustomize secret generator to be decrypted +func (d *KustomizeDecryptor) DecryptResource(res *resource.Resource) (*resource.Resource, error) { + if res == nil || d.kustomization.Spec.Decryption == nil || d.kustomization.Spec.Decryption.Provider == "" { + return nil, nil + } + + switch d.kustomization.Spec.Decryption.Provider { + case DecryptionProviderSOPS: + switch { + case isSOPSEncryptedResource(res): + // As we are expecting to decrypt right before applying, we do not + // care about keeping any other data (e.g. comments) around. + // We can therefore simply work with JSON, which saves us from e.g. + // JSON -> YAML -> JSON transformations. + out, err := res.MarshalJSON() + if err != nil { + return nil, err + } + + data, err := d.SopsDecryptWithFormat(out, formats.Json, formats.Json) + if err != nil { + return nil, fmt.Errorf("failed to decrypt and format '%s/%s' %s data: %w", + res.GetNamespace(), res.GetName(), res.GetKind(), err) + } + + err = res.UnmarshalJSON(data) if err != nil { - return fmt.Errorf("error decrypting .env files in dir '%s': %w", - rsrcPath, err) + return nil, fmt.Errorf("failed to unmarshal decrypted '%s/%s' %s to JSON: %w", + res.GetNamespace(), res.GetName(), res.GetKind(), err) + } + return res, nil + case res.GetKind() == "Secret": + dataMap := res.GetDataMap() + for key, value := range dataMap { + data, err := base64.StdEncoding.DecodeString(value) + if err != nil { + // If we fail to base64 decode, it is (very) likely to be a + // user input error. Instead of failing here, let it bubble + // up during the actual build. + continue + } + + if bytes.Contains(data, sopsFormatToMarkerBytes[formats.Yaml]) || bytes.Contains(data, sopsFormatToMarkerBytes[formats.Json]) { + outF := formats.FormatForPath(key) + out, err := d.SopsDecryptWithFormat(data, formats.Yaml, outF) + if err != nil { + return nil, fmt.Errorf("failed to decrypt and format '%s/%s' Secret field '%s': %w", + res.GetNamespace(), res.GetName(), key, err) + } + dataMap[key] = base64.StdEncoding.EncodeToString(out) + } } + res.SetDataMap(dataMap) + return res, nil } } + return nil, nil +} - secretGens := kus.SecretGenerator - for _, gen := range secretGens { - for _, envFile := range gen.EnvSources { +// DecryptEnvSources attempts to decrypt all types.SecretArgs FileSources and +// EnvSources a Kustomization file in the directory at the provided path refers +// to, before walking recursively over all other resources it refers to. +// It ignores resource references which refer to absolute or relative paths +// outside the working directory of the decryptor, but returns any decryption +// error. +func (d *KustomizeDecryptor) DecryptEnvSources(path string) error { + if d.kustomization.Spec.Decryption.Provider != DecryptionProviderSOPS { + return nil + } + + decrypted, visited := make(map[string]struct{}, 0), make(map[string]struct{}, 0) + visit := d.decryptKustomizationEnvSources(decrypted) + return recurseKustomizationFiles(d.root, path, visit, visited) +} - envFileParts := strings.Split(envFile, "=") - if len(envFileParts) > 1 { - envFile = envFileParts[1] +// decryptKustomizationEnvSources returns a visitKustomization implementation +// which attempts to decrypt any EnvSources entry it finds in the Kustomization +// file it is called with. +// After a successful decrypt, the absolute path of the file is added to the +// given map. +func (d *KustomizeDecryptor) decryptKustomizationEnvSources(visited map[string]struct{}) visitKustomization { + return func(root, path string, kus *kustypes.Kustomization) error { + visitRef := func(ref string, format formats.Format) error { + refParts := strings.Split(ref, "=") + if len(refParts) > 1 { + ref = refParts[1] + } + if !filepath.IsAbs(ref) { + ref = filepath.Join(path, ref) } - envPath := filepath.Join(dirpath, envFile) - data, err := os.ReadFile(envPath) + absRef, _, err := securePaths(root, ref) if err != nil { return err } + if _, ok := visited[absRef]; ok { + return nil + } - if bytes.Contains(data, []byte("sops_mac=ENC[")) { - out, err := kd.DataWithFormat(data, formats.Dotenv, formats.Dotenv) - if err != nil { + if err := d.sopsDecryptFile(absRef, format, format); err != nil { + return securePathErr(root, err) + } + + // Explicitly set _after_ the decryption operation, this makes + // visited work as a list of actually decrypted files + visited[absRef] = struct{}{} + return nil + } + + for _, gen := range kus.SecretGenerator { + for _, fileSrc := range gen.FileSources { + if err := visitRef(fileSrc, formats.FormatForPath(fileSrc)); err != nil { return err } - - err = os.WriteFile(envPath, out, 0644) - if err != nil { - return fmt.Errorf("error writing to file: %w", err) + } + for _, envFile := range gen.EnvSources { + format := formats.FormatForPath(envFile) + if formats.FormatForPath(envFile) == formats.Binary { + // Default to dotenv + format = formats.Dotenv + } + if err := visitRef(envFile, format); err != nil { + return err } } } + return nil } +} +// sopsDecryptFile attempts to decrypt the file at the given path using SOPS' +// store for the provided input format, and writes it back to the path using +// the store for the output format. +// Path must be absolute and a regular file, the file is not allowed to exceed +// the maxFileSize. +// +// NB: The method only does the simple checks described above and does not +// verify whether the path provided is inside the working directory. Boundary +// enforcement is expected to have been done by the caller. +func (d *KustomizeDecryptor) sopsDecryptFile(path string, inputFormat, outputFormat formats.Format) error { + fi, err := os.Lstat(path) + if err != nil { + return err + } + + if !fi.Mode().IsRegular() { + return fmt.Errorf("cannot decrypt irregular file as it has file mode type bits set") + } + if fileSize := fi.Size(); d.maxFileSize > 0 && fileSize > d.maxFileSize { + return fmt.Errorf("cannot decrypt file with size (%d bytes) exceeding limit (%d)", fileSize, d.maxFileSize) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + if !bytes.Contains(data, sopsFormatToMarkerBytes[inputFormat]) { + return nil + } + + out, err := d.SopsDecryptWithFormat(data, inputFormat, outputFormat) + if err != nil { + return err + } + err = os.WriteFile(path, out, 0o644) + if err != nil { + return fmt.Errorf("error writing sops decrypted %s data to %s file: %w", + sopsFormatToString[inputFormat], sopsFormatToString[outputFormat], err) + } return nil } -func (kd KustomizeDecryptor) DataWithFormat(data []byte, inputFormat, outputFormat formats.Format) ([]byte, error) { +// sopsEncryptWithFormat attempts to load a plain file using the store +// for the input format, gathers the data key for it from the key service, +// and then encrypt the file data with the retrieved data key. +// It returns the encrypted bytes in the provided output format, or an error. +func (d *KustomizeDecryptor) sopsEncryptWithFormat(metadata sops.Metadata, data []byte, inputFormat, outputFormat formats.Format) ([]byte, error) { store := common.StoreForFormat(inputFormat) - tree, err := store.LoadEncryptedFile(data) + branches, err := store.LoadPlainFile(data) if err != nil { - return nil, fmt.Errorf("LoadEncryptedFile: %w", err) + return nil, err } + tree := sops.Tree{ + Branches: branches, + Metadata: metadata, + } + dataKey, errs := tree.GenerateDataKeyWithKeyServices(d.keyServiceServer()) + if len(errs) > 0 { + return nil, sopsUserErr("could not generate data key", fmt.Errorf("%s", errs)) + } + + cipher := aes.NewCipher() + unencryptedMac, err := tree.Encrypt(dataKey, cipher) + if err != nil { + return nil, sopsUserErr("error encrypting sops tree", err) + } + tree.Metadata.LastModified = time.Now().UTC() + tree.Metadata.MessageAuthenticationCode, err = cipher.Encrypt(unencryptedMac, dataKey, tree.Metadata.LastModified.Format(time.RFC3339)) + if err != nil { + return nil, sopsUserErr("cannot encrypt sops data tree", err) + } + + outStore := common.StoreForFormat(outputFormat) + out, err := outStore.EmitEncryptedFile(tree) + if err != nil { + return nil, sopsUserErr("failed to emit sops encrypted file", err) + } + return out, nil +} + +// keyServiceServer returns the SOPS (local) key service clients used to serve +// decryption requests. loadKeyServiceServers() is only configured on the first +// call. +func (d *KustomizeDecryptor) keyServiceServer() []keyservice.KeyServiceClient { + d.localServiceOnce.Do(func() { + d.loadKeyServiceServers() + }) + return d.keyServices +} + +// loadKeyServiceServers loads the SOPS (local) key service clients used to +// serve decryption requests for the current set of KustomizeDecryptor +// credentials. +func (d *KustomizeDecryptor) loadKeyServiceServers() { serverOpts := []intkeyservice.ServerOption{ - intkeyservice.WithGnuPGHome(kd.gnuPGHome), - intkeyservice.WithVaultToken(kd.vaultToken), - intkeyservice.WithAgeIdentities(kd.ageIdentities), + intkeyservice.WithGnuPGHome(d.gnuPGHome), + intkeyservice.WithVaultToken(d.vaultToken), + intkeyservice.WithAgeIdentities(d.ageIdentities), } - if kd.azureToken != nil { - serverOpts = append(serverOpts, intkeyservice.WithAzureToken{Token: kd.azureToken}) + if d.azureToken != nil { + serverOpts = append(serverOpts, intkeyservice.WithAzureToken{Token: d.azureToken}) } + server := intkeyservice.NewServer(serverOpts...) + d.keyServices = append(make([]keyservice.KeyServiceClient, 0), intkeyservice.NewLocalClient(server)) +} + +// secureLoadKustomizationFile tries to securely load a Kustomization file from +// the given directory path. +// If multiple Kustomization files are found, or the request is ambiguous, an +// error is returned. +func secureLoadKustomizationFile(root, path string) (*kustypes.Kustomization, error) { + if !filepath.IsAbs(root) { + return nil, fmt.Errorf("root '%s' must be absolute", root) + } + if filepath.IsAbs(path) { + return nil, fmt.Errorf("path '%s' must be relative", path) + } + + var loadPath string + for _, fName := range konfig.RecognizedKustomizationFileNames() { + fPath, err := securejoin.SecureJoin(root, filepath.Join(path, fName)) + if err != nil { + return nil, fmt.Errorf("failed to secure join %s: %w", fName, err) + } + fi, err := os.Lstat(fPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + return nil, fmt.Errorf("failed to lstat %s: %w", fName, securePathErr(root, err)) + } - metadataKey, err := tree.Metadata.GetDataKeyWithKeyServices( - []keyservice.KeyServiceClient{ - intkeyservice.NewLocalClient(intkeyservice.NewServer(serverOpts...)), + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("expected %s to be a regular file", fName) + } + if loadPath != "" { + return nil, fmt.Errorf("found multiple kustomization files") + } + loadPath = fPath + } + if loadPath == "" { + return nil, fmt.Errorf("no kustomization file found") + } + + data, err := os.ReadFile(loadPath) + if err != nil { + return nil, fmt.Errorf("failed to read kustomization file: %w", securePathErr(root, err)) + } + + kus := kustypes.Kustomization{ + TypeMeta: kustypes.TypeMeta{ + APIVersion: kustypes.KustomizationVersion, + Kind: kustypes.KustomizationKind, }, - ) + } + if err := yaml.Unmarshal(data, &kus); err != nil { + return nil, fmt.Errorf("failed to unmarshal kustomization file: %w", err) + } + return &kus, nil +} + +// visitKustomization is called by recurseKustomizationFiles after every +// successful Kustomization file load. +type visitKustomization func(root, path string, kus *kustypes.Kustomization) error + +// errRecurseIgnore is a wrapping error to signal to recurseKustomizationFiles +// the error can be ignored during recursion. For example, because the +// Kustomization file can not be loaded for a subsequent call. +type errRecurseIgnore struct { + Err error +} + +// Unwrap returns the actual underlying error. +func (e *errRecurseIgnore) Unwrap() error { + return e.Err +} + +// Error returns the error string of the underlying error. +func (e *errRecurseIgnore) Error() string { + if err := e.Err; err != nil { + return e.Err.Error() + } + return "recurse ignore" +} + +// recurseKustomizationFiles attempts to recursively load and visit +// Kustomization files. +// The provided path is allowed to be relative, in which case it is safely +// joined with root. When absolute, it must be inside root. +func recurseKustomizationFiles(root, path string, visit visitKustomization, visited map[string]struct{}) error { + // Resolve the secure paths + absPath, relPath, err := securePaths(root, path) if err != nil { - if userErr, ok := err.(sops.UserError); ok { - err = fmt.Errorf(userErr.UserError()) - } - return nil, fmt.Errorf("GetDataKey: %w", err) + return err } - cipher := aes.NewCipher() - if _, err := tree.Decrypt(metadataKey, cipher); err != nil { - return nil, fmt.Errorf("AES decrypt: %w", err) + if _, ok := visited[absPath]; ok { + // Short-circuit + return nil } + visited[absPath] = struct{}{} - outputStore := common.StoreForFormat(outputFormat) + // Confirm we are dealing with a directory + fi, err := os.Lstat(absPath) + if err != nil { + err = securePathErr(root, err) + if errors.Is(err, fs.ErrNotExist) { + err = &errRecurseIgnore{Err: err} + } + return err + } + if !fi.IsDir() { + return &errRecurseIgnore{Err: fmt.Errorf("not a directory")} + } - out, err := outputStore.EmitPlainFile(tree.Branches) + // Attempt to load the Kustomization file from the directory + kus, err := secureLoadKustomizationFile(root, relPath) if err != nil { - return nil, fmt.Errorf("EmitPlainFile: %w", err) + return err } - return out, err + // Visit the Kustomization + if err = visit(root, path, kus); err != nil { + return err + } + + // Recurse over other resources in Kustomization, + // repeating the above logic per item + for _, res := range kus.Resources { + if !filepath.IsAbs(res) { + res = filepath.Join(path, res) + } + if err = recurseKustomizationFiles(root, res, visit, visited); err != nil { + // When the resource does not exist at the compiled path, it's + // either an invalid reference, or a URL. + // If the reference is valid but does not point to a directory, + // we have run into a dead end as well. + // In all other cases, the error is of (possible) importance to + // the user, and we should return it. + if _, ok := err.(*errRecurseIgnore); !ok { + return err + } + } + } + return nil +} + +// isSOPSEncryptedResource detects if the given resource is a SOPS' encrypted +// resource by looking for ".sops" and ".sops.mac" fields. +func isSOPSEncryptedResource(res *resource.Resource) bool { + if res == nil { + return false + } + sopsField := res.Field("sops") + if sopsField.IsNilOrEmpty() { + return false + } + macField := sopsField.Value.Field("mac") + return !macField.IsNilOrEmpty() } -func isDir(path string) (bool, error) { - fileInfo, err := os.Stat(path) +// securePaths returns the absolute and relative paths for the provided path, +// guaranteed to be scoped inside the provided root. +// When the given path is absolute, the root is stripped before secure joining +// it on root. +func securePaths(root, path string) (string, string, error) { + if filepath.IsAbs(path) { + path = stripRoot(root, path) + } + secureAbsPath, err := securejoin.SecureJoin(root, path) if err != nil { - return false, err + return "", "", err } + return secureAbsPath, stripRoot(root, secureAbsPath), nil +} - return fileInfo.IsDir(), nil +func stripRoot(root, path string) string { + sepStr := string(filepath.Separator) + root, path = filepath.Clean(sepStr+root), filepath.Clean(sepStr+path) + switch { + case path == root: + path = sepStr + case root == sepStr: + // noop + case strings.HasPrefix(path, root+sepStr): + path = strings.TrimPrefix(path, root+sepStr) + } + return filepath.Clean(filepath.Join("."+sepStr, path)) } -// IsEncryptedSecret checks if the given object is a Kubernetes Secret encrypted with Mozilla SOPS. -func IsEncryptedSecret(object *unstructured.Unstructured) bool { - if object.GetKind() == "Secret" && object.GetAPIVersion() == "v1" { - if _, found, _ := unstructured.NestedFieldNoCopy(object.Object, "sops"); found { - return true - } +func sopsUserErr(msg string, err error) error { + if userErr, ok := err.(sops.UserError); ok { + err = fmt.Errorf(userErr.UserError()) } - return false + return fmt.Errorf("%s: %w", msg, err) +} + +func securePathErr(root string, err error) error { + if pathErr := new(fs.PathError); errors.As(err, &pathErr) { + err = &fs.PathError{Op: pathErr.Op, Path: stripRoot(root, pathErr.Path), Err: pathErr.Err} + } + return err } diff --git a/controllers/kustomization_decryptor_test.go b/controllers/kustomization_decryptor_test.go index 70b98c65a..9d954343a 100644 --- a/controllers/kustomization_decryptor_test.go +++ b/controllers/kustomization_decryptor_test.go @@ -17,22 +17,42 @@ limitations under the License. package controllers import ( + "bytes" "context" + "encoding/base64" "fmt" + "io/fs" "os" "os/exec" + "path/filepath" + "regexp" + "strings" "testing" "time" - kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" - "github.com/fluxcd/pkg/apis/meta" + extage "filippo.io/age" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" "github.com/hashicorp/vault/api" . "github.com/onsi/gomega" + gt "github.com/onsi/gomega/types" + "go.mozilla.org/sops/v3" + sopsage "go.mozilla.org/sops/v3/age" + "go.mozilla.org/sops/v3/cmd/sops/formats" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/kustomize/api/konfig" + "sigs.k8s.io/kustomize/api/provider" + "sigs.k8s.io/kustomize/api/resource" + kustypes "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/yaml" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" + "github.com/fluxcd/kustomize-controller/internal/sops/age" + "github.com/fluxcd/pkg/apis/meta" ) func TestKustomizationReconciler_Decryptor(t *testing.T) { @@ -169,6 +189,8 @@ func TestKustomizationReconciler_Decryptor(t *testing.T) { }, timeout, time.Second).Should(BeTrue()) t.Run("decrypts SOPS secrets", func(t *testing.T) { + g := NewWithT(t) + var pgpSecret corev1.Secret g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-pgp", Namespace: id}, &pgpSecret)).To(Succeed()) g.Expect(pgpSecret.Data["secret"]).To(Equal([]byte(`my-sops-pgp-secret`))) @@ -207,6 +229,8 @@ func TestKustomizationReconciler_Decryptor(t *testing.T) { }) t.Run("does not emit change events for identical secrets", func(t *testing.T) { + g := NewWithT(t) + resultK := &kustomizev1.Kustomization{} revision := "v2.0.0" err = applyGitRepository(repositoryName, artifactName, revision) @@ -223,3 +247,1392 @@ func TestKustomizationReconciler_Decryptor(t *testing.T) { g.Expect(events[0].Message).ShouldNot(ContainSubstring("configured")) }) } + +func TestIsEncryptedSecret(t *testing.T) { + tests := []struct { + name string + object []byte + want gt.GomegaMatcher + }{ + {name: "encrypted secret", object: []byte("apiVersion: v1\nkind: Secret\nsops: true\n"), want: BeTrue()}, + {name: "decrypted secret", object: []byte("apiVersion: v1\nkind: Secret\n"), want: BeFalse()}, + {name: "other resource", object: []byte("apiVersion: v1\nkind: Deployment\n"), want: BeFalse()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + u := &unstructured.Unstructured{} + g.Expect(yaml.Unmarshal(tt.object, u)).To(Succeed()) + g.Expect(IsEncryptedSecret(u)).To(tt.want) + }) + } +} + +func TestKustomizeDecryptor_ImportKeys(t *testing.T) { + g := NewWithT(t) + + const provider = "sops" + + pgpKey, err := os.ReadFile("testdata/sops/pgp.asc") + g.Expect(err).ToNot(HaveOccurred()) + ageKey, err := os.ReadFile("testdata/sops/age.txt") + g.Expect(err).ToNot(HaveOccurred()) + + tests := []struct { + name string + decryption *kustomizev1.Decryption + secret *corev1.Secret + wantErr bool + inspectFunc func(g *GomegaWithT, decryptor *KustomizeDecryptor) + }{ + { + name: "PGP key", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "pgp-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pgp-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + "pgp" + DecryptionPGPExt: pgpKey, + }, + }, + }, + { + name: "PGP key import error", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "pgp-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pgp-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + "pgp" + DecryptionPGPExt: []byte("not-a-valid-armored-key"), + }, + }, + wantErr: true, + }, + { + name: "age key", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "age-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "age-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + "age" + DecryptionAgeExt: ageKey, + }, + }, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.ageIdentities).To(HaveLen(1)) + }, + }, + { + name: "age key import error", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "age-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "age-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + "age" + DecryptionAgeExt: []byte("not-a-valid-key"), + }, + }, + wantErr: true, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.ageIdentities).To(HaveLen(0)) + }, + }, + { + name: "HC Vault token", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "hcvault-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hcvault-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + DecryptionVaultTokenFileName: []byte("some-hcvault-token"), + }, + }, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.vaultToken).To(Equal("some-hcvault-token")) + }, + }, + { + name: "Azure Key Vault token", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "azkv-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azkv-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + DecryptionAzureAuthFile: []byte(`tenantId: some-tenant-id +clientId: some-client-id +clientSecret: some-client-secret`), + }, + }, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.azureToken).ToNot(BeNil()) + }, + }, + { + name: "Azure Key Vault token load config error", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "azkv-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azkv-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + DecryptionAzureAuthFile: []byte(`{"malformed\: JSON"}`), + }, + }, + wantErr: true, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.azureToken).To(BeNil()) + }, + }, + { + name: "Azure Key Vault unsupported config", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "azkv-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "azkv-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + DecryptionAzureAuthFile: []byte(`tenantId: incomplete`), + }, + }, + wantErr: true, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.azureToken).To(BeNil()) + }, + }, + { + name: "multiple Secret data entries", + decryption: &kustomizev1.Decryption{ + Provider: provider, + SecretRef: &meta.LocalObjectReference{ + Name: "multiple-secret", + }, + }, + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multiple-secret", + Namespace: provider, + }, + Data: map[string][]byte{ + "age" + DecryptionAgeExt: ageKey, + DecryptionVaultTokenFileName: []byte("some-hcvault-token"), + }, + }, + inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) { + g.Expect(decryptor.vaultToken).ToNot(BeEmpty()) + g.Expect(decryptor.ageIdentities).To(HaveLen(1)) + }, + }, + { + name: "no Decryption spec", + decryption: nil, + wantErr: false, + }, + { + name: "no Decryption Secret", + decryption: &kustomizev1.Decryption{ + Provider: DecryptionProviderSOPS, + }, + wantErr: false, + }, + { + name: "non-existing Decryption Secret", + decryption: &kustomizev1.Decryption{ + Provider: DecryptionProviderSOPS, + SecretRef: &meta.LocalObjectReference{ + Name: "does-not-exist", + }, + }, + wantErr: true, + }, + { + name: "unimplemented Decryption Provider", + decryption: &kustomizev1.Decryption{ + Provider: "not-supported", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + cb := fake.NewClientBuilder() + if tt.secret != nil { + cb.WithObjects(tt.secret) + } + kustomization := kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: provider + "-" + tt.name, + Namespace: provider, + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: 2 * time.Minute}, + Path: "./", + Decryption: tt.decryption, + }, + } + + d, cleanup, err := NewTempDecryptor("", cb.Build(), kustomization) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(cleanup) + + match := Succeed() + if tt.wantErr { + match = HaveOccurred() + } + g.Expect(d.ImportKeys(context.TODO())).To(match) + + if tt.inspectFunc != nil { + tt.inspectFunc(g, d) + } + }) + } +} + +func TestKustomizeDecryptor_SopsDecryptWithFormat(t *testing.T) { + t.Run("decrypt INI to INI", func(t *testing.T) { + g := NewWithT(t) + + ageID, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + + kd := &KustomizeDecryptor{ + checkSopsMac: true, + ageIdentities: age.ParsedIdentities{ageID}, + } + + format := formats.Ini + data := []byte("[config]\nkey = value\n\n") + encData, err := kd.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: ageID.Recipient().String()}}, + }, + }, data, format, format) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(bytes.Contains(encData, sopsFormatToMarkerBytes[format])).To(BeTrue()) + g.Expect(encData).ToNot(Equal(data)) + + out, err := kd.SopsDecryptWithFormat(encData, format, format) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out).To(Equal(data)) + }) + + t.Run("decrypt JSON to YAML", func(t *testing.T) { + g := NewWithT(t) + + ageID, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + + kd := &KustomizeDecryptor{ + checkSopsMac: true, + ageIdentities: age.ParsedIdentities{ageID}, + } + + inputFormat, outputFormat := formats.Json, formats.Yaml + data := []byte("{\"key\": \"value\"}\n") + encData, err := kd.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: ageID.Recipient().String()}}, + }, + }, data, inputFormat, inputFormat) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(bytes.Contains(encData, sopsFormatToMarkerBytes[inputFormat])).To(BeTrue()) + + out, err := kd.SopsDecryptWithFormat(encData, inputFormat, outputFormat) + t.Logf("%s", out) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out).To(Equal([]byte("key: value\n"))) + }) + + t.Run("invalid JSON data", func(t *testing.T) { + g := NewWithT(t) + + format := formats.Json + data, err := (&KustomizeDecryptor{}).SopsDecryptWithFormat([]byte("invalid json"), format, format) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to load encrypted JSON data")) + g.Expect(data).To(BeNil()) + }) + + t.Run("no data key", func(t *testing.T) { + g := NewWithT(t) + + ageID, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + + kd := &KustomizeDecryptor{} + + format := formats.Binary + encData, err := kd.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: ageID.Recipient().String()}}, + }, + }, []byte("foo bar"), format, format) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(bytes.Contains(encData, sopsFormatToMarkerBytes[format])).To(BeTrue()) + + data, err := kd.SopsDecryptWithFormat(encData, format, format) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("cannot get sops data key")) + g.Expect(data).To(BeNil()) + }) + + t.Run("with mac check", func(t *testing.T) { + g := NewWithT(t) + + ageID, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + + kd := &KustomizeDecryptor{ + checkSopsMac: true, + ageIdentities: age.ParsedIdentities{ageID}, + } + + format := formats.Dotenv + data := []byte("key=value\n") + encData, err := kd.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: ageID.Recipient().String()}}, + }, + }, data, format, format) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(bytes.Contains(encData, sopsFormatToMarkerBytes[format])).To(BeTrue()) + + out, err := kd.SopsDecryptWithFormat(encData, format, format) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out).To(Equal(data)) + + badMAC := regexp.MustCompile("(?m)[\r\n]+^.*sops_mac=.*$") + badMACData := badMAC.ReplaceAll(encData, []byte("\nsops_mac=\n")) + out, err = kd.SopsDecryptWithFormat(badMACData, format, format) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("failed to verify sops data integrity: expected mac 'no MAC'")) + g.Expect(out).To(BeNil()) + }) +} + +func TestKustomizeDecryptor_DecryptResource(t *testing.T) { + var ( + resourceFactory = provider.NewDefaultDepProvider().GetResourceFactory() + emptyResource = resourceFactory.FromMap(map[string]interface{}{}) + ) + + newSecretResource := func(namespace, name string, data map[string]interface{}) *resource.Resource { + return resourceFactory.FromMap(map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "secret", + "namespace": "test", + }, + "data": data, + }) + } + + kustomization := kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "decrypt", + Namespace: "decrypt", + }, + Spec: kustomizev1.KustomizationSpec{ + Interval: metav1.Duration{Duration: 2 * time.Minute}, + Path: "./", + }, + } + + t.Run("SOPS encrypted resource", func(t *testing.T) { + g := NewWithT(t) + + kus := kustomization.DeepCopy() + kus.Spec.Decryption = &kustomizev1.Decryption{ + Provider: DecryptionProviderSOPS, + } + + d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), *kus) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(cleanup) + + ageID, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + d.ageIdentities = append(d.ageIdentities, ageID) + + secret := newSecretResource("test", "secret", map[string]interface{}{ + "key": "value", + }) + g.Expect(isSOPSEncryptedResource(secret)).To(BeFalse()) + + secretData, err := secret.MarshalJSON() + g.Expect(err).ToNot(HaveOccurred()) + + encData, err := d.sopsEncryptWithFormat(sops.Metadata{ + EncryptedRegex: "^(data|stringData)$", + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: ageID.Recipient().String()}}, + }, + }, secretData, formats.Json, formats.Json) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(secret.UnmarshalJSON(encData)).To(Succeed()) + g.Expect(isSOPSEncryptedResource(secret)).To(BeTrue()) + + got, err := d.DecryptResource(secret) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.MarshalJSON()).To(Equal(secretData)) + }) + + t.Run("SOPS encrypted binary Secret data field", func(t *testing.T) { + g := NewWithT(t) + + kus := kustomization.DeepCopy() + kus.Spec.Decryption = &kustomizev1.Decryption{ + Provider: DecryptionProviderSOPS, + } + + d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), *kus) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(cleanup) + + ageID, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + d.ageIdentities = append(d.ageIdentities, ageID) + + plainData := []byte("[config]\napp = secret\n\n") + encData, err := d.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: ageID.Recipient().String()}}, + }, + }, plainData, formats.Ini, formats.Yaml) + g.Expect(err).ToNot(HaveOccurred()) + + secret := newSecretResource("test", "secret-data", map[string]interface{}{ + "file.ini": base64.StdEncoding.EncodeToString(encData), + }) + g.Expect(isSOPSEncryptedResource(secret)).To(BeFalse()) + + got, err := d.DecryptResource(secret) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.GetDataMap()).To(HaveKeyWithValue("file.ini", base64.StdEncoding.EncodeToString(plainData))) + }) + + t.Run("SOPS encrypted YAML Secret data field", func(t *testing.T) { + g := NewWithT(t) + + kus := kustomization.DeepCopy() + kus.Spec.Decryption = &kustomizev1.Decryption{ + Provider: DecryptionProviderSOPS, + } + + d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), *kus) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(cleanup) + + ageID, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + d.ageIdentities = append(d.ageIdentities, ageID) + + plainData := []byte("structured:\n data:\n key: value\n") + encData, err := d.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: ageID.Recipient().String()}}, + }, + }, plainData, formats.Yaml, formats.Yaml) + g.Expect(err).ToNot(HaveOccurred()) + + secret := newSecretResource("test", "secret-data", map[string]interface{}{ + "key.yaml": base64.StdEncoding.EncodeToString(encData), + }) + g.Expect(isSOPSEncryptedResource(secret)).To(BeFalse()) + + got, err := d.DecryptResource(secret) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(got.GetDataMap()).To(HaveKeyWithValue("key.yaml", base64.StdEncoding.EncodeToString(plainData))) + }) + + t.Run("nil resource", func(t *testing.T) { + g := NewWithT(t) + + d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), *kustomization.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(cleanup) + + got, err := d.DecryptResource(nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) + + t.Run("no decryption spec", func(t *testing.T) { + g := NewWithT(t) + + d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), *kustomization.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(cleanup) + + got, err := d.DecryptResource(emptyResource.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) + + t.Run("unimplemented decryption provider", func(t *testing.T) { + g := NewWithT(t) + + kus := kustomization.DeepCopy() + kus.Spec.Decryption = &kustomizev1.Decryption{ + Provider: "not-supported", + } + d, cleanup, err := NewTempDecryptor("", fake.NewClientBuilder().Build(), *kus) + g.Expect(err).ToNot(HaveOccurred()) + t.Cleanup(cleanup) + + got, err := d.DecryptResource(emptyResource.DeepCopy()) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(BeNil()) + }) +} + +func TestKustomizeDecryptor_decryptKustomizationEnvSources(t *testing.T) { + type file struct { + name string + symlink string + data []byte + encrypt bool + expectData bool + } + tests := []struct { + name string + wordirSuffix string + path string + files []file + secretGenerator []kustypes.SecretArgs + expectVisited []string + wantErr error + }{ + { + name: "decrypt env sources", + path: "subdir", + files: []file{ + {name: "subdir/app.env", data: []byte("var1=value1\n"), encrypt: true, expectData: true}, + {name: "subdir/file.txt", data: []byte("file"), encrypt: true, expectData: true}, + {name: "secret.env", data: []byte("var2=value2\n"), encrypt: true, expectData: true}, + }, + secretGenerator: []kustypes.SecretArgs{ + { + GeneratorArgs: kustypes.GeneratorArgs{ + Name: "envSecret", + KvPairSources: kustypes.KvPairSources{ + FileSources: []string{"file.txt"}, + EnvSources: []string{"app.env", "key=../secret.env"}, + }, + }, + }, + }, + expectVisited: []string{"subdir/app.env", "subdir/file.txt", "secret.env"}, + }, + { + name: "decryption error", + files: []file{}, + secretGenerator: []kustypes.SecretArgs{ + { + GeneratorArgs: kustypes.GeneratorArgs{ + Name: "envSecret", + KvPairSources: kustypes.KvPairSources{ + EnvSources: []string{"file.txt"}, + }, + }, + }, + }, + expectVisited: []string{}, + wantErr: &fs.PathError{Op: "lstat", Path: "file.txt", Err: fmt.Errorf("")}, + }, + { + name: "follows relative symlink within root", + path: "subdir", + files: []file{ + {name: "subdir/symlink", symlink: "../otherdir/data.env"}, + {name: "otherdir/data.env", data: []byte("key=value\n"), encrypt: true, expectData: true}, + }, + secretGenerator: []kustypes.SecretArgs{ + { + GeneratorArgs: kustypes.GeneratorArgs{ + Name: "envSecret", + KvPairSources: kustypes.KvPairSources{ + EnvSources: []string{"symlink"}, + }, + }, + }, + }, + expectVisited: []string{"otherdir/data.env"}, + }, + { + name: "error on symlink outside root", + wordirSuffix: "subdir", + path: "./", + files: []file{ + {name: "subdir/symlink", symlink: "../otherdir/data.env"}, + {name: "otherdir/data.env", data: []byte("key=value\n"), encrypt: true, expectData: false}, + }, + secretGenerator: []kustypes.SecretArgs{ + { + GeneratorArgs: kustypes.GeneratorArgs{ + Name: "envSecret", + KvPairSources: kustypes.KvPairSources{ + EnvSources: []string{"symlink"}, + }, + }, + }, + }, + wantErr: &fs.PathError{Op: "lstat", Path: "otherdir/data.env", Err: fmt.Errorf("")}, + expectVisited: []string{}, + }, + { + name: "error on reference outside root", + wordirSuffix: "subdir", + path: "./", + files: []file{ + {name: "data.env", data: []byte("key=value\n"), encrypt: true, expectData: false}, + }, + secretGenerator: []kustypes.SecretArgs{ + { + GeneratorArgs: kustypes.GeneratorArgs{ + Name: "envSecret", + KvPairSources: kustypes.KvPairSources{ + EnvSources: []string{"../data.env"}, + }, + }, + }, + }, + wantErr: &fs.PathError{Op: "lstat", Path: "data.env", Err: fmt.Errorf("")}, + expectVisited: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + tmpDir := t.TempDir() + root := filepath.Join(tmpDir, tt.wordirSuffix) + + id, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + ageIdentities := age.ParsedIdentities{id} + + d := &KustomizeDecryptor{ + root: root, + ageIdentities: ageIdentities, + } + + for _, f := range tt.files { + fPath := filepath.Join(tmpDir, f.name) + g.Expect(os.MkdirAll(filepath.Dir(fPath), 0o700)).To(Succeed()) + if f.symlink != "" { + g.Expect(os.Symlink(f.symlink, fPath)).To(Succeed()) + continue + } + data := f.data + if f.encrypt { + format := formats.FormatForPath(f.name) + data, err = d.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: id.Recipient().String()}}, + }, + }, f.data, format, format) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(data).ToNot(Equal(f.data)) + } + g.Expect(os.WriteFile(fPath, data, 0o644)).To(Succeed()) + } + + visited := make(map[string]struct{}, 0) + visit := d.decryptKustomizationEnvSources(visited) + kus := &kustypes.Kustomization{SecretGenerator: tt.secretGenerator} + + err = visit(root, tt.path, kus) + if tt.wantErr == nil { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(HaveOccurred()) + g.Expect(err).To(BeAssignableToTypeOf(tt.wantErr)) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr.Error())) + } + + for _, f := range tt.files { + if f.symlink != "" { + continue + } + + b, err := os.ReadFile(filepath.Join(tmpDir, f.name)) + g.Expect(err).ToNot(HaveOccurred()) + if f.expectData { + g.Expect(b).To(Equal(f.data)) + } else { + g.Expect(b).ToNot(Equal(f.data)) + } + } + + absVisited := make(map[string]struct{}, 0) + for _, v := range tt.expectVisited { + absVisited[filepath.Join(tmpDir, v)] = struct{}{} + } + g.Expect(visited).To(Equal(absVisited)) + }) + } +} + +func TestKustomizeDecryptor_decryptSopsFile(t *testing.T) { + g := NewWithT(t) + + id, err := extage.GenerateX25519Identity() + g.Expect(err).ToNot(HaveOccurred()) + ageIdentities := age.ParsedIdentities{id} + + type file struct { + name string + symlink string + data []byte + encrypt bool + format formats.Format + expectData bool + } + tests := []struct { + name string + ageIdentities age.ParsedIdentities + maxFileSize int64 + files []file + path string + format formats.Format + wantErr error + }{ + { + name: "decrypt dotenv file", + ageIdentities: age.ParsedIdentities{id}, + files: []file{ + {name: "app.env", data: []byte("app=key\n"), encrypt: true, format: formats.Dotenv, expectData: true}, + }, + path: "app.env", + format: formats.Dotenv, + }, + { + name: "decrypt YAML file", + ageIdentities: age.ParsedIdentities{id}, + files: []file{ + {name: "app.yaml", data: []byte("app: key\n"), encrypt: true, format: formats.Yaml, expectData: true}, + }, + path: "app.yaml", + format: formats.Yaml, + }, + { + name: "irregular file", + files: []file{}, + wantErr: fmt.Errorf("cannot decrypt irregular file as it has file mode type bits set"), + }, + { + name: "file exceeds max size", + maxFileSize: 5, + files: []file{ + {name: "app.env", data: []byte("app=key\n"), encrypt: true, format: formats.Dotenv, expectData: false}, + }, + path: "app.env", + wantErr: fmt.Errorf("cannot decrypt file with size (972 bytes) exceeding limit (5)"), + }, + { + name: "wrong file format", + files: []file{ + {name: "app.ini", data: []byte("[app]\nkey = value"), encrypt: true, format: formats.Ini, expectData: false}, + }, + path: "app.ini", + }, + { + name: "does not follow symlink", + files: []file{ + {name: "link", symlink: "../"}, + }, + path: "link", + wantErr: fmt.Errorf("cannot decrypt irregular file as it has file mode type bits set"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + tmpDir := t.TempDir() + + d := &KustomizeDecryptor{ + root: tmpDir, + maxFileSize: maxEncryptedFileSize, + ageIdentities: ageIdentities, + } + if tt.maxFileSize != 0 { + d.maxFileSize = tt.maxFileSize + } + + for _, f := range tt.files { + fPath := filepath.Join(tmpDir, f.name) + if f.symlink != "" { + g.Expect(os.Symlink(f.symlink, fPath)).To(Succeed()) + continue + } + data := f.data + if f.encrypt { + b, err := d.sopsEncryptWithFormat(sops.Metadata{ + KeyGroups: []sops.KeyGroup{ + {&sopsage.MasterKey{Recipient: id.Recipient().String()}}, + }, + }, data, f.format, f.format) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(b).ToNot(Equal(f.data)) + data = b + } + g.Expect(os.MkdirAll(filepath.Dir(fPath), 0o700)).To(Succeed()) + g.Expect(os.WriteFile(fPath, data, 0o644)).To(Succeed()) + } + + path := filepath.Join(tmpDir, tt.path) + err := d.sopsDecryptFile(path, tt.format, tt.format) + if tt.wantErr != nil { + g.Expect(err).To(HaveOccurred()) + g.Expect(err).To(BeAssignableToTypeOf(tt.wantErr)) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr.Error())) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + for _, f := range tt.files { + if f.symlink != "" { + continue + } + + b, err := os.ReadFile(filepath.Join(tmpDir, f.name)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(bytes.Compare(f.data, b) == 0).To(Equal(f.expectData)) + } + }) + } +} + +func Test_secureLoadKustomizationFile(t *testing.T) { + kusType := kustypes.TypeMeta{ + APIVersion: kustypes.KustomizationVersion, + Kind: kustypes.KustomizationKind, + } + type file struct { + name string + symlink string + data []byte + } + tests := []struct { + name string + rootSuffix string + files []file + path string + want *kustypes.Kustomization + wantErr error + }{ + { + name: "loads default kustomization file", + files: []file{ + {name: konfig.DefaultKustomizationFileName(), data: []byte("resources:\n- resource.yaml")}, + }, + path: "./", + want: &kustypes.Kustomization{ + TypeMeta: kusType, + Resources: []string{"resource.yaml"}, + }, + }, + { + name: "loads recognized kustomization file", + files: []file{ + {name: konfig.RecognizedKustomizationFileNames()[1], data: []byte("resources:\n- resource.yaml")}, + }, + path: "./", + want: &kustypes.Kustomization{ + TypeMeta: kusType, + Resources: []string{"resource.yaml"}, + }, + }, + { + name: "error on ambitious file match", + files: []file{ + {name: konfig.RecognizedKustomizationFileNames()[0], data: []byte("resources:\n- resource.yaml")}, + {name: konfig.RecognizedKustomizationFileNames()[1], data: []byte("resources:\n- resource.yaml")}, + }, + path: "./", + wantErr: fmt.Errorf("found multiple kustomization files"), + }, + { + name: "error on no file found", + files: []file{}, + path: "./", + wantErr: fmt.Errorf("no kustomization file found"), + }, + { + name: "error on symlink outside root", + rootSuffix: "subdir", + files: []file{ + {name: konfig.DefaultKustomizationFileName(), data: []byte("resources:\n- resource.yaml")}, + {name: "subdir/" + konfig.DefaultKustomizationFileName(), symlink: "../kustomization.yaml"}, + }, + wantErr: fmt.Errorf("no kustomization file found"), + }, + { + name: "error on invalid file", + files: []file{ + {name: konfig.DefaultKustomizationFileName(), data: []byte("resources")}, + }, + wantErr: fmt.Errorf("failed to unmarshal kustomization file"), + }, + { + name: "error on absolute path", + path: "/absolute/", + wantErr: fmt.Errorf("path '/absolute/' must be relative"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + tmpDir := t.TempDir() + for _, f := range tt.files { + fPath := filepath.Join(tmpDir, f.name) + if f.symlink != "" { + g.Expect(os.Symlink(f.symlink, fPath)) + continue + } + g.Expect(os.MkdirAll(filepath.Dir(fPath), 0o700)).To(Succeed()) + g.Expect(os.WriteFile(fPath, f.data, 0o644)).To(Succeed()) + } + + root := filepath.Join(tmpDir, tt.rootSuffix) + got, err := secureLoadKustomizationFile(root, tt.path) + if wantErr := tt.wantErr; wantErr != nil { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(wantErr.Error())) + g.Expect(got).To(BeNil()) + return + } + + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func Test_recurseKustomizationFiles(t *testing.T) { + type kusNode struct { + path string + symlink string + resources []string + visitErr error + visited int + expectVisited int + expectCached bool + } + tests := []struct { + name string + wordirSuffix string + path string + nodes []*kusNode + wantErr error + wantErrStr string + }{ + { + name: "recurse on resources", + wordirSuffix: "foo", + path: "bar", + nodes: []*kusNode{ + { + path: "foo/bar/kustomization.yaml", + resources: []string{"../baz"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "foo/baz/kustomization.yaml", + resources: []string{"/foo/bar/baz"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "foo/bar/baz/kustomization.yaml", + resources: []string{}, + expectVisited: 1, + expectCached: true, + }, + }, + }, + { + name: "recursive loop", + wordirSuffix: "foo", + path: "bar", + nodes: []*kusNode{ + { + path: "foo/bar/kustomization.yaml", + resources: []string{"../baz"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "foo/baz/kustomization.yaml", + resources: []string{"../foobar"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "foo/foobar/kustomization.yaml", + resources: []string{"../bar"}, + expectVisited: 1, + expectCached: true, + }, + }, + }, + { + name: "absolute symlink", + path: "bar", + nodes: []*kusNode{ + { + path: "bar/baz/kustomization.yaml", + resources: []string{"../bar/absolute"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "bar/absolute", + symlink: "/bar/foo/", + }, + { + path: "bar/foo/kustomization.yaml", + expectVisited: 1, + expectCached: true, + }, + }, + }, + { + name: "relative symlink", + path: "bar", + nodes: []*kusNode{ + { + path: "bar/baz/kustomization.yaml", + resources: []string{"../bar/relative"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "bar/relative", + symlink: "../foo/", + }, + { + path: "bar/foo/kustomization.yaml", + expectVisited: 1, + expectCached: true, + }, + }, + }, + { + name: "recognized kustomization names", + path: "./", + nodes: []*kusNode{ + { + path: konfig.RecognizedKustomizationFileNames()[1], + resources: []string{"bar"}, + expectVisited: 1, + expectCached: true, + }, + { + path: filepath.Join("bar", konfig.RecognizedKustomizationFileNames()[0]), + resources: []string{"../baz"}, + expectVisited: 1, + expectCached: true, + }, + { + path: filepath.Join("baz", konfig.RecognizedKustomizationFileNames()[2]), + expectVisited: 1, + expectCached: true, + }, + }, + }, + { + name: "path does not exist", + path: "./invalid", + wantErr: &errRecurseIgnore{Err: fs.ErrNotExist}, + wantErrStr: "lstat invalid", + }, + { + name: "path is not a directory", + path: "./file.txt", + nodes: []*kusNode{ + { + path: "file.txt", + }, + }, + wantErr: &errRecurseIgnore{Err: fmt.Errorf("not a directory")}, + wantErrStr: "not a directory", + }, + { + name: "recurse error is returned", + path: "/foo", + nodes: []*kusNode{ + { + path: "foo/kustomization.yaml", + resources: []string{"../baz"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "baz/wrongfile.yaml", + expectVisited: 0, + expectCached: false, + }, + }, + wantErr: fmt.Errorf("no kustomization file found"), + }, + { + name: "recurse ignores errRecurseIgnore", + path: "/foo", + nodes: []*kusNode{ + { + path: "foo/kustomization.yaml", + resources: []string{"../baz"}, + expectVisited: 1, + expectCached: true, + }, + { + path: "baz", + expectVisited: 0, + expectCached: false, + }, + }, + }, + { + name: "remote build references are ignored", + path: "/foo", + nodes: []*kusNode{ + { + path: "foo/kustomization.yaml", + resources: []string{ + "../baz", + "https://github.com/kubernetes-sigs/kustomize//examples/multibases/dev/?ref=v1.0.6", + }, + expectVisited: 1, + expectCached: true, + }, + { + path: "baz/kustomization.yaml", + resources: []string{ + "github.com/Liujingfang1/mysql?ref=test", + }, + expectVisited: 1, + expectCached: true, + }, + }, + }, + { + name: "visit error is returned", + path: "/", + nodes: []*kusNode{ + { + path: "kustomization.yaml", + resources: []string{ + "baz", + }, + expectVisited: 1, + expectCached: true, + }, + { + path: "baz/kustomization.yaml", + visitErr: fmt.Errorf("visit error"), + expectVisited: 1, + expectCached: true, + }, + }, + wantErr: fmt.Errorf("visit error"), + wantErrStr: "visit error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + tmpDir := t.TempDir() + + for _, n := range tt.nodes { + path := filepath.Join(tmpDir, n.path) + if n.symlink != "" { + g.Expect(os.Symlink(strings.Replace(n.symlink, "", tmpDir, 1), path)).To(Succeed()) + return + } + kus := kustypes.Kustomization{ + TypeMeta: kustypes.TypeMeta{ + APIVersion: kustypes.KustomizationVersion, + Kind: kustypes.KustomizationKind, + }, + } + for _, res := range n.resources { + res = strings.Replace(res, "", tmpDir, 1) + kus.Resources = append(kus.Resources, res) + } + b, err := yaml.Marshal(kus) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(os.MkdirAll(filepath.Dir(path), 0o700)).To(Succeed()) + g.Expect(os.WriteFile(path, b, 0o644)) + } + + visit := func(root, path string, kus *kustypes.Kustomization) error { + if filepath.IsAbs(path) { + path = stripRoot(root, path) + } + for _, n := range tt.nodes { + if dir := filepath.Dir(n.path); filepath.Join(tt.wordirSuffix, path) != dir { + continue + } + n.visited++ + if n.visitErr != nil { + return n.visitErr + } + } + return nil + } + + visited := make(map[string]struct{}, 0) + err := recurseKustomizationFiles(filepath.Join(tmpDir, tt.wordirSuffix), tt.path, visit, visited) + if tt.wantErr != nil { + g.Expect(err).To(HaveOccurred()) + g.Expect(err).To(BeAssignableToTypeOf(tt.wantErr)) + if tt.wantErrStr != "" { + g.Expect(err.Error()).To(ContainSubstring(tt.wantErrStr)) + } + return + } + + g.Expect(err).ToNot(HaveOccurred()) + for _, n := range tt.nodes { + g.Expect(n.visited).To(Equal(n.expectVisited), n.path) + + haveCache := HaveKey(filepath.Dir(filepath.Join(tmpDir, n.path))) + if n.expectCached { + g.Expect(visited).To(haveCache) + } else { + g.Expect(visited).ToNot(haveCache) + } + } + }) + } +} + +func Test_isSOPSEncryptedResource(t *testing.T) { + g := NewWithT(t) + + resourceFactory := provider.NewDefaultDepProvider().GetResourceFactory() + encrypted := resourceFactory.FromMap(map[string]interface{}{ + "sops": map[string]string{ + "mac": "some mac value", + }, + }) + empty := resourceFactory.FromMap(map[string]interface{}{}) + + g.Expect(isSOPSEncryptedResource(encrypted)).To(BeTrue()) + g.Expect(isSOPSEncryptedResource(empty)).To(BeFalse()) +} + +func Test_secureAbsPath(t *testing.T) { + tests := []struct { + name string + root string + path string + wantAbs string + wantRel string + wantErr bool + }{ + { + name: "absolute to root", + root: "/wordir/", + path: "/wordir/foo/", + wantAbs: "/wordir/foo", + wantRel: "foo", + }, + { + name: "relative to root", + root: "/wordir", + path: "./foo", + wantAbs: "/wordir/foo", + wantRel: "foo", + }, + { + name: "illegal traverse", + root: "/wordir/foo", + path: "../../bar", + wantAbs: "/wordir/foo/bar", + wantRel: "bar", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + gotAbs, gotRel, err := securePaths(tt.root, tt.path) + if tt.wantErr { + g.Expect(err).To(HaveOccurred()) + g.Expect(gotAbs).To(BeEmpty()) + g.Expect(gotRel).To(BeEmpty()) + return + } + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(gotAbs).To(Equal(tt.wantAbs)) + g.Expect(gotRel).To(Equal(tt.wantRel)) + }) + } +}