diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index b0eb7f9e..ffd71271 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -20,10 +20,10 @@ from collections.abc import Iterable from importlib.metadata import distributions from json import JSONDecodeError, loads as json_loads -from os import getcwd, name as os_name +from os import environ, getcwd, name as os_name from os.path import exists, isdir, join from subprocess import run # nosec -from sys import path as sys_path +from sys import executable, path as sys_path from textwrap import dedent from typing import TYPE_CHECKING, Any, Optional @@ -50,6 +50,19 @@ T_AllComponents = dict[str, tuple['Component', Iterable[Requirement]]] +# Parent-process variables that change interpreter discovery but are not part of +# the target environment unless the caller opts in by leaving `--isolated` off. +_ISOLATED_ENV_NAMES = ('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV') + + +def _path_probe_env(isolated: bool) -> Optional[dict[str, str]]: + if not isolated: + return None + # Windows environment names are case-insensitive. A plain dict lookup would + # leave PythonPath in place, and the child interpreter would still honor it. + dropped = {name.casefold() for name in _ISOLATED_ENV_NAMES} + return {key: value for key, value in environ.items() if key.casefold() not in dropped} + class EnvironmentBB(BomBuilder): @@ -116,6 +129,12 @@ 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='Do not inherit PYTHONPATH, PYTHONHOME, or VIRTUAL_ENV\n' + 'when detecting the target interpreter package path.\n' + 'Off by default, so environments that rely on those variables stay unchanged.') p.add_argument('--gather-license-texts', action='store_true', dest='gather_license_texts', @@ -146,6 +165,7 @@ def __call__(self, *, # type:ignore[override] python: Optional[str], pyproject_file: Optional[str], mc_type: 'ComponentType', + isolated: bool = False, **__: Any) -> 'Bom': if pyproject_file is None: rc = None @@ -161,7 +181,9 @@ def __call__(self, *, # type:ignore[override] path: list[str] if python: - path = self.__path4python(python, import_site) + path = self.__path4python(python, import_site, isolated=isolated) + elif isolated: + path = self.__path4python(executable, import_site, isolated=True) else: path = sys_path.copy() if path[0] in ('', getcwd()): @@ -293,14 +315,15 @@ 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 = False) -> 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') self._logger.debug('fetch `path` from python interpreter cmd: %r', cmd) - res = run(cmd, capture_output=True, encoding='utf8', shell=False) # nosec + res = run( # nosec + cmd, capture_output=True, encoding='utf8', shell=False, env=_path_probe_env(isolated)) if res.returncode != 0: raise RuntimeError('Fail fetching `path` from Python interpreter.\n' f'returncode: {res.returncode}\n' diff --git a/docs/usage.rst b/docs/usage.rst index ad6eef21..4569c80b 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -61,7 +61,7 @@ The full documentation can be issued by running with ``environment --help``: [--validate | --no-validate] [-o ] [--sv ] [--of ] [--pyproject ] [--mc-type ] - [-S] + [-S] [--isolated] [] Build an SBOM from Python (virtual) environment @@ -73,6 +73,10 @@ 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 Do not inherit PYTHONPATH, PYTHONHOME, or VIRTUAL_ENV + when detecting the target interpreter package path. + Off by default, so environments that rely on those + variables stay unchanged. --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..5fd223e1 100644 --- a/tests/integration/test_cli_environment.py +++ b/tests/integration/test_cli_environment.py @@ -18,12 +18,14 @@ import random from collections.abc import Generator from glob import glob -from os import name as os_name +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 @@ -54,6 +56,7 @@ class TestCliEnvironment(TestCase, SnapshotMixin): def test_help(self, subcommand: str) -> None: res, out, err = run_cli(subcommand, '--help') self.assertEqual(0, res, '\n'.join((out, err))) + self.assertIn('--isolated', out) @classmethod def __setup_testbeds_init(cls) -> None: @@ -147,6 +150,39 @@ 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_discovery_env(self) -> None: + leak_name = 'leakpkg-isolated-test' + with TemporaryDirectory() as leak_root, TemporaryDirectory() as python_home: + dist_info = join(leak_root, 'leakpkg_isolated_test-9.9.9.dist-info') + mkdir(dist_info) + with open(join(dist_info, 'METADATA'), 'w', encoding='utf-8') as metadata: + metadata.write( + 'Metadata-Version: 2.1\n' + 'Name: leakpkg-isolated-test\n' + 'Version: 9.9.9\n' + ) + with patch.dict(environ, {'PYTHONPATH': leak_root, 'PythonHome': python_home}): + res, out, err = run_cli( + 'environment', '--no-validate', '-o=-', executable) + self.assertNotEqual(0, res, err) + self.assertIn('Fail fetching `path`', err) + + res, out, err = run_cli( + 'environment', '--isolated', '--no-validate', '-o=-', executable) + self.assertEqual(0, res, err) + self.assertNotIn(leak_name, out) + + with patch.dict(environ, {'PYTHONPATH': leak_root}): + res, out, err = run_cli( + 'environment', '--no-validate', '-o=-', executable) + self.assertEqual(0, res, err) + self.assertIn(leak_name, out) + + res, out, err = run_cli( + 'environment', '--isolated', '--no-validate', '-o=-') + self.assertEqual(0, res, err) + self.assertNotIn(leak_name, out) + def test_with_current_python(self) -> None: sv = SchemaVersion.V1_6 of = random.choice((OutputFormat.XML, OutputFormat.JSON)) # nosec B311 diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py new file mode 100644 index 00000000..00ff994e --- /dev/null +++ b/tests/unit/test_environment.py @@ -0,0 +1,44 @@ +# This file is part of CycloneDX Python +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) OWASP Foundation. All Rights Reserved. + +from os import environ +from unittest import TestCase +from unittest.mock import patch + +from cyclonedx_py._internal.environment import _ISOLATED_ENV_NAMES, _path_probe_env + + +class TestPathProbeEnv(TestCase): + + def test_default_inherits_parent_environment(self) -> None: + self.assertIsNone(_path_probe_env(False)) + + def test_isolated_drops_discovery_variables_regardless_of_case(self) -> None: + with patch.dict(environ, { + 'PythonPath': r'C:\leak', + 'PYTHONHOME': r'C:\pyhome', + 'virtual_env': r'C:\venv', + 'PATH': environ.get('PATH', ''), + }, clear=False): + env = _path_probe_env(True) + + if env is None: + self.fail('isolated probe must build an environment') + folded = {key.casefold() for key in env} + for name in _ISOLATED_ENV_NAMES: + self.assertNotIn(name.casefold(), folded) + self.assertIn('path', folded)