Add ability to scan snaps (as a source) (#3929)

This commit is contained in:
Alex Goodman
2025-06-25 16:53:35 -04:00
committed by GitHub
parent 4eb8ba4575
commit 2bda086423
63 changed files with 5060 additions and 175 deletions
+32 -2
View File
@@ -36,8 +36,8 @@ func Notify(message string) {
})
}
func StartCatalogerTask(info monitor.GenericTask, size int64, initialStage string) *monitor.CatalogerTaskProgress {
t := &monitor.CatalogerTaskProgress{
func StartCatalogerTask(info monitor.GenericTask, size int64, initialStage string) *monitor.TaskProgress {
t := &monitor.TaskProgress{
AtomicStage: progress.NewAtomicStage(initialStage),
Manual: progress.NewManual(size),
}
@@ -50,3 +50,33 @@ func StartCatalogerTask(info monitor.GenericTask, size int64, initialStage strin
return t
}
func StartPullSourceTask(info monitor.GenericTask, size int64, initialStage string) *monitor.TaskProgress {
t := &monitor.TaskProgress{
AtomicStage: progress.NewAtomicStage(initialStage),
Manual: progress.NewManual(size),
}
Publish(partybus.Event{
Type: event.PullSourceStarted,
Source: info,
Value: progress.StagedProgressable(t),
})
return t
}
func StartIndexingFiles(path string) *monitor.TaskProgress {
t := &monitor.TaskProgress{
AtomicStage: progress.NewAtomicStage(""),
Manual: progress.NewManual(-1),
}
Publish(partybus.Event{
Type: event.FileIndexingStarted,
Source: path,
Value: progress.StagedProgressable(t),
})
return t
}
+154
View File
@@ -0,0 +1,154 @@
package file
import (
"fmt"
"io"
"net/http"
"github.com/hashicorp/go-getter"
"github.com/hashicorp/go-getter/helper/url"
"github.com/wagoodman/go-progress"
"github.com/anchore/clio"
"github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/syft/internal"
)
var (
archiveExtensions = getterDecompressorNames()
ErrNonArchiveSource = fmt.Errorf("non-archive sources are not supported for directory destinations")
)
type Getter interface {
// GetFile downloads the give URL into the given path. The URL must reference a single file.
GetFile(dst, src string, monitor ...*progress.Manual) error
// GetToDir downloads the resource found at the `src` URL into the given `dst` directory.
// The directory must already exist, and the remote resource MUST BE AN ARCHIVE (e.g. `.tar.gz`).
GetToDir(dst, src string, monitor ...*progress.Manual) error
}
type HashiGoGetter struct {
httpGetter getter.HttpGetter
}
// NewGetter creates and returns a new Getter. Providing an http.Client is optional. If one is provided,
// it will be used for all HTTP(S) getting; otherwise, go-getter's default getters will be used.
func NewGetter(id clio.Identification, httpClient *http.Client) *HashiGoGetter {
return &HashiGoGetter{
httpGetter: getter.HttpGetter{
Client: httpClient,
Header: http.Header{
"User-Agent": []string{fmt.Sprintf("%v %v", id.Name, id.Version)},
},
},
}
}
func (g HashiGoGetter) GetFile(dst, src string, monitors ...*progress.Manual) error {
if len(monitors) > 1 {
return fmt.Errorf("multiple monitors provided, which is not allowed")
}
return getterClient(dst, src, false, g.httpGetter, monitors).Get()
}
func (g HashiGoGetter) GetToDir(dst, src string, monitors ...*progress.Manual) error {
// though there are multiple getters, only the http/https getter requires extra validation
if err := validateHTTPSource(src); err != nil {
return err
}
if len(monitors) > 1 {
return fmt.Errorf("multiple monitors provided, which is not allowed")
}
return getterClient(dst, src, true, g.httpGetter, monitors).Get()
}
func validateHTTPSource(src string) error {
// we are ignoring any sources that are not destined to use the http getter object
if !internal.HasAnyOfPrefixes(src, "http://", "https://") {
return nil
}
u, err := url.Parse(src)
if err != nil {
return fmt.Errorf("bad URL provided %q: %w", src, err)
}
// only allow for sources with archive extensions
if !internal.HasAnyOfSuffixes(u.Path, archiveExtensions...) {
return ErrNonArchiveSource
}
return nil
}
func getterClient(dst, src string, dir bool, httpGetter getter.HttpGetter, monitors []*progress.Manual) *getter.Client {
client := &getter.Client{
Src: src,
Dst: dst,
Dir: dir,
Getters: map[string]getter.Getter{
"http": &httpGetter,
"https": &httpGetter,
// note: these are the default getters from https://github.com/hashicorp/go-getter/blob/v1.5.9/get.go#L68-L74
// it is possible that other implementations need to account for custom httpclient injection, however,
// that has not been accounted for at this time.
"file": new(getter.FileGetter),
"git": new(getter.GitGetter),
"gcs": new(getter.GCSGetter),
"hg": new(getter.HgGetter),
"s3": new(getter.S3Getter),
},
Options: mapToGetterClientOptions(monitors),
}
return client
}
func withProgress(monitor *progress.Manual) func(client *getter.Client) error {
return getter.WithProgress(
&progressAdapter{monitor: monitor},
)
}
func mapToGetterClientOptions(monitors []*progress.Manual) []getter.ClientOption {
var result []getter.ClientOption
for _, monitor := range monitors {
result = append(result, withProgress(monitor))
}
// derived from https://github.com/hashicorp/go-getter/blob/v2.2.3/decompress.go#L23-L63
fileSizeLimit := int64(5 * file.GB)
dec := getter.LimitedDecompressors(0, fileSizeLimit)
result = append(result, getter.WithDecompressors(dec))
return result
}
type readCloser struct {
progress.Reader
}
func (c *readCloser) Close() error { return nil }
type progressAdapter struct {
monitor *progress.Manual
}
func (a *progressAdapter) TrackProgress(_ string, currentSize, totalSize int64, stream io.ReadCloser) io.ReadCloser {
a.monitor.Set(currentSize)
a.monitor.SetTotal(totalSize)
return &readCloser{
Reader: *progress.NewProxyReader(stream, a.monitor),
}
}
func getterDecompressorNames() (names []string) {
for name := range getter.Decompressors {
names = append(names, name)
}
return names
}
+268
View File
@@ -0,0 +1,268 @@
package file
import (
"archive/tar"
"bytes"
"context"
"crypto/x509"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path"
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/clio"
)
func TestGetter_GetFile(t *testing.T) {
testCases := []struct {
name string
prepareClient func(*http.Client)
assert assert.ErrorAssertionFunc
}{
{
name: "client trusts server's CA",
assert: assert.NoError,
},
{
name: "client doesn't trust server's CA",
prepareClient: removeTrustedCAs,
assert: assertUnknownAuthorityError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestPath := "/foo"
server := newTestServer(t, withResponseForPath(t, requestPath, testFileContent))
t.Cleanup(server.Close)
httpClient := getClient(t, server)
if tc.prepareClient != nil {
tc.prepareClient(httpClient)
}
getter := NewGetter(testID, httpClient)
requestURL := createRequestURL(t, server, requestPath)
tempDir := t.TempDir()
tempFile := path.Join(tempDir, "some-destination-file")
err := getter.GetFile(tempFile, requestURL)
tc.assert(t, err)
})
}
}
func TestGetter_GetToDir_FilterNonArchivesWired(t *testing.T) {
testCases := []struct {
name string
source string
assert assert.ErrorAssertionFunc
}{
{
name: "error out on non-archive sources",
source: "http://localhost/something.txt",
assert: assertErrNonArchiveSource,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
test.assert(t, NewGetter(testID, nil).GetToDir(t.TempDir(), test.source))
})
}
}
func TestGetter_validateHttpSource(t *testing.T) {
testCases := []struct {
name string
source string
assert assert.ErrorAssertionFunc
}{
{
name: "error out on non-archive sources",
source: "http://localhost/something.txt",
assert: assertErrNonArchiveSource,
},
{
name: "filter out non-archive sources with get param",
source: "https://localhost/vulnerability-db_v3_2021-11-21T08:15:44Z.txt?checksum=sha256%3Ac402d01fa909a3fa85a5c6733ef27a3a51a9105b6c62b9152adbd24c08358911",
assert: assertErrNonArchiveSource,
},
{
name: "ignore non http-https input",
source: "s3://bucket/something.txt",
assert: assert.NoError,
},
}
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
test.assert(t, validateHTTPSource(test.source))
})
}
}
func TestGetter_GetToDir_CertConcerns(t *testing.T) {
testCases := []struct {
name string
prepareClient func(*http.Client)
assert assert.ErrorAssertionFunc
}{
{
name: "client trusts server's CA",
assert: assert.NoError,
},
{
name: "client doesn't trust server's CA",
prepareClient: removeTrustedCAs,
assert: assertUnknownAuthorityError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
requestPath := "/foo.tar"
tarball := createTarball("foo", testFileContent)
server := newTestServer(t, withResponseForPath(t, requestPath, tarball))
t.Cleanup(server.Close)
httpClient := getClient(t, server)
if tc.prepareClient != nil {
tc.prepareClient(httpClient)
}
getter := NewGetter(testID, httpClient)
requestURL := createRequestURL(t, server, requestPath)
tempDir := t.TempDir()
err := getter.GetToDir(tempDir, requestURL)
tc.assert(t, err)
})
}
}
func assertUnknownAuthorityError(t assert.TestingT, err error, _ ...interface{}) bool {
return assert.ErrorAs(t, err, &x509.UnknownAuthorityError{})
}
func assertErrNonArchiveSource(t assert.TestingT, err error, _ ...interface{}) bool {
return assert.ErrorIs(t, err, ErrNonArchiveSource)
}
func removeTrustedCAs(client *http.Client) {
client.Transport.(*http.Transport).TLSClientConfig.RootCAs = x509.NewCertPool()
}
// createTarball makes a single-file tarball and returns it as a byte slice.
func createTarball(filename string, content []byte) []byte {
tarBuffer := new(bytes.Buffer)
tarWriter := tar.NewWriter(tarBuffer)
tarWriter.WriteHeader(&tar.Header{
Name: filename,
Size: int64(len(content)),
Mode: 0600,
})
tarWriter.Write(content)
tarWriter.Close()
return tarBuffer.Bytes()
}
type muxOption func(mux *http.ServeMux)
func withResponseForPath(t *testing.T, path string, response []byte) muxOption {
t.Helper()
return func(mux *http.ServeMux) {
mux.HandleFunc(path, func(w http.ResponseWriter, req *http.Request) {
t.Logf("server handling request: %s %s", req.Method, req.URL)
_, err := w.Write(response)
if err != nil {
t.Fatal(err)
}
})
}
}
var testID = clio.Identification{
Name: "test-app",
Version: "v0.5.3",
}
func newTestServer(t *testing.T, muxOptions ...muxOption) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
for _, option := range muxOptions {
option(mux)
}
server := httptest.NewTLSServer(mux)
t.Logf("new TLS server listening at %s", getHost(t, server))
return server
}
func createRequestURL(t *testing.T, server *httptest.Server, path string) string {
t.Helper()
// TODO: Figure out how to get this value from the server without hardcoding it here
const testServerCertificateName = "example.com"
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
// Set URL hostname to value from TLS certificate
serverURL.Host = fmt.Sprintf("%s:%s", testServerCertificateName, serverURL.Port())
serverURL.Path = path
return serverURL.String()
}
// getClient returns an http.Client that can be used to contact the test TLS server.
func getClient(t *testing.T, server *httptest.Server) *http.Client {
t.Helper()
httpClient := server.Client()
transport := httpClient.Transport.(*http.Transport)
serverHost := getHost(t, server)
transport.DialContext = func(_ context.Context, _, addr string) (net.Conn, error) {
t.Logf("client dialing %q for host %q", serverHost, addr)
// Ensure the client dials our test server
return net.Dial("tcp", serverHost)
}
return httpClient
}
// getHost extracts the host value from a server URL string.
// e.g. given a server with URL "http://1.2.3.4:5000/foo", getHost returns "1.2.3.4:5000"
func getHost(t *testing.T, server *httptest.Server) string {
t.Helper()
u, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
return u.Hostname() + ":" + u.Port()
}
var testFileContent = []byte("This is the content of a test file!\n")
+89
View File
@@ -0,0 +1,89 @@
package file
import (
"errors"
"io/fs"
"os"
"path/filepath"
"github.com/diskfs/go-diskfs/filesystem"
)
type WalkDiskDirFunc func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error
// WalkDiskDir walks the file tree within the go-diskfs filesystem at root, calling fn for each file or directory in the tree, including root.
// This is meant to mimic the behavior of fs.WalkDir in the standard library.
func WalkDiskDir(fsys filesystem.FileSystem, root string, fn WalkDiskDirFunc) error {
infos, err := fsys.ReadDir(root)
if err != nil {
return err
}
if len(infos) == 0 {
return nil
}
for _, info := range infos {
p := filepath.Join(root, info.Name())
err = walkDiskDir(fsys, p, info, fn)
if err != nil {
if errors.Is(err, fs.SkipDir) {
continue
}
if errors.Is(err, fs.SkipAll) {
return nil
}
return err
}
}
return err
}
func walkDiskDir(fsys filesystem.FileSystem, name string, d os.FileInfo, walkDirFn WalkDiskDirFunc) error {
if err := walkDirFn(fsys, name, d, nil); err != nil {
if errors.Is(err, fs.SkipDir) && (d == nil || d.IsDir()) {
return nil
}
return err
}
isDir := d != nil && d.IsDir()
if d == nil {
_, err := fsys.ReadDir(name)
if err != nil {
return nil
}
isDir = true
}
if !isDir {
return nil
}
dirs, err := fsys.ReadDir(name)
if err != nil {
err = walkDirFn(fsys, name, d, err)
if err != nil {
if errors.Is(err, fs.SkipDir) {
return nil
}
return err
}
}
for _, d1 := range dirs {
name1 := filepath.Join(name, d1.Name())
if err := walkDiskDir(fsys, name1, d1, walkDirFn); err != nil {
if errors.Is(err, fs.SkipDir) {
break
}
if errors.Is(err, fs.SkipAll) {
return err
}
return err
}
}
return nil
}
+250
View File
@@ -0,0 +1,250 @@
package file
import (
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/diskfs/go-diskfs/backend/file"
"github.com/diskfs/go-diskfs/filesystem"
"github.com/diskfs/go-diskfs/filesystem/squashfs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func createTestFS(t *testing.T) filesystem.FileSystem {
dir := t.TempDir()
filename := "test.squashfs"
f, err := os.Create(filepath.Join(dir, filename))
require.NoError(t, err)
b := file.New(f, false)
fsys, err := squashfs.Create(b, 0, 0, 4096)
require.NoError(t, err)
testFiles := []struct {
path string
content string
isDir bool
}{
{"/file1.txt", "content of file1", false},
{"/file2.txt", "content of file2", false},
{"/dir1", "", true},
{"/dir1/subfile1.txt", "content of subfile1", false},
{"/dir1/subfile2.txt", "content of subfile2", false},
{"/dir1/subdir1", "", true},
{"/dir1/subdir1/deepfile.txt", "deep content", false},
{"/dir2", "", true},
{"/dir2/anotherfile.txt", "another content", false},
{"/emptydir", "", true},
}
for _, tf := range testFiles {
if tf.isDir {
err := fsys.Mkdir(tf.path)
require.NoError(t, err)
} else {
f, err := fsys.OpenFile(tf.path, os.O_CREATE|os.O_RDWR)
require.NoError(t, err)
_, err = f.Write([]byte(tf.content))
require.NoError(t, err)
f.Close()
}
}
return fsys
}
func TestWalkDiskDir_CompleteTraversal(t *testing.T) {
fsys := createTestFS(t)
var visitedPaths []string
err := WalkDiskDir(fsys, "/", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
require.NoError(t, err)
visitedPaths = append(visitedPaths, path)
return nil
})
require.NoError(t, err)
expectedPaths := []string{
"/file1.txt",
"/file2.txt",
"/dir1",
"/dir1/subfile1.txt",
"/dir1/subfile2.txt",
"/dir1/subdir1",
"/dir1/subdir1/deepfile.txt",
"/dir2",
"/dir2/anotherfile.txt",
"/emptydir",
}
assert.ElementsMatch(t, expectedPaths, visitedPaths)
}
func TestWalkDiskDir_FileInfoCorrect(t *testing.T) {
fsys := createTestFS(t)
var fileInfos []struct {
path string
isDir bool
name string
}
err := WalkDiskDir(fsys, "/", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
require.NoError(t, err)
require.NotNil(t, d)
fileInfos = append(fileInfos, struct {
path string
isDir bool
name string
}{
path: path,
isDir: d.IsDir(),
name: d.Name(),
})
return nil
})
require.NoError(t, err)
for _, fi := range fileInfos {
expectedName := filepath.Base(fi.path)
assert.Equal(t, expectedName, fi.name)
if fi.path == "/dir1" || fi.path == "/dir2" || fi.path == "/emptydir" || fi.path == "/dir1/subdir1" {
assert.True(t, fi.isDir, "Expected %s to be directory", fi.path)
} else {
assert.False(t, fi.isDir, "Expected %s to be file", fi.path)
}
}
}
func TestWalkDiskDir_SkipDir(t *testing.T) {
fsys := createTestFS(t)
var visitedPaths []string
err := WalkDiskDir(fsys, "/", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
require.NoError(t, err)
visitedPaths = append(visitedPaths, path)
if path == "/dir1" {
return fs.SkipDir
}
return nil
})
require.NoError(t, err)
assert.Contains(t, visitedPaths, "/dir1")
assert.NotContains(t, visitedPaths, "/dir1/subfile1.txt")
assert.NotContains(t, visitedPaths, "/dir1/subfile2.txt")
assert.NotContains(t, visitedPaths, "/dir1/subdir1")
assert.NotContains(t, visitedPaths, "/dir1/subdir1/deepfile.txt")
assert.Contains(t, visitedPaths, "/dir2")
assert.Contains(t, visitedPaths, "/dir2/anotherfile.txt")
}
func TestWalkDiskDir_SkipAll(t *testing.T) {
fsys := createTestFS(t)
var visitedPaths []string
err := WalkDiskDir(fsys, "/", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
require.NoError(t, err)
visitedPaths = append(visitedPaths, path)
if path == "/dir1" {
return fs.SkipAll
}
return nil
})
require.NoError(t, err)
assert.Contains(t, visitedPaths, "/dir1")
assert.NotContains(t, visitedPaths, "/file1.txt")
assert.NotContains(t, visitedPaths, "/file2.txt")
assert.NotContains(t, visitedPaths, "/dir1/subfile1.txt")
assert.NotContains(t, visitedPaths, "/dir2")
assert.NotContains(t, visitedPaths, "/dir2/anotherfile.txt")
assert.NotContains(t, visitedPaths, "/emptydir")
}
func TestWalkDiskDir_EmptyDirectory(t *testing.T) {
fs := createTestFS(t)
var visitedPaths []string
err := WalkDiskDir(fs, "/emptydir", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
require.NoError(t, err)
visitedPaths = append(visitedPaths, path)
return nil
})
require.NoError(t, err)
assert.Empty(t, visitedPaths)
}
func TestWalkDiskDir_NonexistentPath(t *testing.T) {
fs := createTestFS(t)
err := WalkDiskDir(fs, "/nonexistent", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
return nil
})
assert.Error(t, err)
}
func TestWalkDiskDir_WalkFunctionError(t *testing.T) {
fs := createTestFS(t)
customErr := assert.AnError
err := WalkDiskDir(fs, "/", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
if path == "/file1.txt" {
return customErr
}
return nil
})
assert.Error(t, err)
assert.Equal(t, customErr, err)
}
func TestWalkDiskDir_SubdirectoryTraversal(t *testing.T) {
fs := createTestFS(t)
var visitedPaths []string
err := WalkDiskDir(fs, "/dir1", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
require.NoError(t, err)
visitedPaths = append(visitedPaths, path)
return nil
})
require.NoError(t, err)
expectedPaths := []string{
"/dir1/subfile1.txt",
"/dir1/subfile2.txt",
"/dir1/subdir1",
"/dir1/subdir1/deepfile.txt",
}
assert.ElementsMatch(t, expectedPaths, visitedPaths)
}
func TestWalkDiskDir_SingleFile(t *testing.T) {
fs := createTestFS(t)
var visitedPaths []string
err := WalkDiskDir(fs, "/file1.txt", func(fsys filesystem.FileSystem, path string, d os.FileInfo, err error) error {
require.NoError(t, err)
visitedPaths = append(visitedPaths, path)
return nil
})
// we are providing a file path, not a directory
require.Error(t, err)
assert.Empty(t, visitedPaths)
}
+11
View File
@@ -13,6 +13,17 @@ func HasAnyOfPrefixes(input string, prefixes ...string) bool {
return false
}
// HasAnyOfSuffixes returns an indication if the given string has any of the given suffixes.
func HasAnyOfSuffixes(input string, suffixes ...string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(input, suffix) {
return true
}
}
return false
}
func TruncateMiddleEllipsis(input string, maxLen int) string {
if len(input) <= maxLen {
return input
+57
View File
@@ -7,6 +7,63 @@ import (
"github.com/stretchr/testify/assert"
)
func TestHasAnyOfSuffixes(t *testing.T) {
tests := []struct {
name string
input string
suffixes []string
expected bool
}{
{
name: "go case",
input: "this has something",
suffixes: []string{
"has something",
"has NOT something",
},
expected: true,
},
{
name: "no match",
input: "this has something",
suffixes: []string{
"has NOT something",
},
expected: false,
},
{
name: "empty",
input: "this has something",
suffixes: []string{},
expected: false,
},
{
name: "positive match last",
input: "this has something",
suffixes: []string{
"that does not have",
"something",
},
expected: true,
},
{
name: "empty input",
input: "",
suffixes: []string{
"that does not have",
"this has",
},
expected: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.expected, HasAnyOfSuffixes(test.input, test.suffixes...))
})
}
}
func TestHasAnyOfPrefixes(t *testing.T) {
tests := []struct {
name string
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"github.com/anchore/syft/syft/sbom"
)
func RunTask(ctx context.Context, tsk Task, resolver file.Resolver, s sbomsync.Builder, prog *monitor.CatalogerTaskProgress) error {
func RunTask(ctx context.Context, tsk Task, resolver file.Resolver, s sbomsync.Builder, prog *monitor.TaskProgress) error {
err := runTaskSafely(ctx, tsk, resolver, s)
unknowns, remainingErrors := unknown.ExtractCoordinateErrors(err)
if len(unknowns) > 0 {
+1 -1
View File
@@ -17,7 +17,7 @@ func Test_TaskExecutor_PanicHandling(t *testing.T) {
panic("something bad happened")
})
err := RunTask(context.Background(), tsk, nil, nil, &monitor.CatalogerTaskProgress{
err := RunTask(context.Background(), tsk, nil, nil, &monitor.TaskProgress{
Manual: progress.NewManual(-1),
})