mirror of
https://github.com/anchore/syft.git
synced 2026-10-31 06:26:30 +01:00
599 lines
18 KiB
Go
599 lines
18 KiB
Go
/*
|
|
Package source provides an abstraction to allow a user to loosely define a data source to catalog and expose a common interface that
|
|
catalogers and use explore and analyze data from the data source. All valid (cataloggable) data sources are defined
|
|
within this package.
|
|
*/
|
|
package source
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"github.com/mholt/archiver/v3"
|
|
digest "github.com/opencontainers/go-digest"
|
|
"github.com/spf13/afero"
|
|
|
|
"github.com/anchore/stereoscope"
|
|
"github.com/anchore/stereoscope/pkg/image"
|
|
"github.com/anchore/syft/internal/log"
|
|
"github.com/anchore/syft/syft/artifact"
|
|
)
|
|
|
|
// Source is an object that captures the data source to be cataloged, configuration, and a specific resolver used
|
|
// in cataloging (based on the data source and configuration)
|
|
type Source struct {
|
|
id artifact.ID `hash:"ignore"`
|
|
Image *image.Image `hash:"ignore"` // the image object to be cataloged (image only)
|
|
Metadata Metadata
|
|
directoryResolver *directoryResolver `hash:"ignore"`
|
|
path string
|
|
base string
|
|
mutex *sync.Mutex
|
|
Exclusions []string `hash:"ignore"`
|
|
}
|
|
|
|
// Input is an object that captures the detected user input regarding source location, scheme, and provider type.
|
|
// It acts as a struct input for some source constructors.
|
|
type Input struct {
|
|
UserInput string
|
|
Scheme Scheme
|
|
ImageSource image.Source
|
|
Location string
|
|
Platform string
|
|
Name string
|
|
}
|
|
|
|
// ParseInput generates a source Input that can be used as an argument to generate a new source
|
|
// from specific providers including a registry.
|
|
func ParseInput(userInput string, platform string) (*Input, error) {
|
|
return ParseInputWithName(userInput, platform, "", "")
|
|
}
|
|
|
|
// ParseInputWithName generates a source Input that can be used as an argument to generate a new source
|
|
// from specific providers including a registry, with an explicit name.
|
|
func ParseInputWithName(userInput string, platform, name, defaultImageSource string) (*Input, error) {
|
|
fs := afero.NewOsFs()
|
|
scheme, source, location, err := DetectScheme(fs, image.DetectSource, userInput)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if source == image.UnknownSource {
|
|
// only run for these two scheme
|
|
// only check on packages command, attest we automatically try to pull from userInput
|
|
switch scheme {
|
|
case ImageScheme, UnknownScheme:
|
|
scheme = ImageScheme
|
|
location = userInput
|
|
if defaultImageSource != "" {
|
|
source = parseDefaultImageSource(defaultImageSource)
|
|
} else {
|
|
imagePullSource := image.DetermineDefaultImagePullSource(userInput)
|
|
source = imagePullSource
|
|
}
|
|
if location == "" {
|
|
location = userInput
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
if scheme != ImageScheme && platform != "" {
|
|
return nil, fmt.Errorf("cannot specify a platform for a non-image source")
|
|
}
|
|
|
|
// collect user input for downstream consumption
|
|
return &Input{
|
|
UserInput: userInput,
|
|
Scheme: scheme,
|
|
ImageSource: source,
|
|
Location: location,
|
|
Platform: platform,
|
|
Name: name,
|
|
}, nil
|
|
}
|
|
|
|
func parseDefaultImageSource(defaultImageSource string) image.Source {
|
|
switch defaultImageSource {
|
|
case "registry":
|
|
return image.OciRegistrySource
|
|
case "docker":
|
|
return image.DockerDaemonSource
|
|
case "podman":
|
|
return image.PodmanDaemonSource
|
|
default:
|
|
return image.UnknownSource
|
|
}
|
|
}
|
|
|
|
type sourceDetector func(string) (image.Source, string, error)
|
|
|
|
func NewFromRegistry(in Input, registryOptions *image.RegistryOptions, exclusions []string) (*Source, func(), error) {
|
|
source, cleanupFn, err := generateImageSource(in, registryOptions)
|
|
if source != nil {
|
|
source.Exclusions = exclusions
|
|
}
|
|
return source, cleanupFn, err
|
|
}
|
|
|
|
// New produces a Source based on userInput like dir: or image:tag
|
|
func New(in Input, registryOptions *image.RegistryOptions, exclusions []string) (*Source, func(), error) {
|
|
var err error
|
|
fs := afero.NewOsFs()
|
|
var source *Source
|
|
cleanupFn := func() {}
|
|
|
|
switch in.Scheme {
|
|
case FileScheme:
|
|
source, cleanupFn, err = generateFileSource(fs, in)
|
|
case DirectoryScheme:
|
|
source, cleanupFn, err = generateDirectorySource(fs, in)
|
|
case ImageScheme:
|
|
source, cleanupFn, err = generateImageSource(in, registryOptions)
|
|
default:
|
|
err = fmt.Errorf("unable to process input for scanning: %q", in.UserInput)
|
|
}
|
|
|
|
if err == nil {
|
|
source.Exclusions = exclusions
|
|
}
|
|
|
|
return source, cleanupFn, err
|
|
}
|
|
|
|
func generateImageSource(in Input, registryOptions *image.RegistryOptions) (*Source, func(), error) {
|
|
img, cleanup, err := getImageWithRetryStrategy(in, registryOptions)
|
|
if err != nil || img == nil {
|
|
return nil, cleanup, fmt.Errorf("could not fetch image %q: %w", in.Location, err)
|
|
}
|
|
|
|
s, err := NewFromImageWithName(img, in.Location, in.Name)
|
|
if err != nil {
|
|
return nil, cleanup, fmt.Errorf("could not populate source with image: %w", err)
|
|
}
|
|
|
|
return &s, cleanup, nil
|
|
}
|
|
|
|
func parseScheme(userInput string) string {
|
|
parts := strings.SplitN(userInput, ":", 2)
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
|
|
return parts[0]
|
|
}
|
|
|
|
func getImageWithRetryStrategy(in Input, registryOptions *image.RegistryOptions) (*image.Image, func(), error) {
|
|
ctx := context.TODO()
|
|
|
|
var opts []stereoscope.Option
|
|
if registryOptions != nil {
|
|
opts = append(opts, stereoscope.WithRegistryOptions(*registryOptions))
|
|
}
|
|
|
|
if in.Platform != "" {
|
|
opts = append(opts, stereoscope.WithPlatform(in.Platform))
|
|
}
|
|
|
|
img, err := stereoscope.GetImageFromSource(ctx, in.Location, in.ImageSource, opts...)
|
|
cleanup := func() {
|
|
if err := img.Cleanup(); err != nil {
|
|
log.Warnf("unable to cleanup image=%q: %w", in.UserInput, err)
|
|
}
|
|
}
|
|
if err == nil {
|
|
// Success on the first try!
|
|
return img, cleanup, nil
|
|
}
|
|
|
|
scheme := parseScheme(in.UserInput)
|
|
if !(scheme == "docker" || scheme == "registry") {
|
|
// Image retrieval failed, and we shouldn't retry it. It's most likely that the
|
|
// user _did_ intend the parsed scheme, but there was a legitimate failure with
|
|
// using the scheme to load the image. Alert the user to this failure, so they
|
|
// can fix the problem.
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Maybe the user wanted "docker" or "registry" to refer to an _image name_
|
|
// (e.g. "docker:latest"), not a scheme. We'll retry image retrieval with this
|
|
// alternative interpretation, in an attempt to avoid unnecessary user friction.
|
|
|
|
log.Warnf(
|
|
"scheme %q specified, but it coincides with a common image name; re-examining user input %q"+
|
|
" without scheme parsing because image retrieval using scheme parsing was unsuccessful: %v",
|
|
scheme,
|
|
in.UserInput,
|
|
err,
|
|
)
|
|
|
|
// We need to determine the image source again, such that this determination
|
|
// doesn't take scheme parsing into account.
|
|
in.ImageSource = image.DetermineDefaultImagePullSource(in.UserInput)
|
|
img, userInputErr := stereoscope.GetImageFromSource(ctx, in.UserInput, in.ImageSource, opts...)
|
|
cleanup = func() {
|
|
if err := img.Cleanup(); err != nil {
|
|
log.Warnf("unable to cleanup image=%q: %w", in.UserInput, err)
|
|
}
|
|
}
|
|
if userInputErr != nil {
|
|
// Image retrieval failed on both tries, we will want to return both errors.
|
|
return nil, nil, fmt.Errorf(
|
|
"scheme %q specified; "+
|
|
"image retrieval using scheme parsing (%s) was unsuccessful: %v; "+
|
|
"image retrieval without scheme parsing (%s) was unsuccessful: %v",
|
|
scheme,
|
|
in.Location,
|
|
err,
|
|
in.UserInput,
|
|
userInputErr,
|
|
)
|
|
}
|
|
|
|
return img, cleanup, nil
|
|
}
|
|
|
|
func generateDirectorySource(fs afero.Fs, in Input) (*Source, func(), error) {
|
|
fileMeta, err := fs.Stat(in.Location)
|
|
if err != nil {
|
|
return nil, func() {}, fmt.Errorf("unable to stat dir=%q: %w", in.Location, err)
|
|
}
|
|
|
|
if !fileMeta.IsDir() {
|
|
return nil, func() {}, fmt.Errorf("given path is not a directory (path=%q): %w", in.Location, err)
|
|
}
|
|
|
|
s, err := NewFromDirectoryWithName(in.Location, in.Name)
|
|
if err != nil {
|
|
return nil, func() {}, fmt.Errorf("could not populate source from path=%q: %w", in.Location, err)
|
|
}
|
|
|
|
return &s, func() {}, nil
|
|
}
|
|
|
|
func generateFileSource(fs afero.Fs, in Input) (*Source, func(), error) {
|
|
fileMeta, err := fs.Stat(in.Location)
|
|
if err != nil {
|
|
return nil, func() {}, fmt.Errorf("unable to stat dir=%q: %w", in.Location, err)
|
|
}
|
|
|
|
if fileMeta.IsDir() {
|
|
return nil, func() {}, fmt.Errorf("given path is not a directory (path=%q): %w", in.Location, err)
|
|
}
|
|
|
|
s, cleanupFn := NewFromFileWithName(in.Location, in.Name)
|
|
|
|
return &s, cleanupFn, nil
|
|
}
|
|
|
|
// NewFromDirectory creates a new source object tailored to catalog a given filesystem directory recursively.
|
|
func NewFromDirectory(path string) (Source, error) {
|
|
return NewFromDirectoryWithName(path, "")
|
|
}
|
|
|
|
// NewFromDirectory creates a new source object tailored to catalog a given filesystem directory recursively.
|
|
func NewFromDirectoryRoot(path string) (Source, error) {
|
|
return NewFromDirectoryRootWithName(path, "")
|
|
}
|
|
|
|
// NewFromDirectoryWithName creates a new source object tailored to catalog a given filesystem directory recursively, with an explicitly provided name.
|
|
func NewFromDirectoryWithName(path string, name string) (Source, error) {
|
|
s := Source{
|
|
mutex: &sync.Mutex{},
|
|
Metadata: Metadata{
|
|
Name: name,
|
|
Scheme: DirectoryScheme,
|
|
Path: path,
|
|
},
|
|
path: path,
|
|
}
|
|
s.SetID()
|
|
return s, nil
|
|
}
|
|
|
|
// NewFromDirectoryRootWithName creates a new source object tailored to catalog a given filesystem directory recursively, with an explicitly provided name.
|
|
func NewFromDirectoryRootWithName(path string, name string) (Source, error) {
|
|
s := Source{
|
|
mutex: &sync.Mutex{},
|
|
Metadata: Metadata{
|
|
Name: name,
|
|
Scheme: DirectoryScheme,
|
|
Path: path,
|
|
Base: path,
|
|
},
|
|
path: path,
|
|
base: path,
|
|
}
|
|
s.SetID()
|
|
return s, nil
|
|
}
|
|
|
|
// NewFromFile creates a new source object tailored to catalog a file.
|
|
func NewFromFile(path string) (Source, func()) {
|
|
return NewFromFileWithName(path, "")
|
|
}
|
|
|
|
// NewFromFileWithName creates a new source object tailored to catalog a file, with an explicitly provided name.
|
|
func NewFromFileWithName(path string, name string) (Source, func()) {
|
|
analysisPath, cleanupFn := fileAnalysisPath(path)
|
|
|
|
s := Source{
|
|
mutex: &sync.Mutex{},
|
|
Metadata: Metadata{
|
|
Name: name,
|
|
Scheme: FileScheme,
|
|
Path: path,
|
|
},
|
|
path: analysisPath,
|
|
}
|
|
|
|
s.SetID()
|
|
return s, cleanupFn
|
|
}
|
|
|
|
// fileAnalysisPath returns the path given, or in the case the path is an archive, the location where the archive
|
|
// contents have been made available. A cleanup function is provided for any temp files created (if any).
|
|
func fileAnalysisPath(path string) (string, func()) {
|
|
var analysisPath = path
|
|
var cleanupFn = func() {}
|
|
|
|
// if the given file is an archive (as indicated by the file extension and not MIME type) then unarchive it and
|
|
// use the contents as the source. Note: this does NOT recursively unarchive contents, only the given path is
|
|
// unarchived.
|
|
envelopedUnarchiver, err := archiver.ByExtension(path)
|
|
if unarchiver, ok := envelopedUnarchiver.(archiver.Unarchiver); err == nil && ok {
|
|
if tar, ok := unarchiver.(*archiver.Tar); ok {
|
|
// when tar files are extracted, if there are multiple entries at the same
|
|
// location, the last entry wins
|
|
// NOTE: this currently does not display any messages if an overwrite happens
|
|
tar.OverwriteExisting = true
|
|
}
|
|
unarchivedPath, tmpCleanup, err := unarchiveToTmp(path, unarchiver)
|
|
if err != nil {
|
|
log.Warnf("file could not be unarchived: %+v", err)
|
|
} else {
|
|
log.Debugf("source path is an archive")
|
|
analysisPath = unarchivedPath
|
|
}
|
|
if tmpCleanup != nil {
|
|
cleanupFn = tmpCleanup
|
|
}
|
|
}
|
|
|
|
return analysisPath, cleanupFn
|
|
}
|
|
|
|
// NewFromImage creates a new source object tailored to catalog a given container image, relative to the
|
|
// option given (e.g. all-layers, squashed, etc)
|
|
func NewFromImage(img *image.Image, userImageStr string) (Source, error) {
|
|
return NewFromImageWithName(img, userImageStr, "")
|
|
}
|
|
|
|
// NewFromImageWithName creates a new source object tailored to catalog a given container image, relative to the
|
|
// option given (e.g. all-layers, squashed, etc), with an explicit name.
|
|
func NewFromImageWithName(img *image.Image, userImageStr string, name string) (Source, error) {
|
|
if img == nil {
|
|
return Source{}, fmt.Errorf("no image given")
|
|
}
|
|
|
|
s := Source{
|
|
Image: img,
|
|
Metadata: Metadata{
|
|
Name: name,
|
|
Scheme: ImageScheme,
|
|
ImageMetadata: NewImageMetadata(img, userImageStr),
|
|
},
|
|
}
|
|
s.SetID()
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Source) ID() artifact.ID {
|
|
if s.id == "" {
|
|
s.SetID()
|
|
}
|
|
return s.id
|
|
}
|
|
|
|
func (s *Source) SetID() {
|
|
var d string
|
|
switch s.Metadata.Scheme {
|
|
case DirectoryScheme:
|
|
d = digest.FromString(s.Metadata.Path).String()
|
|
case FileScheme:
|
|
// attempt to use the digest of the contents of the file as the ID
|
|
file, err := os.Open(s.Metadata.Path)
|
|
if err != nil {
|
|
d = digest.FromString(s.Metadata.Path).String()
|
|
break
|
|
}
|
|
defer file.Close()
|
|
di, err := digest.FromReader(file)
|
|
if err != nil {
|
|
d = digest.FromString(s.Metadata.Path).String()
|
|
break
|
|
}
|
|
d = di.String()
|
|
case ImageScheme:
|
|
manifestDigest := digest.FromBytes(s.Metadata.ImageMetadata.RawManifest).String()
|
|
if manifestDigest != "" {
|
|
d = manifestDigest
|
|
break
|
|
}
|
|
|
|
// calcuate chain ID for image sources where manifestDigest is not available
|
|
// https://github.com/opencontainers/image-spec/blob/main/config.md#layer-chainid
|
|
d = calculateChainID(s.Metadata.ImageMetadata.Layers)
|
|
if d == "" {
|
|
// TODO what happens here if image has no layers?
|
|
// Is this case possible
|
|
d = digest.FromString(s.Metadata.ImageMetadata.UserInput).String()
|
|
}
|
|
default: // for UnknownScheme we hash the struct
|
|
id, _ := artifact.IDByHash(s)
|
|
d = string(id)
|
|
}
|
|
|
|
s.id = artifact.ID(strings.TrimPrefix(d, "sha256:"))
|
|
s.Metadata.ID = strings.TrimPrefix(d, "sha256:")
|
|
}
|
|
|
|
func calculateChainID(lm []LayerMetadata) string {
|
|
if len(lm) < 1 {
|
|
return ""
|
|
}
|
|
|
|
// DiffID(L0) = digest of layer 0
|
|
// https://github.com/anchore/stereoscope/blob/1b1b744a919964f38d14e1416fb3f25221b761ce/pkg/image/layer_metadata.go#L19-L32
|
|
chainID := lm[0].Digest
|
|
id := chain(chainID, lm[1:])
|
|
|
|
return id
|
|
}
|
|
|
|
func chain(chainID string, layers []LayerMetadata) string {
|
|
if len(layers) < 1 {
|
|
return chainID
|
|
}
|
|
|
|
chainID = digest.FromString(layers[0].Digest + " " + chainID).String()
|
|
return chain(chainID, layers[1:])
|
|
}
|
|
|
|
func (s *Source) FileResolver(scope Scope) (FileResolver, error) {
|
|
switch s.Metadata.Scheme {
|
|
case DirectoryScheme, FileScheme:
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
if s.directoryResolver == nil {
|
|
exclusionFunctions, err := getDirectoryExclusionFunctions(s.path, s.Exclusions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolver, err := newDirectoryResolver(s.path, s.base, exclusionFunctions...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create directory resolver: %w", err)
|
|
}
|
|
s.directoryResolver = resolver
|
|
}
|
|
return s.directoryResolver, nil
|
|
case ImageScheme:
|
|
var resolver FileResolver
|
|
var err error
|
|
switch scope {
|
|
case SquashedScope:
|
|
resolver, err = newImageSquashResolver(s.Image)
|
|
case AllLayersScope:
|
|
resolver, err = newAllLayersResolver(s.Image)
|
|
default:
|
|
return nil, fmt.Errorf("bad image scope provided: %+v", scope)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// image tree contains all paths, so we filter out the excluded entries afterwards
|
|
if len(s.Exclusions) > 0 {
|
|
resolver = NewExcludingResolver(resolver, getImageExclusionFunction(s.Exclusions))
|
|
}
|
|
return resolver, nil
|
|
}
|
|
return nil, fmt.Errorf("unable to determine FilePathResolver with current scheme=%q", s.Metadata.Scheme)
|
|
}
|
|
|
|
func unarchiveToTmp(path string, unarchiver archiver.Unarchiver) (string, func(), error) {
|
|
tempDir, err := os.MkdirTemp("", "syft-archive-contents-")
|
|
if err != nil {
|
|
return "", func() {}, fmt.Errorf("unable to create tempdir for archive processing: %w", err)
|
|
}
|
|
|
|
cleanupFn := func() {
|
|
if err := os.RemoveAll(tempDir); err != nil {
|
|
log.Warnf("unable to cleanup archive tempdir: %+v", err)
|
|
}
|
|
}
|
|
|
|
return tempDir, cleanupFn, unarchiver.Unarchive(path, tempDir)
|
|
}
|
|
|
|
func getImageExclusionFunction(exclusions []string) func(string) bool {
|
|
if len(exclusions) == 0 {
|
|
return nil
|
|
}
|
|
// add subpath exclusions
|
|
for _, exclusion := range exclusions {
|
|
exclusions = append(exclusions, exclusion+"/**")
|
|
}
|
|
return func(path string) bool {
|
|
for _, exclusion := range exclusions {
|
|
matches, err := doublestar.Match(exclusion, path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if matches {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
func getDirectoryExclusionFunctions(root string, exclusions []string) ([]pathIndexVisitor, error) {
|
|
if len(exclusions) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// this is what directoryResolver.indexTree is doing to get the absolute path:
|
|
root, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// this handles Windows file paths by converting them to C:/something/else format
|
|
root = filepath.ToSlash(root)
|
|
|
|
if !strings.HasSuffix(root, "/") {
|
|
root += "/"
|
|
}
|
|
|
|
var errors []string
|
|
for idx, exclusion := range exclusions {
|
|
// check exclusions for supported paths, these are all relative to the "scan root"
|
|
if strings.HasPrefix(exclusion, "./") || strings.HasPrefix(exclusion, "*/") || strings.HasPrefix(exclusion, "**/") {
|
|
exclusion = strings.TrimPrefix(exclusion, "./")
|
|
exclusions[idx] = root + exclusion
|
|
} else {
|
|
errors = append(errors, exclusion)
|
|
}
|
|
}
|
|
|
|
if errors != nil {
|
|
return nil, fmt.Errorf("invalid exclusion pattern(s): '%s' (must start with one of: './', '*/', or '**/')", strings.Join(errors, "', '"))
|
|
}
|
|
|
|
return []pathIndexVisitor{
|
|
func(path string, info os.FileInfo, _ error) error {
|
|
for _, exclusion := range exclusions {
|
|
// this is required to handle Windows filepaths
|
|
path = filepath.ToSlash(path)
|
|
matches, err := doublestar.Match(exclusion, path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if matches {
|
|
if info != nil && info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return errSkipPath
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
}, nil
|
|
}
|