github.com/google/osv-scalibr@v0.4.1/binary/scanrunner/scanrunner_test.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package scanrunner_test
    16  
    17  import (
    18  	"bytes"
    19  	"errors"
    20  	"io"
    21  	"os"
    22  	"path/filepath"
    23  	"runtime"
    24  	"slices"
    25  	"testing"
    26  
    27  	"archive/tar"
    28  
    29  	"github.com/google/go-cmp/cmp"
    30  	"github.com/google/go-containerregistry/pkg/v1/empty"
    31  	"github.com/google/go-containerregistry/pkg/v1/mutate"
    32  	"github.com/google/go-containerregistry/pkg/v1/tarball"
    33  	"github.com/google/osv-scalibr/binary/cli"
    34  	"github.com/google/osv-scalibr/binary/scanrunner"
    35  	"google.golang.org/protobuf/encoding/prototext"
    36  
    37  	spb "github.com/google/osv-scalibr/binary/proto/scan_result_go_proto"
    38  )
    39  
    40  func createDetectorTestFiles(t *testing.T) string {
    41  	// Create an /etc/passwd file for the example detector.
    42  	t.Helper()
    43  	dir := t.TempDir()
    44  	passwdDir := filepath.Join(dir, "etc")
    45  	if err := os.Mkdir(passwdDir, 0777); err != nil {
    46  		t.Fatalf("error creating directory %v: %v", passwdDir, err)
    47  	}
    48  	passwdFile := filepath.Join(passwdDir, "passwd")
    49  	if err := os.WriteFile(passwdFile, []byte("content"), 0644); err != nil {
    50  		t.Fatalf("Error while creating file %s: %v", passwdFile, err)
    51  	}
    52  	return dir
    53  }
    54  
    55  func createExtractorTestFiles(t *testing.T) string {
    56  	// Move an example python metadata file into the test dir for the wheelegg extractor.
    57  	t.Helper()
    58  	dir := t.TempDir()
    59  	distDir := filepath.Join(dir, "pip.dist-info")
    60  	if err := os.Mkdir(distDir, 0777); err != nil {
    61  		t.Fatalf("error creating directory %v: %v", distDir, err)
    62  	}
    63  	srcFile := "../../extractor/filesystem/language/python/wheelegg/testdata/distinfo_meta"
    64  	dstFile := filepath.Join(distDir, "METADATA")
    65  	data, err := os.ReadFile(srcFile)
    66  	if err != nil {
    67  		t.Errorf("os.ReadFile(%v): %v", srcFile, err)
    68  	}
    69  	if err := os.WriteFile(dstFile, data, 0644); err != nil {
    70  		t.Fatalf("os.WriteFile(%s): %v", dstFile, err)
    71  	}
    72  	return dir
    73  }
    74  
    75  func createFailingDetectorTestFiles(t *testing.T) string {
    76  	// /etc/passwd can't be read.
    77  	t.Helper()
    78  	dir := t.TempDir()
    79  	passwdDir := filepath.Join(dir, "etc")
    80  	if err := os.Mkdir(passwdDir, 0600); err != nil {
    81  		t.Fatalf("error creating directory %v: %v", passwdDir, err)
    82  	}
    83  	return dir
    84  }
    85  
    86  func createImageTarball(t *testing.T) string {
    87  	t.Helper()
    88  
    89  	var buf bytes.Buffer
    90  	w := tar.NewWriter(&buf)
    91  	gomod := `
    92  module my-library
    93  
    94  require (
    95  	github.com/BurntSushi/toml v1.0.0
    96  	gopkg.in/yaml.v2 v2.4.0
    97  )
    98  	`
    99  	files := []struct {
   100  		name, contents string
   101  	}{
   102  		{"go.mod", gomod},
   103  	}
   104  	for _, file := range files {
   105  		hdr := &tar.Header{
   106  			Name:     file.name,
   107  			Mode:     0600,
   108  			Size:     int64(len(file.contents)),
   109  			Typeflag: tar.TypeReg,
   110  		}
   111  		if err := w.WriteHeader(hdr); err != nil {
   112  			t.Fatalf("couldn't write header for %s: %v", file.name, err)
   113  		}
   114  		if _, err := w.Write([]byte(file.contents)); err != nil {
   115  			t.Fatalf("couldn't write %s: %v", file.name, err)
   116  		}
   117  	}
   118  	w.Close()
   119  	layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
   120  		return io.NopCloser(bytes.NewBuffer(buf.Bytes())), nil
   121  	})
   122  	if err != nil {
   123  		t.Fatalf("unable to create layer: %v", err)
   124  	}
   125  	image, err := mutate.AppendLayers(empty.Image, layer)
   126  	if err != nil {
   127  		t.Fatalf("unable to create image: %v", err)
   128  	}
   129  
   130  	dir := t.TempDir()
   131  	tarPath := filepath.Join(dir, "image.tar")
   132  	if err := tarball.WriteToFile(tarPath, nil, image); err != nil {
   133  		t.Fatalf("unable to write tarball: %v", err)
   134  	}
   135  
   136  	return dir
   137  }
   138  
   139  func createBadImageTarball(t *testing.T) string {
   140  	t.Helper()
   141  
   142  	dir := t.TempDir()
   143  	tarPath := filepath.Join(dir, "image.tar")
   144  	if err := os.WriteFile(tarPath, []byte("bad tarball"), 0600); err != nil {
   145  		t.Fatalf("unable to write tarball: %v", err)
   146  	}
   147  	return dir
   148  }
   149  
   150  func TestRunScan(t *testing.T) {
   151  	testCases := []struct {
   152  		desc              string
   153  		setupFunc         func(t *testing.T) string
   154  		flags             *cli.Flags
   155  		wantExit          int
   156  		wantScanStatus    spb.ScanStatus_ScanStatusEnum
   157  		wantPluginStatus  []spb.ScanStatus_ScanStatusEnum
   158  		wantPackagesCount int
   159  		wantFindingCount  int
   160  		excludeOS         []string // test will not run on these operating systems
   161  	}{
   162  		{
   163  			desc:              "Successful detector run",
   164  			setupFunc:         createDetectorTestFiles,
   165  			flags:             &cli.Flags{DetectorsToRun: []string{"cis"}},
   166  			wantScanStatus:    spb.ScanStatus_SUCCEEDED,
   167  			wantPluginStatus:  []spb.ScanStatus_ScanStatusEnum{spb.ScanStatus_SUCCEEDED},
   168  			wantPackagesCount: 0,
   169  			wantFindingCount:  1,
   170  			// TODO: b/343368902: Fix once we have a detector for Windows.
   171  			excludeOS: []string{"windows"},
   172  		},
   173  		{
   174  			desc:              "Successful extractor run",
   175  			setupFunc:         createExtractorTestFiles,
   176  			flags:             &cli.Flags{ExtractorsToRun: []string{"python/wheelegg"}},
   177  			wantScanStatus:    spb.ScanStatus_SUCCEEDED,
   178  			wantPluginStatus:  []spb.ScanStatus_ScanStatusEnum{spb.ScanStatus_SUCCEEDED},
   179  			wantPackagesCount: 1,
   180  			wantFindingCount:  0,
   181  		},
   182  		{
   183  			desc:      "Successful image extractor run",
   184  			setupFunc: createImageTarball,
   185  			flags: &cli.Flags{
   186  				ImageTarball:    "image.tar",
   187  				ExtractorsToRun: []string{"go/gomod"},
   188  			},
   189  			wantScanStatus:    spb.ScanStatus_SUCCEEDED,
   190  			wantPluginStatus:  []spb.ScanStatus_ScanStatusEnum{spb.ScanStatus_SUCCEEDED},
   191  			wantPackagesCount: 2,
   192  			wantFindingCount:  0,
   193  		},
   194  		{
   195  			desc:      "Failure to read image tarball",
   196  			setupFunc: createBadImageTarball,
   197  			flags: &cli.Flags{
   198  				ImageTarball:    "image.tar",
   199  				ExtractorsToRun: []string{"go/gomod"},
   200  			},
   201  			wantExit:          1,
   202  			wantScanStatus:    spb.ScanStatus_FAILED,
   203  			wantPluginStatus:  []spb.ScanStatus_ScanStatusEnum{spb.ScanStatus_FAILED},
   204  			wantPackagesCount: 0,
   205  			wantFindingCount:  0,
   206  		},
   207  		{
   208  			desc:              "Unsuccessful plugin run",
   209  			setupFunc:         createFailingDetectorTestFiles,
   210  			flags:             &cli.Flags{DetectorsToRun: []string{"cis"}},
   211  			wantExit:          1,
   212  			wantScanStatus:    spb.ScanStatus_PARTIALLY_SUCCEEDED,
   213  			wantPluginStatus:  []spb.ScanStatus_ScanStatusEnum{spb.ScanStatus_FAILED},
   214  			wantPackagesCount: 0,
   215  			wantFindingCount:  0,
   216  			// TODO: b/343368902: Fix once we have a detector for Windows.
   217  			excludeOS: []string{"windows"},
   218  		},
   219  	}
   220  
   221  	for _, tc := range testCases {
   222  		t.Run(tc.desc, func(t *testing.T) {
   223  			if slices.Contains(tc.excludeOS, runtime.GOOS) {
   224  				t.Skipf("Skipping test on %s", runtime.GOOS)
   225  			}
   226  
   227  			dir := tc.setupFunc(t)
   228  			resultFile := filepath.Join(dir, "result.textproto")
   229  			tc.flags.ResultFile = resultFile
   230  
   231  			if tc.flags.ImageTarball != "" {
   232  				tc.flags.ImageTarball = filepath.Join(dir, tc.flags.ImageTarball)
   233  			} else {
   234  				tc.flags.Root = dir
   235  			}
   236  
   237  			gotExit := scanrunner.RunScan(tc.flags)
   238  			if gotExit != tc.wantExit {
   239  				t.Fatalf("result.RunScan(%v) = %d, want %d", tc.flags, gotExit, tc.wantExit)
   240  			}
   241  			_, err := os.Stat(resultFile)
   242  			if gotExit == 0 && errors.Is(err, os.ErrNotExist) {
   243  				t.Fatalf("Scan returned successful exit code 0 but no result file was created")
   244  			}
   245  			if gotExit != 0 && errors.Is(err, os.ErrNotExist) {
   246  				// It is expected that no results are created if the scan fails. Nothing else to check.
   247  				return
   248  			}
   249  
   250  			output, err := os.ReadFile(resultFile)
   251  			if err != nil {
   252  				t.Fatalf("os.ReadFile(%v): %v", resultFile, err)
   253  			}
   254  
   255  			result := &spb.ScanResult{}
   256  			if err = prototext.Unmarshal(output, result); err != nil {
   257  				t.Fatalf("prototext.Unmarshal(%v): %v", result, err)
   258  			}
   259  			if result.Status.Status != tc.wantScanStatus {
   260  				t.Errorf("Unexpected scan status, want %v got %v", tc.wantScanStatus, result.Status.Status)
   261  			}
   262  			gotPS := []spb.ScanStatus_ScanStatusEnum{}
   263  			for _, s := range result.PluginStatus {
   264  				gotPS = append(gotPS, s.Status.Status)
   265  			}
   266  			if diff := cmp.Diff(tc.wantPluginStatus, gotPS); diff != "" {
   267  				t.Errorf("Unexpected plugin status (-want +got):\n%s", diff)
   268  			}
   269  			if len(result.Inventory.Packages) != tc.wantPackagesCount {
   270  				t.Errorf("Unexpected package count, want %d got %d", tc.wantPackagesCount, len(result.Inventory.Packages))
   271  			}
   272  			if len(result.Inventory.GenericFindings) != tc.wantFindingCount {
   273  				t.Errorf("Unexpected finding count, want %d got %d", tc.wantFindingCount, len(result.Inventory.GenericFindings))
   274  			}
   275  		})
   276  	}
   277  }