fix: only skip tmpfs mounts for some paths (#2918)

* fix: only skip tmpfs mounts for some paths

Signed-off-by: Will Murphy <will.murphy@anchore.com>

* refactor and add tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* add regression test for archive processing

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* bump to golang 1.22

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* remove rule 1 and add more tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Will Murphy <will.murphy@anchore.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
William Murphy 2024-06-04 15:21:45 -04:00 committed by GitHub
parent cb09dd9e19
commit 557ad73ee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 669 additions and 199 deletions

View file

@ -5,7 +5,7 @@ inputs:
go-version:
description: "Go version to install"
required: true
default: "1.21.x"
default: "1.22.x"
go-dependencies:
description: "Download go dependencies"
required: true

2
go.mod
View file

@ -1,6 +1,6 @@
module github.com/anchore/syft
go 1.21.0
go 1.22.0
require (
github.com/CycloneDX/cyclonedx-go v0.8.0

View file

@ -9,13 +9,11 @@ import (
"path/filepath"
"strings"
"github.com/moby/sys/mountinfo"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/anchore/stereoscope/pkg/file"
"github.com/anchore/stereoscope/pkg/filetree"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/internal/bus"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/event"
@ -43,7 +41,8 @@ func newDirectoryIndexer(path, base string, visitors ...PathIndexVisitor) *direc
[]PathIndexVisitor{
requireFileInfo,
disallowByFileType,
newUnixSystemMountFinder().disallowUnixSystemRuntimePath},
skipPathsByMountTypeAndName(path),
},
visitors...,
),
errPaths: make(map[string]error),
@ -450,57 +449,6 @@ func (r *directoryIndexer) disallowRevisitingVisitor(_, path string, _ os.FileIn
return nil
}
type unixSystemMountFinder struct {
disallowedMountPaths []string
}
func newUnixSystemMountFinder() unixSystemMountFinder {
infos, err := mountinfo.GetMounts(nil)
if err != nil {
log.WithFields("error", err).Warnf("unable to get system mounts")
return unixSystemMountFinder{}
}
return unixSystemMountFinder{
disallowedMountPaths: keepUnixSystemMountPaths(infos),
}
}
func keepUnixSystemMountPaths(infos []*mountinfo.Info) []string {
var mountPaths []string
for _, info := range infos {
if info == nil {
continue
}
// we're only interested in ignoring the logical filesystems typically found at these mount points:
// - /proc
// - procfs
// - proc
// - /sys
// - sysfs
// - /dev
// - devfs - BSD/darwin flavored systems and old linux systems
// - devtmpfs - driver core maintained /dev tmpfs
// - udev - userspace implementation that replaced devfs
// - tmpfs - used for /dev in special instances (within a container)
switch info.FSType {
case "proc", "procfs", "sysfs", "devfs", "devtmpfs", "udev", "tmpfs":
log.WithFields("mountpoint", info.Mountpoint).Debug("ignoring system mountpoint")
mountPaths = append(mountPaths, info.Mountpoint)
}
}
return mountPaths
}
func (f unixSystemMountFinder) disallowUnixSystemRuntimePath(_, path string, _ os.FileInfo, _ error) error {
if internal.HasAnyOfPrefixes(path, f.disallowedMountPaths...) {
return fs.SkipDir
}
return nil
}
func disallowByFileType(_, _ string, info os.FileInfo, _ error) error {
if info == nil {
// we can't filter out by filetype for non-existent files

View file

@ -10,7 +10,6 @@ import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/moby/sys/mountinfo"
"github.com/scylladb/go-set/strset"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -462,145 +461,3 @@ func relativePath(basePath, givenPath string) string {
return relPath
}
func Test_disallowUnixSystemRuntimePath(t *testing.T) {
unixSubject := unixSystemMountFinder{
// mock out detecting the mount points
disallowedMountPaths: []string{"/proc", "/sys", "/dev"},
}
tests := []struct {
name string
path string
base string
expected error
}{
{
name: "relative path to proc is allowed",
path: "proc/place",
},
{
name: "relative path within proc is not allowed",
path: "/proc/place",
expected: fs.SkipDir,
},
{
name: "path exactly to proc is not allowed",
path: "/proc",
expected: fs.SkipDir,
},
{
name: "similar to proc",
path: "/pro/c",
},
{
name: "similar to proc",
path: "/pro",
},
{
name: "dev is not allowed",
path: "/dev",
expected: fs.SkipDir,
},
{
name: "sys is not allowed",
path: "/sys",
expected: fs.SkipDir,
},
{
name: "unrelated allowed path",
path: "/something/sys",
},
{
name: "do not consider base when matching paths (non-matching)",
base: "/a/b/c",
path: "/a/b/c/dev",
},
{
name: "do not consider base when matching paths (matching)",
base: "/a/b/c",
path: "/dev",
expected: fs.SkipDir,
},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
assert.Equal(t, test.expected, unixSubject.disallowUnixSystemRuntimePath(test.base, test.path, nil, nil))
})
}
}
func Test_keepUnixSystemMountPaths(t *testing.T) {
tests := []struct {
name string
infos []*mountinfo.Info
want []string
}{
{
name: "all valid filesystems",
infos: []*mountinfo.Info{
{
Mountpoint: "/etc/hostname",
FSType: "/dev/vda1",
},
{
Mountpoint: "/sys/fs/cgroup",
FSType: "cgroup",
},
{
Mountpoint: "/",
FSType: "overlay",
},
},
want: nil,
},
{
name: "no valid filesystems",
infos: []*mountinfo.Info{
{
Mountpoint: "/proc",
FSType: "proc",
},
{
Mountpoint: "/proc-2",
FSType: "procfs",
},
{
Mountpoint: "/sys",
FSType: "sysfs",
},
{
Mountpoint: "/dev",
FSType: "devfs",
},
{
Mountpoint: "/dev-u",
FSType: "udev",
},
{
Mountpoint: "/dev-tmp",
FSType: "devtmpfs",
},
{
Mountpoint: "/run",
FSType: "tmpfs",
},
},
want: []string{
"/proc",
"/proc-2",
"/sys",
"/dev",
"/dev-u",
"/dev-tmp",
"/run",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, keepUnixSystemMountPaths(tt.infos))
})
}
}

