Skip to content

Commit b2f562c

Browse files
committed
sec: ensure safe packages upon installation
1 parent 2def40c commit b2f562c

2 files changed

Lines changed: 171 additions & 16 deletions

File tree

colrev/package_manager/package_manager.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
import subprocess
1010
import sys
1111
import typing
12+
from pathlib import Path
13+
14+
from packaging.requirements import InvalidRequirement
15+
from packaging.requirements import Requirement
1216

1317
import colrev.exceptions as colrev_exceptions
1418
import colrev.package_manager.colrev_internal_packages
@@ -21,11 +25,41 @@
2125

2226
def _validate_internal_package_selection(
2327
*, selected_package: str, internal_packages_dict: dict[str, str]
24-
) -> None:
28+
) -> str:
2529
if selected_package not in internal_packages_dict:
2630
raise ValueError(
2731
f"Installation rejected: unknown internal package '{selected_package}'."
2832
)
33+
selected_path = (
34+
Path(internal_packages_dict[selected_package]).expanduser().resolve()
35+
)
36+
if not selected_path.exists():
37+
raise ValueError(
38+
f"Installation rejected: internal package path does not exist: {selected_path}"
39+
)
40+
if not selected_path.is_dir():
41+
raise ValueError(
42+
f"Installation rejected: internal package path is not a directory: {selected_path}"
43+
)
44+
if not (selected_path / "pyproject.toml").is_file():
45+
raise ValueError(
46+
f"Installation rejected: internal package path misses pyproject.toml: {selected_path}"
47+
)
48+
return str(selected_path)
49+
50+
51+
def _validate_external_package_selection(*, selected_package: str) -> str:
52+
if not selected_package:
53+
raise ValueError("Installation rejected: empty package spec")
54+
if selected_package.startswith("-"):
55+
raise ValueError(
56+
"Installation rejected: package specs must not start with '-' "
57+
"(option injection risk)"
58+
)
59+
60+
# Accept only normal Python requirement specifications.
61+
Requirement(selected_package)
62+
return selected_package
2963

3064

3165
def _run_uv_pip_list() -> subprocess.CompletedProcess[str]:
@@ -194,9 +228,12 @@ def install(
194228
) -> None:
195229
"""Install packages using uv if available, otherwise fallback to pip."""
196230
if uv:
197-
package_manager = ["uv", "pip"]
231+
uv_executable = shutil.which("uv")
232+
if uv_executable is None:
233+
raise FileNotFoundError("uv executable not found")
234+
package_manager = [uv_executable, "pip"]
198235
else:
199-
package_manager = ["pip"]
236+
package_manager = [sys.executable, "-m", "pip"]
200237

201238
# print(package_manager)
202239

