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
24 changes: 11 additions & 13 deletions pkg/action/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,15 +519,6 @@ func formatReportKey(res ScanResult, fr *malcontent.FileReport, isReport bool) s
return formatKey(res, CleanPath(fr.Path, res.tmpRoot))
}

func behaviorExists(b *malcontent.Behavior, behaviors []*malcontent.Behavior) bool {
for _, tb := range behaviors {
if b.ID == tb.ID {
return true
}
}
return false
}

// fileDiff handles files that exist in both source and destination.
func fileDiff(ctx context.Context, c malcontent.Config, fr, tr *malcontent.FileReport, rpath, apath string, d *malcontent.DiffReport, src ScanResult, dest ScanResult, archiveOrImage, isReport, isMoved bool) {
if ctx.Err() != nil {
Expand Down Expand Up @@ -563,17 +554,26 @@ func fileDiff(ctx context.Context, c malcontent.Config, fr, tr *malcontent.FileR
abs.PreviousPath = fr.Path
}

srcBehaviorIDs := make(map[string]struct{}, len(fr.Behaviors))
for _, b := range fr.Behaviors {
srcBehaviorIDs[b.ID] = struct{}{}
}
destBehaviorIDs := make(map[string]struct{}, len(tr.Behaviors))
for _, b := range tr.Behaviors {
destBehaviorIDs[b.ID] = struct{}{}
}

// if destination behavior is not in the source
for _, tb := range tr.Behaviors {
if !behaviorExists(tb, fr.Behaviors) {
if _, ok := srcBehaviorIDs[tb.ID]; !ok {
tb.DiffAdded = true
abs.Behaviors = append(abs.Behaviors, tb)
}
}

// if source behavior is not in the destination
for _, fb := range fr.Behaviors {
if !behaviorExists(fb, tr.Behaviors) {
if _, ok := destBehaviorIDs[fb.ID]; !ok {
fb.DiffRemoved = true
abs.Behaviors = append(abs.Behaviors, fb)
}
Expand Down Expand Up @@ -758,8 +758,6 @@ func formatKey(res ScanResult, name string) string {
switch {
case res.imageURI != "":
return fmt.Sprintf("%s ∴ %s", res.imageURI, name)
case res.tmpRoot != "":
return fmt.Sprintf("%s ∴ %s", res.tmpRoot, name)
case res.base != "":
return fmt.Sprintf("%s ∴ %s", res.base, name)
default:
Expand Down
4 changes: 2 additions & 2 deletions pkg/action/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,10 @@ func TestFormatKey(t *testing.T) {
want: "/bin/ls",
},
{
name: "with tmpRoot prepends tmpRoot",
name: "with tmpRoot falls through to plain path",
res: ScanResult{tmpRoot: "/tmp/extract", imageURI: ""},
file: "/tmp/extract/bin/ls",
want: "/tmp/extract ∴ /tmp/extract/bin/ls",
want: "/tmp/extract/bin/ls",
},
{
name: "with image URI prepends imageURI",
Expand Down
41 changes: 27 additions & 14 deletions pkg/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,31 @@ func evalSymlinks(path string) (string, bool) {
return resolved, true
}

// symlinkEscapesDir checks whether a symlink at target resolves outside dir.
func symlinkEscapesDir(target, dir string) bool {
fi, err := os.Lstat(target)
if err != nil || fi.Mode()&os.ModeSymlink == 0 {
return false
}

evalTarget, err := filepath.EvalSymlinks(target)
if err != nil {
// Dangling symlinks (target doesn't exist) are not path traversals.
return !errors.Is(err, fs.ErrNotExist)
}

evalDir, err := filepath.EvalSymlinks(dir)
if err != nil {
return true
}

rel, err := filepath.Rel(evalDir, evalTarget)
if err != nil {
return false
}
return rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// isValidPath checks if the target file is within the given directory.
func IsValidPath(target, dir string) bool {
if strings.Contains(target, "\x00") || strings.Contains(dir, "\x00") {
Expand All @@ -69,20 +94,8 @@ func IsValidPath(target, dir string) bool {
cleanTarget := filepath.Clean(target)
cleanDir := filepath.Clean(dir)

// avoid evaluating symlinks if the target is not a symlink
if fi, err := os.Lstat(cleanTarget); err == nil && fi.Mode()&os.ModeSymlink == os.ModeSymlink {
var evalTarget, evalDir, rel string

if evalTarget, err = filepath.EvalSymlinks(cleanTarget); err != nil {
return false
}
if evalDir, err = filepath.EvalSymlinks(cleanDir); err != nil {
return false
}
if rel, err = filepath.Rel(evalDir, evalTarget); err == nil &&
(rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
return false
}
if symlinkEscapesDir(cleanTarget, cleanDir) {
return false
}

switch {
Expand Down
33 changes: 32 additions & 1 deletion pkg/archive/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import (
"github.com/chainguard-dev/malcontent/pkg/programkind"
)

// maxFuzzSize is the maximum input size for fuzz tests to stay well under
// Go's 100MB fuzzer shared memory capacity and avoid OOM in parsers.
const maxFuzzSize = 10 * 1024 * 1024

// readTestFile reads a file using file.GetContents for consistency with production code.
func readTestFile(path string) ([]byte, error) {
f, err := os.Open(path)
Expand Down Expand Up @@ -52,6 +56,9 @@ func FuzzExtractTar(f *testing.F) {
f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}) // gzip magic bytes only

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-tar-*.tar.gz")
if err != nil {
t.Skip("failed to create temp file")
Expand Down Expand Up @@ -107,6 +114,9 @@ func FuzzExtractZip(f *testing.F) {
f.Add([]byte{0x50, 0x4b, 0x03, 0x04}) // full zip signature

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-zip-*.zip")
if err != nil {
t.Skip("failed to create temp file")
Expand Down Expand Up @@ -196,6 +206,9 @@ func FuzzExtractArchive(f *testing.F) {
f.Add([]byte{0x1f, 0x8b, 0x08, 0x00}, ".gz") // gzip header

f.Fuzz(func(t *testing.T, data []byte, ext string) {
if len(data) > maxFuzzSize {
return
}
if _, ok := programkind.ArchiveMap[ext]; !ok {
return
}
Expand Down Expand Up @@ -294,6 +307,9 @@ func FuzzExtractGzip(f *testing.F) {
f.Add(make([]byte, 1024*1024)) // large zeros (compression bomb test)

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-gz-*.gz")
if err != nil {
t.Skip()
Expand Down Expand Up @@ -338,6 +354,9 @@ func FuzzExtractBz2(f *testing.F) {
f.Add(make([]byte, 1024*1024)) // large zeros

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-bz2-*.bz2")
if err != nil {
t.Skip()
Expand Down Expand Up @@ -390,6 +409,9 @@ func FuzzExtractZstd(f *testing.F) {
f.Add(make([]byte, 1024*1024)) // large zeros

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-zst-*.zst")
if err != nil {
t.Skip()
Expand Down Expand Up @@ -444,6 +466,9 @@ func FuzzExtractZlib(f *testing.F) {
f.Add(make([]byte, 1024*1024)) // large zeros

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-zlib-*.zlib")
if err != nil {
t.Skip()
Expand Down Expand Up @@ -497,7 +522,7 @@ func FuzzExtractRPM(f *testing.F) {
f.Add([]byte("not rpm")) // invalid

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) < 96 || !bytes.Equal(data[:4], rpmMagic) {
if len(data) < 96 || len(data) > maxFuzzSize || !bytes.Equal(data[:4], rpmMagic) {
return
}

Expand Down Expand Up @@ -552,6 +577,9 @@ func FuzzExtractDeb(f *testing.F) {
f.Add([]byte("not deb")) // invalid

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-deb-*.deb")
if err != nil {
t.Skip()
Expand Down Expand Up @@ -593,6 +621,9 @@ func FuzzExtractUPX(f *testing.F) {
f.Add([]byte("not upx")) // invalid

f.Fuzz(func(t *testing.T, data []byte) {
if len(data) > maxFuzzSize {
return
}
tmpFile, err := os.CreateTemp("", "fuzz-upx-*")
if err != nil {
t.Skip()
Expand Down
53 changes: 53 additions & 0 deletions pkg/archive/symlink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package archive

import (
"archive/tar"
"bytes"
"context"
"os"
"path/filepath"
Expand Down Expand Up @@ -195,6 +197,57 @@ func TestExtractNestedArchiveCollision(t *testing.T) {
}
}

// TestDanglingSymlinkExtraction verifies that a tar containing a dangling symlink
// (target doesn't exist) extracts without error and all extracted paths pass IsValidPath.
func TestDanglingSymlinkExtraction(t *testing.T) {
t.Parallel()

// Build a tar in memory with a dangling symlink (points to nonexistent file within dir)
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tw.WriteHeader(&tar.Header{
Name: "link",
Typeflag: tar.TypeSymlink,
Linkname: "nonexistent",
}); err != nil {
t.Fatal(err)
}
tw.Close()

tmpFile, err := os.CreateTemp("", "dangling-symlink-*.tar")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmpFile.Name())
tmpFile.Write(buf.Bytes())
tmpFile.Close()

tmpDir, err := os.MkdirTemp("", "dangling-symlink-extract-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)

// Extraction should succeed
if err := ExtractTar(context.Background(), tmpDir, tmpFile.Name()); err != nil {
t.Fatalf("ExtractTar failed on dangling symlink: %v", err)
}

// Every extracted path must pass IsValidPath (this is what the fuzzer checks)
err = filepath.WalkDir(tmpDir, func(path string, _ os.DirEntry, err error) error {
if err != nil {
return err
}
if !IsValidPath(path, tmpDir) {
t.Errorf("IsValidPath returned false for dangling symlink: %s", path)
}
return nil
})
if err != nil {
t.Fatalf("WalkDir failed: %v", err)
}
}

func TestHandleSymlink(t *testing.T) {
t.Parallel()
tmpDir, err := os.MkdirTemp("", "symlink-test-*")
Expand Down
9 changes: 8 additions & 1 deletion pkg/compile/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import (
"time"
)

// maxFuzzSize is the maximum input size for fuzz tests to stay well under
// Go's 100MB fuzzer shared memory capacity and avoid OOM in parsers.
const maxFuzzSize = 10 * 1024 * 1024

// FuzzRemoveRules tests the removeRules function with random inputs.
func FuzzRemoveRules(f *testing.F) {
for _, root := range getAllRuleFS() {
Expand Down Expand Up @@ -75,6 +79,9 @@ rule keep_me { condition: true }
`), "remove_me_1,remove_me_2")

f.Fuzz(func(t *testing.T, data []byte, rulesToRemove string) {
if len(data) > maxFuzzSize {
return
}
var rules []string
if rulesToRemove != "" {
rules = strings.Split(rulesToRemove, ",")
Expand Down Expand Up @@ -138,7 +145,7 @@ func FuzzRecursiveCompile(f *testing.F) {
floatPattern := regexp.MustCompile(`\d+\.\d+`)

f.Fuzz(func(_ *testing.T, data []byte) {
if len(data) > 50*1024*1024 {
if len(data) > maxFuzzSize {
return
}

Expand Down
9 changes: 6 additions & 3 deletions pkg/programkind/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import (
"github.com/chainguard-dev/malcontent/pkg/file"
)

// maxFuzzSize is the maximum input size for fuzz tests to stay well under
// Go's 100MB fuzzer shared memory capacity and avoid OOM in parsers.
const maxFuzzSize = 10 * 1024 * 1024

// FuzzFile tests file type detection with random inputs.
func FuzzFile(f *testing.F) {
// Limit seed file size to avoid excessive memory usage during fuzzing.
const maxSeedSize int64 = 50 * 1024 * 1024 // 50MB
const maxSeedSize int64 = maxFuzzSize

samplesDir := "../../out/chainguard-sandbox/malcontent-samples"
err := filepath.WalkDir(samplesDir, func(path string, d os.DirEntry, _ error) error {
Expand Down Expand Up @@ -64,7 +67,7 @@ func FuzzFile(f *testing.F) {
f.Add([]byte("UPX!"), "test.upx") // UPX magic

f.Fuzz(func(t *testing.T, data []byte, filename string) {
if len(data) > 50*1024*1024 {
if len(data) > maxFuzzSize {
return
}
if len(filename) > 255 || filepath.Clean(filename) != filename {
Expand Down
1 change: 1 addition & 0 deletions pkg/render/fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func FuzzRenderDifferential(f *testing.F) {
}

f.Fuzz(func(t *testing.T, riskLevel int8, filePath, behaviorName, behaviorDesc string, hasDiff bool) {
filePath = sanitizeUTF8(filePath)
if filePath == "" || yamlIgnore[filePath] {
return
}
Expand Down
26 changes: 1 addition & 25 deletions pkg/render/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,31 +48,7 @@ func (r JSON) Full(ctx context.Context, c *malcontent.Config, rep *malcontent.Re
if ctx.Err() != nil {
return false
}

if key == nil || value == nil {
return true
}
if path, ok := key.(string); ok {
if r, ok := value.(*malcontent.FileReport); ok {
if r.Skipped == "" {
r.ArchiveRoot = ""
r.FullPath = ""

cleanPath := sanitizeUTF8(path)

r.Path = sanitizeUTF8(r.Path)

for _, b := range r.Behaviors {
if b != nil {
b.ID = sanitizeUTF8(b.ID)
b.Description = sanitizeUTF8(b.Description)
}
}

jr.Files[cleanPath] = r
}
}
}
sanitizeFileReport(key, value, jr.Files)
return true
})

Expand Down
Loading