mirror of
https://github.com/anchore/syft.git
synced 2026-11-03 07:56:36 +01:00
feat(archive): add extractor interface and zip/tar implementations
Introduce the internal/archive package with a generic Extractor interface for extracting archive contents to a destination directory. Includes two implementations: - ZipExtractor: handles zip-based archives (.zip, .jar, .war, etc.) - TarExtractor: handles tar-based archives (.tar, .tar.gz, .tar.bz2, etc.) Both extractors enforce configurable safety limits and protect against common malicious-archive vectors: - Per-archive extraction size limit - Per-archive file count limit - Zip-slip path traversal prevention via existing SafeJoin - Symlink entries are written as real symlinks only when their target resolves inside destDir; symlinks pointing outside are dropped to avoid escape via symlink-then-write - Non-regular non-symlink entries (devices, fifos, sockets) are skipped because their contents can't safely round-trip to disk Also provides FindExtractor for auto-detecting archive format, and IsExcludedExtension for filtering by extension. Signed-off-by: Thomas Bechtold <thomasbechtold@jpberlin.de>
This commit is contained in:
@@ -0,0 +1,285 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mholt/archives"
|
||||||
|
|
||||||
|
intFile "github.com/anchore/syft/internal/file"
|
||||||
|
"github.com/anchore/syft/internal/log"
|
||||||
|
"github.com/anchore/syft/syft/cataloging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extractor extracts archive contents to a destination directory.
|
||||||
|
type Extractor interface {
|
||||||
|
// CanExtract returns true if this extractor can handle the given file.
|
||||||
|
CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool
|
||||||
|
|
||||||
|
// Extract extracts the archive contents to destDir, respecting the given limits.
|
||||||
|
// Returns the number of files extracted and total bytes written.
|
||||||
|
Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractionLimits defines safety limits for archive extraction.
|
||||||
|
type ExtractionLimits struct {
|
||||||
|
MaxExtractionSizeBytes int64
|
||||||
|
MaxFileCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractionResult holds the result of an extraction operation.
|
||||||
|
type ExtractionResult struct {
|
||||||
|
FilesExtracted int
|
||||||
|
BytesWritten int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultExtractionLimits returns limits from the given config.
|
||||||
|
func DefaultExtractionLimits(cfg cataloging.ArchiveSearchConfig) ExtractionLimits {
|
||||||
|
return ExtractionLimits{
|
||||||
|
MaxExtractionSizeBytes: cfg.MaxExtractionSizeBytes,
|
||||||
|
MaxFileCount: cfg.MaxFileCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultExtractors returns the set of built-in archive extractors.
|
||||||
|
func DefaultExtractors() []Extractor {
|
||||||
|
return []Extractor{
|
||||||
|
&ZipExtractor{},
|
||||||
|
&TarExtractor{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindExtractor finds an extractor that can handle the given file, or nil if none can.
|
||||||
|
func FindExtractor(ctx context.Context, extractors []Extractor, path string) Extractor {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Tracef("unable to open file for archive detection: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
for _, ext := range extractors {
|
||||||
|
if ext.CanExtract(ctx, path, f) {
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
// reset for next extractor
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
log.Tracef("unable to seek file for archive detection: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExcludedExtension checks if a file path has an excluded extension.
|
||||||
|
func IsExcludedExtension(path string, excludeExtensions []string) bool {
|
||||||
|
for _, ext := range excludeExtensions {
|
||||||
|
if strings.HasSuffix(strings.ToLower(path), strings.ToLower(ext)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZipExtractor extracts zip-based archives (zip, jar, war, ear, etc.).
|
||||||
|
type ZipExtractor struct{}
|
||||||
|
|
||||||
|
func (z *ZipExtractor) CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool {
|
||||||
|
format, _, err := intFile.IdentifyArchive(ctx, path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := format.(archives.Zip)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *ZipExtractor) Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) {
|
||||||
|
var result ExtractionResult
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("unable to open zip archive %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
return result, archives.Zip{}.Extract(ctx, f, func(_ context.Context, file archives.FileInfo) error {
|
||||||
|
if file.IsDir() {
|
||||||
|
joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.MkdirAll(joinedPath, file.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
if limits.MaxFileCount > 0 && result.FilesExtracted >= limits.MaxFileCount {
|
||||||
|
return fmt.Errorf("archive file count limit reached (%d files)", limits.MaxFileCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(joinedPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("unable to create parent dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractFileWithLimits(file, joinedPath, destDir, &result, limits)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TarExtractor extracts tar-based archives (tar, tar.gz, tar.bz2, tar.xz, tar.zst).
|
||||||
|
type TarExtractor struct{}
|
||||||
|
|
||||||
|
func (t *TarExtractor) CanExtract(ctx context.Context, path string, reader io.ReadSeeker) bool {
|
||||||
|
format, _, err := intFile.IdentifyArchive(ctx, path, reader)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// mholt/archives returns a compound type for tar+compression, check if it's an extractor but not a zip
|
||||||
|
if _, isZip := format.(archives.Zip); isZip {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := format.(archives.Extractor)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TarExtractor) Extract(ctx context.Context, path string, destDir string, limits ExtractionLimits) (ExtractionResult, error) {
|
||||||
|
var result ExtractionResult
|
||||||
|
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("unable to open tar archive %q: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
format, readerAfterIdentify, err := intFile.IdentifyArchive(ctx, path, f)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("unable to identify archive format for %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
extractor, ok := format.(archives.Extractor)
|
||||||
|
if !ok {
|
||||||
|
return result, fmt.Errorf("file format does not support extraction: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// use the reader returned by IdentifyArchive since it may have consumed some bytes
|
||||||
|
extractReader := readerAfterIdentify
|
||||||
|
if extractReader == nil {
|
||||||
|
extractReader = f
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, extractor.Extract(ctx, extractReader, func(_ context.Context, file archives.FileInfo) error {
|
||||||
|
if file.IsDir() {
|
||||||
|
joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.MkdirAll(joinedPath, file.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
if limits.MaxFileCount > 0 && result.FilesExtracted >= limits.MaxFileCount {
|
||||||
|
return fmt.Errorf("archive file count limit reached (%d files)", limits.MaxFileCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
joinedPath, err := intFile.SafeJoin(destDir, file.NameInArchive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(joinedPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("unable to create parent dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractFileWithLimits(file, joinedPath, destDir, &result, limits)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFileWithLimits extracts a single file from an archive, respecting size limits.
|
||||||
|
// Symlinks are written as real symlinks (only when the target stays inside destDir);
|
||||||
|
// non-regular file types other than symlinks are skipped to avoid writing bogus content.
|
||||||
|
func extractFileWithLimits(file archives.FileInfo, destPath, destDir string, result *ExtractionResult, limits ExtractionLimits) error {
|
||||||
|
mode := file.Mode()
|
||||||
|
if mode.Type()&fs.ModeSymlink != 0 {
|
||||||
|
if err := writeSafeSymlink(file.LinkTarget, destPath, destDir); err != nil {
|
||||||
|
log.WithFields("entry", file.NameInArchive, "target", file.LinkTarget, "error", err).Debug("skipping unsafe symlink in archive")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result.FilesExtracted++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !mode.IsRegular() {
|
||||||
|
// skip device nodes, fifos, sockets, etc. — they can't safely round-trip
|
||||||
|
log.WithFields("entry", file.NameInArchive, "mode", mode).Debug("skipping non-regular archive entry")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to open archive entry %q: %w", file.NameInArchive, err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create dest file %q: %w", destPath, err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
var reader io.Reader = src
|
||||||
|
|
||||||
|
// enforce per-archive total size limit
|
||||||
|
if limits.MaxExtractionSizeBytes > 0 {
|
||||||
|
remaining := limits.MaxExtractionSizeBytes - result.BytesWritten
|
||||||
|
if remaining <= 0 {
|
||||||
|
return fmt.Errorf("archive extraction size limit reached (%d bytes)", limits.MaxExtractionSizeBytes)
|
||||||
|
}
|
||||||
|
reader = io.LimitReader(src, remaining+1) // +1 to detect exceeding the limit
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := io.Copy(dst, reader)
|
||||||
|
result.BytesWritten += n
|
||||||
|
result.FilesExtracted++
|
||||||
|
|
||||||
|
if limits.MaxExtractionSizeBytes > 0 && result.BytesWritten > limits.MaxExtractionSizeBytes {
|
||||||
|
return fmt.Errorf("archive extraction size limit reached (%d bytes)", limits.MaxExtractionSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSafeSymlink creates a symlink at destPath only if target is relative and
|
||||||
|
// resolves — relative to destPath's directory — to a location inside destDir.
|
||||||
|
//
|
||||||
|
// Absolute targets are rejected outright: os.Symlink writes the literal target
|
||||||
|
// string, so an absolute target like "/etc/passwd" would resolve on the host
|
||||||
|
// filesystem when read, regardless of any safety check we performed against
|
||||||
|
// destDir at extraction time.
|
||||||
|
//
|
||||||
|
// NOTE: this guards against the single-symlink-escape pattern, but a malicious
|
||||||
|
// archive can still attempt a multi-step chain where two cooperating link
|
||||||
|
// entries together resolve outside destDir (e.g., one entry creates a benign
|
||||||
|
// link, a later entry writes through it). Fully preventing that requires
|
||||||
|
// either openat/O_NOFOLLOW per path component during extraction or
|
||||||
|
// transitively resolving every newly-created symlink against all prior ones
|
||||||
|
// — neither is implemented here.
|
||||||
|
func writeSafeSymlink(target, destPath, destDir string) error {
|
||||||
|
if target == "" {
|
||||||
|
return fmt.Errorf("empty link target")
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(target) {
|
||||||
|
return fmt.Errorf("absolute symlink target not allowed")
|
||||||
|
}
|
||||||
|
resolved := filepath.Join(filepath.Dir(destPath), target)
|
||||||
|
cleanRoot := filepath.Clean(destDir)
|
||||||
|
cleanResolved := filepath.Clean(resolved)
|
||||||
|
if cleanResolved != cleanRoot && !strings.HasPrefix(cleanResolved, cleanRoot+string(filepath.Separator)) {
|
||||||
|
return fmt.Errorf("symlink target escapes extraction root")
|
||||||
|
}
|
||||||
|
return os.Symlink(target, destPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package archive
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"archive/zip"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/anchore/syft/syft/cataloging"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createTestZip(t *testing.T, dir string, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
zipPath := filepath.Join(dir, "test.zip")
|
||||||
|
f, err := os.Create(zipPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := zip.NewWriter(f)
|
||||||
|
for name, content := range files {
|
||||||
|
fw, err := w.Create(name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = fw.Write([]byte(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
return zipPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestTarGz(t *testing.T, dir string, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
tarPath := filepath.Join(dir, "test.tar.gz")
|
||||||
|
f, err := os.Create(tarPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
|
for name, content := range files {
|
||||||
|
hdr := &tar.Header{
|
||||||
|
Name: name,
|
||||||
|
Mode: 0o644,
|
||||||
|
Size: int64(len(content)),
|
||||||
|
}
|
||||||
|
require.NoError(t, tw.WriteHeader(hdr))
|
||||||
|
_, err = tw.Write([]byte(content))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
require.NoError(t, tw.Close())
|
||||||
|
require.NoError(t, gw.Close())
|
||||||
|
return tarPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZipExtractor_CanExtract(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"})
|
||||||
|
|
||||||
|
ext := &ZipExtractor{}
|
||||||
|
|
||||||
|
f, err := os.Open(zipPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
assert.True(t, ext.CanExtract(ctx, zipPath, f))
|
||||||
|
|
||||||
|
// non-zip file
|
||||||
|
nonZipPath := filepath.Join(dir, "notazip.txt")
|
||||||
|
require.NoError(t, os.WriteFile(nonZipPath, []byte("just text"), 0o644))
|
||||||
|
|
||||||
|
f2, err := os.Open(nonZipPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f2.Close()
|
||||||
|
|
||||||
|
assert.False(t, ext.CanExtract(ctx, nonZipPath, f2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZipExtractor_Extract(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"file1.txt": "content1",
|
||||||
|
"dir/file2.txt": "content2",
|
||||||
|
}
|
||||||
|
zipPath := createTestZip(t, dir, files)
|
||||||
|
|
||||||
|
ext := &ZipExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
result, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, result.FilesExtracted)
|
||||||
|
|
||||||
|
// verify extracted files
|
||||||
|
content, err := os.ReadFile(filepath.Join(destDir, "file1.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content1", string(content))
|
||||||
|
|
||||||
|
content, err = os.ReadFile(filepath.Join(destDir, "dir", "file2.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content2", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZipExtractor_Extract_FileLimitReached(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"file1.txt": "a",
|
||||||
|
"file2.txt": "b",
|
||||||
|
"file3.txt": "c",
|
||||||
|
}
|
||||||
|
zipPath := createTestZip(t, dir, files)
|
||||||
|
|
||||||
|
ext := &ZipExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
_, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{MaxFileCount: 2})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "file count limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZipExtractor_Extract_SizeLimitReached(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"file1.txt": strings.Repeat("x", 100),
|
||||||
|
}
|
||||||
|
zipPath := createTestZip(t, dir, files)
|
||||||
|
|
||||||
|
ext := &ZipExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
_, err := ext.Extract(ctx, zipPath, destDir, ExtractionLimits{MaxExtractionSizeBytes: 50})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "size limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarExtractor_CanExtract(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tarPath := createTestTarGz(t, dir, map[string]string{"hello.txt": "world"})
|
||||||
|
|
||||||
|
ext := &TarExtractor{}
|
||||||
|
|
||||||
|
f, err := os.Open(tarPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
assert.True(t, ext.CanExtract(ctx, tarPath, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarExtractor_Extract(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"file1.txt": "content1",
|
||||||
|
"dir/file2.txt": "content2",
|
||||||
|
}
|
||||||
|
tarPath := createTestTarGz(t, dir, files)
|
||||||
|
|
||||||
|
ext := &TarExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
result, err := ext.Extract(ctx, tarPath, destDir, ExtractionLimits{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, result.FilesExtracted)
|
||||||
|
|
||||||
|
// verify extracted files
|
||||||
|
content, err := os.ReadFile(filepath.Join(destDir, "file1.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content1", string(content))
|
||||||
|
|
||||||
|
content, err = os.ReadFile(filepath.Join(destDir, "dir", "file2.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "content2", string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtractor(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
extractors := DefaultExtractors()
|
||||||
|
|
||||||
|
// zip file should match
|
||||||
|
zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"})
|
||||||
|
ext := FindExtractor(ctx, extractors, zipPath)
|
||||||
|
require.NotNil(t, ext)
|
||||||
|
assert.IsType(t, &ZipExtractor{}, ext)
|
||||||
|
|
||||||
|
// tar.gz should match
|
||||||
|
tarPath := createTestTarGz(t, dir, map[string]string{"hello.txt": "world"})
|
||||||
|
ext = FindExtractor(ctx, extractors, tarPath)
|
||||||
|
require.NotNil(t, ext)
|
||||||
|
assert.IsType(t, &TarExtractor{}, ext)
|
||||||
|
|
||||||
|
// non-archive should return nil
|
||||||
|
textPath := filepath.Join(dir, "plain.txt")
|
||||||
|
require.NoError(t, os.WriteFile(textPath, []byte("just text"), 0o644))
|
||||||
|
ext = FindExtractor(ctx, extractors, textPath)
|
||||||
|
assert.Nil(t, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExcludedExtension(t *testing.T) {
|
||||||
|
assert.True(t, IsExcludedExtension("/path/to/file.rpm", []string{".rpm", ".deb"}))
|
||||||
|
assert.True(t, IsExcludedExtension("/path/to/file.deb", []string{".rpm", ".deb"}))
|
||||||
|
assert.False(t, IsExcludedExtension("/path/to/file.zip", []string{".rpm", ".deb"}))
|
||||||
|
assert.False(t, IsExcludedExtension("/path/to/file.zip", nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtractor_NoReaders(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ext := FindExtractor(ctx, DefaultExtractors(), "/nonexistent/path")
|
||||||
|
assert.Nil(t, ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarExtractor_CanExtract_NonTarFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// create a plain text file
|
||||||
|
path := filepath.Join(dir, "plain.txt")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("not a tar"), 0o644))
|
||||||
|
|
||||||
|
ext := &TarExtractor{}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
assert.False(t, ext.CanExtract(ctx, path, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarExtractor_CanExtract_ZipFileReturnsFalse(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
zipPath := createTestZip(t, dir, map[string]string{"hello.txt": "world"})
|
||||||
|
|
||||||
|
ext := &TarExtractor{}
|
||||||
|
f, err := os.Open(zipPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// tar extractor should NOT claim to handle zip files
|
||||||
|
assert.False(t, ext.CanExtract(ctx, zipPath, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultExtractionLimits(t *testing.T) {
|
||||||
|
cfg := cataloging.ArchiveSearchConfig{
|
||||||
|
MaxExtractionSizeBytes: 1024,
|
||||||
|
MaxFileCount: 50,
|
||||||
|
}
|
||||||
|
limits := DefaultExtractionLimits(cfg)
|
||||||
|
assert.Equal(t, int64(1024), limits.MaxExtractionSizeBytes)
|
||||||
|
assert.Equal(t, 50, limits.MaxFileCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZipExtractor_Extract_ZipSlipPrevented(t *testing.T) {
|
||||||
|
// Create a zip with a path traversal entry
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
zipPath := filepath.Join(dir, "evil.zip")
|
||||||
|
f, err := os.Create(zipPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
w := zip.NewWriter(f)
|
||||||
|
fw, err := w.Create("../../etc/passwd")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = fw.Write([]byte("evil"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, w.Close())
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
ext := &ZipExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
_, err = ext.Extract(ctx, zipPath, destDir, ExtractionLimits{})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "path traversal")
|
||||||
|
|
||||||
|
// verify evil file was not created
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "etc", "passwd"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarExtractor_Extract_SymlinkInsideRootIsCreated(t *testing.T) {
|
||||||
|
// A tar archive with a symlink whose target stays inside the extraction
|
||||||
|
// directory should be written as a real symlink, not as a regular file
|
||||||
|
// containing the link target as bytes.
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tarPath := filepath.Join(dir, "with-link.tar.gz")
|
||||||
|
f, err := os.Create(tarPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
|
target := []byte("real content")
|
||||||
|
require.NoError(t, tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "real.txt",
|
||||||
|
Mode: 0o644,
|
||||||
|
Size: int64(len(target)),
|
||||||
|
}))
|
||||||
|
_, err = tw.Write(target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "link.txt",
|
||||||
|
Linkname: "real.txt",
|
||||||
|
Typeflag: tar.TypeSymlink,
|
||||||
|
Mode: 0o777,
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.NoError(t, tw.Close())
|
||||||
|
require.NoError(t, gw.Close())
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
ext := &TarExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
_, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// the link should be a real symlink
|
||||||
|
info, err := os.Lstat(filepath.Join(destDir, "link.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, info.Mode()&fs.ModeSymlink, "expected link.txt to be a symlink, got mode %v", info.Mode())
|
||||||
|
|
||||||
|
// reading through the link should return the target's content
|
||||||
|
readBack, err := os.ReadFile(filepath.Join(destDir, "link.txt"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "real content", string(readBack))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarExtractor_Extract_SymlinkEscapingRootIsSkipped(t *testing.T) {
|
||||||
|
// A symlink whose target resolves outside the extraction directory must not
|
||||||
|
// be created — neither as a symlink nor as a regular file. Extraction itself
|
||||||
|
// should succeed (the unsafe entry is skipped, not fatal).
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tarPath := filepath.Join(dir, "evil-link.tar.gz")
|
||||||
|
f, err := os.Create(tarPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
|
require.NoError(t, tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "passwd",
|
||||||
|
Linkname: "../../../../etc/passwd",
|
||||||
|
Typeflag: tar.TypeSymlink,
|
||||||
|
Mode: 0o777,
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.NoError(t, tw.Close())
|
||||||
|
require.NoError(t, gw.Close())
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
ext := &TarExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
_, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// nothing should have been created at the destination
|
||||||
|
_, statErr := os.Lstat(filepath.Join(destDir, "passwd"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "expected no entry to be created for escaping symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTarExtractor_Extract_AbsoluteSymlinkTargetIsSkipped(t *testing.T) {
|
||||||
|
// Absolute symlink targets must be rejected: os.Symlink writes the literal
|
||||||
|
// target, so once the symlink is read it resolves on the host filesystem
|
||||||
|
// regardless of any safety check at extraction time. Verifying both that
|
||||||
|
// the symlink itself is not created and (importantly) that nothing was
|
||||||
|
// written under destDir along the absolute path.
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tarPath := filepath.Join(dir, "absolute-link.tar.gz")
|
||||||
|
f, err := os.Create(tarPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(f)
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
|
||||||
|
require.NoError(t, tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "shadow",
|
||||||
|
Linkname: "/etc/shadow",
|
||||||
|
Typeflag: tar.TypeSymlink,
|
||||||
|
Mode: 0o777,
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.NoError(t, tw.Close())
|
||||||
|
require.NoError(t, gw.Close())
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
|
ext := &TarExtractor{}
|
||||||
|
destDir := filepath.Join(dir, "extracted")
|
||||||
|
require.NoError(t, os.MkdirAll(destDir, 0o755))
|
||||||
|
|
||||||
|
_, err = ext.Extract(ctx, tarPath, destDir, ExtractionLimits{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// the symlink itself must not exist
|
||||||
|
_, statErr := os.Lstat(filepath.Join(destDir, "shadow"))
|
||||||
|
assert.True(t, os.IsNotExist(statErr), "expected absolute-target symlink to be skipped")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user