Skip to content

Support following absolute symlinks for node analysis #1014

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/analyzer/dotnetcoreruntime/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package dotnetcoreruntime
import (
"testing"

"github.com/stackrox/scanner/pkg/fileinfo/mock"
"github.com/stackrox/scanner/pkg/fsutil/fileinfo/mock"
"github.com/stretchr/testify/assert"
)

Expand Down
26 changes: 24 additions & 2 deletions pkg/analyzer/nodes/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/stackrox/scanner/pkg/analyzer"
"github.com/stackrox/scanner/pkg/analyzer/detection"
"github.com/stackrox/scanner/pkg/component"
"github.com/stackrox/scanner/pkg/fsutil/fileinfo"
"github.com/stackrox/scanner/pkg/matcher"
"github.com/stackrox/scanner/pkg/metrics"
"github.com/stackrox/scanner/singletons/requiredfilenames"
Expand All @@ -23,6 +24,8 @@ type fileMetadata struct {
isExecutable bool
// If true, contents can be extracted from the filesystem.
isExtractable bool
// If true, the file is a symlink to some other file.
isSymlink bool
}

var _ analyzer.Files = (*filesMap)(nil)
Expand Down Expand Up @@ -168,8 +171,9 @@ func extractFile(path string, entry fs.DirEntry, pathMatcher matcher.Matcher, m
return nil, nil
}
return &fileMetadata{
isExecutable: matcher.IsFileExecutable(fileInfo),
isExecutable: fileinfo.IsFileExecutable(fileInfo),
isExtractable: isExtractable,
isSymlink: fileinfo.IsFileSymlink(fileInfo),
}, nil
}