@@ -227,16 +264,27 @@ def install(
227264
if editable:
228265
install_args.append("--editable")
229266

230-
# Install both internal and external packages in a single command
231-
all_packages = [
232-
internal_packages_dict[p] if p in internal_packages_dict else p
267+
safe_internal_packages = [
268+
_validate_internal_package_selection(
269+
selected_package=p, internal_packages_dict=internal_packages_dict
270+
)
233271
for p in colrev_packages
234-
] + packages
272+
]
273+
safe_external_packages = [
274+
_validate_external_package_selection(selected_package=p) for p in packages
275+
]
235276

236-
for selected_package in all_packages:
277+
for safe_selected_package in safe_internal_packages + safe_external_packages:
237278
try:
238-
# install_args += all_packages
239-
subprocess.run(install_args + [selected_package], check=True)
240-
except Exception as e:
241-
print(f"Installation failed: {selected_package}")
242-
print(e)
279+
subprocess.run( # nosec B603 - package spec/path is validated above; shell=False.
280+
install_args + [safe_selected_package],
281+
check=True,
282+
)
283+
except (
284+
subprocess.CalledProcessError,
285+
FileNotFoundError,
286+
ValueError,
287+
InvalidRequirement,
288+
) as exc:
289+
print(f"Installation failed: {safe_selected_package}")
290+
print(exc)

tests/1_env/package_manager_security_test.py

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33

44
from __future__ import annotations
55

6+
from pathlib import Path
7+
import sys
68
from unittest.mock import MagicMock
79

810
import pytest
11+
from packaging.requirements import InvalidRequirement
912

1013
from colrev.package_manager import check
1114
from colrev.package_manager.package_manager import PackageManager
15+
from colrev.package_manager.package_manager import _validate_external_package_selection
16+
from colrev.package_manager.package_manager import _validate_internal_package_selection
1217

1318

1419
@pytest.mark.parametrize(
@@ -38,20 +43,27 @@ def test_check_package_installed_validates_package_name_before_subprocess(
3843

3944

4045
def test_install_accepts_internal_package_and_calls_subprocess(
41-
monkeypatch: pytest.MonkeyPatch,
46+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
4247
) -> None:
48+
internal_dir = tmp_path / "colrev-allowed"
49+
internal_dir.mkdir()
50+
(internal_dir / "pyproject.toml").write_text("[project]\nname='colrev-allowed'\n")
51+
4352
run_mock = MagicMock()
4453
monkeypatch.setattr(
4554
"colrev.package_manager.package_manager.subprocess.run", run_mock
4655
)
4756
monkeypatch.setattr(
4857
"colrev.package_manager.package_manager.get_internal_packages_dict",
49-
MagicMock(return_value={"colrev.allowed": "colrev-allowed"}),
58+
MagicMock(return_value={"colrev.allowed": str(internal_dir)}),
5059
)
5160

5261
PackageManager().install(packages=["colrev.allowed"], upgrade=False)
5362

54-
run_mock.assert_called_once_with(["pip", "install", "colrev-allowed"], check=True)
63+
run_mock.assert_called_once_with(
64+
[sys.executable, "-m", "pip", "install", str(internal_dir.resolve())],
65+
check=True,
66+
)
5567

5668

5769
def test_install_rejects_unknown_internal_package_before_subprocess(
@@ -70,3 +82,98 @@ def test_install_rejects_unknown_internal_package_before_subprocess(
7082
PackageManager().install(packages=["colrev.unknown"], upgrade=False)
7183

7284
run_mock.assert_not_called()
85+
86+
87+
@pytest.mark.parametrize("package_spec", ["requests", "requests>=2.0"])
88+
def test_validate_external_package_accepts(package_spec: str) -> None:
89+
assert (
90+
_validate_external_package_selection(selected_package=package_spec)
91+
== package_spec
92+
)
93+
94+
95+
@pytest.mark.parametrize(
96+
"package_spec", ["", "--index-url=https://example.com", "-r requirements.txt"]
97+
)
98+
def test_validate_external_package_rejects(package_spec: str) -> None:
99+
with pytest.raises((ValueError, InvalidRequirement)):
100+
_validate_external_package_selection(selected_package=package_spec)
101+
102+
103+
def test_validate_internal_package_selection_accepts(tmp_path: Path) -> None:
104+
package_dir = tmp_path / "internal"
105+
package_dir.mkdir()
106+
(package_dir / "pyproject.toml").write_text("[project]\nname='internal'\n")
107+
108+
selected = _validate_internal_package_selection(
109+
selected_package="colrev.allowed",
110+
internal_packages_dict={"colrev.allowed": str(package_dir)},
111+
)
112+
assert selected == str(package_dir.resolve())
113+
114+
115+
def test_validate_internal_package_selection_rejects_missing_path(
116+
tmp_path: Path,
117+
) -> None:
118+
missing_dir = tmp_path / "missing"
119+
with pytest.raises(ValueError):
120+
_validate_internal_package_selection(
121+
selected_package="colrev.allowed",
122+
internal_packages_dict={"colrev.allowed": str(missing_dir)},
123+
)
124+
125+
126+
def test_validate_internal_package_selection_rejects_missing_pyproject(
127+
tmp_path: Path,
128+
) -> None:
129+
package_dir = tmp_path / "internal"
130+
package_dir.mkdir()
131+
with pytest.raises(ValueError):
132+
_validate_internal_package_selection(
133+
selected_package="colrev.allowed",
134+
internal_packages_dict={"colrev.allowed": str(package_dir)},
135+
)
136+
137+
138+
def test_install_uses_uv_executable_when_enabled(
139+
monkeypatch: pytest.MonkeyPatch,
140+
) -> None:
141+
run_mock = MagicMock()
142+
monkeypatch.setattr(
143+
"colrev.package_manager.package_manager.subprocess.run", run_mock
144+
)
145+
monkeypatch.setattr(
146+
"colrev.package_manager.package_manager.shutil.which", lambda _: "/usr/bin/uv"
147+
)
148+
monkeypatch.setattr(
149+
"colrev.package_manager.package_manager.get_internal_packages_dict",
150+
MagicMock(return_value={}),
151+
)
152+
153+
PackageManager().install(packages=["requests"], upgrade=False, uv=True)
154+
155+
run_mock.assert_called_once_with(
156+
["/usr/bin/uv", "pip", "install", "requests"],
157+
check=True,
158+
)
159+
160+
161+
def test_install_validates_external_packages_before_subprocess(
162+
monkeypatch: pytest.MonkeyPatch,
163+
) -> None:
164+
run_mock = MagicMock()
165+
monkeypatch.setattr(
166+
"colrev.package_manager.package_manager.subprocess.run", run_mock
167+
)
168+
monkeypatch.setattr(
169+
"colrev.package_manager.package_manager.get_internal_packages_dict",
170+
MagicMock(return_value={}),
171+
)
172+
173+
with pytest.raises(ValueError, match="must not start with '-'"):
174+
PackageManager().install(
175+
packages=["--index-url=https://example.com"],
176+
upgrade=False,
177+
)
178+
179+
run_mock.assert_not_called()

0 commit comments

Comments
 (0)