github.com/google/osv-scalibr@v0.4.1/scalibr_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 scalibr_test
    16  
    17  import (
    18  	"context"
    19  	"errors"
    20  	"fmt"
    21  	"io/fs"
    22  	"os"
    23  	"path/filepath"
    24  	"strings"
    25  	"testing"
    26  
    27  	"github.com/google/go-cmp/cmp"
    28  	"github.com/google/go-cmp/cmp/cmpopts"
    29  	scalibr "github.com/google/osv-scalibr"
    30  	"github.com/google/osv-scalibr/annotator/cachedir"
    31  	"github.com/google/osv-scalibr/artifact/image"
    32  	"github.com/google/osv-scalibr/artifact/image/layerscanning/testing/fakeimage"
    33  	"github.com/google/osv-scalibr/artifact/image/layerscanning/testing/fakelayerbuilder"
    34  	"github.com/google/osv-scalibr/enricher"
    35  	ce "github.com/google/osv-scalibr/enricher/secrets/convert"
    36  	"github.com/google/osv-scalibr/extractor"
    37  	"github.com/google/osv-scalibr/extractor/filesystem"
    38  	cf "github.com/google/osv-scalibr/extractor/filesystem/secrets/convert"
    39  	scalibrfs "github.com/google/osv-scalibr/fs"
    40  	"github.com/google/osv-scalibr/inventory"
    41  	"github.com/google/osv-scalibr/inventory/vex"
    42  	"github.com/google/osv-scalibr/log"
    43  	"github.com/google/osv-scalibr/packageindex"
    44  	"github.com/google/osv-scalibr/plugin"
    45  	fd "github.com/google/osv-scalibr/testing/fakedetector"
    46  	fen "github.com/google/osv-scalibr/testing/fakeenricher"
    47  	fe "github.com/google/osv-scalibr/testing/fakeextractor"
    48  	"github.com/google/osv-scalibr/veles"
    49  	"github.com/google/osv-scalibr/veles/velestest"
    50  	"github.com/google/osv-scalibr/version"
    51  	"github.com/mohae/deepcopy"
    52  	"github.com/opencontainers/go-digest"
    53  )
    54  
    55  func TestScan(t *testing.T) {
    56  	success := &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded}
    57  	partialSuccess := &plugin.ScanStatus{
    58  		Status:        plugin.ScanStatusPartiallySucceeded,
    59  		FailureReason: "not all plugins succeeded, see the plugin statuses",
    60  	}
    61  	pluginFailure := "failed to run plugin"
    62  	extFailure := &plugin.ScanStatus{
    63  		Status:        plugin.ScanStatusFailed,
    64  		FailureReason: "encountered 1 error(s) while running plugin; check file-specific errors for details",
    65  		FileErrors: []*plugin.FileError{
    66  			{FilePath: "file.txt", ErrorMessage: pluginFailure},
    67  		},
    68  	}
    69  	detFailure := &plugin.ScanStatus{
    70  		Status:        plugin.ScanStatusFailed,
    71  		FailureReason: pluginFailure,
    72  	}
    73  	enrFailure := &plugin.ScanStatus{
    74  		Status:        plugin.ScanStatusFailed,
    75  		FailureReason: "API: " + pluginFailure,
    76  	}
    77  
    78  	tmp := t.TempDir()
    79  	fs := scalibrfs.DirFS(tmp)
    80  	tmpRoot := []*scalibrfs.ScanRoot{{FS: fs, Path: tmp}}
    81  	_ = os.WriteFile(filepath.Join(tmp, "file.txt"), []byte("Content"), 0644)
    82  	_ = os.WriteFile(filepath.Join(tmp, "config"), []byte("Content"), 0644)
    83  
    84  	pkgName := "software"
    85  	fakeExtractor := fe.New(
    86  		"python/wheelegg", 1, []string{"file.txt"},
    87  		map[string]fe.NamesErr{"file.txt": {Names: []string{pkgName}, Err: nil}},
    88  	)
    89  	pkg := &extractor.Package{
    90  		Name:      pkgName,
    91  		Locations: []string{"file.txt"},
    92  		Plugins:   []string{fakeExtractor.Name()},
    93  	}
    94  	withLayerMetadata := func(pkg *extractor.Package, ld *extractor.LayerMetadata) *extractor.Package {
    95  		pkg = deepcopy.Copy(pkg).(*extractor.Package)
    96  		pkg.LayerMetadata = ld
    97  		return pkg
    98  	}
    99  	pkgWithLayerMetadata := withLayerMetadata(pkg, &extractor.LayerMetadata{Index: 0, DiffID: "diff-id-0", Command: "command-0"})
   100  	pkgWithLayerMetadata.Plugins = []string{fakeExtractor.Name()}
   101  	finding := &inventory.GenericFinding{Adv: &inventory.GenericFindingAdvisory{ID: &inventory.AdvisoryID{Reference: "CVE-1234"}}}
   102  
   103  	fakeEnricherCfg := &fen.Config{
   104  		Name:         "enricher",
   105  		Version:      1,
   106  		Capabilities: &plugin.Capabilities{Network: plugin.NetworkOnline},
   107  		WantEnrich: map[uint64]fen.InventoryAndErr{
   108  			fen.MustHash(
   109  				t,
   110  				&enricher.ScanInput{
   111  					ScanRoot: &scalibrfs.ScanRoot{
   112  						FS:   fs,
   113  						Path: tmp,
   114  					},
   115  				},
   116  				&inventory.Inventory{
   117  					Packages: []*extractor.Package{pkg},
   118  					GenericFindings: []*inventory.GenericFinding{
   119  						withDetectorName(finding, "detector"),
   120  					},
   121  				},
   122  			): {
   123  				Inventory: &inventory.Inventory{
   124  					Packages: []*extractor.Package{pkgWithLayerMetadata},
   125  					GenericFindings: []*inventory.GenericFinding{
   126  						withDetectorName(finding, "detector"),
   127  					},
   128  				},
   129  			},
   130  		},
   131  	}
   132  	fakeEnricher := fen.MustNew(t, fakeEnricherCfg)
   133  
   134  	fakeEnricherCfgErr := &fen.Config{
   135  		Name:         "enricher",
   136  		Version:      1,
   137  		Capabilities: &plugin.Capabilities{Network: plugin.NetworkOnline},
   138  		WantEnrich: map[uint64]fen.InventoryAndErr{
   139  			fen.MustHash(
   140  				t, &enricher.ScanInput{ScanRoot: &scalibrfs.ScanRoot{FS: fs, Path: tmp}},
   141  				&inventory.Inventory{
   142  					Packages: []*extractor.Package{pkg},
   143  					GenericFindings: []*inventory.GenericFinding{
   144  						withDetectorName(finding, "detector2"),
   145  					},
   146  				},
   147  			): {
   148  				Inventory: &inventory.Inventory{
   149  					Packages: []*extractor.Package{pkg},
   150  					GenericFindings: []*inventory.GenericFinding{
   151  						withDetectorName(finding, "detector2"),
   152  					},
   153  				},
   154  				Err: errors.New(enrFailure.FailureReason),
   155  			},
   156  		},
   157  	}
   158  	fakeEnricherErr := fen.MustNew(t, fakeEnricherCfgErr)
   159  
   160  	fakeSecretDetector1 := velestest.NewFakeDetector("Con")
   161  	fakeSecretDetector2 := velestest.NewFakeDetector("tent")
   162  	fakeSecretValidator1 := velestest.NewFakeStringSecretValidator(veles.ValidationValid, nil)
   163  
   164  	testCases := []struct {
   165  		desc string
   166  		cfg  *scalibr.ScanConfig
   167  		want *scalibr.ScanResult
   168  	}{
   169  		{
   170  			desc: "Successful_scan",
   171  			cfg: &scalibr.ScanConfig{
   172  				Plugins: []plugin.Plugin{
   173  					fakeExtractor,
   174  					fd.New().WithName("detector").WithVersion(2).WithGenericFinding(finding),
   175  					fakeEnricher,
   176  				},
   177  				ScanRoots: tmpRoot,
   178  			},
   179  			want: &scalibr.ScanResult{
   180  				Version: version.ScannerVersion,
   181  				Status:  success,
   182  				PluginStatus: []*plugin.Status{
   183  					{Name: "detector", Version: 2, Status: success},
   184  					{Name: "enricher", Version: 1, Status: success},
   185  					{Name: "python/wheelegg", Version: 1, Status: success},
   186  				},
   187  				Inventory: inventory.Inventory{
   188  					Packages: []*extractor.Package{pkgWithLayerMetadata},
   189  					GenericFindings: []*inventory.GenericFinding{
   190  						withDetectorName(finding, "detector"),
   191  					},
   192  				},
   193  			},
   194  		},
   195  		{
   196  			desc: "Global_error",
   197  			cfg: &scalibr.ScanConfig{
   198  				Plugins: []plugin.Plugin{
   199  					// Will error due to duplicate non-identical Advisories.
   200  					fd.New().WithName("detector").WithVersion(2).WithGenericFinding(finding),
   201  					fd.New().WithName("detector").WithVersion(3).WithGenericFinding(&inventory.GenericFinding{
   202  						Adv: &inventory.GenericFindingAdvisory{ID: finding.Adv.ID, Title: "different title"},
   203  					}),
   204  				},
   205  				ScanRoots: tmpRoot,
   206  			},
   207  			want: &scalibr.ScanResult{
   208  				Version: version.ScannerVersion,
   209  				Status: &plugin.ScanStatus{
   210  					Status:        plugin.ScanStatusFailed,
   211  					FailureReason: "multiple non-identical advisories with ID &{ CVE-1234}",
   212  				},
   213  				PluginStatus: []*plugin.Status{
   214  					{Name: "detector", Version: 2, Status: success},
   215  					{Name: "detector", Version: 3, Status: success},
   216  				},
   217  			},
   218  		},
   219  		{
   220  			desc: "Extractor_plugin_failed",
   221  			cfg: &scalibr.ScanConfig{
   222  				Plugins: []plugin.Plugin{
   223  					fe.New("python/wheelegg", 1, []string{"file.txt"}, map[string]fe.NamesErr{"file.txt": {Names: nil, Err: errors.New(pluginFailure)}}),
   224  					fd.New().WithName("detector").WithVersion(2).WithGenericFinding(finding),
   225  				},
   226  				ScanRoots: tmpRoot,
   227  			},
   228  			want: &scalibr.ScanResult{
   229  				Version: version.ScannerVersion,
   230  				Status:  partialSuccess,
   231  				PluginStatus: []*plugin.Status{
   232  					{Name: "detector", Version: 2, Status: success},
   233  					{Name: "python/wheelegg", Version: 1, Status: extFailure},
   234  				},
   235  				Inventory: inventory.Inventory{
   236  					Packages: nil,
   237  					GenericFindings: []*inventory.GenericFinding{
   238  						withDetectorName(finding, "detector"),
   239  					},
   240  				},
   241  			},
   242  		},
   243  		{
   244  			desc: "Detector_plugin_failed",
   245  			cfg: &scalibr.ScanConfig{
   246  				Plugins: []plugin.Plugin{
   247  					fakeExtractor,
   248  					fd.New().WithName("detector").WithVersion(2).WithErr(errors.New(pluginFailure)),
   249  				},
   250  				ScanRoots: tmpRoot,
   251  			},
   252  			want: &scalibr.ScanResult{
   253  				Version: version.ScannerVersion,
   254  				Status:  partialSuccess,
   255  				PluginStatus: []*plugin.Status{
   256  					{Name: "detector", Version: 2, Status: detFailure},
   257  					{Name: "python/wheelegg", Version: 1, Status: success},
   258  				},
   259  				Inventory: inventory.Inventory{
   260  					Packages: []*extractor.Package{pkg},
   261  				},
   262  			},
   263  		},
   264  		{
   265  			desc: "Enricher_plugin_failed",
   266  			cfg: &scalibr.ScanConfig{
   267  				Plugins: []plugin.Plugin{
   268  					fakeExtractor,
   269  					fd.New().WithName("detector2").WithVersion(2).WithGenericFinding(finding),
   270  					fakeEnricherErr,
   271  				},
   272  				ScanRoots: tmpRoot,
   273  			},
   274  			want: &scalibr.ScanResult{
   275  				Version: version.ScannerVersion,
   276  				Status:  partialSuccess,
   277  				PluginStatus: []*plugin.Status{
   278  					{Name: "detector2", Version: 2, Status: success},
   279  					{Name: "enricher", Version: 1, Status: enrFailure},
   280  					{Name: "python/wheelegg", Version: 1, Status: success},
   281  				},
   282  				Inventory: inventory.Inventory{
   283  					Packages: []*extractor.Package{pkg},
   284  					GenericFindings: []*inventory.GenericFinding{
   285  						withDetectorName(finding, "detector2"),
   286  					},
   287  				},
   288  			},
   289  		},
   290  		{
   291  			desc: "Missing_scan_roots_causes_error",
   292  			cfg: &scalibr.ScanConfig{
   293  				Plugins:   []plugin.Plugin{fakeExtractor},
   294  				ScanRoots: []*scalibrfs.ScanRoot{},
   295  			},
   296  			want: &scalibr.ScanResult{
   297  				Version: version.ScannerVersion,
   298  				Status: &plugin.ScanStatus{
   299  					Status:        plugin.ScanStatusFailed,
   300  					FailureReason: "no scan root specified",
   301  				},
   302  			},
   303  		},
   304  		{
   305  			desc: "One_Veles_secret_detector",
   306  			cfg: &scalibr.ScanConfig{
   307  				Plugins: []plugin.Plugin{
   308  					cf.FromVelesDetector(fakeSecretDetector1, "secret-detector", 1)(),
   309  				},
   310  				ScanRoots: tmpRoot,
   311  			},
   312  			want: &scalibr.ScanResult{
   313  				Version: version.ScannerVersion,
   314  				Status:  success,
   315  				PluginStatus: []*plugin.Status{
   316  					{Name: "secrets/veles", Version: 1, Status: success},
   317  				},
   318  				Inventory: inventory.Inventory{
   319  					Secrets: []*inventory.Secret{{Secret: velestest.NewFakeStringSecret("Con"), Location: "file.txt"}},
   320  				},
   321  			},
   322  		},
   323  		{
   324  			desc: "Two_Veles_secret_detectors",
   325  			cfg: &scalibr.ScanConfig{
   326  				Plugins: []plugin.Plugin{
   327  					cf.FromVelesDetector(fakeSecretDetector1, "secret-detector-1", 1)(),
   328  					cf.FromVelesDetector(fakeSecretDetector2, "secret-detector-2", 2)(),
   329  				},
   330  				ScanRoots: tmpRoot,
   331  			},
   332  			want: &scalibr.ScanResult{
   333  				Version: version.ScannerVersion,
   334  				Status:  success,
   335  				PluginStatus: []*plugin.Status{
   336  					{Name: "secrets/veles", Version: 1, Status: success},
   337  				},
   338  				Inventory: inventory.Inventory{
   339  					Secrets: []*inventory.Secret{
   340  						{Secret: velestest.NewFakeStringSecret("Con"), Location: "file.txt"},
   341  						{Secret: velestest.NewFakeStringSecret("tent"), Location: "file.txt"},
   342  					},
   343  				},
   344  			},
   345  		},
   346  		{
   347  			desc: "Veles_secret_detector_with_validation",
   348  			cfg: &scalibr.ScanConfig{
   349  				Plugins: []plugin.Plugin{
   350  					cf.FromVelesDetector(fakeSecretDetector1, "secret-detector", 1)(),
   351  					ce.FromVelesValidator(fakeSecretValidator1, "secret-validator", 1)(),
   352  				},
   353  				ScanRoots: tmpRoot,
   354  			},
   355  			want: &scalibr.ScanResult{
   356  				Version: version.ScannerVersion,
   357  				Status:  success,
   358  				PluginStatus: []*plugin.Status{
   359  					{Name: "secrets/veles", Version: 1, Status: success},
   360  					{Name: "secrets/velesvalidate", Version: 1, Status: success},
   361  				},
   362  				Inventory: inventory.Inventory{
   363  					Secrets: []*inventory.Secret{{
   364  						Secret:     velestest.NewFakeStringSecret("Con"),
   365  						Location:   "file.txt",
   366  						Validation: inventory.SecretValidationResult{Status: veles.ValidationValid},
   367  					}},
   368  				},
   369  			},
   370  		},
   371  		{
   372  			desc: "Veles_secret_detector_with_extractor",
   373  			cfg: &scalibr.ScanConfig{
   374  				Plugins: []plugin.Plugin{
   375  					// use the fakeSecretDetector1 also on config files
   376  					cf.FromVelesDetectorWithRequire(
   377  						fakeSecretDetector1, "secret-detector", 1,
   378  						func(fa filesystem.FileAPI) bool {
   379  							return strings.HasSuffix(fa.Path(), "config")
   380  						},
   381  					),
   382  				},
   383  				ScanRoots: tmpRoot,
   384  			},
   385  			want: &scalibr.ScanResult{
   386  				Version: version.ScannerVersion,
   387  				Status:  success,
   388  				PluginStatus: []*plugin.Status{
   389  					{Name: "secret-detector", Version: 1, Status: success},
   390  					{Name: "secrets/veles", Version: 1, Status: success},
   391  				},
   392  				Inventory: inventory.Inventory{
   393  					Secrets: []*inventory.Secret{
   394  						{Secret: velestest.NewFakeStringSecret("Con"), Location: "file.txt"},
   395  						{Secret: velestest.NewFakeStringSecret("Con"), Location: "config"},
   396  					},
   397  				},
   398  			},
   399  		},
   400  	}
   401  
   402  	for _, tc := range testCases {
   403  		t.Run(tc.desc, func(t *testing.T) {
   404  			got := scalibr.New().Scan(t.Context(), tc.cfg)
   405  
   406  			// We can't mock the time from here so we skip it in the comparison.
   407  			tc.want.StartTime = got.StartTime
   408  			tc.want.EndTime = got.EndTime
   409  
   410  			// Ignore timestamps.
   411  			ignoreFields := cmpopts.IgnoreFields(inventory.SecretValidationResult{}, "At")
   412  
   413  			ignoreOrder := cmpopts.SortSlices(func(a, b any) bool {
   414  				return fmt.Sprintf("%+v", a) < fmt.Sprintf("%+v", b)
   415  			})
   416  
   417  			if diff := cmp.Diff(tc.want, got, fe.AllowUnexported, ignoreFields, ignoreOrder); diff != "" {
   418  				t.Errorf("scalibr.New().Scan(%v): unexpected diff (-want +got):\n%s", tc.cfg, diff)
   419  			}
   420  		})
   421  	}
   422  }
   423  
   424  func TestScanContainer(t *testing.T) {
   425  	fakeChainLayers := fakelayerbuilder.BuildFakeChainLayersFromPath(t, t.TempDir(),
   426  		"testdata/populatelayers.yml")
   427  
   428  	lm := func(i int) *extractor.LayerMetadata {
   429  		return &extractor.LayerMetadata{
   430  			Index:   i,
   431  			DiffID:  digest.Digest(fmt.Sprintf("sha256:diff-id-%d", i)),
   432  			ChainID: digest.Digest(fmt.Sprintf("sha256:chain-id-%d", i)),
   433  			Command: fmt.Sprintf("command-%d", i),
   434  		}
   435  	}
   436  
   437  	testCases := []struct {
   438  		desc        string
   439  		chainLayers []image.ChainLayer
   440  		want        *scalibr.ScanResult
   441  		wantErr     error
   442  	}{
   443  		{
   444  			desc: "Successful_scan_with_1_layer,_2_packages",
   445  			chainLayers: []image.ChainLayer{
   446  				fakeChainLayers[0],
   447  			},
   448  			want: &scalibr.ScanResult{
   449  				Version: version.ScannerVersion,
   450  				Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   451  				PluginStatus: []*plugin.Status{
   452  					{
   453  						Name:    "fake/layerextractor",
   454  						Version: 0,
   455  						Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   456  					},
   457  				},
   458  				Inventory: inventory.Inventory{
   459  					Packages: []*extractor.Package{
   460  						{
   461  							Name:          "bar",
   462  							Locations:     []string{"bar.txt"},
   463  							PURLType:      "generic",
   464  							Plugins:       []string{"fake/layerextractor"},
   465  							LayerMetadata: lm(0),
   466  						},
   467  						{
   468  							Name:          "foo",
   469  							Locations:     []string{"foo.txt"},
   470  							PURLType:      "generic",
   471  							Plugins:       []string{"fake/layerextractor"},
   472  							LayerMetadata: lm(0),
   473  						},
   474  					},
   475  					ContainerImageMetadata: []*extractor.ContainerImageMetadata{
   476  						{
   477  							LayerMetadata: []*extractor.LayerMetadata{lm(0)},
   478  						},
   479  					},
   480  				},
   481  			},
   482  		},
   483  		{
   484  			desc: "Successful_scan_with_2_layers,_1_package_deleted_in_last_layer",
   485  			chainLayers: []image.ChainLayer{
   486  				fakeChainLayers[0],
   487  				fakeChainLayers[1],
   488  			},
   489  			want: &scalibr.ScanResult{
   490  				Version: version.ScannerVersion,
   491  				Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   492  				PluginStatus: []*plugin.Status{
   493  					{
   494  						Name:    "fake/layerextractor",
   495  						Version: 0,
   496  						Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   497  					},
   498  				},
   499  				Inventory: inventory.Inventory{
   500  					Packages: []*extractor.Package{
   501  						{
   502  							Name:          "foo",
   503  							Locations:     []string{"foo.txt"},
   504  							PURLType:      "generic",
   505  							Plugins:       []string{"fake/layerextractor"},
   506  							LayerMetadata: lm(0),
   507  						},
   508  					},
   509  					ContainerImageMetadata: []*extractor.ContainerImageMetadata{
   510  						{
   511  							LayerMetadata: []*extractor.LayerMetadata{lm(0), lm(1)},
   512  						},
   513  					},
   514  				},
   515  			},
   516  		},
   517  		{
   518  			desc: "Successful_scan_with_3_layers,_package_readded_in_last_layer",
   519  			chainLayers: []image.ChainLayer{
   520  				fakeChainLayers[0],
   521  				fakeChainLayers[1],
   522  				fakeChainLayers[2],
   523  			},
   524  			want: &scalibr.ScanResult{
   525  				Version: version.ScannerVersion,
   526  				Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   527  				PluginStatus: []*plugin.Status{
   528  					{
   529  						Name:    "fake/layerextractor",
   530  						Version: 0,
   531  						Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   532  					},
   533  				},
   534  				Inventory: inventory.Inventory{
   535  					Packages: []*extractor.Package{
   536  						{
   537  							Name:          "baz",
   538  							Locations:     []string{"baz.txt"},
   539  							PURLType:      "generic",
   540  							Plugins:       []string{"fake/layerextractor"},
   541  							LayerMetadata: lm(2),
   542  						},
   543  						{
   544  							Name:          "foo",
   545  							Locations:     []string{"foo.txt"},
   546  							PURLType:      "generic",
   547  							Plugins:       []string{"fake/layerextractor"},
   548  							LayerMetadata: lm(0),
   549  						},
   550  					},
   551  					ContainerImageMetadata: []*extractor.ContainerImageMetadata{
   552  						{
   553  							LayerMetadata: []*extractor.LayerMetadata{lm(0), lm(1), lm(2)},
   554  						},
   555  					},
   556  				},
   557  			},
   558  		},
   559  		{
   560  			desc: "Successful_scan_with_4_layers",
   561  			chainLayers: []image.ChainLayer{
   562  				fakeChainLayers[0],
   563  				fakeChainLayers[1],
   564  				fakeChainLayers[2],
   565  				fakeChainLayers[3],
   566  			},
   567  			want: &scalibr.ScanResult{
   568  				Version: version.ScannerVersion,
   569  				Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   570  				PluginStatus: []*plugin.Status{
   571  					{
   572  						Name:    "fake/layerextractor",
   573  						Version: 0,
   574  						Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   575  					},
   576  				},
   577  				Inventory: inventory.Inventory{
   578  					Packages: []*extractor.Package{
   579  						{
   580  							Name:          "bar",
   581  							Locations:     []string{"bar.txt"},
   582  							PURLType:      "generic",
   583  							Plugins:       []string{"fake/layerextractor"},
   584  							LayerMetadata: lm(3),
   585  						},
   586  						{
   587  							Name:          "baz",
   588  							Locations:     []string{"baz.txt"},
   589  							PURLType:      "generic",
   590  							Plugins:       []string{"fake/layerextractor"},
   591  							LayerMetadata: lm(2),
   592  						},
   593  						{
   594  							Name:          "foo",
   595  							Locations:     []string{"foo.txt"},
   596  							PURLType:      "generic",
   597  							Plugins:       []string{"fake/layerextractor"},
   598  							LayerMetadata: lm(0),
   599  						},
   600  					},
   601  					ContainerImageMetadata: []*extractor.ContainerImageMetadata{
   602  						{
   603  							LayerMetadata: []*extractor.LayerMetadata{lm(0), lm(1), lm(2), lm(3)},
   604  						},
   605  					},
   606  				},
   607  			},
   608  		},
   609  		{
   610  			desc: "Successful_scan_with_5_layers",
   611  			chainLayers: []image.ChainLayer{
   612  				fakeChainLayers[0],
   613  				fakeChainLayers[1],
   614  				fakeChainLayers[2],
   615  				fakeChainLayers[3],
   616  				fakeChainLayers[4],
   617  			},
   618  			want: &scalibr.ScanResult{
   619  				Version: version.ScannerVersion,
   620  				Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   621  				PluginStatus: []*plugin.Status{
   622  					{
   623  						Name:    "fake/layerextractor",
   624  						Version: 0,
   625  						Status:  &plugin.ScanStatus{Status: plugin.ScanStatusSucceeded},
   626  					},
   627  				},
   628  				Inventory: inventory.Inventory{
   629  					Packages: []*extractor.Package{
   630  						{
   631  							Name:          "bar",
   632  							Locations:     []string{"bar.txt"},
   633  							PURLType:      "generic",
   634  							Plugins:       []string{"fake/layerextractor"},
   635  							LayerMetadata: lm(3),
   636  						},
   637  						{
   638  							Name:          "baz",
   639  							Locations:     []string{"baz.txt"},
   640  							PURLType:      "generic",
   641  							Plugins:       []string{"fake/layerextractor"},
   642  							LayerMetadata: lm(2),
   643  						},
   644  						{
   645  							Name:          "foo",
   646  							Locations:     []string{"foo.txt"},
   647  							PURLType:      "generic",
   648  							Plugins:       []string{"fake/layerextractor"},
   649  							LayerMetadata: lm(0),
   650  						},
   651  						{
   652  							Name:          "foo2",
   653  							Locations:     []string{"foo.txt"},
   654  							PURLType:      "generic",
   655  							Plugins:       []string{"fake/layerextractor"},
   656  							LayerMetadata: lm(4),
   657  						},
   658  					},
   659  					ContainerImageMetadata: []*extractor.ContainerImageMetadata{
   660  						{
   661  							LayerMetadata: []*extractor.LayerMetadata{lm(0), lm(1), lm(2), lm(3), lm(4)},
   662  						},
   663  					},
   664  				},
   665  			},
   666  		},
   667  	}
   668  
   669  	for _, tc := range testCases {
   670  		t.Run(tc.desc, func(t *testing.T) {
   671  			scanConfig := scalibr.ScanConfig{Plugins: []plugin.Plugin{
   672  				fakelayerbuilder.FakeTestLayersExtractor{},
   673  			}}
   674  
   675  			fi := fakeimage.New(tc.chainLayers)
   676  			got, err := scalibr.New().ScanContainer(t.Context(), fi, &scanConfig)
   677  
   678  			if tc.wantErr != nil {
   679  				if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" {
   680  					t.Errorf("scalibr.New().ScanContainer(): unexpected error diff (-want +got):\n%s", diff)
   681  				}
   682  			}
   683  			// We can't mock the time from here so we skip it in the comparison.
   684  			tc.want.StartTime = got.StartTime
   685  			tc.want.EndTime = got.EndTime
   686  
   687  			if diff := cmp.Diff(tc.want, got, fe.AllowUnexported, cmpopts.IgnoreFields(extractor.LayerMetadata{}, "ParentContainer")); diff != "" {
   688  				t.Errorf("scalibr.New().Scan(): unexpected diff (-want +got):\n%s", diff)
   689  			}
   690  		})
   691  	}
   692  }
   693  
   694  func TestScan_ExtractorOverride(t *testing.T) {
   695  	tmp := t.TempDir()
   696  	fs := scalibrfs.DirFS(tmp)
   697  	if err := os.WriteFile(filepath.Join(tmp, "file1"), []byte("content1"), 0644); err != nil {
   698  		t.Fatalf("write file1: %v", err)
   699  	}
   700  	if err := os.WriteFile(filepath.Join(tmp, "file2"), []byte("content2"), 0644); err != nil {
   701  		t.Fatalf("write file2: %v", err)
   702  	}
   703  	if err := os.Mkdir(filepath.Join(tmp, "dir"), 0755); err != nil {
   704  		t.Fatalf("mkdir dir: %v", err)
   705  	}
   706  	tmpRoot := []*scalibrfs.ScanRoot{{FS: fs, Path: tmp}}
   707  
   708  	e1 := fe.New("e1", 1, []string{"file1"}, map[string]fe.NamesErr{"file1": {Names: []string{"pkg1"}}})
   709  	e2 := fe.New("e2", 1, []string{"file2"}, map[string]fe.NamesErr{"file2": {Names: []string{"pkg2"}}})
   710  	e3 := fe.New("e3", 1, []string{}, map[string]fe.NamesErr{"file2": {Names: []string{"pkg3"}}})
   711  	e4 := fe.NewDirExtractor("e4", 1, []string{"dir"}, map[string]fe.NamesErr{"dir": {Names: []string{"pkg4"}}})
   712  	e5 := fe.NewDirExtractor("e5", 1, []string{"notdir"}, map[string]fe.NamesErr{"dir": {Names: []string{"pkg5"}}})
   713  
   714  	pkg1 := &extractor.Package{Name: "pkg1", Locations: []string{"file1"}, Plugins: []string{"e1"}}
   715  	pkg2 := &extractor.Package{Name: "pkg2", Locations: []string{"file2"}, Plugins: []string{"e2"}}
   716  	pkg3 := &extractor.Package{Name: "pkg3", Locations: []string{"file2"}, Plugins: []string{"e3"}}
   717  	pkg4 := &extractor.Package{Name: "pkg4", Locations: []string{"dir"}, Plugins: []string{"e4"}}
   718  	pkg5 := &extractor.Package{Name: "pkg5", Locations: []string{"dir"}, Plugins: []string{"e5"}}
   719  
   720  	tests := []struct {
   721  		name              string
   722  		plugins           []plugin.Plugin
   723  		extractorOverride func(filesystem.FileAPI) []filesystem.Extractor
   724  		wantPkgs          []*extractor.Package
   725  	}{
   726  		{
   727  			name:    "no override",
   728  			plugins: []plugin.Plugin{e1, e2, e3},
   729  			wantPkgs: []*extractor.Package{
   730  				pkg1, pkg2,
   731  			},
   732  		},
   733  		{
   734  			name:    "override returns nil",
   735  			plugins: []plugin.Plugin{e1, e2, e3},
   736  			extractorOverride: func(api filesystem.FileAPI) []filesystem.Extractor {
   737  				return nil
   738  			},
   739  			wantPkgs: []*extractor.Package{
   740  				pkg1, pkg2,
   741  			},
   742  		},
   743  		{
   744  			name:    "override returns empty",
   745  			plugins: []plugin.Plugin{e1, e2, e3},
   746  			extractorOverride: func(api filesystem.FileAPI) []filesystem.Extractor {
   747  				return []filesystem.Extractor{}
   748  			},
   749  			wantPkgs: []*extractor.Package{
   750  				pkg1, pkg2,
   751  			},
   752  		},
   753  		{
   754  			name:    "override e3 for file2",
   755  			plugins: []plugin.Plugin{e1, e2, e3},
   756  			extractorOverride: func(api filesystem.FileAPI) []filesystem.Extractor {
   757  				if api.Path() == "file2" {
   758  					return []filesystem.Extractor{e3}
   759  				}
   760  				return nil
   761  			},
   762  			wantPkgs: []*extractor.Package{
   763  				pkg1, pkg3,
   764  			},
   765  		},
   766  		{
   767  			name:    "override e5 for irrelevant directory",
   768  			plugins: []plugin.Plugin{e1, e4, e5},
   769  			extractorOverride: func(api filesystem.FileAPI) []filesystem.Extractor {
   770  				if api.Path() == "otherdir" {
   771  					return []filesystem.Extractor{e5}
   772  				}
   773  				return nil
   774  			},
   775  			wantPkgs: []*extractor.Package{
   776  				pkg1, pkg4,
   777  			},
   778  		},
   779  		{
   780  			name:    "override e5 for dir",
   781  			plugins: []plugin.Plugin{e1, e4, e5},
   782  			extractorOverride: func(api filesystem.FileAPI) []filesystem.Extractor {
   783  				if api.Path() == "dir" {
   784  					return []filesystem.Extractor{e5}
   785  				}
   786  				return nil
   787  			},
   788  			wantPkgs: []*extractor.Package{
   789  				pkg1, pkg5,
   790  			},
   791  		},
   792  	}
   793  
   794  	for _, tt := range tests {
   795  		t.Run(tt.name, func(t *testing.T) {
   796  			cfg := &scalibr.ScanConfig{
   797  				Plugins:           tt.plugins,
   798  				ScanRoots:         tmpRoot,
   799  				ExtractorOverride: tt.extractorOverride,
   800  			}
   801  			res := scalibr.New().Scan(t.Context(), cfg)
   802  			if res.Status.Status != plugin.ScanStatusSucceeded {
   803  				t.Fatalf("Scan failed: %s", res.Status.FailureReason)
   804  			}
   805  
   806  			sortSlices := cmpopts.SortSlices(func(a, b *extractor.Package) bool { return scalibr.CmpPackages(a, b) < 0 })
   807  			if diff := cmp.Diff(tt.wantPkgs, res.Inventory.Packages, fe.AllowUnexported, sortSlices, cmpopts.EquateEmpty()); diff != "" {
   808  				t.Errorf("Scan() packages diff (-want +got):\n%s", diff)
   809  			}
   810  		})
   811  	}
   812  }
   813  
   814  func withDetectorName(f *inventory.GenericFinding, det string) *inventory.GenericFinding {
   815  	c := *f
   816  	c.Plugins = []string{det}
   817  	return &c
   818  }
   819  
   820  func TestEnableRequiredPlugins(t *testing.T) {
   821  	cases := []struct {
   822  		name        string
   823  		cfg         scalibr.ScanConfig
   824  		wantPlugins []string
   825  		wantErr     error
   826  	}{
   827  		{
   828  			name: "empty",
   829  		},
   830  		{
   831  			name: "no_required_extractors",
   832  			cfg: scalibr.ScanConfig{
   833  				Plugins: []plugin.Plugin{
   834  					fd.New().WithName("foo"),
   835  				},
   836  			},
   837  			wantPlugins: []string{"foo"},
   838  		},
   839  		{
   840  			name: "required_extractor_in_already_enabled",
   841  			cfg: scalibr.ScanConfig{
   842  				Plugins: []plugin.Plugin{
   843  					fd.New().WithName("foo").WithRequiredExtractors("bar/baz"),
   844  					fe.New("bar/baz", 0, nil, nil),
   845  				},
   846  			},
   847  			wantPlugins: []string{"foo", "bar/baz"},
   848  		},
   849  		{
   850  			name: "auto-loaded_required_extractor",
   851  			cfg: scalibr.ScanConfig{
   852  				Plugins: []plugin.Plugin{
   853  					fd.New().WithName("foo").WithRequiredExtractors("python/wheelegg"),
   854  				},
   855  			},
   856  			wantPlugins: []string{"foo", "python/wheelegg"},
   857  		},
   858  		{
   859  			name: "auto-loaded_required_extractor_by_enricher",
   860  			cfg: scalibr.ScanConfig{
   861  				Plugins: []plugin.Plugin{
   862  					fen.MustNew(t, &fen.Config{Name: "foo", RequiredPlugins: []string{"python/wheelegg"}}),
   863  				},
   864  			},
   865  			wantPlugins: []string{"foo", "python/wheelegg"},
   866  		},
   867  		{
   868  			name: "required_extractor_doesn't_exist",
   869  			cfg: scalibr.ScanConfig{
   870  				Plugins: []plugin.Plugin{
   871  					fd.New().WithName("foo").WithRequiredExtractors("bar/baz"),
   872  				},
   873  			},
   874  			wantErr: cmpopts.AnyError,
   875  		},
   876  		{
   877  			name: "explicit_plugins_enabled",
   878  			cfg: scalibr.ScanConfig{
   879  				Plugins: []plugin.Plugin{
   880  					fd.New().WithName("foo").WithRequiredExtractors("python/wheelegg"),
   881  				},
   882  				ExplicitPlugins: true,
   883  			},
   884  			wantErr: cmpopts.AnyError,
   885  		},
   886  	}
   887  
   888  	for _, tc := range cases {
   889  		t.Run(tc.name, func(t *testing.T) {
   890  			if err := tc.cfg.EnableRequiredPlugins(); !cmp.Equal(tc.wantErr, err, cmpopts.EquateErrors()) {
   891  				t.Fatalf("EnableRequiredPlugins() error: %v, want %v", tc.wantErr, err)
   892  			}
   893  			if tc.wantErr == nil {
   894  				gotPlugins := []string{}
   895  				for _, p := range tc.cfg.Plugins {
   896  					gotPlugins = append(gotPlugins, p.Name())
   897  				}
   898  				if diff := cmp.Diff(
   899  					tc.wantPlugins,
   900  					gotPlugins,
   901  					cmpopts.EquateEmpty(),
   902  					cmpopts.SortSlices(func(l, r string) bool { return l < r }),
   903  				); diff != "" {
   904  					t.Errorf("EnableRequiredPlugins() diff (-want, +got):\n%s", diff)
   905  				}
   906  			}
   907  		})
   908  	}
   909  }
   910  
   911  type fakeExNeedsNetwork struct{}
   912  
   913  func (fakeExNeedsNetwork) Name() string                           { return "fake-extractor" }
   914  func (fakeExNeedsNetwork) Version() int                           { return 0 }
   915  func (fakeExNeedsNetwork) FileRequired(_ filesystem.FileAPI) bool { return false }
   916  func (fakeExNeedsNetwork) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
   917  	return inventory.Inventory{}, nil
   918  }
   919  func (fakeExNeedsNetwork) Requirements() *plugin.Capabilities {
   920  	return &plugin.Capabilities{Network: plugin.NetworkOnline}
   921  }
   922  
   923  type fakeDetNeedsFS struct {
   924  }
   925  
   926  func (fakeDetNeedsFS) Name() string                       { return "fake-extractor" }
   927  func (fakeDetNeedsFS) Version() int                       { return 0 }
   928  func (fakeDetNeedsFS) RequiredExtractors() []string       { return nil }
   929  func (fakeDetNeedsFS) DetectedFinding() inventory.Finding { return inventory.Finding{} }
   930  func (fakeDetNeedsFS) Scan(ctx context.Context, scanRoot *scalibrfs.ScanRoot, px *packageindex.PackageIndex) (inventory.Finding, error) {
   931  	return inventory.Finding{}, nil
   932  }
   933  func (fakeDetNeedsFS) Requirements() *plugin.Capabilities {
   934  	return &plugin.Capabilities{DirectFS: true}
   935  }
   936  
   937  func TestValidatePluginRequirements(t *testing.T) {
   938  	cases := []struct {
   939  		desc    string
   940  		cfg     scalibr.ScanConfig
   941  		wantErr error
   942  	}{
   943  		{
   944  			desc: "requirements_satisfied",
   945  			cfg: scalibr.ScanConfig{
   946  				Plugins: []plugin.Plugin{
   947  					&fakeExNeedsNetwork{},
   948  					&fakeDetNeedsFS{},
   949  					fen.MustNew(t, &fen.Config{
   950  						Name:    "enricher",
   951  						Version: 1,
   952  						Capabilities: &plugin.Capabilities{
   953  							Network:  plugin.NetworkOnline,
   954  							DirectFS: true,
   955  						},
   956  					}),
   957  				},
   958  				Capabilities: &plugin.Capabilities{
   959  					Network:  plugin.NetworkOnline,
   960  					DirectFS: true,
   961  				},
   962  			},
   963  			wantErr: nil,
   964  		},
   965  		{
   966  			desc: "one_detector's_requirements_unsatisfied",
   967  			cfg: scalibr.ScanConfig{
   968  				Plugins: []plugin.Plugin{
   969  					&fakeExNeedsNetwork{},
   970  					&fakeDetNeedsFS{},
   971  				},
   972  				Capabilities: &plugin.Capabilities{
   973  					Network:  plugin.NetworkOffline,
   974  					DirectFS: true,
   975  				},
   976  			},
   977  			wantErr: cmpopts.AnyError,
   978  		},
   979  		{
   980  			desc: "one_enrichers's_requirements_unsatisfied",
   981  			cfg: scalibr.ScanConfig{
   982  				Plugins: []plugin.Plugin{
   983  					&fakeExNeedsNetwork{},
   984  					fen.MustNew(t, &fen.Config{
   985  						Name:    "enricher",
   986  						Version: 1,
   987  						Capabilities: &plugin.Capabilities{
   988  							Network:  plugin.NetworkOnline,
   989  							DirectFS: true,
   990  						},
   991  					}),
   992  				},
   993  				Capabilities: &plugin.Capabilities{
   994  					Network:  plugin.NetworkOffline,
   995  					DirectFS: true,
   996  				},
   997  			},
   998  			wantErr: cmpopts.AnyError,
   999  		},
  1000  		{
  1001  			desc: "both_plugin's_requirements_unsatisfied",
  1002  			cfg: scalibr.ScanConfig{
  1003  				Plugins: []plugin.Plugin{
  1004  					&fakeExNeedsNetwork{},
  1005  					&fakeDetNeedsFS{},
  1006  				},
  1007  				Capabilities: &plugin.Capabilities{
  1008  					Network:  plugin.NetworkOffline,
  1009  					DirectFS: false,
  1010  				},
  1011  			},
  1012  			wantErr: cmpopts.AnyError,
  1013  		},
  1014  	}
  1015  
  1016  	for _, tc := range cases {
  1017  		t.Run(tc.desc, func(t *testing.T) {
  1018  			if err := tc.cfg.ValidatePluginRequirements(); !cmp.Equal(tc.wantErr, err, cmpopts.EquateErrors()) {
  1019  				t.Fatalf("ValidatePluginRequirements() error: %v, want %v", tc.wantErr, err)
  1020  			}
  1021  		})
  1022  	}
  1023  }
  1024  
  1025  type errorFS struct {
  1026  	err error
  1027  }
  1028  
  1029  func (f errorFS) Open(name string) (fs.File, error)          { return nil, f.err }
  1030  func (f errorFS) ReadDir(name string) ([]fs.DirEntry, error) { return nil, f.err }
  1031  func (f errorFS) Stat(name string) (fs.FileInfo, error)      { return nil, f.err }
  1032  
  1033  func TestErrorOnFSErrors(t *testing.T) {
  1034  	cases := []struct {
  1035  		desc            string
  1036  		ErrorOnFSErrors bool
  1037  		wantstatus      plugin.ScanStatusEnum
  1038  	}{
  1039  		{
  1040  			desc:            "ErrorOnFSErrors_is_false",
  1041  			ErrorOnFSErrors: false,
  1042  			wantstatus:      plugin.ScanStatusSucceeded,
  1043  		},
  1044  		{
  1045  			desc:            "ErrorOnFSErrors_is_true",
  1046  			ErrorOnFSErrors: true,
  1047  			wantstatus:      plugin.ScanStatusFailed,
  1048  		},
  1049  	}
  1050  
  1051  	for _, tc := range cases {
  1052  		t.Run(tc.desc, func(t *testing.T) {
  1053  			fs := errorFS{err: errors.New("some error")}
  1054  			cfg := &scalibr.ScanConfig{
  1055  				ScanRoots: []*scalibrfs.ScanRoot{{FS: fs}},
  1056  				Plugins: []plugin.Plugin{
  1057  					// Just a random extractor, such that walk is running.
  1058  					fe.New("python/wheelegg", 1, []string{"file.txt"}, map[string]fe.NamesErr{"file.txt": {Names: []string{"software"}}}),
  1059  				},
  1060  				ErrorOnFSErrors: tc.ErrorOnFSErrors,
  1061  			}
  1062  
  1063  			got := scalibr.New().Scan(t.Context(), cfg)
  1064  
  1065  			if got.Status.Status != tc.wantstatus {
  1066  				t.Errorf("Scan() status: %v, want %v", got.Status.Status, tc.wantstatus)
  1067  			}
  1068  		})
  1069  	}
  1070  }
  1071  
  1072  func TestAnnotator(t *testing.T) {
  1073  	tmp := t.TempDir()
  1074  	tmpRoot := []*scalibrfs.ScanRoot{{FS: scalibrfs.DirFS(tmp), Path: tmp}}
  1075  	log.Warn(filepath.Join(tmp, "file.txt"))
  1076  
  1077  	cacheDir := filepath.Join(tmp, "tmp")
  1078  	_ = os.Mkdir(cacheDir, fs.ModePerm)
  1079  	_ = os.WriteFile(filepath.Join(cacheDir, "file.txt"), []byte("Content"), 0644)
  1080  
  1081  	pkgName := "cached"
  1082  	fakeExtractor := fe.New(
  1083  		"python/wheelegg", 1, []string{"tmp/file.txt"},
  1084  		map[string]fe.NamesErr{"tmp/file.txt": {Names: []string{pkgName}, Err: nil}},
  1085  	)
  1086  
  1087  	cfg := &scalibr.ScanConfig{
  1088  		Plugins:   []plugin.Plugin{fakeExtractor, cachedir.New()},
  1089  		ScanRoots: tmpRoot,
  1090  	}
  1091  
  1092  	wantPkgs := []*extractor.Package{{
  1093  		Name:      pkgName,
  1094  		Locations: []string{"tmp/file.txt"},
  1095  		Plugins:   []string{fakeExtractor.Name()},
  1096  		ExploitabilitySignals: []*vex.PackageExploitabilitySignal{&vex.PackageExploitabilitySignal{
  1097  			Plugin:          cachedir.Name,
  1098  			Justification:   vex.ComponentNotPresent,
  1099  			MatchesAllVulns: true,
  1100  		}},
  1101  	}}
  1102  
  1103  	got := scalibr.New().Scan(t.Context(), cfg)
  1104  
  1105  	if diff := cmp.Diff(wantPkgs, got.Inventory.Packages, fe.AllowUnexported); diff != "" {
  1106  		t.Errorf("scalibr.New().Scan(%v): unexpected diff (-want +got):\n%s", cfg, diff)
  1107  	}
  1108  }