Skip to content

Commit

Permalink
Add cargo auditable deps extractor for Rust bins to SCALIBR
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 713069568
  • Loading branch information
rpbeltran authored and copybara-github committed Jan 8, 2025
1 parent 1b5bfeb commit 68433f2
Show file tree
Hide file tree
Showing 13 changed files with 599 additions and 10 deletions.
19 changes: 19 additions & 0 deletions extractor/filesystem/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,22 @@ func (i *ScanInput) GetRealPath() (string, error) {
io.Copy(f, i.Reader)
return path, nil
}

// IsInterestingExecutable returns true if the specified file is an executable which may need scanning.
func IsInterestingExecutable(path string, fileInfo fs.FileInfo) bool {
// TODO(b/380419487): This is inefficient, it would be better to filter out common
// non executables by their file extension.

// Ignore non regular files such as dirs, symlinks, sockets, pipes, etc....
if !fileInfo.Mode().IsRegular() {
return false
}

// TODO(b/279138598): Research: Maybe on windows all files have the executable bit set.
// Either windows .exe or unix executable bit should be set.
if filepath.Ext(path) != ".exe" && fileInfo.Mode()&0111 == 0 {
return false
}

return true
}
64 changes: 64 additions & 0 deletions extractor/filesystem/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import (
"github.com/google/osv-scalibr/plugin"
"github.com/google/osv-scalibr/stats"
fe "github.com/google/osv-scalibr/testing/fakeextractor"
"github.com/google/osv-scalibr/testing/fakefs"
)

// pathsMapFS provides a hooked version of MapFS that forces slashes. Because depending on the
Expand Down Expand Up @@ -713,3 +714,66 @@ func TestRunFS_ReadError(t *testing.T) {
t.Errorf("extractor.Run(%v): unexpected status (-want +got):\n%s", ex, diff)
}
}

func TestIsInterestingExecutable(t *testing.T) {
tests := []struct {
name string
path string
mode fs.FileMode
want bool
}{
{
name: "user executable",
path: "some/path/a",
mode: 0766,
want: true,
},
{
name: "group executable",
path: "some/path/a",
mode: 0676,
want: true,
},
{
name: "other executable",
path: "some/path/a",
mode: 0667,
want: true,
},
{
name: "windows exe",
path: "some/path/a.exe",
mode: 0666,
want: true,
},
{
name: "not executable bit set",
path: "some/path/a",
mode: 0640,
want: false,
},
{
name: "Non regular file, socket",
path: "some/path/a",
mode: fs.ModeSocket | 0777,
want: false,
},
{
name: "executable required",
path: "some/path/a",
mode: 0766,
want: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filesystem.IsInterestingExecutable(tt.path, fakefs.FakeFileInfo{
FileName: filepath.Base(tt.path),
FileMode: tt.mode,
}); got != tt.want {
t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, got, tt.want)
}
})
}
}
10 changes: 1 addition & 9 deletions extractor/filesystem/language/golang/gobinary/gobinary.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"errors"
"io"
"io/fs"
"path/filepath"
"runtime/debug"
"strings"

Expand Down Expand Up @@ -96,14 +95,7 @@ func (e Extractor) FileRequired(api filesystem.FileAPI) bool {
return false
}

if !fileinfo.Mode().IsRegular() {
// Includes dirs, symlinks, sockets, pipes...
return false
}

