mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-02 17:28:05 +00:00
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.
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 os
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import shutil as _shutil
|
import shutil as _shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -16,6 +17,25 @@ from typing import Optional
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# System directories that should never have their permissions modified
|
||||||
# These directories have special system-level permissions that must be preserved
|
# 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
|
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)],
|
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
||||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
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:
|
if result.returncode == 0:
|
||||||
return result
|
return result
|
||||||
# Distinguish "sudo rejected this exact command line" (worth
|
# Distinguish "sudo rejected this exact command line" (worth
|
||||||
@@ -386,6 +413,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)],
|
[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)
|
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
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user