Skip to content

Commit 2844a83

Browse files
authored
Add fuzzing for extraction, file type determination, and report generation (#1204)
* Add fuzzing for extraction, file type determination, and report generation Signed-off-by: egibs <20933572+egibs@users.noreply.github.com> * Add additional cases Signed-off-by: egibs <20933572+egibs@users.noreply.github.com> --------- Signed-off-by: egibs <20933572+egibs@users.noreply.github.com>
1 parent c792768 commit 2844a83

File tree

10 files changed

+616
-1
lines changed

10 files changed

+616
-1
lines changed

.github/workflows/go-tests.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,42 @@ jobs:
8484
- name: Integration tests
8585
run: |
8686
make integration
87+
88+
fuzz:
89+
if: ${{ github.repository }} == 'chainguard-dev/malcontent'
90+
runs-on: mal-ubuntu-latest-8-core
91+
container:
92+
image: cgr.dev/chainguard/wolfi-base:latest
93+
options: >-
94+
--cap-add DAC_OVERRIDE
95+
--cap-add SETGID
96+
--cap-add SETUID
97+
--cap-drop ALL
98+
--cgroupns private
99+
--cpu-shares=8192
100+
--memory-swappiness=0
101+
--security-opt no-new-privileges
102+
--ulimit core=0
103+
--ulimit nofile=4096:4096
104+
--ulimit nproc=4096:4096
105+
steps:
106+
- name: Install dependencies
107+
run: |
108+
apk update
109+
apk add curl findutils git go nodejs upx xz yara-x
110+
111+
- name: Checkout code
112+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
113+
with:
114+
persist-credentials: false
115+
116+
- name: Trust repository
117+
run: git config --global --add safe.directory "${GITHUB_WORKSPACE}"
118+
119+
- name: Clone malcontent samples required for Fuzz tests
120+
run: |
121+
make samples
122+
123+
- name: Fuzz tests
124+
run: |
125+
make fuzz

Makefile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT):
123123
git -C out/$(YARA_X_REPO) checkout $(YARA_X_COMMIT)
124124
touch out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT)
125125

126+
samples: out/$(SAMPLES_REPO)/.decompressed-$(SAMPLES_COMMIT)
127+
126128
.PHONY: install-yara-x
127129
install-yara-x: out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT)
128130
mkdir -p out/lib
@@ -136,6 +138,27 @@ install-yara-x: out/$(YARA_X_REPO)/.git/commit-$(YARA_X_COMMIT)
136138
test:
137139
go test -race ./pkg/...
138140

141+
.PHONY: fuzz
142+
fuzz:
143+
go test -fuzz=FuzzExtractTar -fuzztime=10s ./pkg/archive/
144+
go test -fuzz=FuzzExtractZip -fuzztime=10s ./pkg/archive/
145+
go test -fuzz=FuzzExtractArchive -fuzztime=10s ./pkg/archive/
146+
go test -fuzz=FuzzIsValidPath -fuzztime=10s ./pkg/archive/
147+
go test -fuzz=FuzzFile -fuzztime=30s ./pkg/programkind/
148+
go test -fuzz=FuzzPath -fuzztime=10s ./pkg/programkind/
149+
go test -fuzz=FuzzGetExt -fuzztime=10s ./pkg/programkind/
150+
go test -fuzz=FuzzLongestUnique -fuzztime=10s ./pkg/report/
151+
go test -fuzz=FuzzTrimPrefixes -fuzztime=10s ./pkg/report/
152+
go test -fuzz=FuzzMatchToString -fuzztime=10s ./pkg/report/
153+
154+
# fuzz tests - runs continuously (use Ctrl+C to stop)
155+
# Usage: make fuzz-continuous FUZZ_TARGET=FuzzExtractArchive FUZZ_PKG=./pkg/archive/
156+
FUZZ_TARGET ?= FuzzExtractArchive
157+
FUZZ_PKG ?= ./pkg/archive/
158+
.PHONY: fuzz-continuous
159+
fuzz-continuous:
160+
go test -fuzz=$(FUZZ_TARGET) $(FUZZ_PKG)
161+
139162
# unit tests only
140163
.PHONY: coverage
141164
coverage: out/mal.coverage