View file

@ -0,0 +1,157 @@
package fileresolver
import (
"io/fs"
"os"
"sort"
"strings"
"github.com/moby/sys/mountinfo"
"github.com/anchore/syft/internal/log"
)
type pathSkipper struct {
// scanTarget is the root path that is being scanned (without any base-path logic applied).
scanTarget string
// ignorableMountTypes is a set of mount types that should be ignored. Optionally a list of paths (the map values)
// can be provided that this mount type should be ignored at. For example in some containers /dev is mounted
// as a tmpfs and should be ignored, but /tmp should not be ignored. An empty list of paths means that paths
// within the mount type should always be ignored.
ignorableMountTypes map[string][]string
// current mount paths for the current system
mounts []*mountinfo.Info
mountsByType map[string][]*mountinfo.Info
}
// skipPathsByMountTypeAndName accepts the root path and returns a PathIndexVisitor that will skip paths based
// the filesystem type, the mountpoint, and configured blocklist paths for each filesystem type.
// This will help syft dodge filesystem topologies that have the potential to make the search space much bigger in
// areas known to not traditionally contain files of interest (installed software). It is meant to allow scanning
// "/" on a unix host to succeed, while also not causing any files in a narrow directory scan to be skipped unnecessarily.
func skipPathsByMountTypeAndName(root string) PathIndexVisitor {
infos, err := mountinfo.GetMounts(nil)
if err != nil {
log.WithFields("error", err).Warnf("unable to get system mounts")
return func(_ string, _ string, _ os.FileInfo, _ error) error {
return nil
}
}
return newPathSkipperFromMounts(root, infos).pathIndexVisitor
}
func newPathSkipperFromMounts(root string, infos []*mountinfo.Info) pathSkipper {
// we're only interested in ignoring the logical filesystems typically found at these mount points:
// - /proc
// - procfs
// - proc
// - /sys
// - sysfs
// - /dev
// - devfs - BSD/darwin flavored systems and old linux systems
// - devtmpfs - driver core maintained /dev tmpfs
// - udev - userspace implementation that replaced devfs
// - tmpfs - used for /dev in special instances (within a container)
ignorableMountTypes := map[string][]string{
"proc": nil,
"procfs": nil,
"sysfs": nil,
"devfs": nil,
"devtmpfs": nil,
"udev": nil,
// note: there should be no order required (e.g. search /sys/thing before /sys) since that would imply that
// we could not ignore a nested path within a path that would be ignored anyway.
"tmpfs": {"/run", "/dev", "/var/run", "/var/lock", "/sys"},
}
// 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
// stating that /home/syft/permanent is ext4 has the longer mount point.
sort.Slice(infos, func(i, j int) bool {
return len(infos[i].Mountpoint) > len(infos[j].Mountpoint)
})
mountsByType := make(map[string][]*mountinfo.Info)
for _, mi := range infos {
mountsByType[mi.FSType] = append(mountsByType[mi.FSType], mi)
}
return pathSkipper{
scanTarget: root,
ignorableMountTypes: ignorableMountTypes,
mounts: infos,
mountsByType: mountsByType,
}
}
func (ps pathSkipper) pathIndexVisitor(_ string, givenPath string, _ os.FileInfo, _ error) error {
for _, mi := range ps.mounts {
conditionalPaths, ignorable := ps.ignorableMountTypes[mi.FSType]
if len(conditionalPaths) == 0 {
// Rule 1: ignore any path within a mount point that is of the given filesystem type unconditionally
if !containsPath(givenPath, mi.Mountpoint) {
continue
}
if !ignorable {
// we've matched on the most specific path at this point, which means we should stop searching
// mount points for this path
break
}
log.WithFields(
"path", givenPath,
"mountpoint", mi.Mountpoint,
"fs", mi.FSType,
).Debug("ignoring path based on mountpoint filesystem type")
return fs.SkipDir
}
// Rule 2: ignore any path within a mount point that is of the given filesystem type, only if
// the path is on a known blocklist of paths for that filesystem type.
// For example: /dev can be mounted as a tmpfs, which should always be skipped.
for _, conditionalPath := range conditionalPaths {
if !containsPath(givenPath, conditionalPath) {
continue
}
log.WithFields(
"path", givenPath,
"mountpoint", mi.Mountpoint,
"fs", mi.FSType,
"condition", conditionalPath,
).Debug("ignoring path based on mountpoint filesystem type")
return fs.SkipDir
}
}
return nil
}
func containsPath(p1, p2 string) bool {
p1Clean := simpleClean(p1)
p2Clean := simpleClean(p2)
if p1Clean == p2Clean {
return true
}
return strings.HasPrefix(p1Clean, p2Clean+"/")
}
func simpleClean(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "."
}
if p == "/" {
return "/"
}
return strings.TrimSuffix(p, "/")
}

