From bc53278eea1f922237c64fd88694f85c064f1177 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Fri, 14 Jan 2022 15:11:56 +0000 Subject: [PATCH] Refactor fuzzing Structure the fuzz implementation to be closer to what go native will support. Add Makefile target to enable smoketesting fuzzers. Add smoketest as CI workflow. Signed-off-by: Paulo Gomes --- .github/workflows/cifuzz.yaml | 20 ++ .gitignore | 2 + Makefile | 21 +- controllers/suite_test.go | 45 ++- fuzz/Dockerfile | 29 -- fuzz/fuzz.go | 591 ------------------------------- tests/fuzz/Dockerfile.builder | 6 + tests/fuzz/README.md | 45 +++ tests/fuzz/age_fuzzer.go | 47 +++ tests/fuzz/controllers_fuzzer.go | 441 +++++++++++++++++++++++ tests/fuzz/go.mod | 5 + tests/fuzz/oss_fuzz_build.sh | 61 ++++ tests/fuzz/oss_fuzz_run.sh | 20 ++ tests/fuzz/pgp_fuzzer.go | 39 ++ 14 files changed, 734 insertions(+), 638 deletions(-) create mode 100644 .github/workflows/cifuzz.yaml delete mode 100644 fuzz/Dockerfile delete mode 100644 fuzz/fuzz.go create mode 100644 tests/fuzz/Dockerfile.builder create mode 100644 tests/fuzz/README.md create mode 100644 tests/fuzz/age_fuzzer.go create mode 100644 tests/fuzz/controllers_fuzzer.go create mode 100644 tests/fuzz/go.mod create mode 100755 tests/fuzz/oss_fuzz_build.sh create mode 100755 tests/fuzz/oss_fuzz_run.sh create mode 100644 tests/fuzz/pgp_fuzzer.go diff --git a/.github/workflows/cifuzz.yaml b/.github/workflows/cifuzz.yaml new file mode 100644 index 000000000..202ce966d --- /dev/null +++ b/.github/workflows/cifuzz.yaml @@ -0,0 +1,20 @@ +name: CIFuzz +on: + pull_request: + branches: + - main +jobs: + Fuzzing: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Restore Go cache + uses: actions/cache@v1 + with: + path: /home/runner/work/_temp/_github_home/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Smoke test Fuzzers + run: make fuzz-smoketest diff --git a/.gitignore b/.gitignore index 5a3a724fb..19c9affd9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ bin/ config/release/ config/crd/bases/gitrepositories.yaml config/crd/bases/buckets.yaml + +build/ diff --git a/Makefile b/Makefile index eff702b04..6c2167ece 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ ENVTEST_ARCH ?= amd64 all: manager # Download the envtest binaries to testbin -ENVTEST_ASSETS_DIR=$(shell pwd)/testbin +ENVTEST_ASSETS_DIR=$(shell pwd)/build/testbin ENVTEST_KUBERNETES_VERSION?=latest install-envtest: setup-envtest mkdir -p ${ENVTEST_ASSETS_DIR} @@ -147,3 +147,22 @@ GOBIN=$(PROJECT_DIR)/bin go install $(2) ;\ rm -rf $$TMP_DIR ;\ } endef + +# Build fuzzers +fuzz-build: + rm -rf $(shell pwd)/build/fuzz/ + mkdir -p $(shell pwd)/build/fuzz/out/ + + docker build . --tag local-fuzzing:latest -f tests/fuzz/Dockerfile.builder + docker run --rm \ + -e FUZZING_LANGUAGE=go -e SANITIZER=address \ + -e CIFUZZ_DEBUG='True' -e OSS_FUZZ_PROJECT_NAME=fluxcd \ + -v "$(shell pwd)/build/fuzz/out":/out \ + local-fuzzing:latest + +fuzz-smoketest: fuzz-build + docker run --rm \ + -v "$(shell pwd)/build/fuzz/out":/out \ + -v "$(shell pwd)/tests/fuzz/oss_fuzz_run.sh":/runner.sh \ + local-fuzzing:latest \ + bash -c "/runner.sh" diff --git a/controllers/suite_test.go b/controllers/suite_test.go index fe89e9373..bcd23ffea 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -69,7 +69,7 @@ var ( debugMode = os.Getenv("DEBUG_TEST") != "" ) -func TestMain(m *testing.M) { +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)) @@ -78,9 +78,7 @@ func TestMain(m *testing.M) { controllerLog.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(false))) } - testEnv = testenv.New(testenv.WithCRDPath( - filepath.Join("..", "config", "crd", "bases"), - )) + testEnv = testenv.New(testenv.WithCRDPath(crdPath)) testServer, err = testserver.NewTempArtifactServer() if err != nil { @@ -89,18 +87,7 @@ func TestMain(m *testing.M) { fmt.Println("Starting the test storage server") testServer.Start() - controllerName := "kustomize-controller" - testEventsH = controller.MakeEvents(testEnv, controllerName, nil) - testMetricsH = controller.MustMakeMetrics(testEnv) - reconciler := &KustomizationReconciler{ - ControllerName: controllerName, - Client: testEnv, - EventRecorder: testEventsH.EventRecorder, - MetricsRecorder: testMetricsH.MetricsRecorder, - } - if err := (reconciler).SetupWithManager(testEnv, KustomizationReconcilerOptions{MaxConcurrentReconciles: 4}); err != nil { - panic(fmt.Sprintf("Failed to start GitRepositoryReconciler: %v", err)) - } + registerControllers(testEnv) go func() { fmt.Println("Starting the test environment") @@ -129,7 +116,7 @@ func TestMain(m *testing.M) { panic(fmt.Sprintf("Failed to create k8s client: %v", err)) } - code := m.Run() + runErr := run() if debugMode { events := &corev1.EventList{} @@ -152,6 +139,30 @@ func TestMain(m *testing.M) { panic(fmt.Sprintf("Failed to remove storage server dir: %v", err)) } + return runErr +} + +func TestMain(m *testing.M) { + code := 0 + + runInContext(func(testEnv *testenv.Environment) { + controllerName := "kustomize-controller" + testEventsH = controller.MakeEvents(testEnv, controllerName, nil) + testMetricsH = controller.MustMakeMetrics(testEnv) + reconciler := &KustomizationReconciler{ + ControllerName: controllerName, + Client: testEnv, + EventRecorder: testEventsH.EventRecorder, + MetricsRecorder: testMetricsH.MetricsRecorder, + } + if err := (reconciler).SetupWithManager(testEnv, KustomizationReconcilerOptions{MaxConcurrentReconciles: 4}); err != nil { + panic(fmt.Sprintf("Failed to start GitRepositoryReconciler: %v", err)) + } + }, func() error { + code = m.Run() + return nil + }, filepath.Join("..", "config", "crd", "bases")) + os.Exit(code) } diff --git a/fuzz/Dockerfile b/fuzz/Dockerfile deleted file mode 100644 index 50e0fa890..000000000 --- a/fuzz/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM golang:1.16-buster as builder - -RUN apt-get update && apt-get install -y git vim clang -RUN git clone https://github.com/fluxcd/kustomize-controller /kustomize-controller - -WORKDIR /kustomize-controller - -# fillippo.io/age v1.0.0-beta7 throws an error -RUN sed 's/filippo.io\/age v1.0.0-beta7/filippo.io\/age v1.0.0/g' -i /kustomize-controller/go.mod -RUN make download-crd-deps -RUN mkdir /kustomize-controller/fuzz -COPY fuzz.go /kustomize-controller/controllers/ - -RUN go mod tidy - -RUN cd / \ - && go get -u github.com/dvyukov/go-fuzz/go-fuzz@latest github.com/dvyukov/go-fuzz/go-fuzz-build@latest -RUN go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest -RUN go get github.com/AdaLogics/go-fuzz-headers -RUN go mod download golang.org/x/sync -RUN go mod download github.com/dvyukov/go-fuzz - -RUN mkdir /fuzzers -RUN cd /kustomize-controller/controllers \ - && go-fuzz-build -libfuzzer -func=Fuzz \ - && clang -o /fuzzers/Fuzz reflect-fuzz.a \ - -fsanitize=fuzzer - -RUN cd /kustomize-controller/controllers && /fuzzers/Fuzz diff --git a/fuzz/fuzz.go b/fuzz/fuzz.go deleted file mode 100644 index 95cddea2f..000000000 --- a/fuzz/fuzz.go +++ /dev/null @@ -1,591 +0,0 @@ -// +build gofuzz -/* -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 ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/sha1" - "errors" - "fmt" - "io" - "io/ioutil" - "net/http" - "os" - "os/exec" - "sync" - "path/filepath" - "strings" - "time" - - kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta1" - "github.com/fluxcd/pkg/runtime/controller" - "github.com/fluxcd/pkg/testserver" - sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - "k8s.io/apimachinery/pkg/types" - "github.com/fluxcd/pkg/apis/meta" - - fuzz "github.com/AdaLogics/go-fuzz-headers" -) - - -var cfg *rest.Config -var k8sClient client.Client -var k8sManager ctrl.Manager -var testEnv *envtest.Environment -var kubeConfig []byte -var testServer *testserver.ArtifactServer -var testEventsH controller.Events -var testMetricsH controller.Metrics -var ctx = ctrl.SetupSignalHandler() -var initter sync.Once -var runningInOssfuzz = false -var ( - localCRDpath = []string{filepath.Join("..", "config", "crd", "bases")} - - // Variables for the OSS-fuzz environment: - downloadLink = "https://storage.googleapis.com/kubebuilder-tools/kubebuilder-tools-1.19.2-linux-amd64.tar.gz" - downloadPath = "/tmp/envtest-bins.tar.gz" - binariesDir = "/tmp/test-binaries" - ossFuzzCrdPath = []string{filepath.Join(".", "bases")} - ossFuzzCrdYaml = "https://raw.githubusercontent.com/fluxcd/kustomize-controller/main/config/crd/bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml" - ossFuzzGitYaml = "https://raw.githubusercontent.com/fluxcd/source-controller/v0.15.4/config/crd/bases/source.toolkit.fluxcd.io_gitrepositories.yaml" - ossFuzzBucYaml = "https://raw.githubusercontent.com/fluxcd/source-controller/v0.15.4/config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml" - crdPath []string - pgpAscFile = "https://raw.githubusercontent.com/fluxcd/kustomize-controller/main/controllers/testdata/sops/pgp.asc" - ageTxtFile = "https://raw.githubusercontent.com/fluxcd/kustomize-controller/main/controllers/testdata/sops/age.txt" -) - -// createKUBEBUILDER_ASSETS runs "setup-envtest use" and -// returns the path of the 3 binaries that are created. -// If this one fails, it means that setup-envtest is not -// available in the runtime environment, and that means -// that the fuzzer is being run by OSS-fuzz. In that case -// we set "runningInOssfuzz" to true which will later trigger -// download of all required files so the OSS-fuzz can run -// the fuzzer. -func createKUBEBUILDER_ASSETS() string { - out, err := exec.Command("setup-envtest", "use").Output() - if err != nil { - // If there is an error here, the fuzzer is running - // in OSS-fuzz where the binary setup-envtest is - // not available, so we have to get them in an - // alternative way - runningInOssfuzz = true - return "" - } - - // split the output: - splitString := strings.Split(string(out), " ") - binPath := strings.TrimSuffix(splitString[len(splitString)-1], "\n") - if err != nil { - panic(err) - } - return binPath -} - -// Downloads a file to a path. This is only needed when -// the fuzzer is running in OSS-fuzz. -func DownloadFile(filepath string, url string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - // Create the file - out, err := os.Create(filepath) - if err != nil { - return err - } - defer out.Close() - - // Write the body to file - _, err = io.Copy(out, resp.Body) - return err -} - -// When OSS-fuzz runs the fuzzer a few files need to -// be download during initialization. No files from the -// kustomize-controller repo are available at runtime, -// and each of the files that are needed must be downloaded -// manually. -func downloadFilesForOssFuzz() error { - err := DownloadFile(downloadPath, downloadLink) - if err != nil { - return err - } - err = os.MkdirAll(binariesDir, 0777) - if err != nil { - return err - } - cmd := exec.Command("tar", "xvf", downloadPath, "-C", binariesDir) - err = cmd.Run() - if err != nil { - return err - } - - // Download crds - err = os.MkdirAll("bases", 0777) - if err != nil { - return err - } - err = DownloadFile("./bases/kustomize.toolkit.fluxcd.io_kustomizations.yaml", ossFuzzCrdYaml) - if err != nil { - return err - } - err = DownloadFile("./bases/source.toolkit.fluxcd.io_gitrepositories.yaml", ossFuzzGitYaml) - if err != nil { - return err - } - err = DownloadFile("./bases/source.toolkit.fluxcd.io_buckets.yaml", ossFuzzBucYaml) - if err != nil { - return err - } - - // Download sops files - err = os.MkdirAll("testdata/sops", 0777) - if err != nil { - return err - } - err = DownloadFile("./testdata/sops/pgp.asc", pgpAscFile) - if err != nil { - return err - } - err = DownloadFile("./testdata/sops/age.txt", ageTxtFile) - if err != nil { - return err - } - return nil -} - -// customInit implements an init function that -// is invoked by way of sync.Do -func customInit() { - kubebuilder_assets := createKUBEBUILDER_ASSETS() - os.Setenv("KUBEBUILDER_ASSETS", kubebuilder_assets) - - // Set up things for fuzzing in the OSS-fuzz environment: - if runningInOssfuzz { - err := downloadFilesForOssFuzz() - if err != nil { - panic(err) - } - os.Setenv("KUBEBUILDER_ASSETS", binariesDir+"/kubebuilder/bin") - crdPath = ossFuzzCrdPath - runningInOssfuzz = false - } else { - crdPath = localCRDpath - } - - var err error - err = sourcev1.AddToScheme(scheme.Scheme) - if err != nil { - panic(err) - } - err = kustomizev1.AddToScheme(scheme.Scheme) - if err != nil { - panic(err) - } - - testEnv = &envtest.Environment{ - CRDDirectoryPaths: 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() - - cfg, err = testEnv.Start() - - user, err := testEnv.ControlPlane.AddUser(envtest.User{ - Name: "envtest-admin", - Groups: []string{"system:masters"}, - }, nil) - if err != nil { - panic(fmt.Sprintf("Failed to create envtest-admin user: %v", err)) - } - - kubeConfig, err = user.KubeConfig() - if err != nil { - panic(fmt.Sprintf("Failed to create the envtest-admin user kubeconfig: %v", err)) - } - - // client with caching disabled - k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) - - k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme.Scheme, - }) - - if err := (&KustomizationReconciler{ - Client: k8sManager.GetClient(), - }).SetupWithManager(k8sManager, KustomizationReconcilerOptions{MaxConcurrentReconciles: 1}); err != nil { - panic(fmt.Sprintf("Failed to start GitRepositoryReconciler: %v", err)) - } - time.Sleep(1*time.Second) - - go func() { - fmt.Println("Starting the test environment") - if err := k8sManager.Start(ctx); err != nil { - panic(fmt.Sprintf("Failed to start the test environment manager: %v", err)) - } - }() - time.Sleep(time.Second*1) - <-k8sManager.Elected() -} - -// Fuzz implements the fuzz harness -func Fuzz(data []byte) int { - initter.Do(customInit) - f := fuzz.NewConsumer(data) - dname, err := os.MkdirTemp("", "artifact-dir") - if err != nil { - return 0 - } - defer os.RemoveAll(dname) - err = f.CreateFiles(dname) - if err != nil { - return 0 - } - id, err := randString(f) - if err != nil { - return 0 - } - namespace, err := createNamespaceForFuzzing(id) - if err != nil { - return 0 - } - defer k8sClient.Delete(context.Background(), namespace) - - fmt.Println("createKubeConfigSecretForFuzzing...") - secret, err := createKubeConfigSecretForFuzzing(id) - if err != nil { - fmt.Println(err) - return 0 - } - defer k8sClient.Delete(context.Background(), secret) - - artifactFile, err := randString(f) - if err != nil { - return 0 - } - fmt.Println("createArtifact...") - artifactChecksum, err := createArtifact(testServer, dname, artifactFile) - if err != nil { - fmt.Println(err) - return 0 - } - _ = artifactChecksum - fmt.Println("testServer.URLForFile...") - artifactURL, err := testServer.URLForFile(artifactFile) - if err != nil { - fmt.Println("URLForFile error: ", err) - return 0 - } - _ = artifactURL - repName, err := randString(f) - if err != nil { - return 0 - } - repositoryName := types.NamespacedName{ - Name: repName, - Namespace: id, - } - fmt.Println("applyGitRepository...") - err = applyGitRepository(repositoryName, artifactURL, "main/"+artifactChecksum, artifactChecksum) - if err != nil { - fmt.Println(err) - } - pgpKey, err := ioutil.ReadFile("testdata/sops/pgp.asc") - if err != nil { - return 0 - } - ageKey, err := ioutil.ReadFile("testdata/sops/age.txt") - if err != nil { - return 0 - } - sskName, err := randString(f) - if err != nil { - return 0 - } - sopsSecretKey := types.NamespacedName{ - Name: sskName, - 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), - }, - } - err = k8sClient.Create(context.Background(), sopsSecret) - if err != nil { - return 0 - } - defer k8sClient.Delete(context.Background(), sopsSecret) - - kkName, err := randString(f) - if err != nil { - return 0 - } - kustomizationKey := types.NamespacedName{ - Name: kkName, - Namespace: id, - } - kustomization := &kustomizev1.Kustomization{ - ObjectMeta: metav1.ObjectMeta{ - Name: kustomizationKey.Name, - Namespace: kustomizationKey.Namespace, - }, - Spec: kustomizev1.KustomizationSpec{ - 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, - }, - } - - err = k8sClient.Create(context.TODO(), kustomization) - if err != nil { - fmt.Println(err) - return 0 - } - defer k8sClient.Delete(context.TODO(), kustomization) - return 1 -} - -// Allows the fuzzer to create a random lowercase string -func randString(f *fuzz.ConsumeFuzzer) (string, error) { - stringLength, err := f.GetInt() - if err != nil { - return "", err - } - maxLength := 50 - var buffer bytes.Buffer - for i:=0;i 'z' { - return "", errors.New("Not a good char") - } - buffer.WriteByte(getByte) - } - return buffer.String(), nil -} - -// Creates a namespace. -// Caller must delete the created namespace. -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. -// Caller must delete the created secret. -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 -} - -// taken from https://github.com/fluxcd/kustomize-controller/blob/main/controllers/suite_test.go#L222 -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 - } - 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(), 0644); err != nil { - return "", err - } - - return fmt.Sprintf("%x", h.Sum(nil)), nil -} - -// Taken from https://github.com/fluxcd/kustomize-controller/blob/main/controllers/suite_test.go#L171 -func applyGitRepository(objKey client.ObjectKey, artifactURL, artifactRevision, artifactChecksum 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}, - }, - } - - status := sourcev1.GitRepositoryStatus{ - Conditions: []metav1.Condition{ - { - Type: meta.ReadyCondition, - Status: metav1.ConditionTrue, - LastTransitionTime: metav1.Now(), - Reason: sourcev1.GitOperationSucceedReason, - }, - }, - Artifact: &sourcev1.Artifact{ - Path: artifactURL, - URL: artifactURL, - Revision: artifactRevision, - Checksum: artifactChecksum, - 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 - if err := k8sClient.Status().Patch(context.Background(), repo, client.Apply, opt...); err != nil { - return err - } - return nil -} \ No newline at end of file diff --git a/tests/fuzz/Dockerfile.builder b/tests/fuzz/Dockerfile.builder new file mode 100644 index 000000000..4c04cccec --- /dev/null +++ b/tests/fuzz/Dockerfile.builder @@ -0,0 +1,6 @@ +FROM gcr.io/oss-fuzz-base/base-builder-go + +COPY ./ $GOPATH/src/github.com/fluxcd/kustomize-controller/ +COPY ./tests/fuzz/oss_fuzz_build.sh $SRC/build.sh + +WORKDIR $SRC diff --git a/tests/fuzz/README.md b/tests/fuzz/README.md new file mode 100644 index 000000000..f2d233967 --- /dev/null +++ b/tests/fuzz/README.md @@ -0,0 +1,45 @@ +# fuzz testing + +Flux is part of Google's [oss fuzz] program which provides continuous fuzzing for +open source projects. + +The long running fuzzing execution is configured in the [oss-fuzz repository]. +Shorter executions are done on a per-PR basis, configured as a [github workflow]. + +For fuzzers to be called, they must be compiled within [oss_fuzz_build.sh](./oss_fuzz_build.sh). + +### Testing locally + +Build fuzzers: + +```bash +make fuzz-build +``` +All fuzzers will be built into `./build/fuzz/out`. + +Smoke test fuzzers: + +```bash +make fuzz-smoketest +``` + +The smoke test runs each fuzzer once to ensure they are fully functional. + +Run fuzzer locally: +```bash +./build/fuzz/out/fuzz_conditions_match +``` + +Run fuzzer inside a container: + +```bash + docker run --rm -ti \ + -v "$(pwd)/build/fuzz/out":/out \ + gcr.io/oss-fuzz/fluxcd \ + /out/fuzz_conditions_match +``` + + +[oss fuzz]: https://github.com/google/oss-fuzz +[oss-fuzz repository]: https://github.com/google/oss-fuzz/tree/master/projects/fluxcd +[github workflow]: .github/workflows/cifuzz.yaml diff --git a/tests/fuzz/age_fuzzer.go b/tests/fuzz/age_fuzzer.go new file mode 100644 index 000000000..004effb3b --- /dev/null +++ b/tests/fuzz/age_fuzzer.go @@ -0,0 +1,47 @@ +//go:build gofuzz +// +build gofuzz + +/* +Copyright 2022 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 age + +import ( + fuzz "github.com/AdaLogics/go-fuzz-headers" +) + +// FuzzAge implements a fuzzer that targets functions within age/keysource.go. +func FuzzAge(data []byte) int { + f := fuzz.NewConsumer(data) + masterKey := MasterKey{} + + if err := f.GenerateStruct(&masterKey); err != nil { + return 0 + } + + _ = masterKey.Encrypt(data) + _ = masterKey.EncryptIfNeeded(data) + + receipts, err := f.GetString() + if err != nil { + return 0 + } + + _, _ = MasterKeysFromRecipients(receipts) + _, _ = MasterKeyFromRecipient(receipts) + + return 1 +} diff --git a/tests/fuzz/controllers_fuzzer.go b/tests/fuzz/controllers_fuzzer.go new file mode 100644 index 000000000..01f47c38c --- /dev/null +++ b/tests/fuzz/controllers_fuzzer.go @@ -0,0 +1,441 @@ +//go:build gofuzz +// +build gofuzz + +/* +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 ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" + + "embed" + "io/fs" + + securejoin "github.com/cyphar/filepath-securejoin" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" + "github.com/fluxcd/kustomize-controller/controllers" + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/testenv" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + fuzz "github.com/AdaLogics/go-fuzz-headers" +) + +var doOnce sync.Once + +const defaultBinVersion = "1.23" + +func envtestBinVersion() string { + if binVersion := os.Getenv("ENVTEST_BIN_VERSION"); binVersion != "" { + return binVersion + } + return defaultBinVersion +} + +//go:embed testdata/crd/*.yaml +//go:embed testdata/sops/pgp.asc +//go:embed testdata/sops/age.txt +var testFiles embed.FS + +// 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, 0o755) + 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, 0o644) + if err != nil { + return fmt.Errorf("writing %s: %v", fileName, err) + } + } + } + + return nil +} + +// 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 FuzzControllers(data []byte) int { + // 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(data) < 1000 { + return 0 + } + fmt.Printf("Data input length: %d\n", len(data)) + + f := fuzz.NewConsumer(data) + ff := fuzz.NewConsumer(data) + + 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 := &controllers.KustomizationReconciler{ + ControllerName: controllerName, + Client: testEnv, + } + if err := (reconciler).SetupWithManager(testEnv, controllers.KustomizationReconcilerOptions{MaxConcurrentReconciles: 1}); 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(f, 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(f, 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(f, 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/pgp.asc") + if err != nil { + return err + } + ageKey, err := testFiles.ReadFile("testdata/sops/age.txt") + if err != nil { + return err + } + sskName, err := randStringRange(f, 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(f, 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: &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: 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) + return 0 + } + + return 1 +} + +// 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, 0o755) + 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 +} diff --git a/tests/fuzz/go.mod b/tests/fuzz/go.mod new file mode 100644 index 000000000..b36e24253 --- /dev/null +++ b/tests/fuzz/go.mod @@ -0,0 +1,5 @@ +module github.com/fluxcd/kustomize-controller/tests/fuzz +// This module is used only to avoid polluting the main module +// with fuzz dependencies. + +go 1.17 diff --git a/tests/fuzz/oss_fuzz_build.sh b/tests/fuzz/oss_fuzz_build.sh new file mode 100755 index 000000000..b5e37931c --- /dev/null +++ b/tests/fuzz/oss_fuzz_build.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +# Copyright 2022 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. + +set -euxo pipefail + +GOPATH="${GOPATH:-/root/go}" +GO_SRC="${GOPATH}/src" +PROJECT_PATH="github.com/fluxcd/kustomize-controller" + +cd "${GO_SRC}" + +# Move fuzzer to their respective directories. +# This removes dependency noises from the modules' go.mod and go.sum files. +mv "${PROJECT_PATH}/tests/fuzz/age_fuzzer.go" "${PROJECT_PATH}/internal/sops/age/" +mv "${PROJECT_PATH}/tests/fuzz/pgp_fuzzer.go" "${PROJECT_PATH}/internal/sops/pgp/" + +# Some private functions within suite_test.go are extremly useful for testing. +# Instead of duplicating them here, or refactoring them away, this simply renames +# the file to make it available to "non-testing code". +# This is a temporary fix, which will cease once the implementation is migrated to +# the built-in fuzz support in golang 1.18. +cp "${PROJECT_PATH}/controllers/suite_test.go" "${PROJECT_PATH}/tests/fuzz/fuzzer_helper.go" +sed -i 's;KustomizationReconciler;abc.KustomizationReconciler;g' "${PROJECT_PATH}/tests/fuzz/fuzzer_helper.go" +sed -i 's;import (;import(\n abc "github.com/fluxcd/kustomize-controller/controllers";g' "${PROJECT_PATH}/tests/fuzz/fuzzer_helper.go" + +pushd "${PROJECT_PATH}" + +go mod tidy + +compile_go_fuzzer "${PROJECT_PATH}/internal/sops/age/" FuzzAge fuzz_age +compile_go_fuzzer "${PROJECT_PATH}/internal/sops/pgp/" FuzzPgp fuzz_pgp + +popd + +pushd "${PROJECT_PATH}/tests/fuzz" + +# Setup files to be embedded into controllers_fuzzer.go's testFiles variable. +mkdir -p testdata/crd +mkdir -p testdata/sops +cp ../../config/crd/bases/*.yaml testdata/crd +cp ../../controllers/testdata/sops/age.txt testdata/sops +cp ../../controllers/testdata/sops/pgp.asc testdata/sops + +go mod tidy + +compile_go_fuzzer "${PROJECT_PATH}/tests/fuzz/" FuzzControllers fuzz_controllers + +popd diff --git a/tests/fuzz/oss_fuzz_run.sh b/tests/fuzz/oss_fuzz_run.sh new file mode 100755 index 000000000..4c87f489b --- /dev/null +++ b/tests/fuzz/oss_fuzz_run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# Copyright 2022 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. + +set -euxo pipefail + +# run each fuzzer once to ensure they are working properly +find /out -type f -name "fuzz*" -exec echo {} -runs=1 \; | bash -e diff --git a/tests/fuzz/pgp_fuzzer.go b/tests/fuzz/pgp_fuzzer.go new file mode 100644 index 000000000..504b2d75f --- /dev/null +++ b/tests/fuzz/pgp_fuzzer.go @@ -0,0 +1,39 @@ +//go:build gofuzz +// +build gofuzz + +/* +Copyright 2022 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 pgp + +import ( + fuzz "github.com/AdaLogics/go-fuzz-headers" +) + +// FuzzPgp implements a fuzzer that targets functions within pgp/keysource.go. +func FuzzPgp(data []byte) int { + f := fuzz.NewConsumer(data) + masterKey := MasterKey{} + + if err := f.GenerateStruct(&masterKey); err != nil { + return 0 + } + + _ = masterKey.Encrypt(data) + _ = masterKey.EncryptIfNeeded(data) + + return 1 +}