Expand All @@ -182,7 +186,25 @@ func (n *filesMap) Get(path string) (analyzer.FileData, bool) {
if !f.isExtractable {
return fileData, true
}
fileData.Contents, n.readError = os.ReadFile(filepath.Join(n.root, filepath.FromSlash(path)))
// Prepend the root to make this an absolute file path.
absPath := filepath.Join(n.root, filepath.FromSlash(path))
if f.isSymlink {
// Resolve the symlink to the correct destination.
var linkDest string
linkDest, n.readError = os.Readlink(absPath)
if n.readError != nil {
return analyzer.FileData{}, false
}
// If the symlink is an absolute path,
// prepend n.root to the link's destination
// and read that file, instead.
// Note: this only matters for symlinks to absolute paths.
// Symlinks to relative paths are followed correctly.
if filepath.IsAbs(linkDest) {
absPath = filepath.Join(n.root, filepath.FromSlash(linkDest))
}
}
fileData.Contents, n.readError = os.ReadFile(absPath)
if n.readError == nil {
return fileData, true
}
Expand Down
127 changes: 123 additions & 4 deletions pkg/analyzer/nodes/node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (
"github.com/pkg/errors"
"github.com/stackrox/scanner/pkg/analyzer"
"github.com/stackrox/scanner/pkg/analyzer/analyzertest"
"github.com/stackrox/scanner/singletons/requiredfilenames"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type matcherMock struct {
Expand Down Expand Up @@ -184,8 +186,6 @@ func Test_filesMap_extractFile(t *testing.T) {
}

func Test_filesMap_Get(t *testing.T) {
wd, _ := os.Getwd()
testdata := filepath.Join(wd, "testdata")
type fields struct {
root string
fileMap map[string]*fileMetadata
Expand Down Expand Up @@ -224,7 +224,7 @@ func Test_filesMap_Get(t *testing.T) {
{
name: "when file is in map and is extractable, should return with contents",
fields: fields{
root: filepath.Join(testdata, "rootfs-foo"),
root: filepath.Join("testdata", "rootfs-foo"),
fileMap: map[string]*fileMetadata{
"etc/redhat-release": {
isExecutable: false,
Expand All @@ -242,7 +242,7 @@ func Test_filesMap_Get(t *testing.T) {
{
name: "when file is in map and is extractable but does not exist, then keep error",
fields: fields{
root: filepath.Join(testdata, "rootfs-foo"),
root: filepath.Join("testdata", "rootfs-foo"),
fileMap: map[string]*fileMetadata{
"foo/does/not/exist": {
isExecutable: false,
Expand All @@ -256,6 +256,44 @@ func Test_filesMap_Get(t *testing.T) {
wantExists: false,
wantError: os.ErrNotExist,
},
{
name: "when file is in map, is extractable, and is an absolute symlink, should return with contents",
fields: fields{
root: filepath.Join("testdata", "rootfs-rhcos"),
fileMap: map[string]*fileMetadata{
"etc/redhat-release": {
isExecutable: false,
isExtractable: true,
isSymlink: true,
},
},
readError: nil,
},
path: "etc/redhat-release",
wantFileData: analyzer.FileData{
Contents: []byte("Red Hat Enterprise Linux CoreOS release 4.11\n"),
},
wantExists: true,
},
{
name: "when file is in map, is extractable, and is a relative symlink, should return with contents",
fields: fields{
root: filepath.Join("testdata", "rootfs-rhcos-rel-symlink"),
fileMap: map[string]*fileMetadata{
"etc/redhat-release": {
isExecutable: false,
isExtractable: true,
isSymlink: true,
},
},
readError: nil,
},
path: "etc/redhat-release",
wantFileData: analyzer.FileData{
Contents: []byte("Hello"),
},
wantExists: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -308,3 +346,84 @@ func Test_filesMap_ReadErr(t *testing.T) {
})
}
}

func Test_extractFilesFromDirectory(t *testing.T) {
fooRoot := filepath.Join("testdata", "rootfs-foo")
fooRootAbs, err := filepath.Abs(fooRoot)
require.NoError(t, err)
rhcosRoot := filepath.Join("testdata", "rootfs-rhcos")
rhcosRootAbs, err := filepath.Abs(rhcosRoot)
require.NoError(t, err)
symlinkRelRoot := filepath.Join("testdata", "rootfs-rhcos-rel-symlink")
symlinkRelRootAbs, err := filepath.Abs(symlinkRelRoot)
require.NoError(t, err)

testcases := []struct {
name string
root string
expectedFilesMap *filesMap
}{
{
name: "etc/redhat-release no symlink",
root: fooRoot,
expectedFilesMap: &filesMap{
root: fooRootAbs,
files: map[string]*fileMetadata{
"etc/redhat-release": {
isExecutable: false,
isExtractable: true,
isSymlink: false,
},
},
readError: nil,
},
},
{
name: "etc/redhat-release with absolute symlink",
root: rhcosRoot,
expectedFilesMap: &filesMap{
root: rhcosRootAbs,
files: map[string]*fileMetadata{
"etc/redhat-release": {
isExecutable: false,
isExtractable: true,
isSymlink: true,
},
},
readError: nil,
},
},
{
name: "etc/redhat-release with relative symlink",
root: symlinkRelRoot,
expectedFilesMap: &filesMap{
root: symlinkRelRootAbs,
files: map[string]*fileMetadata{
"etc/redhat-release": {
isExecutable: false,
isExtractable: true,
isSymlink: true,
},
},
readError: nil,
},
},
}

for _, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
filesMap, err := extractFilesFromDirectory(testcase.root, requiredfilenames.SingletonOSMatcher())
assert.NoError(t, err)
assert.NoError(t, filesMap.readError)

assert.Equal(t, testcase.expectedFilesMap.root, filesMap.root)
assert.Len(t, filesMap.files, len(testcase.expectedFilesMap.files))
for expectedPath, expectedMetadata := range testcase.expectedFilesMap.files {
metadata, hasFile := filesMap.files[expectedPath]
assert.True(t, hasFile)
assert.NotNil(t, metadata)
assert.Equal(t, *expectedMetadata, *metadata)
}
})
}
}
12 changes: 12 additions & 0 deletions pkg/analyzer/nodes/testdata/rootfs-rhcos-rel-symlink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# ROOTFS-RHCOS-REL-SYMLINK

This directory structure mirrors what we have found in RHCOS 4.11
except it uses a symlink to a relative path.

* `release` contains the data.
* `etc/redhat-release` is a symlink to `release`
```shell
$ ls -l etc/redhat-release
total 0
lrwxr-xr-x 1 <OWNER> <GROUP> <DATE> redhat-release -> ../release
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello
11 changes: 11 additions & 0 deletions pkg/analyzer/nodes/testdata/rootfs-rhcos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# ROOTFS-RHCOS

This directory structure mirrors what we have found in RHCOS 4.11.

* `usr/lib/system-release` contains the distro identification data.
* `etc/redhat-release` is a symlink to `/usr/lib/system-release`
```shell
$ ls -l etc/redhat-release
total 0
lrwxr-xr-x 1 <OWNER> <GROUP> <DATE> redhat-release -> /usr/lib/system-release
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Red Hat Enterprise Linux CoreOS release 4.11
File renamed without changes.
File renamed without changes.
13 changes: 13 additions & 0 deletions pkg/fsutil/fileinfo/util.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package fileinfo

import "io/fs"

// IsFileExecutable returns true if the file is an executable regular file.
func IsFileExecutable(fileInfo fs.FileInfo) bool {
return fileInfo.Mode().IsRegular() && fileInfo.Mode()&0111 != 0
}

// IsFileSymlink returns true if fileInfo represents a symlink.
func IsFileSymlink(fileInfo fs.FileInfo) bool {
return fileInfo.Mode().Type()&fs.ModeSymlink != 0
}
8 changes: 2 additions & 6 deletions pkg/matcher/matcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

"github.com/stackrox/rox/pkg/set"
"github.com/stackrox/scanner/pkg/fsutil/fileinfo"
"github.com/stackrox/scanner/pkg/whiteout"
)

Expand Down Expand Up @@ -121,7 +122,7 @@ func NewExecutableMatcher() Matcher {
}

func (e *executableMatcher) Match(_ string, fi os.FileInfo, _ io.ReaderAt) (matches bool, extract bool) {
return IsFileExecutable(fi), false
return fileinfo.IsFileExecutable(fi), false
}

type regexpMatcher struct {
Expand Down Expand Up @@ -198,8 +199,3 @@ func (a *andMatcher) Match(fullPath string, fileInfo os.FileInfo, contents io.Re
}
return true, extract
}

// IsFileExecutable returns if the file is an executable regular file.
func IsFileExecutable(fileInfo fs.FileInfo) bool {
return fileInfo.Mode().IsRegular() && fileInfo.Mode()&0111 != 0
}
2 changes: 1 addition & 1 deletion pkg/matcher/matcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"regexp"
"testing"

"github.com/stackrox/scanner/pkg/fileinfo/mock"
"github.com/stackrox/scanner/pkg/fsutil/fileinfo/mock"
"github.com/stretchr/testify/assert"
)

Expand Down
3 changes: 2 additions & 1 deletion pkg/tarutil/tarutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/stackrox/rox/pkg/utils"
"github.com/stackrox/scanner/pkg/analyzer"
"github.com/stackrox/scanner/pkg/elf"
"github.com/stackrox/scanner/pkg/fsutil/fileinfo"
"github.com/stackrox/scanner/pkg/ioutils"
"github.com/stackrox/scanner/pkg/matcher"
"github.com/stackrox/scanner/pkg/metrics"
Expand Down Expand Up @@ -113,7 +114,7 @@ func ExtractFiles(r io.Reader, filenameMatcher matcher.Matcher) (LayerFiles, err
case tar.TypeReg, tar.TypeLink:
var fileData analyzer.FileData

executable := matcher.IsFileExecutable(hdr.FileInfo())
executable := fileinfo.IsFileExecutable(hdr.FileInfo())
if hdr.Size > analyzer.GetMaxELFExecutableFileSize() {
log.Warnf("Skipping ELF executable check for file %q (%d bytes) because it is larger than the configured maxELFExecutableFileSize of %d MiB",
filename, hdr.Size, analyzer.GetMaxELFExecutableFileSize()/1024/1024)
Expand Down