Files
syft/internal/archive/extractor.go
T
2026-09-10 08:45:36 -04:00

513 lines
22 KiB
Go

package archive
import (
"bytes"
"context"
"errors"
"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 archive content.
CanExtract(ctx context.Context, content Content) bool
// Extract writes the archive's entries into sink - one store per archive, not one file per entry -
// respecting the given limits. Returns how many entries were written and how many bytes that took.
Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error)
}
// EntrySink is where an extractor puts the entries it reads out of an archive.
//
// Two implementations exist because they store an archive differently and the difference is
// measurable: OverflowTar writes every entry into one tar on disk and is indexed by seek offset,
// while EntryStore holds entries in memory until a bound says otherwise and is indexed by the entries
// themselves. The extractors do not care which they were given, with one exception - see
// VerbatimSink.
type EntrySink interface {
// AddEntry stores one entry of the archive.
AddEntry(f archives.FileInfo, result *ExtractionResult, limits ExtractionLimits) error
// Finish is called once the last entry has been offered, for whatever a sink has to write after
// its content.
Finish(result *ExtractionResult, limits ExtractionLimits)
}
// VerbatimSink is an EntrySink that stores archives in tar form, so a tar-family archive can be
// copied into it as it stands rather than decoded and re-encoded entry by entry.
//
// This is the one place the storage format shows through the extractor interface, and it is worth the
// leak: a tar.gz copied verbatim is one streamed decompression, where walking it costs a read per
// entry. A sink that does not store tars does not implement this and gets the entry-by-entry path.
type VerbatimSink interface {
EntrySink
CopyVerbatim(r io.Reader, result *ExtractionResult, limits ExtractionLimits) error
}
// ExtractionLimits defines safety limits for extracting one archive.
type ExtractionLimits struct {
// Charge is this archive's draw on the scan's resource limiter. Entry bytes are charged to it as
// they land, never from the size the entry declared, and a refused charge truncates the
// extraction. A nil Charge enforces no byte bound.
//
// It is a handle rather than a remaining-bytes value because what it charges has to be given back
// when the archive is released, and a value copied into a call cannot be.
Charge *Charge
}
// TruncationReason names the configured bound that stopped an extraction early. The empty value
// means the archive was extracted in full.
type TruncationReason string
const (
// TruncatedByDiskLimit means placing more of this archive's content would have taken the scan's
// disk usage past the disk limit. Named rather than reported as a bare truncation because a breach
// has to be attributed to the archive that triggered it, and saying which bound was reached is
// half of that attribution.
TruncatedByDiskLimit TruncationReason = "disk limit"
// TruncatedByLimit means a bound stopped the extraction but the walker replaced the error that
// said which one. Defensive: both bounds record their own reason on the result before stopping.
TruncatedByLimit TruncationReason = "configured limit"
)
// errTruncated stops an in-progress archive walk when a configured limit is reached. It never
// escapes this package: the extractors convert it into a successful, truncated ExtractionResult.
var errTruncated = errors.New("archive extraction stopped at a configured limit")
// ExtractionResult holds the result of an extraction operation.
//
// Reaching a configured limit is NOT an error: it is a truncation. Whatever was written before the
// limit is left on disk and is usable, so callers can still catalog what was extracted rather than
// discarding the whole archive. Check Truncated to tell a complete extraction from a partial one;
// a non-nil error from Extract means the extraction failed and nothing usable was produced.
type ExtractionResult struct {
// FilesExtracted counts the entries this archive's filesystem holds - the records its overflow tar
// was indexed into: regular files, symlinks, and directory entries. It is the count after indexing
// rather than the count written, so an entry dropped by an exclusion pattern is not counted as
// present. Reported, not bounded.
FilesExtracted int
// BytesWritten is what this archive put on disk: every byte of the overflow tar, headers and block
// padding included, because that is what the disk limit was charged for.
BytesWritten int64
// Truncation names the limit that stopped extraction early, or "" when the archive was
// extracted in full.
Truncation TruncationReason
}
// Truncated reports whether a configured limit stopped this extraction before the end of the
// archive.
func (r ExtractionResult) Truncated() bool {
return r.Truncation != ""
}
// finishExtraction turns a walk that stopped at a configured limit into a successful, truncated
// result.
//
// The limit signal is carried on the result rather than only in the returned error because the
// underlying archive walkers are free to wrap or replace a handler's error, in which case
// errors.Is(err, errTruncated) would not match and a truncation would be reported as a failure -
// discarding an archive syft could have cataloged. The error is still checked, so a walker that
// passes it through cleanly is handled too.
func finishExtraction(result ExtractionResult, err error) (ExtractionResult, error) {
if result.Truncated() || errors.Is(err, errTruncated) {
if !result.Truncated() {
// the walker swallowed the reason but passed the sentinel through
result.Truncation = TruncatedByLimit
}
return result, nil
}
return result, err
}
// DefaultExtractionLimits returns the per-archive limits from the given config.
//
// Nothing is read from the config: every bound this scan enforces is an in-use limit held by the
// scan's limiter, and the only per-archive field left is the Charge the caller draws against it.
// The parameter stays so the two Default* constructors are called the same way, and so a per-archive
// bound that is genuinely per-archive has somewhere to land.
func DefaultExtractionLimits(_ cataloging.ArchiveSearchConfig) ExtractionLimits {
return ExtractionLimits{}
}
// DefaultLimits returns the scan-wide in-use limits from the given config.
func DefaultLimits(cfg cataloging.ArchiveSearchConfig) Limits {
return Limits{
MaxMemoryBytes: cfg.MaxMemoryBytes,
MaxDiskBytes: cfg.MaxDiskBytes,
}
}
// 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 archive content, or nil if none can.
func FindExtractor(ctx context.Context, extractors []Extractor, content Content) Extractor {
for _, ext := range extractors {
// every extractor sniffs from the start, whether the content is a file or a buffer
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
log.Tracef("unable to seek archive content for detection: %v", err)
return nil
}
if ext.CanExtract(ctx, content) {
return ext
}
}
return nil
}
// ZipExtractor extracts zip-based archives (zip, jar, war, ear, etc.).
type ZipExtractor struct{}
// CanExtract reports whether this content is a zip, asking two questions in an order that matters.
//
// First, what does the content say it is, reading from the start? A format that identifies itself at
// offset zero is what this content is, and if that format is not zip then it is not a zip - a tar
// holding one jar ends in that jar's end-of-central-directory record, and reading it as a zip
// instead of as a tar would lose everything else the tar holds.
//
// Only when nothing identifies from the head is the end-of-central-directory record consulted. That
// record is what defines a zip - a reader locates the central directory through it - and it is the
// only thing that can see an archive whose head is something else entirely. A self-extracting
// archive is a launcher stub with a zip concatenated onto it, so no magic at offset zero can find
// it; the canonical case is a Spring Boot executable jar, a shell script followed by a whole zip.
//
// Identification is intFile.IdentifyArchive, which matches on the filename as well as the content,
// and it is left that way. Dropping the name makes the answer content's alone, which reads better -
// but archives.Identify with no path has to try every format's content matcher against every
// candidate, and measured on a stock ruby image that took the scan from 17 seconds to 45. The name
// cannot admit a non-archive in practice: a file only reaches an extractor after its sniffed content
// type put it in the candidate set, so the prose named notes.zip that this would wrongly identify is
// never offered here at all. `#content-based-archive-detection` is upheld by the candidate set, and
// Test_archiveCataloger_aMisnamedNonArchiveIsNotAnError is where that is asserted.
func (z *ZipExtractor) CanExtract(ctx context.Context, content Content) bool {
format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader)
if err == nil {
_, isZip := format.(archives.Zip)
return isZip
}
if _, seekErr := content.Reader.Seek(0, io.SeekStart); seekErr != nil {
return false
}
return HasZipEndOfCentralDirectory(content.Reader)
}
func (z *ZipExtractor) Extract(ctx context.Context, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) {
var result ExtractionResult
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
return result, fmt.Errorf("unable to seek zip archive %q: %w", content.Name, err)
}
// mholt/archives imposes no extraction target: the FileHandler is the caller's, so an entry can
// be written into a tar header rather than onto the filesystem. Nothing about the library had to
// be customized - the old code wrote files because the handler wrote files.
err := archives.Zip{}.Extract(ctx, content.Reader, func(_ context.Context, file archives.FileInfo) error {
return sink.AddEntry(file, &result, limits)
})
sink.Finish(&result, limits)
return finishExtraction(result, err)
}
// TarExtractor extracts tar-based archives (tar, tar.gz, tar.bz2, tar.xz, tar.zst).
type TarExtractor struct{}
func (t *TarExtractor) CanExtract(ctx context.Context, content Content) bool {
format, _, err := intFile.IdentifyArchive(ctx, content.Name, content.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, content Content, sink EntrySink, limits ExtractionLimits) (ExtractionResult, error) {
var result ExtractionResult
if _, err := content.Reader.Seek(0, io.SeekStart); err != nil {
return result, fmt.Errorf("unable to seek tar archive %q: %w", content.Name, err)
}
format, readerAfterIdentify, err := intFile.IdentifyArchive(ctx, content.Name, content.Reader)
if err != nil {
return result, fmt.Errorf("unable to identify archive format for %q: %w", content.Name, err)
}
extractor, ok := format.(archives.Extractor)
if !ok {
return result, fmt.Errorf("file format does not support extraction: %s", content.Name)
}
// use the reader returned by IdentifyArchive since it may have consumed some bytes
var extractReader = readerAfterIdentify
if extractReader == nil {
extractReader = content.Reader
}
// a tar handed to a sink that stores tars is not walked at all: a compressed tar is decompressed
// once into the sink's file and a plain one is copied, and the entries are first looked at when
// the file is indexed. Where the sink stores entries rather than a tar there is nothing to copy
// into, so the tar is walked like any other archive.
if verbatim, isVerbatim := sink.(VerbatimSink); isVerbatim {
if plain, ok := tarStream(format, extractReader); ok {
err = verbatim.CopyVerbatim(plain, &result, limits)
if closeErr := plain.Close(); closeErr != nil {
log.WithFields("archive", content.Name, "error", closeErr).Trace("unable to close decompressed tar stream")
}
return finishExtraction(result, err)
}
}
// anything else - 7z, rar, or a tar going into a sink that does not store tars - is walked entry
// by entry
err = extractor.Extract(ctx, extractReader, func(_ context.Context, file archives.FileInfo) error {
return sink.AddEntry(file, &result, limits)
})
sink.Finish(&result, limits)
return finishExtraction(result, err)
}
// resolvesInsideRoot reports whether writing to path really lands inside root, following any symlink
// that already exists along the way the same way the filesystem will.
//
// The one filesystem write left in this package is the archive's own bytes overflowing into its work
// directory, under a name derived from where the archive was found - which inside another archive is
// attacker-controlled text. That is what this now guards; entries are tar headers and traverse no
// path at all. See overflowContent.
//
// It walks path one component at a time rather than calling filepath.EvalSymlinks on the whole
// thing, because the leaf of an entry being extracted does not exist yet, which makes EvalSymlinks
// fail rather than answer. The first component that does not exist ends the walk: nothing below it
// exists either, so whatever is created there is created fresh under a path already known to be
// inside the root.
func resolvesInsideRoot(root, path string) (bool, error) {
// the root itself may sit under a symlinked directory (a temp dir on macOS, for one), so
// containment is judged against its real location
realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return false, fmt.Errorf("unable to resolve extraction root: %w", err)
}
rel, err := filepath.Rel(root, path)
if err != nil {
return false, err
}
if !isWithin(filepath.Join(realRoot, rel), realRoot) {
return false, nil
}
current := realRoot
for _, part := range strings.Split(filepath.ToSlash(rel), "/") {
if part == "" || part == "." {
continue
}
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
break
}
return false, err
}
if info.Mode()&fs.ModeSymlink == 0 {
continue
}
resolved, err := resolveLink(current)
if err != nil {
return false, err
}
if !isWithin(resolved, realRoot) {
return false, nil
}
current = resolved
}
return isWithin(current, realRoot), nil
}
// resolveLink returns where a symlink really points, falling back to the lexically cleaned target
// when the target does not exist yet - a dangling link still redirects a write.
func resolveLink(path string) (string, error) {
target, err := os.Readlink(path)
if err != nil {
return "", err
}
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(path), target)
}
resolved, err := filepath.EvalSymlinks(target)
if err != nil {
if !os.IsNotExist(err) {
return "", err
}
return filepath.Clean(target), nil
}
return resolved, nil
}
// isWithin reports whether path is root or sits beneath it, compared on a path boundary rather than
// as a string prefix so a sibling sharing the prefix does not pass.
func isWithin(path, root string) bool {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// writeSafeSymlink creates a symlink at destPath only if target is relative and really 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.
//
// A relative target is resolved against the REAL location of destPath's directory, not against its
// lexical one, because two cooperating entries can otherwise each pass on their own and together
// point outside: a link to "." followed by a link to ".." inside it resolves lexically to the root
// while really landing above it, and repeating the pair climbs a level each time. The parent of
// every write is checked the same way before the write happens, so a link that slipped through here
// still could not be written through - this is the earlier of two refusals, not the only one.
//
// It has no caller since an archive's entries became headers in one tar rather than files on disk: a
// symlink entry is now a header write, which creates nothing and traverses nothing, and where the
// link points is decided inside the archive's own filetree. It is kept rather than retired because
// that is a judgement about every path that could write a link, not about this one call site, and
// because the reasoning above is the expensive part to reconstruct.
//
//nolint:unused // retained deliberately; see the note above
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")
}
realRoot, err := filepath.EvalSymlinks(destDir)
if err != nil {
return fmt.Errorf("unable to resolve extraction root: %w", err)
}
realParent, err := filepath.EvalSymlinks(filepath.Dir(destPath))
if err != nil {
return fmt.Errorf("unable to resolve link parent: %w", err)
}
if !isWithin(realParent, realRoot) {
return fmt.Errorf("symlink parent escapes extraction root")
}
resolved := filepath.Join(realParent, target)
if !isWithin(resolved, realRoot) {
return fmt.Errorf("symlink target escapes extraction root")
}
// the target itself may run through links planted by earlier entries
if existing, err := filepath.EvalSymlinks(resolved); err == nil && !isWithin(existing, realRoot) {
return fmt.Errorf("symlink target resolves outside extraction root")
}
return os.Symlink(target, destPath)
}
// zipEOCDSignature is the end-of-central-directory record's signature. A zip is defined by this
// record: it is what a reader seeks to in order to find the central directory, which is why a zip
// with something concatenated in front of it still opens.
var zipEOCDSignature = []byte{'P', 'K', 0x05, 0x06}
// maxZipEOCDSearch is how far back the record can be. The EOCD is 22 bytes and ends with a comment
// of up to 65535 bytes, so it starts no earlier than this many bytes from the end.
const maxZipEOCDSearch = 22 + 65535
// zipLocalFileHeaderSignature starts every entry in a zip. In a self-extracting archive the first
// one follows the stub, so it is what a bounded read of the head can find.
var zipLocalFileHeaderSignature = []byte{'P', 'K', 0x03, 0x04}
// MaxAppendedArchiveStubBytes is how far into a file a hidden archive is looked for. A Spring Boot
// launcher stub is about 9 KB, so this is generous by a wide margin; a stub longer than this hides
// its archive from the walk, which is the cost of not reading every candidate to the end.
const MaxAppendedArchiveStubBytes = 64 * 1024
// MayHideAnAppendedArchive reports whether the head of this stream contains a zip local file header,
// and returns a reader that replays what it consumed.
//
// It exists to make the candidate set affordable. A file whose head sniffs as a script, an
// executable, or as nothing recognizable may still be an archive wearing a stub, but almost none of
// them are - and the authoritative test, the end-of-central-directory record, is at the far end of
// the file, so applying it to every such candidate means reading every executable in a scan to its
// end. Measured on a stock ruby image that doubled the scan.
//
// So the head is checked first, cheaply and with a bound, and only a file that shows an entry
// signature there is read in full and put to the real test. This narrows what is examined; it never
// admits anything, because a file that passes here is still refused by the extractor unless it ends
// in a valid central directory.
func MayHideAnAppendedArchive(r io.Reader) (io.Reader, bool) {
head := make([]byte, MaxAppendedArchiveStubBytes)
n, err := io.ReadFull(r, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return io.MultiReader(bytes.NewReader(head[:n]), r), false
}
head = head[:n]
return io.MultiReader(bytes.NewReader(head), r), bytes.Contains(head, zipLocalFileHeaderSignature)
}
// HasZipEndOfCentralDirectory reports whether the content ends in a zip end-of-central-directory
// record, which is the definition of a zip and the only part of one whose position is knowable
// without parsing.
//
// This is what identifies an archive that content sniffing types by its head: a self-extracting
// archive is a launcher stub with a zip concatenated onto it, so its first bytes are the stub's and
// no magic-at-offset-zero test can see the archive. The record at the end is unambiguous, and
// mholt/archives extracts such a file correctly once it is asked to - it reads through
// archive/zip, which locates the central directory from the end.
//
// It is a content test, so it cannot make a non-archive extractable: a file named `notes.zip` that
// is prose has no such record and is refused here exactly as it is refused by format identification.
func HasZipEndOfCentralDirectory(r ReaderAtSeeker) bool {
size, err := r.Seek(0, io.SeekEnd)
if err != nil || size < int64(len(zipEOCDSignature)) {
return false
}
window := int64(maxZipEOCDSearch)
if size < window {
window = size
}
buf := make([]byte, window)
if _, err := r.ReadAt(buf, size-window); err != nil && !errors.Is(err, io.EOF) {
return false
}
return bytes.LastIndex(buf, zipEOCDSignature) >= 0
}