pkg/archive/archive.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,22 @@ var (
3131

3232
// isValidPath checks if the target file is within the given directory.
3333
func IsValidPath(target, dir string) bool {
34-
return strings.HasPrefix(filepath.Clean(target), filepath.Clean(dir))
34+
cleanTarget := filepath.Clean(target)
35+
cleanDir := filepath.Clean(dir)
36+
37+
switch {
38+
case cleanDir == "", cleanTarget == "":
39+
return false
40+
case !strings.HasPrefix(cleanTarget, cleanDir):
41+
return false
42+
case cleanTarget == cleanDir:
43+
return true
44+
case len(cleanTarget) > len(cleanDir):
45+
nextChar := cleanTarget[len(cleanDir)]
46+
return nextChar == filepath.Separator || nextChar == '/'
47+
default:
48+
return false
49+
}
3550
}
3651

3752
func extractNestedArchive(ctx context.Context, c malcontent.Config, d string, f string, extracted *sync.Map, logger *clog.Logger) error {

pkg/archive/fuzz_test.go

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package archive
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/chainguard-dev/malcontent/pkg/malcontent"
11+
)
12+
13+
// FuzzExtractTar tests tar extraction with random inputs to find crashes,
14+
// path traversal vulnerabilities, and other issues.
15+
func FuzzExtractTar(f *testing.F) {
16+
testdata := []string{
17+
"../../pkg/action/testdata/apko.tar.gz",
18+
"../../pkg/action/testdata/apko_nested.tar.gz",
19+
}
20+
21+
for _, td := range testdata {
22+
if data, err := os.ReadFile(td); err == nil {
23+
f.Add(data)
24+
}
25+
}
26+
27+
f.Add([]byte{}) // empty file
28+
f.Add([]byte("not a tar file"))
29+
f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}) // gzip magic bytes only
30+
31+
f.Fuzz(func(t *testing.T, data []byte) {
32+
tmpFile, err := os.CreateTemp("", "fuzz-tar-*.tar.gz")
33+
if err != nil {
34+
t.Skip("failed to create temp file")
35+
}
36+
defer os.Remove(tmpFile.Name())
37+
38+
if _, err := tmpFile.Write(data); err != nil {
39+
t.Skip("failed to write to temp file")
40+
}
41+
tmpFile.Close()
42+
43+
tmpDir, err := os.MkdirTemp("", "fuzz-extract-*")
44+
if err != nil {
45+
t.Skip("failed to create temp dir")
46+
}
47+
defer os.RemoveAll(tmpDir)
48+
49+
ctx := context.Background()
50+
_ = ExtractTar(ctx, tmpDir, tmpFile.Name())
51+
52+
err = filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error {
53+
if err != nil {
54+
return err
55+
}
56+
if !IsValidPath(path, tmpDir) {
57+
t.Fatalf("path traversal detected: %s is outside %s", path, tmpDir)
58+
}
59+
return nil
60+
})
61+
if err != nil {
62+
return
63+
}
64+
})
65+
}
66+
67+
// FuzzExtractZip tests zip extraction with random inputs.
68+
func FuzzExtractZip(f *testing.F) {
69+
testdata := []string{
70+
"../../pkg/action/testdata/apko.zip",
71+
"../../pkg/action/testdata/conflict.zip",
72+
"../../pkg/action/testdata/17419.zip",
73+
}
74+
75+
for _, td := range testdata {
76+
if data, err := os.ReadFile(td); err == nil {
77+
f.Add(data)
78+
}
79+
}
80+
81+
f.Add([]byte{}) // empty file
82+
f.Add([]byte("PK")) // zip magic bytes only
83+
f.Add([]byte{0x50, 0x4b, 0x03, 0x04}) // full zip signature
84+
85+
f.Fuzz(func(t *testing.T, data []byte) {
86+
tmpFile, err := os.CreateTemp("", "fuzz-zip-*.zip")
87+
if err != nil {
88+
t.Skip("failed to create temp file")
89+
}
90+
defer os.Remove(tmpFile.Name())
91+
92+
if _, err := tmpFile.Write(data); err != nil {
93+
t.Skip("failed to write to temp file")
94+
}
95+
tmpFile.Close()
96+
97+
tmpDir, err := os.MkdirTemp("", "fuzz-extract-*")
98+
if err != nil {
99+
t.Skip("failed to create temp dir")
100+
}
101+
defer os.RemoveAll(tmpDir)
102+
103+
ctx := context.Background()
104+
_ = ExtractZip(ctx, tmpDir, tmpFile.Name())
105+
106+
err = filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error {
107+
if err != nil {
108+
return err
109+
}
110+
if !IsValidPath(path, tmpDir) {
111+
t.Fatalf("path traversal detected: %s is outside %s", path, tmpDir)
112+
}
113+
return nil
114+
})
115+
if err != nil {
116+
return
117+
}
118+
})
119+
}
120+
121+
// FuzzExtractArchive tests archive extraction via the top-level ExtractArchiveToTempDir
122+
// function which handles initialization properly.
123+
func FuzzExtractArchive(f *testing.F) {
124+
testdata := []string{
125+
"../../pkg/action/testdata/apko.tar.gz",
126+
"../../pkg/action/testdata/apko_nested.tar.gz",
127+
"../../pkg/action/testdata/apko.zip",
128+
"../../pkg/action/testdata/apko.gz",
129+
}
130+
131+
for _, td := range testdata {
132+
if data, err := os.ReadFile(td); err == nil {
133+
switch {
134+
case strings.HasSuffix(td, ".tar.gz"):
135+
f.Add(data, ".tar.gz")
136+
case strings.HasSuffix(td, ".zip"):
137+
f.Add(data, ".zip")
138+
case strings.HasSuffix(td, ".gz"):
139+
f.Add(data, ".gz")
140+
}
141+
}
142+
}
143+
144+
f.Add([]byte{}, ".tar.gz") // empty file
145+
f.Add([]byte("not a tar file"), ".tar.gz") // invalid content
146+
f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}, ".tar.gz") // gzip magic bytes only
147+
f.Add([]byte{0x50, 0x4b, 0x03, 0x04}, ".zip") // zip magic bytes
148+
f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}, ".gz") // gzip header
149+
150+
f.Fuzz(func(t *testing.T, data []byte, ext string) {
151+
if ext != ".tar.gz" && ext != ".zip" && ext != ".gz" && ext != ".tar" {
152+
return
153+
}
154+
155+
tmpFile, err := os.CreateTemp("", "fuzz-archive-*"+ext)
156+
if err != nil {
157+
t.Skip("failed to create temp file")
158+
}
159+
defer os.Remove(tmpFile.Name())
160+
161+
if _, err := tmpFile.Write(data); err != nil {
162+
t.Skip("failed to write to temp file")
163+
}
164+
tmpFile.Close()
165+
166+
ctx := context.Background()
167+
cfg := malcontent.Config{}
168+
extractedDir, err := ExtractArchiveToTempDir(ctx, cfg, tmpFile.Name())
169+
if err == nil && extractedDir != "" {
170+
defer os.RemoveAll(extractedDir)
171+
172+
walkErr := filepath.WalkDir(extractedDir, func(path string, _ os.DirEntry, err error) error {
173+
if err != nil {
174+
return err
175+
}
176+
if !IsValidPath(path, extractedDir) {
177+
t.Fatalf("path traversal detected: %s is outside %s", path, extractedDir)
178+
}
179+
return nil
180+
})
181+
if walkErr != nil {
182+
return
183+
}
184+
}
185+
})
186+
}
187+
188+
// FuzzIsValidPath tests path validation via the IsValidPath function.
189+
func FuzzIsValidPath(f *testing.F) {
190+
tmpDir, err := os.MkdirTemp("", "fuzz-path-")
191+
if err != nil {
192+
f.Fatal(err)
193+
}
194+
defer os.RemoveAll(tmpDir)
195+
196+
f.Add(tmpDir, filepath.Join(tmpDir, "safe.txt"))
197+
f.Add(tmpDir, filepath.Join(tmpDir, "..", "etc", "passwd"))
198+
f.Add(tmpDir, "/etc/passwd")
199+
f.Add(tmpDir, filepath.Join(tmpDir, ".", ".", "safe.txt"))
200+
f.Add(tmpDir, filepath.Join(tmpDir, "subdir", "..", "..", "etc", "passwd"))
201+
f.Add(tmpDir, filepath.Join(tmpDir, "deeply", "nested", "path", "file.txt"))
202+
203+
f.Fuzz(func(t *testing.T, baseDir, targetPath string) {
204+
if len(baseDir) < 3 {
205+
return
206+
}
207+
208+
result := IsValidPath(targetPath, baseDir)
209+
210+
if result {
211+
cleanTarget := filepath.Clean(targetPath)
212+
cleanBase := filepath.Clean(baseDir)
213+
214+
if cleanBase == "" || cleanTarget == "" {
215+
return
216+
}
217+
218+
if filepath.IsAbs(cleanTarget) && filepath.IsAbs(cleanBase) {
219+
rel, err := filepath.Rel(cleanBase, cleanTarget)
220+
if err == nil && (rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
221+
t.Fatalf("IsValidPath returned true but path %q escapes base %q (rel=%q)", targetPath, baseDir, rel)
222+
}
223+
}
224+
}
225+
})
226+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
go test fuzz v1
2+
string("/va")
3+
string("/va0")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
go test fuzz v1
2+
string("/0")
3+
string("/00")

0 commit comments

Comments
 (0)