mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 09:18:06 +00:00
Compare commits
4
Commits
d86dc5914b
...
22f0a96cbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f0a96cbb | ||
|
|
2a99cf6e64 | ||
|
|
b89653c836 | ||
|
|
d59a66133b |
@@ -8,6 +8,7 @@ files that need to be accessible by both root service and web user.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
import shutil as _shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -16,6 +17,25 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
|
||||
# own error output can be logged/displayed without echoing back a private
|
||||
# index URL's embedded basic-auth secret verbatim (e.g. from a
|
||||
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
|
||||
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
|
||||
|
||||
|
||||
def _redact_url_credentials(text: Optional[str]) -> str:
|
||||
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
|
||||
|
||||
Safe to call on any subprocess output destined for logs: it only ever
|
||||
shortens/replaces the credential substring, never changes the presence
|
||||
or absence of the specific fixed phrases callers check for
|
||||
(e.g. "a password is required"), so it can't affect control flow.
|
||||
"""
|
||||
if not text:
|
||||
return text or ""
|
||||
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
|
||||
|
||||
# System directories that should never have their permissions modified
|
||||
# These directories have special system-level permissions that must be preserved
|
||||
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
||||
@@ -338,6 +358,13 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||
)
|
||||
# Redact immediately: pip can echo a private index URL's embedded
|
||||
# basic-auth credentials back in its own error/progress output
|
||||
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
||||
# the fixed-phrase "denied" check below -- those phrases never
|
||||
# overlap with URL syntax.
|
||||
result.stderr = _redact_url_credentials(result.stderr)
|
||||
result.stdout = _redact_url_credentials(result.stdout)
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
# Distinguish "sudo rejected this exact command line" (worth
|
||||
@@ -348,16 +375,24 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
||||
)
|
||||
if not denied:
|
||||
# Deliberately don't interpolate req_file or the pip output here:
|
||||
# this log line is scanner-visible, and a static analyzer can't
|
||||
# tell "already redacted above" from "still raw" just by looking
|
||||
# at this call in isolation. The full (redacted) text is still
|
||||
# available to callers via the returned CompletedProcess.
|
||||
logger.warning(
|
||||
"Root pip install failed (rc=%s) for %s: %s",
|
||||
result.returncode, req_file, result.stderr.strip()[:500],
|
||||
"Root pip install failed (rc=%s); see the returned "
|
||||
"CompletedProcess.stderr for details.",
|
||||
result.returncode,
|
||||
)
|
||||
return result
|
||||
|
||||
# Same reasoning as above: no req_file / pip-output interpolation in
|
||||
# this log line, only in the returned note/CompletedProcess.
|
||||
logger.warning(
|
||||
"Root pip install wrapper denied via sudo for %s; falling back to "
|
||||
"user-level install: %s",
|
||||
req_file, result.stderr.strip()[:500] if result else "no bash candidates found",
|
||||
"Root pip install wrapper denied via sudo for all candidates; "
|
||||
"falling back to user-level install. See the returned "
|
||||
"CompletedProcess.stderr for details."
|
||||
)
|
||||
note = (
|
||||
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
||||
@@ -367,8 +402,7 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"safe_pip_install.sh not found; falling back to user-level install for %s",
|
||||
req_file,
|
||||
"safe_pip_install.sh not found; falling back to user-level install."
|
||||
)
|
||||
note = (
|
||||
"[safe_pip_install.sh not found; installed for the current process's "
|
||||
@@ -386,6 +420,7 @@ def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||
)
|
||||
result.stdout = note + (result.stdout or "")
|
||||
result.stderr = _redact_url_credentials(result.stderr)
|
||||
result.stdout = note + _redact_url_credentials(result.stdout)
|
||||
return result
|
||||
|
||||
|
||||
@@ -93,6 +93,27 @@ def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
||||
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
|
||||
|
||||
Used as a containment check for a directory name derived from untrusted
|
||||
input (a manifest-declared plugin id, an externally-supplied plugin
|
||||
path): the returned value always comes from enumerating trusted_dir
|
||||
itself via os.scandir(), so a caller that builds a path by joining
|
||||
trusted_dir with this return value is joining against a name the
|
||||
filesystem produced under a trusted root -- not the caller's original
|
||||
string, which could otherwise smuggle a traversal sequence through.
|
||||
"""
|
||||
try:
|
||||
with os.scandir(trusted_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.name == name and entry.is_dir():
|
||||
return entry.name
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
"""Handles plugin module loading and class instantiation."""
|
||||
|
||||
@@ -200,14 +221,14 @@ class PluginLoader:
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
||||
continue
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def install_dependencies(
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
plugins_dir: Path,
|
||||
timeout: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
@@ -216,7 +237,12 @@ class PluginLoader:
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Trusted base plugins directory for path containment check
|
||||
plugins_dir: Trusted base plugins directory for path containment check.
|
||||
Required (not optional) so every caller reconstructs the plugin
|
||||
path through the sanitiser below rather than trusting plugin_dir
|
||||
directly -- CodeQL's path-injection query (and a malicious
|
||||
manifest/plugin_id in practice) can't tell a legitimate
|
||||
plugin_dir from one crafted to traverse outside plugins_dir.
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
Returns:
|
||||
@@ -228,31 +254,24 @@ class PluginLoader:
|
||||
|
||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
requested_name = os.path.basename(plugin_dir_real)
|
||||
|
||||
if plugins_dir is not None:
|
||||
# Reconstruct the plugin path from a trusted base + a sanitised
|
||||
# directory name. os.path.basename() is CodeQL's recognised
|
||||
# py/path-injection sanitiser: it strips all directory components
|
||||
# so the result cannot contain traversal sequences. Joining it
|
||||
# with the resolved, trusted plugins_dir produces a path that
|
||||
# CodeQL considers untainted.
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
safe_dir_name = os.path.basename(plugin_dir_real)
|
||||
if not safe_dir_name:
|
||||
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
|
||||
return False
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
else:
|
||||
safe_plugin_dir = plugin_dir_real
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||
return False
|
||||
# Match the requested directory against an entry actually enumerated
|
||||
# from the trusted plugins_dir, and build the path from that entry --
|
||||
# not from requested_name. A name that came out of os.scandir() on a
|
||||
# trusted root carries no taint regardless of what the caller asked
|
||||
# for, so this is a real containment guarantee (an allowlist check
|
||||
# against a trusted source), not a string-sanitisation of untrusted
|
||||
# input that a static analyzer has to trust blindly.
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||
if matched_name is None:
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||
|
||||
if not os.path.isfile(requirements_file):
|
||||
@@ -698,6 +717,14 @@ class PluginLoader:
|
||||
"""
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
if plugins_dir is None:
|
||||
raise PluginError(
|
||||
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
|
||||
"(needed for path containment; pass install_deps=False if the caller "
|
||||
"doesn't have a trusted plugins directory to supply)",
|
||||
plugin_id=plugin_id,
|
||||
context={'plugin_dir': str(plugin_dir)},
|
||||
)
|
||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||
raise PluginError(
|
||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
||||
|
||||
@@ -25,7 +25,9 @@ import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
||||
from src.plugin_system.plugin_loader import requirements_has_real_deps, requirements_are_satisfied
|
||||
from src.plugin_system.plugin_loader import (
|
||||
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
|
||||
)
|
||||
|
||||
try:
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
@@ -1900,15 +1902,33 @@ class PluginStoreManager:
|
||||
def _install_dependencies(self, plugin_path: Path) -> bool:
|
||||
"""
|
||||
Install Python dependencies from requirements.txt.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_path: Path to plugin directory
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful or no requirements file
|
||||
"""
|
||||
requirements_file = plugin_path / "requirements.txt"
|
||||
|
||||
# Reconstruct the plugin path from the trusted self.plugins_dir base +
|
||||
# an entry actually enumerated from it, rather than trusting
|
||||
# plugin_path directly -- callers ultimately derive it from a
|
||||
# plugin-supplied manifest "id" field (see install_plugin_from_url),
|
||||
# so without this a malicious manifest could point requirements_file
|
||||
# outside plugins_dir. find_trusted_subdir()'s return value always
|
||||
# comes from os.scandir() on the trusted root, so building the path
|
||||
# from it (not from the caller's string) is a real containment
|
||||
# guarantee, matching the pattern in PluginLoader.install_dependencies().
|
||||
plugin_dir_real = os.path.realpath(str(plugin_path))
|
||||
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
|
||||
requested_name = os.path.basename(plugin_dir_real)
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||
if matched_name is None:
|
||||
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
|
||||
return False
|
||||
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
|
||||
|
||||
requirements_file = safe_plugin_path / "requirements.txt"
|
||||
|
||||
if not requirements_file.exists():
|
||||
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Tests for src.common.permission_utils's URL-credential redaction.
|
||||
|
||||
Covers the fix for a CodeQL clear-text-logging-of-secrets alert:
|
||||
install_requirements_file() must never let a private index URL's embedded
|
||||
user:pass@ credentials reach logs or its returned CompletedProcess, since
|
||||
pip can echo that URL back verbatim in its own stderr/stdout on failure.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.common.permission_utils import _redact_url_credentials, install_requirements_file
|
||||
|
||||
|
||||
class TestRedactUrlCredentials:
|
||||
def test_redacts_embedded_basic_auth(self):
|
||||
text = "Could not fetch URL https://alice:s3cr3t@pypi.example.com/simple/: 403"
|
||||
redacted = _redact_url_credentials(text)
|
||||
assert "s3cr3t" not in redacted
|
||||
assert "alice" not in redacted
|
||||
assert "https://***:***@pypi.example.com/simple/" in redacted
|
||||
|
||||
def test_leaves_credential_free_text_unchanged(self):
|
||||
text = "ERROR: Could not find a version that satisfies the requirement foo==1.0"
|
||||
assert _redact_url_credentials(text) == text
|
||||
|
||||
def test_handles_none_and_empty(self):
|
||||
assert _redact_url_credentials(None) == ""
|
||||
assert _redact_url_credentials("") == ""
|
||||
|
||||
def test_does_not_touch_denied_check_phrases(self):
|
||||
"""The fixed phrases install_requirements_file greps for must survive
|
||||
redaction untouched -- they don't overlap with URL syntax, but this
|
||||
pins that assumption so a regex change can't silently break it."""
|
||||
text = "sudo: a password is required"
|
||||
assert _redact_url_credentials(text) == text
|
||||
|
||||
|
||||
class TestInstallRequirementsFileRedaction:
|
||||
@patch('src.common.permission_utils.subprocess.run')
|
||||
def test_wrapper_path_redacts_stderr_and_stdout(self, mock_run, tmp_path):
|
||||
"""safe_pip_install.sh exists in this repo, so install_requirements_file
|
||||
takes the sudo-wrapper branch; a failing result must come back
|
||||
with any embedded index-URL credentials already redacted."""
|
||||
req_file = tmp_path / "requirements.txt"
|
||||
req_file.write_text("requests\n")
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="Looking in indexes: https://bob:hunter2@pypi.internal/simple\n",
|
||||
stderr="ERROR https://bob:hunter2@pypi.internal/simple/foo: 401",
|
||||
)
|
||||
|
||||
result = install_requirements_file(req_file, timeout=5)
|
||||
|
||||
assert "hunter2" not in result.stdout
|
||||
assert "hunter2" not in result.stderr
|
||||
assert "https://***:***@pypi.internal" in result.stdout
|
||||
assert "https://***:***@pypi.internal" in result.stderr
|
||||
|
||||
@patch('src.common.permission_utils.subprocess.run')
|
||||
@patch('src.common.permission_utils.Path.exists', return_value=False)
|
||||
def test_no_wrapper_fallback_path_redacts_stderr_and_stdout(self, mock_exists, mock_run, tmp_path):
|
||||
"""No safe_pip_install.sh wrapper -> falls straight to the
|
||||
sys.executable pip fallback (the second subprocess.run call site);
|
||||
its result must come back redacted too, independent of the wrapper
|
||||
branch's own redaction above."""
|
||||
req_file = tmp_path / "requirements.txt"
|
||||
req_file.write_text("requests\n")
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=1,
|
||||
stdout="Looking in indexes: https://carol:swordfish@pypi.internal/simple\n",
|
||||
stderr="ERROR https://carol:swordfish@pypi.internal/simple/foo: 401",
|
||||
)
|
||||
|
||||
result = install_requirements_file(req_file, timeout=5)
|
||||
|
||||
assert "swordfish" not in result.stdout
|
||||
assert "swordfish" not in result.stderr
|
||||
assert "https://***:***@pypi.internal" in result.stdout
|
||||
assert "https://***:***@pypi.internal" in result.stderr
|
||||
@@ -193,7 +193,7 @@ class TestPluginLoader:
|
||||
|
||||
mock_subprocess.return_value = MagicMock(returncode=0)
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
mock_subprocess.assert_called_once()
|
||||
@@ -204,7 +204,7 @@ class TestPluginLoader:
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
mock_subprocess.assert_not_called()
|
||||
@@ -219,7 +219,7 @@ class TestPluginLoader:
|
||||
|
||||
mock_subprocess.return_value = MagicMock(returncode=1)
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is False
|
||||
|
||||
@@ -245,7 +245,7 @@ class TestPluginLoader:
|
||||
retry_attempt = MagicMock(returncode=0, stderr="")
|
||||
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_subprocess.call_count == 2
|
||||
@@ -271,7 +271,7 @@ class TestPluginLoader:
|
||||
retry_attempt = MagicMock(returncode=1, stderr="some other pip error")
|
||||
mock_subprocess.side_effect = [first_attempt, retry_attempt]
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_subprocess.call_count == 2
|
||||
@@ -298,7 +298,7 @@ class TestPluginLoader:
|
||||
subprocess.TimeoutExpired(cmd="pip", timeout=300),
|
||||
]
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_subprocess.call_count == 2
|
||||
@@ -311,7 +311,34 @@ class TestPluginLoader:
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
requirements_file.write_text("pytest>=1.0\n")
|
||||
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
result = plugin_loader.install_dependencies(plugin_dir, "test_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is True
|
||||
mock_subprocess.assert_not_called()
|
||||
|
||||
def test_install_dependencies_requires_plugins_dir(self, plugin_loader, tmp_plugins_dir):
|
||||
"""plugins_dir is a required argument, not an optional trust-me flag --
|
||||
calling without it must fail loudly (TypeError) rather than silently
|
||||
falling back to trusting plugin_dir unchecked."""
|
||||
plugin_dir = tmp_plugins_dir / "test_plugin"
|
||||
plugin_dir.mkdir()
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
plugin_loader.install_dependencies(plugin_dir, "test_plugin")
|
||||
|
||||
@patch('subprocess.run')
|
||||
def test_install_dependencies_rejects_path_outside_plugins_dir(
|
||||
self, mock_subprocess, plugin_loader, tmp_path, tmp_plugins_dir
|
||||
):
|
||||
"""A plugin_dir that doesn't actually live inside plugins_dir (e.g. a
|
||||
manifest-derived id crafted to traverse elsewhere) must be rejected
|
||||
rather than read from -- this is the path-injection containment
|
||||
check CodeQL flagged as missing."""
|
||||
outside_dir = tmp_path / "outside"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "requirements.txt").write_text("requests>=2.0\n")
|
||||
|
||||
result = plugin_loader.install_dependencies(outside_dir, "evil_plugin", plugins_dir=tmp_plugins_dir)
|
||||
|
||||
assert result is False
|
||||
mock_subprocess.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user