Skip to content

Commit 55c15d7

Browse files
authored
feat: add -S flag to skip *.pth evaluation during environment analysis (#1032)
Signed-off-by: Jan Kowalleck <jan.kowalleck@gmail.com>
1 parent ecf8768 commit 55c15d7

13 files changed

Lines changed: 274 additions & 7 deletions

File tree

cyclonedx_py/_internal/environment.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from argparse import OPTIONAL, ArgumentParser
2020
from collections.abc import Iterable
2121
from importlib.metadata import distributions
22-
from json import loads
22+
from json import JSONDecodeError, loads as json_loads
2323
from os import getcwd, name as os_name
2424
from os.path import exists, isdir, join
2525
from subprocess import run # nosec
@@ -111,6 +111,11 @@ def make_argument_parser(**kwargs: Any) -> 'ArgumentParser':
111111
• Build an SBOM from uv environment:
112112
$ %(prog)s "$(uv python find)"
113113
""")
114+
p.add_argument('-S', # mimic `python -S`
115+
action='store_false',
116+
dest='import_site',
117+
help='Do not implicitly import site during Python path detection.\n'
118+
'Prevents evaluation of `*.pth` files, but may lead to incomplete component detection.')
114119
p.add_argument('--gather-license-texts',
115120
action='store_true',
116121
dest='gather_license_texts',
@@ -137,6 +142,7 @@ def __init__(self, *,
137142
self._gather_license_texts = gather_license_texts
138143

139144
def __call__(self, *, # type:ignore[override]
145+
import_site: bool,
140146
python: Optional[str],
141147
pyproject_file: Optional[str],
142148
mc_type: 'ComponentType',
@@ -155,7 +161,7 @@ def __call__(self, *, # type:ignore[override]
155161

156162
path: list[str]
157163
if python:
158-
path = self.__path4python(python)
164+
path = self.__path4python(python, import_site)
159165
else:
160166
path = sys_path.copy()
161167
if path[0] in ('', getcwd()):
@@ -278,17 +284,21 @@ def __py_interpreter(value: str) -> str:
278284
raise ValueError(f'No such file or directory: {value}')
279285
if isdir(value):
280286
for venv_loc in (
281-
('bin', 'python'), # unix
282-
('Scripts', 'python.exe'), # win
287+
('bin', 'python'), # unix
288+
('Scripts', 'python.exe'), # win
283289
):
284290
maybe = join(value, *venv_loc)
285291
if exists(maybe):
286292
return maybe
287293
raise ValueError(f'Failed to find python in directory: {value}')
288294
return value
289295

290-
def __path4python(self, python: str) -> list[str]:
291-
cmd = self.__py_interpreter(python), '-c', 'import json,sys;json.dump(sys.path,sys.stdout)'
296+
def __path4python(self, python: str, import_site: bool) -> list[str]:
297+
cmd = [self.__py_interpreter(python),
298+
'-c', 'import json,sys;json.dump(sys.path,sys.stdout)']
299+
if not import_site:
300+
cmd.insert(1, '-S')
301+
292302
self._logger.debug('fetch `path` from python interpreter cmd: %r', cmd)
293303
res = run(cmd, capture_output=True, encoding='utf8', shell=False) # nosec
294304
if res.returncode != 0:
@@ -297,4 +307,12 @@ def __path4python(self, python: str) -> list[str]:
297307
f'stdout: {res.stdout}\n'
298308
f'stderr: {res.stderr}\n')
299309
self._logger.debug('got `path` from Python interpreter: %r', res.stdout)
300-
return loads(res.stdout) # type:ignore[no-any-return]
310+
try:
311+
path = json_loads(res.stdout)
312+
except JSONDecodeError as err:
313+
raise ValueError('Fail fetching `path` from Python interpreter.\n'
314+
f'stdout: {res.stdout}\n') from err
315+
if type(path) is not list or any(type(p) is not str for p in path):
316+
raise TypeError('Fail fetching `path` from Python interpreter.\n'
317+
f'stdout: {res.stdout}\n')
318+
return path

docs/usage.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ The full documentation can be issued by running with ``environment --help``:
7070
7171
options:
7272
-h, --help show this help message and exit
73+
-S Do not implicitly import site during Python path detection.
74+
Prevents evaluation of `*.pth` files, but may lead to incomplete component detection.
7375
--gather-license-texts
7476
Enable license text gathering.
7577
--pyproject <file> Path to the root component's `pyproject.toml` file.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# in these cases we need to keep the dists, since this is the manipulated one
2+
/build/
3+
/**.egg-info.egg-info
4+
5+
!/dist/
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include src/module_d/pown.pth
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
create a package that installs a malicious `.pth` file.
2+
3+
build via
4+
```shell
5+
rm -rf dist build src/d_with_malicious_pth.egg-info.egg-info
6+
python -m build --wheel
7+
8+
cd dist
9+
rm -rf manip_build
10+
unzip d_with_malicious_pth-0.0.1-py3-none-any.whl -d manip_build
11+
rm d_with_malicious_pth-0.0.1-py3-none-any.whl
12+
13+
cd manip_build
14+
mv module_d/pown.pth .
15+
echo pown.pth >> d_with_malicious_pth-0.0.1.dist-info/top_level.txt
16+
sed -i 's#module_d/pown.pth#pown.pth#g' d_with_malicious_pth-0.0.1.dist-info/RECORD
17+
18+
zip ../d_with_malicious_pth-0.0.1-py3-none-any.whl -r .
19+
cd ..
20+
rm -rf manip_build
21+
cd ..
22+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "d_with_malicious_pth"
3+
version = "0.0.1"
4+
description = "some package D with malicious pth"
5+
authors = []
6+
requires-python = ">=3.8"
7+
8+
[tool.setuptools.packages.find]
9+
where = ["src"]
10+
11+
[build-system]
12+
requires = ["setuptools>=68.0", "wheel"]
13+
build-backend = "setuptools.build_meta"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# This file is part of CycloneDX Python
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
19+
"""
20+
module C
21+
"""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import sys; print('!! YOU GOT POWNED !!',file=sys.stderr); print('!! YOU GOT POWNED !!',file=sys.stdout); raise Exception('!! YOU GOT POWNED !!')
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# This file is part of CycloneDX Python
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# SPDX-License-Identifier: Apache-2.0
16+
# Copyright (c) OWASP Foundation. All Rights Reserved.
17+
18+
"""
19+
initialize this testbed.
20+
"""
21+
22+
from os import name as os_name
23+
from os.path import abspath, dirname, join
24+
from subprocess import check_call # nosec:B404
25+
from sys import executable, stderr
26+
from venv import EnvBuilder
27+
28+
__all__ = ['main']
29+
30+
this_dir = dirname(__file__)
31+
env_dir = join(this_dir, '.venv')
32+
33+
localpackages_dir = abspath(join(dirname(__file__), '..', '..', '_helpers', 'local_pckages'))
34+
35+
36+
def pip_install(*args: str) -> None:
37+
# pip is not API, but a CLI -- call it like that!
38+
call = (executable, '-m', 'pip',
39+
'--python', env_dir,
40+
'install', '--require-virtualenv', '--no-input', '--progress-bar=off', '--no-color',
41+
*args)
42+
print('+ ', *call, file=stderr)
43+
check_call(call, cwd=this_dir, shell=False) # nosec:B603
44+
45+
46+
def main() -> None:
47+
EnvBuilder(
48+
system_site_packages=False,
49+
symlinks=os_name != 'nt',
50+
with_pip=False,
51+
clear=True, # explicitely important, since the env might be broken on purpose
52+
).create(env_dir)
53+
54+
pip_install(
55+
join(localpackages_dir, 'd_with_malicious_pth', 'dist', 'd_with_malicious_pth-0.0.1-py3-none-any.whl'),
56+
)
57+
58+
59+
if __name__ == '__main__':
60+
main()

0 commit comments

Comments
 (0)