diff --git a/.github/workflows/go-tests.yaml b/.github/workflows/go-tests.yaml index 47503f512..ba1dc8fd5 100644 --- a/.github/workflows/go-tests.yaml +++ b/.github/workflows/go-tests.yaml @@ -84,3 +84,42 @@ jobs: - name: Integration tests run: | make integration + + fuzz: + if: ${{ github.repository }} == 'chainguard-dev/malcontent' + runs-on: mal-ubuntu-latest-8-core + container: + image: cgr.dev/chainguard/wolfi-base:latest + options: >- + --cap-add DAC_OVERRIDE + --cap-add SETGID + --cap-add SETUID + --cap-drop ALL + --cgroupns private + --cpu-shares=8192 + --memory-swappiness=0 + --security-opt no-new-privileges + --ulimit core=0 + --ulimit nofile=4096:4096 + --ulimit nproc=4096:4096 + steps: + - name: Install dependencies + run: | + apk update + apk add curl findutils git go nodejs upx xz yara-x + + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Trust repository + run: git config --global --add safe.directory "${GITHUB_WORKSPACE}" + + - name: Clone malcontent samples required for Fuzz tests + run: | + make samples + + - name: Fuzz tests + run: | + make fuzz diff --git a/Makefile b/Makefile index 67f63e10a..e2a2a0996 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,8 @@ out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT): git -C out/$(YARA_X_REPO) checkout $(YARA_X_COMMIT) touch out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT) +samples: out/$(SAMPLES_REPO)/.decompressed-$(SAMPLES_COMMIT) + .PHONY: install-yara-x install-yara-x: out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT) mkdir -p out/lib @@ -136,6 +138,27 @@ install-yara-x: out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT) test: go test -race ./pkg/... +.PHONY: fuzz +fuzz: + go test -fuzz=FuzzExtractTar -fuzztime=10s ./pkg/archive/ + go test -fuzz=FuzzExtractZip -fuzztime=10s ./pkg/archive/ + go test -fuzz=FuzzExtractArchive -fuzztime=10s ./pkg/archive/ + go test -fuzz=FuzzIsValidPath -fuzztime=10s ./pkg/archive/ + go test -fuzz=FuzzFile -fuzztime=30s ./pkg/programkind/ + go test -fuzz=FuzzPath -fuzztime=10s ./pkg/programkind/ + go test -fuzz=FuzzGetExt -fuzztime=10s ./pkg/programkind/ + go test -fuzz=FuzzLongestUnique -fuzztime=10s ./pkg/report/ + go test -fuzz=FuzzTrimPrefixes -fuzztime=10s ./pkg/report/ + go test -fuzz=FuzzMatchToString -fuzztime=10s ./pkg/report/ + +# fuzz tests - runs continuously (use Ctrl+C to stop) +# Usage: make fuzz-continuous FUZZ_TARGET=FuzzExtractArchive FUZZ_PKG=./pkg/archive/ +FUZZ_TARGET ?= FuzzExtractArchive +FUZZ_PKG ?= ./pkg/archive/ +.PHONY: fuzz-continuous +fuzz-continuous: + go test -fuzz=$(FUZZ_TARGET) $(FUZZ_PKG) + # unit tests only .PHONY: coverage coverage: out/mal.coverage diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go index e16bc2302..7594d6ba6 100644 --- a/pkg/archive/archive.go +++ b/pkg/archive/archive.go @@ -31,7 +31,22 @@ var ( // isValidPath checks if the target file is within the given directory. func IsValidPath(target, dir string) bool { - return strings.HasPrefix(filepath.Clean(target), filepath.Clean(dir)) + cleanTarget := filepath.Clean(target) + cleanDir := filepath.Clean(dir) + + switch { + case cleanDir == "", cleanTarget == "": + return false + case !strings.HasPrefix(cleanTarget, cleanDir): + return false + case cleanTarget == cleanDir: + return true + case len(cleanTarget) > len(cleanDir): + nextChar := cleanTarget[len(cleanDir)] + return nextChar == filepath.Separator || nextChar == '/' + default: + return false + } } func extractNestedArchive(ctx context.Context, c malcontent.Config, d string, f string, extracted *sync.Map, logger *clog.Logger) error { diff --git a/pkg/archive/fuzz_test.go b/pkg/archive/fuzz_test.go new file mode 100644 index 000000000..3b33b9fe7 --- /dev/null +++ b/pkg/archive/fuzz_test.go @@ -0,0 +1,226 @@ +package archive + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chainguard-dev/malcontent/pkg/malcontent" +) + +// FuzzExtractTar tests tar extraction with random inputs to find crashes, +// path traversal vulnerabilities, and other issues. +func FuzzExtractTar(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/apko.tar.gz", + "../../pkg/action/testdata/apko_nested.tar.gz", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + f.Add(data) + } + } + + f.Add([]byte{}) // empty file + f.Add([]byte("not a tar file")) + f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}) // gzip magic bytes only + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-tar-*.tar.gz") + if err != nil { + t.Skip("failed to create temp file") + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip("failed to write to temp file") + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip("failed to create temp dir") + } + defer os.RemoveAll(tmpDir) + + ctx := context.Background() + _ = ExtractTar(ctx, tmpDir, tmpFile.Name()) + + err = filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal detected: %s is outside %s", path, tmpDir) + } + return nil + }) + if err != nil { + return + } + }) +} + +// FuzzExtractZip tests zip extraction with random inputs. +func FuzzExtractZip(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/apko.zip", + "../../pkg/action/testdata/conflict.zip", + "../../pkg/action/testdata/17419.zip", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + f.Add(data) + } + } + + f.Add([]byte{}) // empty file + f.Add([]byte("PK")) // zip magic bytes only + f.Add([]byte{0x50, 0x4b, 0x03, 0x04}) // full zip signature + + f.Fuzz(func(t *testing.T, data []byte) { + tmpFile, err := os.CreateTemp("", "fuzz-zip-*.zip") + if err != nil { + t.Skip("failed to create temp file") + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip("failed to write to temp file") + } + tmpFile.Close() + + tmpDir, err := os.MkdirTemp("", "fuzz-extract-*") + if err != nil { + t.Skip("failed to create temp dir") + } + defer os.RemoveAll(tmpDir) + + ctx := context.Background() + _ = ExtractZip(ctx, tmpDir, tmpFile.Name()) + + err = filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, tmpDir) { + t.Fatalf("path traversal detected: %s is outside %s", path, tmpDir) + } + return nil + }) + if err != nil { + return + } + }) +} + +// FuzzExtractArchive tests archive extraction via the top-level ExtractArchiveToTempDir +// function which handles initialization properly. +func FuzzExtractArchive(f *testing.F) { + testdata := []string{ + "../../pkg/action/testdata/apko.tar.gz", + "../../pkg/action/testdata/apko_nested.tar.gz", + "../../pkg/action/testdata/apko.zip", + "../../pkg/action/testdata/apko.gz", + } + + for _, td := range testdata { + if data, err := os.ReadFile(td); err == nil { + switch { + case strings.HasSuffix(td, ".tar.gz"): + f.Add(data, ".tar.gz") + case strings.HasSuffix(td, ".zip"): + f.Add(data, ".zip") + case strings.HasSuffix(td, ".gz"): + f.Add(data, ".gz") + } + } + } + + f.Add([]byte{}, ".tar.gz") // empty file + f.Add([]byte("not a tar file"), ".tar.gz") // invalid content + f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}, ".tar.gz") // gzip magic bytes only + f.Add([]byte{0x50, 0x4b, 0x03, 0x04}, ".zip") // zip magic bytes + f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}, ".gz") // gzip header + + f.Fuzz(func(t *testing.T, data []byte, ext string) { + if ext != ".tar.gz" && ext != ".zip" && ext != ".gz" && ext != ".tar" { + return + } + + tmpFile, err := os.CreateTemp("", "fuzz-archive-*"+ext) + if err != nil { + t.Skip("failed to create temp file") + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip("failed to write to temp file") + } + tmpFile.Close() + + ctx := context.Background() + cfg := malcontent.Config{} + extractedDir, err := ExtractArchiveToTempDir(ctx, cfg, tmpFile.Name()) + if err == nil && extractedDir != "" { + defer os.RemoveAll(extractedDir) + + walkErr := filepath.WalkDir(extractedDir, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if !IsValidPath(path, extractedDir) { + t.Fatalf("path traversal detected: %s is outside %s", path, extractedDir) + } + return nil + }) + if walkErr != nil { + return + } + } + }) +} + +// FuzzIsValidPath tests path validation via the IsValidPath function. +func FuzzIsValidPath(f *testing.F) { + tmpDir, err := os.MkdirTemp("", "fuzz-path-") + if err != nil { + f.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + f.Add(tmpDir, filepath.Join(tmpDir, "safe.txt")) + f.Add(tmpDir, filepath.Join(tmpDir, "..", "etc", "passwd")) + f.Add(tmpDir, "/etc/passwd") + f.Add(tmpDir, filepath.Join(tmpDir, ".", ".", "safe.txt")) + f.Add(tmpDir, filepath.Join(tmpDir, "subdir", "..", "..", "etc", "passwd")) + f.Add(tmpDir, filepath.Join(tmpDir, "deeply", "nested", "path", "file.txt")) + + f.Fuzz(func(t *testing.T, baseDir, targetPath string) { + if len(baseDir) < 3 { + return + } + + result := IsValidPath(targetPath, baseDir) + + if result { + cleanTarget := filepath.Clean(targetPath) + cleanBase := filepath.Clean(baseDir) + + if cleanBase == "" || cleanTarget == "" { + return + } + + if filepath.IsAbs(cleanTarget) && filepath.IsAbs(cleanBase) { + rel, err := filepath.Rel(cleanBase, cleanTarget) + if err == nil && (rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))) { + t.Fatalf("IsValidPath returned true but path %q escapes base %q (rel=%q)", targetPath, baseDir, rel) + } + } + } + }) +} diff --git a/pkg/archive/testdata/fuzz/FuzzIsValidPath/309f75027cfce63a b/pkg/archive/testdata/fuzz/FuzzIsValidPath/309f75027cfce63a new file mode 100644 index 000000000..03c6bd7d5 --- /dev/null +++ b/pkg/archive/testdata/fuzz/FuzzIsValidPath/309f75027cfce63a @@ -0,0 +1,3 @@ +go test fuzz v1 +string("/va") +string("/va0") diff --git a/pkg/archive/testdata/fuzz/FuzzIsValidPath/63b2b417aafe67a1 b/pkg/archive/testdata/fuzz/FuzzIsValidPath/63b2b417aafe67a1 new file mode 100644 index 000000000..4efc993ab --- /dev/null +++ b/pkg/archive/testdata/fuzz/FuzzIsValidPath/63b2b417aafe67a1 @@ -0,0 +1,3 @@ +go test fuzz v1 +string("/0") +string("/00") diff --git a/pkg/programkind/fuzz_test.go b/pkg/programkind/fuzz_test.go new file mode 100644 index 000000000..7cb74d6d6 --- /dev/null +++ b/pkg/programkind/fuzz_test.go @@ -0,0 +1,140 @@ +package programkind + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// FuzzFile tests file type detection with random inputs. +func FuzzFile(f *testing.F) { + samplesDir := "../../out/chainguard-dev/malcontent-samples" + err := filepath.WalkDir(samplesDir, func(path string, d os.DirEntry, _ error) error { + if d == nil || d.IsDir() { + return nil + } + if filepath.Base(path)[0] == '.' { + return nil + } + + if data, readErr := os.ReadFile(path); readErr == nil { + if len(data) <= 10*1024*1024 { // 10MB max + f.Add(data, filepath.Base(path)) + } + } + return nil + }) + if err != nil { + f.Logf("Could not walk samples directory: %v", err) + } + + f.Add([]byte{0x7f, 0x45, 0x4c, 0x46}, "test.elf") // ELF magic + f.Add([]byte{0x4d, 0x5a}, "test.exe") // PE/MZ magic + f.Add([]byte{0xca, 0xfe, 0xba, 0xbe}, "test.macho") // Mach-O magic + f.Add([]byte{0x1f, 0x8b, 0x08}, "test.gz") // gzip magic + f.Add([]byte{0x50, 0x4b, 0x03, 0x04}, "test.zip") // zip magic + f.Add([]byte("#!/bin/sh\necho hello"), "test.sh") // shell script + f.Add([]byte("#!/usr/bin/env python\nprint('hello')"), "test.py") // python script + f.Add([]byte("\nint main() {}"), "test.c") // C code + f.Add([]byte("package main\nfunc main() {}"), "test.go") // Go code + f.Add([]byte(""), "empty") // empty file + f.Add([]byte{0x00, 0x00, 0x00, 0x00}, "nulls") // null bytes + f.Add([]byte{0xff, 0xff, 0xff, 0xff}, "ones") // all ones + f.Add([]byte("UPX!"), "test.upx") // UPX magic + + f.Fuzz(func(t *testing.T, data []byte, filename string) { + if len(filename) > 255 || filepath.Clean(filename) != filename { + return + } + + tmpFile, err := os.CreateTemp("", "fuzz-file-*-"+filepath.Base(filename)) + if err != nil { + t.Skip("failed to create temp file") + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.Write(data); err != nil { + t.Skip("failed to write to temp file") + } + tmpFile.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + ft, err := File(ctx, tmpFile.Name()) + + _ = ft + _ = err + + if ft != nil { + if len(ft.MIME) > 1000 { + t.Fatalf("MIME type too long: %d bytes", len(ft.MIME)) + } + if len(ft.Ext) > 100 { + t.Fatalf("Extension too long: %d bytes", len(ft.Ext)) + } + } + }) +} + +// FuzzPath tests the Path() function which determines file type from path/extension. +func FuzzPath(f *testing.F) { + f.Add("test.sh") + f.Add("test.py") + f.Add("test.go") + f.Add("test.c") + f.Add("test.js") + f.Add("test.rb") + f.Add("test.elf") + f.Add("test.exe") + f.Add("test.dll") + f.Add("test.so") + f.Add("test.tar.gz") + f.Add("test.zip") + f.Add("../../../etc/passwd") + f.Add("test") + f.Add("") + f.Add("test....") + f.Add(".hidden") + f.Add("test.a.b.c.d.e") + + f.Fuzz(func(t *testing.T, path string) { + ft := Path(path) + + if ft != nil { + if len(ft.MIME) > 1000 { + t.Fatalf("MIME type too long: %d bytes", len(ft.MIME)) + } + if len(ft.Ext) > 100 { + t.Fatalf("Extension too long: %d bytes", len(ft.Ext)) + } + } + }) +} + +// FuzzGetExt tests the GetExt() function which extracts file extensions. +func FuzzGetExt(f *testing.F) { + f.Add("test.tar.gz") + f.Add("test.tar.xz") + f.Add("test.tar.bz2") + f.Add("test.zip") + f.Add("test") + f.Add("") + f.Add(".") + f.Add("..") + f.Add("...") + f.Add("/path/to/file.tar.gz") + f.Add("file_1.0.0.tar.gz") + f.Add("file.a.b.c") + + f.Fuzz(func(t *testing.T, path string) { + ext := GetExt(path) + + if ext != "" && ext[0] != '.' { + t.Fatalf("extension doesn't start with dot: %q", ext) + } + }) +} diff --git a/pkg/programkind/testdata/fuzz/FuzzGetExt/aa94859cebcbd47f b/pkg/programkind/testdata/fuzz/FuzzGetExt/aa94859cebcbd47f new file mode 100644 index 000000000..5a644c624 --- /dev/null +++ b/pkg/programkind/testdata/fuzz/FuzzGetExt/aa94859cebcbd47f @@ -0,0 +1,2 @@ +go test fuzz v1 +string(".0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") diff --git a/pkg/report/fuzz_test.go b/pkg/report/fuzz_test.go new file mode 100644 index 000000000..1a5d469b4 --- /dev/null +++ b/pkg/report/fuzz_test.go @@ -0,0 +1,161 @@ +package report + +import ( + "strings" + "testing" +) + +// FuzzLongestUnique tests the longestUnique function with random string inputs. +func FuzzLongestUnique(f *testing.F) { + f.Add("apple,banana,cherry,applecherry,bananaapple,cherrybanana") + f.Add("test,testing,tester,testest") + f.Add(",a,aa,aaa") + f.Add("abc,def,ghi") + f.Add("abc,abcabc,abcabcabc") + f.Add("") // empty input + f.Add("single") // single string + f.Add("a,a,a,a") // all duplicates + f.Add("very_long_string_" + strings.Repeat("x", 1000)) // long strings + f.Add(strings.Repeat("a,", 100)) // many strings + f.Add("a,b,c,d,e,f,g,h,i,j,k,l,m") // many different strings + f.Add("test\x00null,normal") // null byte + f.Add("test\nnewline,test\rcarriage,test\ttab") // whitespace control chars + f.Add("test\x01\x02\x03,normal") // low control characters + f.Add("test\x7f\x80\x9f,normal") // high control characters + f.Add("test\u200b,normal") // zero-width space + f.Add("test\u200c,normal") // zero-width non-joiner + f.Add("test\u200d,normal") // zero-width joiner + f.Add("test\ufeff,normal") // zero-width no-break space (BOM) + f.Add("test\u202a\u202b\u202c,normal") // bidirectional text marks + f.Add("test\u2060,normal") // word joiner + f.Add("hello\u200bworld,helloworld") // same word with/without zero-width + f.Add("test\u034f,normal") // combining grapheme joiner + f.Add("\u200b\u200c\u200d,visible") // only invisible characters + f.Add("a\u0300\u0301\u0302,a") // combining diacritical marks + f.Add("test\u00ad,test") // soft hyphen + f.Add("fi\ufb01,fi") // ligature vs normal chars + f.Add("test\u180e,normal") // mongolian vowel separator + f.Add("\u061c\u2066\u2067\u2068\u2069,normal") // directional formatting + + f.Fuzz(func(t *testing.T, input string) { + var strs []string + if input != "" { + strs = strings.Split(input, ",") + } + + if len(strs) > 1000 { + strs = strs[:1000] + } + + for i, s := range strs { + if len(s) > 10000 { + strs[i] = s[:10000] + } + } + + result := longestUnique(strs) + + for _, s := range result { + if s == "" { + t.Fatal("result contains empty string") + } + } + + for i, s1 := range result { + for j, s2 := range result { + if i != j && strings.Contains(s1, s2) { + t.Fatalf("result[%d]=%q contains result[%d]=%q", i, s1, j, s2) + } + } + } + + inputMap := make(map[string]bool) + for _, s := range strs { + if s != "" { + inputMap[s] = true + } + } + for _, s := range result { + if !inputMap[s] { + t.Fatalf("result contains %q which was not in input", s) + } + } + + if len(result) > len(strs) { + t.Fatalf("result length %d exceeds input length %d", len(result), len(strs)) + } + }) +} + +// FuzzTrimPrefixes tests the TrimPrefixes function with random inputs. +func FuzzTrimPrefixes(f *testing.F) { + f.Add("/tmp/extract/path/to/file", "/tmp/extract") + f.Add("/home/user/file", "/home/user,/tmp") + f.Add("/absolute/path", "/absolute,./relative") + f.Add("/path/to/file", "/path/to") + f.Add("./relative/path", "./relative") + f.Add("./path/to/file", "./path") + f.Add("./a/b/c/d/e", "./a/b") + f.Add("../path/to/file", "../path/to") + f.Add("../../parent/path", "../../parent") + f.Add("../../../deeply/nested", "../../../deeply") + f.Add("./././path", "./") + f.Add("path/../other/file", "path/..") + f.Add("relative/path", "/absolute,./relative") + f.Add("/abs/path", "./relative,/abs") + f.Add("", "") + f.Add("path", "") + f.Add("path/to/file", "path") + f.Add(".", ".") + f.Add("..", "..") + f.Add("../..", "../..") + f.Add("./path/./to/./file", "./path") + f.Add("path/./to/file", "path/.") + f.Add("path/../to/file", "path/..") + f.Add("path/to/link/../real", "path/to") + f.Add("./path/to/../../other", "./path") + f.Add("path/to/file/", "path/to/") + f.Add("/path/to/file/", "/path/to/") + f.Add("./path/to/file/", "./path/to/") + + f.Fuzz(func(t *testing.T, path, prefixesStr string) { + var prefixes []string + if prefixesStr != "" { + prefixes = strings.Split(prefixesStr, ",") + } + + if len(prefixes) > 100 { + prefixes = prefixes[:100] + } + + result := TrimPrefixes(path, prefixes) + + if len(result) > len(path) { + t.Fatalf("result %q is longer than input %q", result, path) + } + }) +} + +// FuzzMatchToString tests the matchToString function. +func FuzzMatchToString(f *testing.F) { + f.Add("rule_name", "matched_string") + f.Add("", "") + f.Add("rule", "") + f.Add("", "match") + f.Add(strings.Repeat("a", 1000), strings.Repeat("b", 1000)) // long strings + + f.Fuzz(func(t *testing.T, ruleName, match string) { + if len(ruleName) > 10000 { + ruleName = ruleName[:10000] + } + if len(match) > 10000 { + match = match[:10000] + } + + result := matchToString(ruleName, match) + + if len(result) > len(ruleName)+len(match)+100 { + t.Fatalf("result length %d is unreasonably large", len(result)) + } + }) +} diff --git a/pkg/report/testdata/fuzz/FuzzTrimPrefixes/14bc260a87609fb7 b/pkg/report/testdata/fuzz/FuzzTrimPrefixes/14bc260a87609fb7 new file mode 100644 index 000000000..3b338273e --- /dev/null +++ b/pkg/report/testdata/fuzz/FuzzTrimPrefixes/14bc260a87609fb7 @@ -0,0 +1,3 @@ +go test fuzz v1 +string("path0//") +string("p")