diff --git a/cyclonedx_py/_internal/environment.py b/cyclonedx_py/_internal/environment.py index c6dd5f9a3..b0eb7f9e9 100644 --- a/cyclonedx_py/_internal/environment.py +++ b/cyclonedx_py/_internal/environment.py @@ -19,7 +19,7 @@ from argparse import OPTIONAL, ArgumentParser from collections.abc import Iterable from importlib.metadata import distributions -from json import loads +from json import JSONDecodeError, loads as json_loads from os import getcwd, name as os_name from os.path import exists, isdir, join from subprocess import run # nosec @@ -111,6 +111,11 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser': • Build an SBOM from uv environment: $ %(prog)s "$(uv python find)" """) + p.add_argument('-S', # mimic `python -S` + action='store_false', + 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('--gather-license-texts', action='store_true', dest='gather_license_texts', @@ -137,6 +142,7 @@ def __init__(self, *, self._gather_license_texts = gather_license_texts def __call__(self, *, # type:ignore[override] + import_site: bool, python: Optional[str], pyproject_file: Optional[str], mc_type: 'ComponentType', @@ -155,7 +161,7 @@ def __call__(self, *, # type:ignore[override] path: list[str] if python: - path = self.__path4python(python) + path = self.__path4python(python, import_site) else: path = sys_path.copy() if path[0] in ('', getcwd()): @@ -278,8 +284,8 @@ def __py_interpreter(value: str) -> str: raise ValueError(f'No such file or directory: {value}') if isdir(value): for venv_loc in ( - ('bin', 'python'), # unix - ('Scripts', 'python.exe'), # win + ('bin', 'python'), # unix + ('Scripts', 'python.exe'), # win ): maybe = join(value, *venv_loc) if exists(maybe): @@ -287,8 +293,12 @@ def __py_interpreter(value: str) -> str: raise ValueError(f'Failed to find python in directory: {value}') return value - def __path4python(self, python: str) -> list[str]: - cmd = self.__py_interpreter(python), '-c', 'import json,sys;json.dump(sys.path,sys.stdout)' + def __path4python(self, python: str, import_site: 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') + self._logger.debug('fetch `path` from python interpreter cmd: %r', cmd) res = run(cmd, capture_output=True, encoding='utf8', shell=False) # nosec if res.returncode != 0: @@ -297,4 +307,12 @@ def __path4python(self, python: str) -> list[str]: f'stdout: {res.stdout}\n' f'stderr: {res.stderr}\n') self._logger.debug('got `path` from Python interpreter: %r', res.stdout) - return loads(res.stdout) # type:ignore[no-any-return] + try: + path = json_loads(res.stdout) + except JSONDecodeError as err: + raise ValueError('Fail fetching `path` from Python interpreter.\n' + f'stdout: {res.stdout}\n') from err + if type(path) is not list or any(type(p) is not str for p in path): + raise TypeError('Fail fetching `path` from Python interpreter.\n' + f'stdout: {res.stdout}\n') + return path diff --git a/docs/usage.rst b/docs/usage.rst index 2bd433744..3dc040ad8 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -70,6 +70,8 @@ The full documentation can be issued by running with ``environment --help``: options: -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. --gather-license-texts Enable license text gathering. --pyproject Path to the root component's `pyproject.toml` file. diff --git a/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/.gitignore b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/.gitignore new file mode 100644 index 000000000..14efe0a91 --- /dev/null +++ b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/.gitignore @@ -0,0 +1,5 @@ +# in these cases we need to keep the dists, since this is the manipulated one +/build/ +/**.egg-info.egg-info + +!/dist/ diff --git a/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/MANIFEST.in b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/MANIFEST.in new file mode 100644 index 000000000..e7e887c29 --- /dev/null +++ b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/MANIFEST.in @@ -0,0 +1 @@ +include src/module_d/pown.pth diff --git a/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/README.md b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/README.md new file mode 100644 index 000000000..e9bef2e0a --- /dev/null +++ b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/README.md @@ -0,0 +1,22 @@ +create a package that installs a malicious `.pth` file. + +build via +```shell +rm -rf dist build src/d_with_malicious_pth.egg-info.egg-info +python -m build --wheel + +cd dist +rm -rf manip_build +unzip d_with_malicious_pth-0.0.1-py3-none-any.whl -d manip_build +rm d_with_malicious_pth-0.0.1-py3-none-any.whl + +cd manip_build +mv module_d/pown.pth . +echo pown.pth >> d_with_malicious_pth-0.0.1.dist-info/top_level.txt +sed -i 's#module_d/pown.pth#pown.pth#g' d_with_malicious_pth-0.0.1.dist-info/RECORD + +zip ../d_with_malicious_pth-0.0.1-py3-none-any.whl -r . +cd .. +rm -rf manip_build +cd .. +``` diff --git a/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/dist/d_with_malicious_pth-0.0.1-py3-none-any.whl b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/dist/d_with_malicious_pth-0.0.1-py3-none-any.whl new file mode 100644 index 000000000..ef8209465 Binary files /dev/null and b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/dist/d_with_malicious_pth-0.0.1-py3-none-any.whl differ diff --git a/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/pyproject.toml b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/pyproject.toml new file mode 100644 index 000000000..4bb002f35 --- /dev/null +++ b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "d_with_malicious_pth" +version = "0.0.1" +description = "some package D with malicious pth" +authors = [] +requires-python = ">=3.8" + +[tool.setuptools.packages.find] +where = ["src"] + +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/src/module_d/__init__.py b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/src/module_d/__init__.py new file mode 100644 index 000000000..78821cf21 --- /dev/null +++ b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/src/module_d/__init__.py @@ -0,0 +1,21 @@ +# 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. + + +""" +module C +""" diff --git a/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/src/module_d/pown.pth b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/src/module_d/pown.pth new file mode 100644 index 000000000..3355714f4 --- /dev/null +++ b/tests/_data/infiles/_helpers/local_pckages/d_with_malicious_pth/src/module_d/pown.pth @@ -0,0 +1 @@ +import sys; print('!! YOU GOT POWNED !!',file=sys.stderr); print('!! YOU GOT POWNED !!',file=sys.stdout); raise Exception('!! YOU GOT POWNED !!') diff --git a/tests/_data/infiles/environment/broken-with-malicious-pth/init.py b/tests/_data/infiles/environment/broken-with-malicious-pth/init.py new file mode 100644 index 000000000..0f0f7c776 --- /dev/null +++ b/tests/_data/infiles/environment/broken-with-malicious-pth/init.py @@ -0,0 +1,60 @@ +# 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. + +""" +initialize this testbed. +""" + +from os import name as os_name +from os.path import abspath, dirname, join +from subprocess import check_call # nosec:B404 +from sys import executable, stderr +from venv import EnvBuilder + +__all__ = ['main'] + +this_dir = dirname(__file__) +env_dir = join(this_dir, '.venv') + +localpackages_dir = abspath(join(dirname(__file__), '..', '..', '_helpers', 'local_pckages')) + + +def pip_install(*args: str) -> None: + # pip is not API, but a CLI -- call it like that! + call = (executable, '-m', 'pip', + '--python', env_dir, + 'install', '--require-virtualenv', '--no-input', '--progress-bar=off', '--no-color', + *args) + print('+ ', *call, file=stderr) + check_call(call, cwd=this_dir, shell=False) # nosec:B603 + + +def main() -> None: + EnvBuilder( + system_site_packages=False, + symlinks=os_name != 'nt', + with_pip=False, + clear=True, # explicitely important, since the env might be broken on purpose + ).create(env_dir) + + pip_install( + join(localpackages_dir, 'd_with_malicious_pth', 'dist', 'd_with_malicious_pth-0.0.1-py3-none-any.whl'), + ) + + +if __name__ == '__main__': + main() diff --git a/tests/_data/infiles/environment/broken-with-malicious-pth/pyproject.toml b/tests/_data/infiles/environment/broken-with-malicious-pth/pyproject.toml new file mode 100644 index 000000000..c00bb6dca --- /dev/null +++ b/tests/_data/infiles/environment/broken-with-malicious-pth/pyproject.toml @@ -0,0 +1,5 @@ +[project] +# https://packaging.python.org/en/latest/specifications/declaring-project-metadata/#declaring-project-metadata +name = "with_malicious_pth" +version = "0.1.0" +description = "packages with malicious pth" diff --git a/tests/_data/snapshots/environment/test_with_sites_evaluation_suppressed_broken-with-malicious-pth_1.6.json.bin b/tests/_data/snapshots/environment/test_with_sites_evaluation_suppressed_broken-with-malicious-pth_1.6.json.bin new file mode 100644 index 000000000..6b8e08d7e --- /dev/null +++ b/tests/_data/snapshots/environment/test_with_sites_evaluation_suppressed_broken-with-malicious-pth_1.6.json.bin @@ -0,0 +1,88 @@ +{ + "dependencies": [ + { + "ref": "root-component" + } + ], + "metadata": { + "component": { + "bom-ref": "root-component", + "description": "packages with malicious pth", + "name": "with_malicious_pth", + "type": "application", + "version": "0.1.0" + }, + "properties": [ + { + "name": "cdx:reproducible", + "value": "true" + } + ], + "tools": { + "components": [ + { + "description": "CycloneDX Software Bill of Materials (SBOM) generator for Python projects and environments", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-bom/" + }, + { + "type": "documentation", + "url": "https://cyclonedx-bom-tool.readthedocs.io/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python/" + }, + { + "type": "website", + "url": "https://github.com/CycloneDX/cyclonedx-python/#readme" + } + ], + "group": "CycloneDX", + "licenses": [ + { + "license": { + "acknowledgement": "declared", + "id": "Apache-2.0" + } + } + ], + "name": "cyclonedx-py", + "type": "application", + "version": "thisVersion-testing" + }, + { + "description": "stripped", + "externalReferences": [ ], + "group": "CycloneDX", + "licenses": [ ], + "name": "cyclonedx-python-lib", + "type": "library", + "version": "libVersion-testing" + } + ] + } + }, + "version": 1, + "$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.6" +} \ No newline at end of file diff --git a/tests/integration/test_cli_environment.py b/tests/integration/test_cli_environment.py index 1459943b4..a657feb16 100644 --- a/tests/integration/test_cli_environment.py +++ b/tests/integration/test_cli_environment.py @@ -35,6 +35,7 @@ test_data = tuple( (f'{basename(projectdir)}-{sv.name}-{of.name}', projectdir, sv, of) for projectdir in map(dirname, initfiles) + if not basename(projectdir).startswith('broken-') for of, sv in SUPPORTED_OF_SV ) @@ -116,6 +117,36 @@ def test_with_pyproject_not_found(self) -> None: self.assertNotEqual(0, res, err) self.assertIn('Could not open pyproject file: something-that-must-not-exist.testing', err) + def test_with_sites_detection_fails(self) -> None: + projectdir = join(INFILES_DIRECTORY, 'environment', 'broken-with-malicious-pth') + res, out, err = run_cli( + 'environment', + '-vvv', + '-o=-', + '--pyproject', join(projectdir, 'pyproject.toml'), + join(projectdir, '.venv') + ) + self.assertNotEqual(0, res, err) + self.assertIn('JSONDecodeError', err) # due to pownage + + def test_with_sites_evaluation_suppressed(self) -> None: + projectdir = join(INFILES_DIRECTORY, 'environment', 'broken-with-malicious-pth') + sv = SchemaVersion.V1_6 + of = OutputFormat.JSON + res, out, err = run_cli( + 'environment', + '-vvv', + '--sv', sv.to_version(), + '--of', of.name, + '--output-reproducible', + '-o=-', + '--pyproject', join(projectdir, 'pyproject.toml'), + '-S', # the important part + join(projectdir, '.venv') + ) + self.assertEqual(0, res, err) + self.assertEqualSnapshot(out, 'test_with_sites_evaluation_suppressed', projectdir, sv, of) + def test_with_current_python(self) -> None: sv = SchemaVersion.V1_6 of = random.choice((OutputFormat.XML, OutputFormat.JSON)) # nosec B311