mirror of
https://github.com/anchore/syft.git
synced 2026-03-30 05:33:24 +02:00
* migrate fixtures to testdata
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* fix: correct broken symlinks after testdata migration
The migration from test-fixtures to testdata broke several symlinks:
- elf-test-fixtures symlinks pointed to old test-fixtures paths
- elf-test-fixtures needed to be renamed to elf-testdata
- image-pkg-coverage symlink pointed to test-fixtures instead of testdata
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* fix: handle missing classifiers/bin directory in Makefile
The clean-fingerprint target was failing when classifiers/bin doesn't
exist (e.g., on fresh clone without downloaded binaries).
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* fix: add gitignore negation for jar/zip fixtures in test/cli
The jar and zip files in test/cli/testdata/image-unknowns were being
gitignored by the root .gitignore patterns. This caused them to be
untracked and not included when building docker images in CI, resulting
in Test_Unknowns failures since the test expects errors from corrupt
archive files that weren't present.
Add a .gitignore in test/cli/testdata to negate the exclusions for
these specific test fixture files.
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* switch fixture cache to v2
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* test: update expected versions for rebuilt fixtures
Update test expectations for packages that have been updated in
upstream repositories when docker images are rebuilt:
- glibc: 2.42-r4 → 2.43-r1 (wolfi)
- php: 8.2.29 → 8.2.30 (ubuntu/apache)
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* upgrade go
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* fix: add go-shlex dependency for testdata manager tool
The manager tool in syft/pkg/cataloger/binary/testdata/ imports
go-shlex, but since it's in a testdata directory, Go doesn't track
its dependencies. This caused CI failures when go.mod didn't
explicitly list the dependency.
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* refactor: move binary classifier manager to internal/
Move the manager tool from testdata/manager to internal/manager so
that Go properly tracks its dependencies. Code in testdata directories
is ignored by Go for dependency tracking, which caused CI failures
when go.mod didn't explicitly list transitive dependencies.
This is a cleaner solution than manually adding dependencies to go.mod
for code that happens to live in testdata.
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* fix: add gitignore negations for test fixtures blocked by root patterns
Multiple test fixtures were being blocked by root-level gitignore patterns
like bin/, *.jar, *.tar, and *.exe. This adds targeted .gitignore files with
negation patterns to allow these specific test fixtures to be tracked:
- syft/linux/testdata/os/busybox/bin/busybox (blocked by bin/)
- syft/pkg/cataloger/java/testdata/corrupt/example.{jar,tar} (blocked by *.jar, *.tar)
- syft/pkg/cataloger/binary/testdata/classifiers/snippets/go-version-hint/**/bin/go (blocked by bin/)
- syft/pkg/cataloger/bitnami/testdata/no-rel/.../bin/redis-server (blocked by bin/)
Also updates the bitnami test expectation to include the newly required
.gitignore files in the test fixture.
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* test: update glibc version expectation (2.43-r1 -> 2.43-r2)
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* add capability drift check as unit step
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* dont clear test observations before drift detection
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
* bump stereoscope commit to main
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
---------
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
838 lines
25 KiB
Go
838 lines
25 KiB
Go
//go:build !windows
|
|
// +build !windows
|
|
|
|
package file
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-test/deep"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func equal(r1, r2 io.Reader) (bool, error) {
|
|
w1 := sha256.New()
|
|
w2 := sha256.New()
|
|
n1, err1 := io.Copy(w1, r1)
|
|
if err1 != nil {
|
|
return false, err1
|
|
}
|
|
n2, err2 := io.Copy(w2, r2)
|
|
if err2 != nil {
|
|
return false, err2
|
|
}
|
|
|
|
var b1, b2 [sha256.Size]byte
|
|
copy(b1[:], w1.Sum(nil))
|
|
copy(b2[:], w2.Sum(nil))
|
|
|
|
return n1 != n2 || b1 == b2, nil
|
|
}
|
|
|
|
func TestUnzipToDir(t *testing.T) {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
goldenRootDir := filepath.Join(cwd, "testdata")
|
|
sourceDirPath := path.Join(goldenRootDir, "zip-source")
|
|
archiveFilePath := setupZipFileTest(t, sourceDirPath, false)
|
|
|
|
unzipDestinationDir := t.TempDir()
|
|
|
|
t.Logf("content path: %s", unzipDestinationDir)
|
|
|
|
expectedPaths := len(expectedZipArchiveEntries)
|
|
observedPaths := 0
|
|
|
|
err = UnzipToDir(context.Background(), archiveFilePath, unzipDestinationDir)
|
|
if err != nil {
|
|
t.Fatalf("unable to unzip archive: %+v", err)
|
|
}
|
|
|
|
// compare the source dir tree and the unzipped tree
|
|
err = filepath.Walk(unzipDestinationDir,
|
|
func(path string, info os.FileInfo, err error) error {
|
|
// We don't unzip the root archive dir, since there's no archive entry for it
|
|
if path != unzipDestinationDir {
|
|
t.Logf("unzipped path: %s", path)
|
|
observedPaths++
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("this should not happen")
|
|
return err
|
|
}
|
|
|
|
goldenPath := filepath.Join(sourceDirPath, strings.TrimPrefix(path, unzipDestinationDir))
|
|
|
|
if info.IsDir() {
|
|
i, err := os.Stat(goldenPath)
|
|
if err != nil {
|
|
t.Fatalf("unable to stat golden path: %+v", err)
|
|
}
|
|
if !i.IsDir() {
|
|
t.Fatalf("mismatched file types: %s", goldenPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// this is a file, not a dir...
|
|
|
|
testFile, err := os.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("unable to open test file=%s :%+v", path, err)
|
|
}
|
|
|
|
goldenFile, err := os.Open(goldenPath)
|
|
if err != nil {
|
|
t.Fatalf("unable to open golden file=%s :%+v", goldenPath, err)
|
|
}
|
|
|
|
same, err := equal(testFile, goldenFile)
|
|
if err != nil {
|
|
t.Fatalf("could not compare files (%s, %s): %+v", goldenPath, path, err)
|
|
}
|
|
|
|
if !same {
|
|
t.Errorf("paths are not the same (%s, %s)", goldenPath, path)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
t.Errorf("failed to walk dir: %+v", err)
|
|
}
|
|
|
|
if observedPaths != expectedPaths {
|
|
t.Errorf("missed test paths: %d != %d", observedPaths, expectedPaths)
|
|
}
|
|
}
|
|
|
|
func TestContentsFromZip(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
archivePrep func(tb testing.TB) string
|
|
}{
|
|
{
|
|
name: "standard, non-nested zip",
|
|
archivePrep: prepZipSourceFixture,
|
|
},
|
|
{
|
|
name: "zip with prepended bytes",
|
|
archivePrep: prependZipSourceFixtureWithString(t, "junk at the beginning of the file..."),
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
archivePath := test.archivePrep(t)
|
|
expected := zipSourceFixtureExpectedContents()
|
|
|
|
var paths []string
|
|
for p := range expected {
|
|
paths = append(paths, p)
|
|
}
|
|
|
|
actual, err := ContentsFromZip(context.Background(), archivePath, paths...)
|
|
if err != nil {
|
|
t.Fatalf("unable to extract from unzip archive: %+v", err)
|
|
}
|
|
|
|
assertZipSourceFixtureContents(t, actual, expected)
|
|
})
|
|
}
|
|
}
|
|
|
|
func prependZipSourceFixtureWithString(tb testing.TB, value string) func(tb testing.TB) string {
|
|
if len(value) == 0 {
|
|
tb.Fatalf("no bytes given to prefix")
|
|
}
|
|
return func(t testing.TB) string {
|
|
archivePath := prepZipSourceFixture(t)
|
|
|
|
// create a temp file
|
|
tmpFile, err := os.CreateTemp(tb.TempDir(), "syft-ziputil-prependZipSourceFixtureWithString-")
|
|
if err != nil {
|
|
t.Fatalf("unable to create tempfile: %+v", err)
|
|
}
|
|
defer tmpFile.Close()
|
|
|
|
// write value to the temp file
|
|
if _, err := tmpFile.WriteString(value); err != nil {
|
|
t.Fatalf("unable to write to tempfile: %+v", err)
|
|
}
|
|
|
|
// open the original archive
|
|
sourceFile, err := os.Open(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("unable to read source file: %+v", err)
|
|
}
|
|
|
|
// copy all contents from the archive to the temp file
|
|
if _, err := io.Copy(tmpFile, sourceFile); err != nil {
|
|
t.Fatalf("unable to copy source to dest: %+v", err)
|
|
}
|
|
|
|
sourceFile.Close()
|
|
|
|
// remove the original archive and replace it with the temp file
|
|
if err := os.Remove(archivePath); err != nil {
|
|
t.Fatalf("unable to remove original source archive (%q): %+v", archivePath, err)
|
|
}
|
|
|
|
if err := os.Rename(tmpFile.Name(), archivePath); err != nil {
|
|
t.Fatalf("unable to move new archive to old path (%q): %+v", tmpFile.Name(), err)
|
|
}
|
|
|
|
return archivePath
|
|
}
|
|
}
|
|
|
|
func prepZipSourceFixture(t testing.TB) string {
|
|
t.Helper()
|
|
archivePrefix := path.Join(t.TempDir(), "syft-ziputil-prepZipSourceFixture-")
|
|
|
|
// the zip utility will add ".zip" to the end of the given name
|
|
archivePath := archivePrefix + ".zip"
|
|
|
|
t.Logf("archive path: %s", archivePath)
|
|
|
|
createZipArchive(t, "zip-source", archivePrefix, false)
|
|
|
|
return archivePath
|
|
}
|
|
|
|
func zipSourceFixtureExpectedContents() map[string]string {
|
|
return map[string]string{
|
|
filepath.Join("some-dir", "a-file.txt"): "A file! nice!",
|
|
filepath.Join("b-file.txt"): "B file...",
|
|
}
|
|
}
|
|
|
|
func assertZipSourceFixtureContents(t testing.TB, actual map[string]string, expected map[string]string) {
|
|
t.Helper()
|
|
diffs := deep.Equal(actual, expected)
|
|
if len(diffs) > 0 {
|
|
for _, d := range diffs {
|
|
t.Errorf("diff: %+v", d)
|
|
}
|
|
|
|
b, err := json.MarshalIndent(actual, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("can't show results: %+v", err)
|
|
}
|
|
|
|
t.Errorf("full result: %s", string(b))
|
|
}
|
|
}
|
|
|
|
// looks like there isn't a helper for this yet? https://github.com/stretchr/testify/issues/497
|
|
func assertErrorAs(expectedErr interface{}) assert.ErrorAssertionFunc {
|
|
return func(t assert.TestingT, actualErr error, i ...interface{}) bool {
|
|
return errors.As(actualErr, &expectedErr)
|
|
}
|
|
}
|
|
|
|
func TestSafeJoin(t *testing.T) {
|
|
tests := []struct {
|
|
prefix string
|
|
args []string
|
|
expected string
|
|
errAssertion assert.ErrorAssertionFunc
|
|
}{
|
|
// go cases...
|
|
{
|
|
prefix: "/a/place",
|
|
args: []string{
|
|
"somewhere/else",
|
|
},
|
|
expected: "/a/place/somewhere/else",
|
|
errAssertion: assert.NoError,
|
|
},
|
|
{
|
|
prefix: "/a/place",
|
|
args: []string{
|
|
"somewhere/../else",
|
|
},
|
|
expected: "/a/place/else",
|
|
errAssertion: assert.NoError,
|
|
},
|
|
{
|
|
prefix: "/a/../place",
|
|
args: []string{
|
|
"somewhere/else",
|
|
},
|
|
expected: "/place/somewhere/else",
|
|
errAssertion: assert.NoError,
|
|
},
|
|
// zip slip examples....
|
|
{
|
|
prefix: "/a/place",
|
|
args: []string{
|
|
"../../../etc/passwd",
|
|
},
|
|
expected: "",
|
|
errAssertion: assertErrorAs(&errZipSlipDetected{}),
|
|
},
|
|
{
|
|
prefix: "/a/place",
|
|
args: []string{
|
|
"../",
|
|
"../",
|
|
},
|
|
expected: "",
|
|
errAssertion: assertErrorAs(&errZipSlipDetected{}),
|
|
},
|
|
{
|
|
prefix: "/a/place",
|
|
args: []string{
|
|
"../",
|
|
},
|
|
expected: "",
|
|
errAssertion: assertErrorAs(&errZipSlipDetected{}),
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(fmt.Sprintf("%+v:%+v", test.prefix, test.args), func(t *testing.T) {
|
|
actual, err := SafeJoin(test.prefix, test.args...)
|
|
test.errAssertion(t, err)
|
|
assert.Equal(t, test.expected, actual)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSafeJoin_SymlinkProtection demonstrates that SafeJoin protects against
|
|
// directory traversal attacks by validating that archive entry paths cannot escape
|
|
// the extraction directory.
|
|
func TestSafeJoin_SymlinkProtection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
archivePath string // Path as it would appear in the archive
|
|
expectError bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "path traversal via ../",
|
|
archivePath: "../../../outside/file.txt",
|
|
expectError: true,
|
|
description: "Archive entry with ../ trying to escape extraction dir",
|
|
},
|
|
{
|
|
name: "absolute path symlink target",
|
|
archivePath: "../../../sensitive.txt",
|
|
expectError: true,
|
|
description: "Simulates symlink pointing outside via relative path",
|
|
},
|
|
{
|
|
name: "safe relative path within extraction dir",
|
|
archivePath: "subdir/safe.txt",
|
|
expectError: false,
|
|
description: "Normal file path that stays within extraction directory",
|
|
},
|
|
{
|
|
name: "safe path with internal ../",
|
|
archivePath: "dir1/../dir2/file.txt",
|
|
expectError: false,
|
|
description: "Path with ../ that still resolves within extraction dir",
|
|
},
|
|
{
|
|
name: "deeply nested traversal",
|
|
archivePath: "../../../../../../tmp/evil.txt",
|
|
expectError: true,
|
|
description: "Multiple levels of ../ trying to escape",
|
|
},
|
|
{
|
|
name: "single parent directory escape",
|
|
archivePath: "../",
|
|
expectError: true,
|
|
description: "Simple one-level escape attempt",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create temp directories to simulate extraction scenario
|
|
tmpDir := t.TempDir()
|
|
extractDir := filepath.Join(tmpDir, "extract")
|
|
outsideDir := filepath.Join(tmpDir, "outside")
|
|
|
|
require.NoError(t, os.MkdirAll(extractDir, 0755))
|
|
require.NoError(t, os.MkdirAll(outsideDir, 0755))
|
|
|
|
// Create a file outside extraction dir that an attacker might target
|
|
outsideFile := filepath.Join(outsideDir, "sensitive.txt")
|
|
require.NoError(t, os.WriteFile(outsideFile, []byte("sensitive data"), 0644))
|
|
|
|
// Test SafeJoin - this is what happens when processing archive entries
|
|
result, err := SafeJoin(extractDir, tt.archivePath)
|
|
|
|
if tt.expectError {
|
|
// Should block malicious paths
|
|
require.Error(t, err, "Expected SafeJoin to reject malicious path")
|
|
var zipSlipErr *errZipSlipDetected
|
|
assert.ErrorAs(t, err, &zipSlipErr, "Error should be errZipSlipDetected type")
|
|
assert.Empty(t, result, "Result should be empty for blocked paths")
|
|
} else {
|
|
// Should allow safe paths
|
|
require.NoError(t, err, "Expected SafeJoin to allow safe path")
|
|
assert.NotEmpty(t, result, "Result should not be empty for safe paths")
|
|
assert.True(t, strings.HasPrefix(filepath.Clean(result), filepath.Clean(extractDir)),
|
|
"Safe path should resolve within extraction directory")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnzipToDir_SymlinkAttacks tests UnzipToDir function with malicious ZIP archives
|
|
// containing symlink entries that attempt path traversal attacks.
|
|
//
|
|
// EXPECTED BEHAVIOR: UnzipToDir should either:
|
|
// 1. Detect and reject symlinks explicitly with a security error, OR
|
|
// 2. Extract them safely (library converts symlinks to regular files)
|
|
func TestUnzipToDir_SymlinkAttacks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
symlinkName string
|
|
fileName string
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "direct symlink to outside directory",
|
|
symlinkName: "evil_link",
|
|
fileName: "evil_link/payload.txt",
|
|
errContains: "not a directory", // attempt to write through symlink leaf (which is not a directory)
|
|
},
|
|
{
|
|
name: "directory symlink attack",
|
|
symlinkName: "safe_dir/link",
|
|
fileName: "safe_dir/link/payload.txt",
|
|
errContains: "not a directory", // attempt to write through symlink (which is not a directory)
|
|
},
|
|
{
|
|
name: "symlink without payload file",
|
|
symlinkName: "standalone_link",
|
|
fileName: "", // no payload file
|
|
errContains: "", // no error expected, symlink without payload is safe
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
// create outside target directory
|
|
outsideDir := filepath.Join(tempDir, "outside_target")
|
|
require.NoError(t, os.MkdirAll(outsideDir, 0755))
|
|
|
|
// create extraction directory
|
|
extractDir := filepath.Join(tempDir, "extract")
|
|
require.NoError(t, os.MkdirAll(extractDir, 0755))
|
|
|
|
maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, outsideDir, tt.fileName)
|
|
|
|
err := UnzipToDir(context.Background(), maliciousZip, extractDir)
|
|
|
|
// check error expectations
|
|
if tt.errContains != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.errContains)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
analyzeExtractionDirectory(t, extractDir)
|
|
|
|
// check if payload file escaped extraction directory
|
|
if tt.fileName != "" {
|
|
maliciousFile := filepath.Join(outsideDir, filepath.Base(tt.fileName))
|
|
checkFileOutsideExtraction(t, maliciousFile)
|
|
}
|
|
|
|
// check if symlink was created pointing outside
|
|
symlinkPath := filepath.Join(extractDir, tt.symlinkName)
|
|
checkSymlinkCreation(t, symlinkPath, extractDir, outsideDir)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestContentsFromZip_SymlinkAttacks tests the ContentsFromZip function with malicious
|
|
// ZIP archives containing symlink entries.
|
|
//
|
|
// EXPECTED BEHAVIOR: ContentsFromZip should either:
|
|
// 1. Reject symlinks explicitly, OR
|
|
// 2. Return empty content for symlinks (library behavior)
|
|
//
|
|
// Though ContentsFromZip doesn't write to disk, but if symlinks are followed, it could read sensitive
|
|
// files from outside the archive.
|
|
func TestContentsFromZip_SymlinkAttacks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
symlinkName string
|
|
symlinkTarget string
|
|
requestPath string
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "request symlink entry directly",
|
|
symlinkName: "evil_link",
|
|
symlinkTarget: "/etc/hosts", // attempt to read sensitive file
|
|
requestPath: "evil_link",
|
|
errContains: "", // no error expected - library returns symlink metadata
|
|
},
|
|
{
|
|
name: "symlink in nested directory",
|
|
symlinkName: "nested/link",
|
|
symlinkTarget: "/etc/hosts",
|
|
requestPath: "nested/link",
|
|
errContains: "", // no error expected - library returns symlink metadata
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
// create malicious ZIP with symlink entry (no payload file needed)
|
|
maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "")
|
|
|
|
contents, err := ContentsFromZip(context.Background(), maliciousZip, tt.requestPath)
|
|
|
|
// check error expectations
|
|
if tt.errContains != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.errContains)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
// verify symlink handling - library should return symlink target as content (metadata)
|
|
content, found := contents[tt.requestPath]
|
|
require.True(t, found, "symlink entry should be found in results")
|
|
|
|
// verify symlink was NOT followed (content should be target path or empty)
|
|
if content != "" && content != tt.symlinkTarget {
|
|
// content is not empty and not the symlink target - check if actual file was read
|
|
if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil {
|
|
targetContent, readErr := os.ReadFile(tt.symlinkTarget)
|
|
if readErr == nil && string(targetContent) == content {
|
|
t.Errorf("critical issue!... symlink was FOLLOWED and external file content was read!")
|
|
t.Logf(" symlink: %s → %s", tt.requestPath, tt.symlinkTarget)
|
|
t.Logf(" content length: %d bytes", len(content))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExtractFromZipToUniqueTempFile_SymlinkAttacks tests the ExtractFromZipToUniqueTempFile
|
|
// function with malicious ZIP archives containing symlink entries.
|
|
//
|
|
// EXPECTED BEHAVIOR: ExtractFromZipToUniqueTempFile should either:
|
|
// 1. Reject symlinks explicitly, OR
|
|
// 2. Extract them safely (library converts to empty files, filepath.Base sanitizes names)
|
|
//
|
|
// This function uses filepath.Base() on the archive entry name for temp file prefix and
|
|
// os.CreateTemp() which creates files in the specified directory, so it should be protected.
|
|
func TestExtractFromZipToUniqueTempFile_SymlinkAttacks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
symlinkName string
|
|
symlinkTarget string
|
|
requestPath string
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "extract symlink entry to temp file",
|
|
symlinkName: "evil_link",
|
|
symlinkTarget: "/etc/passwd",
|
|
requestPath: "evil_link",
|
|
errContains: "", // no error expected - library extracts symlink metadata
|
|
},
|
|
{
|
|
name: "extract nested symlink",
|
|
symlinkName: "nested/dir/link",
|
|
symlinkTarget: "/tmp/outside",
|
|
requestPath: "nested/dir/link",
|
|
errContains: "", // no error expected
|
|
},
|
|
{
|
|
name: "extract path traversal symlink name",
|
|
symlinkName: "../../escape",
|
|
symlinkTarget: "/tmp/outside",
|
|
requestPath: "../../escape",
|
|
errContains: "", // no error expected - filepath.Base sanitizes name
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "")
|
|
|
|
// create temp directory for extraction
|
|
extractTempDir := filepath.Join(tempDir, "temp_extract")
|
|
require.NoError(t, os.MkdirAll(extractTempDir, 0755))
|
|
|
|
openers, err := ExtractFromZipToUniqueTempFile(context.Background(), maliciousZip, extractTempDir, tt.requestPath)
|
|
|
|
// check error expectations
|
|
if tt.errContains != "" {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.errContains)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
// verify symlink was extracted
|
|
opener, found := openers[tt.requestPath]
|
|
require.True(t, found, "symlink entry should be extracted")
|
|
|
|
// verify temp file is within temp directory
|
|
tempFilePath := opener.path
|
|
cleanTempDir := filepath.Clean(extractTempDir)
|
|
cleanTempFile := filepath.Clean(tempFilePath)
|
|
require.True(t, strings.HasPrefix(cleanTempFile, cleanTempDir),
|
|
"temp file must be within temp directory: %s not in %s", cleanTempFile, cleanTempDir)
|
|
|
|
// verify symlink was NOT followed (content should be target path or empty)
|
|
f, openErr := opener.Open()
|
|
require.NoError(t, openErr)
|
|
defer f.Close()
|
|
|
|
content, readErr := io.ReadAll(f)
|
|
require.NoError(t, readErr)
|
|
|
|
// check if symlink was followed (content matches actual file)
|
|
if len(content) > 0 && string(content) != tt.symlinkTarget {
|
|
if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil {
|
|
targetContent, readErr := os.ReadFile(tt.symlinkTarget)
|
|
if readErr == nil && string(targetContent) == string(content) {
|
|
t.Errorf("critical issue!... symlink was FOLLOWED and external file content was copied!")
|
|
t.Logf(" symlink: %s → %s", tt.requestPath, tt.symlinkTarget)
|
|
t.Logf(" content length: %d bytes", len(content))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// forensicFindings contains the results of analyzing an extraction directory
|
|
type forensicFindings struct {
|
|
symlinksFound []forensicSymlink
|
|
regularFiles []string
|
|
directories []string
|
|
symlinkVulnerabilities []string
|
|
}
|
|
|
|
type forensicSymlink struct {
|
|
path string
|
|
target string
|
|
escapesExtraction bool
|
|
resolvedPath string
|
|
}
|
|
|
|
// analyzeExtractionDirectory walks the extraction directory and detects symlinks that point
|
|
// outside the extraction directory. It is silent unless vulnerabilities are found.
|
|
func analyzeExtractionDirectory(t *testing.T, extractDir string) forensicFindings {
|
|
t.Helper()
|
|
|
|
findings := forensicFindings{}
|
|
|
|
filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
// only log if there's an error walking the directory
|
|
t.Logf("Error walking %s: %v", path, err)
|
|
return nil
|
|
}
|
|
|
|
relPath := strings.TrimPrefix(path, extractDir+"/")
|
|
if relPath == "" {
|
|
relPath = "."
|
|
}
|
|
|
|
// use Lstat to detect symlinks without following them
|
|
linfo, lerr := os.Lstat(path)
|
|
if lerr == nil && linfo.Mode()&os.ModeSymlink != 0 {
|
|
target, _ := os.Readlink(path)
|
|
|
|
// resolve to see where it actually points
|
|
var resolvedPath string
|
|
var escapesExtraction bool
|
|
|
|
if filepath.IsAbs(target) {
|
|
// absolute symlink
|
|
resolvedPath = target
|
|
cleanExtractDir := filepath.Clean(extractDir)
|
|
escapesExtraction = !strings.HasPrefix(filepath.Clean(target), cleanExtractDir)
|
|
|
|
if escapesExtraction {
|
|
t.Errorf("critical issue!... absolute symlink created: %s → %s", relPath, target)
|
|
t.Logf(" this symlink points outside the extraction directory")
|
|
findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities,
|
|
fmt.Sprintf("absolute symlink: %s → %s", relPath, target))
|
|
}
|
|
} else {
|
|
// relative symlink - resolve it
|
|
resolvedPath = filepath.Join(filepath.Dir(path), target)
|
|
cleanResolved := filepath.Clean(resolvedPath)
|
|
cleanExtractDir := filepath.Clean(extractDir)
|
|
|
|
escapesExtraction = !strings.HasPrefix(cleanResolved, cleanExtractDir)
|
|
|
|
if escapesExtraction {
|
|
t.Errorf("critical issue!... symlink escapes extraction dir: %s → %s", relPath, target)
|
|
t.Logf(" symlink resolves to: %s (outside extraction directory)", cleanResolved)
|
|
findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities,
|
|
fmt.Sprintf("relative symlink escape: %s → %s (resolves to %s)", relPath, target, cleanResolved))
|
|
}
|
|
}
|
|
|
|
findings.symlinksFound = append(findings.symlinksFound, forensicSymlink{
|
|
path: relPath,
|
|
target: target,
|
|
escapesExtraction: escapesExtraction,
|
|
resolvedPath: resolvedPath,
|
|
})
|
|
} else {
|
|
// regular file or directory - collect silently
|
|
if info.IsDir() {
|
|
findings.directories = append(findings.directories, relPath)
|
|
} else {
|
|
findings.regularFiles = append(findings.regularFiles, relPath)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
return findings
|
|
}
|
|
|
|
// checkFileOutsideExtraction checks if a file was written outside the extraction directory.
|
|
// Returns true if the file exists (vulnerability), false otherwise. Silent on success.
|
|
func checkFileOutsideExtraction(t *testing.T, filePath string) bool {
|
|
t.Helper()
|
|
|
|
if stat, err := os.Stat(filePath); err == nil {
|
|
content, _ := os.ReadFile(filePath)
|
|
t.Errorf("critical issue!... file written OUTSIDE extraction directory!")
|
|
t.Logf(" location: %s", filePath)
|
|
t.Logf(" size: %d bytes", stat.Size())
|
|
t.Logf(" content: %s", string(content))
|
|
t.Logf(" ...this means an attacker can write files to arbitrary locations on the filesystem")
|
|
return true
|
|
}
|
|
// no file found outside extraction directory...
|
|
return false
|
|
}
|
|
|
|
// checkSymlinkCreation verifies if a symlink was created at the expected path and reports
|
|
// whether it points outside the extraction directory. Silent unless a symlink is found.
|
|
func checkSymlinkCreation(t *testing.T, symlinkPath, extractDir, expectedTarget string) bool {
|
|
t.Helper()
|
|
|
|
if linfo, err := os.Lstat(symlinkPath); err == nil {
|
|
if linfo.Mode()&os.ModeSymlink != 0 {
|
|
target, _ := os.Readlink(symlinkPath)
|
|
|
|
if expectedTarget != "" && target == expectedTarget {
|
|
t.Errorf("critical issue!... symlink pointing outside extraction dir was created!")
|
|
t.Logf(" Symlink: %s → %s", symlinkPath, target)
|
|
return true
|
|
}
|
|
|
|
// Check if it escapes even if target doesn't match expected
|
|
if filepath.IsAbs(target) {
|
|
cleanExtractDir := filepath.Clean(extractDir)
|
|
if !strings.HasPrefix(filepath.Clean(target), cleanExtractDir) {
|
|
t.Errorf("critical issue!... absolute symlink escapes extraction dir!")
|
|
t.Logf(" symlink: %s → %s", symlinkPath, target)
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
// if it exists but is not a symlink, that's good (attack was thwarted)...
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// createMaliciousZipWithSymlink creates a ZIP archive containing a symlink entry pointing to an arbitrary target,
|
|
// followed by a file entry that attempts to write through that symlink.
|
|
// returns the path to the created ZIP archive.
|
|
func createMaliciousZipWithSymlink(t *testing.T, tempDir, symlinkName, symlinkTarget, fileName string) string {
|
|
t.Helper()
|
|
|
|
maliciousZip := filepath.Join(tempDir, "malicious.zip")
|
|
zipFile, err := os.Create(maliciousZip)
|
|
require.NoError(t, err)
|
|
defer zipFile.Close()
|
|
|
|
zw := zip.NewWriter(zipFile)
|
|
|
|
// create parent directories if the symlink is nested
|
|
if dir := filepath.Dir(symlinkName); dir != "." {
|
|
dirHeader := &zip.FileHeader{
|
|
Name: dir + "/",
|
|
Method: zip.Store,
|
|
}
|
|
dirHeader.SetMode(os.ModeDir | 0755)
|
|
_, err = zw.CreateHeader(dirHeader)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// create symlink entry pointing outside extraction directory
|
|
// note: ZIP format stores symlinks as regular files with the target path as content
|
|
symlinkHeader := &zip.FileHeader{
|
|
Name: symlinkName,
|
|
Method: zip.Store,
|
|
}
|
|
symlinkHeader.SetMode(os.ModeSymlink | 0755)
|
|
|
|
symlinkWriter, err := zw.CreateHeader(symlinkHeader)
|
|
require.NoError(t, err)
|
|
|
|
// write the symlink target as the file content (this is how ZIP stores symlinks)
|
|
_, err = symlinkWriter.Write([]byte(symlinkTarget))
|
|
require.NoError(t, err)
|
|
|
|
// create file entry that will be written through the symlink
|
|
if fileName != "" {
|
|
payloadContent := []byte("MALICIOUS PAYLOAD - This should NOT be written outside extraction dir!")
|
|
payloadHeader := &zip.FileHeader{
|
|
Name: fileName,
|
|
Method: zip.Deflate,
|
|
}
|
|
payloadHeader.SetMode(0644)
|
|
|
|
payloadWriter, err := zw.CreateHeader(payloadHeader)
|
|
require.NoError(t, err)
|
|
|
|
_, err = payloadWriter.Write(payloadContent)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
require.NoError(t, zw.Close())
|
|
require.NoError(t, zipFile.Close())
|
|
|
|
return maliciousZip
|
|
}
|