kustomize-controller/internal/controller/kustomization_fuzzer_test.go

779 lines
21 KiB
Go

//go:build gofuzz_libfuzzer
// +build gofuzz_libfuzzer
/*
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 controller
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha1"
"embed"
"errors"
"fmt"
"io"
"io/fs"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/hashicorp/vault/api"
"github.com/opencontainers/go-digest"
"github.com/ory/dockertest/v3"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
controllerLog "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/runtime/controller"
"github.com/fluxcd/pkg/runtime/testenv"
"github.com/fluxcd/pkg/testserver"
sourcev1 "github.com/fluxcd/source-controller/api/v1"
fuzz "github.com/AdaLogics/go-fuzz-headers"
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
)
var (
doOnce sync.Once
reconciler *KustomizationReconciler
k8sClient client.Client
testEnv *testenv.Environment
testServer *testserver.ArtifactServer
testMetricsH controller.Metrics
ctx = ctrl.SetupSignalHandler()
kubeConfig []byte
debugMode = os.Getenv("DEBUG_TEST") != ""
)
const vaultVersion = "1.13.2"
const defaultBinVersion = "1.24"
//go:embed testdata/crd/*.yaml
//go:embed testdata/sops/keys/pgp.asc
//go:embed testdata/sops/keys/age.txt
var testFiles embed.FS
// FuzzControllers implements a fuzzer that targets the Kustomize controller.
//
// The test must ensure a valid test state around Kubernetes objects, as the
// focus is to ensure the controller behaves properly, not Kubernetes nor
// testing infrastructure.
func Fuzz_Controllers(f *testing.F) {
f.Fuzz(func(t *testing.T, seed, fileSeed []byte) {
// Fuzzing has to be deterministic, so that input A can be
// associated with crash B consistently. The current approach
// taken by go-fuzz-headers to achieve that uses the data input
// as a buffer which is used until its end (i.e. EOF).
//
// The problem with this approach is when the test setup requires
// a higher amount of input than the available in the buffer,
// resulting in an invalid test state.
//
// This is currently being countered by openning two consumers on
// the data input, and requiring at least a length of 1000 to
// run the tests.
//
// During the migration to the native fuzz feature in go we should
// review this approach.
if len(fileSeed) < 1000 {
return
}
fmt.Printf("Data input length: %d\n", len(fileSeed))
fc := fuzz.NewConsumer(seed)
ff := fuzz.NewConsumer(fileSeed)
doOnce.Do(func() {
if err := ensureDependencies(); err != nil {
panic(fmt.Sprintf("Failed to ensure dependencies: %v", err))
}
})
err := runInContext(func(testEnv *testenv.Environment) {
controllerName := "kustomize-controller"
reconciler := &KustomizationReconciler{
ControllerName: controllerName,
Client: testEnv,
Mapper: testEnv.GetRESTMapper(),
}
if err := (reconciler).SetupWithManager(ctx, testEnv, KustomizationReconcilerOptions{}); err != nil {
panic(fmt.Sprintf("Failed to start GitRepositoryReconciler: %v", err))
}
}, func() error {
dname, err := os.MkdirTemp("", "artifact-dir")
if err != nil {
return err
}
defer os.RemoveAll(dname)
if err = createFiles(ff, dname); err != nil {
return err
}
namespaceName, err := randStringRange(fc, 1, 63)
if err != nil {
return err
}
namespace, err := createNamespaceForFuzzing(namespaceName)
if err != nil {
return err
}
defer k8sClient.Delete(context.Background(), namespace)
fmt.Println("createKubeConfigSecretForFuzzing...")
secret, err := createKubeConfigSecretForFuzzing(namespaceName)
if err != nil {
fmt.Println(err)
return err
}
defer k8sClient.Delete(context.Background(), secret)
artifactFile, err := randStringRange(fc, 1, 63)
if err != nil {
return err
}
fmt.Println("createArtifact...")
artifactChecksum, err := createArtifact(testServer, dname, artifactFile)
if err != nil {
fmt.Println(err)
return err
}
repName, err := randStringRange(fc, 1, 63)
if err != nil {
return err
}
repositoryName := types.NamespacedName{
Name: repName,
Namespace: namespaceName,
}
fmt.Println("ApplyGitRepository...")
err = applyGitRepository(repositoryName, artifactFile, "main/"+artifactChecksum)
if err != nil {
return err
}
pgpKey, err := testFiles.ReadFile("testdata/sops/keys/pgp.asc")
if err != nil {
return err
}
ageKey, err := testFiles.ReadFile("testdata/sops/keys/age.txt")
if err != nil {
return err
}
sskName, err := randStringRange(fc, 1, 63)
if err != nil {
return err
}
sopsSecretKey := types.NamespacedName{
Name: sskName,
Namespace: namespaceName,
}
sopsSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: sopsSecretKey.Name,
Namespace: sopsSecretKey.Namespace,
},
StringData: map[string]string{
"pgp.asc": string(pgpKey),
"age.agekey": string(ageKey),
},
}
if err = k8sClient.Create(context.Background(), sopsSecret); err != nil {
return err
}
defer k8sClient.Delete(context.Background(), sopsSecret)
kkName, err := randStringRange(fc, 1, 63)
if err != nil {
return err
}
kustomizationKey := types.NamespacedName{
Name: kkName,
Namespace: namespaceName,
}
kustomization := &kustomizev1.Kustomization{
ObjectMeta: metav1.ObjectMeta{
Name: kustomizationKey.Name,
Namespace: kustomizationKey.Namespace,
},
Spec: kustomizev1.KustomizationSpec{
Path: "./",
KubeConfig: &meta.KubeConfigReference{
SecretRef: meta.SecretKeyReference{
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: namespaceName,
},
}
if err = k8sClient.Create(context.TODO(), kustomization); err != nil {
return err
}
// ensure reconciliation of the kustomization above took place before moving on
time.Sleep(10 * time.Second)
if err = k8sClient.Delete(context.TODO(), kustomization); err != nil {
return err
}
// ensure the deferred deletion of all objects (namespace, secret, sopSecret) and
// the kustomization above were reconciled before moving on. This avoids unneccessary
// errors whilst tearing down the testing infrastructure.
time.Sleep(10 * time.Second)
return nil
}, "testdata/crd")
if err != nil {
fmt.Println(err)
}
})
}
// Allows the fuzzer to create a random lowercase string within a given range
func randStringRange(f *fuzz.ConsumeFuzzer, minLen, maxLen int) (string, error) {
stringLength, err := f.GetInt()
if err != nil {
return "", err
}
len := stringLength % maxLen
if len < minLen {
len += minLen
}
return f.GetStringFrom(string(letterRunes), len)
}
// Creates a namespace and returns the created object.
func createNamespaceForFuzzing(name string) (*corev1.Namespace, error) {
namespace := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: name},
}
err := k8sClient.Create(context.Background(), namespace)
if err != nil {
return namespace, err
}
return namespace, nil
}
// Creates a secret and returns the created object.
func createKubeConfigSecretForFuzzing(namespace string) (*corev1.Secret, error) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "kubeconfig",
Namespace: namespace,
},
Data: map[string][]byte{
"value.yaml": kubeConfig,
},
}
err := k8sClient.Create(context.Background(), secret)
if err != nil {
return secret, err
}
return secret, nil
}
// Creates pseudo-random files in rootDir.
// Will create subdirs and place the files there.
// It is the callers responsibility to ensure that
// rootDir exists.
//
// Original source:
// https://github.com/AdaLogics/go-fuzz-headers/blob/9f22f86e471065b8d56861991dc885e27b1ae7de/consumer.go#L345
//
// The change assures that as long as the f buffer has
// enough length to set numberOfFiles and the first fileName,
// this is returned without errors.
// Effectively making subDir and FileContent optional.
func createFiles(f *fuzz.ConsumeFuzzer, rootDir string) error {
noOfCreatedFiles := 0
numberOfFiles, err := f.GetInt()
if err != nil {
return err
}
maxNoFiles := numberOfFiles % 10000 //
for i := 0; i <= maxNoFiles; i++ {
fileName, err := f.GetString()
if err != nil {
if noOfCreatedFiles > 0 {
return nil
} else {
return errors.New("Could not get fileName")
}
}
if fileName == "" {
continue
}
// leave subDir empty if no more strings are available.
subDir, _ := f.GetString()
// Avoid going outside the root dir
if strings.Contains(subDir, "../") || (len(subDir) > 0 && subDir[0] == 47) || strings.Contains(subDir, "\\") {
continue // continue as this is not a permanent error
}
dirPath, err := securejoin.SecureJoin(rootDir, subDir)
if err != nil {
continue // some errors here are not permanent, so we can try again with different values
}
err = os.MkdirAll(dirPath, 0o750)
if err != nil {
if noOfCreatedFiles > 0 {
return nil
} else {
return errors.New("Could not create the subDir")
}
}
fullFilePath, err := securejoin.SecureJoin(dirPath, fileName)
if err != nil {
continue // potentially not a permanent error
}
// leave fileContents empty if no more bytes are available,
// afterall empty files is a valid test case.
fileContents, _ := f.GetBytes()
createdFile, err := os.Create(fullFilePath)
if err != nil {
createdFile.Close()
continue // some errors here are not permanent, so we can try again with different values
}
_, err = createdFile.Write(fileContents)
if err != nil {
createdFile.Close()
if noOfCreatedFiles > 0 {
return nil
} else {
return errors.New("Could not write the file")
}
}
createdFile.Close()
noOfCreatedFiles++
}
return nil
}
// ensureDependencies ensure that:
// a) setup-envtest is installed and a specific version of envtest is deployed.
// b) the embedded crd files are exported onto the "runner container".
//
// The steps above are important as the fuzzers tend to be built in an
// environment (or container) and executed in other.
func ensureDependencies() error {
// only install dependencies when running inside a container
if _, err := os.Stat("/.dockerenv"); os.IsNotExist(err) {
return nil
}
if os.Getenv("KUBEBUILDER_ASSETS") == "" {
binVersion := envtestBinVersion()
cmd := exec.Command("/usr/bin/bash", "-c", fmt.Sprintf(`go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest && \
/root/go/bin/setup-envtest use -p path %s`, binVersion))
cmd.Env = append(os.Environ(), "GOPATH=/root/go")
assetsPath, err := cmd.Output()
if err != nil {
return err
}
os.Setenv("KUBEBUILDER_ASSETS", string(assetsPath))
}
// Output all embedded testdata files
// "testdata/sops" does not need to be saved in disk
// as it is being consumed directly from the embed.FS.
embedDirs := []string{"testdata/crd"}
for _, dir := range embedDirs {
err := os.MkdirAll(dir, 0o750)
if err != nil {
return fmt.Errorf("mkdir %s: %v", dir, err)
}
templates, err := fs.ReadDir(testFiles, dir)
if err != nil {
return fmt.Errorf("reading embedded dir: %v", err)
}
for _, template := range templates {
fileName := fmt.Sprintf("%s/%s", dir, template.Name())
fmt.Println(fileName)
data, err := testFiles.ReadFile(fileName)
if err != nil {
return fmt.Errorf("reading embedded file %s: %v", fileName, err)
}
os.WriteFile(fileName, data, 0o600)
if err != nil {
return fmt.Errorf("writing %s: %v", fileName, err)
}
}
}
return nil
}
func envtestBinVersion() string {
if binVersion := os.Getenv("ENVTEST_BIN_VERSION"); binVersion != "" {
return binVersion
}
return defaultBinVersion
}
func runInContext(registerControllers func(*testenv.Environment), run func() error, crdPath string) error {
var err error
utilruntime.Must(sourcev1.AddToScheme(scheme.Scheme))
utilruntime.Must(kustomizev1.AddToScheme(scheme.Scheme))
if debugMode {
controllerLog.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(false)))
}
testEnv = testenv.New(testenv.WithCRDPath(crdPath))
testServer, err = testserver.NewTempArtifactServer()
if err != nil {
panic(fmt.Sprintf("Failed to create a temporary storage server: %v", err))
}
fmt.Println("Starting the test storage server")
testServer.Start()
registerControllers(testEnv)
go func() {
fmt.Println("Starting the test environment")
if err := testEnv.Start(ctx); err != nil {
panic(fmt.Sprintf("Failed to start the test environment manager: %v", err))
}
}()
<-testEnv.Manager.Elected()
user, err := testEnv.AddUser(envtest.User{
Name: "testenv-admin",
Groups: []string{"system:masters"},
}, nil)
if err != nil {
panic(fmt.Sprintf("Failed to create testenv-admin user: %v", err))
}
kubeConfig, err = user.KubeConfig()
if err != nil {
panic(fmt.Sprintf("Failed to create the testenv-admin user kubeconfig: %v", err))
}
// Client with caching disabled.
k8sClient, err = client.New(testEnv.Config, client.Options{Scheme: scheme.Scheme})
if err != nil {
panic(fmt.Sprintf("Failed to create k8s client: %v", err))
}
// Create a Vault test instance.
pool, resource, err := createVaultTestInstance()
if err != nil {
panic(fmt.Sprintf("Failed to create Vault instance: %v", err))
}
defer func() {
pool.Purge(resource)
}()
runErr := run()
if debugMode {
events := &corev1.EventList{}
_ = k8sClient.List(ctx, events)
for _, event := range events.Items {
fmt.Printf("%s %s \n%s\n",
event.InvolvedObject.Name, event.GetAnnotations()["kustomize.toolkit.fluxcd.io/revision"],
event.Message)
}
}
fmt.Println("Stopping the test environment")
if err := testEnv.Stop(); err != nil {
panic(fmt.Sprintf("Failed to stop the test environment: %v", err))
}
fmt.Println("Stopping the file server")
testServer.Stop()
if err := os.RemoveAll(testServer.Root()); err != nil {
panic(fmt.Sprintf("Failed to remove storage server dir: %v", err))
}
return runErr
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890")
func randStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func getEvents(objName string, annotations map[string]string) []corev1.Event {
var result []corev1.Event
events := &corev1.EventList{}
_ = k8sClient.List(ctx, events)
for _, event := range events.Items {
if event.InvolvedObject.Name == objName {
if annotations == nil && len(annotations) == 0 {
result = append(result, event)
} else {
for ak, av := range annotations {
if event.GetAnnotations()[ak] == av {
result = append(result, event)
break
}
}
}
}
}
return result
}
func applyGitRepository(objKey client.ObjectKey, artifactName string, revision string) error {
repo := &sourcev1.GitRepository{
TypeMeta: metav1.TypeMeta{
Kind: sourcev1.GitRepositoryKind,
APIVersion: sourcev1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: objKey.Name,
Namespace: objKey.Namespace,
},
Spec: sourcev1.GitRepositorySpec{
URL: "https://github.com/test/repository",
Interval: metav1.Duration{Duration: time.Minute},
},
}
b, _ := os.ReadFile(filepath.Join(testServer.Root(), artifactName))
dig := digest.SHA256.FromBytes(b)
url := fmt.Sprintf("%s/%s", testServer.URL(), artifactName)
status := sourcev1.GitRepositoryStatus{
Conditions: []metav1.Condition{
{
Type: meta.ReadyCondition,
Status: metav1.ConditionTrue,
LastTransitionTime: metav1.Now(),
Reason: sourcev1.GitOperationSucceedReason,
},
},
Artifact: &sourcev1.Artifact{
Path: url,
URL: url,
Revision: revision,
Digest: dig.String(),
LastUpdateTime: metav1.Now(),
},
}
opt := []client.PatchOption{
client.ForceOwnership,
client.FieldOwner("kustomize-controller"),
}
if err := k8sClient.Patch(context.Background(), repo, client.Apply, opt...); err != nil {
return err
}
repo.ManagedFields = nil
repo.Status = status
statusOpts := &client.SubResourcePatchOptions{
PatchOptions: client.PatchOptions{
FieldManager: "source-controller",
},
}
if err := k8sClient.Status().Patch(context.Background(), repo, client.Apply, statusOpts); err != nil {
return err
}
return nil
}
func createArtifact(artifactServer *testserver.ArtifactServer, fixture, path string) (string, error) {
if f, err := os.Stat(fixture); os.IsNotExist(err) || !f.IsDir() {
return "", fmt.Errorf("invalid fixture path: %s", fixture)
}
f, err := os.Create(filepath.Join(artifactServer.Root(), path))
if err != nil {
return "", err
}
defer func() {
if err != nil {
os.Remove(f.Name())
}
}()
h := sha1.New()
mw := io.MultiWriter(h, f)
gw := gzip.NewWriter(mw)
tw := tar.NewWriter(gw)
if err = filepath.Walk(fixture, func(p string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
// Ignore anything that is not a file (directories, symlinks)
if !fi.Mode().IsRegular() {
return nil
}
// Ignore dotfiles
if strings.HasPrefix(fi.Name(), ".") {
return nil
}
header, err := tar.FileInfoHeader(fi, p)
if err != nil {
return err
}
// The name needs to be modified to maintain directory structure
// as tar.FileInfoHeader only has access to the base name of the file.
// Ref: https://golang.org/src/archive/tar/common.go?#L626
relFilePath := p
if filepath.IsAbs(fixture) {
relFilePath, err = filepath.Rel(fixture, p)
if err != nil {
return err
}
}
header.Name = relFilePath
if err := tw.WriteHeader(header); err != nil {
return err
}
f, err := os.Open(p)
if err != nil {
f.Close()
return err
}
if _, err := io.Copy(tw, f); err != nil {
f.Close()
return err
}
return f.Close()
}); err != nil {
return "", err
}
if err := tw.Close(); err != nil {
gw.Close()
f.Close()
return "", err
}
if err := gw.Close(); err != nil {
f.Close()
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
if err := os.Chmod(f.Name(), 0o600); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func createVaultTestInstance() (*dockertest.Pool, *dockertest.Resource, error) {
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
pool, err := dockertest.NewPool("")
if err != nil {
return nil, nil, fmt.Errorf("Could not connect to docker: %s", err)
}
// pulls an image, creates a container based on it and runs it
resource, err := pool.Run("vault", vaultVersion, []string{"VAULT_DEV_ROOT_TOKEN_ID=secret"})
if err != nil {
return nil, nil, fmt.Errorf("Could not start resource: %s", err)
}
os.Setenv("VAULT_ADDR", fmt.Sprintf("http://127.0.0.1:%v", resource.GetPort("8200/tcp")))
os.Setenv("VAULT_TOKEN", "secret")
// exponential backoff-retry, because the application in the container might not be ready to accept connections yet
if err := pool.Retry(func() error {
cli, err := api.NewClient(api.DefaultConfig())
if err != nil {
return fmt.Errorf("Cannot create Vault Client: %w", err)
}
status, err := cli.Sys().InitStatus()
if err != nil {
return err
}
if status != true {
return fmt.Errorf("Vault not ready yet")
}
if err := cli.Sys().Mount("sops", &api.MountInput{
Type: "transit",
}); err != nil {
return fmt.Errorf("Cannot create Vault Transit Engine: %w", err)
}
return nil
}); err != nil {
return nil, nil, fmt.Errorf("Could not connect to docker: %w", err)
}
return pool, resource, nil
}