github.com/anchore/syft@v1.38.2/internal/file/zip_file_traversal_test.go (about)

     1  //go:build !windows
     2  // +build !windows
     3  
     4  package file
     5  
     6  import (
     7  	"archive/zip"
     8  	"context"
     9  	"crypto/sha256"
    10  	"encoding/json"
    11  	"errors"
    12  	"fmt"
    13  	"io"
    14  	"os"
    15  	"path"
    16  	"path/filepath"
    17  	"strings"
    18  	"testing"
    19  
    20  	"github.com/go-test/deep"
    21  	"github.com/stretchr/testify/assert"
    22  	"github.com/stretchr/testify/require"
    23  )
    24  
    25  func equal(r1, r2 io.Reader) (bool, error) {
    26  	w1 := sha256.New()
    27  	w2 := sha256.New()
    28  	n1, err1 := io.Copy(w1, r1)
    29  	if err1 != nil {
    30  		return false, err1
    31  	}
    32  	n2, err2 := io.Copy(w2, r2)
    33  	if err2 != nil {
    34  		return false, err2
    35  	}
    36  
    37  	var b1, b2 [sha256.Size]byte
    38  	copy(b1[:], w1.Sum(nil))
    39  	copy(b2[:], w2.Sum(nil))
    40  
    41  	return n1 != n2 || b1 == b2, nil
    42  }
    43  
    44  func TestUnzipToDir(t *testing.T) {
    45  	cwd, err := os.Getwd()
    46  	if err != nil {
    47  		t.Fatal(err)
    48  	}
    49  
    50  	goldenRootDir := filepath.Join(cwd, "test-fixtures")
    51  	sourceDirPath := path.Join(goldenRootDir, "zip-source")
    52  	archiveFilePath := setupZipFileTest(t, sourceDirPath, false)
    53  
    54  	unzipDestinationDir := t.TempDir()
    55  
    56  	t.Logf("content path: %s", unzipDestinationDir)
    57  
    58  	expectedPaths := len(expectedZipArchiveEntries)
    59  	observedPaths := 0
    60  
    61  	err = UnzipToDir(context.Background(), archiveFilePath, unzipDestinationDir)
    62  	if err != nil {
    63  		t.Fatalf("unable to unzip archive: %+v", err)
    64  	}
    65  
    66  	// compare the source dir tree and the unzipped tree
    67  	err = filepath.Walk(unzipDestinationDir,
    68  		func(path string, info os.FileInfo, err error) error {
    69  			// We don't unzip the root archive dir, since there's no archive entry for it
    70  			if path != unzipDestinationDir {
    71  				t.Logf("unzipped path: %s", path)
    72  				observedPaths++
    73  			}
    74  
    75  			if err != nil {
    76  				t.Fatalf("this should not happen")
    77  				return err
    78  			}
    79  
    80  			goldenPath := filepath.Join(sourceDirPath, strings.TrimPrefix(path, unzipDestinationDir))
    81  
    82  			if info.IsDir() {
    83  				i, err := os.Stat(goldenPath)
    84  				if err != nil {
    85  					t.Fatalf("unable to stat golden path: %+v", err)
    86  				}
    87  				if !i.IsDir() {
    88  					t.Fatalf("mismatched file types: %s", goldenPath)
    89  				}
    90  				return nil
    91  			}
    92  
    93  			// this is a file, not a dir...
    94  
    95  			testFile, err := os.Open(path)
    96  			if err != nil {
    97  				t.Fatalf("unable to open test file=%s :%+v", path, err)
    98  			}
    99  
   100  			goldenFile, err := os.Open(goldenPath)
   101  			if err != nil {
   102  				t.Fatalf("unable to open golden file=%s :%+v", goldenPath, err)
   103  			}
   104  
   105  			same, err := equal(testFile, goldenFile)
   106  			if err != nil {
   107  				t.Fatalf("could not compare files (%s, %s): %+v", goldenPath, path, err)
   108  			}
   109  
   110  			if !same {
   111  				t.Errorf("paths are not the same (%s, %s)", goldenPath, path)
   112  			}
   113  
   114  			return nil
   115  		})
   116  
   117  	if err != nil {
   118  		t.Errorf("failed to walk dir: %+v", err)
   119  	}
   120  
   121  	if observedPaths != expectedPaths {
   122  		t.Errorf("missed test paths: %d != %d", observedPaths, expectedPaths)
   123  	}
   124  }
   125  
   126  func TestContentsFromZip(t *testing.T) {
   127  	tests := []struct {
   128  		name        string
   129  		archivePrep func(tb testing.TB) string
   130  	}{
   131  		{
   132  			name:        "standard, non-nested zip",
   133  			archivePrep: prepZipSourceFixture,
   134  		},
   135  		{
   136  			name:        "zip with prepended bytes",
   137  			archivePrep: prependZipSourceFixtureWithString(t, "junk at the beginning of the file..."),
   138  		},
   139  	}
   140  
   141  	for _, test := range tests {
   142  		t.Run(test.name, func(t *testing.T) {
   143  			archivePath := test.archivePrep(t)
   144  			expected := zipSourceFixtureExpectedContents()
   145  
   146  			var paths []string
   147  			for p := range expected {
   148  				paths = append(paths, p)
   149  			}
   150  
   151  			actual, err := ContentsFromZip(context.Background(), archivePath, paths...)
   152  			if err != nil {
   153  				t.Fatalf("unable to extract from unzip archive: %+v", err)
   154  			}
   155  
   156  			assertZipSourceFixtureContents(t, actual, expected)
   157  		})
   158  	}
   159  }
   160  
   161  func prependZipSourceFixtureWithString(tb testing.TB, value string) func(tb testing.TB) string {
   162  	if len(value) == 0 {
   163  		tb.Fatalf("no bytes given to prefix")
   164  	}
   165  	return func(t testing.TB) string {
   166  		archivePath := prepZipSourceFixture(t)
   167  
   168  		// create a temp file
   169  		tmpFile, err := os.CreateTemp(tb.TempDir(), "syft-ziputil-prependZipSourceFixtureWithString-")
   170  		if err != nil {
   171  			t.Fatalf("unable to create tempfile: %+v", err)
   172  		}
   173  		defer tmpFile.Close()
   174  
   175  		// write value to the temp file
   176  		if _, err := tmpFile.WriteString(value); err != nil {
   177  			t.Fatalf("unable to write to tempfile: %+v", err)
   178  		}
   179  
   180  		// open the original archive
   181  		sourceFile, err := os.Open(archivePath)
   182  		if err != nil {
   183  			t.Fatalf("unable to read source file: %+v", err)
   184  		}
   185  
   186  		// copy all contents from the archive to the temp file
   187  		if _, err := io.Copy(tmpFile, sourceFile); err != nil {
   188  			t.Fatalf("unable to copy source to dest: %+v", err)
   189  		}
   190  
   191  		sourceFile.Close()
   192  
   193  		// remove the original archive and replace it with the temp file
   194  		if err := os.Remove(archivePath); err != nil {
   195  			t.Fatalf("unable to remove original source archive (%q): %+v", archivePath, err)
   196  		}
   197  
   198  		if err := os.Rename(tmpFile.Name(), archivePath); err != nil {
   199  			t.Fatalf("unable to move new archive to old path (%q): %+v", tmpFile.Name(), err)
   200  		}
   201  
   202  		return archivePath
   203  	}
   204  }
   205  
   206  func prepZipSourceFixture(t testing.TB) string {
   207  	t.Helper()
   208  	archivePrefix := path.Join(t.TempDir(), "syft-ziputil-prepZipSourceFixture-")
   209  
   210  	// the zip utility will add ".zip" to the end of the given name
   211  	archivePath := archivePrefix + ".zip"
   212  
   213  	t.Logf("archive path: %s", archivePath)
   214  
   215  	createZipArchive(t, "zip-source", archivePrefix, false)
   216  
   217  	return archivePath
   218  }
   219  
   220  func zipSourceFixtureExpectedContents() map[string]string {
   221  	return map[string]string{
   222  		filepath.Join("some-dir", "a-file.txt"): "A file! nice!",
   223  		filepath.Join("b-file.txt"):             "B file...",
   224  	}
   225  }
   226  
   227  func assertZipSourceFixtureContents(t testing.TB, actual map[string]string, expected map[string]string) {
   228  	t.Helper()
   229  	diffs := deep.Equal(actual, expected)
   230  	if len(diffs) > 0 {
   231  		for _, d := range diffs {
   232  			t.Errorf("diff: %+v", d)
   233  		}
   234  
   235  		b, err := json.MarshalIndent(actual, "", "  ")
   236  		if err != nil {
   237  			t.Fatalf("can't show results: %+v", err)
   238  		}
   239  
   240  		t.Errorf("full result: %s", string(b))
   241  	}
   242  }
   243  
   244  // looks like there isn't a helper for this yet? https://github.com/stretchr/testify/issues/497
   245  func assertErrorAs(expectedErr interface{}) assert.ErrorAssertionFunc {
   246  	return func(t assert.TestingT, actualErr error, i ...interface{}) bool {
   247  		return errors.As(actualErr, &expectedErr)
   248  	}
   249  }
   250  
   251  func TestSafeJoin(t *testing.T) {
   252  	tests := []struct {
   253  		prefix       string
   254  		args         []string
   255  		expected     string
   256  		errAssertion assert.ErrorAssertionFunc
   257  	}{
   258  		// go cases...
   259  		{
   260  			prefix: "/a/place",
   261  			args: []string{
   262  				"somewhere/else",
   263  			},
   264  			expected:     "/a/place/somewhere/else",
   265  			errAssertion: assert.NoError,
   266  		},
   267  		{
   268  			prefix: "/a/place",
   269  			args: []string{
   270  				"somewhere/../else",
   271  			},
   272  			expected:     "/a/place/else",
   273  			errAssertion: assert.NoError,
   274  		},
   275  		{
   276  			prefix: "/a/../place",
   277  			args: []string{
   278  				"somewhere/else",
   279  			},
   280  			expected:     "/place/somewhere/else",
   281  			errAssertion: assert.NoError,
   282  		},
   283  		// zip slip examples....
   284  		{
   285  			prefix: "/a/place",
   286  			args: []string{
   287  				"../../../etc/passwd",
   288  			},
   289  			expected:     "",
   290  			errAssertion: assertErrorAs(&errZipSlipDetected{}),
   291  		},
   292  		{
   293  			prefix: "/a/place",
   294  			args: []string{
   295  				"../",
   296  				"../",
   297  			},
   298  			expected:     "",
   299  			errAssertion: assertErrorAs(&errZipSlipDetected{}),
   300  		},
   301  		{
   302  			prefix: "/a/place",
   303  			args: []string{
   304  				"../",
   305  			},
   306  			expected:     "",
   307  			errAssertion: assertErrorAs(&errZipSlipDetected{}),
   308  		},
   309  	}
   310  
   311  	for _, test := range tests {
   312  		t.Run(fmt.Sprintf("%+v:%+v", test.prefix, test.args), func(t *testing.T) {
   313  			actual, err := SafeJoin(test.prefix, test.args...)
   314  			test.errAssertion(t, err)
   315  			assert.Equal(t, test.expected, actual)
   316  		})
   317  	}
   318  }
   319  
   320  // TestSymlinkProtection demonstrates that SafeJoin protects against symlink-based
   321  // directory traversal attacks by validating that archive entry paths cannot escape
   322  // the extraction directory.
   323  func TestSafeJoin_SymlinkProtection(t *testing.T) {
   324  	tests := []struct {
   325  		name        string
   326  		archivePath string // Path as it would appear in the archive
   327  		expectError bool
   328  		description string
   329  	}{
   330  		{
   331  			name:        "path traversal via ../",
   332  			archivePath: "../../../outside/file.txt",
   333  			expectError: true,
   334  			description: "Archive entry with ../ trying to escape extraction dir",
   335  		},
   336  		{
   337  			name:        "absolute path symlink target",
   338  			archivePath: "../../../sensitive.txt",
   339  			expectError: true,
   340  			description: "Simulates symlink pointing outside via relative path",
   341  		},
   342  		{
   343  			name:        "safe relative path within extraction dir",
   344  			archivePath: "subdir/safe.txt",
   345  			expectError: false,
   346  			description: "Normal file path that stays within extraction directory",
   347  		},
   348  		{
   349  			name:        "safe path with internal ../",
   350  			archivePath: "dir1/../dir2/file.txt",
   351  			expectError: false,
   352  			description: "Path with ../ that still resolves within extraction dir",
   353  		},
   354  		{
   355  			name:        "deeply nested traversal",
   356  			archivePath: "../../../../../../tmp/evil.txt",
   357  			expectError: true,
   358  			description: "Multiple levels of ../ trying to escape",
   359  		},
   360  		{
   361  			name:        "single parent directory escape",
   362  			archivePath: "../",
   363  			expectError: true,
   364  			description: "Simple one-level escape attempt",
   365  		},
   366  	}
   367  
   368  	for _, tt := range tests {
   369  		t.Run(tt.name, func(t *testing.T) {
   370  			// Create temp directories to simulate extraction scenario
   371  			tmpDir := t.TempDir()
   372  			extractDir := filepath.Join(tmpDir, "extract")
   373  			outsideDir := filepath.Join(tmpDir, "outside")
   374  
   375  			require.NoError(t, os.MkdirAll(extractDir, 0755))
   376  			require.NoError(t, os.MkdirAll(outsideDir, 0755))
   377  
   378  			// Create a file outside extraction dir that an attacker might target
   379  			outsideFile := filepath.Join(outsideDir, "sensitive.txt")
   380  			require.NoError(t, os.WriteFile(outsideFile, []byte("sensitive data"), 0644))
   381  
   382  			// Test SafeJoin - this is what happens when processing archive entries
   383  			result, err := SafeJoin(extractDir, tt.archivePath)
   384  
   385  			if tt.expectError {
   386  				// Should block malicious paths
   387  				require.Error(t, err, "Expected SafeJoin to reject malicious path")
   388  				var zipSlipErr *errZipSlipDetected
   389  				assert.ErrorAs(t, err, &zipSlipErr, "Error should be errZipSlipDetected type")
   390  				assert.Empty(t, result, "Result should be empty for blocked paths")
   391  			} else {
   392  				// Should allow safe paths
   393  				require.NoError(t, err, "Expected SafeJoin to allow safe path")
   394  				assert.NotEmpty(t, result, "Result should not be empty for safe paths")
   395  				assert.True(t, strings.HasPrefix(filepath.Clean(result), filepath.Clean(extractDir)),
   396  					"Safe path should resolve within extraction directory")
   397  			}
   398  		})
   399  	}
   400  }
   401  
   402  // TestUnzipToDir_SymlinkAttacks tests UnzipToDir function with malicious ZIP archives
   403  // containing symlink entries that attempt path traversal attacks.
   404  //
   405  // EXPECTED BEHAVIOR: UnzipToDir should either:
   406  //  1. Detect and reject symlinks explicitly with a security error, OR
   407  //  2. Extract them safely (library converts symlinks to regular files)
   408  func TestUnzipToDir_SymlinkAttacks(t *testing.T) {
   409  	tests := []struct {
   410  		name        string
   411  		symlinkName string
   412  		fileName    string
   413  		errContains string
   414  	}{
   415  		{
   416  			name:        "direct symlink to outside directory",
   417  			symlinkName: "evil_link",
   418  			fileName:    "evil_link/payload.txt",
   419  			errContains: "not a directory", // attempt to write through symlink leaf (which is not a directory)
   420  		},
   421  		{
   422  			name:        "directory symlink attack",
   423  			symlinkName: "safe_dir/link",
   424  			fileName:    "safe_dir/link/payload.txt",
   425  			errContains: "not a directory", // attempt to write through symlink (which is not a directory)
   426  		},
   427  		{
   428  			name:        "symlink without payload file",
   429  			symlinkName: "standalone_link",
   430  			fileName:    "", // no payload file
   431  			errContains: "", // no error expected, symlink without payload is safe
   432  		},
   433  	}
   434  
   435  	for _, tt := range tests {
   436  		t.Run(tt.name, func(t *testing.T) {
   437  			tempDir := t.TempDir()
   438  
   439  			// create outside target directory
   440  			outsideDir := filepath.Join(tempDir, "outside_target")
   441  			require.NoError(t, os.MkdirAll(outsideDir, 0755))
   442  
   443  			// create extraction directory
   444  			extractDir := filepath.Join(tempDir, "extract")
   445  			require.NoError(t, os.MkdirAll(extractDir, 0755))
   446  
   447  			maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, outsideDir, tt.fileName)
   448  
   449  			err := UnzipToDir(context.Background(), maliciousZip, extractDir)
   450  
   451  			// check error expectations
   452  			if tt.errContains != "" {
   453  				require.Error(t, err)
   454  				require.Contains(t, err.Error(), tt.errContains)
   455  			} else {
   456  				require.NoError(t, err)
   457  			}
   458  
   459  			analyzeExtractionDirectory(t, extractDir)
   460  
   461  			// check if payload file escaped extraction directory
   462  			if tt.fileName != "" {
   463  				maliciousFile := filepath.Join(outsideDir, filepath.Base(tt.fileName))
   464  				checkFileOutsideExtraction(t, maliciousFile)
   465  			}
   466  
   467  			// check if symlink was created pointing outside
   468  			symlinkPath := filepath.Join(extractDir, tt.symlinkName)
   469  			checkSymlinkCreation(t, symlinkPath, extractDir, outsideDir)
   470  		})
   471  	}
   472  }
   473  
   474  // TestContentsFromZip_SymlinkAttacks tests the ContentsFromZip function with malicious
   475  // ZIP archives containing symlink entries.
   476  //
   477  // EXPECTED BEHAVIOR: ContentsFromZip should either:
   478  //  1. Reject symlinks explicitly, OR
   479  //  2. Return empty content for symlinks (library behavior)
   480  //
   481  // Though ContentsFromZip doesn't write to disk, but if symlinks are followed, it could read sensitive
   482  // files from outside the archive.
   483  func TestContentsFromZip_SymlinkAttacks(t *testing.T) {
   484  	tests := []struct {
   485  		name          string
   486  		symlinkName   string
   487  		symlinkTarget string
   488  		requestPath   string
   489  		errContains   string
   490  	}{
   491  		{
   492  			name:          "request symlink entry directly",
   493  			symlinkName:   "evil_link",
   494  			symlinkTarget: "/etc/hosts", // attempt to read sensitive file
   495  			requestPath:   "evil_link",
   496  			errContains:   "", // no error expected - library returns symlink metadata
   497  		},
   498  		{
   499  			name:          "symlink in nested directory",
   500  			symlinkName:   "nested/link",
   501  			symlinkTarget: "/etc/hosts",
   502  			requestPath:   "nested/link",
   503  			errContains:   "", // no error expected - library returns symlink metadata
   504  		},
   505  	}
   506  
   507  	for _, tt := range tests {
   508  		t.Run(tt.name, func(t *testing.T) {
   509  			tempDir := t.TempDir()
   510  
   511  			// create malicious ZIP with symlink entry (no payload file needed)
   512  			maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "")
   513  
   514  			contents, err := ContentsFromZip(context.Background(), maliciousZip, tt.requestPath)
   515  
   516  			// check error expectations
   517  			if tt.errContains != "" {
   518  				require.Error(t, err)
   519  				require.Contains(t, err.Error(), tt.errContains)
   520  				return
   521  			}
   522  			require.NoError(t, err)
   523  
   524  			// verify symlink handling - library should return symlink target as content (metadata)
   525  			content, found := contents[tt.requestPath]
   526  			require.True(t, found, "symlink entry should be found in results")
   527  
   528  			// verify symlink was NOT followed (content should be target path or empty)
   529  			if content != "" && content != tt.symlinkTarget {
   530  				// content is not empty and not the symlink target - check if actual file was read
   531  				if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil {
   532  					targetContent, readErr := os.ReadFile(tt.symlinkTarget)
   533  					if readErr == nil && string(targetContent) == content {
   534  						t.Errorf("critical issue!... symlink was FOLLOWED and external file content was read!")
   535  						t.Logf("  symlink: %s → %s", tt.requestPath, tt.symlinkTarget)
   536  						t.Logf("  content length: %d bytes", len(content))
   537  					}
   538  				}
   539  			}
   540  		})
   541  	}
   542  }
   543  
   544  // TestExtractFromZipToUniqueTempFile_SymlinkAttacks tests the ExtractFromZipToUniqueTempFile
   545  // function with malicious ZIP archives containing symlink entries.
   546  //
   547  // EXPECTED BEHAVIOR: ExtractFromZipToUniqueTempFile should either:
   548  //  1. Reject symlinks explicitly, OR
   549  //  2. Extract them safely (library converts to empty files, filepath.Base sanitizes names)
   550  //
   551  // This function uses filepath.Base() on the archive entry name for temp file prefix and
   552  // os.CreateTemp() which creates files in the specified directory, so it should be protected.
   553  func TestExtractFromZipToUniqueTempFile_SymlinkAttacks(t *testing.T) {
   554  	tests := []struct {
   555  		name          string
   556  		symlinkName   string
   557  		symlinkTarget string
   558  		requestPath   string
   559  		errContains   string
   560  	}{
   561  		{
   562  			name:          "extract symlink entry to temp file",
   563  			symlinkName:   "evil_link",
   564  			symlinkTarget: "/etc/passwd",
   565  			requestPath:   "evil_link",
   566  			errContains:   "", // no error expected - library extracts symlink metadata
   567  		},
   568  		{
   569  			name:          "extract nested symlink",
   570  			symlinkName:   "nested/dir/link",
   571  			symlinkTarget: "/tmp/outside",
   572  			requestPath:   "nested/dir/link",
   573  			errContains:   "", // no error expected
   574  		},
   575  		{
   576  			name:          "extract path traversal symlink name",
   577  			symlinkName:   "../../escape",
   578  			symlinkTarget: "/tmp/outside",
   579  			requestPath:   "../../escape",
   580  			errContains:   "", // no error expected - filepath.Base sanitizes name
   581  		},
   582  	}
   583  
   584  	for _, tt := range tests {
   585  		t.Run(tt.name, func(t *testing.T) {
   586  			tempDir := t.TempDir()
   587  
   588  			maliciousZip := createMaliciousZipWithSymlink(t, tempDir, tt.symlinkName, tt.symlinkTarget, "")
   589  
   590  			// create temp directory for extraction
   591  			extractTempDir := filepath.Join(tempDir, "temp_extract")
   592  			require.NoError(t, os.MkdirAll(extractTempDir, 0755))
   593  
   594  			openers, err := ExtractFromZipToUniqueTempFile(context.Background(), maliciousZip, extractTempDir, tt.requestPath)
   595  
   596  			// check error expectations
   597  			if tt.errContains != "" {
   598  				require.Error(t, err)
   599  				require.Contains(t, err.Error(), tt.errContains)
   600  				return
   601  			}
   602  			require.NoError(t, err)
   603  
   604  			// verify symlink was extracted
   605  			opener, found := openers[tt.requestPath]
   606  			require.True(t, found, "symlink entry should be extracted")
   607  
   608  			// verify temp file is within temp directory
   609  			tempFilePath := opener.path
   610  			cleanTempDir := filepath.Clean(extractTempDir)
   611  			cleanTempFile := filepath.Clean(tempFilePath)
   612  			require.True(t, strings.HasPrefix(cleanTempFile, cleanTempDir),
   613  				"temp file must be within temp directory: %s not in %s", cleanTempFile, cleanTempDir)
   614  
   615  			// verify symlink was NOT followed (content should be target path or empty)
   616  			f, openErr := opener.Open()
   617  			require.NoError(t, openErr)
   618  			defer f.Close()
   619  
   620  			content, readErr := io.ReadAll(f)
   621  			require.NoError(t, readErr)
   622  
   623  			// check if symlink was followed (content matches actual file)
   624  			if len(content) > 0 && string(content) != tt.symlinkTarget {
   625  				if _, statErr := os.Stat(tt.symlinkTarget); statErr == nil {
   626  					targetContent, readErr := os.ReadFile(tt.symlinkTarget)
   627  					if readErr == nil && string(targetContent) == string(content) {
   628  						t.Errorf("critical issue!... symlink was FOLLOWED and external file content was copied!")
   629  						t.Logf("  symlink: %s → %s", tt.requestPath, tt.symlinkTarget)
   630  						t.Logf("  content length: %d bytes", len(content))
   631  					}
   632  				}
   633  			}
   634  		})
   635  	}
   636  }
   637  
   638  // forensicFindings contains the results of analyzing an extraction directory
   639  type forensicFindings struct {
   640  	symlinksFound          []forensicSymlink
   641  	regularFiles           []string
   642  	directories            []string
   643  	symlinkVulnerabilities []string
   644  }
   645  
   646  type forensicSymlink struct {
   647  	path              string
   648  	target            string
   649  	escapesExtraction bool
   650  	resolvedPath      string
   651  }
   652  
   653  // analyzeExtractionDirectory walks the extraction directory and detects symlinks that point
   654  // outside the extraction directory. It is silent unless vulnerabilities are found.
   655  func analyzeExtractionDirectory(t *testing.T, extractDir string) forensicFindings {
   656  	t.Helper()
   657  
   658  	findings := forensicFindings{}
   659  
   660  	filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
   661  		if err != nil {
   662  			// only log if there's an error walking the directory
   663  			t.Logf("Error walking %s: %v", path, err)
   664  			return nil
   665  		}
   666  
   667  		relPath := strings.TrimPrefix(path, extractDir+"/")
   668  		if relPath == "" {
   669  			relPath = "."
   670  		}
   671  
   672  		// use Lstat to detect symlinks without following them
   673  		linfo, lerr := os.Lstat(path)
   674  		if lerr == nil && linfo.Mode()&os.ModeSymlink != 0 {
   675  			target, _ := os.Readlink(path)
   676  
   677  			// resolve to see where it actually points
   678  			var resolvedPath string
   679  			var escapesExtraction bool
   680  
   681  			if filepath.IsAbs(target) {
   682  				// absolute symlink
   683  				resolvedPath = target
   684  				cleanExtractDir := filepath.Clean(extractDir)
   685  				escapesExtraction = !strings.HasPrefix(filepath.Clean(target), cleanExtractDir)
   686  
   687  				if escapesExtraction {
   688  					t.Errorf("critical issue!... absolute symlink created: %s → %s", relPath, target)
   689  					t.Logf("  this symlink points outside the extraction directory")
   690  					findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities,
   691  						fmt.Sprintf("absolute symlink: %s → %s", relPath, target))
   692  				}
   693  			} else {
   694  				// relative symlink - resolve it
   695  				resolvedPath = filepath.Join(filepath.Dir(path), target)
   696  				cleanResolved := filepath.Clean(resolvedPath)
   697  				cleanExtractDir := filepath.Clean(extractDir)
   698  
   699  				escapesExtraction = !strings.HasPrefix(cleanResolved, cleanExtractDir)
   700  
   701  				if escapesExtraction {
   702  					t.Errorf("critical issue!... symlink escapes extraction dir: %s → %s", relPath, target)
   703  					t.Logf("  symlink resolves to: %s (outside extraction directory)", cleanResolved)
   704  					findings.symlinkVulnerabilities = append(findings.symlinkVulnerabilities,
   705  						fmt.Sprintf("relative symlink escape: %s → %s (resolves to %s)", relPath, target, cleanResolved))
   706  				}
   707  			}
   708  
   709  			findings.symlinksFound = append(findings.symlinksFound, forensicSymlink{
   710  				path:              relPath,
   711  				target:            target,
   712  				escapesExtraction: escapesExtraction,
   713  				resolvedPath:      resolvedPath,
   714  			})
   715  		} else {
   716  			// regular file or directory - collect silently
   717  			if info.IsDir() {
   718  				findings.directories = append(findings.directories, relPath)
   719  			} else {
   720  				findings.regularFiles = append(findings.regularFiles, relPath)
   721  			}
   722  		}
   723  		return nil
   724  	})
   725  
   726  	return findings
   727  }
   728  
   729  // checkFileOutsideExtraction checks if a file was written outside the extraction directory.
   730  // Returns true if the file exists (vulnerability), false otherwise. Silent on success.
   731  func checkFileOutsideExtraction(t *testing.T, filePath string) bool {
   732  	t.Helper()
   733  
   734  	if stat, err := os.Stat(filePath); err == nil {
   735  		content, _ := os.ReadFile(filePath)
   736  		t.Errorf("critical issue!... file written OUTSIDE extraction directory!")
   737  		t.Logf("  location: %s", filePath)
   738  		t.Logf("  size: %d bytes", stat.Size())
   739  		t.Logf("  content: %s", string(content))
   740  		t.Logf("  ...this means an attacker can write files to arbitrary locations on the filesystem")
   741  		return true
   742  	}
   743  	// no file found outside extraction directory...
   744  	return false
   745  }
   746  
   747  // checkSymlinkCreation verifies if a symlink was created at the expected path and reports
   748  // whether it points outside the extraction directory. Silent unless a symlink is found.
   749  func checkSymlinkCreation(t *testing.T, symlinkPath, extractDir, expectedTarget string) bool {
   750  	t.Helper()
   751  
   752  	if linfo, err := os.Lstat(symlinkPath); err == nil {
   753  		if linfo.Mode()&os.ModeSymlink != 0 {
   754  			target, _ := os.Readlink(symlinkPath)
   755  
   756  			if expectedTarget != "" && target == expectedTarget {
   757  				t.Errorf("critical issue!... symlink pointing outside extraction dir was created!")
   758  				t.Logf("  Symlink: %s → %s", symlinkPath, target)
   759  				return true
   760  			}
   761  
   762  			// Check if it escapes even if target doesn't match expected
   763  			if filepath.IsAbs(target) {
   764  				cleanExtractDir := filepath.Clean(extractDir)
   765  				if !strings.HasPrefix(filepath.Clean(target), cleanExtractDir) {
   766  					t.Errorf("critical issue!... absolute symlink escapes extraction dir!")
   767  					t.Logf("  symlink: %s → %s", symlinkPath, target)
   768  					return true
   769  				}
   770  			}
   771  		}
   772  		// if it exists but is not a symlink, that's good (attack was thwarted)...
   773  	}
   774  
   775  	return false
   776  }
   777  
   778  // createMaliciousZipWithSymlink creates a ZIP archive containing a symlink entry pointing to an arbitrary target,
   779  // followed by a file entry that attempts to write through that symlink.
   780  // returns the path to the created ZIP archive.
   781  func createMaliciousZipWithSymlink(t *testing.T, tempDir, symlinkName, symlinkTarget, fileName string) string {
   782  	t.Helper()
   783  
   784  	maliciousZip := filepath.Join(tempDir, "malicious.zip")
   785  	zipFile, err := os.Create(maliciousZip)
   786  	require.NoError(t, err)
   787  	defer zipFile.Close()
   788  
   789  	zw := zip.NewWriter(zipFile)
   790  
   791  	// create parent directories if the symlink is nested
   792  	if dir := filepath.Dir(symlinkName); dir != "." {
   793  		dirHeader := &zip.FileHeader{
   794  			Name:   dir + "/",
   795  			Method: zip.Store,
   796  		}
   797  		dirHeader.SetMode(os.ModeDir | 0755)
   798  		_, err = zw.CreateHeader(dirHeader)
   799  		require.NoError(t, err)
   800  	}
   801  
   802  	// create symlink entry pointing outside extraction directory
   803  	// note: ZIP format stores symlinks as regular files with the target path as content
   804  	symlinkHeader := &zip.FileHeader{
   805  		Name:   symlinkName,
   806  		Method: zip.Store,
   807  	}
   808  	symlinkHeader.SetMode(os.ModeSymlink | 0755)
   809  
   810  	symlinkWriter, err := zw.CreateHeader(symlinkHeader)
   811  	require.NoError(t, err)
   812  
   813  	// write the symlink target as the file content (this is how ZIP stores symlinks)
   814  	_, err = symlinkWriter.Write([]byte(symlinkTarget))
   815  	require.NoError(t, err)
   816  
   817  	// create file entry that will be written through the symlink
   818  	if fileName != "" {
   819  		payloadContent := []byte("MALICIOUS PAYLOAD - This should NOT be written outside extraction dir!")
   820  		payloadHeader := &zip.FileHeader{
   821  			Name:   fileName,
   822  			Method: zip.Deflate,
   823  		}
   824  		payloadHeader.SetMode(0644)
   825  
   826  		payloadWriter, err := zw.CreateHeader(payloadHeader)
   827  		require.NoError(t, err)
   828  
   829  		_, err = payloadWriter.Write(payloadContent)
   830  		require.NoError(t, err)
   831  	}
   832  
   833  	require.NoError(t, zw.Close())
   834  	require.NoError(t, zipFile.Close())
   835  
   836  	return maliciousZip
   837  }