diff --git a/go.mod b/go.mod index bafec5e0698..fab954591a0 100644 --- a/go.mod +++ b/go.mod @@ -91,6 +91,7 @@ require ( github.com/OneOfOne/xxhash v1.2.8 github.com/adrg/xdg v0.5.3 github.com/magiconair/properties v1.8.7 + github.com/thediveo/procfsroot v1.0.1 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 ) diff --git a/go.sum b/go.sum index 41ba1194616..54b7be5651d 100644 --- a/go.sum +++ b/go.sum @@ -327,6 +327,8 @@ github.com/go-restruct/restruct v1.2.0-alpha h1:2Lp474S/9660+SJjpVxoKuWX09JsXHSr github.com/go-restruct/restruct v1.2.0-alpha/go.mod h1:KqrpKpn4M8OLznErihXTGLlsXFGeLxHUrLRRI/1YjGk= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -613,6 +615,8 @@ github.com/nwaples/rardecode v1.1.0 h1:vSxaY8vQhOcVr4mm5e8XllHWTiM4JF507A0Katqw7 github.com/nwaples/rardecode v1.1.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -768,6 +772,10 @@ github.com/sylabs/squashfs v1.0.0 h1:xAyMS21ogglkuR5HaY55PCfqY3H32ma9GkasTYo28Zg github.com/sylabs/squashfs v1.0.0/go.mod h1:rhWzvgefq1X+R+LZdts10hfMsTg3g74OfGunW8tvg/4= github.com/terminalstatic/go-xsd-validate v0.1.5 h1:RqpJnf6HGE2CB/lZB1A8BYguk8uRtcvYAPLCF15qguo= github.com/terminalstatic/go-xsd-validate v0.1.5/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= +github.com/thediveo/procfsroot v1.0.1 h1:uJBK+LARIa8fJVyMqgsdZHaK8/XYyLAB0QzQr0zEeIs= +github.com/thediveo/procfsroot v1.0.1/go.mod h1:COuiAyTYS1iy2NP2Uti9YzTxxWqQlNMD57Xvfn65kIk= +github.com/thediveo/success v1.0.1 h1:NVwUOwKUwaN8szjkJ+vsiM2L3sNBFscldoDJ2g2tAPg= +github.com/thediveo/success v1.0.1/go.mod h1:AZ8oUArgbIsCuDEWrzWNQHdKnPbDOLQsWOFj9ynwLt0= github.com/therootcompany/xz v1.0.1 h1:CmOtsn1CbtmyYiusbfmhmkpAAETj0wBIH6kCYaX+xzw= github.com/therootcompany/xz v1.0.1/go.mod h1:3K3UH1yCKgBneZYhuQUvJ9HPD19UEXEI0BWbMn8qNMY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/syft/internal/fileresolver/chroot_context.go b/syft/internal/fileresolver/chroot_context.go index f643411a85b..550520cb5d0 100644 --- a/syft/internal/fileresolver/chroot_context.go +++ b/syft/internal/fileresolver/chroot_context.go @@ -5,8 +5,12 @@ import ( "os" "path" "path/filepath" + "regexp" "strings" + "github.com/thediveo/procfsroot" + + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/internal/windows" ) @@ -21,21 +25,43 @@ type ChrootContext struct { } func NewChrootContextFromCWD(root, base string) (*ChrootContext, error) { - currentWD, err := os.Getwd() + var currentWD string + var err error + + cleanBase, err := NormalizeBaseDirectory(base) + if err != nil { + return nil, err + } + + inProcfs, err := isPathInProcfsPid(base) if err != nil { - return nil, fmt.Errorf("could not get current working directory: %w", err) + return nil, err } + if inProcfs { + currentWD, err = getProcfsCwd(cleanBase) + if err != nil { + return nil, fmt.Errorf("could not get current working directory: %w", err) + } + } else { + currentWD, err = os.Getwd() + if err != nil { + return nil, fmt.Errorf("could not get current working directory: %w", err) + } + } + + log.Tracef("cwd: %q", currentWD) + return NewChrootContext(root, base, currentWD) } func NewChrootContext(root, base, cwd string) (*ChrootContext, error) { - cleanRoot, err := NormalizeRootDirectory(root) + cleanBase, err := NormalizeBaseDirectory(base) if err != nil { return nil, err } - cleanBase, err := NormalizeBaseDirectory(base) + cleanRoot, err := NormalizeRootDirectory(root, cleanBase) if err != nil { return nil, err } @@ -49,25 +75,166 @@ func NewChrootContext(root, base, cwd string) (*ChrootContext, error) { return chroot, chroot.ChangeDirectory(cwd) } -func NormalizeRootDirectory(root string) (string, error) { - cleanRoot, err := filepath.EvalSymlinks(root) +// Evaluate all the symlinks from source until we find the base path, which we +// assume it's a new root filesystem (it can be used as a chroot target). From +// there, all the absolute symbolic links are resolved relative to the base +// path. We return the path (either relative or absolute) that can be used by +// the host to access the directory/file inside the chroot. +// +// If the base is empty or we are running on Windows, this function returns +// filepath.Evalsymlinks(source) +// +// If the source doesn't contain the base path, we do regular symlink +// resolution. +func EvalSymlinksRelativeToBase(source string, base string) (string, error) { + var err error + var index int + var absPath string + var path string + var resolvedPath string + + // For windows we don't support resolving absolute symlinks inside a + // chroot, so we preserve the existing behavior + if base == "" || windows.HostRunningOnWindows() { + return filepath.EvalSymlinks(source) + } + + absBase, err := filepath.Abs(base) + if err != nil { + return "", err + } + + log.Tracef("solving source %q relative to base %q", source, base) + source = filepath.Clean(source) + + // we don't support resolving relative paths when the base is a procfs path + inProcfs, err := isPathInProcfsPid(absBase) + if err != nil { + return "", err + } + + if inProcfs && !filepath.IsAbs(source) { + return "", fmt.Errorf("relative paths are not supported with procfs base") + } + + containedPaths := allContainedPaths(source) + for index, path = range containedPaths { + resolvedPath, err = evalSymlinksExceptProcfs(path) + if err != nil { + return "", err + } + absPath, err = filepath.Abs(resolvedPath) + if err != nil { + return "", err + } + log.Tracef("path %q absPath %q resolvedPath %q\n", path, absPath, resolvedPath) + if strings.HasPrefix(absPath, absBase) { + break + } + } + + // if we don't encounter base, return the resolved path (which could be relative) + // note, the absolutePath is absolute, so we don't want to return that one + if !strings.HasPrefix(absPath, absBase) { + log.Tracef("prefix not found, resolved path = %s", resolvedPath) + return resolvedPath, nil + } + + chrootPath := strings.TrimPrefix(source, path) + if chrootPath == "" { + log.Tracef("resolved path = %s", resolvedPath) + return resolvedPath, nil + } + + log.Tracef("found chroot symlink, chrootPath %q, absPath: %q, base %q, absBase %q, index %d, path %q", chrootPath, absPath, base, absBase, index, path) + + normalizedPath, err := procfsroot.EvalSymlinks(chrootPath, absBase, procfsroot.EvalFullPath) + if err != nil { + return "", fmt.Errorf("could not evaluate source=%q, base=%q absBase=%q symlinks: %w", source, base, absBase, err) + } + + log.Tracef("resolved path = %s", base+normalizedPath) + // we use base instead of absBase, since base could be relative + // it's the same argument as returning resolvedPath instead of absResolvedPath + return base + normalizedPath, nil +} + +func getProcfsCwd(base string) (string, error) { + inProcfs, err := isPathInProcfsPid(base) + if err != nil { + return "", err + } + if !inProcfs { + return "", fmt.Errorf("path %q not in procfs", base) + } + + components := strings.Split(base, "/") + pidStr := components[2] + + processProcfsCwd := filepath.Join("/proc", pidStr, "cwd") + processProcfsCwd, err = os.Readlink(processProcfsCwd) + if err != nil { + return "", err + } + log.Tracef("base: %q, processProcfsCwd %q", base, processProcfsCwd) + return filepath.Join("/proc", pidStr, "root", processProcfsCwd), nil +} + +func NormalizeRootDirectory(root string, base string) (string, error) { + cleanRoot, err := EvalSymlinksRelativeToBase(root, base) if err != nil { return "", fmt.Errorf("could not evaluate root=%q symlinks: %w", root, err) } + return cleanRoot, nil } +func isPathInProcfsPid(path string) (bool, error) { + match, err := regexp.MatchString("/proc/[1-9][0-9]*/root", path) + if err != nil { + return false, err + } + return match, nil +} + +// If both source and base are absolute we support base being a symlink +// This is mainly needed for procfs paths, e.g. /proc/PID/root, where +// PID could be in a different mount namespace, so we can't follow the +// symlink +func evalSymlinksExceptProcfs(path string) (string, error) { + // don't follow symlink for paths in procfs + inProcfs, err := isPathInProcfsPid(path) + if err != nil { + return "", err + } + if inProcfs { + return path, nil + } + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + return "", fmt.Errorf("could not evaluate path=%q err: %w", path, err) + } + return resolvedPath, nil +} + func NormalizeBaseDirectory(base string) (string, error) { + var cleanBase string + var err error if base == "" { return "", nil } - cleanBase, err := filepath.EvalSymlinks(base) + absBase, err := filepath.Abs(base) + if err != nil { + return "", err + } + + cleanBase, err = evalSymlinksExceptProcfs(absBase) if err != nil { return "", fmt.Errorf("could not evaluate base=%q symlinks: %w", base, err) } - return filepath.Abs(cleanBase) + return cleanBase, nil } // Root returns the root path with all symlinks evaluated. @@ -153,18 +320,7 @@ func (r ChrootContext) ToNativeGlob(chrootPath string) (string, error) { return r.ToNativePath(chrootPath) } - responsePath := parts[0] - - if filepath.IsAbs(responsePath) { - // don't allow input to potentially hop above root path - responsePath = path.Join(r.root, responsePath) - } else { - // ensure we take into account any relative difference between the root path and the CWD for relative requests - responsePath = path.Join(r.cwdRelativeToRoot, responsePath) - } - - var err error - responsePath, err = filepath.Abs(responsePath) + responsePath, err := r.ToNativePath(parts[0]) if err != nil { return "", err } diff --git a/syft/internal/fileresolver/chroot_context_test.go b/syft/internal/fileresolver/chroot_context_test.go index 245e08b63f4..01d8a77bf3b 100644 --- a/syft/internal/fileresolver/chroot_context_test.go +++ b/syft/internal/fileresolver/chroot_context_test.go @@ -3,6 +3,7 @@ package fileresolver import ( "os" "path/filepath" + "strconv" "testing" "github.com/stretchr/testify/assert" @@ -13,15 +14,17 @@ func Test_ChrootContext_RequestResponse(t *testing.T) { // / // somewhere/ // outside.txt + // abs-to-path -> /path // root-link -> ./ // path/ // to/ // abs-inside.txt -> /path/to/the/file.txt # absolute link to somewhere inside of the root // rel-inside.txt -> ./the/file.txt # relative link to somewhere inside of the root // the/ - // file.txt + // file.txt // abs-outside.txt -> /somewhere/outside.txt # absolute link to outside of the root // rel-outside -> ../../../somewhere/outside.txt # relative link to outside of the root + // chroot-abs-symlink-to-dir -> /to/the # absolute link to dir inside "path" chroot // testDir, err := os.Getwd() @@ -49,9 +52,22 @@ func Test_ChrootContext_RequestResponse(t *testing.T) { absViaDoubleLinkPathToTheFile := filepath.Join(absViaDoubleLink, "path", "to", "the", "file.txt") absViaDoubleLinkRelOutsidePath := filepath.Join(absViaDoubleLink, "path", "to", "the", "rel-outside.txt") + absChrootBase := filepath.Join(absolute, "path") + relChrootBase := filepath.Join(relative, "path") + chrootAbsSymlinkToDir := filepath.Join(absolute, "path", "to", "chroot-abs-symlink-to-dir") + absAbsToPathFromSomewhere := filepath.Join(absolute, "somewhere", "abs-to-path") + relAbsToPathFromSomewhere := filepath.Join(relative, "somewhere", "abs-to-path") + + thisPid := os.Getpid() + processProcfsRoot := filepath.Join("/proc", strconv.Itoa(thisPid), "root") + processProcfsCwd, err := getProcfsCwd(processProcfsRoot) + assert.NoError(t, err) + cleanup := func() { _ = os.Remove(absAbsInsidePath) _ = os.Remove(absAbsOutsidePath) + _ = os.Remove(chrootAbsSymlinkToDir) + _ = os.Remove(absAbsToPathFromSomewhere) } // ensure the absolute symlinks are cleaned up from any previous runs @@ -59,9 +75,17 @@ func Test_ChrootContext_RequestResponse(t *testing.T) { require.NoError(t, os.Symlink(filepath.Join(absolute, "path", "to", "the", "file.txt"), absAbsInsidePath)) require.NoError(t, os.Symlink(filepath.Join(absolute, "somewhere", "outside.txt"), absAbsOutsidePath)) + require.NoError(t, os.Symlink("/to/the", chrootAbsSymlinkToDir)) + require.NoError(t, os.Symlink(filepath.Join(absolute, "path"), absAbsToPathFromSomewhere)) t.Cleanup(cleanup) + // To enable logging uncomment the following: + // cfg := &clio.LoggingConfig{Level: "trace"} + // l, err := clio.DefaultLogger(clio.Config{Log: cfg}, nil) + // l.(logger.Controller).SetOutput(os.Stdout) + // log.Set(l) + cases := []struct { name string cwd string @@ -452,12 +476,66 @@ func Test_ChrootContext_RequestResponse(t *testing.T) { expectedNativePath: absRelOutsidePath, expectedChrootPath: "to/the/rel-outside.txt", }, + { + name: "absolute symlink to directory relative to the chroot", + root: chrootAbsSymlinkToDir, + base: absChrootBase, + input: "file.txt", + expectedNativePath: filepath.Join(absolute, "path", "to", "the", "file.txt"), + expectedChrootPath: "/to/the/file.txt", + }, + { + name: "absolute symlink to directory relative to the chroot, relative root", + root: filepath.Join(relative, "path", "to", "chroot-abs-symlink-to-dir"), + base: relChrootBase, + input: "file.txt", + expectedNativePath: filepath.Join(absolute, "path", "to", "the", "file.txt"), + expectedChrootPath: "/to/the/file.txt", + }, + { + name: "absolute symlink to directory relative to the chroot, with extra symlink to chroot", + root: filepath.Join(absAbsToPathFromSomewhere, "to", "chroot-abs-symlink-to-dir"), + base: absChrootBase, + input: "file.txt", + expectedNativePath: filepath.Join(absolute, "path", "to", "the", "file.txt"), + expectedChrootPath: "/to/the/file.txt", + }, + { + name: "absolute symlink to directory relative to the chroot, with extra symlink to chroot, relative root", + root: filepath.Join(relAbsToPathFromSomewhere, "to", "chroot-abs-symlink-to-dir"), + base: relChrootBase, + input: "file.txt", + expectedNativePath: filepath.Join(absolute, "path", "to", "the", "file.txt"), + expectedChrootPath: "/to/the/file.txt", + }, + { + name: "_procfs_, abs root, relative request, direct", + cwd: processProcfsCwd, + root: filepath.Join(processProcfsRoot, absolute), + base: processProcfsRoot, + input: "path/to/the/file.txt", + expectedNativePath: filepath.Join(processProcfsRoot, absolute, "path/to/the/file.txt"), + expectedChrootPath: filepath.Join(absolute, "path/to/the/file.txt"), + }, + { + name: "_procfs_, abs root, abs request, direct", + root: filepath.Join(processProcfsRoot, absolute), + base: processProcfsRoot, + input: "/path/to/the/file.txt", + expectedNativePath: filepath.Join(processProcfsRoot, absolute, "path/to/the/file.txt"), + expectedChrootPath: filepath.Join(absolute, "path/to/the/file.txt"), + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { + var targetPath string + if filepath.IsAbs(c.cwd) { + targetPath = c.cwd + } else { + targetPath = filepath.Join(testDir, c.cwd) + } // we need to mimic a shell, otherwise we won't get a path within a symlink - targetPath := filepath.Join(testDir, c.cwd) t.Setenv("PWD", filepath.Clean(targetPath)) require.NoError(t, err) diff --git a/syft/internal/fileresolver/directory_indexer.go b/syft/internal/fileresolver/directory_indexer.go index 2d9101999ed..9e8e645d99c 100644 --- a/syft/internal/fileresolver/directory_indexer.go +++ b/syft/internal/fileresolver/directory_indexer.go @@ -126,13 +126,13 @@ func (r *directoryIndexer) indexTree(root string, stager *progress.Stage) ([]str return roots, nil } - shouldIndexFullTree, err := isRealPath(root) + shouldIndexFullTree, err := isRealPath(root, r.base) if err != nil { return nil, err } if !shouldIndexFullTree { - newRoots, err := r.indexBranch(root, stager) + newRoots, err := r.indexBranch(root, r.base, stager) if err != nil { return nil, fmt.Errorf("unable to index branch=%q: %w", root, err) } @@ -166,21 +166,23 @@ func (r *directoryIndexer) indexTree(root string, stager *progress.Stage) ([]str return roots, nil } -func isRealPath(root string) (bool, error) { +func isRealPath(root string, base string) (bool, error) { rootParent := filepath.Clean(filepath.Dir(root)) - realRootParent, err := filepath.EvalSymlinks(rootParent) + realRootParent, err := EvalSymlinksRelativeToBase(rootParent, base) if err != nil { return false, err } realRootParent = filepath.Clean(realRootParent) + log.Tracef("rootParent %q, realRootParent %q", rootParent, realRootParent) + return rootParent == realRootParent, nil } -func (r *directoryIndexer) indexBranch(root string, stager *progress.Stage) ([]string, error) { - rootRealPath, err := filepath.EvalSymlinks(root) +func (r *directoryIndexer) indexBranch(root string, base string, stager *progress.Stage) ([]string, error) { + rootRealPath, err := EvalSymlinksRelativeToBase(root, base) if err != nil { var pathErr *os.PathError if errors.As(err, &pathErr) { @@ -204,7 +206,7 @@ func (r *directoryIndexer) indexBranch(root string, stager *progress.Stage) ([]s var targetPath string if idx != 0 { parent := path.Dir(p) - cleanParent, err := filepath.EvalSymlinks(parent) + cleanParent, err := EvalSymlinksRelativeToBase(parent, base) if err != nil { return nil, fmt.Errorf("unable to evaluate symlink for contained path parent=%q: %w", parent, err) } diff --git a/syft/internal/fileresolver/directory_test.go b/syft/internal/fileresolver/directory_test.go index 8c271c073f9..4fd4696d06e 100644 --- a/syft/internal/fileresolver/directory_test.go +++ b/syft/internal/fileresolver/directory_test.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "testing" "time" @@ -21,6 +22,7 @@ import ( "go.uber.org/goleak" stereoscopeFile "github.com/anchore/stereoscope/pkg/file" + "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/file" ) @@ -28,15 +30,17 @@ func TestDirectoryResolver_FilesByPath_request_response(t *testing.T) { // / // somewhere/ // outside.txt + // abs-to-path -> /path // root-link -> ./ // path/ // to/ // abs-inside.txt -> /path/to/the/file.txt # absolute link to somewhere inside of the root // rel-inside.txt -> ./the/file.txt # relative link to somewhere inside of the root // the/ - // file.txt + // file.txt // abs-outside.txt -> /somewhere/outside.txt # absolute link to outside of the root // rel-outside -> ../../../somewhere/outside.txt # relative link to outside of the root + // chroot-abs-symlink-to-dir -> /to/the # absolute link to dir inside "path" chroot // testDir, err := os.Getwd() @@ -53,9 +57,23 @@ func TestDirectoryResolver_FilesByPath_request_response(t *testing.T) { relativeViaDoubleLink := filepath.Join(relative, "root-link", "root-link") absoluteViaDoubleLink := filepath.Join(absolute, "root-link", "root-link") + absChrootBase := filepath.Join(absolute, "path") + relChrootBase := filepath.Join(relative, "path") + chrootAbsSymlinkToDir := filepath.Join(absolute, "path", "to", "chroot-abs-symlink-to-dir") + absAbsToPathFromSomewhere := filepath.Join(absolute, "somewhere", "abs-to-path") + relAbsToPathFromSomewhere := filepath.Join(relative, "somewhere", "abs-to-path") + + thisPid := os.Getpid() + processProcfsRoot := filepath.Join("/proc", strconv.Itoa(thisPid), "root") + processProcfsCwd, err := getProcfsCwd(processProcfsRoot) + assert.NoError(t, err) + log.Tracef("cwd: %q", processProcfsCwd) + cleanup := func() { _ = os.Remove(absInsidePath) _ = os.Remove(absOutsidePath) + _ = os.Remove(chrootAbsSymlinkToDir) + _ = os.Remove(absAbsToPathFromSomewhere) } // ensure the absolute symlinks are cleaned up from any previous runs @@ -63,6 +81,8 @@ func TestDirectoryResolver_FilesByPath_request_response(t *testing.T) { require.NoError(t, os.Symlink(filepath.Join(absolute, "path", "to", "the", "file.txt"), absInsidePath)) require.NoError(t, os.Symlink(filepath.Join(absolute, "somewhere", "outside.txt"), absOutsidePath)) + require.NoError(t, os.Symlink("/to/the", chrootAbsSymlinkToDir)) + require.NoError(t, os.Symlink(filepath.Join(absolute, "path"), absAbsToPathFromSomewhere)) t.Cleanup(cleanup) @@ -488,6 +508,56 @@ func TestDirectoryResolver_FilesByPath_request_response(t *testing.T) { expectedRealPath: filepath.Join(absolute, "/somewhere/outside.txt"), expectedAccessPath: "to/the/rel-outside.txt", }, + { + name: "absolute symlink to directory relative to the chroot", + root: chrootAbsSymlinkToDir, + base: absChrootBase, + input: "file.txt", + expectedRealPath: "/to/the/file.txt", + }, + { + name: "absolute symlink to directory relative to the chroot, relative root", + root: filepath.Join(relative, "path", "to", "chroot-abs-symlink-to-dir"), + base: relChrootBase, + input: "file.txt", + expectedRealPath: "/to/the/file.txt", + }, + { + name: "absolute symlink to directory relative to the chroot, relative root via link", + root: filepath.Join(relativeViaLink, "path", "to", "chroot-abs-symlink-to-dir"), + base: relChrootBase, + input: "file.txt", + expectedRealPath: "/to/the/file.txt", + }, + { + name: "absolute symlink to directory relative to the chroot, with extra symlink to chroot", + root: filepath.Join(absAbsToPathFromSomewhere, "to", "chroot-abs-symlink-to-dir"), + base: absChrootBase, + input: "file.txt", + expectedRealPath: "/to/the/file.txt", + }, + { + name: "absolute symlink to directory relative to the chroot, with extra symlink to chroot, relative root", + root: filepath.Join(relAbsToPathFromSomewhere, "to", "chroot-abs-symlink-to-dir"), + base: relChrootBase, + input: "file.txt", + expectedRealPath: "/to/the/file.txt", + }, + { + name: "_procfs_, abs root, relative request, direct", + cwd: processProcfsCwd, + root: filepath.Join(processProcfsRoot, absolute), + base: processProcfsRoot, + input: "path/to/the/file.txt", + expectedRealPath: filepath.Join(absolute, "path/to/the/file.txt"), + }, + { + name: "_procfs_, abs root, abs request, direct", + root: filepath.Join(processProcfsRoot, absolute), + base: processProcfsRoot, + input: "/path/to/the/file.txt", + expectedRealPath: filepath.Join(absolute, "path/to/the/file.txt"), + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -495,8 +565,13 @@ func TestDirectoryResolver_FilesByPath_request_response(t *testing.T) { c.expectedAccessPath = c.expectedRealPath } + var targetPath string + if filepath.IsAbs(c.cwd) { + targetPath = c.cwd + } else { + targetPath = filepath.Join(testDir, c.cwd) + } // we need to mimic a shell, otherwise we won't get a path within a symlink - targetPath := filepath.Join(testDir, c.cwd) t.Setenv("PWD", filepath.Clean(targetPath)) require.NoError(t, err) diff --git a/syft/internal/fileresolver/path_skipper.go b/syft/internal/fileresolver/path_skipper.go index 496aaa01719..6b96be2a343 100644 --- a/syft/internal/fileresolver/path_skipper.go +++ b/syft/internal/fileresolver/path_skipper.go @@ -67,6 +67,13 @@ func newPathSkipperFromMounts(root string, infos []*mountinfo.Info) pathSkipper "tmpfs": {"/run", "/dev", "/var/run", "/var/lock", "/sys"}, } + inProcfs, err := isPathInProcfsPid(root) + if err == nil && inProcfs { + log.Debugf("Including procfs mount types because root %q is in procfs", root) + delete(ignorableMountTypes, "proc") + delete(ignorableMountTypes, "procfs") + } + // The longest path is the most specific path, e.g. // if / is mounted as tmpfs, but /home/syft/permanent is mounted as ext4, // then the mount type for /home/syft/permanent/foo is ext4, and the mount info diff --git a/syft/source/directorysource/directory_source.go b/syft/source/directorysource/directory_source.go index 0ab06980fe5..36eeb51503b 100644 --- a/syft/source/directorysource/directory_source.go +++ b/syft/source/directorysource/directory_source.go @@ -84,8 +84,8 @@ func cleanDirPath(path, base string) string { } if base != "" { - cleanRoot, rootErr := fileresolver.NormalizeRootDirectory(path) cleanBase, baseErr := fileresolver.NormalizeBaseDirectory(base) + cleanRoot, rootErr := fileresolver.NormalizeRootDirectory(path, cleanBase) if rootErr == nil && baseErr == nil { // allows for normalizing inputs: diff --git a/syft/source/filesource/file_source.go b/syft/source/filesource/file_source.go index d810a95c2f9..2b780f14e6b 100644 --- a/syft/source/filesource/file_source.go +++ b/syft/source/filesource/file_source.go @@ -27,6 +27,7 @@ var _ source.Source = (*fileSource)(nil) type Config struct { Path string + Base string Exclude source.ExcludeConfig DigestAlgorithms []crypto.Hash Alias source.Alias @@ -49,7 +50,7 @@ func NewFromPath(path string) (source.Source, error) { } func New(cfg Config) (source.Source, error) { - fileMeta, err := os.Stat(cfg.Path) + fileMeta, err := os.Lstat(cfg.Path) if err != nil { return nil, fmt.Errorf("unable to stat path=%q: %w", cfg.Path, err) } @@ -58,11 +59,26 @@ func New(cfg Config) (source.Source, error) { return nil, fmt.Errorf("given path is a directory: %q", cfg.Path) } - analysisPath, cleanupFn := fileAnalysisPath(cfg.Path) + base, err := fileresolver.NormalizeBaseDirectory(cfg.Base) + if err != nil { + return nil, fmt.Errorf("unable to normalize base=%q: %w", cfg.Base, err) + } + + configPath, err := filepath.Abs(cfg.Path) + if err != nil { + return nil, fmt.Errorf("unable to get absolute path for analysis path=%q: %w", cfg.Path, err) + } + + configPath, err = fileresolver.EvalSymlinksRelativeToBase(configPath, base) + if err != nil { + return nil, fmt.Errorf("cannot resolve symlinks for %q, base [%q]", cfg.Path, base) + } + + analysisPath, cleanupFn := fileAnalysisPath(configPath) var digests []file.Digest if len(cfg.DigestAlgorithms) > 0 { - fh, err := os.Open(cfg.Path) + fh, err := os.Open(configPath) if err != nil { return nil, fmt.Errorf("unable to open file=%q: %w", cfg.Path, err) } @@ -71,13 +87,13 @@ func New(cfg Config) (source.Source, error) { digests, err = intFile.NewDigestsFromFile(fh, cfg.DigestAlgorithms) if err != nil { - return nil, fmt.Errorf("unable to calculate digests for file=%q: %w", cfg.Path, err) + return nil, fmt.Errorf("unable to calculate digests for file=%q: %w", configPath, err) } } - fh, err := os.Open(cfg.Path) + fh, err := os.Open(configPath) if err != nil { - return nil, fmt.Errorf("unable to open file=%q: %w", cfg.Path, err) + return nil, fmt.Errorf("unable to open file=%q: %w", configPath, err) } defer fh.Close() @@ -160,10 +176,7 @@ func (s fileSource) FileResolver(_ source.Scope) (file.Resolver, error) { } isArchiveAnalysis := fi.IsDir() - absParentDir, err := absoluteSymlinkFreePathToParent(s.analysisPath) - if err != nil { - return nil, err - } + absParentDir := filepath.Dir(s.analysisPath) var res *fileresolver.Directory if isArchiveAnalysis { @@ -190,7 +203,7 @@ func (s fileSource) FileResolver(_ source.Scope) (file.Resolver, error) { return fs.SkipDir } - if filepath.Base(p) != filepath.Base(s.config.Path) { + if filepath.Base(p) != filepath.Base(s.analysisPath) { // we're in the root directory, but this is not the file we want to scan... // we should selectively skip this file (not the directory we're in). return fileresolver.ErrSkipPath @@ -210,18 +223,6 @@ func (s fileSource) FileResolver(_ source.Scope) (file.Resolver, error) { return s.resolver, nil } -func absoluteSymlinkFreePathToParent(path string) (string, error) { - absAnalysisPath, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("unable to get absolute path for analysis path=%q: %w", path, err) - } - dereferencedAbsAnalysisPath, err := filepath.EvalSymlinks(absAnalysisPath) - if err != nil { - return "", fmt.Errorf("unable to get absolute path for analysis path=%q: %w", path, err) - } - return filepath.Dir(dereferencedAbsAnalysisPath), nil -} - func (s *fileSource) Close() error { if s.closer == nil { return nil diff --git a/syft/source/filesource/file_source_provider.go b/syft/source/filesource/file_source_provider.go index 2cba170547c..954c1480cca 100644 --- a/syft/source/filesource/file_source_provider.go +++ b/syft/source/filesource/file_source_provider.go @@ -6,14 +6,14 @@ import ( "fmt" "github.com/mitchellh/go-homedir" - "github.com/spf13/afero" "github.com/anchore/syft/syft/source" ) -func NewSourceProvider(path string, exclude source.ExcludeConfig, digestAlgorithms []crypto.Hash, alias source.Alias) source.Provider { +func NewSourceProvider(path string, exclude source.ExcludeConfig, digestAlgorithms []crypto.Hash, alias source.Alias, basePath string) source.Provider { return &fileSourceProvider{ path: path, + basePath: basePath, exclude: exclude, digestAlgorithms: digestAlgorithms, alias: alias, @@ -22,6 +22,7 @@ func NewSourceProvider(path string, exclude source.ExcludeConfig, digestAlgorith type fileSourceProvider struct { path string + basePath string exclude source.ExcludeConfig digestAlgorithms []crypto.Hash alias source.Alias @@ -37,19 +38,10 @@ func (p fileSourceProvider) Provide(_ context.Context) (source.Source, error) { return nil, fmt.Errorf("unable to expand potential directory path: %w", err) } - fs := afero.NewOsFs() - fileMeta, err := fs.Stat(location) - if err != nil { - return nil, fmt.Errorf("unable to stat location: %w", err) - } - - if fileMeta.IsDir() { - return nil, fmt.Errorf("not a file source: %s", p.path) - } - return New( Config{ Path: location, + Base: p.basePath, Exclude: p.exclude, DigestAlgorithms: p.digestAlgorithms, Alias: p.alias, diff --git a/syft/source/filesource/file_source_test.go b/syft/source/filesource/file_source_test.go index 1f046d253b3..1ae2048ca25 100644 --- a/syft/source/filesource/file_source_test.go +++ b/syft/source/filesource/file_source_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "path" "path/filepath" + "strconv" "syscall" "testing" @@ -21,9 +22,29 @@ import ( func TestNewFromFile(t *testing.T) { testutil.Chdir(t, "..") // run with source/test-fixtures + testDir, err := os.Getwd() + require.NoError(t, err) + + absoluteSymlinkInsideChroot := filepath.Join(testDir, "test-fixtures", "absolute-symlink") + + thisPid := os.Getpid() + processProcfsRoot := filepath.Join("/proc", strconv.Itoa(thisPid), "root") + + cleanup := func() { + _ = os.Remove(absoluteSymlinkInsideChroot) + } + + // ensure the absolute symlinks are cleaned up from any previous runs + cleanup() + + require.NoError(t, os.Symlink("/file-index-filter/.vimrc", absoluteSymlinkInsideChroot)) + + t.Cleanup(cleanup) + testCases := []struct { desc string input string + base string expString string testPathFn func(file.Resolver) ([]file.Location, error) expRefs int @@ -68,11 +89,30 @@ func TestNewFromFile(t *testing.T) { }, expRefs: 1, }, + { + desc: "absolute symlink inside chroot", + input: absoluteSymlinkInsideChroot, + base: filepath.Join(testDir, "test-fixtures"), + testPathFn: func(resolver file.Resolver) ([]file.Location, error) { + return resolver.FilesByPath(".vimrc") + }, + expRefs: 1, + }, + { + desc: "file in procfs", + input: filepath.Join(testDir, "test-fixtures", "file-index-filter", ".vimrc"), + base: filepath.Join(processProcfsRoot), + testPathFn: func(resolver file.Resolver) ([]file.Location, error) { + return resolver.FilesByPath(".vimrc") + }, + expRefs: 1, + }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { src, err := New(Config{ Path: test.input, + Base: test.base, }) require.NoError(t, err) t.Cleanup(func() { @@ -88,7 +128,12 @@ func TestNewFromFile(t *testing.T) { require.NoError(t, err) require.Len(t, refs, test.expRefs) if test.expRefs == 1 { - assert.Equal(t, path.Base(test.input), path.Base(refs[0].RealPath)) + fileMeta, err := os.Lstat(test.input) + require.NoError(t, err) + // if it's not a symlink we expect the base names to match + if fileMeta.Mode()&os.ModeSymlink == 0 { + assert.Equal(t, path.Base(test.input), path.Base(refs[0].RealPath)) + } } }) diff --git a/syft/source/sourceproviders/source_providers.go b/syft/source/sourceproviders/source_providers.go index 107f3ad4825..c5c15b6c3aa 100644 --- a/syft/source/sourceproviders/source_providers.go +++ b/syft/source/sourceproviders/source_providers.go @@ -26,7 +26,7 @@ func All(userInput string, cfg *Config) []collections.TaggedValue[source.Provide return collections.TaggedValueSet[source.Provider]{}. // --from file, dir, oci-archive, etc. Join(stereoscopeProviders.Select(FileTag, DirTag)...). - Join(tagProvider(filesource.NewSourceProvider(userInput, cfg.Exclude, cfg.DigestAlgorithms, cfg.Alias), FileTag)). + Join(tagProvider(filesource.NewSourceProvider(userInput, cfg.Exclude, cfg.DigestAlgorithms, cfg.Alias, cfg.BasePath), FileTag)). Join(tagProvider(directorysource.NewSourceProvider(userInput, cfg.Exclude, cfg.Alias, cfg.BasePath), DirTag)). // --from docker, registry, etc.