diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index b0eb7f9e..5f86d751 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -116,6 +116,14 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': dest='import_site', help='Do not implicitly import site during Python path detection.\n' 'Prevents evaluation of `*.pth` files, but may lead to incomplete component detection.') + p.add_argument('--isolated', + action='store_true', + dest='isolated', + help='Run the target interpreter with `-E` (ignore PYTHON* environment\n' + 'variables such as PYTHONPATH) when detecting its path.\n' + 'Only applies when a target `` is given; has no effect when\n' + 'analyzing the current environment.\n' + 'Interpreters that require PYTHONHOME to start may fail with this flag.') p.add_argument('--gather-license-texts', action='store_true', dest='gather_license_texts', @@ -143,6 +151,7 @@ def __init__(self, *, def __call__(self, *, # type:ignore[override] import_site: bool, + isolated: bool, python: Optional[str], pyproject_file: Optional[str], mc_type: 'ComponentType', @@ -161,7 +170,7 @@ def __call__(self, *, # type:ignore[override] path: list[str] if python: - path = self.__path4python(python, import_site) + path = self.__path4python(python, import_site, isolated) else: path = sys_path.copy() if path[0] in ('', getcwd()): @@ -293,11 +302,16 @@ def __py_interpreter(value: str) -> str: raise ValueError(f'Failed to find python in directory: {value}') return value - def __path4python(self, python: str, import_site: bool) -> list[str]: + def __path4python(self, python: str, import_site: bool, isolated: bool) -> list[str]: cmd = [self.__py_interpreter(python), '-c', 'import json,sys;json.dump(sys.path,sys.stdout)'] if not import_site: cmd.insert(1, '-S') + if isolated: + # `-E` ignores PYTHON* env vars (PYTHONPATH, PYTHONHOME, ...); + # see https://github.com/CycloneDX/cyclonedx-python/issues/1045 + # Prefer `-E` over `-I` so user-site packages stay discoverable. + cmd.insert(1, '-E') self._logger.debug('fetch `path` from python interpreter cmd: %r', cmd) res = run(cmd, capture_output=True, encoding='utf8', shell=False) # nosec diff --git a/docs/usage.rst b/docs/usage.rst index ad6eef21..38e75a1d 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -62,6 +62,7 @@ The full documentation can be issued by running with ``environment --help``: [-o ] [--sv ] [--of ] [--pyproject ] [--mc-type ] [-S] + [--isolated] [] Build an SBOM from Python (virtual) environment @@ -73,6 +74,12 @@ The full documentation can be issued by running with ``environment --help``: -h, --help show this help message and exit -S Do not implicitly import site during Python path detection. Prevents evaluation of `*.pth` files, but may lead to incomplete component detection. + --isolated Run the target interpreter with `-E` (ignore PYTHON* + environment variables such as PYTHONPATH) when + detecting its path. Only applies when a target + `` is given; has no effect when analyzing the + current environment. Interpreters that require + PYTHONHOME to start may fail with this flag. --gather-license-texts Enable license text gathering. --pyproject Path to the root component's `pyproject.toml` file. diff --git a/tests/integration/test_cli_environment.py b/tests/integration/test_cli_environment.py index a657feb1..bd0b3a3f 100644 --- a/tests/integration/test_cli_environment.py +++ b/tests/integration/test_cli_environment.py @@ -18,12 +18,15 @@ import random from collections.abc import Generator from glob import glob -from os import name as os_name +from json import loads as json_loads +from os import environ, mkdir, name as os_name from os.path import basename, dirname, join from subprocess import run # nosec:B404 from sys import executable, stderr +from tempfile import TemporaryDirectory from typing import Any from unittest import TestCase, skipIf +from unittest.mock import patch from cyclonedx.schema import OutputFormat, SchemaVersion from ddt import data, ddt, named_data @@ -147,6 +150,46 @@ def test_with_sites_evaluation_suppressed(self) -> None: self.assertEqual(0, res, err) self.assertEqualSnapshot(out, 'test_with_sites_evaluation_suppressed', projectdir, sv, of) + def test_isolated_ignores_parent_pythonpath(self) -> None: + """Regression for #1045: with --isolated, target is probed via python -E, + so a parent PYTHONPATH (inherited by the subprocess via os.environ / + patch.dict) must not appear in the SBOM.""" + projectdir = join(INFILES_DIRECTORY, 'environment', 'no-deps') + sv = SchemaVersion.V1_6 + of = OutputFormat.JSON + foreign_name = 'leakypkg' + + with TemporaryDirectory() as foreign_root: + dist_info = join(foreign_root, f'{foreign_name}-9.9.9.dist-info') + mkdir(dist_info) + with open(join(dist_info, 'METADATA'), 'w', encoding='utf8') as fh: + fh.write('Metadata-Version: 2.1\n' + f'Name: {foreign_name}\n' + 'Version: 9.9.9\n') + + common = ( + 'environment', + '-vvv', + '--sv', sv.to_version(), + '--of', of.name, + '--output-reproducible', + '-o=-', + join(projectdir, '.venv'), + ) + + with patch.dict(environ, {'PYTHONPATH': foreign_root}): + res, out, err = run_cli(*common) + self.assertEqual(0, res, err) + names = {c['name'] for c in json_loads(out).get('components') or ()} + self.assertIn(foreign_name, names) + + with patch.dict(environ, {'PYTHONPATH': foreign_root}): + res, out, err = run_cli(*common[:-1], '--isolated', common[-1]) + self.assertEqual(0, res, err) + self.assertIn('-E', err) # probe cmd logged at -vvv + names = {c['name'] for c in json_loads(out).get('components') or ()} + self.assertNotIn(foreign_name, names) + def test_with_current_python(self) -> None: sv = SchemaVersion.V1_6 of = random.choice((OutputFormat.XML, OutputFormat.JSON)) # nosec B311