Skip to content
Open
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
18 changes: 16 additions & 2 deletions cyclonedx_py/_internal/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<python>` 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',
Expand Down Expand Up @@ -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',
Expand All @@ -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()):
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ The full documentation can be issued by running with ``environment --help``:
[-o <file>] [--sv <version>] [--of <format>]
[--pyproject <file>] [--mc-type <type>]
[-S]
[--isolated]
[<python>]

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