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 }