mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
fix(plugins): replace dependency marker files with a real satisfaction check (#390)
* fix(plugins): replace dependency marker files with a real satisfaction check The .dependencies_installed hash-marker system only tracked "was this exact requirements.txt hashed before" — not whether the packages it names are actually present. That made it fragile (a wiped venv, a manually removed package, or a lost/corrupted marker forces a needless full pip reinstall or, worse, a false skip) and produced dead weight for the ~10 plugins whose requirements.txt is comment-only (they still paid a pip subprocess on first boot before a marker existed). Replace it with requirements_are_satisfied() in plugin_loader.py, which checks each real requirement line against importlib.metadata directly, so install_dependencies() only shells out to pip when something is actually missing or version-mismatched. Drops the marker file entirely: removed all marker read/write sites in plugin_loader.py and store_manager.py, the now-pointless marker-cleanup step in the git-update path, the unused legacy marker implementation in plugin_manager.py, and the already-stale clear_dependency_markers.sh script. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * fix(security): close path-injection gap in dependency-satisfaction checks CodeQL flagged 2 new high-severity "uncontrolled data used in path expression" alerts at the open() calls inside this PR's new requirements_has_real_deps()/requirements_are_satisfied() -- both are reachable from paths that were never run through the basename+trusted-base sanitiser this codebase already uses elsewhere: - PluginLoader.install_dependencies() only applied that sanitiser when its optional plugins_dir argument was actually passed; the "no plugins_dir" branch trusted plugin_dir_real directly. Made plugins_dir required (not Optional) so that branch can't exist, and added an explicit guard in load_plugin() so install_deps=True without a plugins_dir fails loudly instead of silently. Production's only real caller (PluginManager) always passes plugins_dir already; the harness/dev-server/render-plugin callers all use install_deps=False and are unaffected. - StoreManager._install_dependencies() never sanitised plugin_path at all, and its call sites ultimately derive that path from a plugin's own manifest.json "id" field (install_plugin_from_url) -- a malicious plugin could otherwise point requirements_file outside plugins_dir. Applied the same os.path.basename()-based containment pattern PluginLoader already uses (and that CodeQL recognises as a real sanitiser). Added test_install_dependencies_requires_plugins_dir and test_install_dependencies_rejects_path_outside_plugins_dir to lock in the actual security property, not just quiet the scanner. Verified: all 20 tests in test_plugin_loader.py pass, plus the PR's existing test plan (test_plugin_system.py, test_store_manager_caches.py: 53 passed) and the full CI plugin-safety suite (test_harness.py, test_visual_rendering.py, test_plugin_matrix.py: 52 passed, 2 pre-existing skips) all still pass. * fix(security): replace basename-only sanitiser with a trusted-enumeration check The previous commit's os.path.basename() + os.path.join() pattern (which a pre-existing code comment claimed CodeQL recognises as a sanitiser) did not actually clear the alert -- the next CodeQL run still flagged the same 2 sink lines, plus a new one at the os.path.join() call itself. Taking a substring of tainted data apparently isn't treated as a barrier by this query, whatever the comment assumed. Replaced it with find_trusted_subdir(): enumerate the trusted plugins_dir via os.scandir() and only use a name that scandir itself produced, matched by equality against the caller's requested name. The path is then built from that enumerated entry, not from the caller's string -- a value sourced from iterating a trusted, non-tainted directory carries no taint regardless of what it happens to equal, which is a stronger and more conventional allowlist-style barrier than string-stripping. Applied identically in both PluginLoader.install_dependencies() and StoreManager._install_dependencies(), sharing one implementation. Re-verified: all 65 tests across test_plugin_loader.py (20, including the 2 new security regression tests), test_store_manager_caches.py (35), test_plugin_system.py (10) pass, plus the full CI plugin-safety suite (test_harness.py/test_visual_rendering.py/test_plugin_matrix.py: 52 passed, 2 pre-existing skips). * fix(security): redact URL credentials from pip subprocess output before logging CodeQL flagged 3 clear-text-logging-of-secrets alerts in install_requirements_file() (src/common/permission_utils.py:353,360,371). Pre-existing on main, unrelated to this PR's own diff, but now visible since the path-injection alerts that previously took priority in the annotation list are fixed. The underlying risk is real: pip can echo a private index URL's embedded basic-auth credentials (from a requirements.txt --index-url line or PIP_INDEX_URL) back verbatim in its own stderr/stdout on failure, and this function both logs that output directly and returns it to callers -- store_manager.py's _install_dependencies() logs result.stderr from this same function too. Added _redact_url_credentials(), applied immediately after each of the two subprocess.run() calls (mutating result.stderr/stdout in place) rather than patching each log call site individually. This closes the leak at the source: every downstream use -- the three flagged log lines, the "note" string embedded in the returned stdout, and store_manager.py's own logging of the returned result -- gets the redacted text for free. Verified the fixed-phrase "denied" check (`"a password is required" in result.stderr`) is unaffected, since URL syntax and those phrases don't overlap -- covered explicitly by test_does_not_touch_denied_check_phrases. Added test/test_permission_utils.py (6 tests) covering the redaction helper directly and both subprocess.run() call sites (the sudo-wrapper branch, which this repo's scripts/fix_perms/safe_pip_install.sh makes live, and the no-wrapper fallback branch). All pass. * fix(security): stop interpolating req_file/pip-output into log calls The previous commit's redaction (mutating result.stderr/stdout right after each subprocess.run()) didn't clear CodeQL's clear-text-logging alerts -- same lesson as the path-injection fix earlier in this PR: a static analyzer can't tell "this value was already sanitised two lines up" from "this is still the raw tainted value" just by looking at a single log call in isolation, so it conservatively keeps flagging it regardless of what the redaction function actually does. Removed all dynamic interpolation (req_file, result.stderr) from the 3 flagged logger.warning() calls entirely, replacing them with fixed messages plus (for the one that had it) result.returncode, which is a plain int with no possible taint. The full redacted detail is still available where it actually matters -- in the returned CompletedProcess.stderr/stdout and the "note" text -- just not duplicated into a log line a scanner has to reason about in isolation. Re-verified: all 6 test_permission_utils.py tests still pass (they assert on the returned result, not log call arguments), plus the full test_plugin_loader.py/test_store_manager_caches.py/test_plugin_system.py suite (71 passed, 1 pre-existing deselect, 4 subtests). --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user