Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions cyclonedx_py/_internal/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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()):
Expand Down Expand Up @@ -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'
Expand Down
6 changes: 5 additions & 1 deletion docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ The full documentation can be issued by running with ``environment --help``:
[--validate | --no-validate]
[-o <file>] [--sv <version>] [--of <format>]
[--pyproject <file>] [--mc-type <type>]
[-S]
[-S] [--isolated]
[<python>]

Build an SBOM from Python (virtual) environment
Expand All @@ -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 <file> Path to the root component's `pyproject.toml` file.
Expand Down
38 changes: 37 additions & 1 deletion tests/integration/test_cli_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions tests/unit/test_environment.py
Original file line number Diff line number Diff line change
@@ -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)