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

536 lines
23 KiB
Go

package task
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
"github.com/dustin/go-humanize"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/archive"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/internal/mimetype"
"github.com/anchore/syft/internal/sbomsync"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cataloging"
"github.com/anchore/syft/syft/cataloging/pkgcataloging"
"github.com/anchore/syft/syft/event/monitor"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
)
const ArchiveCatalogerTaskName = "archive-cataloger"
// NewArchiveCatalogerTask returns a task that recursively extracts archives (including JAR-family
// archives, whose cataloger skips its own unarchiving when this task is enabled), treats each as
// its own standalone indexed filesystem, runs the given cataloger sub-pipeline against each, and
// records file-level CONTAINS relationships from the archive to the packages and files discovered
// inside it. Returns nil when archive cataloging is disabled (MaxDepth <= 0), so a disabled feature
// registers no task at all rather than a task that runs and does nothing.
//
// subPipeline is the set of package/file cataloger tasks to run against each extracted archive; it
// must NOT include this task, so recursion is driven only by the explicit depth-limited walk here.
// newResolver builds an FSID-stamped indexed resolver over an extracted archive directory (injected
// so this package does not depend on the syft-subtree internal fileresolver package).
func NewArchiveCatalogerTask(cfg cataloging.ArchiveSearchConfig, subPipeline []Task, newResolver archive.ResolverFactory, newStoreResolver archive.StoreResolverFactory, notify archive.Notify) Task {
if cfg.MaxDepth == 0 || newResolver == nil {
return nil
}
// this task drives recursion itself via an explicit depth-bounded walk, so it must never appear
// in its own sub-pipeline: each nesting level would then be processed once by the walk and
// again by the nested copy. Enforced rather than left to a comment, since the caller assembles
// the sub-pipeline from whatever package and file tasks are selected.
subPipeline = withoutArchiveCataloger(subPipeline)
if len(subPipeline) == 0 {
return nil
}
fn := func(ctx context.Context, resolver file.Resolver, builder sbomsync.Builder) error {
// one progress row for the whole recursive walk, updated as each archive is entered. The
// sub-pipeline underneath it reports into this same row rather than starting rows of its own:
// those catalogers have already started (and finished) a row for the scan root, and starting
// them again per archive is what both floods the display and strands the rows it replaces
// (see bus.WithCatalogerTaskProgress).
prog := bus.StartCatalogerTask(ctx, archiveCatalogerProgressInfo(), -1, "")
c := &archiveCataloger{
cfg: cfg,
subPipeline: subPipeline,
extractors: archive.DefaultExtractors(),
limits: archive.DefaultExtractionLimits(cfg),
limiter: archive.NewLimiter(archive.DefaultLimits(cfg)),
newResolver: newResolver,
newStoreResolver: newStoreResolver,
notify: notify,
prog: prog,
}
err := c.catalog(archive.WithLimiter(bus.WithCatalogerTaskProgress(ctx, prog), c.limiter), resolver, nil, 0, builder)
c.left()
c.logStats()
prog.SetCompleted()
return err
}
return NewTask(ArchiveCatalogerTaskName, fn, pkgcataloging.PackageTag, "archive")
}
func archiveCatalogerProgressInfo() monitor.GenericTask {
return monitor.GenericTask{
Title: monitor.Title{
Default: "Archives",
WhileRunning: "Cataloging archives",
OnSuccess: "Cataloged archives",
},
ID: ArchiveCatalogerTaskName,
ParentID: monitor.TopLevelCatalogingTaskID,
}
}
// withoutArchiveCataloger returns the given tasks with any archive-cataloger task removed.
func withoutArchiveCataloger(tasks []Task) []Task {
out := make([]Task, 0, len(tasks))
for _, t := range tasks {
if t == nil {
continue
}
if t.Name() == ArchiveCatalogerTaskName {
log.WithFields("task", t.Name()).
Debug("dropping archive cataloger task from its own sub-pipeline")
continue
}
out = append(out, t)
}
return out
}
type archiveCataloger struct {
cfg cataloging.ArchiveSearchConfig
subPipeline []Task
extractors []archive.Extractor
limits archive.ExtractionLimits
newResolver archive.ResolverFactory
newStoreResolver archive.StoreResolverFactory
// notify carries structured extraction events - which archive overflowed to disk and why, which was
// skipped - to whoever asked for them. Nil is the no-op, and nil is what a scan that wants nothing
// instrumented passes.
notify archive.Notify
// limiter measures the archive content this scan is holding right now, falling as each archive is
// released. One instance per task run, because one task run is one scan and that is the only
// scoping under which "held at once" means anything. It replaces a monotonic byte counter, which
// bounded a number matching no state that ever existed: every archive's extraction directory is
// removed on the way out while the counter only ever rose.
limiter *archive.Limiter
// prog is the single progress row for the whole walk: its stage names the archive currently being
// cataloged and its count is the number of archives entered so far. The recursion reports through
// this one row rather than letting the sub-pipeline start rows of its own at every level.
prog *monitor.TaskProgress
// slowest is the archive that spent the most time on its own extraction and cataloging, named by
// its full virtual path. Guarded for the same reason the limiter is: the walk is sequential today
// and lives in a concurrent neighbourhood.
statsMu sync.Mutex
slowest time.Duration
slowestPath string
archivesSeen int64
}
// recordArchiveTime keeps the longest self time seen and the archive that spent it. Self time
// excludes the archives nested inside this one, which are timed against themselves - inclusive time
// would name the outermost archive every run and say nothing.
func (c *archiveCataloger) recordArchiveTime(virtualPath string, took time.Duration) {
c.statsMu.Lock()
defer c.statsMu.Unlock()
c.archivesSeen++
if took > c.slowest {
c.slowest = took
c.slowestPath = virtualPath
}
}
// logStats reports what the walk cost, once, when it is done. The two peaks are the high-water marks
// of the gauges the limits are enforced against, so they are directly comparable to the configured
// bounds - they are not process memory and not filesystem usage. Reported at info rather than debug
// because it is one line per scan and it is what someone tuning the limits is looking for; the
// per-archive skip and truncation lines around it stay at debug.
func (c *archiveCataloger) logStats() {
c.statsMu.Lock()
slowest, slowestPath, seen := c.slowest, c.slowestPath, c.archivesSeen
c.statsMu.Unlock()
peakMemory, peakDisk := c.limiter.Peak()
fields := []any{
"archives", seen,
"peak-memory", humanize.Bytes(uint64(peakMemory)),
"peak-disk", humanize.Bytes(uint64(peakDisk)),
}
if slowestPath != "" {
fields = append(fields, "slowest", slowestPath, "slowest-took", slowest.Round(time.Millisecond))
}
log.WithFields(fields...).Info("nested archive cataloging complete")
}
// catalog discovers archives in the given resolver and processes each, recursing up to MaxDepth.
// parent is the traversal for the archive whose contents are being searched (nil at the scan root).
func (c *archiveCataloger) catalog(ctx context.Context, resolver file.Resolver, parent *archive.Traversal, depth int, builder sbomsync.Builder) error {
if c.cfg.MaxDepth >= 0 && depth >= c.cfg.MaxDepth {
return nil
}
var errs error
for _, candidate := range c.discoverArchives(resolver) {
// there is no between-archives check: a limit falls when an archive is released, so an
// archive that does not fit says nothing about the next one, and reaching a bound skips only
// the archive that reached it.
if err := c.processArchive(ctx, resolver, candidate, parent, depth, builder); err != nil {
errs = unknown.Append(errs, candidate.location, err)
}
}
return errs
}
func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver file.Resolver, candidate archiveCandidate, parent *archive.Traversal, depth int, builder sbomsync.Builder) error {
archiveLoc := candidate.location
content, err := parentResolver.FileContentsByLocation(archiveLoc)
if err != nil {
return err
}
defer internal.CloseAndLogError(content, archiveLoc.AccessPath)
// a candidate that is only a candidate is checked cheaply before its bytes are read in full: an
// archive wearing a stub carries an entry signature near the front, and almost nothing else does.
// Reading every executable in a scan to its end to ask the authoritative question instead doubled
// a stock ruby image.
var archiveContent io.Reader = content
if !candidate.sniffedAsArchive {
var mayHide bool
archiveContent, mayHide = archive.MayHideAnAppendedArchive(content)
if !mayHide {
return nil
}
}
// self time for this archive: its extraction, its sub-pipeline and its merge, stopped before the
// recursive descent so a containing archive is not charged for what it contains
started := time.Now()
// the extracted files inherit the filesystem id of the location the archive was found at,
// uniformly at every level: for an image source that is the layer digest, for a directory source
// it is empty, and inside another archive the enclosing resolver stamped it there already. The
// nesting chain is carried separately as the archive path (the colon-delimited chain of archive
// access paths from the scan root). Together these keep identically-named files in different
// archives - and the same archive path in different layers - apart in the coordinate-keyed tables.
archivePath := parent.VirtualPathOf(accessPath(archiveLoc))
extracted, err := archive.ExtractToResolver(ctx, archiveContent, archiveLoc.AccessPath, archiveLoc.FileSystemID, archivePath, c.extractors, c.limiter, c.limits, c.newResolver, c.newStoreResolver, c.notify)
if errors.Is(err, archive.ErrDiskLimitReached) {
// the disk limit is terminal and nothing will be released while this archive waits, since
// the walk descends before it unwinds - so the archive is skipped and the scan continues.
// Attributed to the archive, because a log line carrying only the bound says nothing about
// what was lost.
log.WithFields("archive", accessPath(archiveLoc), "limit", c.cfg.MaxDiskBytes).
Debug("skipping archive whose content would exceed the disk limit")
return nil
}
if err != nil {
return err
}
if extracted == nil {
// nothing could open it: a format with no extractor (an ar archive, a bare gzip that is not a
// tar), or a candidate whose head could have been hiding an archive and was not. Logged, not
// returned as an error.
//
// Returning one was measured at 3x the scan on a stock ruby image, whose 3,679 gzip files are
// mostly man pages with no extractor: every one produced an error, and appending thousands of
// them into one chain that is then walked for coordinate errors is quadratic. A file syft
// cannot open is also not the failure `#archive-failure-is-not-fatal` is about - that is a
// corrupt archive, which fails during extraction and still returns an error from there.
log.WithFields("archive", accessPath(archiveLoc), "sniffed-as-archive", candidate.sniffedAsArchive).
Trace("no extractor could read this candidate; skipping")
return nil
}
// releases the extraction directory and gives back everything this archive charged to the
// limiter, after the sub-pipeline and after recursion
defer extracted.Cleanup()
if extracted.Result.Truncated() {
// a bound stopped the extraction early: catalog what was written rather than discarding the
// archive, but say so, since the packages found here are not the full set. Recorded in the
// SBOM's unknowns and not only in the logs, because "syft saw part of this archive" is a fact
// about the SBOM's completeness that outlives the run that produced it - which is exactly what
// unknowns are for.
log.WithFields("archive", accessPath(archiveLoc), "limit", string(extracted.Result.Truncation)).
Debug("archive extraction truncated by a configured limit; cataloging partial contents")
appendUnknowns(builder, ArchiveCatalogerTaskName, []unknown.CoordinateError{{
Coordinates: archiveLoc.Coordinates,
Reason: fmt.Errorf("archive cataloged from part of its contents: extraction stopped at the %s",
string(extracted.Result.Truncation)),
}})
}
// expose the nesting chain to catalogers in the sub-pipeline (and deeper recursion) so they can
// reconstruct nesting-aware identity (e.g. the java cataloger's colon-delimited virtual paths)
trav := &archive.Traversal{
Location: archiveLoc,
VirtualPath: archivePath,
FileSystemID: extracted.FileSystemID,
Depth: depth + 1,
Parent: parent,
Digests: extracted.Digests,
}
subCtx := archive.WithTraversal(ctx, trav)
c.entering(trav.VirtualPath)
// run the package/file cataloger sub-pipeline against the archive's standalone filesystem
scratch := c.runSubPipeline(subCtx, extracted.Resolver, archiveLoc.Coordinates)
// merge results into the shared SBOM and record file-level CONTAINS edges from this archive
mergeArchiveResults(archiveLoc.Coordinates, scratch, builder)
c.left()
c.recordArchiveTime(trav.VirtualPath, time.Since(started))
// recurse into archives nested within this one
return c.catalog(subCtx, extracted.Resolver, trav, depth+1, builder)
}
// entering records on the walk's single progress row that this archive is now being cataloged, and
// left takes that row's stage back once it is done. Both are nil-safe, so a cataloger assembled
// without a progress row simply reports nothing.
//
// The row is taken back because the sub-pipeline shares its stage - that is what makes the nested
// catalogers' progress visible - and each of them signs off with a summary of its own ("0
// executables"). Left to stand, that summary is what the row reads while the next archive is being
// extracted, which is both wrong and unchanging. The count of archives entered is the one thing the
// row can say that is always true of the walk as a whole.
func (c *archiveCataloger) entering(virtualPath string) {
if c.prog == nil {
return
}
c.prog.Increment()
c.prog.AtomicStage.Set(virtualPath)
}
func (c *archiveCataloger) left() {
if c.prog == nil {
return
}
c.prog.AtomicStage.Set(fmt.Sprintf("%s archives", humanize.Comma(c.prog.Current())))
}
// accessPath returns the location's access path, falling back to the real path when unset.
func accessPath(loc file.Location) string {
if loc.AccessPath != "" {
return loc.AccessPath
}
return loc.RealPath
}
// runSubPipeline runs every cataloger task against the given resolver into a throwaway SBOM so the
// results can be attributed to the containing archive before being merged into the shared SBOM.
//
// A task's failure is recorded the same way RunTask records one at the top level (executor.go), so a
// cataloger that fails inside an archive is visible in the SBOM's unknowns rather than only in the
// logs: errors carrying their own coordinates keep them, and anything left has no location of its
// own, so it is attributed to archiveCoord - the archive that was being cataloged. A failure here
// never fails the archive or the scan.
func (c *archiveCataloger) runSubPipeline(ctx context.Context, resolver file.Resolver, archiveCoord file.Coordinates) *sbom.SBOM {
scratch := &sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(),
FileMetadata: map[file.Coordinates]file.Metadata{},
FileDigests: map[file.Coordinates][]file.Digest{},
FileContents: map[file.Coordinates]string{},
FileLicenses: map[file.Coordinates][]file.License{},
Executables: map[file.Coordinates]file.Executable{},
Unknowns: map[file.Coordinates][]string{},
},
}
scratchBuilder := sbomsync.NewBuilder(scratch)
for _, t := range c.subPipeline {
err := runTaskSafely(ctx, t, resolver, scratchBuilder)
if err == nil {
continue
}
log.WithFields("task", t.Name(), "error", err).Trace("archive sub-cataloger task reported an error")
unknowns, remaining := unknown.ExtractCoordinateErrors(err)
if remaining != nil {
unknowns = append(unknowns, unknown.CoordinateError{Coordinates: archiveCoord, Reason: remaining})
}
appendUnknowns(scratchBuilder, t.Name(), unknowns)
}
return scratch
}
// archiveCandidate is one file the walk will try to open, and how it came to be a candidate.
type archiveCandidate struct {
location file.Location
// sniffedAsArchive is true when the file's own content types it as an archive. Such a file that
// then cannot be extracted is a broken archive and is reported; a candidate that got here only
// because its head could be hiding one is simply not an archive, and is passed over in silence.
sniffedAsArchive bool
}
// zipArchiveGlobs find the files worth a second look: those that claim to be zip archives by name
// and whose content did not say so. A self-extracting archive is one - a launcher stub with a zip
// concatenated onto it, typed by the stub - and the canonical case is a Spring Boot executable jar,
// which sniffs as a shell script.
//
// This is not a detection rule. Whether a candidate is an archive is settled by the
// end-of-central-directory record and by nothing else, so a file named `.jar` that holds prose is
// refused exactly as it is today. The name decides only what gets looked at.
//
// It is a glob rather than a MIME lookup because the MIME lookup was the whole cost. Asking the
// resolver for every file that sniffs as executable, script, or unrecognized returns 3,274 locations
// on a stock ruby image - all of which this then discarded, since none of them were named like
// archives - and that one call took the scan from 15 seconds to 45. Asking for the names directly
// returns the handful that matter.
//
// The limit this accepts is that a self-extracting archive with no telling extension is not found.
// That is deliberate, and it is the one place here where a name decides whether syft looks.
//
// One pattern per extension, not one pattern with brace alternation: the alternation form matched
// nothing, and the cost of the extra patterns is not measurable - the whole lookup is about a
// millisecond and returns nothing on an image with no self-extracting archives in it.
var zipArchiveGlobs = []string{
"**/*.jar", "**/*.war", "**/*.ear", "**/*.par", "**/*.sar", "**/*.nar", "**/*.kar",
"**/*.hpi", "**/*.jpi", "**/*.far", "**/*.rar", "**/*.zip", "**/*.apk", "**/*.aar",
"**/*.egg", "**/*.whl", "**/*.lpkg", "**/*.zap", "**/*.exe",
}
// discoverArchives returns every location worth opening, in two groups.
//
// There is no exclusion filter here on purpose. The scan's exclusion patterns are applied where each
// filesystem is indexed - the source's own index for the scan root, and the extraction directory's
// index for an archive's contents - so an excluded archive is not in the resolver to be returned in
// the first place. Filtering again here would be a second mechanism answering the same question.
//
// The archive MIME types are archives by their own content. The appended set are files whose head
// says script or executable and whose tail may still be a whole zip: a self-extracting archive is
// typed by its stub, so nothing sniffed at offset zero can see it. Membership in the second set
// decides only that a file is worth opening - whether it is an archive is decided by the extractor,
// which refuses anything with no end-of-central-directory record.
func (c *archiveCataloger) discoverArchives(resolver file.Resolver) []archiveCandidate {
archives, err := resolver.FilesByMIMEType(mimetype.ArchiveMIMETypeSet.List()...)
if err != nil {
log.WithFields("error", err).Debug("unable to list archive files for recursive cataloging")
return nil
}
candidates := make([]archiveCandidate, 0, len(archives))
for _, loc := range archives {
candidates = append(candidates, archiveCandidate{location: loc, sniffedAsArchive: true})
}
appended, err := resolver.FilesByGlob(zipArchiveGlobs...)
if err != nil {
// the archives found above are still worth walking
log.WithFields("error", err).Debug("unable to list files that may carry an appended archive")
return candidates
}
seen := make(map[file.Coordinates]struct{}, len(candidates))
for _, c := range candidates {
seen[c.location.Coordinates] = struct{}{}
}
for _, loc := range appended {
// a location reached by both lookups is one file and must be opened once. The two sets are
// disjoint today, so this guards the invariant rather than a known overlap - and processing
// an archive twice would double every package it holds, which is the failure this whole
// design exists to prevent.
if _, dup := seen[loc.Coordinates]; dup {
continue
}
seen[loc.Coordinates] = struct{}{}
candidates = append(candidates, archiveCandidate{location: loc})
}
return candidates
}
// mergeArchiveResults copies the throwaway SBOM's packages, files, and relationships into the shared
// SBOM, and adds a file-level CONTAINS relationship from the archive's coordinates to every package
// and file discovered inside it.
func mergeArchiveResults(archiveCoord file.Coordinates, scratch *sbom.SBOM, builder sbomsync.Builder) {
pkgs := scratch.Artifacts.Packages.Sorted()
if len(pkgs) > 0 {
builder.AddPackages(pkgs...)
}
accessor, _ := builder.(sbomsync.Accessor)
if accessor != nil {
accessor.WriteToSBOM(func(s *sbom.SBOM) {
mergeFileArtifacts(s, scratch)
})
}
var rels []artifact.Relationship
for _, p := range pkgs {
rels = append(rels, artifact.Relationship{
From: archiveCoord,
To: p,
Type: artifact.ContainsRelationship,
})
}
for _, coord := range scratch.AllCoordinates() {
rels = append(rels, artifact.Relationship{
From: archiveCoord,
To: coord,
Type: artifact.ContainsRelationship,
})
}
// preserve relationships discovered within the archive (e.g. package evident-by file)
rels = append(rels, scratch.Relationships...)
if len(rels) > 0 {
builder.AddRelationships(rels...)
}
}
func mergeFileArtifacts(dst, src *sbom.SBOM) {
if dst.Artifacts.FileMetadata == nil {
dst.Artifacts.FileMetadata = map[file.Coordinates]file.Metadata{}
}
for k, v := range src.Artifacts.FileMetadata {
dst.Artifacts.FileMetadata[k] = v
}
if dst.Artifacts.FileDigests == nil {
dst.Artifacts.FileDigests = map[file.Coordinates][]file.Digest{}
}
for k, v := range src.Artifacts.FileDigests {
dst.Artifacts.FileDigests[k] = v
}
if dst.Artifacts.FileContents == nil {
dst.Artifacts.FileContents = map[file.Coordinates]string{}
}
for k, v := range src.Artifacts.FileContents {
dst.Artifacts.FileContents[k] = v
}
if dst.Artifacts.FileLicenses == nil {
dst.Artifacts.FileLicenses = map[file.Coordinates][]file.License{}
}
for k, v := range src.Artifacts.FileLicenses {
dst.Artifacts.FileLicenses[k] = v
}
if dst.Artifacts.Executables == nil {
dst.Artifacts.Executables = map[file.Coordinates]file.Executable{}
}
for k, v := range src.Artifacts.Executables {
dst.Artifacts.Executables[k] = v
}
if dst.Artifacts.Unknowns == nil {
dst.Artifacts.Unknowns = map[file.Coordinates][]string{}
}
for k, v := range src.Artifacts.Unknowns {
dst.Artifacts.Unknowns[k] = append(dst.Artifacts.Unknowns[k], v...)
}
}