Repository: google/osv-scanner

Issue:
Use `transitivedependency` enricher for transitive extraction for requirements.txt
Currently, OSV-Scanner depends on [`requirementsnet` extractor](https://github.com/google/osv-scalibr/tree/main/extractor/filesystem/language/python/requirementsnet) for transitive extraction for `requirements.txt`. 

However, OSV-Scalibr is migrating to use [`transitivedependency` enricher](https://github.com/google/osv-scalibr/tree/main/enricher/transitivedependency/requirements) for this purpose.

We probably should migrate [`requirementsenhancable`](https://github.com/google/osv-scanner/tree/main/internal/scalibrextract/language/python/requirementsenhancable) to use the enricher as well.

Hints from issue comments:

Solution patch:
diff --git a/cmd/osv-scanner/scan/source/__snapshots__/command_test.snap b/cmd/osv-scanner/scan/source/__snapshots__/command_test.snap
index 504bb8a7..907953a3 100755
--- a/cmd/osv-scanner/scan/source/__snapshots__/command_test.snap
+++ b/cmd/osv-scanner/scan/source/__snapshots__/command_test.snap
@@ -1371,15 +1371,15 @@ unsupported output format "unknown" - must be one of: table, html, vertical, jso
 
 [TestCommand/requirements.txt_can_have_all_kinds_of_names - 1]
 Scanning dir ./testdata/locks-requirements
-Scanned <rootdir>/testdata/locks-requirements/my-requirements.txt file and found 6 packages
-Scanned <rootdir>/testdata/locks-requirements/requirements-dev.txt file and found 8 packages
-Scanned <rootdir>/testdata/locks-requirements/requirements-transitive.txt file and found 14 packages
-Scanned <rootdir>/testdata/locks-requirements/requirements.prod.txt file and found 3 packages
-Scanned <rootdir>/testdata/locks-requirements/requirements.txt file and found 13 packages
+Scanned <rootdir>/testdata/locks-requirements/my-requirements.txt file and found 1 package
+Scanned <rootdir>/testdata/locks-requirements/requirements-dev.txt file and found 1 package
+Scanned <rootdir>/testdata/locks-requirements/requirements-transitive.txt file and found 4 packages
+Scanned <rootdir>/testdata/locks-requirements/requirements.prod.txt file and found 1 package
+Scanned <rootdir>/testdata/locks-requirements/requirements.txt file and found 3 packages
 Scanned <rootdir>/testdata/locks-requirements/the_requirements_for_test.txt file and found 1 package
 Scanned <rootdir>/testdata/locks-requirements/unresolvable-requirements.txt file and found 3 packages
+failed resolution: no file can be used for parsing requirements for package flask-cors version 1.0
 failed to parse metadata for file Flask-Cors-1.0.tar.gz: sdist: dependencies in setup.py, not in PKG-INFO
-failed to resolve transitive dependencies for "<rootdir>/testdata/locks-requirements/unresolvable-requirements.txt", falling back to offline extraction: failed resolving: no file can be used for parsing requirements for package flask-cors version 1.0
 Total 15 packages affected by 59 known vulnerabilities (5 Critical, 23 High, 28 Medium, 0 Low, 3 Unknown) from 1 ecosystem.
 59 vulnerabilities can be fixed.
 
@@ -1399,12 +1399,12 @@ Total 15 packages affected by 59 known vulnerabilities (5 Critical, 23 High, 28
 | https://osv.dev/GHSA-rrqc-c2jx-6jgv | 6.3  | PyPI      | django     | 1.11.29 | 4.2.16        | testdata/locks-requirements/requirements-transitive.txt   |
 | https://osv.dev/PYSEC-2023-62       | 8.7  | PyPI      | flask      | 1.0     | 2.2.5         | testdata/locks-requirements/requirements-transitive.txt   |
 | https://osv.dev/GHSA-m2qf-hxjv-5gpq |      |           |            |         |               |                                                           |
-| https://osv.dev/PYSEC-2024-60       | 7.5  | PyPI      | idna       | 2.7     | 3.7           | testdata/locks-requirements/requirements-transitive.txt   |
-| https://osv.dev/GHSA-jjg7-2v4v-x38h |      |           |            |         |               |                                                           |
 | https://osv.dev/PYSEC-2023-74       | 6.1  | PyPI      | requests   | 2.20.0  | 2.31.0        | testdata/locks-requirements/requirements-transitive.txt   |
 | https://osv.dev/GHSA-j8r2-6x86-q33q |      |           |            |         |               |                                                           |
 | https://osv.dev/GHSA-9hjg-9r4m-mvj7 | 5.3  | PyPI      | requests   | 2.20.0  | 2.32.4        | testdata/locks-requirements/requirements-transitive.txt   |
 | https://osv.dev/GHSA-9wx4-h78v-vm56 | 5.6  | PyPI      | requests   | 2.20.0  | 2.32.0        | testdata/locks-requirements/requirements-transitive.txt   |
+| https://osv.dev/PYSEC-2024-60       | 7.5  | PyPI      | idna       | 2.7     | 3.7           | testdata/locks-requirements/requirements-transitive.txt   |
+| https://osv.dev/GHSA-jjg7-2v4v-x38h |      |           |            |         |               |                                                           |
 | https://osv.dev/PYSEC-2020-148      | 6.9  | PyPI      | urllib3    | 1.24.3  | 1.25.9        | testdata/locks-requirements/requirements-transitive.txt   |
 | https://osv.dev/GHSA-wqvq-5m8c-6g24 |      |           |            |         |               |                                                           |
 | https://osv.dev/PYSEC-2021-108      |      | PyPI      | urllib3    | 1.24.3  | 1.26.5        | testdata/locks-requirements/requirements-transitive.txt   |
@@ -1446,12 +1446,12 @@ Total 15 packages affected by 59 known vulnerabilities (5 Critical, 23 High, 28
 | https://osv.dev/GHSA-rrqc-c2jx-6jgv | 6.3  | PyPI      | django     | 1.11.29 | 4.2.16        | testdata/locks-requirements/requirements.txt              |
 | https://osv.dev/PYSEC-2023-62       | 8.7  | PyPI      | flask      | 1.0     | 2.2.5         | testdata/locks-requirements/requirements.txt              |
 | https://osv.dev/GHSA-m2qf-hxjv-5gpq |      |           |            |         |               |                                                           |
-| https://osv.dev/PYSEC-2024-60       | 7.5  | PyPI      | idna       | 2.7     | 3.7           | testdata/locks-requirements/requirements.txt              |
-| https://osv.dev/GHSA-jjg7-2v4v-x38h |      |           |            |         |               |                                                           |
 | https://osv.dev/PYSEC-2023-74       | 6.1  | PyPI      | requests   | 2.20.0  | 2.31.0        | testdata/locks-requirements/requirements.txt              |
 | https://osv.dev/GHSA-j8r2-6x86-q33q |      |           |            |         |               |                                                           |
 | https://osv.dev/GHSA-9hjg-9r4m-mvj7 | 5.3  | PyPI      | requests   | 2.20.0  | 2.32.4        | testdata/locks-requirements/requirements.txt              |
 | https://osv.dev/GHSA-9wx4-h78v-vm56 | 5.6  | PyPI      | requests   | 2.20.0  | 2.32.0        | testdata/locks-requirements/requirements.txt              |
+| https://osv.dev/PYSEC-2024-60       | 7.5  | PyPI      | idna       | 2.7     | 3.7           | testdata/locks-requirements/requirements.txt              |
+| https://osv.dev/GHSA-jjg7-2v4v-x38h |      |           |            |         |               |                                                           |
 | https://osv.dev/PYSEC-2020-148      | 6.9  | PyPI      | urllib3    | 1.24.3  | 1.25.9        | testdata/locks-requirements/requirements.txt              |
 | https://osv.dev/GHSA-wqvq-5m8c-6g24 |      |           |            |         |               |                                                           |
 | https://osv.dev/PYSEC-2021-108      |      | PyPI      | urllib3    | 1.24.3  | 1.26.5        | testdata/locks-requirements/requirements.txt              |
@@ -4891,8 +4891,8 @@ unsupported data-source "github" - must be one of: deps.dev, native
 [TestCommand_Transitive/fall_back_to_the_offline_extractor_if_resolution_failed - 1]
 Scanning dir ./testdata/locks-requirements/unresolvable-requirements.txt
 Scanned <rootdir>/testdata/locks-requirements/unresolvable-requirements.txt file and found 3 packages
+failed resolution: no file can be used for parsing requirements for package flask-cors version 1.0
 failed to parse metadata for file Flask-Cors-1.0.tar.gz: sdist: dependencies in setup.py, not in PKG-INFO
-failed to resolve transitive dependencies for "<rootdir>/testdata/locks-requirements/unresolvable-requirements.txt", falling back to offline extraction: failed resolving: no file can be used for parsing requirements for package flask-cors version 1.0
 Total 3 packages affected by 8 known vulnerabilities (0 Critical, 3 High, 4 Medium, 0 Low, 1 Unknown) from 1 ecosystem.
 8 vulnerabilities can be fixed.
 
@@ -5124,9 +5124,19 @@ Total 1 package affected by 4 known vulnerabilities (2 Critical, 1 High, 1 Mediu
 
 ---
 
+[TestCommand_Transitive/transitive_requirements_enricher_requires_enabled_requirements_extractor - 1]
+Scanning dir ./testdata/locks-requirements/requirements-transitive.txt
+
+---
+
+[TestCommand_Transitive/transitive_requirements_enricher_requires_enabled_requirements_extractor - 2]
+No package sources found, --help for usage information.
+
+---
+
 [TestCommand_Transitive/uses_native_data_source_for_requirements.txt - 1]
 Scanning dir ./testdata/locks-requirements/requirements.txt
-Scanned <rootdir>/testdata/locks-requirements/requirements.txt file and found 13 packages
+Scanned <rootdir>/testdata/locks-requirements/requirements.txt file and found 3 packages
 Total 5 packages affected by 18 known vulnerabilities (1 Critical, 6 High, 10 Medium, 0 Low, 1 Unknown) from 1 ecosystem.
 18 vulnerabilities can be fixed.
 
@@ -5144,12 +5154,12 @@ Total 5 packages affected by 18 known vulnerabilities (1 Critical, 6 High, 10 Me
 | https://osv.dev/GHSA-rrqc-c2jx-6jgv | 6.3  | PyPI      | django   | 1.11.29 | 4.2.16        | testdata/locks-requirements/requirements.txt |
 | https://osv.dev/PYSEC-2023-62       | 8.7  | PyPI      | flask    | 1.0     | 2.2.5         | testdata/locks-requirements/requirements.txt |
 | https://osv.dev/GHSA-m2qf-hxjv-5gpq |      |           |          |         |               |                                              |
-| https://osv.dev/PYSEC-2024-60       | 7.5  | PyPI      | idna     | 2.7     | 3.7           | testdata/locks-requirements/requirements.txt |
-| https://osv.dev/GHSA-jjg7-2v4v-x38h |      |           |          |         |               |                                              |
 | https://osv.dev/PYSEC-2023-74       | 6.1  | PyPI      | requests | 2.20.0  | 2.31.0        | testdata/locks-requirements/requirements.txt |
 | https://osv.dev/GHSA-j8r2-6x86-q33q |      |           |          |         |               |                                              |
 | https://osv.dev/GHSA-9hjg-9r4m-mvj7 | 5.3  | PyPI      | requests | 2.20.0  | 2.32.4        | testdata/locks-requirements/requirements.txt |
 | https://osv.dev/GHSA-9wx4-h78v-vm56 | 5.6  | PyPI      | requests | 2.20.0  | 2.32.0        | testdata/locks-requirements/requirements.txt |
+| https://osv.dev/PYSEC-2024-60       | 7.5  | PyPI      | idna     | 2.7     | 3.7           | testdata/locks-requirements/requirements.txt |
+| https://osv.dev/GHSA-jjg7-2v4v-x38h |      |           |          |         |               |                                              |
 | https://osv.dev/PYSEC-2020-148      | 6.9  | PyPI      | urllib3  | 1.24.3  | 1.25.9        | testdata/locks-requirements/requirements.txt |
 | https://osv.dev/GHSA-wqvq-5m8c-6g24 |      |           |          |         |               |                                              |
 | https://osv.dev/PYSEC-2021-108      |      | PyPI      | urllib3  | 1.24.3  | 1.26.5        | testdata/locks-requirements/requirements.txt |
diff --git a/internal/scalibrextract/language/python/requirementsenhancable/requirementsenhanceable.go b/internal/scalibrextract/language/python/requirementsenhancable/requirementsenhanceable.go
deleted file mode 100644
index 6c69664c..00000000
--- a/internal/scalibrextract/language/python/requirementsenhancable/requirementsenhanceable.go
+++ /dev/null
@@ -1,100 +0,0 @@
-// Package requirementsenhancable provides an extractor for requirements.txt
-// that can both do offline and transitive scanning.
-package requirementsenhancable
-
-import (
-	"context"
-
-	"github.com/google/osv-scalibr/extractor/filesystem"
-	"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
-	"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirementsnet"
-	"github.com/google/osv-scalibr/inventory"
-	"github.com/google/osv-scalibr/plugin"
-	"github.com/google/osv-scanner/v2/internal/cmdlogger"
-)
-
-const (
-	// Name is the unique name of this extractor.
-	Name = "python/requirementsenhanceable"
-)
-
-// Extractor extracts python packages from requirements.txt files.
-type Extractor struct {
-	offline filesystem.Extractor
-	online  filesystem.Extractor
-}
-
-// New returns a new instance of the extractor.
-func New() filesystem.Extractor {
-	base := requirements.New(requirements.DefaultConfig())
-	return &Extractor{offline: base, online: base}
-}
-
-// Name of the extractor
-func (e *Extractor) Name() string { return Name }
-
-// Version of the extractor
-func (e *Extractor) Version() int { return 0 }
-
-// Requirements of the extractor
-func (e *Extractor) Requirements() *plugin.Capabilities {
-	req := e.online.Requirements()
-	req.Network = plugin.NetworkAny
-
-	return req
-}
-
-// FileRequired returns true if the specified file matches using the underlying requirements extractor
-func (e *Extractor) FileRequired(api filesystem.FileAPI) bool {
-	return e.online.FileRequired(api)
-}
-
-// Extract extracts packages using the internal extractor
-func (e *Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
-	inv, err := e.online.Extract(ctx, input)
-	if err == nil {
-		return inv, nil
-	}
-
-	if e.online.Name() == e.offline.Name() {
-		// online is the same as offline so we don't need to run extraction again.
-		return inv, err
-	}
-
-	cmdlogger.Warnf(
-		"failed to resolve transitive dependencies for %q, falling back to offline extraction: %s", input.Path, err.Error())
-
-	// Fallback to the base extractor if the enhanced extraction failed.
-	f, err := input.FS.Open(input.Path)
-	if err != nil {
-		return inventory.Inventory{}, err
-	}
-	input.Reader = f
-	defer f.Close()
-
-	return e.offline.Extract(ctx, input)
-}
-
-var _ filesystem.Extractor = &Extractor{}
-
-type enhanceable interface {
-	Enhance(config requirementsnet.Config)
-}
-
-// Enhance uses the given config to improve the abilities of this extractor,
-// at the cost of additional requirements such as networking and direct fs access
-func (e *Extractor) Enhance(config requirementsnet.Config) {
-	e.online = requirementsnet.New(config)
-}
-
-var _ enhanceable = &Extractor{}
-
-// EnhanceIfPossible calls Extractor.Enhance with the given config if the
-// provided plug(in) is an Extractor
-func EnhanceIfPossible(plug plugin.Plugin, config requirementsnet.Config) {
-	us, ok := plug.(enhanceable)
-
-	if ok {
-		us.Enhance(config)
-	}
-}
diff --git a/internal/scalibrplugin/__snapshots__/resolve_test.snap b/internal/scalibrplugin/__snapshots__/resolve_test.snap
index d4f20ef3..8a896879 100755
--- a/internal/scalibrplugin/__snapshots__/resolve_test.snap
+++ b/internal/scalibrplugin/__snapshots__/resolve_test.snap
@@ -65,7 +65,7 @@ php/composerlock
 python/pdmlock
 python/pipfilelock
 python/poetrylock
-python/requirementsenhanceable
+python/requirements
 python/uvlock
 r/renvlock
 ruby/gemfilelock
diff --git a/internal/scalibrplugin/presets.go b/internal/scalibrplugin/presets.go
index 99ac7fb5..b78f9a93 100644
--- a/internal/scalibrplugin/presets.go
+++ b/internal/scalibrplugin/presets.go
@@ -32,6 +32,7 @@ import (
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/pdmlock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/pipfilelock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/poetrylock"
+	"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/wheelegg"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/r/renvlock"
@@ -49,7 +50,6 @@ import (
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/java/pomxmlenhanceable"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/javascript/nodemodules"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/osv/osvscannerjson"
-	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/python/requirementsenhancable"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/vcs/gitrepo"
 	"github.com/google/osv-scanner/v2/internal/version"
 )
@@ -94,11 +94,11 @@ var ExtractorPresets = map[string]extractors.InitMap{
 		composerlock.Name: {noCFG(composerlock.New)},
 
 		// Python
-		pipfilelock.Name:            {noCFG(pipfilelock.New)},
-		pdmlock.Name:                {noCFG(pdmlock.New)},
-		poetrylock.Name:             {noCFG(poetrylock.New)},
-		requirementsenhancable.Name: {noCFG(requirementsenhancable.New)},
-		uvlock.Name:                 {noCFG(uvlock.New)},
+		pipfilelock.Name:  {noCFG(pipfilelock.New)},
+		pdmlock.Name:      {noCFG(pdmlock.New)},
+		poetrylock.Name:   {noCFG(poetrylock.New)},
+		requirements.Name: {noCFG(requirements.NewDefault)},
+		uvlock.Name:       {noCFG(uvlock.New)},
 
 		// R
 		renvlock.Name: {noCFG(renvlock.New)},
diff --git a/internal/scalibrplugin/resolve.go b/internal/scalibrplugin/resolve.go
index f37657e5..b3723571 100644
--- a/internal/scalibrplugin/resolve.go
+++ b/internal/scalibrplugin/resolve.go
@@ -11,7 +11,6 @@ import (
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/java/pomxmlenhanceable"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/javascript/nodemodules"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/osv/osvscannerjson"
-	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/python/requirementsenhancable"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/vcs/gitrepo"
 )
 
@@ -29,9 +28,6 @@ func resolveFromName(name string) (plugin.Plugin, error) {
 	// Javascript
 	case nodemodules.Name:
 		return nodemodules.New(), nil
-	// Python
-	case requirementsenhancable.Name:
-		return requirementsenhancable.New(), nil
 	// Directories
 	case vendored.Name:
 		return vendored.New(), nil
diff --git a/pkg/osvscanner/internal/scanners/lockfile.go b/pkg/osvscanner/internal/scanners/lockfile.go
index d8b44094..6611e05d 100644
--- a/pkg/osvscanner/internal/scanners/lockfile.go
+++ b/pkg/osvscanner/internal/scanners/lockfile.go
@@ -29,6 +29,7 @@ import (
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/pdmlock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/pipfilelock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/poetrylock"
+	"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/r/renvlock"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/ruby/gemfilelock"
@@ -38,7 +39,6 @@ import (
 	"github.com/google/osv-scalibr/plugin"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/java/pomxmlenhanceable"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/osv/osvscannerjson"
-	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/python/requirementsenhancable"
 )
 
 // OSV-Scanner and OSV-Scalibr has different plugin/override naming conventions.
@@ -56,7 +56,7 @@ var osvscannerScalibrExtractionMapping = map[string][]string{
 	"poetry.lock":                 {poetrylock.Name},
 	"Pipfile.lock":                {pipfilelock.Name},
 	"pdm.lock":                    {pdmlock.Name},
-	"requirements.txt":            {requirementsenhancable.Name},
+	"requirements.txt":            {requirements.Name},
 	"uv.lock":                     {uvlock.Name},
 	"Cargo.lock":                  {cargolock.Name},
 	"composer.lock":               {composerlock.Name},
diff --git a/pkg/osvscanner/osvscanner.go b/pkg/osvscanner/osvscanner.go
index e74003ae..6e2be810 100644
--- a/pkg/osvscanner/osvscanner.go
+++ b/pkg/osvscanner/osvscanner.go
@@ -290,7 +290,9 @@ func DoContainerScan(actions ScannerActions) (models.VulnerabilityResults, error
 		actions,
 	)
 
-	if len(plugins) == 0 {
+	// technically having one detector enabled would also be sufficient, but we're
+	// not mentioning them to avoid confusion since they're still in their infancy
+	if countNotEnrichers(plugins) == 0 {
 		return models.VulnerabilityResults{}, errors.New("at least one extractor must be enabled")
 	}
 
@@ -347,6 +349,7 @@ func DoContainerScan(actions ScannerActions) (models.VulnerabilityResults, error
 		Plugins:           plugins,
 		Capabilities:      capabilities,
 		StoreAbsolutePath: true,
+		ExplicitPlugins:   true,
 	})
 	if err != nil {
 		return models.VulnerabilityResults{}, fmt.Errorf("failed to scan container image: %w", err)
diff --git a/pkg/osvscanner/scan.go b/pkg/osvscanner/scan.go
index 2509ceef..c2111ae9 100644
--- a/pkg/osvscanner/scan.go
+++ b/pkg/osvscanner/scan.go
@@ -11,12 +11,13 @@ import (
 	"strings"
 
 	scalibr "github.com/google/osv-scalibr"
+	"github.com/google/osv-scalibr/enricher"
 	"github.com/google/osv-scalibr/enricher/reachability/java"
+	transitivedependencyrequirements "github.com/google/osv-scalibr/enricher/transitivedependency/requirements"
 	"github.com/google/osv-scalibr/extractor"
 	"github.com/google/osv-scalibr/extractor/filesystem"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/java/pomxmlnet"
 	"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements"
-	"github.com/google/osv-scalibr/extractor/filesystem/language/python/requirementsnet"
 	"github.com/google/osv-scalibr/extractor/filesystem/simplefileapi"
 	"github.com/google/osv-scalibr/fs"
 	"github.com/google/osv-scalibr/inventory"
@@ -25,7 +26,6 @@ import (
 	"github.com/google/osv-scanner/v2/internal/imodels"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/filesystem/vendored"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/java/pomxmlenhanceable"
-	"github.com/google/osv-scanner/v2/internal/scalibrextract/language/python/requirementsenhancable"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/vcs/gitcommitdirect"
 	"github.com/google/osv-scanner/v2/internal/scalibrextract/vcs/gitrepo"
 	"github.com/google/osv-scanner/v2/internal/scalibrplugin"
@@ -44,12 +44,6 @@ func configurePlugins(plugins []plugin.Plugin, accessors ExternalAccessors, acti
 				MavenRegistryAPIClient: accessors.MavenRegistryAPIClient,
 			})
 		}
-		if accessors.DependencyClients[osvconstants.EcosystemPyPI] != nil {
-			requirementsenhancable.EnhanceIfPossible(plug, requirementsnet.Config{
-				Extractor: &requirements.Extractor{},
-				Client:    accessors.DependencyClients[osvconstants.EcosystemPyPI],
-			})
-		}
 
 		vendored.Configure(plug, vendored.Config{
 			// Only attempt to vendor check git directories if we are not skipping scanning root git directories
@@ -59,6 +53,18 @@ func configurePlugins(plugins []plugin.Plugin, accessors ExternalAccessors, acti
 	}
 }
 
+func isRequirementsExtractorEnabled(plugins []plugin.Plugin) bool {
+	for _, plug := range plugins {
+		_, ok := plug.(*requirements.Extractor)
+
+		if ok {
+			return true
+		}
+	}
+
+	return false
+}
+
 func getPlugins(defaultPlugins []string, accessors ExternalAccessors, actions ScannerActions) []plugin.Plugin {
 	if !actions.PluginsNoDefaults {
 		actions.PluginsEnabled = append(actions.PluginsEnabled, defaultPlugins...)
@@ -74,11 +80,29 @@ func getPlugins(defaultPlugins []string, accessors ExternalAccessors, actions Sc
 
 	plugins := scalibrplugin.Resolve(actions.PluginsEnabled, actions.PluginsDisabled)
 
+	// todo: use Enricher.RequiredPlugins to check this generically
+	if accessors.DependencyClients[osvconstants.EcosystemPyPI] != nil && isRequirementsExtractorEnabled(plugins) {
+		plugins = append(plugins, transitivedependencyrequirements.NewEnricher(accessors.DependencyClients[osvconstants.EcosystemPyPI]))
+	}
+
 	configurePlugins(plugins, accessors, actions)
 
 	return plugins
 }
 
+// countNotEnrichers counts the number of plugins that are not enricher.Enricher plugins
+func countNotEnrichers(plugins []plugin.Plugin) int {
+	count := 0
+	for _, plug := range plugins {
+		_, ok := plug.(enricher.Enricher)
+		if !ok {
+			count++
+		}
+	}
+
+	return count
+}
+
 // scan essentially converts ScannerActions into imodels.ScanResult by performing the extractions
 func scan(accessors ExternalAccessors, actions ScannerActions) (*imodels.ScanResult, error) {
 	var scannedInventories []*extractor.Package
@@ -90,7 +114,9 @@ func scan(accessors ExternalAccessors, actions ScannerActions) (*imodels.ScanRes
 		actions,
 	)
 
-	if len(plugins) == 0 {
+	// technically having one detector enabled would also be sufficient, but we're
+	// not mentioning them to avoid confusion since they're still in their infancy
+	if countNotEnrichers(plugins) == 0 {
 		return nil, errors.New("at least one extractor must be enabled")
 	}
 
@@ -209,6 +235,7 @@ SBOMLoop:
 			StoreAbsolutePath:     true,
 			PrintDurationAnalysis: false,
 			ErrorOnFSErrors:       false,
+			ExplicitPlugins:       true,
 			ExtractorOverride: func(api filesystem.FileAPI) []filesystem.Extractor {
 				ext, ok := overrideMap[filepath.Join(root, filepath.FromSlash(api.Path()))]
 				if ok {



Test patch:
diff --git a/cmd/osv-scanner/scan/source/command_test.go b/cmd/osv-scanner/scan/source/command_test.go
index d6091a77..2c769f51 100644
--- a/cmd/osv-scanner/scan/source/command_test.go
+++ b/cmd/osv-scanner/scan/source/command_test.go
@@ -1203,6 +1203,11 @@ func TestCommand_Transitive(t *testing.T) {
 			Args: []string{"", "source", "--no-resolve", "./testdata/locks-requirements/requirements-transitive.txt"},
 			Exit: 1,
 		},
+		{
+			Name: "transitive_requirements_enricher_requires_enabled_requirements_extractor",
+			Args: []string{"", "source", "--experimental-disable-plugins=python/requirements", "./testdata/locks-requirements/requirements-transitive.txt"},
+			Exit: 128,
+		},
 	}
 
 	for _, tt := range tests {