View file

@ -0,0 +1,395 @@
package fileresolver
import (
"io/fs"
"testing"
"github.com/moby/sys/mountinfo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_newPathSkipper(t *testing.T) {
type expect struct {
path string
wantErr assert.ErrorAssertionFunc
}
unixSubject := []*mountinfo.Info{
{
Mountpoint: "/proc",
FSType: "procfs",
},
{
Mountpoint: "/sys",
FSType: "sysfs",
},
{
Mountpoint: "/dev",
FSType: "devfs",
},
{
Mountpoint: "/",
FSType: "/dev/disk3s1s1",
},
{
Mountpoint: "/dev/shm",
FSType: "shm",
},
{
Mountpoint: "/tmp",
FSType: "tmpfs",
},
}
tests := []struct {
name string
root string
base string
mounts []*mountinfo.Info
want []expect
}{
{
name: "happy path",
root: "/somewhere",
mounts: []*mountinfo.Info{
{
Mountpoint: "/home/somewhere/else",
FSType: "/dev/disk3s6",
},
{
Mountpoint: "/somewhere",
FSType: "/dev/disk3s7",
},
},
want: []expect{
{
// within a known mountpoint with valid type (1)
path: "/somewhere/dev",
},
{
// is a known mountpoint with valid type
path: "/somewhere",
},
{
// within a known mountpoint with valid type (2)
path: "/home/somewhere/else/too",
},
{
// outside of any known mountpoint should not be an error
path: "/bogus",
},
},
},
{
name: "ignore paths within a scan target",
root: "/somewhere",
mounts: []*mountinfo.Info{
{
Mountpoint: "/somewhere/doesnt/matter/proc",
FSType: "procfs",
},
{
Mountpoint: "/somewhere",
FSType: "/dev/disk3s7",
},
},
want: []expect{
{
// within a known mountpoint with valid type (1)
path: "/somewhere/dev",
},
{
// is a known mountpoint with valid type
path: "/somewhere",
},
{
// mountpoint that should be ignored
path: "/somewhere/doesnt/matter/proc",
wantErr: assertSkipErr(),
},
{
// within a mountpoint that should be ignored
path: "/somewhere/doesnt/matter/proc",
wantErr: assertSkipErr(),
},
},
},
{
name: "nested mountpoints behave correctly",
root: "/somewhere",
mounts: []*mountinfo.Info{
{
Mountpoint: "/somewhere/dev",
FSType: "devfs",
},
{
Mountpoint: "/somewhere/dev/includeme",
FSType: "/dev/disk3s7",
},
},
want: []expect{
{
// is a known mountpoint with valid type
path: "/somewhere/dev",
wantErr: assertSkipErr(),
},
{
// is a known mountpoint with valid type
path: "/somewhere/dev/includeme",
},
{
// within a known mountpoint with valid type
path: "/somewhere/dev/includeme/too!",
},
},
},
{
name: "keep some tmpfs mounts conditionally",
root: "/",
mounts: []*mountinfo.Info{
{
Mountpoint: "/run/somewhere",
FSType: "tmpfs",
},
{
Mountpoint: "/run/terrafirma",
FSType: "/dev/disk3s8",
},
{
Mountpoint: "/tmp",
FSType: "tmpfs",
},
{
Mountpoint: "/else/othertmp",
FSType: "tmpfs",
},
{
Mountpoint: "/else/othertmp/includeme",
FSType: "/dev/disk3s7",
},
},
want: []expect{
{
// since /run is explicitly ignored, this should be skipped
path: "/run/somewhere/else",
wantErr: assertSkipErr(),
},
{
path: "/run/terrafirma",
},
{
path: "/run/terrafirma/nested",
},
{
path: "/tmp",
},
{
path: "/else/othertmp/includeme",
},
{
path: "/else/othertmp/includeme/nested",
},
{
// no mount path, so we should include it
path: "/somewhere/dev/includeme",
},
{
// keep additional tmpfs mounts that are not explicitly ignored
path: "/else/othertmp",
},
},
},
{
name: "ignore known trixy tmpfs paths",
root: "/",
mounts: []*mountinfo.Info{
{
Mountpoint: "/",
FSType: "/dev/disk3s7",
},
{
Mountpoint: "/dev",
FSType: "tmpfs",
},
{
Mountpoint: "/run",
FSType: "tmpfs",
},
{
Mountpoint: "/var/run",
FSType: "tmpfs",
},
{
Mountpoint: "/var/lock",
FSType: "tmpfs",
},
{
Mountpoint: "/sys",
FSType: "tmpfs",
},
{
Mountpoint: "/tmp",
FSType: "tmpfs",
},
},
want: []expect{
{
path: "/dev",
wantErr: assertSkipErr(),
},
{
path: "/run",
wantErr: assertSkipErr(),
},
{
path: "/var/run",
wantErr: assertSkipErr(),
},
{
path: "/var/lock",
wantErr: assertSkipErr(),
},
{
path: "/sys",
wantErr: assertSkipErr(),
},
// show that we honor ignoring nested paths
{
path: "/sys/nested",
wantErr: assertSkipErr(),
},
// show that paths outside of the known mountpoints are not skipped
{
path: "/stuff",
},
// show that we allow other tmpfs paths that are not on the blocklist
{
path: "/tmp/allowed",
},
// show sibling paths with same prefix (e.g. /sys vs /system) to that of not allowed paths are not skipped
{
path: "/system",
},
},
},
{
name: "test unix paths",
mounts: unixSubject,
root: "/",
want: []expect{
{
// relative path to proc is allowed
path: "proc/place",
},
{
// relative path within proc is not allowed
path: "/proc/place",
wantErr: assertSkipErr(),
},
{
// path exactly to proc is not allowed
path: "/proc",
wantErr: assertSkipErr(),
},
{
// similar to proc
path: "/pro/c",
},
{
// similar to proc
path: "/pro",
},
{
// dev is not allowed
path: "/dev",
wantErr: assertSkipErr(),
},
{
// sys is not allowed
path: "/sys",
wantErr: assertSkipErr(),
},
},
},
{
name: "test unix paths with base",
mounts: unixSubject,
root: "/",
base: "/a/b/c",
want: []expect{
{
// do not consider base when matching paths (non-matching)
path: "/a/b/c/dev",
},
{
// do not consider base when matching paths (matching)
path: "/dev",
wantErr: assertSkipErr(),
},
},
},
{
name: "mimic nixos setup",
root: "/",
mounts: []*mountinfo.Info{
{
Mountpoint: "/",
FSType: "tmpfs", // this is an odd setup, but valid
},
{
Mountpoint: "/home",
FSType: "/dev/disk3s7",
},
},
want: []expect{
{
path: "/home/somewhere",
},
{
path: "/home",
},
{
path: "/somewhere",
},
{
// still not allowed...
path: "/run",
wantErr: assertSkipErr(),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.base == "" {
tt.base = tt.root
}
require.NotEmpty(t, tt.want)
ps := newPathSkipperFromMounts(tt.root, tt.mounts)
for _, exp := range tt.want {
t.Run(exp.path, func(t *testing.T) {
got := ps.pathIndexVisitor(tt.base, exp.path, nil, nil)
if exp.wantErr == nil {
assert.NoError(t, got)
return
}
exp.wantErr(t, got)
})
}
})
}
}
func assertSkipErr() assert.ErrorAssertionFunc {
return assertErrorIs(fs.SkipDir)
}
func assertErrorIs(want error) assert.ErrorAssertionFunc {
return func(t assert.TestingT, got error, msgAndArgs ...interface{}) bool {
return assert.ErrorIs(t, got, want, msgAndArgs...)
}
}

60
test/cli/archive_test.go Normal file
View file

@ -0,0 +1,60 @@
package cli
import (
"archive/tar"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestArchiveScan(t *testing.T) {
tests := []struct {
name string
args []string
archiveFixture string
env map[string]string
assertions []traitAssertion
}{
{
name: "scan an archive within the temp dir",
args: []string{
"scan",
"-o",
"json",
"file:" + createArchive(t, "test-fixtures/archive", t.TempDir()),
},
assertions: []traitAssertion{
assertSuccessfulReturnCode,
assertJsonReport,
assertPackageCount(1),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cmd, stdout, stderr := runSyft(t, test.env, test.args...)
for _, traitAssertionFn := range test.assertions {
traitAssertionFn(t, stdout, stderr, cmd.ProcessState.ExitCode())
}
logOutputOnFailure(t, cmd, stdout, stderr)
})
}
}
func createArchive(t *testing.T, path string, destDir string) string {
// create a tarball of the test fixtures (not by shelling out)
archivePath := filepath.Join(destDir, "test.tar")
fh, err := os.Create(archivePath)
require.NoError(t, err)
defer fh.Close()
writer := tar.NewWriter(fh)
require.NoError(t, writer.AddFS(os.DirFS(path)))
require.NoError(t, writer.Close())
return archivePath
}

View file

@ -0,0 +1,47 @@
Metadata-Version: 2.1
Name: Pygments
Version: 2.6.1
Summary: Pygments is a syntax highlighting package written in Python.
Home-page: https://pygments.org/
Author: Georg Brandl
Author-email: georg@python.org
License: BSD License
Keywords: syntax highlighting
Platform: any
Classifier: License :: OSI Approved :: BSD License
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: Intended Audience :: System Administrators
Classifier: Development Status :: 6 - Mature
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Operating System :: OS Independent
Classifier: Topic :: Text Processing :: Filters
Classifier: Topic :: Utilities
Requires-Python: >=3.5
Pygments
~~~~~~~~
Pygments is a syntax highlighting package written in Python.
It is a generic syntax highlighter suitable for use in code hosting, forums,
wikis or other applications that need to prettify source code. Highlights
are:
* a wide range of over 500 languages and other text formats is supported
* special attention is paid to details, increasing quality by a fair amount
* support for new languages and formats are added easily
* a number of output formats, presently HTML, LaTeX, RTF, SVG, all image formats that PIL supports and ANSI sequences
* it is usable as a command-line tool and as a library
:copyright: Copyright 2006-2019 by the Pygments team, see AUTHORS.
:license: BSD, see LICENSE for details.

View file

@ -0,0 +1,5 @@
../../../bin/pygmentize,sha256=dDhv_U2jiCpmFQwIRHpFRLAHUO4R1jIJPEvT_QYTFp8,220
Pygments-2.6.1.dist-info/AUTHORS,sha256=PVpa2_Oku6BGuiUvutvuPnWGpzxqFy2I8-NIrqCvqUY,8449
Pygments-2.6.1.dist-info/RECORD,,
pygments/__pycache__/__init__.cpython-38.pyc,,
pygments/util.py,sha256=586xXHiJGGZxqk5PMBu3vBhE68DLuAe5MBARWrSPGxA,10778

View file

@ -0,0 +1 @@
top-level-pkg