Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/go-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
23 changes: 23 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion pkg/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
226 changes: 226 additions & 0 deletions pkg/archive/fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
})
}
3 changes: 3 additions & 0 deletions pkg/archive/testdata/fuzz/FuzzIsValidPath/309f75027cfce63a
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
go test fuzz v1
string("/va")
string("/va0")
3 changes: 3 additions & 0 deletions pkg/archive/testdata/fuzz/FuzzIsValidPath/63b2b417aafe67a1
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
go test fuzz v1
string("/0")
string("/00")
Loading