1732 lines
49 KiB
Go
1732 lines
49 KiB
Go
/*
|
|
Copyright 2021 The Flux authors
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package controllers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io/fs"
|
|
"math"
|
|
"math/rand"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
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"
|
|
)
|
|
|
|
const (
|
|
percent10 = 10.0
|
|
percent80 = 80.0
|
|
)
|
|
|
|
func TestKustomizationReconciler_Decryptor(t *testing.T) {
|
|
g := NewWithT(t)
|
|
|
|
cli, err := api.NewClient(api.DefaultConfig())
|
|
g.Expect(err).NotTo(HaveOccurred(), "failed to create vault client")
|
|
|
|
// create a master key on the vault transit engine
|
|
path, data := "sops/keys/firstkey", map[string]interface{}{"type": "rsa-4096"}
|
|
_, err = cli.Logical().Write(path, data)
|
|
g.Expect(err).NotTo(HaveOccurred(), "failed to write key")
|
|
|
|
// encrypt the testdata vault secret
|
|
cmd := exec.Command("sops", "--hc-vault-transit", cli.Address()+"/v1/sops/keys/firstkey", "--encrypt", "--encrypted-regex", "^(data|stringData)$", "--in-place", "./testdata/sops/secret.vault.yaml")
|
|
err = cmd.Run()
|
|
g.Expect(err).NotTo(HaveOccurred(), "failed to encrypt file")
|
|
|
|
// defer the testdata vault secret decryption, to leave a clean testdata vault secret
|
|
defer func() {
|
|
cmd := exec.Command("sops", "--hc-vault-transit", cli.Address()+"/v1/sops/keys/firstkey", "--decrypt", "--encrypted-regex", "^(data|stringData)$", "--in-place", "./testdata/sops/secret.vault.yaml")
|
|
err = cmd.Run()
|
|
}()
|
|
|
|
id := "sops-" + randStringRunes(5)
|
|
|
|
err = createNamespace(id)
|
|
g.Expect(err).NotTo(HaveOccurred(), "failed to create test namespace")
|
|
|
|
err = createKubeConfigSecret(id)
|
|
g.Expect(err).NotTo(HaveOccurred(), "failed to create kubeconfig secret")
|
|
|
|
artifactName := "sops-" + randStringRunes(5)
|
|
artifactChecksum, err := createArtifact(testServer, "testdata/sops", artifactName)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
overlayArtifactName := "sops-" + randStringRunes(5)
|
|
overlayChecksum, err := createArtifact(testServer, "testdata/test-dotenv", overlayArtifactName)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
repositoryName := types.NamespacedName{
|
|
Name: fmt.Sprintf("sops-%s", randStringRunes(5)),
|
|
Namespace: id,
|
|
}
|
|
|
|
overlayRepositoryName := types.NamespacedName{
|
|
Name: fmt.Sprintf("sops-%s", randStringRunes(5)),
|
|
Namespace: id,
|
|
}
|
|
|
|
err = applyGitRepository(repositoryName, artifactName, "main/"+artifactChecksum)
|
|
g.Expect(err).NotTo(HaveOccurred())
|
|
|
|
err = applyGitRepository(overlayRepositoryName, overlayArtifactName, "main/"+overlayChecksum)
|
|
g.Expect(err).NotTo(HaveOccurred())
|
|
|
|
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())
|
|
|
|
sopsSecretKey := types.NamespacedName{
|
|
Name: "sops-" + randStringRunes(5),
|
|
Namespace: id,
|
|
}
|
|
|
|
sopsSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: sopsSecretKey.Name,
|
|
Namespace: sopsSecretKey.Namespace,
|
|
},
|
|
StringData: map[string]string{
|
|
"pgp.asc": string(pgpKey),
|
|
"age.agekey": string(ageKey),
|
|
"sops.vault-token": "secret",
|
|
},
|
|
}
|
|
|
|
g.Expect(k8sClient.Create(context.Background(), sopsSecret)).To(Succeed())
|
|
|
|
kustomizationKey := types.NamespacedName{
|
|
Name: fmt.Sprintf("sops-%s", randStringRunes(5)),
|
|
Namespace: id,
|
|
}
|
|
kustomization := &kustomizev1.Kustomization{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: kustomizationKey.Name,
|
|
Namespace: kustomizationKey.Namespace,
|
|
},
|
|
Spec: kustomizev1.KustomizationSpec{
|
|
Interval: metav1.Duration{Duration: 2 * time.Minute},
|
|
Path: "./",
|
|
KubeConfig: &kustomizev1.KubeConfig{
|
|
SecretRef: meta.LocalObjectReference{
|
|
Name: "kubeconfig",
|
|
},
|
|
},
|
|
SourceRef: kustomizev1.CrossNamespaceSourceReference{
|
|
Name: repositoryName.Name,
|
|
Namespace: repositoryName.Namespace,
|
|
Kind: sourcev1.GitRepositoryKind,
|
|
},
|
|
Decryption: &kustomizev1.Decryption{
|
|
Provider: "sops",
|
|
SecretRef: &meta.LocalObjectReference{
|
|
Name: sopsSecretKey.Name,
|
|
},
|
|
},
|
|
TargetNamespace: id,
|
|
},
|
|
}
|
|
g.Expect(k8sClient.Create(context.TODO(), kustomization)).To(Succeed())
|
|
|
|
g.Eventually(func() bool {
|
|
var obj kustomizev1.Kustomization
|
|
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), &obj)
|
|
return obj.Status.LastAppliedRevision == "main/"+artifactChecksum
|
|
}, timeout, time.Second).Should(BeTrue())
|
|
|
|
overlayKustomizationName := fmt.Sprintf("sops-%s", randStringRunes(5))
|
|
overlayKs := kustomization.DeepCopy()
|
|
overlayKs.ResourceVersion = ""
|
|
overlayKs.Name = overlayKustomizationName
|
|
overlayKs.Spec.SourceRef.Name = overlayRepositoryName.Name
|
|
overlayKs.Spec.SourceRef.Namespace = overlayRepositoryName.Namespace
|
|
overlayKs.Spec.Path = "./testdata/test-dotenv/overlays"
|
|
|
|
g.Expect(k8sClient.Create(context.TODO(), overlayKs)).To(Succeed())
|
|
|
|
g.Eventually(func() bool {
|
|
var obj kustomizev1.Kustomization
|
|
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(overlayKs), &obj)
|
|
return obj.Status.LastAppliedRevision == "main/"+overlayChecksum
|
|
}, 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`)))
|
|
|
|
var ageSecret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-age", Namespace: id}, &ageSecret)).To(Succeed())
|
|
g.Expect(ageSecret.Data["secret"]).To(Equal([]byte(`my-sops-age-secret`)))
|
|
|
|
var daySecret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-day", Namespace: id}, &daySecret)).To(Succeed())
|
|
g.Expect(string(daySecret.Data["secret"])).To(Equal("day=Tuesday\n"))
|
|
|
|
var yearSecret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-year", Namespace: id}, &yearSecret)).To(Succeed())
|
|
g.Expect(string(yearSecret.Data["year"])).To(Equal("2017"))
|
|
|
|
var unencryptedSecret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "unencrypted-sops-year", Namespace: id}, &unencryptedSecret)).To(Succeed())
|
|
g.Expect(string(unencryptedSecret.Data["year"])).To(Equal("2021"))
|
|
|
|
var year1Secret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-year1", Namespace: id}, &year1Secret)).To(Succeed())
|
|
g.Expect(string(year1Secret.Data["year"])).To(Equal("year1"))
|
|
|
|
var year2Secret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-year2", Namespace: id}, &year2Secret)).To(Succeed())
|
|
g.Expect(string(year2Secret.Data["year"])).To(Equal("year2"))
|
|
|
|
var encodedSecret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-month", Namespace: id}, &encodedSecret)).To(Succeed())
|
|
g.Expect(string(encodedSecret.Data["month.yaml"])).To(Equal("month: May\n"))
|
|
|
|
var hcvaultSecret corev1.Secret
|
|
g.Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: "sops-hcvault", Namespace: id}, &hcvaultSecret)).To(Succeed())
|
|
g.Expect(string(hcvaultSecret.Data["secret"])).To(Equal("my-sops-vault-secret\n"))
|
|
})
|
|
|
|
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)
|
|
g.Expect(err).NotTo(HaveOccurred())
|
|
|
|
g.Eventually(func() bool {
|
|
_ = k8sClient.Get(context.Background(), client.ObjectKeyFromObject(kustomization), resultK)
|
|
return resultK.Status.LastAttemptedRevision == revision
|
|
}, timeout, time.Second).Should(BeTrue())
|
|
|
|
events := getEvents(resultK.GetName(), map[string]string{"kustomize.toolkit.fluxcd.io/revision": revision})
|
|
g.Expect(len(events)).To(BeIdenticalTo(1))
|
|
g.Expect(events[0].Message).Should(ContainSubstring("Reconciliation finished"))
|
|
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{"<tmpdir>/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: "<tmpdir>/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>", 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>", 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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// benchmarkRecKustFiles runs benchmark for recurseKustomizationFiles().
|
|
func benchmarkRecKustFiles(numKusNodes int, percentSymlinks float64, b *testing.B) {
|
|
b.StopTimer()
|
|
|
|
numSymlinksFloat := (percentSymlinks / 100.0) * float64(numKusNodes)
|
|
numSymlinks := int(math.Round(numSymlinksFloat))
|
|
|
|
g := NewWithT(b)
|
|
tmpDir := b.TempDir()
|
|
|
|
// Create manifest directories. Write all the symlinked manifests in bar.
|
|
// /tmp/test-dir/
|
|
// ├── bar
|
|
// │ └── 1
|
|
// │ └── kustomization.yaml
|
|
// │
|
|
// └── foo
|
|
// ├── 0
|
|
// │ └── kustomization.yaml
|
|
// └── 1 -> ../bar/1
|
|
os.MkdirAll(filepath.Join(tmpDir, "foo"), 0o700)
|
|
os.MkdirAll(filepath.Join(tmpDir, "bar"), 0o700)
|
|
|
|
// Generate index of kustomizations that'll be symlinked.
|
|
rand.Seed(42) // Reproducible.
|
|
randKusNodes := rand.Perm(numKusNodes)[0:numSymlinks]
|
|
symlinkKusNodes := map[int]struct{}{}
|
|
for _, n := range randKusNodes {
|
|
symlinkKusNodes[n] = struct{}{}
|
|
}
|
|
|
|
// Create kustomization nodes.
|
|
// The kustomizations are chained to one another. The first kustomization
|
|
// refers to the second, the second refers to the third, and so on.
|
|
for kn := 0; kn < numKusNodes; kn++ {
|
|
// /tmp/test-dir/foo/0
|
|
kPath := filepath.Join(tmpDir, "foo", fmt.Sprint(kn))
|
|
kus := kustypes.Kustomization{
|
|
TypeMeta: kustypes.TypeMeta{
|
|
APIVersion: kustypes.KustomizationVersion,
|
|
Kind: kustypes.KustomizationKind,
|
|
},
|
|
Resources: []string{},
|
|
}
|
|
// Append next node reference, except for when it is the last node.
|
|
if kn != numKusNodes-1 {
|
|
kus.Resources = append(kus.Resources, filepath.Join("..", fmt.Sprint(kn+1)))
|
|
}
|
|
b, err := yaml.Marshal(kus)
|
|
g.Expect(err).ToNot(HaveOccurred())
|
|
|
|
// If this node is a symlink, create a symlink and write to the
|
|
// respective source file. Else, write to the actual file.
|
|
if _, ok := symlinkKusNodes[kn]; ok {
|
|
srcPath := filepath.Join(tmpDir, "bar", fmt.Sprint(kn))
|
|
g.Expect(os.MkdirAll(srcPath, 0o700)).ToNot(HaveOccurred())
|
|
// Relative path from foo/0 to bar/0 is ../bar/0.
|
|
g.Expect(os.Symlink(filepath.Join("..", "bar", fmt.Sprint(kn)), kPath)).ToNot(HaveOccurred())
|
|
g.Expect(os.WriteFile(filepath.Join(srcPath, "kustomization.yaml"), b, 0o644)).ToNot(HaveOccurred())
|
|
} else {
|
|
g.Expect(os.MkdirAll(kPath, 0o700)).ToNot(HaveOccurred())
|
|
g.Expect(os.WriteFile(filepath.Join(kPath, "kustomization.yaml"), b, 0o644)).ToNot(HaveOccurred())
|
|
}
|
|
}
|
|
|
|
// no-op visit.
|
|
visit := func(root, path string, kus *kustypes.Kustomization) error {
|
|
return nil
|
|
}
|
|
|
|
b.StartTimer()
|
|
for n := 0; n < b.N; n++ {
|
|
visited := make(map[string]struct{})
|
|
recurseKustomizationFiles(tmpDir, filepath.Join(tmpDir, "foo", "0"), visit, visited)
|
|
}
|
|
}
|
|
|
|
func BenchmarkRecKust10Nodes10pcSymlink(b *testing.B) { benchmarkRecKustFiles(10, percent10, b) }
|
|
func BenchmarkRecKust10Nodes80pcSymlink(b *testing.B) { benchmarkRecKustFiles(10, percent80, b) }
|
|
func BenchmarkRecKust100Nodes10pcSymlink(b *testing.B) { benchmarkRecKustFiles(100, percent10, b) }
|
|
func BenchmarkRecKust100Nodes80pcSymlink(b *testing.B) { benchmarkRecKustFiles(100, percent80, b) }
|
|
func BenchmarkRecKust1000Nodes10pcSymlink(b *testing.B) { benchmarkRecKustFiles(1000, percent10, b) }
|
|
func BenchmarkRecKust1000Nodes80pcSymlink(b *testing.B) { benchmarkRecKustFiles(1000, percent80, b) }
|
|
func BenchmarkRecKust10000Nodes10pcSymlink(b *testing.B) { benchmarkRecKustFiles(10000, percent10, b) }
|
|
func BenchmarkRecKust10000Nodes80pcSymlink(b *testing.B) { benchmarkRecKustFiles(10000, percent80, b) }
|
|
|
|
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))
|
|
})
|
|
}
|
|
}
|