diff --git a/controllers/kustomization_controller.go b/controllers/kustomization_controller.go index f8bbfda3..b553995d 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 acfd47dc..b0ccf302 100644 --- a/controllers/kustomization_decryptor.go +++ b/controllers/kustomization_decryptor.go @@ -20,17 +20,23 @@ import ( "bytes" "context" "encoding/base64" + "errors" "fmt" + "io/fs" "os" "path/filepath" "strings" + "sync" + "time" + securejoin "github.com/cyphar/filepath-securejoin" "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" @@ -47,268 +53,678 @@ import ( ) 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) + } + } + + 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 } - // 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) + 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 + } - envFileParts := strings.Split(envFile, "=") - if len(envFileParts) > 1 { - envFile = envFileParts[1] + decrypted, visited := make(map[string]struct{}, 0), make(map[string]struct{}, 0) + visit := d.decryptKustomizationEnvSources(decrypted) + return recurseKustomizationFiles(d.root, path, visit, visited) +} + +// 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, 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, fmt.Errorf("LoadEncryptedFile: %w", err) + 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 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)) + } + + 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") } - if kd.azureToken != nil { - serverOpts = append(serverOpts, intkeyservice.WithAzureToken{Token: kd.azureToken}) + + data, err := os.ReadFile(loadPath) + if err != nil { + return nil, fmt.Errorf("failed to read kustomization file: %w", securePathErr(root, err)) } - metadataKey, err := tree.Metadata.GetDataKeyWithKeyServices( - []keyservice.KeyServiceClient{ - intkeyservice.NewLocalClient(intkeyservice.NewServer(serverOpts...)), + 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 70b98c65..9d954343 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)) + }) + } +}