// TODO(b/279138598): Research: Maybe on windows all files have the executable bit set.
// Either windows .exe or unix executable bit should be set.
if filepath.Ext(path) != ".exe" && fileinfo.Mode()&0111 == 0 {
if !filesystem.IsInterestingExecutable(path, fileinfo) {
return false
}

Expand Down
173 changes: 173 additions & 0 deletions extractor/filesystem/language/rust/cargoauditable/cargoauditable.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package cargoauditable extracts dependencies from cargo auditable inside rust binaries.
package cargoauditable

import (
"context"
"errors"
"fmt"
"io"

"github.com/google/osv-scalibr/extractor"
"github.com/google/osv-scalibr/extractor/filesystem"
"github.com/google/osv-scalibr/log"
"github.com/google/osv-scalibr/plugin"
"github.com/google/osv-scalibr/purl"
"github.com/google/osv-scalibr/stats"
"github.com/microsoft/go-rustaudit/v/v0/rustaudit"

Check failure on line 30 in extractor/filesystem/language/rust/cargoauditable/cargoauditable.go

View workflow job for this annotation

GitHub Actions / tests (ubuntu-latest)

no required module provides package github.com/microsoft/go-rustaudit/v/v0/rustaudit; to add it:

Check failure on line 30 in extractor/filesystem/language/rust/cargoauditable/cargoauditable.go

View workflow job for this annotation

GitHub Actions / tests (macos-latest)

no required module provides package github.com/microsoft/go-rustaudit/v/v0/rustaudit; to add it:
)

const pluginName = "cargoauditable"
const pluginVersion = 0

// defaultMaxFileSizeBytes is the maximum file size an extractor will unmarshal.
// If Extract gets a bigger file, it will return an error.
const defaultMaxFileSizeBytes = 0

// Config is the configuration for the Extractor.
type Config struct {
// Stats is a stats collector for reporting metrics.
Stats stats.Collector
// MaxFileSizeBytes is the maximum size of a file that can be extracted.
// If this limit is greater than zero and a file is encountered that is larger
// than this limit, the file is ignored by returning false for `FileRequired`.
MaxFileSizeBytes int64
}

// Extractor extracts extracts dependencies from cargo auditable inside rust binaries.
type Extractor struct {
stats stats.Collector
maxFileSizeBytes int64
}

// DefaultConfig returns a default configuration for the extractor.
func DefaultConfig() Config {
return Config{
Stats: nil,
MaxFileSizeBytes: defaultMaxFileSizeBytes,
}
}

// New returns a Cargo Auditable extractor.
//
// For most use cases, initialize with:
// ```
// e := New(DefaultConfig())
// ```
func New(cfg Config) *Extractor {
return &Extractor{
stats: cfg.Stats,
maxFileSizeBytes: cfg.MaxFileSizeBytes,
}
}

// Name of the extractor.
func (e Extractor) Name() string { return pluginName }

// Version of the extractor.
func (e Extractor) Version() int { return pluginVersion }

// Requirements for enabling the extractor.
func (e Extractor) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} }

// FileRequired returns true if the specified file is marked executable.
func (e Extractor) FileRequired(api filesystem.FileAPI) bool {
path := api.Path()

fileinfo, err := api.Stat()
if err != nil {
return false
}

if !filesystem.IsInterestingExecutable(path, fileinfo) {
return false
}

sizeLimitExceeded := e.maxFileSizeBytes > 0 && fileinfo.Size() > e.maxFileSizeBytes
result := stats.FileRequiredResultOK
if sizeLimitExceeded {
result = stats.FileRequiredResultSizeLimitExceeded
}

if e.stats != nil {
e.stats.AfterFileRequired(pluginName, &stats.FileRequiredStats{
Path: path,
Result: result,
FileSizeBytes: fileinfo.Size(),
})
}
return !sizeLimitExceeded
}

// ToPURL converts an inventory created by this extractor into a PURL.
func (e Extractor) ToPURL(i *extractor.Inventory) *purl.PackageURL {
return &purl.PackageURL{
Type: purl.TypeCargo,
Name: i.Name,
Version: i.Version,
}
}

// Ecosystem returns the OSV ecosystem ('crates.io') of the software extracted by this extractor.
func (e Extractor) Ecosystem(_ *extractor.Inventory) string {
return "crates.io"
}

// Extract extracts packages from cargo auditable inside rust binaries.
func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) ([]*extractor.Inventory, error) {
dependencyInfo, err := rustaudit.GetDependencyInfo(input.Reader.(io.ReaderAt))
if err != nil {
if e.stats != nil {
e.stats.AfterFileExtracted(pluginName, &stats.FileExtractedStats{
Path: input.Path,
Result: filesystem.ExtractorErrorToFileExtractedResult(err),
FileSizeBytes: input.Info.Size(),
})
}

// Most likely the file is simply not a rust binary or was built without cargo auditable enabled.
if errors.Is(err, rustaudit.ErrUnknownFileFormat) || errors.Is(err, rustaudit.ErrNoRustDepInfo) {
return []*extractor.Inventory{}, nil
}

log.Debugf("error getting dependency information from binary (%s) for extraction: %v", input.Path, err)
return nil, fmt.Errorf("rustaudit.GetDependencyInfo(%q): %w", input.Path, err)
}

var inventory []*extractor.Inventory
for _, dep := range dependencyInfo.Packages {
// Cargo auditable also tracks build dependencies which we don't want to report.
if dep.Kind == rustaudit.Runtime {
inventory = append(inventory, &extractor.Inventory{
Name: dep.Name,
Version: dep.Version,
Locations: []string{input.Path},
})
}
if e.stats != nil {
e.stats.AfterFileExtracted(pluginName, &stats.FileExtractedStats{
Path: input.Path,
Result: stats.FileExtractedResultSuccess,
FileSizeBytes: input.Info.Size(),
})
}
}

return inventory, nil
}

// Ensure Extractor implements the filesystem.Extractor interface.
var _ filesystem.Extractor = Extractor{}
Loading

0 comments on commit 68433f2

Please sign in to comment.