Skip to content
Merged
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
32 changes: 25 additions & 7 deletions cyclonedx_py/_internal/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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()):
Expand Down Expand Up @@ -278,17 +284,21 @@ 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):
return maybe
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:
Expand All @@ -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
2 changes: 2 additions & 0 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> Path to the root component's `pyproject.toml` file.
Expand Down
Original file line number Diff line number Diff line change
@@ -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/
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include src/module_d/pown.pth
Original file line number Diff line number Diff line change
@@ -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 ..
```
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import sys; print('!! YOU GOT POWNED !!',file=sys.stderr); print('!! YOU GOT POWNED !!',file=sys.stdout); raise Exception('!! YOU GOT POWNED !!')
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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"

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions tests/integration/test_cli_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down Expand Up @@ -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
Expand Down
Loading