Skip to content

Commit 29fe225

Browse files
committed
security: validate package names before installation
1 parent e169946 commit 29fe225

3 files changed

Lines changed: 106 additions & 5 deletions

File tree

colrev/package_manager/check.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from importlib import import_module
1010
from importlib import util
1111
from pathlib import Path
12+
import re
1213

1314
import toml
1415

@@ -104,10 +105,25 @@
104105
}
105106

106107

108+
def _validate_package_name(package_name: str) -> str:
109+
if not package_name:
110+
raise ValueError("Invalid package name: package name must not be empty.")
111+
if package_name.startswith("-"):
112+
raise ValueError("Invalid package name: package name must not start with '-'.")
113+
if not re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*$", package_name):
114+
raise ValueError(
115+
"Invalid package name: only letters, digits, dots, underscores, and hyphens are allowed."
116+
)
117+
return package_name
118+
119+
107120
def _check_package_installed(data: dict) -> bool:
108-
package_name = data["project"]["name"]
121+
package_name = _validate_package_name(data["project"]["name"])
109122
try:
110-
subprocess.check_output([sys.executable, "-m", "pip", "show", package_name])
123+
# package_name is validated above and shell=True is not used.
124+
subprocess.check_output( # nosec B603
125+
[sys.executable, "-m", "pip", "show", package_name]
126+
)
111127
except subprocess.CalledProcessError:
112128
print(
113129
f"Navigate to {Path.cwd()} and run: "

colrev/package_manager/package_manager.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,21 @@
1313
import colrev.exceptions as colrev_exceptions
1414
import colrev.package_manager.colrev_internal_packages
1515
import colrev.package_manager.package
16+
from colrev.package_manager.colrev_internal_packages import get_internal_packages_dict
1617
from colrev.constants import Colors
1718
from colrev.constants import EndpointType
1819
from colrev.constants import Filepaths
1920

2021

22+
def _validate_internal_package_selection(
23+
*, selected_package: str, internal_packages_dict: dict[str, str]
24+
) -> None:
25+
if selected_package not in internal_packages_dict:
26+
raise ValueError(
27+
f"Installation rejected: unknown internal package '{selected_package}'."
28+
)
29+
30+
2131
def _run_uv_pip_list() -> subprocess.CompletedProcess[str]:
2232
"""Return package information reported by ``uv pip list``.
2333
@@ -190,16 +200,19 @@ def install(
190200

191201
# print(package_manager)
192202

193-
internal_packages_dict = (
194-
colrev.package_manager.colrev_internal_packages.get_internal_packages_dict()
195-
)
203+
internal_packages_dict = get_internal_packages_dict()
196204

197205
if len(packages) == 1 and packages[0] == "all_internal_packages":
198206
packages = list(internal_packages_dict.keys())
199207

200208
# Install internal colrev packages first
201209
colrev_packages = []
202210
for package in packages:
211+
if package.startswith("colrev."):
212+
_validate_internal_package_selection(
213+
selected_package=package,
214+
internal_packages_dict=internal_packages_dict,
215+
)
203216
if package in internal_packages_dict:
204217
colrev_packages.append(package)
205218
packages = [p for p in packages if p not in colrev_packages]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python
2+
"""Security tests for package-manager subprocess arguments."""
3+
4+
from __future__ import annotations
5+
6+
from unittest.mock import MagicMock
7+
8+
import pytest
9+
10+
from colrev.package_manager import check
11+
from colrev.package_manager.package_manager import PackageManager
12+
13+
14+
@pytest.mark.parametrize(
15+
"package_name",
16+
["colrev", "my-package", "my_package", "my.package", "p1._-name"],
17+
)
18+
def test_validate_package_name_accepts_valid_names(package_name: str) -> None:
19+
assert check._validate_package_name(package_name) == package_name
20+
21+
22+
@pytest.mark.parametrize("package_name", ["", "-help", "bad name", "name;rm -rf /"])
23+
def test_validate_package_name_rejects_invalid_names(package_name: str) -> None:
24+
with pytest.raises(ValueError):
25+
check._validate_package_name(package_name)
26+
27+
28+
def test_check_package_installed_validates_package_name_before_subprocess(
29+
monkeypatch: pytest.MonkeyPatch,
30+
) -> None:
31+
check_output_mock = MagicMock()
32+
monkeypatch.setattr(check.subprocess, "check_output", check_output_mock)
33+
34+
result = check._check_package_installed({"project": {"name": "colrev"}})
35+
36+
assert result is True
37+
check_output_mock.assert_called_once()
38+
39+
40+
def test_install_accepts_internal_package_and_calls_subprocess(
41+
monkeypatch: pytest.MonkeyPatch,
42+
) -> None:
43+
run_mock = MagicMock()
44+
monkeypatch.setattr(
45+
"colrev.package_manager.package_manager.subprocess.run", run_mock
46+
)
47+
monkeypatch.setattr(
48+
"colrev.package_manager.package_manager.get_internal_packages_dict",
49+
MagicMock(return_value={"colrev.allowed": "colrev-allowed"}),
50+
)
51+
52+
PackageManager().install(packages=["colrev.allowed"], upgrade=False)
53+
54+
run_mock.assert_called_once_with(["pip", "install", "colrev-allowed"], check=True)
55+
56+
57+
def test_install_rejects_unknown_internal_package_before_subprocess(
58+
monkeypatch: pytest.MonkeyPatch,
59+
) -> None:
60+
run_mock = MagicMock()
61+
monkeypatch.setattr(
62+
"colrev.package_manager.package_manager.subprocess.run", run_mock
63+
)
64+
monkeypatch.setattr(
65+
"colrev.package_manager.package_manager.get_internal_packages_dict",
66+
MagicMock(return_value={"colrev.allowed": "colrev-allowed"}),
67+
)
68+
69+
with pytest.raises(ValueError):
70+
PackageManager().install(packages=["colrev.unknown"], upgrade=False)
71+
72+
run_mock.assert_not_called()

0 commit comments

Comments
 (0)