chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
View File
+69
View File
@@ -0,0 +1,69 @@
"""
Fixtures for security module tests.
"""
import pytest
@pytest.fixture
def mock_pdf_content():
"""Create minimal valid PDF content for testing."""
return b"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
193
%%EOF"""
@pytest.fixture
def sample_data_with_secrets():
"""Sample data containing sensitive keys."""
return {
"username": "testuser",
"email": "test@example.com",
"api_key": "sk-secret-12345",
"password": "supersecretpassword",
"settings": {
"theme": "dark",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
},
"items": [
{"name": "item1", "secret": "hidden_value"},
{"name": "item2", "value": "visible"},
],
}
@pytest.fixture
def sample_urls():
"""Collection of sample URLs for testing."""
return {
"valid_http": "http://example.com",
"valid_https": "https://example.com/page",
"valid_arxiv": "https://arxiv.org/abs/2301.12345",
"valid_pubmed": "https://pubmed.ncbi.nlm.nih.gov/12345678",
"javascript": "javascript:alert('xss')",
"data": "data:text/html,<script>alert('xss')</script>",
"vbscript": "vbscript:msgbox('xss')",
"file": "file:///etc/passwd",
"mailto": "mailto:test@example.com",
"fragment": "#section-1",
"empty": "",
"none": None,
}
@@ -0,0 +1 @@
"""Tests for security file integrity modules."""
@@ -0,0 +1,238 @@
"""Tests for security/file_integrity/base_verifier.py."""
import pytest
import tempfile
from pathlib import Path
class TestFileTypeEnum:
"""Tests for FileType enum."""
def test_faiss_index_value(self):
"""Test FAISS_INDEX enum value."""
from local_deep_research.security.file_integrity.base_verifier import (
FileType,
)
assert FileType.FAISS_INDEX.value == "faiss_index"
def test_pdf_value(self):
"""Test PDF enum value."""
from local_deep_research.security.file_integrity.base_verifier import (
FileType,
)
assert FileType.PDF.value == "pdf"
def test_export_value(self):
"""Test EXPORT enum value."""
from local_deep_research.security.file_integrity.base_verifier import (
FileType,
)
assert FileType.EXPORT.value == "export"
def test_file_type_is_str_enum(self):
"""Test that FileType is a string enum."""
from local_deep_research.security.file_integrity.base_verifier import (
FileType,
)
assert isinstance(FileType.PDF, str)
assert FileType.PDF == "pdf"
class TestBaseFileVerifierAbstract:
"""Tests for BaseFileVerifier abstract class."""
def test_cannot_instantiate_directly(self):
"""Test that BaseFileVerifier cannot be instantiated directly."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
)
with pytest.raises(TypeError, match="abstract"):
BaseFileVerifier()
def test_defines_abstract_methods(self):
"""Test that abstract methods are defined."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
)
# Check that the class has the expected abstract methods
assert hasattr(BaseFileVerifier, "should_verify")
assert hasattr(BaseFileVerifier, "get_file_type")
assert hasattr(BaseFileVerifier, "allows_modifications")
class TestConcreteVerifier:
"""Tests using a concrete implementation of BaseFileVerifier."""
@pytest.fixture
def concrete_verifier(self):
"""Create a concrete verifier implementation for testing."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class TestVerifier(BaseFileVerifier):
def should_verify(self, file_path: Path) -> bool:
return file_path.suffix == ".test"
def get_file_type(self) -> FileType:
return FileType.PDF
def allows_modifications(self) -> bool:
return False
return TestVerifier()
def test_calculate_checksum_returns_sha256_hex(self, concrete_verifier):
"""Test that calculate_checksum returns a SHA256 hex string."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".test") as f:
f.write(b"test content")
f.flush()
file_path = Path(f.name)
try:
checksum = concrete_verifier.calculate_checksum(file_path)
# SHA256 hex string is 64 characters
assert len(checksum) == 64
assert all(c in "0123456789abcdef" for c in checksum)
finally:
file_path.unlink()
def test_calculate_checksum_is_deterministic(self, concrete_verifier):
"""Test that same content produces same checksum."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".test") as f:
f.write(b"identical content")
f.flush()
file_path = Path(f.name)
try:
checksum1 = concrete_verifier.calculate_checksum(file_path)
checksum2 = concrete_verifier.calculate_checksum(file_path)
assert checksum1 == checksum2
finally:
file_path.unlink()
def test_calculate_checksum_different_for_different_content(
self, concrete_verifier
):
"""Test that different content produces different checksum."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".test") as f1:
f1.write(b"content one")
f1.flush()
file_path1 = Path(f1.name)
with tempfile.NamedTemporaryFile(delete=False, suffix=".test") as f2:
f2.write(b"content two")
f2.flush()
file_path2 = Path(f2.name)
try:
checksum1 = concrete_verifier.calculate_checksum(file_path1)
checksum2 = concrete_verifier.calculate_checksum(file_path2)
assert checksum1 != checksum2
finally:
file_path1.unlink()
file_path2.unlink()
def test_calculate_checksum_raises_for_missing_file(
self, concrete_verifier
):
"""Test that FileNotFoundError is raised for missing file."""
with pytest.raises(FileNotFoundError):
concrete_verifier.calculate_checksum(Path("/nonexistent/file.test"))
def test_get_algorithm_returns_sha256(self, concrete_verifier):
"""Test that get_algorithm returns sha256."""
assert concrete_verifier.get_algorithm() == "sha256"
def test_should_verify_with_matching_file(self, concrete_verifier):
"""Test should_verify returns True for matching file type."""
assert (
concrete_verifier.should_verify(Path("/path/to/file.test")) is True
)
def test_should_verify_with_non_matching_file(self, concrete_verifier):
"""Test should_verify returns False for non-matching file type."""
assert (
concrete_verifier.should_verify(Path("/path/to/file.txt")) is False
)
def test_get_file_type_returns_expected(self, concrete_verifier):
"""Test get_file_type returns the expected FileType."""
from local_deep_research.security.file_integrity.base_verifier import (
FileType,
)
assert concrete_verifier.get_file_type() == FileType.PDF
def test_allows_modifications_returns_false(self, concrete_verifier):
"""Test allows_modifications returns False."""
assert concrete_verifier.allows_modifications() is False
class TestChecksumWithLargeFile:
"""Tests for checksum calculation with larger files."""
@pytest.fixture
def verifier(self):
"""Create a concrete verifier for testing."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class LargeFileVerifier(BaseFileVerifier):
def should_verify(self, file_path: Path) -> bool:
return True
def get_file_type(self) -> FileType:
return FileType.EXPORT
def allows_modifications(self) -> bool:
return True
return LargeFileVerifier()
def test_handles_file_larger_than_chunk_size(self, verifier):
"""Test that files larger than 4096 bytes are handled correctly."""
# Create a file larger than the chunk size (4096 bytes)
content = b"x" * 10000 # 10KB
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(content)
f.flush()
file_path = Path(f.name)
try:
checksum = verifier.calculate_checksum(file_path)
# Should still return valid SHA256
assert len(checksum) == 64
assert all(c in "0123456789abcdef" for c in checksum)
finally:
file_path.unlink()
def test_empty_file_has_valid_checksum(self, verifier):
"""Test that empty files have a valid checksum."""
with tempfile.NamedTemporaryFile(delete=False) as f:
# Don't write anything - empty file
file_path = Path(f.name)
try:
checksum = verifier.calculate_checksum(file_path)
# Empty file should have the SHA256 of empty string
# This is the well-known SHA256 hash of "" (not a secret)
empty_string_sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" # DevSkim: ignore DS173237
assert checksum == empty_string_sha256
finally:
file_path.unlink()
@@ -0,0 +1,836 @@
"""Tests for security/file_integrity/integrity_manager.py."""
import pytest
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
class TestFileIntegrityManagerConstants:
"""Tests for FileIntegrityManager constants."""
def test_max_failures_per_file_is_reasonable(self):
"""Test that MAX_FAILURES_PER_FILE is set to a reasonable value."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
assert FileIntegrityManager.MAX_FAILURES_PER_FILE == 100
assert FileIntegrityManager.MAX_FAILURES_PER_FILE > 0
def test_max_total_failures_is_reasonable(self):
"""Test that MAX_TOTAL_FAILURES is set to a reasonable value."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
assert FileIntegrityManager.MAX_TOTAL_FAILURES == 10000
assert FileIntegrityManager.MAX_TOTAL_FAILURES > 0
class TestFileIntegrityManagerInit:
"""Tests for FileIntegrityManager initialization."""
def test_init_raises_without_session_context(self):
"""Test that init raises ImportError when session context unavailable."""
from local_deep_research.security.file_integrity import (
integrity_manager,
)
# Save original state
original_has_context = integrity_manager._has_session_context
try:
# Simulate missing session context
integrity_manager._has_session_context = False
with pytest.raises(ImportError, match="requires Flask"):
integrity_manager.FileIntegrityManager("testuser", "testpass")
finally:
# Restore original state
integrity_manager._has_session_context = original_has_context
def test_init_stores_username_and_password(self):
"""Test that init stores username and password."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("myuser", "mypass")
assert manager.username == "myuser"
assert manager.password == "mypass"
def test_init_creates_empty_verifiers_list(self):
"""Test that init creates an empty verifiers list."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
assert manager.verifiers == []
class TestNormalizePath:
"""Tests for _normalize_path method."""
def test_normalize_path_returns_string(self):
"""Test that _normalize_path returns a string."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
result = manager._normalize_path(Path("/tmp/test.txt"))
assert isinstance(result, str)
def test_normalize_path_resolves_to_absolute(self):
"""Test that _normalize_path resolves to absolute path."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
# Create actual temp file to test resolution
with tempfile.NamedTemporaryFile(delete=False) as f:
file_path = Path(f.name)
try:
result = manager._normalize_path(file_path)
assert result.startswith("/")
finally:
file_path.unlink()
class TestRegisterVerifier:
"""Tests for register_verifier method."""
def test_register_verifier_adds_to_list(self):
"""Test that register_verifier adds verifier to list."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class TestVerifier(BaseFileVerifier):
def should_verify(self, file_path):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
verifier = TestVerifier()
manager.register_verifier(verifier)
assert verifier in manager.verifiers
def test_register_multiple_verifiers(self):
"""Test that multiple verifiers can be registered."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class Verifier1(BaseFileVerifier):
def should_verify(self, file_path):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
class Verifier2(BaseFileVerifier):
def should_verify(self, file_path):
return True
def get_file_type(self):
return FileType.FAISS_INDEX
def allows_modifications(self):
return False
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
manager.register_verifier(Verifier1())
manager.register_verifier(Verifier2())
assert len(manager.verifiers) == 2
class TestGetVerifierForFile:
"""Tests for _get_verifier_for_file method."""
def test_returns_none_when_no_verifiers(self):
"""Test that None is returned when no verifiers registered."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
result = manager._get_verifier_for_file(Path("/some/file.txt"))
assert result is None
def test_returns_matching_verifier(self):
"""Test that the matching verifier is returned."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class TestVerifier(BaseFileVerifier):
def should_verify(self, file_path):
return file_path.suffix == ".pdf"
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
verifier = TestVerifier()
manager.register_verifier(verifier)
result = manager._get_verifier_for_file(Path("/test/file.pdf"))
assert result is verifier
class TestNeedsVerification:
"""Tests for _needs_verification method."""
def test_returns_true_for_missing_file(self):
"""Test that True is returned for missing files."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
mock_record = Mock()
mock_record.last_verified_at = None
result = manager._needs_verification(
mock_record, Path("/nonexistent/file.txt")
)
assert result is True
def test_returns_true_when_never_verified(self):
"""Test that True is returned when file was never verified."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
mock_record = Mock()
mock_record.last_verified_at = None
with tempfile.NamedTemporaryFile(delete=False) as f:
file_path = Path(f.name)
try:
result = manager._needs_verification(mock_record, file_path)
assert result is True
finally:
file_path.unlink()
class TestUpdateStats:
"""Tests for _update_stats method."""
def test_increments_total_verifications(self):
"""Test that total_verifications is incremented."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
mock_record = Mock()
mock_record.total_verifications = 5
mock_record.consecutive_successes = 0
mock_record.consecutive_failures = 0
manager._update_stats(mock_record, passed=True, session=mock_ctx)
assert mock_record.total_verifications == 6
def test_updates_consecutive_successes_on_pass(self):
"""Test that consecutive_successes is incremented on pass."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
mock_record = Mock()
mock_record.total_verifications = 0
mock_record.consecutive_successes = 3
mock_record.consecutive_failures = 1
manager._update_stats(mock_record, passed=True, session=mock_ctx)
assert mock_record.consecutive_successes == 4
assert mock_record.consecutive_failures == 0
def test_updates_consecutive_failures_on_fail(self):
"""Test that consecutive_failures is incremented on fail."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
manager = FileIntegrityManager("testuser")
mock_record = Mock()
mock_record.total_verifications = 0
mock_record.consecutive_successes = 5
mock_record.consecutive_failures = 0
manager._update_stats(mock_record, passed=False, session=mock_ctx)
assert mock_record.consecutive_failures == 1
assert mock_record.consecutive_successes == 0
class TestVerifyFile:
"""Tests for verify_file method."""
def test_verify_file_returns_result(self):
"""Test that verify_file returns a result."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class TestVerifier(BaseFileVerifier):
def should_verify(self, file_path):
return file_path.suffix == ".pdf"
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
mock_ctx.query.return_value.filter.return_value.first.return_value = None
manager = FileIntegrityManager("testuser")
manager.register_verifier(TestVerifier())
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
f.write(b"test pdf content")
file_path = Path(f.name)
try:
result = manager.verify_file(file_path)
# verify_file should return a verification result
assert result is not None
except (ImportError, AttributeError) as e:
# Expected if optional dependencies missing
pytest.skip(f"Skipping due to missing dependency: {e}")
except (OSError, IOError) as e:
# File system errors are acceptable in test environment
pytest.skip(f"Skipping due to file system error: {e}")
except TypeError as e:
# Mock comparison issues can occur in complex verification flows
pytest.skip(f"Skipping due to mock comparison issue: {e}")
finally:
file_path.unlink()
class TestMaxFailuresLimits:
"""Tests for max failures limits."""
def test_max_failures_per_file_constant(self):
"""Test MAX_FAILURES_PER_FILE is set."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
assert hasattr(FileIntegrityManager, "MAX_FAILURES_PER_FILE")
assert FileIntegrityManager.MAX_FAILURES_PER_FILE > 0
def test_max_total_failures_constant(self):
"""Test MAX_TOTAL_FAILURES is set."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
assert hasattr(FileIntegrityManager, "MAX_TOTAL_FAILURES")
assert FileIntegrityManager.MAX_TOTAL_FAILURES > 0
def test_max_total_failures_greater_than_per_file(self):
"""Test MAX_TOTAL_FAILURES is greater than MAX_FAILURES_PER_FILE."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
assert (
FileIntegrityManager.MAX_TOTAL_FAILURES
>= FileIntegrityManager.MAX_FAILURES_PER_FILE
)
# ---------- helper to build a manager without hitting the real DB ----------
def _make_manager():
"""Create a FileIntegrityManager with mocked DB session."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_session:
mock_ctx = MagicMock()
mock_session.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_session.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
mgr = FileIntegrityManager("testuser", "testpass")
return mgr
class TestDoVerification:
"""Tests for _do_verification (the core tamper-detection method)."""
def test_returns_false_when_file_missing(self, tmp_path):
"""Missing file is detected."""
mgr = _make_manager()
record = Mock(checksum="abc123")
missing = tmp_path / "no_such_file"
passed, reason = mgr._do_verification(record, missing, MagicMock())
assert passed is False
assert reason == "file_missing"
def test_returns_false_when_no_verifier(self, tmp_path):
"""No registered verifier → failure."""
mgr = _make_manager()
f = tmp_path / "file.xyz"
f.write_text("data")
record = Mock(checksum="abc")
passed, reason = mgr._do_verification(record, f, MagicMock())
assert passed is False
assert reason == "no_verifier"
def test_returns_false_on_checksum_mismatch(self, tmp_path):
"""Tampered file detected via checksum mismatch."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class StubVerifier(BaseFileVerifier):
def should_verify(self, fp):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
mgr = _make_manager()
v = StubVerifier()
v.calculate_checksum = Mock(return_value="new_checksum")
mgr.register_verifier(v)
f = tmp_path / "file.bin"
f.write_text("data")
record = Mock(checksum="old_checksum")
passed, reason = mgr._do_verification(record, f, MagicMock())
assert passed is False
assert reason == "checksum_mismatch"
def test_returns_true_on_match_and_updates_mtime(self, tmp_path):
"""Valid file passes and mtime is updated."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class StubVerifier(BaseFileVerifier):
def should_verify(self, fp):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
mgr = _make_manager()
v = StubVerifier()
v.calculate_checksum = Mock(return_value="matching")
mgr.register_verifier(v)
f = tmp_path / "file.bin"
f.write_text("data")
record = Mock(checksum="matching", file_mtime=0.0)
passed, reason = mgr._do_verification(record, f, MagicMock())
assert passed is True
assert reason is None
# mtime should have been updated to the file's actual mtime
assert record.file_mtime == f.stat().st_mtime
def test_checksum_calculation_exception(self, tmp_path):
"""Exception during checksum calculation is handled."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class StubVerifier(BaseFileVerifier):
def should_verify(self, fp):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
mgr = _make_manager()
v = StubVerifier()
v.calculate_checksum = Mock(side_effect=IOError("disk error"))
mgr.register_verifier(v)
f = tmp_path / "file.bin"
f.write_text("data")
record = Mock(checksum="x")
passed, reason = mgr._do_verification(record, f, MagicMock())
assert passed is False
assert "checksum_calculation_failed" in reason
class TestRecordFile:
"""Tests for record_file (baseline creation / update)."""
def test_creates_new_record(self, tmp_path):
"""New file baseline is created."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class StubVerifier(BaseFileVerifier):
def should_verify(self, fp):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
f = tmp_path / "new.bin"
f.write_text("hello")
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_gs:
mock_ctx = MagicMock()
mock_gs.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_gs.return_value.__exit__ = Mock(return_value=False)
# Init: cleanup returns 0
mock_ctx.query.return_value.count.return_value = 0
mgr = FileIntegrityManager("user", "pass")
v = StubVerifier()
v.calculate_checksum = Mock(return_value="abc123")
mgr.register_verifier(v)
# record_file path: no existing record
mock_ctx.query.return_value.filter_by.return_value.first.return_value = None
mgr.record_file(f)
# session.add should have been called with a new record
mock_ctx.add.assert_called_once()
mock_ctx.commit.assert_called()
def test_updates_existing_record(self, tmp_path):
"""Existing record is updated."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class StubVerifier(BaseFileVerifier):
def should_verify(self, fp):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
f = tmp_path / "existing.bin"
f.write_text("updated content")
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_gs:
mock_ctx = MagicMock()
mock_gs.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_gs.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = 0
mgr = FileIntegrityManager("user", "pass")
v = StubVerifier()
v.calculate_checksum = Mock(return_value="newchecksum")
mgr.register_verifier(v)
# Simulate existing record
existing = Mock()
existing.checksum = "oldchecksum"
mock_ctx.query.return_value.filter_by.return_value.first.return_value = existing
mgr.record_file(f)
assert existing.checksum == "newchecksum"
mock_ctx.commit.assert_called()
def test_raises_file_not_found(self, tmp_path):
"""Missing file raises FileNotFoundError."""
mgr = _make_manager()
with pytest.raises(FileNotFoundError):
mgr.record_file(tmp_path / "nope.bin")
def test_raises_no_verifier(self, tmp_path):
"""No matching verifier raises ValueError."""
mgr = _make_manager()
f = tmp_path / "file.xyz"
f.write_text("data")
with pytest.raises(ValueError, match="No verifier"):
mgr.record_file(f)
class TestLogFailure:
"""Tests for _log_failure (audit trail)."""
def test_creates_failure_record(self, tmp_path):
"""Failure record is added to session."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
FileType,
)
class StubVerifier(BaseFileVerifier):
def should_verify(self, fp):
return True
def get_file_type(self):
return FileType.PDF
def allows_modifications(self):
return False
mgr = _make_manager()
v = StubVerifier()
v.calculate_checksum = Mock(return_value="actual_hash")
mgr.register_verifier(v)
f = tmp_path / "tampered.bin"
f.write_text("bad")
mock_session = MagicMock()
mock_session.query.return_value.filter_by.return_value.count.return_value = 0
record = Mock(id=1, checksum="expected_hash")
mgr._log_failure(record, f, "checksum_mismatch", mock_session)
mock_session.add.assert_called_once()
added_obj = mock_session.add.call_args[0][0]
assert added_obj.failure_reason == "checksum_mismatch"
assert added_obj.expected_checksum == "expected_hash"
assert added_obj.actual_checksum == "actual_hash"
class TestCleanupOldFailures:
"""Tests for _cleanup_old_failures (per-file)."""
def test_deletes_excess_failures(self):
"""Old failures beyond MAX_FAILURES_PER_FILE are deleted."""
mgr = _make_manager()
mock_session = MagicMock()
# Simulate 105 failures (limit is 100)
mock_session.query.return_value.filter_by.return_value.count.return_value = 105
old_failures = [Mock() for _ in range(5)]
mock_session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures
record = Mock(id=1)
mgr._cleanup_old_failures(record, mock_session)
assert mock_session.delete.call_count == 5
class TestCleanupAllOldFailures:
"""Tests for cleanup_all_old_failures (global)."""
def test_under_limit_returns_zero(self):
"""No cleanup when under limit."""
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with patch(
"local_deep_research.security.file_integrity.integrity_manager.get_user_db_session"
) as mock_gs:
mock_ctx = MagicMock()
mock_gs.return_value.__enter__ = Mock(return_value=mock_ctx)
mock_gs.return_value.__exit__ = Mock(return_value=False)
mock_ctx.query.return_value.count.return_value = (
50 # well under 10000
)
mgr = FileIntegrityManager("user", "pass")
result = mgr.cleanup_all_old_failures()
assert result == 0
@@ -0,0 +1,319 @@
"""High-value edge case tests for security/file_integrity/integrity_manager.py.
Tests internal methods: _normalize_path, _get_verifier_for_file,
_needs_verification, _do_verification, _update_stats, register_verifier,
and cleanup logic.
"""
from datetime import datetime, UTC
from pathlib import Path
from unittest.mock import MagicMock
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
def _make_manager():
"""Create a FileIntegrityManager instance bypassing __init__."""
mgr = FileIntegrityManager.__new__(FileIntegrityManager)
mgr.username = "test"
mgr.password = None
mgr.verifiers = []
return mgr
class TestNormalizePath:
"""Test _normalize_path."""
def test_returns_string(self, tmp_path):
"""Returns a string, not a Path object."""
mgr = _make_manager()
result = mgr._normalize_path(tmp_path / "file.txt")
assert isinstance(result, str)
def test_resolves_to_absolute(self, tmp_path):
"""Result is an absolute path."""
mgr = _make_manager()
result = mgr._normalize_path(tmp_path / "file.txt")
assert Path(result).is_absolute()
def test_resolves_symlink(self, tmp_path):
"""Symlink and target resolve to the same normalized path."""
mgr = _make_manager()
real_file = tmp_path / "real.txt"
real_file.write_text("content")
symlink = tmp_path / "link.txt"
symlink.symlink_to(real_file)
assert mgr._normalize_path(symlink) == mgr._normalize_path(real_file)
class TestGetVerifierForFile:
"""Test _get_verifier_for_file."""
def test_returns_none_with_no_verifiers(self, tmp_path):
"""No registered verifiers returns None."""
mgr = _make_manager()
assert mgr._get_verifier_for_file(tmp_path / "test.txt") is None
def test_returns_matching_verifier(self, tmp_path):
"""Returns the first verifier that matches."""
mgr = _make_manager()
v1 = MagicMock()
v1.should_verify.return_value = False
v2 = MagicMock()
v2.should_verify.return_value = True
mgr.verifiers = [v1, v2]
assert mgr._get_verifier_for_file(tmp_path / "test.txt") is v2
def test_first_match_wins(self, tmp_path):
"""When multiple verifiers match, the first one wins."""
mgr = _make_manager()
v1 = MagicMock()
v1.should_verify.return_value = True
v2 = MagicMock()
v2.should_verify.return_value = True
mgr.verifiers = [v1, v2]
assert mgr._get_verifier_for_file(tmp_path / "test.txt") is v1
class TestNeedsVerification:
"""Test _needs_verification mtime logic."""
def test_returns_true_for_missing_file(self, tmp_path):
"""Missing file needs verification."""
mgr = _make_manager()
record = MagicMock()
record.last_verified_at = datetime.now(UTC)
result = mgr._needs_verification(record, tmp_path / "nonexistent.txt")
assert result is True
def test_returns_true_when_never_verified(self, tmp_path):
"""File never verified (last_verified_at=None) needs verification."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
record = MagicMock()
record.last_verified_at = None
assert mgr._needs_verification(record, f) is True
def test_returns_true_when_file_mtime_is_none(self, tmp_path):
"""Stored mtime=None means verification needed."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
record = MagicMock()
record.last_verified_at = datetime.now(UTC)
record.file_mtime = None
assert mgr._needs_verification(record, f) is True
def test_returns_false_when_mtime_unchanged(self, tmp_path):
"""Unchanged mtime means no verification needed."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
record = MagicMock()
record.last_verified_at = datetime.now(UTC)
record.file_mtime = f.stat().st_mtime
assert mgr._needs_verification(record, f) is False
def test_returns_true_when_mtime_changed(self, tmp_path):
"""Changed mtime triggers verification."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
record = MagicMock()
record.last_verified_at = datetime.now(UTC)
record.file_mtime = f.stat().st_mtime - 1.0
assert mgr._needs_verification(record, f) is True
def test_mtime_tolerance_below_threshold(self, tmp_path):
"""Difference of 0.0005 is within tolerance (no verification)."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
actual_mtime = f.stat().st_mtime
record = MagicMock()
record.last_verified_at = datetime.now(UTC)
record.file_mtime = actual_mtime - 0.0005
assert mgr._needs_verification(record, f) is False
def test_mtime_tolerance_above_threshold(self, tmp_path):
"""Difference of 0.0015 exceeds tolerance (verification needed)."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
actual_mtime = f.stat().st_mtime
record = MagicMock()
record.last_verified_at = datetime.now(UTC)
record.file_mtime = actual_mtime - 0.0015
assert mgr._needs_verification(record, f) is True
class TestDoVerification:
"""Test _do_verification."""
def test_file_missing_returns_false(self, tmp_path):
"""Missing file returns (False, 'file_missing')."""
mgr = _make_manager()
record = MagicMock()
session = MagicMock()
result, reason = mgr._do_verification(
record, tmp_path / "missing.txt", session
)
assert result is False
assert reason == "file_missing"
def test_no_verifier_returns_false(self, tmp_path):
"""No matching verifier returns (False, 'no_verifier')."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
record = MagicMock()
session = MagicMock()
result, reason = mgr._do_verification(record, f, session)
assert result is False
assert reason == "no_verifier"
def test_checksum_match_returns_true(self, tmp_path):
"""Matching checksum returns (True, None)."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
verifier = MagicMock()
verifier.should_verify.return_value = True
verifier.calculate_checksum.return_value = "abc123"
mgr.verifiers = [verifier]
record = MagicMock()
record.checksum = "abc123"
session = MagicMock()
result, reason = mgr._do_verification(record, f, session)
assert result is True
assert reason is None
def test_checksum_mismatch_returns_false(self, tmp_path):
"""Mismatching checksum returns (False, 'checksum_mismatch')."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
verifier = MagicMock()
verifier.should_verify.return_value = True
verifier.calculate_checksum.return_value = "different"
mgr.verifiers = [verifier]
record = MagicMock()
record.checksum = "abc123"
session = MagicMock()
result, reason = mgr._do_verification(record, f, session)
assert result is False
assert reason == "checksum_mismatch"
def test_mtime_not_updated_on_mismatch(self, tmp_path):
"""On checksum mismatch, record.file_mtime is not changed."""
mgr = _make_manager()
f = tmp_path / "test.txt"
f.write_text("content")
verifier = MagicMock()
verifier.should_verify.return_value = True
verifier.calculate_checksum.return_value = "different"
mgr.verifiers = [verifier]
record = MagicMock()
record.checksum = "abc123"
sentinel = 12345.0
record.file_mtime = sentinel
session = MagicMock()
mgr._do_verification(record, f, session)
assert record.file_mtime == sentinel
class TestUpdateStats:
"""Test _update_stats."""
def test_pass_increments_successes_resets_failures(self):
"""Passing verification increments successes and resets failures."""
mgr = _make_manager()
record = MagicMock()
record.total_verifications = 0
record.consecutive_successes = 0
record.consecutive_failures = 3
mgr._update_stats(record, True, MagicMock())
assert record.total_verifications == 1
assert record.consecutive_successes == 1
assert record.consecutive_failures == 0
def test_fail_increments_failures_resets_successes(self):
"""Failing verification increments failures and resets successes."""
mgr = _make_manager()
record = MagicMock()
record.total_verifications = 0
record.consecutive_successes = 5
record.consecutive_failures = 0
mgr._update_stats(record, False, MagicMock())
assert record.total_verifications == 1
assert record.consecutive_failures == 1
assert record.consecutive_successes == 0
def test_last_verified_at_set(self):
"""last_verified_at is set to a datetime on every call."""
mgr = _make_manager()
record = MagicMock()
record.total_verifications = 0
record.consecutive_successes = 0
record.consecutive_failures = 0
record.last_verified_at = None
mgr._update_stats(record, True, MagicMock())
assert record.last_verified_at is not None
def test_last_verification_passed_reflects_outcome(self):
"""last_verification_passed matches the passed parameter."""
mgr = _make_manager()
record = MagicMock()
record.total_verifications = 0
record.consecutive_successes = 0
record.consecutive_failures = 0
mgr._update_stats(record, False, MagicMock())
assert record.last_verification_passed is False
class TestRegisterVerifier:
"""Test register_verifier."""
def test_adds_to_verifiers_list(self):
"""Verifier is added to the list."""
mgr = _make_manager()
v = MagicMock()
v.get_file_type.return_value = "test"
mgr.register_verifier(v)
assert v in mgr.verifiers
def test_preserves_insertion_order(self):
"""Verifiers appear in registration order."""
mgr = _make_manager()
v1 = MagicMock()
v1.get_file_type.return_value = "type1"
v2 = MagicMock()
v2.get_file_type.return_value = "type2"
v3 = MagicMock()
v3.get_file_type.return_value = "type3"
mgr.register_verifier(v1)
mgr.register_verifier(v2)
mgr.register_verifier(v3)
assert mgr.verifiers == [v1, v2, v3]
class TestClassConstants:
"""Test FileIntegrityManager class constants."""
def test_max_failures_per_file(self):
assert FileIntegrityManager.MAX_FAILURES_PER_FILE == 100
def test_max_total_failures(self):
assert FileIntegrityManager.MAX_TOTAL_FAILURES == 10000
def test_per_file_less_than_total(self):
"""Per-file limit must be less than total limit."""
assert (
FileIntegrityManager.MAX_FAILURES_PER_FILE
< FileIntegrityManager.MAX_TOTAL_FAILURES
)
@@ -0,0 +1 @@
"""Tests for file integrity verifiers."""
@@ -0,0 +1,276 @@
"""
Tests for FAISS Index Verifier - File integrity verification for FAISS vector indexes.
Tests cover:
- File type detection (should_verify)
- FAISS-specific policy enforcement
- Checksum calculation
- Edge cases and error handling
"""
import hashlib
import tempfile
from pathlib import Path
import pytest
from local_deep_research.security.file_integrity.verifiers.faiss_verifier import (
FAISSIndexVerifier,
)
from local_deep_research.security.file_integrity.base_verifier import FileType
@pytest.fixture
def verifier():
"""Create a FAISS verifier instance."""
return FAISSIndexVerifier()
@pytest.fixture
def temp_faiss_file():
"""Create a temporary .faiss file."""
with tempfile.NamedTemporaryFile(suffix=".faiss", delete=False) as f:
f.write(b"fake faiss index content")
return Path(f.name)
@pytest.fixture
def temp_non_faiss_file():
"""Create a temporary non-.faiss file."""
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f:
f.write(b"text content")
return Path(f.name)
class TestFAISSVerifierInitialization:
"""Tests for FAISS verifier initialization."""
def test_verifier_creates_successfully(self):
"""Verifier can be instantiated."""
verifier = FAISSIndexVerifier()
assert verifier is not None
def test_verifier_inherits_base_verifier(self):
"""Verifier inherits from BaseFileVerifier."""
from local_deep_research.security.file_integrity.base_verifier import (
BaseFileVerifier,
)
verifier = FAISSIndexVerifier()
assert isinstance(verifier, BaseFileVerifier)
class TestShouldVerify:
"""Tests for should_verify method."""
def test_should_verify_faiss_extension(self, verifier):
"""Should return True for .faiss files."""
path = Path("/data/indexes/my_index.faiss")
assert verifier.should_verify(path) is True
def test_should_verify_faiss_uppercase_extension(self, verifier):
"""Should return True for .FAISS uppercase extension."""
path = Path("/data/indexes/my_index.FAISS")
assert verifier.should_verify(path) is True
def test_should_verify_faiss_mixed_case_extension(self, verifier):
"""Should return True for .Faiss mixed case extension."""
path = Path("/data/indexes/my_index.Faiss")
assert verifier.should_verify(path) is True
def test_should_not_verify_pdf_extension(self, verifier):
"""Should return False for .pdf files."""
path = Path("/data/docs/document.pdf")
assert verifier.should_verify(path) is False
def test_should_not_verify_txt_extension(self, verifier):
"""Should return False for .txt files."""
path = Path("/data/notes.txt")
assert verifier.should_verify(path) is False
def test_should_not_verify_no_extension(self, verifier):
"""Should return False for files without extension."""
path = Path("/data/file_without_extension")
assert verifier.should_verify(path) is False
def test_should_not_verify_faiss_in_name(self, verifier):
"""Should return False for files with 'faiss' in name but different extension."""
path = Path("/data/faiss_backup.json")
assert verifier.should_verify(path) is False
def test_should_verify_nested_directory_path(self, verifier):
"""Should return True for .faiss files in nested directories."""
path = Path("/data/embeddings/user123/collection_abc/index.faiss")
assert verifier.should_verify(path) is True
def test_should_verify_hidden_file(self, verifier):
"""Should return True for hidden .faiss files."""
path = Path("/data/.hidden_index.faiss")
assert verifier.should_verify(path) is True
def test_should_not_verify_empty_path(self, verifier):
"""Should handle empty file name gracefully."""
path = Path("")
# Empty path has no suffix
assert verifier.should_verify(path) is False
class TestGetFileType:
"""Tests for get_file_type method."""
def test_returns_faiss_index_type(self, verifier):
"""Should return FileType.FAISS_INDEX."""
assert verifier.get_file_type() == FileType.FAISS_INDEX
def test_file_type_is_enum_member(self, verifier):
"""File type should be a valid FileType enum member."""
file_type = verifier.get_file_type()
assert isinstance(file_type, FileType)
def test_file_type_value_is_string(self, verifier):
"""File type value should be a string."""
file_type = verifier.get_file_type()
assert file_type.value == "faiss_index"
class TestAllowsModifications:
"""Tests for allows_modifications method."""
def test_does_not_allow_modifications(self, verifier):
"""FAISS indexes should never allow modifications."""
assert verifier.allows_modifications() is False
def test_modification_policy_is_strict(self, verifier):
"""Modification policy should be consistently False."""
# Multiple calls should return same result
for _ in range(10):
assert verifier.allows_modifications() is False
class TestChecksumCalculation:
"""Tests for checksum calculation (inherited from BaseFileVerifier)."""
def test_calculate_checksum_returns_hex_string(
self, verifier, temp_faiss_file
):
"""Checksum should be a hex string."""
checksum = verifier.calculate_checksum(temp_faiss_file)
assert isinstance(checksum, str)
# SHA256 produces 64 hex characters
assert len(checksum) == 64
# All characters should be valid hex
assert all(c in "0123456789abcdef" for c in checksum)
def test_calculate_checksum_consistency(self, verifier, temp_faiss_file):
"""Same file should produce same checksum."""
checksum1 = verifier.calculate_checksum(temp_faiss_file)
checksum2 = verifier.calculate_checksum(temp_faiss_file)
assert checksum1 == checksum2
def test_calculate_checksum_different_files(self, verifier):
"""Different files should produce different checksums."""
with tempfile.NamedTemporaryFile(suffix=".faiss", delete=False) as f1:
f1.write(b"content A")
path1 = Path(f1.name)
with tempfile.NamedTemporaryFile(suffix=".faiss", delete=False) as f2:
f2.write(b"content B")
path2 = Path(f2.name)
checksum1 = verifier.calculate_checksum(path1)
checksum2 = verifier.calculate_checksum(path2)
assert checksum1 != checksum2
def test_calculate_checksum_file_not_found(self, verifier):
"""Should raise FileNotFoundError for non-existent file."""
path = Path("/nonexistent/path/index.faiss")
with pytest.raises(FileNotFoundError):
verifier.calculate_checksum(path)
def test_calculate_checksum_large_file(self, verifier):
"""Should handle large files (chunked reading)."""
with tempfile.NamedTemporaryFile(suffix=".faiss", delete=False) as f:
# Write 1MB of data
f.write(b"x" * (1024 * 1024))
path = Path(f.name)
checksum = verifier.calculate_checksum(path)
assert len(checksum) == 64
def test_calculate_checksum_empty_file(self, verifier):
"""Should handle empty files."""
with tempfile.NamedTemporaryFile(suffix=".faiss", delete=False) as f:
# Write nothing - empty file
path = Path(f.name)
checksum = verifier.calculate_checksum(path)
# SHA256 of empty string
expected = hashlib.sha256(b"").hexdigest()
assert checksum == expected
class TestGetAlgorithm:
"""Tests for get_algorithm method (inherited from BaseFileVerifier)."""
def test_get_algorithm_returns_sha256(self, verifier):
"""Should return sha256 as the algorithm."""
assert verifier.get_algorithm() == "sha256"
class TestEdgeCases:
"""Edge case tests for FAISS verifier."""
def test_path_with_special_characters(self, verifier):
"""Should handle paths with special characters."""
path = Path("/data/user's files/my (index) [v2].faiss")
assert verifier.should_verify(path) is True
def test_path_with_unicode_characters(self, verifier):
"""Should handle paths with unicode characters."""
path = Path("/données/索引/インデックス.faiss")
assert verifier.should_verify(path) is True
def test_path_with_spaces(self, verifier):
"""Should handle paths with spaces."""
path = Path("/my documents/vector indexes/main index.faiss")
assert verifier.should_verify(path) is True
def test_very_long_path(self, verifier):
"""Should handle very long paths."""
long_dir = "a" * 200
path = Path(f"/{long_dir}/index.faiss")
assert verifier.should_verify(path) is True
def test_relative_path(self, verifier):
"""Should handle relative paths."""
path = Path("./relative/path/index.faiss")
assert verifier.should_verify(path) is True
def test_multiple_extensions(self, verifier):
"""Should only check final extension."""
path = Path("/data/index.backup.faiss")
assert verifier.should_verify(path) is True
path_wrong = Path("/data/index.faiss.backup")
assert verifier.should_verify(path_wrong) is False
class TestIntegrationWithIntegrityManager:
"""Tests for integration with the integrity manager."""
def test_verifier_can_be_registered(self, verifier):
"""Verifier should have all methods required for registration."""
# Check all required methods exist
assert hasattr(verifier, "should_verify")
assert hasattr(verifier, "get_file_type")
assert hasattr(verifier, "allows_modifications")
assert hasattr(verifier, "calculate_checksum")
assert hasattr(verifier, "get_algorithm")
def test_verifier_methods_are_callable(self, verifier):
"""All verifier methods should be callable."""
assert callable(verifier.should_verify)
assert callable(verifier.get_file_type)
assert callable(verifier.allows_modifications)
assert callable(verifier.calculate_checksum)
assert callable(verifier.get_algorithm)
@@ -0,0 +1,370 @@
"""
Tests for the check-absolute-module-paths pre-commit hook.
Ensures the hook correctly detects absolute module paths (local_deep_research.*)
that should be relative imports, while allowing legitimate uses.
"""
import ast
import json
import sys
from importlib import import_module
from pathlib import Path
# Add the pre-commit hooks directory to path
HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks"
sys.path.insert(0, str(HOOKS_DIR))
# Import the checker from the hook (must be after sys.path modification)
hook_module = import_module("check-absolute-module-paths") # noqa: E402
AbsoluteModulePathChecker = hook_module.AbsoluteModulePathChecker
LEGITIMATE_ABSOLUTE_REFS = hook_module.LEGITIMATE_ABSOLUTE_REFS
class TestAbsoluteModulePathChecker:
"""Tests for the AbsoluteModulePathChecker AST visitor."""
def _check_code(self, code: str, filename: str = "src/module.py") -> list:
"""Helper to check code and return errors."""
tree = ast.parse(code)
checker = AbsoluteModulePathChecker(filename)
checker.visit(tree)
return checker.errors
# --- Detection tests ---
def test_detects_web_search_engine_absolute_path(self):
"""Should detect absolute paths in the web_search_engines subpackage."""
code = """
module_path = "local_deep_research.web_search_engines.engines.search_engine_brave"
"""
errors = self._check_code(code)
assert len(errors) == 1
assert "local_deep_research.web_search_engines" in errors[0][1]
def test_detects_llm_absolute_path(self):
"""Should detect absolute paths in the llm subpackage."""
code = """
path = "local_deep_research.llm.providers.implementations.openai_provider"
"""
errors = self._check_code(code)
assert len(errors) == 1
assert "local_deep_research.llm" in errors[0][1]
def test_detects_arbitrary_subpackage_absolute_path(self):
"""Should detect absolute paths in any subpackage."""
code = """
x = "local_deep_research.some_new_package.foo.bar"
"""
errors = self._check_code(code)
assert len(errors) == 1
def test_detects_multiple_violations(self):
"""Should detect multiple violations in one file."""
code = """
a = "local_deep_research.web_search_engines.engines.local_embedding_manager"
b = "local_deep_research.web_search_engines.engines.search_engine_brave"
c = "local_deep_research.llm.providers.implementations.openai_provider"
"""
errors = self._check_code(code)
assert len(errors) == 3
def test_reports_correct_line_numbers(self):
"""Should report the correct line number for violations."""
code = """
# Some comment
x = 1
module_path = "local_deep_research.web_search_engines.engines.foo"
"""
errors = self._check_code(code)
assert len(errors) == 1
assert errors[0][0] == 5 # Line 5
# --- Allowance tests ---
def test_allows_relative_paths(self):
"""Should not flag relative module paths."""
code = """
module_path = ".engines.search_engine_brave"
"""
errors = self._check_code(code)
assert len(errors) == 0
def test_allows_legitimate_absolute_refs(self):
"""Should not flag known legitimate absolute references."""
for ref in LEGITIMATE_ABSOLUTE_REFS:
code = f'''
x = "{ref}"
'''
errors = self._check_code(code)
assert len(errors) == 0, f"Legitimate ref '{ref}' was flagged"
def test_allows_unrelated_strings(self):
"""Should not flag strings that don't start with local_deep_research."""
code = """
name = "some_other_package.module"
url = "https://example.com"
msg = "This is a message"
"""
errors = self._check_code(code)
assert len(errors) == 0
def test_allows_bare_package_name(self):
"""Should not flag 'local_deep_research' without a dot-suffix."""
code = """
name = "local_deep_research"
"""
errors = self._check_code(code)
assert len(errors) == 0
def test_allows_non_string_constants(self):
"""Should not flag integer or other non-string constants."""
code = """
x = 42
y = 3.14
z = True
"""
errors = self._check_code(code)
assert len(errors) == 0
class TestIsFileAllowed:
"""Tests for the _is_file_allowed() function."""
def test_allows_test_directory(self):
"""Files under tests/ should be allowed."""
assert hook_module._is_file_allowed("tests/test_something.py") is True
assert (
hook_module._is_file_allowed("tests/security/test_whitelist.py")
is True
)
def test_allows_pre_commit_hooks_directory(self):
"""Files under .pre-commit-hooks/ should be allowed."""
assert (
hook_module._is_file_allowed(
".pre-commit-hooks/check-absolute-module-paths.py"
)
is True
)
def test_allows_module_whitelist(self):
"""module_whitelist.py should be allowed."""
assert (
hook_module._is_file_allowed(
"src/local_deep_research/security/module_whitelist.py"
)
is True
)
def test_allows_test_prefix_basename(self):
"""Files with test_ prefix should be allowed."""
assert hook_module._is_file_allowed("src/test_something.py") is True
def test_allows_test_suffix_basename(self):
"""Files with _test.py suffix should be allowed."""
assert hook_module._is_file_allowed("src/something_test.py") is True
def test_rejects_normal_source_file(self):
"""Normal source files should NOT be allowed."""
assert (
hook_module._is_file_allowed("src/local_deep_research/config.py")
is False
)
def test_no_false_positive_on_tests_in_filename(self):
"""'tests' in a filename (not dir) should not allow the file."""
# 'my_tests_helper.py' has 'tests' in the name but isn't a test file
# and isn't in a tests/ directory
assert hook_module._is_file_allowed("src/my_tests_helper.py") is False
def test_no_false_positive_on_module_whitelist_substring(self):
"""module_whitelist_extra.py should NOT match module_whitelist.py."""
assert (
hook_module._is_file_allowed(
"src/security/module_whitelist_extra.py"
)
is False
)
def test_no_false_positive_on_test_in_middle_of_name(self):
"""'contest_results.py' should not match test_ prefix."""
assert hook_module._is_file_allowed("src/contest_results.py") is False
class TestCheckPythonFile:
"""Integration tests for check_python_file()."""
def test_clean_file_passes(self, tmp_path):
"""A file with only relative paths should pass."""
clean = tmp_path / "clean_module.py"
clean.write_text("""
config = {
"module_path": ".engines.search_engine_brave",
"class_name": "BraveSearchEngine",
}
""")
assert hook_module.check_python_file(str(clean)) is True
def test_violation_file_fails(self, tmp_path):
"""A file with absolute paths should fail."""
bad = tmp_path / "bad_module.py"
bad.write_text("""
config = {
"module_path": "local_deep_research.web_search_engines.engines.search_engine_brave",
"class_name": "BraveSearchEngine",
}
""")
assert hook_module.check_python_file(str(bad)) is False
def test_legitimate_ref_passes(self, tmp_path):
"""A file using a legitimate absolute ref should pass."""
legit = tmp_path / "legit_module.py"
legit.write_text("""
from importlib import resources
data_dir = resources.files("local_deep_research.defaults.settings")
""")
assert hook_module.check_python_file(str(legit)) is True
def test_syntax_error_passes(self, tmp_path):
"""Files with syntax errors should pass (let other tools handle them)."""
broken = tmp_path / "broken.py"
broken.write_text("def bad(:\n pass")
assert hook_module.check_python_file(str(broken)) is True
def test_test_file_always_passes(self, tmp_path):
"""Test files should always pass even with absolute paths."""
test_file = tmp_path / "test_whitelist.py"
test_file.write_text("""
path = "local_deep_research.web_search_engines.engines.foo"
""")
assert hook_module.check_python_file(str(test_file)) is True
def test_nonexistent_file_fails(self):
"""A non-existent file should fail (returns False)."""
assert (
hook_module.check_python_file("/nonexistent/path/file.py") is False
)
class TestCheckJsonFile:
"""Integration tests for check_json_file()."""
def test_clean_json_passes(self, tmp_path):
"""A JSON file with relative module_path should pass."""
clean = tmp_path / "engine.json"
clean.write_text(
json.dumps(
{
"module_path": ".engines.search_engine_brave",
"class_name": "BraveSearchEngine",
}
)
)
assert hook_module.check_json_file(str(clean)) is True
def test_absolute_module_path_in_json_fails(self, tmp_path):
"""A JSON file with absolute module_path should fail."""
bad = tmp_path / "bad_engine.json"
bad.write_text(
json.dumps(
{
"module_path": "local_deep_research.web_search_engines.engines.search_engine_brave",
"class_name": "BraveSearchEngine",
}
)
)
assert hook_module.check_json_file(str(bad)) is False
def test_nested_module_path_in_json_fails(self, tmp_path):
"""A nested JSON module_path with absolute path should fail."""
bad = tmp_path / "nested.json"
bad.write_text(
json.dumps(
{
"engines": {
"brave": {
"module_path": "local_deep_research.web_search_engines.engines.search_engine_brave",
"class_name": "BraveSearchEngine",
}
}
}
)
)
assert hook_module.check_json_file(str(bad)) is False
def test_absolute_path_in_non_module_path_key_passes(self, tmp_path):
"""Absolute paths in non-module_path keys should NOT trigger (no false positive)."""
ok = tmp_path / "description.json"
ok.write_text(
json.dumps(
{
"description": "Uses local_deep_research.web_search_engines.engines.foo internally",
"module_path": ".engines.search_engine_brave",
}
)
)
assert hook_module.check_json_file(str(ok)) is True
def test_invalid_json_fails(self, tmp_path):
"""Invalid JSON should fail gracefully (returns False)."""
bad = tmp_path / "broken.json"
bad.write_text("{invalid json}")
assert hook_module.check_json_file(str(bad)) is False
def test_legitimate_ref_in_module_path_passes(self, tmp_path):
"""Legitimate absolute refs in module_path should pass."""
legit = tmp_path / "legit.json"
legit.write_text(
json.dumps(
{"module_path": "local_deep_research.web_search_engines"}
)
)
assert hook_module.check_json_file(str(legit)) is True
def test_absolute_full_search_module_in_json_fails(self, tmp_path):
"""A JSON file with absolute full_search_module should fail."""
bad = tmp_path / "bad_full_search.json"
bad.write_text(
json.dumps(
{
"full_search_module": "local_deep_research.web_search_engines.engines.full_search",
"full_search_class": "FullSearchResults",
}
)
)
assert hook_module.check_json_file(str(bad)) is False
def test_relative_full_search_module_in_json_passes(self, tmp_path):
"""A JSON file with relative full_search_module should pass."""
ok = tmp_path / "ok_full_search.json"
ok.write_text(
json.dumps(
{
"full_search_module": ".engines.full_search",
"full_search_class": "FullSearchResults",
}
)
)
assert hook_module.check_json_file(str(ok)) is True
def test_module_path_in_list_of_dicts(self, tmp_path):
"""Should detect absolute paths in lists of engine configs."""
bad = tmp_path / "engines_list.json"
bad.write_text(
json.dumps(
[
{
"module_path": ".engines.good",
"class_name": "Good",
},
{
"module_path": "local_deep_research.web_search_engines.engines.bad",
"class_name": "Bad",
},
]
)
)
assert hook_module.check_json_file(str(bad)) is False
+144
View File
@@ -0,0 +1,144 @@
"""Tests for per-user account lockout."""
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from local_deep_research.security.account_lockout import AccountLockoutManager
class TestAccountLockout:
"""Unit tests for AccountLockoutManager."""
def _make_manager(self, threshold=3, lockout_minutes=15):
return AccountLockoutManager(
threshold=threshold, lockout_minutes=lockout_minutes
)
def test_not_locked_initially(self):
mgr = self._make_manager()
assert mgr.is_locked("alice") is False
def test_not_locked_below_threshold(self):
mgr = self._make_manager(threshold=3)
mgr.record_failure("alice")
mgr.record_failure("alice")
assert mgr.is_locked("alice") is False
def test_locked_at_threshold(self):
mgr = self._make_manager(threshold=3)
for _ in range(3):
mgr.record_failure("alice")
assert mgr.is_locked("alice") is True
def test_success_resets_lockout(self):
mgr = self._make_manager(threshold=3)
for _ in range(3):
mgr.record_failure("alice")
assert mgr.is_locked("alice") is True
mgr.record_success("alice")
assert mgr.is_locked("alice") is False
def test_counter_resets_after_success(self):
mgr = self._make_manager(threshold=10)
for _ in range(9):
mgr.record_failure("alice")
mgr.record_success("alice")
# After reset, 9 more failures should not lock (threshold is 10)
for _ in range(9):
mgr.record_failure("alice")
assert mgr.is_locked("alice") is False
def test_independent_users(self):
mgr = self._make_manager(threshold=3)
for _ in range(3):
mgr.record_failure("alice")
assert mgr.is_locked("alice") is True
assert mgr.is_locked("bob") is False
def test_success_on_nonexistent_user_is_noop(self):
mgr = self._make_manager()
# Should not raise
mgr.record_success("nonexistent")
assert mgr.is_locked("nonexistent") is False
def test_lockout_expires_after_duration(self):
mgr = self._make_manager(threshold=3, lockout_minutes=15)
now = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch(
"local_deep_research.security.account_lockout.datetime"
) as mock_dt:
mock_dt.now.return_value = now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
for _ in range(3):
mgr.record_failure("alice")
# Still locked 14 minutes later
with patch(
"local_deep_research.security.account_lockout.datetime"
) as mock_dt:
mock_dt.now.return_value = now + timedelta(minutes=14)
assert mgr.is_locked("alice") is True
# Unlocked at exactly 15 minutes
with patch(
"local_deep_research.security.account_lockout.datetime"
) as mock_dt:
mock_dt.now.return_value = now + timedelta(minutes=15)
assert mgr.is_locked("alice") is False
def test_eviction_removes_unlocked_entries(self):
"""Eviction should remove unlocked/expired entries, not blanket clear."""
mgr = self._make_manager(threshold=5)
mgr._MAX_STATE_ENTRIES = 100
now = datetime.now(timezone.utc)
# Add unlocked entries (count < threshold, no locked_until)
for i in range(90):
mgr._state[f"unlocked_{i}"] = {"count": 1, "locked_until": None}
# Add expired locked entries
for i in range(10):
mgr._state[f"expired_{i}"] = {
"count": 5,
"locked_until": now - timedelta(minutes=1),
}
# Add actively locked entries
for i in range(5):
mgr._state[f"locked_{i}"] = {
"count": 5,
"locked_until": now + timedelta(minutes=10),
}
assert len(mgr._state) == 105
# Trigger eviction via record_failure
mgr.record_failure("attacker")
# Actively locked entries should survive
for i in range(5):
assert f"locked_{i}" in mgr._state
# New entry should exist
assert "attacker" in mgr._state
# Unlocked/expired entries should be gone
assert not any(k.startswith("unlocked_") for k in mgr._state)
assert not any(k.startswith("expired_") for k in mgr._state)
def test_blanket_clear_as_last_resort(self):
"""If eviction can't reduce below limit, blanket clear."""
mgr = self._make_manager(threshold=3)
mgr._MAX_STATE_ENTRIES = 10
now = datetime.now(timezone.utc)
# Fill with actively locked entries (can't be evicted)
for i in range(15):
mgr._state[f"locked_{i}"] = {
"count": 5,
"locked_until": now + timedelta(hours=1),
}
mgr.record_failure("attacker")
# Should have blanket-cleared then added new entry
assert len(mgr._state) == 1
assert "attacker" in mgr._state
+911
View File
@@ -0,0 +1,911 @@
"""Tests that LLM provider error paths never leak API key bytes into logs
or exception messages reaching callers.
Bundled with this file: a production fix to
``src/local_deep_research/llm/providers/implementations/google.py`` whose
``list_models_for_api`` method previously used ``logger.exception(...)``
to log a ``requests`` exception whose message embedded the full request
URL — and Google's API requires the API key as a ``?key=...`` query
parameter, so the key value was written to every loguru sink.
The tests below pin no-leak behavior across a few representative
providers, with the Google case being the one that previously failed.
"""
import base64
from unittest.mock import Mock, patch
from urllib.parse import quote, quote_plus
import pytest
import requests
# A recognizable sentinel that should never appear in any logged or
# returned text after these tests run. Kept URL-safe so it can be
# embedded literally in test URLs without confusing `urllib.parse`; the
# encoding-matrix helper covers non-URL transformations (base64, repr,
# truncation) that *do* change shape.
_LEAKED_KEY = "sk-leaked-sentinel-DO-NOT-APPEAR-12345"
# An opaque key with NO regex-detectable shape (no sk-/pk- prefix, not a URL
# param, not a Bearer token). Only the literal-redaction pass (self.api_key via
# _secret_attrs) can scrub it — used to prove the literal net for header keys.
_LEAKED_OPAQUE_KEY = "opaqueleakedsentinelDONOTAPPEAR0123456789"
def _all_encodings_of(secret: str) -> list:
"""Return every encoding of *secret* a leak might appear under.
Used by the leak tests to assert no form of the sentinel reaches the
log output. New providers / engines that introduce different encoding
paths (e.g., a SDK that wraps the key in a JWT) should extend this
helper so the contract scales.
"""
return [
secret,
quote(secret, safe=""), # %-encoded for ?key=
quote_plus(secret), # +-encoded for form-urlencoded
repr(secret)[1:-1], # f-string {x!r} leak shape
base64.b64encode(secret.encode()).decode(),
secret[:8], # partial-leak (mask formatters)
]
def _stub_rate_tracker() -> Mock:
"""Return a minimal rate_tracker stub for tests that exercise the
request-layer catch block but not rate limiting.
Bypasses rate limiting without pulling in the real
``AdaptiveRateLimitTracker`` (which would trigger DB imports and a
settings snapshot). The leak tests want the catch block to run, not
the rate-limit code path.
- ``apply_rate_limit`` returns ``0`` so the engine's
``time.sleep(self._last_wait_time)`` is a no-op (keeps the test
fast). Forgetting this would have ``Mock`` return a truthy
``Mock`` instance and either sleep garbage or raise.
- ``enabled = False`` so any code path that gates on
``self.rate_tracker.enabled`` sees the stub as disabled.
- ``record_event`` is a no-op (the real implementation writes to
a DB; the stub has no DB to write to).
"""
tracker = Mock()
tracker.enabled = False
tracker.apply_rate_limit.return_value = 0
return tracker
@pytest.fixture
def google_provider_module():
"""Late-import the Google provider so the test's loguru_caplog
fixture has a chance to enable propagation first.
"""
from local_deep_research.llm.providers.implementations import google
return google
class TestGoogleProviderKeyLeakage:
"""The Google provider's ``list_models_for_api`` previously built a
URL with the API key as a query parameter (Google's then-documented
requirement). If the upstream request raised with the URL in its
exception message, ``logger.exception`` would write the key to logs
verbatim. The fix at
:file:`src/local_deep_research/llm/providers/implementations/google.py`
is two-layered:
1. **Prevention by construction (primary defense, issue #4184):** the
key is now passed via the ``x-goog-api-key`` header, so the URL
handed to ``requests`` no longer carries the secret. HTTP
exception messages embed the URL but never the headers.
2. **Log-side redaction (defense-in-depth):** the except handler
still wraps ``str(e)`` with ``redact_secrets(..., api_key)`` and
uses ``logger.warning`` (no traceback) so any future code path
that reintroduces the key into a logged string is still scrubbed.
Both properties are pinned below — the construction-layer test
fails if a maintainer ever reverts to ``?key=...``; the log-side
tests fail if the redaction wrap is removed.
"""
def test_api_key_is_passed_via_header_not_url(self, google_provider_module):
"""Prevention-by-construction: the URL handed to ``safe_get`` must
not contain the api_key under any name, and the ``x-goog-api-key``
header must carry it instead. This is the primary defense from
issue #4184 — reverting to the old ``?key=`` form would make this
test fail before any exception-handling code even runs.
"""
import local_deep_research.security as sec_pkg
captured = {}
class _Resp:
status_code = 200
def json(self):
return {"models": []}
def _capture(url, *args, **kwargs):
captured["url"] = url
captured["headers"] = kwargs.get("headers") or {}
return _Resp()
with patch.object(sec_pkg, "safe_get", side_effect=_capture):
google_provider_module.GoogleProvider.list_models_for_api(
api_key=_LEAKED_KEY
)
assert "url" in captured, "safe_get was not called"
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in captured["url"], (
"API key found in the URL handed to safe_get (as encoding "
f"{encoding!r}) - The Google provider must pass the key via the"
" x-goog-api-key header (see issue #4184) so HTTP exception"
" messages — which embed the URL but not headers — cannot "
"carry the secret."
)
assert captured["headers"].get("x-goog-api-key") == _LEAKED_KEY, (
"API key must be passed via the x-goog-api-key header. "
f"Got headers: {captured['headers']!r}"
)
def test_no_leak_when_safe_get_raises_with_url_in_message(
self, loguru_caplog, google_provider_module
):
"""Defense-in-depth: even if some upstream exception still embeds
the key (e.g., a future code path reintroduces it, or an
intermediate proxy echoes it back), the except handler's
``redact_secrets`` wrap must scrub it from the logged message.
"""
import local_deep_research.security as sec_pkg
exc = requests.exceptions.ConnectionError(
"HTTPSConnectionPool(host='generativelanguage.googleapis.com', "
"port=443): Max retries exceeded with url: "
f"/v1beta/models?key={_LEAKED_KEY}"
)
with loguru_caplog.at_level("DEBUG"):
with patch.object(sec_pkg, "safe_get", side_effect=exc):
result = (
google_provider_module.GoogleProvider.list_models_for_api(
api_key=_LEAKED_KEY
)
)
assert result == []
assert _LEAKED_KEY not in loguru_caplog.text, (
"API key value leaked into logs via the upstream exception "
"message. The except handler must redact the key before "
"logging."
)
# Sanity: we did log something — proving the test exercised the
# except branch rather than passing trivially.
assert "Error fetching Google Gemini models" in loguru_caplog.text
def test_no_leak_when_safe_get_raises_generic_runtime_error(
self, loguru_caplog, google_provider_module
):
"""Some upstream failures raise a generic exception whose ``str()``
contains the URL. Redaction must handle that path too.
"""
import local_deep_research.security as sec_pkg
exc = RuntimeError(
f"upstream failure calling /v1beta/models?key={_LEAKED_KEY}"
)
with loguru_caplog.at_level("DEBUG"):
with patch.object(sec_pkg, "safe_get", side_effect=exc):
google_provider_module.GoogleProvider.list_models_for_api(
api_key=_LEAKED_KEY
)
assert _LEAKED_KEY not in loguru_caplog.text
def test_non_200_response_does_not_leak_key(
self, loguru_caplog, google_provider_module
):
"""The status-code branch must also not surface the URL. The
existing warning at line 88-90 only includes ``response.status_code``
— verify that contract holds.
"""
import local_deep_research.security as sec_pkg
class _Resp:
status_code = 503
text = "upstream busy"
def json(self):
return {}
with loguru_caplog.at_level("DEBUG"):
with patch.object(sec_pkg, "safe_get", return_value=_Resp()):
result = (
google_provider_module.GoogleProvider.list_models_for_api(
api_key=_LEAKED_KEY
)
)
assert result == []
assert _LEAKED_KEY not in loguru_caplog.text
class TestCredentialStoreKeyLeakage:
"""Pin no-leak behavior in the credential store base class. The class
is a small wrapper around a dict; verify ``__repr__``,
``__str__``, and any exception paths do not expose stored secrets.
"""
def test_repr_does_not_expose_stored_passwords(self):
from local_deep_research.scheduler.background import (
SchedulerCredentialStore,
)
store = SchedulerCredentialStore(ttl_hours=1)
store.store("alice", _LEAKED_KEY)
# repr / str must not expose the password
assert _LEAKED_KEY not in repr(store)
assert _LEAKED_KEY not in str(store)
def test_clear_entry_does_not_log_store_state(self, loguru_caplog):
"""``clear_entry`` must be completely silent. The implementation
in ``credential_store_base.py`` does not call ``logger`` at all
— this test pins that contract so a future
``logger.debug(f"store contents: {self._store}")`` (which would
expose every stored credential) is caught immediately. Exercises
both the present-key and missing-key paths and asserts not just
the leaked sentinel but that *no records were emitted at all*.
"""
from local_deep_research.scheduler.background import (
SchedulerCredentialStore,
)
store = SchedulerCredentialStore(ttl_hours=1)
store.store("alice", _LEAKED_KEY)
store.store("bob", "another-stored-secret-87654321")
with loguru_caplog.at_level("DEBUG"):
store.clear_entry("never-stored-user")
store.clear_entry("alice")
assert not loguru_caplog.records, (
"clear_entry must be silent. Got log records: "
f"{[r.getMessage() for r in loguru_caplog.records]}"
)
assert _LEAKED_KEY not in loguru_caplog.text
class TestOpenAICompatErrorRedaction:
"""The OpenAI-compat error helper at
``src/local_deep_research/error_handling/openai_compat_errors.py``
runs ``_strip_credentials`` on ``base_url`` and appends ``{exc!s}`` to
the returned friendly message. Verify that an embedded-credential
base URL is stripped from the final string.
"""
def test_friendly_error_strips_credentials_from_base_url(self):
from local_deep_research.error_handling.openai_compat_errors import (
friendly_openai_compatible_error,
)
# Some users embed API keys in the base URL itself
embedded_url = f"https://user:{_LEAKED_KEY}@host.example.com/v1"
exc = RuntimeError("upstream failed") # exc!s does NOT contain the key
result = friendly_openai_compatible_error(
exc,
provider="lmstudio",
base_url=embedded_url,
model="some-model",
)
assert _LEAKED_KEY not in result, (
"_strip_credentials must remove userinfo from base_url before "
"the URL is embedded in the friendly message"
)
class TestOpenAIBaseProviderKeyLeakage:
"""``OpenAICompatibleProvider.list_models`` wraps the inner
``list_models_for_api`` call. If a subclass override raises (or the
settings-fetch path raises while the api_key is in scope), the
upstream exception's ``str()`` may embed the key. The except block at
``openai_base.py`` redacts the key from the exception string and uses
``logger.warning`` (no traceback) to keep the cause chain off the log.
These tests use ``loguru_caplog_full`` — the stricter fixture that
captures the rendered exception block — so a leak that lives only in
the traceback would be caught. The vanilla ``loguru_caplog`` fixture
uses ``format='{message}'`` and would false-pass on a traceback leak.
"""
def test_no_leak_when_inner_call_raises_with_url_in_message(
self, loguru_caplog_full
):
"""A subclass override of ``list_models_for_api`` that constructs
the auth URL with the key in a query parameter (the Google
pattern) and then raises a ``requests`` exception embedding that
URL must not leak the key.
"""
from local_deep_research.llm.providers.openai_base import (
OpenAICompatibleProvider,
)
exc = requests.exceptions.ConnectionError(
"HTTPSConnectionPool: Max retries exceeded with url: "
f"/v1/models?key={_LEAKED_KEY}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(
OpenAICompatibleProvider,
"list_models_for_api",
side_effect=exc,
):
with patch.object(
OpenAICompatibleProvider,
"requires_auth_for_models",
return_value=False,
):
with patch(
"local_deep_research.config.thread_settings."
"get_setting_from_snapshot",
return_value=_LEAKED_KEY,
):
# Subclass override drives auth-required to True so
# api_key flows through the settings path.
OpenAICompatibleProvider.requires_auth_for_models = (
classmethod(lambda cls: True)
)
try:
result = OpenAICompatibleProvider.list_models()
finally:
del OpenAICompatibleProvider.requires_auth_for_models
assert result == []
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"API key leaked into logs as encoding {encoding!r}. "
f"The except handler must pass the key to redact_secrets "
f"and use logger.warning (not logger.exception)."
)
# Sanity: we did exercise the except branch.
assert "Error listing models" in loguru_caplog_full.text
def test_no_leak_when_inner_call_raises_generic_runtime_error(
self, loguru_caplog_full
):
"""Some upstream failures raise generic exceptions whose ``str()``
embeds the URL. Redaction must handle that path too — not just
``requests`` exception subclasses.
"""
from local_deep_research.llm.providers.openai_base import (
OpenAICompatibleProvider,
)
exc = RuntimeError(
f"call to /v1/models?key={_LEAKED_KEY} failed: connection reset"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(
OpenAICompatibleProvider,
"list_models_for_api",
side_effect=exc,
):
with patch(
"local_deep_research.config.thread_settings."
"get_setting_from_snapshot",
return_value=_LEAKED_KEY,
):
OpenAICompatibleProvider.requires_auth_for_models = (
classmethod(lambda cls: True)
)
try:
OpenAICompatibleProvider.list_models()
finally:
del OpenAICompatibleProvider.requires_auth_for_models
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"API key leaked as encoding {encoding!r}"
)
class TestOpenAIEmbeddingProviderKeyLeakage:
"""``OpenAIEmbeddingsProvider.get_available_models`` calls the OpenAI
SDK's ``client.models.list()`` with the api_key in scope. If the SDK
raises (e.g., the base_url points at a misconfigured proxy that
echoes the bearer token in its error body), the except handler at
``embeddings/providers/implementations/openai.py`` must redact the
key before logging.
"""
def test_no_leak_when_models_list_raises_with_key_in_message(
self, loguru_caplog_full
):
from local_deep_research.embeddings.providers.implementations.openai import (
OpenAIEmbeddingsProvider,
)
def _settings_lookup(key, default=None, settings_snapshot=None):
if key == "embeddings.openai.api_key":
return _LEAKED_KEY
if key == "embeddings.openai.base_url":
return None
return default
exc = RuntimeError(
f"upstream proxy echoed Authorization: Bearer {_LEAKED_KEY}"
)
class _Models:
def list(self_inner):
raise exc
class _FakeOpenAI:
def __init__(self, **kwargs):
self.models = _Models()
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.embeddings.providers.implementations."
"openai.get_setting_from_snapshot",
side_effect=_settings_lookup,
):
with patch("openai.OpenAI", _FakeOpenAI):
result = OpenAIEmbeddingsProvider.get_available_models()
assert result == []
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"API key leaked as encoding {encoding!r}. The except "
f"handler at openai.py:218 must redact via "
f"redact_secrets(str(e), api_key) and use logger.warning."
)
# Sanity: the except branch ran.
assert (
"Error fetching OpenAI embedding models" in loguru_caplog_full.text
)
class TestSearchEngineKeyLeakage:
"""Pin no-leak behavior across representative search engines whose
HTTP-call error paths were wrapped in #4131. The base-class fix in
``search_engine_base.py`` covers the catch-all path, but engines
that catch+swallow inside ``_get_previews`` need their own wrap —
these tests cover those direct call sites.
"""
def test_tavily_get_previews_does_not_leak_on_request_exception(
self, loguru_caplog_full
):
"""Tavily's ``_get_previews`` catches
``requests.exceptions.RequestException`` and logs it. The except
block must redact ``self.api_key`` from the rendered exception
text. We drive this by patching ``safe_post`` to raise an
exception whose ``str()`` embeds the sentinel in the URL.
Tavily is the representative engine for this contract; the
base-class fix in ``search_engine_base.py`` covers the
catch-all path for all subclasses (see ``run()``). Other
engines have engine-specific attribute requirements that make
a parametrized test brittle without a fuller fixture suite —
tracked as follow-up to #4131.
"""
from local_deep_research.web_search_engines.engines import (
search_engine_tavily as mod,
)
EngineCls = mod.TavilySearchEngine
engine_class = "TavilySearchEngine"
api_attr = "api_key"
# Build an instance without going through normal __init__ — the
# engines have settings-dependent __init__ paths that complicate
# test setup. Provide every attribute referenced before safe_post
# so the test actually reaches the leak-vector code path.
engine = EngineCls.__new__(EngineCls)
setattr(engine, api_attr, _LEAKED_KEY)
engine.max_results = 10
engine.search_depth = "basic"
engine.include_full_content = False
engine.include_domains = []
engine.exclude_domains = []
engine.base_url = "https://api.example.com"
engine.engine_type = "test_engine"
engine._search_results = None
# Bypass the rate-limit-tracker dependency — see _stub_rate_tracker.
# Tavily/Exa each call ``self.rate_tracker.apply_rate_limit(...)``
# before the HTTP request.
engine.rate_tracker = _stub_rate_tracker()
# ``_raise_if_rate_limit`` is a base-class method that inspects
# the response status code; with a non-HTTP exception passed in,
# it must not itself raise. Stub it to a no-op for safety.
engine._raise_if_rate_limit = lambda *a, **k: None
exc = requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: Max retries exceeded with url: "
f"/v1/search?api_key={_LEAKED_KEY}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(mod, "safe_post", side_effect=exc, create=True):
try:
engine._get_previews("test query")
except Exception:
pass
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"{engine_class} leaked api_key as encoding {encoding!r}. "
f"The except block must call "
f"redact_secrets(str(e), self.{api_attr}) before logging."
)
# Sanity: the except branch must have actually run — otherwise the
# leak assertion passes trivially.
assert "Error getting Tavily" in loguru_caplog_full.text, (
f"{engine_class} test did not exercise the except branch. "
f"Captured logs: {loguru_caplog_full.text!r}"
)
def test_nasa_ads_get_previews_does_not_leak_on_request_exception(
self, loguru_caplog_full
):
"""NASA ADS sends its key as an ``Authorization: Bearer`` header
(``self.headers``) and its ``_get_previews`` catch-all logged the
exception unredacted. This engine was *not* in the original #4131
engine list — it is covered by the #4131 follow-up. The except
block must redact ``self.api_key`` from the rendered exception
text. We drive this by patching ``safe_get`` to raise an exception
whose ``str()`` embeds the sentinel in the URL.
"""
from local_deep_research.web_search_engines.engines import (
search_engine_nasa_ads as mod,
)
EngineCls = mod.NasaAdsSearchEngine
engine = EngineCls.__new__(EngineCls)
engine.api_key = _LEAKED_KEY
engine.headers = {"Authorization": f"Bearer {_LEAKED_KEY}"}
engine.api_base = "https://api.adsabs.harvard.edu/v1"
engine.max_results = 10
engine.sort_by = "relevance"
engine.from_publication_date = None
engine.min_citations = 0
engine.include_arxiv = True
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
exc = requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: Max retries exceeded with url: "
f"/v1/search/query?token={_LEAKED_KEY}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(mod, "safe_get", side_effect=exc, create=True):
try:
engine._get_previews("test query")
except Exception:
pass
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
"NASAADSSearchEngine leaked api_key as encoding "
f"{encoding!r}. The except block must call "
"redact_secrets(str(e), self.api_key) before logging."
)
# Sanity: the except branch must have actually run.
assert "Error searching NASA ADS" in loguru_caplog_full.text, (
"NASA ADS test did not exercise the except branch. "
f"Captured logs: {loguru_caplog_full.text!r}"
)
def test_exa_get_previews_does_not_leak_header_key(
self, loguru_caplog_full
):
"""Exa sends its key in the ``x-api-key`` header. After the
_scrub_error centralization, the catch block relies on the literal
pass (self.api_key via _secret_attrs) — the regex cannot scrub a raw
header value. Use an OPAQUE key embedded header-style so only the
literal pass can redact it; this pins that exa keeps its net.
"""
from local_deep_research.web_search_engines.engines import (
search_engine_exa as mod,
)
engine = mod.ExaSearchEngine.__new__(mod.ExaSearchEngine)
engine.api_key = _LEAKED_OPAQUE_KEY
engine.max_results = 10
engine.search_type = "auto"
engine.include_domains = []
engine.exclude_domains = []
engine.start_published_date = None
engine.end_published_date = None
engine.category = None
engine.include_full_content = False
engine.base_url = "https://api.exa.ai"
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine._raise_if_rate_limit = lambda *a, **k: None
exc = requests.exceptions.ConnectionError(
f"request failed; sent x-api-key: {_LEAKED_OPAQUE_KEY}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(mod, "safe_post", side_effect=exc, create=True):
try:
engine._get_previews("test query")
except Exception:
pass
for encoding in _all_encodings_of(_LEAKED_OPAQUE_KEY):
assert encoding not in loguru_caplog_full.text, (
f"ExaSearchEngine leaked header api_key as {encoding!r}."
)
assert "Error getting Exa results" in loguru_caplog_full.text
def test_serper_get_previews_does_not_leak_header_key(
self, loguru_caplog_full
):
"""Serper sends its key in the ``X-API-KEY`` header — same literal-pass
contract as exa. Opaque key embedded header-style."""
from local_deep_research.web_search_engines.engines import (
search_engine_serper as mod,
)
engine = mod.SerperSearchEngine.__new__(mod.SerperSearchEngine)
engine.api_key = _LEAKED_OPAQUE_KEY
engine.max_results = 10
engine.region = "us"
engine.search_language = "en"
engine.time_period = None
engine.base_url = "https://google.serper.dev/search"
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine._raise_if_rate_limit = lambda *a, **k: None
exc = requests.exceptions.ConnectionError(
f"request failed; sent X-API-KEY: {_LEAKED_OPAQUE_KEY}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(mod, "safe_post", side_effect=exc, create=True):
try:
engine._get_previews("test query")
except Exception:
pass
for encoding in _all_encodings_of(_LEAKED_OPAQUE_KEY):
assert encoding not in loguru_caplog_full.text, (
f"SerperSearchEngine leaked header api_key as {encoding!r}."
)
assert "Error getting Serper API results" in loguru_caplog_full.text
class TestSearchEngineParamsKeyLeakage:
"""Pin no-leak behavior for search engines that pass the API key via
the ``params=`` dict to ``safe_get``. The underlying ``requests``
library assembles ``params`` into the request URL before the network
call; when ``requests`` raises (``ConnectionError``, ``Timeout``,
etc.), the exception's ``__str__()`` includes that assembled URL —
and therefore the key.
Each engine catches the exception in its own try/except and is
protected today by either ``_sanitize_error_message()``
(regex-based, defined on the base class) and/or
``redact_secrets()`` (literal-value substitution). This contract is
"safe by convention" — every catch site must remember the wrap. A
future refactor that consolidates error handling into a base method
without one of those calls would silently leak. The tests below
pin the no-leak property so such a regression flips the test.
Note on Guardian specifically: its key parameter is ``api-key`` (with
a dash), which the base-class regex at ``search_engine_base.py:984``
does NOT match (the regex covers ``api_key|apikey|key|token|secret``).
Guardian therefore relies entirely on ``redact_secrets()`` for the
URL-leak path — making it the most fragile of the four and the most
important to pin.
"""
@staticmethod
def _mojeek_engine():
from local_deep_research.web_search_engines.engines import (
search_engine_mojeek as mod,
)
engine = mod.MojeekSearchEngine.__new__(mod.MojeekSearchEngine)
engine.api_key = _LEAKED_KEY
engine.search_url = "https://api.mojeek.com/search"
engine.max_results = 10
engine.safe_search = True
engine.language = None
engine.region = None
return (
mod,
engine,
"_get_search_results",
"/search?api_key=",
"Error when searching using Mojeek",
)
@staticmethod
def _scaleserp_engine():
from local_deep_research.web_search_engines.engines import (
search_engine_scaleserp as mod,
)
engine = mod.ScaleSerpSearchEngine.__new__(mod.ScaleSerpSearchEngine)
engine.api_key = _LEAKED_KEY
engine.base_url = "https://api.scaleserp.com/search"
engine.max_results = 10
engine.location = "United States"
engine.language = "en"
engine.device = "desktop"
engine.safe_search = False
engine.enable_cache = False
engine.engine_type = "test_engine"
engine._knowledge_graph = None
engine._search_results = None
engine.rate_tracker = _stub_rate_tracker()
engine._raise_if_rate_limit = lambda *a, **k: None
return (
mod,
engine,
"_get_previews",
"/search?api_key=",
"Error getting ScaleSerp API results",
)
@staticmethod
def _google_pse_engine():
from local_deep_research.web_search_engines.engines import (
search_engine_google_pse as mod,
)
engine = mod.GooglePSESearchEngine.__new__(mod.GooglePSESearchEngine)
engine.api_key = _LEAKED_KEY
engine.search_engine_id = "test-cx"
engine.max_results = 10
engine.safe = "off"
engine.language = "en"
engine.region = "us"
engine.max_retries = 1 # single attempt — no real sleep, no real retry
engine.retry_delay = 0
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
# _make_request will re-raise as RequestException after max_retries
# — callers must catch that. Return the method name to invoke.
# The retry loop logs each attempt before re-raising, so the log
# marker is the per-attempt warning from line ~266.
return (
mod,
engine,
"_make_request",
"/customsearch/v1?key=",
"Request error on attempt",
)
@staticmethod
def _guardian_engine():
from local_deep_research.web_search_engines.engines import (
search_engine_guardian as mod,
)
engine = mod.GuardianSearchEngine.__new__(mod.GuardianSearchEngine)
engine.api_key = _LEAKED_KEY
engine.api_url = "https://content.guardianapis.com/search"
engine.max_results = 10
engine.from_date = "2024-01-01"
engine.to_date = "2024-12-31"
engine.order_by = "relevance"
engine.section = None
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine._raise_if_rate_limit = lambda *a, **k: None
return (
mod,
engine,
"_get_all_data",
"/search?api-key=",
"Error getting data from The Guardian API",
)
@staticmethod
def _pubmed_engine():
from local_deep_research.web_search_engines.engines import (
search_engine_pubmed as mod,
)
engine = mod.PubMedSearchEngine.__new__(mod.PubMedSearchEngine)
engine.api_key = _LEAKED_KEY
engine.search_url = (
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
)
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
return (
mod,
engine,
"_get_result_count",
"?api_key=",
"Error getting result count",
)
@pytest.mark.parametrize(
"engine_factory_name",
[
"_mojeek_engine",
"_scaleserp_engine",
"_google_pse_engine",
"_guardian_engine",
"_pubmed_engine",
],
)
def test_no_leak_when_safe_get_raises_with_assembled_url(
self, loguru_caplog_full, engine_factory_name
):
"""When ``safe_get`` raises a ``ConnectionError`` whose message
embeds the assembled URL (base + query string with the key from
``params=``), the engine's catch block must scrub the key before
logging.
Mutation check: temporarily remove the per-engine
``redact_secrets()`` / ``_sanitize_error_message()`` call from
the catch block and the relevant parametrized case will fail.
"""
factory = getattr(self, engine_factory_name)
mod, engine, method_name, url_path_with_key, marker = factory()
# Simulate the exception that requests raises when the network
# call fails — its message embeds the *assembled* URL with the
# query string built from `params=`.
exc = requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: Max retries exceeded with url: "
f"{url_path_with_key}{_LEAKED_KEY}&q=test"
)
# No create=True: every engine under test imports safe_get at
# module level, so a typo'd patch target (or a future refactor
# that drops the import) must fail loudly rather than silently
# no-op into a passing test.
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(
mod, "safe_get", side_effect=exc
) as mock_safe_get:
# Only swallow the request-layer exception we injected.
# Google PSE's _make_request re-raises as
# RequestException after retries; the other three engines
# catch internally and return []. AttributeError /
# TypeError from a bad test stub must propagate so the
# test fails loudly instead of silently skipping the
# logging/redaction path.
try:
getattr(engine, method_name)("test query")
except requests.exceptions.RequestException:
pass
# Sanity: the SUT must have actually called safe_get.
assert mock_safe_get.called, (
f"{engine_factory_name}: safe_get was not invoked — the "
f"redaction path under test never ran. Check that "
f"{mod.__name__} still imports safe_get at module level."
)
# Sanity: the SUT must have actually logged the catch-block warning.
# Without this, the leak assertion above passes vacuously if the
# catch block is removed or refactored to silently re-raise.
assert marker in loguru_caplog_full.text, (
f"{engine_factory_name}: catch-block log marker {marker!r} not "
f"emitted — the redaction path under test never ran. Check that "
f"{mod.__name__} still wraps the safe_get call with a "
f"logger.warning(...) that includes the redacted message. "
f"Captured logs: {loguru_caplog_full.text!r}"
)
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"{engine_factory_name}: api_key leaked as encoding "
f"{encoding!r}. The catch block must scrub the key via "
f"redact_secrets(str(e), self.api_key) and/or "
f"self._sanitize_error_message(str(e)) before logging."
)
+346
View File
@@ -0,0 +1,346 @@
"""
API Security Tests
Tests that verify API endpoints follow security best practices,
based on OWASP API Security Top 10.
"""
import pytest
from tests.test_utils import add_src_to_path
add_src_to_path()
class TestAPISecurityOWASPTop10:
"""Test API security based on OWASP API Security Top 10 2023."""
@pytest.fixture
def client(self):
"""Create a test client."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
# Enable CORS for testing (tests expect open CORS)
app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = "*"
return app.test_client()
# API1:2023 - Broken Object Level Authorization (BOLA)
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_api1_broken_object_level_authorization(self, client):
"""
Test that users can only access their own objects.
BOLA/IDOR (Insecure Direct Object Reference):
- User A tries to access User B's research by changing research_id
- API should verify that user owns the requested object
"""
# Example vulnerable endpoint:
# GET /api/v1/quick_summary/{research_id}
# Without checking if current user owns research_id
# Test accessing research with different IDs
# Should return 403 Forbidden if not owned by user
# Should return 404 Not Found to avoid info leakage
# For LDR with per-user databases, this is mitigated by architecture
assert True # Architecture-level protection
# API2:2023 - Broken Authentication
def test_api2_broken_authentication(self, client):
"""
Test that API authentication is secure.
Common issues:
- Weak password requirements
- Credential stuffing
- Missing rate limiting
- Weak token generation
"""
# Test that protected API endpoints require authentication
protected_endpoints = [
"/api/v1/quick_summary",
"/api/v1/settings",
]
for endpoint in protected_endpoints:
# Try accessing without authentication
response = client.get(endpoint)
# Should be one of the auth-block codes:
# 401 Unauthorized, 403 Forbidden, 404 Not Found (route hidden),
# 405 Method Not Allowed (endpoint only accepts POST).
# MUST NOT be 200 — that would mean the endpoint is reachable
# without authentication, which is what this OWASP API2 test
# exists to catch.
assert response.status_code in {401, 403, 404, 405}, (
f"{endpoint} returned {response.status_code} without auth"
)
# API4:2023 - Unrestricted Resource Consumption
def test_api4_unrestricted_resource_consumption(self, client):
"""
Test protection against resource exhaustion attacks.
Attack vectors:
- Extremely large requests
- Too many simultaneous requests
- Expensive operations without limits
"""
# Test large payload
large_query = "test query " * 100000 # Very large query
response = client.post(
"/api/v1/quick_summary",
json={"query": large_query},
content_type="application/json",
)
# Should have request size limit (413 Payload Too Large)
# Or validate query length (400/422)
# Current implementation may return 500 for edge cases
assert response.status_code == 401, response.status_code
# Test rate limiting (if implemented)
# Make many requests rapidly
for i in range(100):
response = client.get("/api/v1/health")
# Should eventually rate limit (429 Too Many Requests)
# Rate limiting may not be enabled in development
pass
# API5:2023 - Broken Function Level Authorization
def test_api5_broken_function_level_authorization(self, client):
"""
Test that administrative functions require admin privileges.
Common issues:
- Admin endpoints accessible to regular users
- Missing authorization checks on sensitive functions
"""
# Admin functions that should require elevated privileges:
admin_endpoints = [
"/api/v1/admin/users",
"/api/v1/admin/settings",
"/api/v1/admin/logs",
]
for endpoint in admin_endpoints:
response = client.get(endpoint)
# Should return 401 Unauthorized, 403 Forbidden, or 404 Not Found
assert response.status_code == 404, response.status_code
# API6:2023 - Unrestricted Access to Sensitive Business Flows
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_api6_unrestricted_sensitive_flows(self, client):
"""
Test protection of sensitive business logic flows.
Examples:
- Account deletion without verification
- Mass data export without limits
- Automated scraping/abuse
"""
# For LDR, sensitive flows might include:
# - Deleting all research history
# - Exporting all data
# - Automated research generation (resource intensive)
# These should have:
# - Confirmation required
# - Rate limiting
# - CAPTCHA for automated abuse prevention
pass # Implementation-specific
# API8:2023 - Security Misconfiguration
def test_api8_security_misconfiguration(self, client):
"""
Test for common security misconfigurations.
Common issues:
- Debug mode enabled in production
- Verbose error messages
- Missing security headers
- Default credentials
"""
# Test that debug mode is off
response = client.get("/api/v1/health")
data = response.get_json()
# Should not expose debug information
assert "debug" not in str(data).lower() or not data.get("debug")
# Test error responses don't leak sensitive info
response = client.get("/api/v1/nonexistent")
# Should return generic error, not stack trace
# 401 is also acceptable (auth guard rejects before routing)
assert response.status_code == 404, response.status_code
# API9:2023 - Improper Inventory Management
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_api9_improper_inventory_management(self):
"""
Test API documentation and version management.
Issues:
- Undocumented API endpoints
- Old API versions still accessible
- Deprecated endpoints without sunset dates
- Shadow APIs (forgotten endpoints)
"""
# This is primarily a documentation/process issue
# Verify:
# - API endpoints are documented
# - Old versions are deprecated properly
# - API versioning is clear (/api/v1/, /api/v2/)
assert True # Documentation/process test
# API10:2023 - Unsafe Consumption of APIs
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_api10_unsafe_consumption_of_apis(self, client):
"""
Test secure consumption of external APIs.
LDR consumes external APIs:
- Search engines
- Wikipedia
- Web scraping targets
Risks:
- Malicious responses from external APIs
- Injection attacks via external data
- Excessive trust in external data
"""
# External API responses should be:
# - Validated (schema/type checking)
# - Sanitized (remove dangerous content)
# - Size-limited (prevent memory exhaustion)
# - Timeout-protected (prevent hanging)
assert True # Implementation-specific
class TestAPIInputValidation:
"""Test API input validation."""
@pytest.fixture
def client(self):
"""Create a test client."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
# Enable CORS for testing (tests expect open CORS)
app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = "*"
return app.test_client()
def test_json_parsing_errors_handled(self, client):
"""Test that malformed JSON is rejected gracefully."""
# Send invalid JSON
response = client.post(
"/api/v1/quick_summary",
data="{ invalid json }",
content_type="application/json",
)
# Should return 400 Bad Request (or 401 if auth guard rejects first)
assert response.status_code == 401, response.status_code
def test_missing_required_fields_rejected(self, client):
"""Test that requests with missing required fields are rejected."""
# Send request without required field
response = client.post(
"/api/v1/quick_summary",
json={}, # Missing 'query' field
content_type="application/json",
)
# Should return 400 Bad Request or 422 Unprocessable Entity
# 401 is also valid (auth guard rejects unauthenticated requests)
assert response.status_code == 401, response.status_code
def test_invalid_data_types_rejected(self, client):
"""Test that invalid data types are rejected."""
# Send wrong data type
response = client.post(
"/api/v1/quick_summary",
json={"query": 12345}, # Should be string, not number
content_type="application/json",
)
# Should validate data types and reject non-string query
# 401 is also valid (auth guard rejects unauthenticated requests)
assert response.status_code == 401, response.status_code
def test_boundary_value_validation(self, client):
"""Test validation of boundary values."""
boundary_tests = [
{"query": ""}, # Empty string
{"query": "a" * 10000}, # Very long string
{"query": None}, # Null value
]
for test_data in boundary_tests:
response = client.post(
"/api/v1/quick_summary",
json=test_data,
content_type="application/json",
)
# Should validate and reject invalid inputs (400/422)
# 401 is also valid (auth guard rejects unauthenticated requests)
assert response.status_code == 401, response.status_code
class TestAPIRateLimiting:
"""Test API rate limiting (if implemented)."""
@pytest.fixture
def client(self):
"""Create a test client."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
# Enable CORS for testing (tests expect open CORS)
app.config["SECURITY_CORS_ALLOWED_ORIGINS"] = "*"
return app.test_client()
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_api_security_documentation():
"""
Documentation test for API security best practices.
OWASP API Security Top 10 2023:
1. Broken Object Level Authorization (BOLA)
2. Broken Authentication
3. Broken Object Property Level Authorization
4. Unrestricted Resource Consumption
5. Broken Function Level Authorization
6. Unrestricted Access to Sensitive Business Flows
7. Server Side Request Forgery (SSRF)
8. Security Misconfiguration
9. Improper Inventory Management
10. Unsafe Consumption of APIs
LDR-Specific API Security Considerations:
- Research API endpoints handle user queries
- External data fetching (SSRF risk)
- Resource-intensive operations (DoS risk)
- Per-user database isolation (BOLA mitigation)
Recommended Security Controls:
- Input validation on all API endpoints
- Rate limiting on expensive operations
- URL whitelist for external fetching
- Request size limits
- Proper error handling (no info leakage)
- API versioning and documentation
- Authentication on protected endpoints
- Authorization checks on object access
"""
assert True # Documentation test
+39
View File
@@ -0,0 +1,39 @@
# allow: no-sut-import — black-box HTTP test; drives real routes through the Flask test client
"""Test API v1 authentication guard."""
class TestApiV1Auth:
"""Test that /api/v1/ endpoints require authentication."""
def test_unauthenticated_returns_401(self, client):
"""Requests with no username (no g.current_user, no session) get 401."""
response = client.get("/api/v1/")
assert response.status_code == 401
data = response.get_json()
assert data["error"] == "Authentication required"
def test_empty_username_returns_401(self, client, app):
"""An empty string username should still be rejected."""
with client.session_transaction() as sess:
sess["username"] = ""
response = client.get("/api/v1/")
assert response.status_code == 401
def test_authenticated_via_g_current_user(self, authenticated_client):
"""A request with g.current_user set passes the auth guard."""
response = authenticated_client.get("/api/v1/")
# Should not be 401 — may be 200 or other status depending on
# downstream logic, but the auth guard itself should pass.
assert response.status_code != 401
def test_authenticated_via_session(self, authenticated_client):
"""A request with a valid session username passes the auth guard."""
# authenticated_client already has a registered user in the session
response = authenticated_client.get("/api/v1/")
assert response.status_code != 401
def test_health_endpoint_no_auth_required(self, client):
"""The /api/v1/health endpoint does not use @api_access_control."""
response = client.get("/api/v1/health")
# Health should return 200 even without authentication
assert response.status_code == 200
+328
View File
@@ -0,0 +1,328 @@
"""
Authentication Security Tests
Tests that verify authentication mechanisms are secure, including
password storage, session management, and access control.
"""
import pytest
from tests.test_utils import add_src_to_path
add_src_to_path()
class TestPasswordSecurity:
"""Test password security and hashing."""
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_password_hashing_uses_secure_algorithm(self):
"""
Test that passwords are hashed using a secure algorithm.
LDR uses SQLCipher encryption for user databases.
"""
# LDR uses SQLCipher with user password as encryption key
# This means the password is used to encrypt the database
# Not stored as a hash, but used for encryption
# Verify that password is not stored in plaintext
# Verify that database encryption key derivation is secure
assert True # Documentation test - SQLCipher handles this
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_password_minimum_requirements(self):
"""Test that password requirements are enforced (if applicable)."""
# Password requirements to consider:
# - Minimum length (e.g., 8-12 characters)
# - Complexity (uppercase, lowercase, numbers, symbols)
# - No common passwords
# - No username in password
# For local self-hosted tool, strict requirements may be optional
# User is responsible for their own security
# This is a documentation test for password policy
pass
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_password_not_logged(self):
"""Test that passwords are never logged or exposed in errors."""
# Passwords should never appear in:
# - Log files
# - Error messages
# - Debug output
# - Stack traces
# This is a security best practice
assert True # Documentation test
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_timing_attack_resistance(self):
"""
Test that authentication timing is constant to prevent timing attacks.
Timing attacks:
- Attacker measures response time to guess valid usernames
- Fast response: "User doesn't exist"
- Slow response: "User exists, wrong password"
Protection:
- Constant-time password comparison
- Same processing time for valid/invalid users
"""
# Most password hashing libraries (bcrypt, argon2) are timing-safe
# SQLCipher should provide timing-safe comparison
assert True # Documentation test
class TestSessionSecurity:
"""Test session management security."""
@pytest.fixture
def client(self):
"""Create a test client."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
app.config["SECRET_KEY"] = "test-secret-key"
app.config["WTF_CSRF_ENABLED"] = False
return app.test_client()
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_session_expiration(self, client):
"""Test that sessions expire appropriately."""
# Sessions should:
# - Expire after inactivity timeout
# - Have absolute maximum lifetime
# - Be invalidated on logout
# This prevents:
# - Session hijacking
# - Unauthorized access from old sessions
# - Session fixation attacks
pass # Placeholder - implementation depends on session manager
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_session_regeneration_on_login(self):
"""Test that session ID is regenerated after login."""
# Session fixation attack prevention:
# 1. Attacker sets victim's session ID
# 2. Victim logs in with that session ID
# 3. Attacker uses same session ID to access victim's account
# Protection: Regenerate session ID after authentication
# Flask does this automatically on session modification
assert True # Documentation test
def test_logout_invalidates_session(self, client):
"""Test that logout completely invalidates the session."""
# Logout should:
# 1. Clear session data
# 2. Invalidate session token
# 3. Clear session cookies
# 4. Redirect to login page
# Test logout endpoint
response = client.post("/auth/logout")
assert response.status_code == 302, response.status_code
# After logout, protected pages should require re-authentication
# This is tested in access control tests
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_concurrent_session_handling(self):
"""Test handling of concurrent sessions."""
# Concurrent session scenarios:
# - User logs in from multiple devices
# - User logs in from multiple browsers
# - Old session while new session active
# Options:
# 1. Allow multiple sessions (lower security, better UX)
# 2. Invalidate old session on new login (higher security)
# 3. Limit number of concurrent sessions
# For local tool, multiple sessions may be acceptable
assert True # Documentation test
class TestAccessControl:
"""Test access control and authorization."""
@pytest.fixture
def client(self):
"""Create a test client."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
return app.test_client()
def test_unauthenticated_access_blocked(self, client):
"""Test that protected resources require authentication."""
# Protected pages that should redirect to login:
# - Research pages
# - Settings pages
# - User data pages
# Public pages that don't require auth:
# - Login page
# - Registration page (if enabled)
# - Health check endpoint
# Test accessing protected resource without auth
protected_endpoints = [
"/research",
"/settings",
"/api/v1/research",
]
for endpoint in protected_endpoints:
response = client.get(endpoint)
# Should be one of the auth-block codes:
# 302 Redirect to login, 401 Unauthorized, 403 Forbidden,
# 404 Not Found (route hidden behind decorator).
# MUST NOT be 200 — that would mean the endpoint is reachable
# without authentication.
assert response.status_code in {302, 401, 403, 404}, (
f"{endpoint} returned {response.status_code} without auth"
)
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_authentication_required_decorator(self):
"""Test that @login_required decorator is used on protected routes."""
# Flask routes should use authentication decorators:
# - @login_required for authenticated routes
# - Session validation on each request
# This is enforced through code review and testing
assert True # Documentation test
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_authorization_vs_authentication(self):
"""
Clarify difference between authentication and authorization.
Authentication: Verifying user identity (who you are)
- Login with username/password
- Session token validation
- User exists and credentials correct
Authorization: Verifying user permissions (what you can do)
- Can this user access this resource?
- Does user have required role/permissions?
- Resource ownership validation
For single-user LDR instance, authorization is simpler
(authenticated user has full access to their own data)
For multi-user deployments, authorization becomes critical.
"""
assert True # Documentation test
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_user_data_isolation(self):
"""Test that users can only access their own data."""
# In multi-user scenario:
# - User A should not access User B's research
# - Database queries should filter by user
# - User-specific encryption (SQLCipher per-user databases)
# LDR uses per-user encrypted databases
# This provides strong data isolation
assert True # Documentation test
class TestAuthenticationEdgeCases:
"""Test edge cases and attack scenarios."""
@pytest.fixture
def client(self):
"""Create a test client."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
return app.test_client()
def test_sql_injection_in_authentication(self, client):
"""Test that authentication is protected against SQL injection."""
# SQL injection in login form
sql_injection_usernames = [
"admin' OR '1'='1",
"admin'--",
"' OR '1'='1'--",
"admin' OR 1=1--",
]
for username in sql_injection_usernames:
response = client.post(
"/auth/login",
data={"username": username, "password": "anything"},
)
# Should not authenticate with SQL injection
assert response.status_code == 401, response.status_code
def test_empty_credentials_handling(self, client):
"""Test that empty username/password are rejected."""
# Empty username
response1 = client.post(
"/auth/login",
data={"username": "", "password": "password"},
)
assert response1.status_code in [400, 401]
# Empty password
response2 = client.post(
"/auth/login",
data={"username": "admin", "password": ""},
)
assert response2.status_code in [400, 401]
# Both empty
response3 = client.post(
"/auth/login",
data={"username": "", "password": ""},
)
assert response3.status_code in [400, 401]
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_authentication_security_documentation():
"""
Documentation test for authentication security in LDR.
Authentication Architecture:
- SQLCipher encrypted per-user databases
- User password = database encryption key
- No centralized user authentication database
- Each user has their own encrypted database
Security Properties:
- Strong encryption (SQLCipher)
- Password not stored, used as encryption key
- Data at rest encryption
- User data isolation (separate databases)
Threat Model:
- Low risk for local single-user deployment
- Medium risk if deployed as multi-user service
- High risk if exposed to internet without additional protection
Additional Security Measures:
- HTTPS for production deployment
- Firewall/VPN for remote access
- Backup encryption
- Secure key derivation (SQLCipher built-in)
Not Applicable (due to architecture):
- Password hashing/salting (password IS the encryption key)
- Centralized user management
- OAuth/SSO integration
"""
assert True # Documentation test
+191
View File
@@ -0,0 +1,191 @@
"""Tests for the check-bearer-disable pre-commit guard.
The guard rejects `bearer:disable` directives that Bearer silently ignores:
same-line directives and directives with trailing prose after the rule id.
It must NOT flag well-formed directives or mere prose mentions.
"""
# allow: no-sut-import — the SUT is a pre-commit hook script under
# .pre-commit-hooks/, not a local_deep_research module; it is loaded via
# importlib below.
import importlib.util
from pathlib import Path
_HOOK = (
Path(__file__).resolve().parents[2]
/ ".pre-commit-hooks"
/ "check-bearer-disable.py"
)
def _load():
spec = importlib.util.spec_from_file_location("check_bearer_disable", _HOOK)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
guard = _load()
# --- Python (#) -----------------------------------------------------------
def test_py_valid_bare_directive_passes():
src = "# bearer:disable python_lang_sql_injection\nx = 1\n"
assert guard._check_python(src) == []
def test_py_same_line_flagged():
src = 'run(f"...{t}") # noqa: S608 # bearer:disable python_lang_sql_injection\n'
errors = guard._check_python(src)
assert len(errors) == 1
assert "same-line" in errors[0][1]
def test_py_trailing_prose_flagged():
src = (
"# bearer:disable python_lang_sql_injection -- because reasons\nx = 1\n"
)
errors = guard._check_python(src)
assert len(errors) == 1
assert "trailing text" in errors[0][1]
def test_py_emdash_trailing_prose_flagged():
src = (
"# bearer:disable python_lang_sql_injection — because reasons\nx = 1\n"
)
assert len(guard._check_python(src)) == 1
def test_py_docstring_mention_not_flagged():
# A directive quoted inside a docstring must not be treated as real.
src = (
"def f():\n"
' """Carries a ``# bearer:disable python_lang_sql_injection`` note."""\n'
" return 1\n"
)
assert guard._check_python(src) == []
def test_py_comment_prose_mention_not_flagged():
# An own-line comment that mentions the directive in backticked prose.
src = "# suppressed with ``# bearer:disable python_lang_sql_injection`` above\nx = 1\n"
assert guard._check_python(src) == []
def test_py_missing_rule_id_flagged():
src = "# bearer:disable\nx = 1\n"
errors = guard._check_python(src)
assert len(errors) == 1
assert "missing a rule id" in errors[0][1]
# --- JavaScript (//) ------------------------------------------------------
def test_js_valid_bare_directive_passes():
src = "// bearer:disable javascript_lang_dangerous_insert_html\nel.innerHTML = x;\n"
assert guard._check_js(src) == []
def test_js_same_line_flagged():
src = "el.innerHTML = x; // bearer:disable javascript_lang_dangerous_insert_html\n"
errors = guard._check_js(src)
assert len(errors) == 1
assert "same-line" in errors[0][1]
def test_js_trailing_prose_flagged():
src = " // bearer:disable javascript_lang_open_redirect -- hardcoded path\nfoo();\n"
errors = guard._check_js(src)
assert len(errors) == 1
assert "trailing text" in errors[0][1]
def test_js_nested_prose_mention_not_flagged():
src = " // see the // bearer:disable rule note above\nfoo();\n"
assert guard._check_js(src) == []
def test_js_url_with_double_slash_not_misflagged():
src = ' const u = "https://example.com/x";\n'
assert guard._check_js(src) == []
# --- Hardening cases (from adversarial review) ----------------------------
def test_py_prose_mention_on_code_line_not_flagged():
# A code line whose comment merely mentions the text in prose (no embedded
# `# bearer:disable`) must not be treated as a same-line directive.
src = "cursor.execute(safe_q) # parametrized; no bearer:disable needed\n"
assert guard._check_python(src) == []
def test_py_bom_bare_directive_not_flagged(tmp_path):
# A UTF-8 BOM must not make a valid own-line directive look same-line.
# Exercises the real check_file() path (utf-8-sig read strips the BOM).
p = tmp_path / "bom.py"
p.write_bytes(
b"\xef\xbb\xbf# bearer:disable python_lang_sql_injection\nx = 1\n"
)
assert guard.check_file(str(p)) == []
def test_py_lowercase_trailing_prose_flagged():
src = "# bearer:disable python_lang_sql_injection because reasons\nx = 1\n"
assert len(guard._check_python(src)) == 1
def test_py_comma_separated_rule_ids_pass():
src = (
"# bearer:disable python_lang_one_two, python_lang_three_four\nx = 1\n"
)
assert guard._check_python(src) == []
def test_js_directive_in_string_literal_not_flagged():
# A `// bearer:disable ...` inside a string is not a comment.
src = 'const H = "use // bearer:disable rule_x_y to suppress";\n'
assert guard._check_js(src) == []
def test_js_same_line_with_url_in_string_flagged():
# The `//` inside the URL string must not hide the genuine same-line dir.
src = (
'el.innerHTML = `<a href="https://x">${d}</a>`;'
" // bearer:disable javascript_lang_dangerous_insert_html\n"
)
errors = guard._check_js(src)
assert len(errors) == 1
assert "same-line" in errors[0][1]
def test_js_lowercase_trailing_prose_flagged():
src = "// bearer:disable javascript_lang_dangerous_insert_html trusted input\nf();\n"
assert len(guard._check_js(src)) == 1
def test_js_block_comment_directive_flagged():
src = "/* bearer:disable javascript_lang_dangerous_insert_html */\nf();\n"
errors = guard._check_js(src)
assert len(errors) == 1
assert "block comment" in errors[0][1]
def test_js_jsdoc_block_directive_flagged():
src = "/**\n * bearer:disable javascript_lang_dangerous_insert_html\n */\nf();\n"
errors = guard._check_js(src)
assert len(errors) == 1
assert "block comment" in errors[0][1]
def test_js_block_comment_prose_mention_not_flagged():
src = (
"/**\n * suppressed by inline directives at call sites; module-level\n"
" * would be ignored anyway.\n */\nf();\n"
)
assert guard._check_js(src) == []
@@ -0,0 +1,397 @@
"""
Tests for Bearer P0 security alert fixes (PR #1934).
Verifies:
- Shell injection fix: run_command(shell=True) removed from pre_prompt.py
- GPU detection uses list-based subprocess.run (no shell=True)
- open_file_location uses PathValidator for path validation
"""
import importlib.util
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
def _load_pre_prompt_module():
"""Load the pre_prompt module from the cookiecutter-docker hooks directory."""
module_path = (
Path(__file__).resolve().parents[2]
/ "cookiecutter-docker"
/ "hooks"
/ "pre_prompt.py"
)
spec = importlib.util.spec_from_file_location(
"pre_prompt", str(module_path)
)
module = importlib.util.module_from_spec(spec)
# Mock cookiecutter.prompt since it may not be installed in test env
sys.modules.setdefault("cookiecutter", MagicMock())
sys.modules.setdefault("cookiecutter.prompt", MagicMock())
spec.loader.exec_module(module)
return module
class TestRunCommandRemoved:
"""Verify that the insecure run_command function has been removed."""
def test_run_command_not_defined_in_module(self):
"""run_command(shell=True) must not exist in pre_prompt.py."""
module = _load_pre_prompt_module()
import inspect
module_functions = {
name
for name, obj in inspect.getmembers(module, inspect.isfunction)
if obj.__module__ == module.__name__
}
assert "run_command" not in module_functions, (
"run_command should be removed from pre_prompt.py "
"to fix shell injection"
)
def test_no_shell_true_in_source(self):
"""The pre_prompt.py source must not contain shell=True."""
module_path = (
Path(__file__).resolve().parents[2]
/ "cookiecutter-docker"
/ "hooks"
/ "pre_prompt.py"
)
source = module_path.read_text()
assert "shell=True" not in source, (
"pre_prompt.py must not use shell=True in any subprocess call"
)
class TestCheckGpuLinux:
"""Verify check_gpu_linux uses secure subprocess invocation."""
def test_calls_subprocess_run_with_list_args(self):
"""check_gpu_linux must call subprocess.run with a list, not a string."""
module = _load_pre_prompt_module()
context = {}
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=(
"00:02.0 VGA compatible controller: NVIDIA Corporation"
),
returncode=0,
)
module.check_gpu_linux(context)
mock_run.assert_called_once()
call_args = mock_run.call_args
cmd_arg = (
call_args[0][0] if call_args[0] else call_args[1].get("args")
)
assert isinstance(cmd_arg, list), (
f"subprocess.run must be called with a list, "
f"got {type(cmd_arg)}"
)
assert cmd_arg == ["lspci"]
def test_does_not_use_shell_true(self):
"""check_gpu_linux must not pass shell=True."""
module = _load_pre_prompt_module()
context = {}
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(stdout="", returncode=0)
module.check_gpu_linux(context)
call_kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {}
assert call_kwargs.get("shell") is not True, (
"check_gpu_linux must not use shell=True"
)
def test_detects_nvidia_gpu(self):
"""check_gpu_linux correctly detects NVIDIA GPU from lspci output."""
module = _load_pre_prompt_module()
context = {}
nvidia_output = (
"00:02.0 VGA compatible controller: "
"NVIDIA Corporation GeForce RTX 3080"
)
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=nvidia_output,
returncode=0,
)
module.check_gpu_linux(context)
assert context["_nvidia_gpu"] is True
assert context["_amd_gpu"] is False
assert context["enable_gpu"] is True
def test_detects_amd_gpu(self):
"""check_gpu_linux correctly detects AMD GPU from lspci output."""
module = _load_pre_prompt_module()
context = {}
amd_output = "06:00.0 VGA compatible controller: AMD/ATI Radeon RX 7900"
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=amd_output,
returncode=0,
)
module.check_gpu_linux(context)
assert context["_amd_gpu"] is True
assert context["_nvidia_gpu"] is False
assert context["enable_gpu"] is True
def test_no_gpu_detected(self):
"""check_gpu_linux handles no GPU detection."""
module = _load_pre_prompt_module()
context = {}
intel_output = (
"00:02.0 VGA compatible controller: Intel Corporation UHD Graphics"
)
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=intel_output,
returncode=0,
)
module.check_gpu_linux(context)
assert context["_nvidia_gpu"] is False
assert context["_amd_gpu"] is False
assert context["enable_gpu"] is False
def test_filters_vga_lines_in_python(self):
"""check_gpu_linux filters VGA lines in Python, not via shell pipe."""
module = _load_pre_prompt_module()
context = {}
lines = [
"00:00.0 Host bridge: Intel Corporation",
"00:02.0 VGA compatible controller: NVIDIA Corporation",
"00:1f.0 ISA bridge: Intel Corporation",
]
lspci_output = chr(10).join(lines)
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=lspci_output,
returncode=0,
)
module.check_gpu_linux(context)
# subprocess.run called once with ["lspci"], not piped to grep
mock_run.assert_called_once()
assert context["_nvidia_gpu"] is True
class TestCheckGpuWindows:
"""Verify check_gpu_windows uses secure subprocess invocation."""
def test_calls_subprocess_run_with_list_args(self):
"""check_gpu_windows must use subprocess.run with a list."""
module = _load_pre_prompt_module()
context = {}
wmic_output = "Name" + chr(10) + "NVIDIA GeForce RTX 3080"
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=wmic_output,
returncode=0,
)
module.check_gpu_windows(context)
mock_run.assert_called_once()
call_args = mock_run.call_args
cmd_arg = (
call_args[0][0] if call_args[0] else call_args[1].get("args")
)
assert isinstance(cmd_arg, list), (
f"subprocess.run must be called with a list, "
f"got {type(cmd_arg)}"
)
assert cmd_arg == [
"wmic",
"path",
"win32_VideoController",
"get",
"name",
]
def test_does_not_use_shell_true(self):
"""check_gpu_windows must not pass shell=True."""
module = _load_pre_prompt_module()
context = {}
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(stdout="Name", returncode=0)
module.check_gpu_windows(context)
call_kwargs = mock_run.call_args[1] if mock_run.call_args[1] else {}
assert call_kwargs.get("shell") is not True, (
"check_gpu_windows must not use shell=True"
)
def test_detects_nvidia_gpu(self):
"""check_gpu_windows correctly detects NVIDIA GPU."""
module = _load_pre_prompt_module()
context = {}
wmic_output = "Name" + chr(10) + "NVIDIA GeForce RTX 3080"
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=wmic_output,
returncode=0,
)
module.check_gpu_windows(context)
assert context["_nvidia_gpu"] is True
assert context["_amd_gpu"] is False
assert context["enable_gpu"] is True
def test_detects_amd_gpu(self):
"""check_gpu_windows correctly detects AMD GPU."""
module = _load_pre_prompt_module()
context = {}
wmic_output = "Name" + chr(10) + "AMD Radeon RX 7900 XTX"
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=wmic_output,
returncode=0,
)
module.check_gpu_windows(context)
assert context["_amd_gpu"] is True
assert context["_nvidia_gpu"] is False
assert context["enable_gpu"] is True
def test_detects_radeon_gpu(self):
"""check_gpu_windows detects Radeon-branded AMD GPUs."""
module = _load_pre_prompt_module()
context = {}
wmic_output = "Name" + chr(10) + "Radeon RX 580"
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=wmic_output,
returncode=0,
)
module.check_gpu_windows(context)
assert context["_amd_gpu"] is True
assert context["_nvidia_gpu"] is False
assert context["enable_gpu"] is True
def test_no_gpu_detected(self):
"""check_gpu_windows handles no GPU detection."""
module = _load_pre_prompt_module()
context = {}
wmic_output = "Name" + chr(10) + "Microsoft Basic Display Adapter"
with patch.object(subprocess, "run") as mock_run:
mock_run.return_value = MagicMock(
stdout=wmic_output,
returncode=0,
)
module.check_gpu_windows(context)
assert context["_nvidia_gpu"] is False
assert context["_amd_gpu"] is False
assert context["enable_gpu"] is False
class TestOpenFileLocationPathValidation:
"""Verify open_file_location uses PathValidator for defense-in-depth."""
@patch("local_deep_research.research_library.utils.PathValidator")
@patch("local_deep_research.research_library.utils.subprocess")
def test_calls_path_validator_before_opening(
self, mock_subprocess, mock_validator_cls
):
"""open_file_location must validate path via PathValidator."""
from local_deep_research.research_library.utils import (
open_file_location,
)
mock_validated_path = MagicMock()
mock_validated_path.parent = Path("/safe/directory")
mock_validator_cls.validate_local_filesystem_path.return_value = (
mock_validated_path
)
mock_subprocess.run.return_value = MagicMock(returncode=0)
with patch(
"local_deep_research.research_library.utils.sys"
) as mock_sys:
mock_sys.platform = "linux"
result = open_file_location("/safe/directory/file.txt")
mock_validator_cls.validate_local_filesystem_path.assert_called_once_with(
"/safe/directory/file.txt"
)
assert result is True
@patch("local_deep_research.research_library.utils.PathValidator")
@patch("local_deep_research.research_library.utils.subprocess")
def test_uses_validated_path_parent_for_folder(
self, mock_subprocess, mock_validator_cls
):
"""open_file_location must use validated path parent, not raw input."""
from local_deep_research.research_library.utils import (
open_file_location,
)
mock_validated_path = MagicMock()
mock_validated_path.parent = Path("/validated/parent")
mock_validator_cls.validate_local_filesystem_path.return_value = (
mock_validated_path
)
mock_subprocess.run.return_value = MagicMock(returncode=0)
with patch(
"local_deep_research.research_library.utils.sys"
) as mock_sys:
mock_sys.platform = "linux"
open_file_location("/some/raw/path/file.txt")
mock_subprocess.run.assert_called_once()
call_args = mock_subprocess.run.call_args[0][0]
assert call_args == ["xdg-open", str(Path("/validated/parent"))]
@patch("local_deep_research.research_library.utils.PathValidator")
def test_returns_false_on_path_validation_failure(self, mock_validator_cls):
"""open_file_location returns False if PathValidator rejects path."""
from local_deep_research.research_library.utils import (
open_file_location,
)
mock_validator_cls.validate_local_filesystem_path.side_effect = (
ValueError("Cannot access system directories")
)
result = open_file_location("/etc/shadow")
assert result is False
mock_validator_cls.validate_local_filesystem_path.assert_called_once_with(
"/etc/shadow"
)
@patch("local_deep_research.research_library.utils.PathValidator")
def test_blocks_path_traversal_via_validator(self, mock_validator_cls):
"""open_file_location blocks path traversal via PathValidator."""
from local_deep_research.research_library.utils import (
open_file_location,
)
mock_validator_cls.validate_local_filesystem_path.side_effect = (
ValueError("Path traversal patterns not allowed")
)
result = open_file_location("../../etc/passwd")
assert result is False
mock_validator_cls.validate_local_filesystem_path.assert_called_once_with(
"../../etc/passwd"
)
@@ -0,0 +1,44 @@
"""Regression: the bulk settings GET redacts secrets whose leaf name isn't a
classic secret token (client_secret, bearer_token, …).
``GET /settings/api/bulk`` redacts via ``redact_value(key, None, value)`` — it
passes no ``ui_element``, so a password-typed setting is masked only if its leaf
name is in ``DEFAULT_SENSITIVE_KEYS``. These names were previously missing, so
such settings shipped in the clear. Uses a real in-memory ``SettingsManager`` and
mirrors the endpoint's redaction call; fails if a name is dropped from the set.
"""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from local_deep_research.database.models import Base
from local_deep_research.database.models.settings import Setting
from local_deep_research.security.data_sanitizer import DataSanitizer
from local_deep_research.settings.manager import SettingsManager
@pytest.mark.parametrize(
"leaf",
["client_secret", "secret_key", "bearer_token", "api_secret", "app_secret"],
)
def test_bulk_get_redacts_named_secret(leaf):
key = f"integrations.oauth.{leaf}"
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
session.add(
Setting(
key=key,
value="topsecret-value",
type="app",
name=key,
ui_element="password",
)
)
session.commit()
manager = SettingsManager(db_session=session)
# mirror GET /settings/api/bulk: get_setting(key) then redact_value(key, None, value)
shipped = DataSanitizer.redact_value(key, None, manager.get_setting(key))
assert shipped == DataSanitizer.REDACTION_TEXT
+243
View File
@@ -0,0 +1,243 @@
"""Tests for _check_response_size() and SafeSession.send() intermediate redirect branching.
Covers:
- _check_response_size() direct unit tests (8 tests) — zero prior coverage
- SafeSession.send() intermediate redirect size-check logic (10 tests) — lines 437-443
"""
import pytest
from unittest.mock import patch, MagicMock
import requests
from local_deep_research.security import ssrf_validator
from local_deep_research.security.safe_requests import (
_check_response_size,
SafeSession,
MAX_RESPONSE_SIZE,
_REDIRECT_STATUS_CODES,
)
def _mock_response(status_code=200, headers=None):
"""Build a MagicMock Response with the given status and headers."""
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
resp.headers = headers or {}
resp.url = "https://example.com"
# _check_response_size may install a body guard via _install_body_guard,
# which accesses response.raw.read
resp.raw = MagicMock()
return resp
# ── _check_response_size() direct unit tests ────────────────────────
class TestCheckResponseSizeDirect:
"""Direct unit tests for _check_response_size (lines 29-53)."""
def test_no_content_length_header(self):
"""No Content-Length header → no raise."""
resp = _mock_response(headers={})
_check_response_size(resp) # should not raise
def test_empty_string_content_length(self):
"""Empty string Content-Length is falsy → no raise."""
resp = _mock_response(headers={"Content-Length": ""})
_check_response_size(resp)
def test_negative_content_length(self):
"""Negative Content-Length → returns silently (line 46-47)."""
resp = _mock_response(headers={"Content-Length": "-1"})
_check_response_size(resp)
def test_non_numeric_content_length(self):
"""Non-numeric Content-Length → ValueError caught, returns (line 44)."""
resp = _mock_response(headers={"Content-Length": "abc"})
_check_response_size(resp)
def test_content_length_type_error(self):
"""Content-Length value that causes TypeError in int() → caught (line 44)."""
resp = _mock_response(headers={"Content-Length": None})
_check_response_size(resp)
def test_exactly_at_max_size(self):
"""Content-Length exactly at MAX_RESPONSE_SIZE → no raise (> not >=)."""
resp = _mock_response(
headers={"Content-Length": str(MAX_RESPONSE_SIZE)}
)
_check_response_size(resp)
def test_one_over_max_raises(self):
"""Content-Length one over MAX → raises ValueError, response.close() called."""
resp = _mock_response(
headers={"Content-Length": str(MAX_RESPONSE_SIZE + 1)}
)
with pytest.raises(ValueError, match="Response too large"):
_check_response_size(resp)
resp.close.assert_called_once()
def test_normal_size_under_limit(self):
"""Normal Content-Length well under limit → no raise."""
resp = _mock_response(headers={"Content-Length": "1024"})
_check_response_size(resp)
# ── SafeSession.send() intermediate redirect branching ──────────────
def _prep_request(url="https://example.com"):
"""Build a minimal PreparedRequest."""
prep = requests.PreparedRequest()
prep.prepare_url(url, {})
prep.prepare_method("GET")
return prep
@pytest.fixture
def _bypass_ssrf():
"""Patch validate_url to always return True (bypass SSRF check)."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
yield
class TestSendIntermediateRedirectBranching:
"""Tests for is_intermediate_redirect logic (lines 437-443)."""
def test_302_with_location_and_allow_redirects_applies_size_check(
self, _bypass_ssrf
):
"""302 + Location + allow_redirects=True → size check still applies."""
oversized = _mock_response(
302,
{
"Location": "https://other.com",
"Content-Length": str(MAX_RESPONSE_SIZE + 1),
},
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request(), allow_redirects=True)
def test_302_with_location_and_allow_redirects_false_applies_size_check(
self, _bypass_ssrf
):
"""302 + Location + allow_redirects=False → apply size check → raises."""
oversized = _mock_response(
302,
{
"Location": "https://other.com",
"Content-Length": str(MAX_RESPONSE_SIZE + 1),
},
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request(), allow_redirects=False)
def test_302_without_location_applies_size_check(self, _bypass_ssrf):
"""302 without Location header → apply size check → raises."""
oversized = _mock_response(
302, {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request(), allow_redirects=True)
def test_302_without_location_normal_size_passes(self, _bypass_ssrf):
"""302 without Location, normal Content-Length → no raise."""
resp = _mock_response(302, {"Content-Length": "512"})
session = SafeSession()
with patch.object(requests.Session, "send", return_value=resp):
result = session.send(_prep_request(), allow_redirects=True)
assert result.status_code == 302
def test_200_normal_size_passes(self, _bypass_ssrf):
"""200 response, normal size → passes."""
resp = _mock_response(200, {"Content-Length": "1024"})
session = SafeSession()
with patch.object(requests.Session, "send", return_value=resp):
result = session.send(_prep_request())
assert result.status_code == 200
def test_200_oversized_raises(self, _bypass_ssrf):
"""200 response, oversized Content-Length → raises."""
oversized = _mock_response(
200, {"Content-Length": str(MAX_RESPONSE_SIZE + 1)}
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request())
@pytest.mark.parametrize("status_code", [301, 302, 303, 307, 308])
def test_all_redirect_codes_apply_size_check(
self, _bypass_ssrf, status_code
):
"""All 5 redirect codes + Location + oversized → rejected by size check."""
oversized = _mock_response(
status_code,
{
"Location": "https://other.com",
"Content-Length": str(MAX_RESPONSE_SIZE + 1),
},
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request(), allow_redirects=True)
def test_allow_redirects_defaults_to_true_still_checks_size(
self, _bypass_ssrf
):
"""No allow_redirects in kwargs → size check still applies."""
oversized = _mock_response(
302,
{
"Location": "https://other.com",
"Content-Length": str(MAX_RESPONSE_SIZE + 1),
},
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request())
def test_304_not_in_redirect_set_applies_size_check(self, _bypass_ssrf):
"""304 Not Modified is NOT in the redirect frozenset → size check applies."""
assert 304 not in _REDIRECT_STATUS_CODES
oversized = _mock_response(
304,
{
"Location": "https://other.com",
"Content-Length": str(MAX_RESPONSE_SIZE + 1),
},
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request(), allow_redirects=True)
def test_oversized_intermediate_redirect_rejected(self, _bypass_ssrf):
"""301 + Location + allow_redirects=True + oversized → raises.
All responses are size-checked uniformly, including redirects.
"""
oversized = _mock_response(
301,
{
"Location": "https://other.com",
"Content-Length": str(MAX_RESPONSE_SIZE + 1),
},
)
session = SafeSession()
with patch.object(requests.Session, "send", return_value=oversized):
with pytest.raises(ValueError, match="Response too large"):
session.send(_prep_request(), allow_redirects=True)
@@ -0,0 +1,120 @@
"""Per-collection "Available to the research agent" flag (agent_enabled).
A collection a user marked as NOT available to the research agent must be
excluded from the LangGraph agent's specialized tool list — its name never
reaches ``create_agent()`` — while agent-enabled collections survive. This is a
usability switch, independent of egress scope: it filters even a collection
that egress would otherwise allow.
Drives the REAL ``LangGraphAgentStrategy._build_tools`` loop; only the leaf tool
factories and engine discovery are mocked, so the assertion is the filter
decision (which collection names survive into the built tool list).
"""
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy as strat # noqa: E501
from local_deep_research.security import clear_active_context
@pytest.fixture(autouse=True)
def _clear_ctx():
clear_active_context()
yield
clear_active_context()
@contextmanager
def _patched_factories():
spec = MagicMock(
side_effect=lambda name, *a, **k: SimpleNamespace(name=name)
)
with (
patch.object(
strat,
"_make_web_search_tool",
lambda *a, **k: SimpleNamespace(name="web_search"),
),
patch.object(strat, "build_fetch_tool", lambda *a, **k: None),
patch.object(strat, "_make_specialized_search_tool", spec),
):
yield spec
def _build(available):
# BOTH scope: a collection is allowed by egress regardless of is_public, so
# the ONLY differentiator left is the agent_enabled flag.
snapshot = {"policy.egress_scope": "both", "search.fetch.mode": "disabled"}
reg = MagicMock()
reg.get_metadata.return_value = None
with _patched_factories() as spec:
with (
patch(
"local_deep_research.web_search_engines.search_engines_config"
".get_available_engines",
return_value=available,
),
patch(
"local_deep_research.web_search_engines.retriever_registry"
".retriever_registry",
reg,
),
):
strategy = strat.LangGraphAgentStrategy(
model=MagicMock(),
search=MagicMock(),
citation_handler=object(),
include_sub_research=False,
settings_snapshot=snapshot,
)
tools = strategy._build_tools("overall query")
return {t.name for t in tools}, spec
def _coll(is_public=True, agent_enabled=True):
cfg = {
"is_public": is_public,
"is_local": not is_public,
"is_retriever": False,
"description": "a collection",
"strengths": [],
}
if agent_enabled is not None:
cfg["agent_enabled"] = agent_enabled
return cfg
def test_agent_disabled_collection_excluded_from_tools():
names, spec = _build(
{
"collection_enabled": _coll(agent_enabled=True),
"collection_disabled": _coll(agent_enabled=False),
}
)
assert "collection_enabled" in names
assert "collection_disabled" not in names
# The disabled collection's tool factory was never even called (the loop
# `continue`s before construction).
built = {c.args[0] for c in spec.call_args_list}
assert "collection_disabled" not in built
assert "collection_enabled" in built
def test_missing_flag_defaults_to_available():
names, _ = _build({"collection_x": _coll(agent_enabled=None)})
assert "collection_x" in names
def test_agent_flag_independent_of_egress():
# A PUBLIC collection that egress would allow under BOTH is still excluded
# when agent_enabled is False — proving the switch is orthogonal to scope.
names, _ = _build(
{"collection_pub_disabled": _coll(is_public=True, agent_enabled=False)}
)
assert "collection_pub_disabled" not in names
+189
View File
@@ -0,0 +1,189 @@
"""
Cookie Security Tests
Tests for the dynamic cookie security behavior.
Security model:
- Secure flag is added iff wsgi.url_scheme == "https".
- ProxyFix (with x_proto=1) translates X-Forwarded-Proto into wsgi.url_scheme
before SecureCookieMiddleware sees it, so requests served over HTTPS by a
reverse proxy correctly receive the Secure flag.
- HTTP requests never get Secure regardless of source IP. Setting Secure on
an HTTP response causes the browser to drop the cookie entirely, so doing
so based on IP heuristics broke legitimate Docker/LAN access without
providing any cryptographic protection (issue #3849).
- TESTING mode: Never get Secure flag (for CI/development).
"""
import pytest
from tests.test_utils import add_src_to_path
add_src_to_path()
@pytest.fixture
def app():
"""Create test application with TESTING mode enabled (default for tests)."""
from local_deep_research.web.app_factory import create_app
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
return app
@pytest.fixture
def app_production_mode():
"""Create test application with production-like cookie security."""
import os
from local_deep_research.web.app_factory import create_app
old_testing = os.environ.pop("TESTING", None)
old_ci = os.environ.pop("CI", None)
old_pytest = os.environ.pop("PYTEST_CURRENT_TEST", None)
try:
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
app.config["LDR_TESTING_MODE"] = False
app.config["PREFERRED_URL_SCHEME"] = "http"
return app
finally:
if old_testing is not None:
os.environ["TESTING"] = old_testing
if old_ci is not None:
os.environ["CI"] = old_ci
if old_pytest is not None:
os.environ["PYTEST_CURRENT_TEST"] = old_pytest
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
class TestLocalhostCookieSecurity:
"""Localhost HTTP works without Secure flag."""
def test_localhost_http_no_secure_flag(self, client):
response = client.get("/auth/login")
set_cookie = response.headers.get("Set-Cookie", "")
assert response.status_code == 200
assert "session=" in set_cookie
def test_localhost_session_cookie_works(self, client):
response1 = client.get("/auth/login")
assert response1.status_code == 200
response2 = client.get("/auth/login")
assert response2.status_code == 200
class TestLocalhostProductionMode:
"""Localhost HTTP works in production mode (non-testing)."""
def test_localhost_http_no_secure_flag_in_production(
self, app_production_mode
):
app = app_production_mode
with app.test_client() as client:
response = client.get("/auth/login")
set_cookie = response.headers.get("Set-Cookie", "")
assert "; Secure" not in set_cookie, (
f"Localhost HTTP should NOT have Secure flag. Got: {set_cookie}"
)
assert "session=" in set_cookie
class TestHttpCookieSecurity:
"""HTTP requests never get the Secure flag, regardless of source IP.
This is the core fix for #3849: setting Secure on an HTTP response makes
the browser drop the cookie, breaking sessions without adding any real
security (the underlying transport is still plaintext).
"""
@pytest.mark.parametrize(
"remote_addr",
[
"127.0.0.1",
"192.168.1.100",
"10.0.0.50",
"172.16.0.1",
"172.17.0.2", # Default Docker bridge
"172.67.130.145", # Docker Desktop NAT (the #3849 trigger)
"8.8.8.8",
"104.16.0.1",
],
)
def test_http_no_secure_flag(self, app_production_mode, remote_addr):
app = app_production_mode
with app.test_client() as client:
response = client.get(
"/auth/login",
environ_base={"REMOTE_ADDR": remote_addr},
)
set_cookie = response.headers.get("Set-Cookie", "")
assert "; Secure" not in set_cookie, (
f"HTTP from {remote_addr} should NOT have Secure flag. "
f"Got: {set_cookie}"
)
class TestHttpsCookieSecurity:
"""HTTPS requests always get the Secure flag."""
def test_https_via_x_forwarded_proto_gets_secure(self, app_production_mode):
"""A reverse proxy terminating HTTPS sets X-Forwarded-Proto: https.
ProxyFix translates this into wsgi.url_scheme, after which Secure is
added.
"""
app = app_production_mode
with app.test_client() as client:
response = client.get(
"/auth/login",
headers={"X-Forwarded-Proto": "https"},
environ_base={"REMOTE_ADDR": "10.0.0.1"},
)
set_cookie = response.headers.get("Set-Cookie", "")
assert "; Secure" in set_cookie, (
f"HTTPS via reverse proxy should add Secure. Got: {set_cookie}"
)
class TestTestingModeBehavior:
"""TESTING mode disables Secure flag entirely."""
def test_testing_mode_no_secure_flag(self, app):
app.config["LDR_TESTING_MODE"] = True
with app.test_client() as client:
response = client.get(
"/auth/login",
headers={"X-Forwarded-Proto": "https"},
)
set_cookie = response.headers.get("Set-Cookie", "")
assert "; Secure" not in set_cookie
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_cookie_security_summary():
"""
Summary of cookie security behavior for CI validation.
Expected behavior:
| Scenario | wsgi.url_scheme | Secure Flag |
|-------------------------------|-----------------|-------------|
| HTTP from localhost | http | No |
| HTTP from LAN client | http | No |
| HTTP from Docker NAT gateway | http | No |
| HTTP from public IP | http | No |
| HTTPS via reverse proxy | https | Yes |
| Direct HTTPS | https | Yes |
| TESTING=1 mode | any | No |
The decision is based purely on the protocol (post-ProxyFix), not the
source IP. Setting Secure on HTTP responses doesn't add security and
breaks the browser's ability to store the cookie.
"""
assert True
@@ -0,0 +1,394 @@
"""Tests for credential leak fixes that address findings from the PR #4452 audit.
Covers:
- PubMed engine: 5 ``logger.exception()`` calls replaced with dual-scrub
- Base class ``run()``: unredacted ``error_message`` persisted to database
- LLM config ``_log_llm_error``: ``logger.exception()`` replaced with ``logger.warning``
- Google PSE ``_validate_connection``: bare re-raise replaced with sanitized exception
- OpenAI compat errors: ``{exc!s}`` replaced with ``sanitize_error_message``
"""
import base64
from unittest.mock import Mock, patch
from urllib.parse import quote, quote_plus
import pytest
import requests
_LEAKED_KEY = "sk-leaked-sentinel-DO-NOT-APPEAR-12345"
def _all_encodings_of(secret: str) -> list:
return [
secret,
quote(secret, safe=""),
quote_plus(secret),
repr(secret)[1:-1],
base64.b64encode(secret.encode()).decode(),
secret[:8],
]
def _stub_rate_tracker() -> Mock:
tracker = Mock()
tracker.enabled = False
tracker.apply_rate_limit.return_value = 0
return tracker
# ── PubMed engine tests ──────────────────────────────────────────────
def _pubmed_engine():
"""Build a minimal PubMedSearchEngine for testing."""
from local_deep_research.web_search_engines.engines import (
search_engine_pubmed as mod,
)
engine = mod.PubMedSearchEngine.__new__(mod.PubMedSearchEngine)
engine.api_key = _LEAKED_KEY
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine.summary_url = (
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
)
engine.fetch_url = (
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
)
engine.link_url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/elink.fcgi"
engine.get_full_text = True
return mod, engine
@pytest.mark.parametrize(
"method_name,url_path,marker",
[
(
"_get_article_summaries",
"/esummary.fcgi?api_key=",
"Error getting article summaries",
),
(
"_get_article_abstracts",
"/efetch.fcgi?api_key=",
"Error getting article abstracts",
),
(
"_get_article_detailed_metadata",
"/efetch.fcgi?api_key=",
"Error getting detailed article metadata",
),
(
"_find_pmc_ids",
"/elink.fcgi?api_key=",
"Error finding PMC IDs",
),
(
"_get_pmc_full_text",
"/efetch.fcgi?api_key=",
"Error getting PMC full text",
),
],
)
class TestPubMedEngineKeyLeakage:
def test_no_leak_in_catch_block(
self, loguru_caplog_full, method_name, url_path, marker
):
"""Each PubMed method must scrub the API key before logging."""
mod, engine = _pubmed_engine()
exc = requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: Max retries exceeded with url: "
f"{url_path}{_LEAKED_KEY}&id=12345"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(mod, "safe_get", side_effect=exc):
try:
method = getattr(engine, method_name)
if method_name == "_get_article_summaries":
method(["12345"])
elif method_name == "_find_pmc_ids":
method(["12345"])
elif method_name == "_get_pmc_full_text":
method("PMC12345")
else:
method(["12345"])
except requests.exceptions.RequestException:
pass
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"{method_name}: api_key leaked as encoding {encoding!r}"
)
assert marker in loguru_caplog_full.text, (
f"{method_name}: catch-block marker {marker!r} not in logs — "
f"the redaction path may not have run. "
f"Captured: {loguru_caplog_full.text!r}"
)
# ── Base class database persistence tests ────────────────────────────
class TestBaseClassDatabasePersistence:
def test_error_message_sanitized_before_db_persist(self, loguru_caplog):
"""The ``error_message`` passed to ``SearchTracker.record_search``
must be sanitized so credentials don't persist to the database."""
from local_deep_research.web_search_engines.search_engine_base import (
BaseSearchEngine,
)
from local_deep_research.metrics.search_tracker import SearchTracker
class _TestEngine(BaseSearchEngine):
engine_type = "test_engine"
api_key = _LEAKED_KEY
def _get_previews(self, query, *args, **kwargs):
raise requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: url: /search?api_key={_LEAKED_KEY}"
)
engine = _TestEngine.__new__(_TestEngine)
engine.api_key = _LEAKED_KEY
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine.programmatic_mode = False
# __init__ always sets this (settings_snapshot or {}); run() and the
# egress-verification path read it. Bypassing __init__ via __new__
# omits it, which AttributeErrors only when a sibling test arms the
# global egress audit net — a parallel-run flake. Set it explicitly.
engine.settings_snapshot = {}
captured = {}
def _capture_record(**kwargs):
captured["error_message"] = kwargs.get("error_message")
with loguru_caplog.at_level("DEBUG"):
with (
patch.object(
SearchTracker, "record_search", side_effect=_capture_record
),
patch(
"local_deep_research.web_search_engines.search_engine_base.set_search_context"
),
):
engine.run("test query")
assert "error_message" in captured, "record_search was never called"
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in captured["error_message"], (
f"api_key leaked into DB error_message as {encoding!r}: "
f"{captured['error_message']!r}"
)
def test_log_path_sanitizes_foreign_url_credentials(self, loguru_caplog):
"""The logged ``safe_msg`` in run() must run the regex sanitizer,
not just literal redaction.
Regression guard: a *foreign* credential (not the engine's own
api_key) embedded as ``https://user:pass@host`` is only caught by
sanitize_error_message's regex, not by redact_secrets(literal). The
log path previously used redact_secrets alone, so it leaked into the
log even though the DB path was dual-scrubbed.
"""
from local_deep_research.web_search_engines.search_engine_base import (
BaseSearchEngine,
)
from local_deep_research.metrics.search_tracker import SearchTracker
foreign_secret = "hunter2-foreign-pw-DO-NOT-APPEAR"
class _TestEngine(BaseSearchEngine):
engine_type = "test_engine"
api_key = "the-engines-own-key-unrelated"
def _get_previews(self, query, *args, **kwargs):
raise requests.exceptions.ConnectionError(
f"connect failed: https://admin:{foreign_secret}@es.local/_search"
)
engine = _TestEngine.__new__(_TestEngine)
engine.api_key = "the-engines-own-key-unrelated"
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine.programmatic_mode = False
engine.settings_snapshot = {}
with loguru_caplog.at_level("DEBUG"):
with (
patch.object(SearchTracker, "record_search"),
patch(
"local_deep_research.web_search_engines.search_engine_base.set_search_context"
),
):
engine.run("test query")
assert foreign_secret not in loguru_caplog.text, (
f"foreign URL credential leaked into log: {loguru_caplog.text!r}"
)
# ── LLM config _log_llm_error tests ─────────────────────────────────
class TestLLMConfigLogError:
def test_no_leak_with_url_in_exception(self, loguru_caplog_full):
"""``_log_llm_error`` must not leak credentials from the exception."""
from local_deep_research.config.llm_config import _log_llm_error
exc = RuntimeError(
f"Connection to https://api.openai.com/v1?key={_LEAKED_KEY} failed"
)
with loguru_caplog_full.at_level("DEBUG"):
_log_llm_error(exc)
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"_log_llm_error leaked key as {encoding!r}"
)
def test_uses_warning_not_exception(self, loguru_caplog):
"""``_log_llm_error`` should use ``logger.warning``, not
``logger.exception``, to avoid writing the traceback chain."""
from local_deep_research.config.llm_config import _log_llm_error
exc = RuntimeError("test error")
with loguru_caplog.at_level("DEBUG"):
_log_llm_error(exc)
assert "LLM Request - Failed with error" in loguru_caplog.text
assert "Traceback" not in loguru_caplog.text
# ── Google PSE _validate_connection tests ────────────────────────────
class TestGooglePSEValidateConnection:
def test_rethrown_exception_is_sanitized(self):
"""The re-thrown exception from ``_validate_connection`` must not
contain the API key in its string representation."""
from local_deep_research.web_search_engines.engines import (
search_engine_google_pse as mod,
)
engine = mod.GooglePSESearchEngine.__new__(mod.GooglePSESearchEngine)
engine.api_key = _LEAKED_KEY
exc = requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: url: /v1?key={_LEAKED_KEY}"
)
engine._make_request = Mock(side_effect=exc)
caught = None
try:
engine._validate_connection()
except Exception as e:
caught = e
assert caught is not None
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in str(caught), (
f"Re-thrown exception leaks key as {encoding!r}: {str(caught)!r}"
)
def test_exception_chain_suppressed(self):
"""``from None`` should suppress the original exception chain."""
from local_deep_research.web_search_engines.engines import (
search_engine_google_pse as mod,
)
engine = mod.GooglePSESearchEngine.__new__(mod.GooglePSESearchEngine)
engine.api_key = _LEAKED_KEY
exc = ValueError("original")
engine._make_request = Mock(side_effect=exc)
caught = None
try:
engine._validate_connection()
except Exception as e:
caught = e
assert caught is not None
assert caught.__cause__ is None
def test_log_output_sanitized(self, loguru_caplog_full):
"""The warning log from ``_validate_connection`` must not leak."""
from local_deep_research.web_search_engines.engines import (
search_engine_google_pse as mod,
)
engine = mod.GooglePSESearchEngine.__new__(mod.GooglePSESearchEngine)
engine.api_key = _LEAKED_KEY
exc = requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: url: /v1?key={_LEAKED_KEY}"
)
engine._make_request = Mock(side_effect=exc)
with loguru_caplog_full.at_level("DEBUG"):
try:
engine._validate_connection()
except Exception:
pass
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"_validate_connection log leaked key as {encoding!r}"
)
# ── OpenAI compat errors tests ───────────────────────────────────────
class TestOpenAICompatErrorDetails:
def test_details_with_url_key_redacted(self):
"""The ``Details:`` suffix must not contain URL query credentials."""
from local_deep_research.error_handling.openai_compat_errors import (
friendly_openai_compatible_error,
)
exc = RuntimeError(
f"Connection to https://api.host/v1?key={_LEAKED_KEY} failed"
)
result = friendly_openai_compatible_error(
exc, provider="test", base_url="https://api.host/v1", model="gpt-4"
)
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in result, (
f"Details leaked key as {encoding!r}: {result!r}"
)
def test_details_with_bearer_redacted(self):
"""Bearer tokens must be scrubbed from the Details suffix."""
from local_deep_research.error_handling.openai_compat_errors import (
friendly_openai_compatible_error,
)
exc = RuntimeError(f"Bearer {_LEAKED_KEY} rejected")
result = friendly_openai_compatible_error(
exc, provider="test", base_url="https://api.host/v1", model="gpt-4"
)
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in result
def test_no_secrets_preserves_details(self):
"""Non-sensitive details should be preserved."""
from local_deep_research.error_handling.openai_compat_errors import (
friendly_openai_compatible_error,
)
exc = RuntimeError("connection refused on port 443")
result = friendly_openai_compatible_error(
exc, provider="test", base_url="https://api.host/v1", model="gpt-4"
)
assert "connection refused on port 443" in result
@@ -0,0 +1,644 @@
"""Tests for credential leak fixes across additional search engines.
Covers:
- GitHub engine: 7 ``logger.exception()`` calls replaced with dual-scrub
- Elasticsearch: ``logger.exception`` + unsanitized ``raise ConnectionError``
- Paperless: 5 ``logger.exception()`` calls replaced with dual-scrub
- Semantic Scholar: 6 ``logger.exception()`` calls replaced with dual-scrub
- Brave: 2 ``logger.exception()`` calls replaced with dual-scrub
- NASA ADS: 1 ``logger.exception()`` call replaced with dual-scrub
- Guardian: 3 ``logger.exception()`` calls replaced with dual-scrub
"""
from unittest.mock import Mock, patch
import pytest
import requests
_LEAKED_KEY = "sk-phase3-sentinel-DO-NOT-APPEAR-IN-LOGS-9999"
_LEAKED_TOKEN = "tok-paperless-sentinel-DO-NOT-APPEAR-8888"
_LEAKED_PASSWORD = "pw-phase3-sentinel-DO-NOT-APPEAR-7777"
def _all_encodings_of(secret: str) -> list:
import base64
from urllib.parse import quote, quote_plus
return [
secret,
quote(secret, safe=""),
quote_plus(secret),
repr(secret)[1:-1],
base64.b64encode(secret.encode()).decode(),
secret[:8],
]
def _stub_rate_tracker() -> Mock:
tracker = Mock()
tracker.enabled = False
tracker.apply_rate_limit.return_value = 0
return tracker
# ── GitHub engine tests ──────────────────────────────────────────────
def _github_engine():
"""Build a minimal GitHubSearchEngine for testing."""
from local_deep_research.web_search_engines.engines import (
search_engine_github as mod,
)
engine = mod.GitHubSearchEngine.__new__(mod.GitHubSearchEngine)
engine.api_key = _LEAKED_KEY
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine.api_base = "https://api.github.com"
engine.headers = {
"Authorization": f"token {_LEAKED_KEY}",
"Accept": "application/vnd.github.v3+json",
}
return mod, engine
@pytest.mark.parametrize(
"method_name,method_args,marker",
[
(
"_get_readme_content",
("owner/repo",),
"Error getting README",
),
(
"_get_recent_issues",
("owner/repo",),
"Error getting issues",
),
(
"_get_file_content",
("https://api.github.com/some/url",),
"Error getting file content",
),
(
"search_repository",
("owner", "repo"),
"Error getting repository details",
),
(
"_filter_for_relevance",
([{"title": "test"}], "query"),
"Error filtering GitHub results",
),
],
)
class TestGitHubEngineKeyLeakage:
def test_no_leak_in_catch_block(
self, loguru_caplog_full, method_name, method_args, marker
):
"""Each GitHub method must scrub the API key before logging."""
mod, engine = _github_engine()
exc = requests.exceptions.ConnectionError(
f"HTTPSConnectionPool: Max retries exceeded with url: "
f"/repos/owner/repo?key={_LEAKED_KEY}"
)
with loguru_caplog_full.at_level("DEBUG"):
if method_name == "_filter_for_relevance":
# _filter_for_relevance uses self.llm, not safe_get
engine.llm = Mock()
engine.llm.invoke = Mock(side_effect=exc)
try:
getattr(engine, method_name)(*method_args)
except Exception:
pass
else:
with patch.object(mod, "safe_get", side_effect=exc):
try:
getattr(engine, method_name)(*method_args)
except Exception:
pass
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"{method_name}: api_key leaked as encoding {encoding!r}"
)
assert marker in loguru_caplog_full.text, (
f"{method_name}: catch-block marker {marker!r} not in logs — "
f"the redaction path may not have run. "
f"Captured: {loguru_caplog_full.text!r}"
)
class TestGitHubLLMMethods:
def test_optimize_query_no_leak(self, loguru_caplog_full):
"""``_optimize_github_query`` must not leak the API key."""
mod, engine = _github_engine()
exc = RuntimeError(f"LLM error with key={_LEAKED_KEY}")
engine.llm = Mock()
engine.llm.invoke = Mock(side_effect=exc)
with loguru_caplog_full.at_level("DEBUG"):
result = engine._optimize_github_query("test query")
assert result == "test query"
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"_optimize_github_query leaked key as {encoding!r}"
)
# ── Elasticsearch tests ──────────────────────────────────────────────
class TestElasticsearchKeyLeakage:
def test_init_connection_error_sanitized(self):
"""The ``ConnectionError`` raised from ``__init__`` must not
contain the API key."""
from local_deep_research.web_search_engines.engines import (
search_engine_elasticsearch as mod,
)
with patch.object(
mod.Elasticsearch,
"__init__",
side_effect=Exception(
f"Connection to https://es.host:9200?key={_LEAKED_KEY} failed"
),
):
# Elasticsearch.__init__ is patched to raise, so the mock
# won't call super().__init__ — make the constructor safe.
mod.Elasticsearch.__init__ = lambda self, *a, **kw: None
engine = mod.ElasticsearchSearchEngine.__new__(
mod.ElasticsearchSearchEngine
)
# Manually invoke the connection test portion
engine._api_key = _LEAKED_KEY
engine._password = None
caught = None
try:
# Simulate the connection check block
raise Exception(
f"Connection to https://es.host:9200?key={_LEAKED_KEY} failed"
)
except Exception as e:
from local_deep_research.security.log_sanitizer import (
redact_secrets,
sanitize_error_message,
)
safe_msg = redact_secrets(
sanitize_error_message(str(e)), engine._api_key
)
caught = ConnectionError(
f"Could not connect to Elasticsearch: {safe_msg}"
)
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in str(caught), (
f"Elasticsearch ConnectionError leaked key as {encoding!r}"
)
def test_previews_no_leak(self, loguru_caplog_full):
"""``_get_previews`` must not leak the API key."""
from local_deep_research.web_search_engines.engines import (
search_engine_elasticsearch as mod,
)
engine = mod.ElasticsearchSearchEngine.__new__(
mod.ElasticsearchSearchEngine
)
engine._api_key = _LEAKED_KEY
engine._password = None
engine.client = Mock()
engine.client.search = Mock(
side_effect=Exception(
f"Search failed: https://es.host?key={_LEAKED_KEY}"
)
)
engine.highlight_fields = ["content"]
engine.search_fields = ["content"]
engine.filter_query = {}
engine.max_results = 10
engine.index_name = "test"
with loguru_caplog_full.at_level("DEBUG"):
result = engine._get_previews("test")
assert result == []
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text, (
f"Elasticsearch _get_previews leaked key as {encoding!r}"
)
def test_query_string_search_no_leak(self, loguru_caplog_full):
"""``search_by_query_string`` must not leak the API key."""
from local_deep_research.web_search_engines.engines import (
search_engine_elasticsearch as mod,
)
engine = mod.ElasticsearchSearchEngine.__new__(
mod.ElasticsearchSearchEngine
)
engine._api_key = _LEAKED_KEY
engine._password = None
engine.client = Mock()
engine.client.search = Mock(
side_effect=Exception(f"Error with key={_LEAKED_KEY}")
)
engine.highlight_fields = ["content"]
engine.search_fields = ["content"]
engine.max_results = 10
engine.index_name = "test"
with loguru_caplog_full.at_level("DEBUG"):
result = engine.search_by_query_string("test")
assert result == []
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
def test_query_string_and_dsl_search_no_password_leak(
self, loguru_caplog_full
):
"""``search_by_query_string`` / ``search_by_dsl`` must redact the
basic-auth password, not just the API key.
Regression guard: these two catch blocks originally passed only
``_api_key`` to ``redact_secrets`` while the other ES catch blocks
also passed ``_password``. A password surfaced in the client error
(e.g. a basic-auth failure) would have leaked here.
"""
from local_deep_research.web_search_engines.engines import (
search_engine_elasticsearch as mod,
)
for method, arg in (
("search_by_query_string", "test"),
("search_by_dsl", {"query": {"match_all": {}}}),
):
engine = mod.ElasticsearchSearchEngine.__new__(
mod.ElasticsearchSearchEngine
)
engine._api_key = None
engine._password = _LEAKED_PASSWORD
engine.client = Mock()
engine.client.search = Mock(
side_effect=Exception(
f"auth failed for user:{_LEAKED_PASSWORD}@host"
)
)
engine.highlight_fields = ["content"]
engine.search_fields = ["content"]
engine.max_results = 10
engine.index_name = "test"
with loguru_caplog_full.at_level("DEBUG"):
result = getattr(engine, method)(arg)
assert result == []
for encoding in _all_encodings_of(_LEAKED_PASSWORD):
assert encoding not in loguru_caplog_full.text, (
f"password leaked via {method}"
)
# ── Paperless engine tests ───────────────────────────────────────────
def _paperless_engine():
"""Build a minimal PaperlessSearchEngine for testing."""
from local_deep_research.web_search_engines.engines import (
search_engine_paperless as mod,
)
engine = mod.PaperlessSearchEngine.__new__(mod.PaperlessSearchEngine)
engine.api_token = _LEAKED_TOKEN
engine.api_url = "http://localhost:8000"
engine.headers = {"Authorization": f"Token {_LEAKED_TOKEN}"}
engine.timeout = 30
engine.verify_ssl = True
engine.max_results = 10
engine.llm = None
engine.rate_tracker = _stub_rate_tracker()
engine.engine_type = "test_engine"
return mod, engine
class TestPaperlessEngineKeyLeakage:
def test_make_request_no_leak(self, loguru_caplog_full):
"""``_make_request`` must not leak the API token."""
mod, engine = _paperless_engine()
exc = requests.exceptions.ConnectionError(
f"Connection to http://localhost:8000?key={_LEAKED_TOKEN} failed"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(mod, "safe_get", side_effect=exc):
result = engine._make_request("/api/documents/")
assert result == {}
for encoding in _all_encodings_of(_LEAKED_TOKEN):
assert encoding not in loguru_caplog_full.text, (
f"Paperless _make_request leaked token as {encoding!r}"
)
def test_get_previews_no_leak(self, loguru_caplog_full):
"""``_get_previews`` must not leak the API token."""
mod, engine = _paperless_engine()
exc = RuntimeError(f"Error: token={_LEAKED_TOKEN}")
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(engine, "_multi_pass_search", side_effect=exc):
result = engine._get_previews("query")
assert result == []
for encoding in _all_encodings_of(_LEAKED_TOKEN):
assert encoding not in loguru_caplog_full.text
def test_get_full_content_no_leak(self, loguru_caplog_full):
"""``_get_full_content`` must not leak the API token."""
mod, engine = _paperless_engine()
engine.include_content = True
item = {
"_raw_data": {},
"snippet": "test snippet",
"metadata": {"doc_id": "123"},
}
exc = RuntimeError(f"Error: token={_LEAKED_TOKEN}")
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(engine, "_make_request", side_effect=exc):
result = engine._get_full_content([item])
assert len(result) == 1
for encoding in _all_encodings_of(_LEAKED_TOKEN):
assert encoding not in loguru_caplog_full.text
def test_run_no_leak(self, loguru_caplog_full):
"""``run()`` must not leak the API token."""
mod, engine = _paperless_engine()
exc = RuntimeError(f"Error: token={_LEAKED_TOKEN}")
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(engine, "_get_previews", side_effect=exc):
result = engine.run("query")
assert result == []
for encoding in _all_encodings_of(_LEAKED_TOKEN):
assert encoding not in loguru_caplog_full.text
def test_test_connection_no_leak(self, loguru_caplog_full):
"""``test_connection`` must not leak the API token."""
mod, engine = _paperless_engine()
exc = RuntimeError(f"Error: token={_LEAKED_TOKEN}")
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(engine, "_make_request", side_effect=exc):
result = engine.test_connection()
assert result is False
for encoding in _all_encodings_of(_LEAKED_TOKEN):
assert encoding not in loguru_caplog_full.text
# ── Semantic Scholar tests ───────────────────────────────────────────
def _semantic_scholar_engine():
"""Build a minimal SemanticScholarSearchEngine for testing."""
from local_deep_research.web_search_engines.engines import (
search_engine_semantic_scholar as mod,
)
engine = mod.SemanticScholarSearchEngine.__new__(
mod.SemanticScholarSearchEngine
)
engine.api_key = _LEAKED_KEY
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine.session = Mock()
engine.search_url = "https://api.semanticscholar.org/graph/v1/paper/search"
engine.paper_details_url = "https://api.semanticscholar.org/graph/v1/paper"
engine.get_abstracts = True
engine.get_references = False
engine.get_citations = False
engine.get_embeddings = False
engine.get_tldr = True
engine.citation_limit = 10
engine.reference_limit = 10
engine.max_results = 10
engine.optimize_queries = True
engine.llm = None
return mod, engine
class TestSemanticScholarKeyLeakage:
def test_direct_search_no_leak(self, loguru_caplog_full):
"""``_direct_search`` must not leak the API key."""
mod, engine = _semantic_scholar_engine()
exc = requests.exceptions.ConnectionError(
f"Connection to https://api.semanticscholar.org?key={_LEAKED_KEY}"
)
engine.session.get = Mock(side_effect=exc)
with loguru_caplog_full.at_level("DEBUG"):
result = engine._direct_search("test query")
assert result == []
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
def test_get_paper_details_no_leak(self, loguru_caplog_full):
"""``_get_paper_details`` must not leak the API key."""
mod, engine = _semantic_scholar_engine()
exc = requests.exceptions.ConnectionError(f"Error: key={_LEAKED_KEY}")
engine.session.get = Mock(side_effect=exc)
with loguru_caplog_full.at_level("DEBUG"):
result = engine._get_paper_details("paper123")
assert result == {}
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
def test_optimize_query_no_leak(self, loguru_caplog_full):
"""``_optimize_query`` must not leak the API key."""
mod, engine = _semantic_scholar_engine()
engine.llm = Mock()
engine.llm.invoke = Mock(
side_effect=RuntimeError(f"LLM error key={_LEAKED_KEY}")
)
with loguru_caplog_full.at_level("DEBUG"):
result = engine._optimize_query("test query")
assert result == "test query"
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
def test_close_no_leak(self, loguru_caplog_full):
"""``close()`` must not leak the API key."""
mod, engine = _semantic_scholar_engine()
engine.session = Mock()
engine.session.close = Mock(
side_effect=RuntimeError(f"Error: key={_LEAKED_KEY}")
)
with loguru_caplog_full.at_level("DEBUG"):
engine.close()
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
# ── Brave engine tests ───────────────────────────────────────────────
def _brave_engine():
"""Build a minimal BraveSearchEngine for testing."""
from local_deep_research.web_search_engines.engines import (
search_engine_brave as mod,
)
engine = mod.BraveSearchEngine.__new__(mod.BraveSearchEngine)
engine._brave_api_key = _LEAKED_KEY
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine.engine = Mock()
return mod, engine
class TestBraveEngineKeyLeakage:
def test_get_previews_no_leak(self, loguru_caplog_full):
"""``_get_previews`` must not leak the API key."""
mod, engine = _brave_engine()
exc = RuntimeError(f"Brave API error: key={_LEAKED_KEY}")
engine.engine.run = Mock(side_effect=exc)
# Mock _raise_if_rate_limit to re-raise non-rate-limit exceptions
engine._raise_if_rate_limit = Mock()
with loguru_caplog_full.at_level("DEBUG"):
result = engine._get_previews("test query")
assert result == []
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
def test_json_parse_error_no_leak(self, loguru_caplog_full):
"""JSON parse error in ``_get_previews`` must not leak the API key."""
mod, engine = _brave_engine()
# Return a non-JSON string to trigger JSONDecodeError path
engine.engine.run = Mock(return_value="not json {{{}}}")
with loguru_caplog_full.at_level("DEBUG"):
result = engine._get_previews("test query")
assert result == []
# ── NASA ADS tests ───────────────────────────────────────────────────
def _nasa_ads_engine():
"""Build a minimal NasaAdsSearchEngine for testing."""
from local_deep_research.web_search_engines.engines import (
search_engine_nasa_ads as mod,
)
engine = mod.NasaAdsSearchEngine.__new__(mod.NasaAdsSearchEngine)
engine.api_key = _LEAKED_KEY
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
return mod, engine
class TestNasaAdsKeyLeakage:
def test_format_doc_preview_no_leak(self, loguru_caplog_full):
"""``_format_doc_preview`` must not leak the API key."""
mod, engine = _nasa_ads_engine()
# Pass a doc that will cause a formatting error
bad_doc = {"bibcode": None}
with loguru_caplog_full.at_level("DEBUG"):
engine._format_doc_preview(bad_doc)
# Result is None on error
# The main thing is no key in logs regardless
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
# ── Guardian tests ───────────────────────────────────────────────────
def _guardian_engine():
"""Build a minimal GuardianSearchEngine for testing."""
from local_deep_research.web_search_engines.engines import (
search_engine_guardian as mod,
)
engine = mod.GuardianSearchEngine.__new__(mod.GuardianSearchEngine)
engine.api_key = _LEAKED_KEY
engine.engine_type = "test_engine"
engine.rate_tracker = _stub_rate_tracker()
engine.api_url = "https://content.guardianapis.com"
engine.llm = None
engine.optimize_queries = True
return mod, engine
class TestGuardianKeyLeakage:
def test_optimize_query_no_leak(self, loguru_caplog_full):
"""``_optimize_query_for_guardian`` must not leak the API key."""
mod, engine = _guardian_engine()
engine.llm = Mock()
engine.llm.invoke = Mock(
side_effect=RuntimeError(f"LLM error key={_LEAKED_KEY}")
)
with loguru_caplog_full.at_level("DEBUG"):
result = engine._optimize_query_for_guardian("test query")
assert result == "test query"
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
def test_run_catch_all_no_leak(self, loguru_caplog_full):
"""The catch-all in ``run()`` must not leak the API key."""
mod, engine = _guardian_engine()
exc = RuntimeError(f"Unexpected error: key={_LEAKED_KEY}")
with loguru_caplog_full.at_level("DEBUG"):
with patch.object(engine, "_get_previews", side_effect=exc):
engine._original_date_params = {
"from_date": None,
"to_date": None,
}
engine.from_date = None
engine.to_date = None
engine.programmatic_mode = False
result = engine.run("test query")
assert result == []
for encoding in _all_encodings_of(_LEAKED_KEY):
assert encoding not in loguru_caplog_full.text
+100
View File
@@ -0,0 +1,100 @@
# allow: no-sut-import — black-box HTTP test; drives real routes through the Flask test client
"""
End-to-end CSRF flow test for browser-facing API endpoints.
Verifies the full flow: login → CSRF token → API call with CSRF enabled,
ensuring the narrowed CSRF exemptions (only api_v1 exempt) work correctly
for browser-facing endpoints like /api/start_research.
"""
import re
import uuid
import pytest
class TestCSRFEndToEndFlow:
"""Test the complete CSRF flow from login through API usage."""
@pytest.fixture
def csrf_app(self, app):
"""Wrap the root conftest app fixture with CSRF enabled."""
app.config["WTF_CSRF_ENABLED"] = True
app.config["WTF_CSRF_CHECK_DEFAULT"] = True
return app
def test_full_csrf_flow_login_through_api_call(self, csrf_app):
"""Test complete browser-like flow: register → get CSRF token → API call."""
client = csrf_app.test_client()
with client:
# Step 1: GET /auth/login to get a CSRF token for registration
response = client.get("/auth/login")
assert response.status_code == 200
csrf_match = re.search(
r'name="csrf_token" value="([^"]+)"', response.data.decode()
)
assert csrf_match is not None, (
"Could not find CSRF token in login page"
)
form_csrf_token = csrf_match.group(1)
# Step 2: Register (auto-logs in the user)
username = f"csrf_e2e_{uuid.uuid4().hex[:12]}"
password = "TestPass123!"
register_response = client.post(
"/auth/register",
data={
"username": username,
"password": password,
"confirm_password": password,
"acknowledge": "true",
"csrf_token": form_csrf_token,
},
follow_redirects=False,
)
assert register_response.status_code == 302, (
f"Registration should redirect, got {register_response.status_code}"
)
# Step 3: Get API CSRF token from /auth/csrf-token
csrf_response = client.get("/auth/csrf-token")
assert csrf_response.status_code == 200
csrf_data = csrf_response.get_json()
assert csrf_data is not None and "csrf_token" in csrf_data
api_csrf_token = csrf_data["csrf_token"]
# Step 4: POST /api/start_research WITH CSRF token — should pass CSRF
response = client.post(
"/api/start_research",
json={"query": "test csrf flow"},
headers={"X-CSRFToken": api_csrf_token},
)
# Expect 200 (success) or 400 from business logic — but not a CSRF rejection
assert response.status_code in (200, 400), (
f"Expected 200 or business-logic 400, got {response.status_code}"
)
if response.status_code == 400:
data = response.get_json()
error_msg = data.get("error", "") if data else ""
assert "csrf" not in error_msg.lower(), (
f"CSRF should have passed but got: {error_msg}"
)
assert "session cookie" not in error_msg.lower(), (
f"CSRF should have passed but got: {error_msg}"
)
# Step 5: POST /api/start_research WITHOUT CSRF token — should be rejected
response = client.post(
"/api/start_research",
json={"query": "test csrf flow"},
)
assert response.status_code == 400
data = response.get_json()
assert data is not None and "error" in data
error_lower = data["error"].lower()
assert "csrf" in error_lower or "session cookie" in error_lower, (
f"Expected CSRF error but got: {data['error']}"
)
+31
View File
@@ -0,0 +1,31 @@
# allow: no-sut-import — exercises the real app fixture; asserts CSRF exemptions on the live blueprint config
"""Test CSRF hardening configuration against the real app."""
class TestCsrfHardening:
"""Verify CSRF exemptions are narrowly scoped."""
def test_api_v1_is_csrf_exempt(self, app):
"""api_v1 blueprint should be CSRF-exempt (programmatic REST API)."""
csrf = app.extensions.get("csrf")
assert csrf is not None, "CSRFProtect extension not initialized"
exempt_names = {bp.name for bp in csrf._exempt_blueprints}
assert "api_v1" in exempt_names
def test_api_blueprint_not_exempt(self, app):
"""api blueprint (browser-facing) should require CSRF tokens."""
csrf = app.extensions["csrf"]
exempt_names = {bp.name for bp in csrf._exempt_blueprints}
assert "api" not in exempt_names
def test_benchmark_blueprint_not_exempt(self, app):
"""benchmark blueprint (browser-facing) should require CSRF tokens."""
csrf = app.extensions["csrf"]
exempt_names = {bp.name for bp in csrf._exempt_blueprints}
assert "benchmark" not in exempt_names
def test_research_blueprint_not_exempt(self, app):
"""research blueprint (browser-facing) should require CSRF tokens."""
csrf = app.extensions["csrf"]
exempt_names = {bp.name for bp in csrf._exempt_blueprints}
assert "research" not in exempt_names
+328
View File
@@ -0,0 +1,328 @@
"""
CSRF (Cross-Site Request Forgery) Protection Tests
Tests that verify CSRF protection is properly implemented
using Flask-WTF CSRF tokens for state-changing operations.
"""
import pytest
from tests.test_utils import add_src_to_path
add_src_to_path()
class TestCSRFProtection:
"""Test CSRF protection in web forms and API endpoints."""
@pytest.fixture
def app(self):
"""Create a test Flask app instance."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
# Enable CSRF for these tests
app.config["WTF_CSRF_ENABLED"] = True
app.config["WTF_CSRF_CHECK_DEFAULT"] = True
app.config["SECRET_KEY"] = "test-secret-key-for-csrf"
return app
@pytest.fixture
def client(self, app):
"""Create a test client."""
return app.test_client()
@pytest.fixture
def client_no_csrf(self):
"""Create a test client with CSRF disabled for comparison."""
from local_deep_research.web.app import create_app
app, _ = create_app() # Unpack tuple (app, socket_service)
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
return app.test_client()
def test_csrf_token_endpoint_exists(self, client):
"""Test that CSRF token endpoint is available."""
response = client.get("/auth/csrf-token")
assert response.status_code == 200
data = response.get_json()
assert "csrf_token" in data
assert len(data["csrf_token"]) > 0
def test_csrf_token_is_unique_per_session(self, app):
"""Test that each session gets a unique CSRF token."""
with app.test_client() as client1:
response1 = client1.get("/auth/csrf-token")
token1 = response1.get_json()["csrf_token"]
with app.test_client() as client2:
response2 = client2.get("/auth/csrf-token")
token2 = response2.get_json()["csrf_token"]
# Different sessions should have different tokens
assert token1 != token2
def test_post_request_without_csrf_token_rejected(self, client):
"""Test that POST requests without CSRF token are rejected when CSRF is enabled."""
# Try to submit a form without CSRF token
response = client.post(
"/auth/login",
data={"username": "testuser", "password": "testpass"},
follow_redirects=False,
)
# Should be rejected (400 Bad Request or redirect with error)
# Flask-WTF typically returns 400 for missing CSRF token
assert response.status_code == 400, response.status_code
def test_post_request_with_invalid_csrf_token_rejected(self, client):
"""Test that POST requests with invalid CSRF token are rejected."""
# Try to submit with fake/invalid CSRF token
response = client.post(
"/auth/login",
data={
"username": "testuser",
"password": "testpass",
"csrf_token": "invalid-fake-token-12345",
},
follow_redirects=False,
)
# Should be rejected
assert response.status_code == 400, response.status_code
def test_post_request_with_valid_csrf_token_accepted(self, client):
"""Test that POST requests with valid CSRF token are processed."""
# Get a valid CSRF token
csrf_response = client.get("/auth/csrf-token")
csrf_token = csrf_response.get_json()["csrf_token"]
# Submit request with valid CSRF token
response = client.post(
"/auth/login",
data={
"username": "testuser",
"password": "testpass",
"csrf_token": csrf_token,
},
follow_redirects=False,
)
# Should not be rejected due to CSRF (which would be 400 with CSRF error message)
# May return other status codes for invalid credentials, but not CSRF-related 400
# If it's 400, check it's not a CSRF error
if response.status_code == 400:
# Check if it's a CSRF error specifically
response_data = response.get_data(as_text=True)
# CSRF errors typically contain "CSRF" in the response
assert "csrf" not in response_data.lower(), (
f"CSRF validation failed even with valid token: {response_data}"
)
def test_csrf_token_in_json_requests(self, client):
"""Test CSRF protection for JSON API requests."""
# Get CSRF token
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REWRITE).
csrf_response = client.get("/auth/csrf-token")
csrf_token = csrf_response.get_json()["csrf_token"]
# Try POST request without CSRF token in headers
client.post(
"/api/v1/research",
json={"query": "test query"},
content_type="application/json",
)
# Try POST request with CSRF token in headers
client.post(
"/api/v1/research",
json={"query": "test query"},
headers={"X-CSRFToken": csrf_token},
content_type="application/json",
)
# Without token might be rejected (depending on implementation)
# With token should be processed (may have other validation)
# This documents expected behavior
# Note: Some APIs may exempt certain endpoints from CSRF
# (e.g., if using token-based auth instead of cookies)
def test_csrf_token_changes_on_regeneration(self, client):
"""Test that CSRF tokens can be regenerated."""
# Get first token
response1 = client.get("/auth/csrf-token")
token1 = response1.get_json()["csrf_token"]
# Get second token (same session)
response2 = client.get("/auth/csrf-token")
token2 = response2.get_json()["csrf_token"]
# Tokens should be stable within same session
# Or may regenerate on each request (implementation dependent)
assert isinstance(token1, str)
assert isinstance(token2, str)
def test_csrf_protection_on_state_changing_operations(self, client_no_csrf):
"""Test that state-changing operations require CSRF protection."""
# State-changing operations that should require CSRF:
# - Login/Logout
# - Creating research
# - Deleting research
# - Updating settings
# - Any POST, PUT, DELETE, PATCH requests
# Safe operations (no CSRF needed):
# - GET requests (should be idempotent)
# - HEAD, OPTIONS requests
# Test that GET requests don't require CSRF
get_response = client_no_csrf.get("/")
assert get_response.status_code in [200, 302, 404] # Should work
# POST should ideally require CSRF (tested above)
# This is a documentation test
assert True
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_csrf_token_not_leaked_in_logs_or_urls(self):
"""Test that CSRF tokens are not leaked in logs or URLs."""
# CSRF tokens should:
# 1. Not appear in URL query parameters (use POST body or headers)
# 2. Not be logged to console or log files
# 3. Not be exposed in error messages
# 4. Be transmitted over HTTPS only in production
# This is a security best practice documentation test
# CSRF tokens should be in:
# - Hidden form fields
# - Request headers (X-CSRFToken)
# - Request body (for form submissions)
# CSRF tokens should NOT be in:
# - URL query parameters (e.g., ?csrf=token)
# - Referer headers
# - Log files
assert True # Documentation test
def test_double_submit_cookie_pattern(self, client):
"""Test double-submit cookie CSRF protection pattern (if implemented)."""
# Double-submit cookie pattern:
# 1. Server sets CSRF token in cookie
# 2. Client must include same token in request header/body
# 3. Server verifies cookie matches header/body
# Flask-WTF uses session-based CSRF tokens by default
# This test documents the CSRF protection mechanism
# Get CSRF token
response = client.get("/auth/csrf-token")
token = response.get_json()["csrf_token"]
# Token should be associated with session
assert token is not None
assert len(token) > 0
def test_csrf_protection_exempt_endpoints(self, client):
"""Test that some endpoints may be exempt from CSRF protection."""
# Some endpoints may be intentionally exempt from CSRF:
# - Health check endpoint
# - Webhook endpoints (verified by other means)
# - Public read-only APIs
# Health check should work without CSRF
response = client.get("/api/v1/health")
assert response.status_code == 200
# This documents that certain endpoints don't need CSRF
assert True
class TestCSRFProtectionDocumentation:
"""Documentation tests for CSRF protection strategy."""
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_csrf_protection_strategy_documentation(self):
"""
Document CSRF protection strategy for LDR.
CSRF Protection Mechanisms:
1. Flask-WTF CSRF tokens for web forms
2. Token validation on all state-changing operations (POST/PUT/DELETE)
3. CSRF token available via /auth/csrf-token endpoint for API clients
4. Tokens tied to user session
How CSRF Works:
1. Attacker tricks victim into visiting malicious site
2. Malicious site sends forged request to legitimate site
3. Request uses victim's cookies (auto-sent by browser)
4. Without CSRF protection, legitimate site executes unwanted action
CSRF Protection:
- Require CSRF token with each state-changing request
- Token is tied to user's session
- Attacker cannot obtain victim's token (same-origin policy)
- Forged requests without valid token are rejected
LDR-Specific Considerations:
- Local/self-hosted tool: CSRF risk is lower than multi-user SaaS
- Still important for web interface security
- API clients must obtain CSRF token before making requests
Protected Operations:
- User authentication (login/logout)
- Research creation/deletion
- Settings updates
- Any database modifications
Exempt Operations:
- Read-only GET requests
- Public API endpoints (if using API key auth instead)
- Health checks
"""
assert True # Documentation test
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_csrf_vs_cors_clarification(self):
"""
Clarify difference between CSRF and CORS.
CSRF (Cross-Site Request Forgery):
- Attack: Malicious site makes unauthorized requests on behalf of user
- Protection: CSRF tokens, SameSite cookies
- Scope: Protects against forged state-changing requests
CORS (Cross-Origin Resource Sharing):
- Feature: Allows controlled access to resources from different origins
- Protection: Controls which external sites can make requests
- Scope: Browser security policy for cross-origin requests
Both are needed for comprehensive web security.
"""
assert True # Documentation test
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_csrf_integration_with_authentication():
"""
Test that CSRF protection works correctly with authentication.
CSRF and Authentication:
- CSRF protects authenticated users from forged requests
- Attacker cannot forge requests even with victim's session cookie
- CSRF token is separate from authentication token/session
- Both are required for state-changing authenticated operations
Authentication Flow with CSRF:
1. User authenticates (gets session cookie)
2. User obtains CSRF token
3. User makes authenticated request with both session and CSRF token
4. Server validates both authentication and CSRF token
5. Only then is request processed
This provides defense-in-depth security.
"""
assert True # Documentation test
+502
View File
@@ -0,0 +1,502 @@
"""
Tests for DataSanitizer security module.
"""
from local_deep_research.security.data_sanitizer import (
DataSanitizer,
filter_research_metadata,
redact_data,
sanitize_data,
strip_settings_snapshot,
)
class TestDataSanitizerSanitize:
"""Tests for DataSanitizer.sanitize()."""
def test_sanitize_removes_api_key(self):
"""Removes api_key from data."""
data = {"username": "user", "api_key": "sk-secret-12345"}
result = DataSanitizer.sanitize(data)
assert "username" in result
assert "api_key" not in result
def test_sanitize_removes_password(self):
"""Removes password from data."""
data = {"user": "test", "password": "supersecret"}
result = DataSanitizer.sanitize(data)
assert "user" in result
assert "password" not in result
def test_sanitize_removes_access_token(self):
"""Removes access_token from data."""
data = {"id": 1, "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"}
result = DataSanitizer.sanitize(data)
assert "id" in result
assert "access_token" not in result
def test_sanitize_case_insensitive(self):
"""Sanitization is case-insensitive."""
data = {"API_KEY": "secret1", "ApiKey": "secret2", "apikey": "secret3"}
result = DataSanitizer.sanitize(data)
assert len(result) == 0
def test_sanitize_nested_dict(self):
"""Sanitizes nested dictionaries."""
data = {
"user": "test",
"settings": {
"theme": "dark",
"api_key": "nested_secret",
},
}
result = DataSanitizer.sanitize(data)
assert result["user"] == "test"
assert "theme" in result["settings"]
assert "api_key" not in result["settings"]
def test_sanitize_deeply_nested(self):
"""Sanitizes deeply nested structures."""
data = {
"level1": {
"level2": {
"level3": {
"password": "deep_secret",
"value": "keep_this",
}
}
}
}
result = DataSanitizer.sanitize(data)
assert result["level1"]["level2"]["level3"]["value"] == "keep_this"
assert "password" not in result["level1"]["level2"]["level3"]
def test_sanitize_list_of_dicts(self):
"""Sanitizes list of dictionaries."""
data = [
{"name": "item1", "secret": "value1"},
{"name": "item2", "secret": "value2"},
]
result = DataSanitizer.sanitize(data)
assert len(result) == 2
assert result[0]["name"] == "item1"
assert "secret" not in result[0]
assert result[1]["name"] == "item2"
assert "secret" not in result[1]
def test_sanitize_mixed_structure(self):
"""Sanitizes mixed nested structures."""
data = {
"users": [
{"username": "user1", "auth_token": "token1"},
{"username": "user2", "auth_token": "token2"},
],
"config": {
"public": True,
"private_key": "key123",
},
}
result = DataSanitizer.sanitize(data)
assert result["users"][0]["username"] == "user1"
assert "auth_token" not in result["users"][0]
assert result["config"]["public"] is True
assert "private_key" not in result["config"]
def test_sanitize_preserves_primitives(self):
"""Preserves primitive values unchanged."""
assert DataSanitizer.sanitize("string") == "string"
assert DataSanitizer.sanitize(123) == 123
assert DataSanitizer.sanitize(12.5) == 12.5
assert DataSanitizer.sanitize(True) is True
assert DataSanitizer.sanitize(None) is None
def test_sanitize_empty_dict(self):
"""Handles empty dictionary."""
assert DataSanitizer.sanitize({}) == {}
def test_sanitize_empty_list(self):
"""Handles empty list."""
assert DataSanitizer.sanitize([]) == []
def test_sanitize_custom_keys(self):
"""Uses custom sensitive keys."""
data = {
"custom_secret": "value",
"api_key": "standard_secret",
"name": "keep",
}
result = DataSanitizer.sanitize(data, sensitive_keys={"custom_secret"})
assert "custom_secret" not in result
assert "api_key" in result # Not in custom keys
assert "name" in result
def test_sanitize_all_default_keys(self):
"""Tests all default sensitive keys."""
data = {
"api_key": "1",
"apikey": "2",
"password": "3",
"secret": "4",
"access_token": "5",
"refresh_token": "6",
"private_key": "7",
"auth_token": "8",
"session_token": "9",
"csrf_token": "10",
"safe_key": "keep",
}
result = DataSanitizer.sanitize(data)
assert result == {"safe_key": "keep"}
class TestDataSanitizerRedact:
"""Tests for DataSanitizer.redact()."""
def test_redact_replaces_api_key(self):
"""Replaces api_key value with redaction text."""
data = {"username": "user", "api_key": "sk-secret-12345"}
result = DataSanitizer.redact(data)
assert result["username"] == "user"
assert result["api_key"] == "[REDACTED]"
def test_redact_preserves_structure(self):
"""Preserves data structure while redacting values."""
data = {"id": 1, "password": "secret123"}
result = DataSanitizer.redact(data)
assert "id" in result
assert "password" in result
assert result["password"] == "[REDACTED]"
def test_redact_custom_text(self):
"""Uses custom redaction text."""
data = {"password": "secret"}
result = DataSanitizer.redact(data, redaction_text="***")
assert result["password"] == "***"
def test_redact_case_insensitive(self):
"""Redaction is case-insensitive."""
data = {"API_KEY": "secret1", "ApiKey": "secret2", "apikey": "secret3"}
result = DataSanitizer.redact(data)
assert all(v == "[REDACTED]" for v in result.values())
def test_redact_nested_dict(self):
"""Redacts nested dictionaries."""
data = {
"user": "test",
"settings": {
"theme": "dark",
"api_key": "nested_secret",
},
}
result = DataSanitizer.redact(data)
assert result["user"] == "test"
assert result["settings"]["theme"] == "dark"
assert result["settings"]["api_key"] == "[REDACTED]"
def test_redact_list_of_dicts(self):
"""Redacts list of dictionaries."""
data = [
{"name": "item1", "secret": "value1"},
{"name": "item2", "secret": "value2"},
]
result = DataSanitizer.redact(data)
assert result[0]["name"] == "item1"
assert result[0]["secret"] == "[REDACTED]"
assert result[1]["name"] == "item2"
assert result[1]["secret"] == "[REDACTED]"
def test_redact_preserves_primitives(self):
"""Preserves primitive values unchanged."""
assert DataSanitizer.redact("string") == "string"
assert DataSanitizer.redact(123) == 123
assert DataSanitizer.redact(True) is True
def test_redact_custom_keys(self):
"""Uses custom sensitive keys."""
data = {"custom_secret": "value", "api_key": "keep", "name": "keep"}
result = DataSanitizer.redact(data, sensitive_keys={"custom_secret"})
assert result["custom_secret"] == "[REDACTED]"
assert result["api_key"] == "keep" # Not in custom keys
assert result["name"] == "keep"
class TestConvenienceFunctions:
"""Tests for convenience functions."""
def test_sanitize_data_function(self):
"""sanitize_data() works correctly."""
data = {"username": "user", "password": "secret"}
result = sanitize_data(data)
assert "username" in result
assert "password" not in result
def test_sanitize_data_with_custom_keys(self):
"""sanitize_data() with custom keys."""
data = {"custom": "secret", "keep": "value"}
result = sanitize_data(data, sensitive_keys={"custom"})
assert "custom" not in result
assert "keep" in result
def test_redact_data_function(self):
"""redact_data() works correctly."""
data = {"username": "user", "password": "secret"}
result = redact_data(data)
assert result["username"] == "user"
assert result["password"] == "[REDACTED]"
def test_redact_data_with_custom_text(self):
"""redact_data() with custom redaction text."""
data = {"password": "secret"}
result = redact_data(data, redaction_text="<hidden>")
assert result["password"] == "<hidden>"
class TestDefaultSensitiveKeys:
"""Tests for DEFAULT_SENSITIVE_KEYS constant."""
def test_default_keys_exist(self):
"""Default sensitive keys are defined."""
assert isinstance(DataSanitizer.DEFAULT_SENSITIVE_KEYS, set)
assert len(DataSanitizer.DEFAULT_SENSITIVE_KEYS) > 0
def test_default_keys_include_common_secrets(self):
"""Default keys include common secret patterns."""
expected = {
"api_key",
"password",
"secret",
"access_token",
"refresh_token",
"private_key",
}
assert expected.issubset(DataSanitizer.DEFAULT_SENSITIVE_KEYS)
class TestExpandedSecretLeafNames:
"""Secret leaf-names beyond the classic set must be caught by the
name-only predicate the bulk settings GET relies on (ui_element=None).
"""
NEW_NAMES = [
"client_secret",
"secret_key",
"bearer_token",
"api_secret",
"app_secret",
]
def test_present_in_default_set(self):
assert set(self.NEW_NAMES).issubset(
DataSanitizer.DEFAULT_SENSITIVE_KEYS
)
def test_recognized_by_leaf(self):
for name in self.NEW_NAMES:
key = f"integrations.oauth.{name}"
assert DataSanitizer.is_sensitive_setting(key)
# mirrors the bulk GET call: redact_value(key, None, value)
assert (
DataSanitizer.redact_value(key, None, "topsecret-value")
== DataSanitizer.REDACTION_TEXT
)
def test_no_over_redaction_of_similar_names(self):
# exact-leaf match must NOT redact these legit non-secret keys.
# The last two leaves CONTAIN a sensitive name as a proper substring
# ("password" in "passwordless", "secret" in "secretary") — they pin
# the predicate to exact-match, so a substring-broadening regression
# (e.g. `any(s in leaf ...)`) would be caught here.
for key in (
"llm.max_tokens",
"cache.key",
"search.public_key",
"app.token_count",
"oauth.client_id",
"llm.passwordless",
"integrations.secretary",
):
assert DataSanitizer.redact_value(key, None, "value") == "value"
class TestEdgeCases:
"""Edge case tests for DataSanitizer."""
def test_sanitize_with_none_sensitive_keys(self):
"""Uses default keys when sensitive_keys is None."""
data = {"api_key": "secret", "name": "keep"}
result = DataSanitizer.sanitize(data, sensitive_keys=None)
assert "api_key" not in result
assert "name" in result
def test_sanitize_with_empty_sensitive_keys(self):
"""Empty sensitive keys keeps all data."""
data = {"api_key": "secret", "password": "pass"}
result = DataSanitizer.sanitize(data, sensitive_keys=set())
assert "api_key" in result
assert "password" in result
def test_sanitize_list_with_primitives(self):
"""Handles list with primitive values."""
data = [1, 2, "string", None, True]
result = DataSanitizer.sanitize(data)
assert result == [1, 2, "string", None, True]
def test_sanitize_does_not_mutate_original(self):
"""Sanitization does not mutate original data."""
original = {"api_key": "secret", "name": "keep"}
_ = DataSanitizer.sanitize(original)
assert "api_key" in original
assert original["api_key"] == "secret"
def test_redact_does_not_mutate_original(self):
"""Redaction does not mutate original data."""
original = {"password": "secret", "name": "keep"}
_ = DataSanitizer.redact(original)
assert original["password"] == "secret"
class TestFilterResearchMetadata:
"""Tests for filter_research_metadata() helper."""
def test_none_input(self):
"""None returns default."""
assert filter_research_metadata(None) == {"is_news_search": False}
def test_empty_dict(self):
"""Empty dict returns default."""
assert filter_research_metadata({}) == {"is_news_search": False}
def test_strips_settings_snapshot(self):
"""Extracts only is_news_search, strips settings_snapshot."""
meta = {
"is_news_search": True,
"settings_snapshot": {"api_key": "sk-secret"},
}
result = filter_research_metadata(meta)
assert result == {"is_news_search": True}
assert "settings_snapshot" not in result
def test_explicit_false_preserved(self):
"""Explicit is_news_search=False is preserved."""
assert filter_research_metadata({"is_news_search": False}) == {
"is_news_search": False
}
def test_json_string_input(self):
"""JSON string is parsed correctly."""
result = filter_research_metadata('{"is_news_search": true}')
assert result == {"is_news_search": True}
def test_invalid_json_string(self):
"""Invalid JSON string returns default."""
assert filter_research_metadata("invalid json") == {
"is_news_search": False
}
def test_non_dict_json(self):
"""Non-dict JSON (e.g. array) returns default."""
assert filter_research_metadata("[]") == {"is_news_search": False}
def test_non_string_non_dict(self):
"""Non-string, non-dict input (e.g. int) returns default."""
assert filter_research_metadata(42) == {"is_news_search": False}
class TestStripSettingsSnapshot:
"""Tests for strip_settings_snapshot() helper."""
def test_none_input(self):
"""None returns empty dict."""
assert strip_settings_snapshot(None) == {}
def test_strips_settings_snapshot(self):
"""Removes settings_snapshot, keeps other keys."""
meta = {
"phase": "complete",
"settings_snapshot": {"api_key": "sk-secret"},
}
assert strip_settings_snapshot(meta) == {"phase": "complete"}
def test_no_op_when_absent(self):
"""Returns same dict when settings_snapshot absent."""
meta = {"phase": "error", "error_type": "timeout"}
assert strip_settings_snapshot(meta) == meta
def test_only_exact_key_stripped(self):
"""Only strips exact 'settings_snapshot' key, not similar names."""
meta = {"settings_version": 2, "settings_snapshot": {}}
assert strip_settings_snapshot(meta) == {"settings_version": 2}
def test_json_string_input(self):
"""JSON string is parsed and stripped."""
result = strip_settings_snapshot(
'{"phase": "done", "settings_snapshot": {}}'
)
assert result == {"phase": "done"}
def test_invalid_json_string(self):
"""Invalid JSON string returns empty dict."""
assert strip_settings_snapshot("invalid json") == {}
def test_does_not_mutate_original(self):
"""Original input dict is NOT modified."""
original = {
"phase": "complete",
"settings_snapshot": {"api_key": "sk-secret"},
}
_ = strip_settings_snapshot(original)
assert "settings_snapshot" in original
assert original["settings_snapshot"]["api_key"] == "sk-secret"
class TestRedactValueNestedDict:
"""redact_value must recurse into subtree dicts.
A namespace request (e.g. get_setting("llm")) returns a nested dict whose
outer key is not sensitive but whose inner keys are — without recursion
those nested secrets would ship in the clear via GET /settings/api/bulk.
"""
def test_nested_sensitive_leaf_redacted(self):
value = {"provider": "openai", "openai.api_key": "sk-secret"}
out = DataSanitizer.redact_value("llm", None, value)
assert out["openai.api_key"] == DataSanitizer.REDACTION_TEXT
assert out["provider"] == "openai"
def test_deeply_nested_secret_redacted(self):
value = {"openai": {"api_key": "sk-secret"}}
out = DataSanitizer.redact_value("llm", None, value)
assert out["openai"]["api_key"] == DataSanitizer.REDACTION_TEXT
def test_sensitive_outer_key_with_dict_redacted_wholesale(self):
# A sensitive-named key holding a dict is masked entirely (not recursed)
out = DataSanitizer.redact_value("x.api_key", None, {"nested": "v"})
assert out == DataSanitizer.REDACTION_TEXT
def test_primitive_value_unchanged(self):
assert (
DataSanitizer.redact_value("llm.provider", None, "openai")
== "openai"
)
def test_empty_dict_unchanged(self):
assert DataSanitizer.redact_value("llm", None, {}) == {}
def test_list_of_dicts_secret_redacted(self):
# a JSON list-of-dicts leaf (e.g. mcp.servers) must not ship secrets
value = [{"name": "prod", "api_key": "sk-secret"}]
out = DataSanitizer.redact_value("mcp.servers", None, value)
assert out[0]["api_key"] == DataSanitizer.REDACTION_TEXT
assert out[0]["name"] == "prod"
def test_plain_list_unchanged(self):
# a list of scalars under a non-sensitive key is not mangled
value = ["host1", "host2"]
assert DataSanitizer.redact_value("search.hosts", None, value) == [
"host1",
"host2",
]
def test_sensitive_key_with_list_redacted_wholesale(self):
out = DataSanitizer.redact_value("x.secret", None, ["a", "b"])
assert out == DataSanitizer.REDACTION_TEXT
@@ -0,0 +1,488 @@
"""
Behavioral tests for data_sanitizer module.
Tests the DataSanitizer class which removes or redacts sensitive
information from data structures.
"""
class TestSanitizeBasicDictionary:
"""Tests for sanitizing basic dictionaries."""
def test_removes_api_key(self):
"""api_key is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"username": "user", "api_key": "secret123"}
result = DataSanitizer.sanitize(data)
assert "api_key" not in result
assert result["username"] == "user"
def test_removes_password(self):
"""password is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"username": "user", "password": "secret123"}
result = DataSanitizer.sanitize(data)
assert "password" not in result
assert result["username"] == "user"
def test_removes_secret(self):
"""secret is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"name": "service", "secret": "abc123"}
result = DataSanitizer.sanitize(data)
assert "secret" not in result
assert result["name"] == "service"
def test_removes_access_token(self):
"""access_token is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"user_id": 1, "access_token": "token123"}
result = DataSanitizer.sanitize(data)
assert "access_token" not in result
assert result["user_id"] == 1
def test_removes_refresh_token(self):
"""refresh_token is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"user_id": 1, "refresh_token": "refresh123"}
result = DataSanitizer.sanitize(data)
assert "refresh_token" not in result
def test_removes_private_key(self):
"""private_key is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"key_id": "k1", "private_key": "-----BEGIN..."}
result = DataSanitizer.sanitize(data)
assert "private_key" not in result
def test_removes_auth_token(self):
"""auth_token is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"session": "s1", "auth_token": "auth123"}
result = DataSanitizer.sanitize(data)
assert "auth_token" not in result
def test_removes_session_token(self):
"""session_token is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"user": "u1", "session_token": "sess123"}
result = DataSanitizer.sanitize(data)
assert "session_token" not in result
def test_removes_csrf_token(self):
"""csrf_token is removed from dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"form": "f1", "csrf_token": "csrf123"}
result = DataSanitizer.sanitize(data)
assert "csrf_token" not in result
def test_removes_apikey_no_underscore(self):
"""apikey (without underscore) is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"service": "s1", "apikey": "key123"}
result = DataSanitizer.sanitize(data)
assert "apikey" not in result
class TestSanitizeCaseInsensitive:
"""Tests for case-insensitive key matching."""
def test_removes_API_KEY_uppercase(self):
"""API_KEY in uppercase is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"name": "test", "API_KEY": "secret"}
result = DataSanitizer.sanitize(data)
assert "API_KEY" not in result
def test_removes_Api_Key_mixed_case(self):
"""Api_Key in mixed case is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"name": "test", "Api_Key": "secret"}
result = DataSanitizer.sanitize(data)
assert "Api_Key" not in result
def test_removes_PASSWORD_uppercase(self):
"""PASSWORD in uppercase is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"user": "u1", "PASSWORD": "secret"}
result = DataSanitizer.sanitize(data)
assert "PASSWORD" not in result
def test_removes_Secret_capitalized(self):
"""Secret capitalized is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"id": 1, "Secret": "mysecret"}
result = DataSanitizer.sanitize(data)
assert "Secret" not in result
class TestSanitizeNestedStructures:
"""Tests for nested dictionaries and lists."""
def test_removes_key_in_nested_dict(self):
"""api_key in nested dictionary is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"user": {"name": "test", "api_key": "secret"}}
result = DataSanitizer.sanitize(data)
assert "api_key" not in result["user"]
assert result["user"]["name"] == "test"
def test_removes_key_in_deeply_nested_dict(self):
"""api_key in deeply nested dictionary is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"a": {"b": {"c": {"password": "secret", "value": 1}}}}
result = DataSanitizer.sanitize(data)
assert "password" not in result["a"]["b"]["c"]
assert result["a"]["b"]["c"]["value"] == 1
def test_removes_key_in_list_of_dicts(self):
"""api_key in list of dictionaries is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = [{"name": "a", "api_key": "k1"}, {"name": "b", "api_key": "k2"}]
result = DataSanitizer.sanitize(data)
assert all("api_key" not in item for item in result)
assert result[0]["name"] == "a"
assert result[1]["name"] == "b"
def test_removes_key_in_mixed_nested_structure(self):
"""api_key in mixed nested structure is removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {
"users": [{"id": 1, "password": "p1"}, {"id": 2, "password": "p2"}],
"config": {"api_key": "main_key"},
}
result = DataSanitizer.sanitize(data)
assert all("password" not in user for user in result["users"])
assert "api_key" not in result["config"]
class TestSanitizePrimitives:
"""Tests for primitive values."""
def test_returns_string_unchanged(self):
"""String primitives are returned unchanged."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert DataSanitizer.sanitize("hello") == "hello"
def test_returns_number_unchanged(self):
"""Number primitives are returned unchanged."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert DataSanitizer.sanitize(42) == 42
assert DataSanitizer.sanitize(3.14) == 3.14
def test_returns_boolean_unchanged(self):
"""Boolean primitives are returned unchanged."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert DataSanitizer.sanitize(True) is True
assert DataSanitizer.sanitize(False) is False
def test_returns_none_unchanged(self):
"""None is returned unchanged."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert DataSanitizer.sanitize(None) is None
class TestSanitizeCustomKeys:
"""Tests for custom sensitive key sets."""
def test_custom_keys_are_removed(self):
"""Custom sensitive keys are removed."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"custom_secret": "value", "name": "test"}
result = DataSanitizer.sanitize(data, sensitive_keys={"custom_secret"})
assert "custom_secret" not in result
assert result["name"] == "test"
def test_default_keys_not_removed_with_custom(self):
"""Default keys are not removed when custom set is provided."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"api_key": "value", "custom_key": "secret"}
result = DataSanitizer.sanitize(data, sensitive_keys={"custom_key"})
# With custom keys, default keys are not removed
assert "api_key" in result
assert "custom_key" not in result
def test_empty_custom_keys_removes_nothing(self):
"""Empty custom key set removes nothing."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"api_key": "value", "password": "secret"}
result = DataSanitizer.sanitize(data, sensitive_keys=set())
assert "api_key" in result
assert "password" in result
class TestSanitizeEmptyInputs:
"""Tests for empty inputs."""
def test_empty_dict_returns_empty(self):
"""Empty dictionary returns empty dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert DataSanitizer.sanitize({}) == {}
def test_empty_list_returns_empty(self):
"""Empty list returns empty list."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert DataSanitizer.sanitize([]) == []
class TestSanitizeOriginalUnmodified:
"""Tests that original data is not modified."""
def test_original_dict_unchanged(self):
"""Original dictionary is not modified."""
from local_deep_research.security.data_sanitizer import DataSanitizer
original = {"name": "test", "api_key": "secret"}
DataSanitizer.sanitize(original)
assert "api_key" in original
def test_original_nested_dict_unchanged(self):
"""Original nested dictionary is not modified."""
from local_deep_research.security.data_sanitizer import DataSanitizer
original = {"user": {"name": "test", "password": "secret"}}
DataSanitizer.sanitize(original)
assert "password" in original["user"]
class TestRedactBasicDictionary:
"""Tests for redacting basic dictionaries."""
def test_redacts_api_key(self):
"""api_key value is redacted."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"username": "user", "api_key": "secret123"}
result = DataSanitizer.redact(data)
assert result["api_key"] == "[REDACTED]"
assert result["username"] == "user"
def test_redacts_password(self):
"""password value is redacted."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"username": "user", "password": "secret123"}
result = DataSanitizer.redact(data)
assert result["password"] == "[REDACTED]"
assert result["username"] == "user"
def test_redacts_multiple_keys(self):
"""Multiple sensitive keys are redacted."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {
"api_key": "k1",
"password": "p1",
"secret": "s1",
"name": "test",
}
result = DataSanitizer.redact(data)
assert result["api_key"] == "[REDACTED]"
assert result["password"] == "[REDACTED]"
assert result["secret"] == "[REDACTED]"
assert result["name"] == "test"
class TestRedactCustomText:
"""Tests for custom redaction text."""
def test_custom_redaction_text(self):
"""Custom redaction text is used."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"api_key": "secret"}
result = DataSanitizer.redact(data, redaction_text="***HIDDEN***")
assert result["api_key"] == "***HIDDEN***"
def test_empty_redaction_text(self):
"""Empty redaction text is used."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"api_key": "secret"}
result = DataSanitizer.redact(data, redaction_text="")
assert result["api_key"] == ""
class TestRedactNestedStructures:
"""Tests for redacting nested structures."""
def test_redacts_nested_dict(self):
"""Nested dictionary values are redacted."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"config": {"api_key": "secret", "url": "http://example.com"}}
result = DataSanitizer.redact(data)
assert result["config"]["api_key"] == "[REDACTED]"
assert result["config"]["url"] == "http://example.com"
def test_redacts_list_of_dicts(self):
"""List of dictionaries values are redacted."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = [
{"name": "a", "password": "p1"},
{"name": "b", "password": "p2"},
]
result = DataSanitizer.redact(data)
assert result[0]["password"] == "[REDACTED]"
assert result[1]["password"] == "[REDACTED]"
assert result[0]["name"] == "a"
class TestRedactPreservesStructure:
"""Tests that redact preserves data structure."""
def test_preserves_key_in_result(self):
"""Redacted key is still present in result."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"api_key": "secret"}
result = DataSanitizer.redact(data)
assert "api_key" in result
def test_preserves_nested_structure(self):
"""Nested structure is preserved."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"a": {"b": {"password": "secret"}}}
result = DataSanitizer.redact(data)
assert "a" in result
assert "b" in result["a"]
assert "password" in result["a"]["b"]
class TestConvenienceFunctions:
"""Tests for convenience functions."""
def test_sanitize_data_function(self):
"""sanitize_data convenience function works."""
from local_deep_research.security.data_sanitizer import sanitize_data
data = {"api_key": "secret", "name": "test"}
result = sanitize_data(data)
assert "api_key" not in result
assert result["name"] == "test"
def test_redact_data_function(self):
"""redact_data convenience function works."""
from local_deep_research.security.data_sanitizer import redact_data
data = {"api_key": "secret", "name": "test"}
result = redact_data(data)
assert result["api_key"] == "[REDACTED]"
assert result["name"] == "test"
def test_sanitize_data_with_custom_keys(self):
"""sanitize_data works with custom keys."""
from local_deep_research.security.data_sanitizer import sanitize_data
data = {"custom": "value", "other": "data"}
result = sanitize_data(data, sensitive_keys={"custom"})
assert "custom" not in result
assert result["other"] == "data"
def test_redact_data_with_custom_text(self):
"""redact_data works with custom redaction text."""
from local_deep_research.security.data_sanitizer import redact_data
data = {"password": "secret"}
result = redact_data(data, redaction_text="<removed>")
assert result["password"] == "<removed>"
class TestDefaultSensitiveKeys:
"""Tests for the default sensitive keys constant."""
def test_default_keys_include_api_key(self):
"""Default keys include api_key."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert "api_key" in DataSanitizer.DEFAULT_SENSITIVE_KEYS
def test_default_keys_include_password(self):
"""Default keys include password."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert "password" in DataSanitizer.DEFAULT_SENSITIVE_KEYS
def test_default_keys_include_secret(self):
"""Default keys include secret."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert "secret" in DataSanitizer.DEFAULT_SENSITIVE_KEYS
def test_default_keys_is_set(self):
"""Default keys is a set for O(1) lookup."""
from local_deep_research.security.data_sanitizer import DataSanitizer
assert isinstance(DataSanitizer.DEFAULT_SENSITIVE_KEYS, set)
class TestEdgeCases:
"""Tests for edge cases."""
def test_handles_none_values_in_dict(self):
"""Handles None values in dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"name": None, "api_key": "secret"}
result = DataSanitizer.sanitize(data)
assert result["name"] is None
assert "api_key" not in result
def test_handles_numeric_values(self):
"""Handles numeric values in dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"count": 42, "api_key": "secret"}
result = DataSanitizer.sanitize(data)
assert result["count"] == 42
assert "api_key" not in result
def test_handles_boolean_values(self):
"""Handles boolean values in dictionary."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = {"enabled": True, "password": "secret"}
result = DataSanitizer.sanitize(data)
assert result["enabled"] is True
assert "password" not in result
def test_handles_mixed_types_in_list(self):
"""Handles mixed types in list."""
from local_deep_research.security.data_sanitizer import DataSanitizer
data = [1, "string", {"api_key": "secret"}, None, True]
result = DataSanitizer.sanitize(data)
assert result[0] == 1
assert result[1] == "string"
assert "api_key" not in result[2]
assert result[3] is None
assert result[4] is True
@@ -0,0 +1,633 @@
"""
Comprehensive coverage tests for data_sanitizer.py.
Targets gaps not covered by existing test suites:
- Tuple and set inputs (non-list iterables)
- sanitize/redact with dict values that are lists of lists
- redact on deeply nested structures with custom keys + custom text combined
- filter_research_metadata with truthy/falsy edge values (0, empty string, empty list)
- strip_settings_snapshot preserving complex nested values
- Ensuring bool coercion in filter_research_metadata for various truthy types
- sanitize/redact on dicts where values are themselves sensitive-key dicts
- Mixed list containing nested lists and dicts
- redact with empty string redaction_text
- Large flat dict with only sensitive keys
- Key collision: same key different cases in one dict
- Convenience functions with all optional params
- strip_settings_snapshot with empty string input
- filter_research_metadata with empty string input
- Numeric and boolean JSON strings for metadata functions
"""
import json
import pytest
from local_deep_research.security.data_sanitizer import (
DataSanitizer,
filter_research_metadata,
redact_data,
sanitize_data,
strip_settings_snapshot,
)
# ---------------------------------------------------------------------------
# sanitize() -- additional coverage
# ---------------------------------------------------------------------------
class TestSanitizeTupleAndUnusualTypes:
"""Sanitize behavior with tuple, set, and other non-list/dict types."""
def test_tuple_returned_as_is(self):
"""Tuples are primitives from sanitize's perspective -- returned unchanged."""
data = (1, 2, 3)
result = DataSanitizer.sanitize(data)
assert result == (1, 2, 3)
def test_set_returned_as_is(self):
"""Sets are not traversed -- returned unchanged."""
data = {1, 2, 3}
result = DataSanitizer.sanitize(data)
assert result == {1, 2, 3}
def test_float_returned_as_is(self):
"""Float primitives pass through."""
assert DataSanitizer.sanitize(3.14) == 3.14
def test_bytes_returned_as_is(self):
"""Bytes are treated as a primitive."""
data = b"hello"
assert DataSanitizer.sanitize(data) == b"hello"
def test_zero_returned_as_is(self):
"""Zero (falsy int) passes through unchanged."""
assert DataSanitizer.sanitize(0) == 0
def test_empty_string_returned_as_is(self):
"""Empty string (falsy) passes through unchanged."""
assert DataSanitizer.sanitize("") == ""
def test_false_returned_as_is(self):
"""False (falsy bool) passes through unchanged."""
assert DataSanitizer.sanitize(False) is False
class TestSanitizeNestedListsOfLists:
"""Sanitize handles nested list structures."""
def test_list_of_lists_with_dicts(self):
"""Nested list of lists containing dicts are sanitized."""
data = [[{"api_key": "k1", "val": 1}], [{"password": "p1", "val": 2}]]
result = DataSanitizer.sanitize(data)
assert result == [[{"val": 1}], [{"val": 2}]]
def test_list_of_mixed_nested(self):
"""Mixed list containing dicts, lists, and primitives."""
data = [
{"secret": "s", "ok": True},
[{"access_token": "t"}, 42],
"plain",
]
result = DataSanitizer.sanitize(data)
assert result[0] == {"ok": True}
assert result[1] == [{}, 42]
assert result[2] == "plain"
class TestSanitizeValueTypes:
"""Sanitize removes keys regardless of the value type."""
def test_sensitive_key_with_list_value(self):
"""A sensitive key whose value is a list is still removed entirely."""
data = {"api_key": [1, 2, 3], "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "api_key" not in result
assert result == {"name": "ok"}
def test_sensitive_key_with_dict_value(self):
"""A sensitive key whose value is a dict is still removed entirely."""
data = {"password": {"nested": "data"}, "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "password" not in result
assert result == {"name": "ok"}
def test_sensitive_key_with_none_value(self):
"""A sensitive key with None value is still removed."""
data = {"secret": None, "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "secret" not in result
def test_sensitive_key_with_bool_value(self):
"""A sensitive key with boolean value is still removed."""
data = {"csrf_token": True, "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "csrf_token" not in result
def test_sensitive_key_with_numeric_value(self):
"""A sensitive key with numeric value is still removed."""
data = {"auth_token": 12345, "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "auth_token" not in result
class TestSanitizeAllKeySensitive:
"""When every key in a dict is sensitive."""
def test_all_keys_removed_returns_empty_dict(self):
data = {
"api_key": "1",
"password": "2",
"secret": "3",
}
result = DataSanitizer.sanitize(data)
assert result == {}
def test_large_dict_all_sensitive(self):
"""All 10 default sensitive keys in one dict."""
data = {
k: f"val_{i}"
for i, k in enumerate(DataSanitizer.DEFAULT_SENSITIVE_KEYS)
}
result = DataSanitizer.sanitize(data)
assert result == {}
class TestSanitizeReturnTypeSame:
"""Sanitize returns the same container type."""
def test_dict_returns_dict(self):
result = DataSanitizer.sanitize({"a": 1})
assert isinstance(result, dict)
def test_list_returns_list(self):
result = DataSanitizer.sanitize([1, 2])
assert isinstance(result, list)
# ---------------------------------------------------------------------------
# redact() -- additional coverage
# ---------------------------------------------------------------------------
class TestRedactTupleAndUnusualTypes:
"""Redact behavior with non-list/dict types."""
def test_tuple_returned_as_is(self):
data = (1, 2, 3)
assert DataSanitizer.redact(data) == (1, 2, 3)
def test_set_returned_as_is(self):
data = {1, 2, 3}
assert DataSanitizer.redact(data) == {1, 2, 3}
def test_bytes_returned_as_is(self):
assert DataSanitizer.redact(b"data") == b"data"
def test_zero_returned_as_is(self):
assert DataSanitizer.redact(0) == 0
def test_empty_string_returned_as_is(self):
assert DataSanitizer.redact("") == ""
class TestRedactNestedListsOfLists:
"""Redact handles nested list structures."""
def test_list_of_lists_with_dicts(self):
data = [[{"api_key": "k1", "val": 1}], [{"password": "p1", "val": 2}]]
result = DataSanitizer.redact(data)
assert result == [
[{"api_key": "[REDACTED]", "val": 1}],
[{"password": "[REDACTED]", "val": 2}],
]
class TestRedactCombinedCustomKeysAndText:
"""Redact with both custom keys and custom redaction text."""
def test_custom_keys_and_custom_text(self):
data = {"my_secret": "val", "api_key": "keep", "name": "ok"}
result = DataSanitizer.redact(
data, sensitive_keys={"my_secret"}, redaction_text="<REMOVED>"
)
assert result["my_secret"] == "<REMOVED>"
assert result["api_key"] == "keep"
assert result["name"] == "ok"
def test_custom_keys_and_empty_text(self):
data = {"password": "keep_default", "custom": "hide"}
result = DataSanitizer.redact(
data, sensitive_keys={"custom"}, redaction_text=""
)
assert result["custom"] == ""
assert result["password"] == "keep_default"
class TestRedactAllKeysSensitive:
"""When every key in a dict is sensitive, all values are redacted."""
def test_all_keys_redacted(self):
data = {"api_key": "1", "password": "2", "secret": "3"}
result = DataSanitizer.redact(data)
assert all(v == "[REDACTED]" for v in result.values())
assert len(result) == 3
def test_large_dict_all_redacted(self):
data = {
k: f"val_{i}"
for i, k in enumerate(DataSanitizer.DEFAULT_SENSITIVE_KEYS)
}
result = DataSanitizer.redact(data)
assert all(v == "[REDACTED]" for v in result.values())
assert len(result) == len(DataSanitizer.DEFAULT_SENSITIVE_KEYS)
class TestRedactDeeplyNestedCustom:
"""Redact with custom keys in deeply nested structures."""
def test_deeply_nested_custom_key(self):
data = {"a": {"b": {"c": {"my_field": "secret", "val": 1}}}}
result = DataSanitizer.redact(data, sensitive_keys={"my_field"})
assert result["a"]["b"]["c"]["my_field"] == "[REDACTED]"
assert result["a"]["b"]["c"]["val"] == 1
def test_list_nested_in_dict_custom_key(self):
data = {"items": [{"token": "t1", "id": 1}, {"token": "t2", "id": 2}]}
result = DataSanitizer.redact(data, sensitive_keys={"token"})
assert result["items"][0]["token"] == "[REDACTED]"
assert result["items"][0]["id"] == 1
assert result["items"][1]["token"] == "[REDACTED]"
class TestRedactDoesNotMutateNested:
"""Redact does not mutate nested original data."""
def test_nested_dict_original_unchanged(self):
inner = {"password": "secret", "val": 1}
outer = {"config": inner}
DataSanitizer.redact(outer)
assert inner["password"] == "secret"
def test_list_original_unchanged(self):
item = {"api_key": "k", "id": 1}
data = [item]
DataSanitizer.redact(data)
assert item["api_key"] == "k"
# ---------------------------------------------------------------------------
# Convenience functions -- additional coverage
# ---------------------------------------------------------------------------
class TestConvenienceFunctionsExtended:
"""Additional convenience function tests."""
def test_sanitize_data_with_none_keys(self):
"""sanitize_data with explicit None uses defaults."""
data = {"api_key": "k", "name": "n"}
result = sanitize_data(data, sensitive_keys=None)
assert "api_key" not in result
assert result == {"name": "n"}
def test_redact_data_with_none_keys(self):
"""redact_data with explicit None uses defaults."""
data = {"password": "p", "name": "n"}
result = redact_data(data, sensitive_keys=None)
assert result["password"] == "[REDACTED]"
assert result["name"] == "n"
def test_redact_data_custom_keys_and_text(self):
"""redact_data with both custom keys and custom text."""
data = {"my_field": "val", "api_key": "keep"}
result = redact_data(
data, sensitive_keys={"my_field"}, redaction_text="XXX"
)
assert result["my_field"] == "XXX"
assert result["api_key"] == "keep"
def test_sanitize_data_list_input(self):
"""sanitize_data works with list input."""
data = [{"api_key": "k", "id": 1}]
result = sanitize_data(data)
assert result == [{"id": 1}]
def test_redact_data_list_input(self):
"""redact_data works with list input."""
data = [{"password": "p", "id": 1}]
result = redact_data(data)
assert result == [{"password": "[REDACTED]", "id": 1}]
def test_sanitize_data_primitive(self):
"""sanitize_data passes through primitives."""
assert sanitize_data("hello") == "hello"
assert sanitize_data(42) == 42
assert sanitize_data(None) is None
def test_redact_data_primitive(self):
"""redact_data passes through primitives."""
assert redact_data("hello") == "hello"
assert redact_data(42) == 42
assert redact_data(None) is None
# ---------------------------------------------------------------------------
# filter_research_metadata() -- additional coverage
# ---------------------------------------------------------------------------
class TestFilterResearchMetadataExtended:
"""Additional edge cases for filter_research_metadata."""
def test_is_news_search_zero_coerced_to_false(self):
"""0 is falsy, coerced to False."""
result = filter_research_metadata({"is_news_search": 0})
assert result == {"is_news_search": False}
assert type(result["is_news_search"]) is bool
def test_is_news_search_empty_string_coerced_to_false(self):
"""Empty string is falsy, coerced to False."""
result = filter_research_metadata({"is_news_search": ""})
assert result == {"is_news_search": False}
assert type(result["is_news_search"]) is bool
def test_is_news_search_empty_list_coerced_to_false(self):
"""Empty list is falsy, coerced to False."""
result = filter_research_metadata({"is_news_search": []})
assert result == {"is_news_search": False}
def test_is_news_search_nonempty_list_coerced_to_true(self):
"""Non-empty list is truthy, coerced to True."""
result = filter_research_metadata({"is_news_search": [1]})
assert result == {"is_news_search": True}
def test_is_news_search_none_coerced_to_false(self):
"""None is falsy, coerced to False."""
result = filter_research_metadata({"is_news_search": None})
assert result == {"is_news_search": False}
def test_empty_string_input(self):
"""Empty string input (falsy) returns default."""
result = filter_research_metadata("")
assert result == {"is_news_search": False}
def test_json_string_with_extra_fields_stripped(self):
"""JSON string with extra fields returns only is_news_search."""
meta_json = json.dumps(
{
"is_news_search": True,
"settings_snapshot": {"api_key": "secret"},
"phase": "complete",
}
)
result = filter_research_metadata(meta_json)
assert result == {"is_news_search": True}
assert "settings_snapshot" not in result
assert "phase" not in result
def test_dict_with_only_settings_snapshot(self):
"""Dict with only settings_snapshot but no is_news_search."""
result = filter_research_metadata({"settings_snapshot": {"key": "val"}})
assert result == {"is_news_search": False}
def test_boolean_input(self):
"""Boolean input (not a dict/str) returns default."""
result = filter_research_metadata(True)
assert result == {"is_news_search": False}
def test_float_input(self):
"""Float input returns default."""
result = filter_research_metadata(3.14)
assert result == {"is_news_search": False}
def test_list_input(self):
"""List input (non-dict) returns default."""
result = filter_research_metadata([{"is_news_search": True}])
assert result == {"is_news_search": False}
def test_json_number_string(self):
"""JSON string that parses to a number returns default."""
result = filter_research_metadata("42")
assert result == {"is_news_search": False}
def test_json_boolean_string(self):
"""JSON string that parses to a boolean returns default."""
result = filter_research_metadata("true")
assert result == {"is_news_search": False}
def test_json_null_string(self):
"""JSON string 'null' parses to None, then `meta or {}` gives {}."""
result = filter_research_metadata("null")
assert result == {"is_news_search": False}
# ---------------------------------------------------------------------------
# strip_settings_snapshot() -- additional coverage
# ---------------------------------------------------------------------------
class TestStripSettingsSnapshotExtended:
"""Additional edge cases for strip_settings_snapshot."""
def test_empty_string_input(self):
"""Empty string (falsy) returns empty dict."""
result = strip_settings_snapshot("")
assert result == {}
def test_preserves_all_non_snapshot_keys(self):
"""All keys except settings_snapshot are preserved."""
meta = {
"phase": "complete",
"error_type": None,
"processed_query": "test query",
"mode": "quick",
"duration": 12.5,
"is_news_search": True,
"settings_snapshot": {"api_key": "secret"},
}
result = strip_settings_snapshot(meta)
assert "settings_snapshot" not in result
assert result["phase"] == "complete"
assert result["error_type"] is None
assert result["processed_query"] == "test query"
assert result["mode"] == "quick"
assert result["duration"] == 12.5
assert result["is_news_search"] is True
def test_preserves_nested_complex_values(self):
"""Nested complex values (lists, dicts) are preserved."""
meta = {
"results": [{"title": "a"}, {"title": "b"}],
"config": {"depth": 3},
"settings_snapshot": {},
}
result = strip_settings_snapshot(meta)
assert result["results"] == [{"title": "a"}, {"title": "b"}]
assert result["config"] == {"depth": 3}
def test_boolean_input(self):
"""Boolean input returns empty dict."""
assert strip_settings_snapshot(True) == {}
assert strip_settings_snapshot(False) == {}
def test_float_input(self):
"""Float input returns empty dict."""
assert strip_settings_snapshot(3.14) == {}
def test_list_input(self):
"""List input returns empty dict."""
assert strip_settings_snapshot([1, 2, 3]) == {}
def test_json_number_string(self):
"""JSON number string returns empty dict (not a dict)."""
assert strip_settings_snapshot("42") == {}
def test_json_null_string(self):
"""JSON 'null' string returns empty dict."""
assert strip_settings_snapshot("null") == {}
def test_json_boolean_string(self):
"""JSON 'true' string returns empty dict."""
assert strip_settings_snapshot("true") == {}
def test_json_array_string(self):
"""JSON array string returns empty dict."""
assert strip_settings_snapshot("[1, 2]") == {}
def test_does_not_strip_similar_key_names(self):
"""Keys like 'settings_snapshot_v2' are NOT stripped."""
meta = {"settings_snapshot_v2": "data", "settings_snapshot": "secret"}
result = strip_settings_snapshot(meta)
assert "settings_snapshot_v2" in result
assert "settings_snapshot" not in result
def test_settings_snapshot_with_large_value(self):
"""Large settings_snapshot value is stripped cleanly."""
snapshot = {f"key_{i}": f"value_{i}" for i in range(100)}
meta = {"phase": "done", "settings_snapshot": snapshot}
result = strip_settings_snapshot(meta)
assert result == {"phase": "done"}
def test_json_string_preserves_value_types(self):
"""JSON string input preserves value types after parsing."""
meta_json = json.dumps(
{
"phase": "done",
"count": 42,
"active": True,
"settings_snapshot": {},
}
)
result = strip_settings_snapshot(meta_json)
assert result == {"phase": "done", "count": 42, "active": True}
def test_original_dict_not_mutated(self):
"""strip_settings_snapshot does not mutate the original dict."""
meta = {"phase": "done", "settings_snapshot": {"api_key": "secret"}}
strip_settings_snapshot(meta)
assert "settings_snapshot" in meta
# ---------------------------------------------------------------------------
# DEFAULT_SENSITIVE_KEYS -- exhaustive membership
# ---------------------------------------------------------------------------
class TestDefaultSensitiveKeysComplete:
"""Verify exact membership of DEFAULT_SENSITIVE_KEYS."""
def test_exact_set(self):
expected = {
"api_key",
"apikey",
"password",
"secret",
"access_token",
"refresh_token",
"private_key",
"auth_token",
"session_token",
"csrf_token",
"client_secret",
"secret_key",
"bearer_token",
"api_secret",
"app_secret",
}
assert DataSanitizer.DEFAULT_SENSITIVE_KEYS == expected
def test_count(self):
assert len(DataSanitizer.DEFAULT_SENSITIVE_KEYS) == 15
def test_all_lowercase(self):
"""All default keys are stored in lowercase."""
for key in DataSanitizer.DEFAULT_SENSITIVE_KEYS:
assert key == key.lower(), f"Key {key!r} is not lowercase"
# ---------------------------------------------------------------------------
# Cross-method consistency
# ---------------------------------------------------------------------------
class TestSanitizeRedactConsistency:
"""Sanitize and redact should agree on which keys are sensitive."""
@pytest.mark.parametrize(
"key", sorted(DataSanitizer.DEFAULT_SENSITIVE_KEYS)
)
def test_sanitize_removes_what_redact_replaces(self, key):
"""For each default key, sanitize removes it and redact replaces it."""
data = {key: "value", "safe": "keep"}
sanitized = DataSanitizer.sanitize(data)
redacted = DataSanitizer.redact(data)
assert key not in sanitized
assert redacted[key] == "[REDACTED]"
assert sanitized["safe"] == "keep"
assert redacted["safe"] == "keep"
def test_sanitize_and_redact_same_custom_keys(self):
"""Both methods respect the same custom keys set."""
custom = {"my_token", "my_secret"}
data = {"my_token": "t", "my_secret": "s", "name": "n", "api_key": "k"}
sanitized = DataSanitizer.sanitize(data, sensitive_keys=custom)
redacted = DataSanitizer.redact(data, sensitive_keys=custom)
assert "my_token" not in sanitized
assert "my_secret" not in sanitized
assert "api_key" in sanitized # not in custom set
assert redacted["my_token"] == "[REDACTED]"
assert redacted["my_secret"] == "[REDACTED]"
assert redacted["api_key"] == "k" # not in custom set
# ---------------------------------------------------------------------------
# Sanitize/redact with dict containing duplicate-ish case keys
# ---------------------------------------------------------------------------
class TestCaseVariantKeys:
"""Dict keys that differ only in case."""
def test_sanitize_removes_all_case_variants(self):
"""All case variants of a sensitive key are removed."""
data = {"Api_Key": "v1", "API_KEY": "v2", "api_key": "v3", "name": "ok"}
result = DataSanitizer.sanitize(data)
assert result == {"name": "ok"}
def test_redact_replaces_all_case_variants(self):
"""All case variants of a sensitive key are redacted."""
data = {
"Password": "p1",
"PASSWORD": "p2",
"password": "p3",
"name": "ok",
}
result = DataSanitizer.redact(data)
assert result["Password"] == "[REDACTED]"
assert result["PASSWORD"] == "[REDACTED]"
assert result["password"] == "[REDACTED]"
assert result["name"] == "ok"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,294 @@
"""High-value tests for security/data_sanitizer.py - Data Sanitization."""
import json
from local_deep_research.security.data_sanitizer import (
DataSanitizer,
sanitize_data,
redact_data,
filter_research_metadata,
strip_settings_snapshot,
)
# ---------------------------------------------------------------------------
# DataSanitizer.sanitize()
# ---------------------------------------------------------------------------
class TestSanitizeBasic:
"""Basic sanitize behavior."""
def test_removes_api_key(self):
result = DataSanitizer.sanitize(
{"username": "bob", "api_key": "secret"}
)
assert result == {"username": "bob"}
def test_removes_password(self):
result = DataSanitizer.sanitize({"password": "pass123", "name": "x"})
assert result == {"name": "x"}
def test_removes_multiple_sensitive_keys(self):
data = {"api_key": "k", "secret": "s", "safe": "v"}
result = DataSanitizer.sanitize(data)
assert result == {"safe": "v"}
def test_case_insensitive_removal(self):
data = {"API_KEY": "k", "Password": "p", "name": "n"}
result = DataSanitizer.sanitize(data)
assert result == {"name": "n"}
def test_primitive_passthrough_string(self):
assert DataSanitizer.sanitize("hello") == "hello"
def test_primitive_passthrough_int(self):
assert DataSanitizer.sanitize(42) == 42
def test_primitive_passthrough_none(self):
assert DataSanitizer.sanitize(None) is None
def test_empty_dict(self):
assert DataSanitizer.sanitize({}) == {}
def test_empty_list(self):
assert DataSanitizer.sanitize([]) == []
class TestSanitizeRecursive:
"""Recursive sanitization through nested structures."""
def test_nested_dict(self):
data = {"outer": {"api_key": "secret", "value": 1}}
result = DataSanitizer.sanitize(data)
assert result == {"outer": {"value": 1}}
def test_deeply_nested(self):
data = {"a": {"b": {"c": {"password": "p", "data": "d"}}}}
result = DataSanitizer.sanitize(data)
assert result == {"a": {"b": {"c": {"data": "d"}}}}
def test_list_of_dicts(self):
data = [{"api_key": "k1", "name": "a"}, {"api_key": "k2", "name": "b"}]
result = DataSanitizer.sanitize(data)
assert result == [{"name": "a"}, {"name": "b"}]
def test_dict_with_list_of_dicts(self):
data = {"items": [{"secret": "s", "id": 1}]}
result = DataSanitizer.sanitize(data)
assert result == {"items": [{"id": 1}]}
def test_list_of_primitives_unchanged(self):
assert DataSanitizer.sanitize([1, 2, 3]) == [1, 2, 3]
class TestSanitizeCustomKeys:
"""Custom sensitive_keys parameter."""
def test_custom_keys_only(self):
data = {"api_key": "keep", "custom_secret": "remove", "name": "n"}
result = DataSanitizer.sanitize(data, sensitive_keys={"custom_secret"})
assert result == {"api_key": "keep", "name": "n"}
def test_custom_keys_case_insensitive(self):
data = {"MY_TOKEN": "t", "name": "n"}
result = DataSanitizer.sanitize(data, sensitive_keys={"my_token"})
assert result == {"name": "n"}
# ---------------------------------------------------------------------------
# DataSanitizer.redact()
# ---------------------------------------------------------------------------
class TestRedactBasic:
"""Basic redact behavior."""
def test_replaces_with_redacted(self):
data = {"api_key": "secret", "name": "bob"}
result = DataSanitizer.redact(data)
assert result == {"api_key": "[REDACTED]", "name": "bob"}
def test_case_insensitive(self):
result = DataSanitizer.redact({"PASSWORD": "p", "x": 1})
assert result == {"PASSWORD": "[REDACTED]", "x": 1}
def test_custom_redaction_text(self):
result = DataSanitizer.redact({"api_key": "k"}, redaction_text="***")
assert result == {"api_key": "***"}
def test_primitive_passthrough(self):
assert DataSanitizer.redact("hello") == "hello"
assert DataSanitizer.redact(42) == 42
assert DataSanitizer.redact(None) is None
class TestRedactRecursive:
"""Recursive redaction."""
def test_nested_dict_redaction(self):
data = {"outer": {"password": "secret", "value": 1}}
result = DataSanitizer.redact(data)
assert result == {"outer": {"password": "[REDACTED]", "value": 1}}
def test_list_of_dicts_redaction(self):
data = [{"secret": "s1", "id": 1}, {"secret": "s2", "id": 2}]
result = DataSanitizer.redact(data)
assert result == [
{"secret": "[REDACTED]", "id": 1},
{"secret": "[REDACTED]", "id": 2},
]
def test_list_passthrough(self):
assert DataSanitizer.redact([1, "two", 3]) == [1, "two", 3]
class TestRedactNonPrimitiveValue:
"""Redact replaces the value wholesale, even if it's a nested structure."""
def test_nested_dict_value_replaced_not_recursed(self):
data = {"api_key": {"nested": "data"}}
result = DataSanitizer.redact(data)
assert result == {"api_key": "[REDACTED]"}
class TestRedactCustomKeys:
"""Custom sensitive_keys for redact."""
def test_custom_keys(self):
data = {"api_key": "keep", "my_field": "redact"}
result = DataSanitizer.redact(data, sensitive_keys={"my_field"})
assert result == {"api_key": "keep", "my_field": "[REDACTED]"}
# ---------------------------------------------------------------------------
# Convenience functions
# ---------------------------------------------------------------------------
class TestConvenienceFunctions:
def test_sanitize_data_delegates(self):
data = {"api_key": "k", "name": "n"}
assert sanitize_data(data) == {"name": "n"}
def test_redact_data_delegates(self):
data = {"password": "p", "name": "n"}
result = redact_data(data)
assert result == {"password": "[REDACTED]", "name": "n"}
def test_redact_data_custom_text(self):
result = redact_data({"api_key": "k"}, redaction_text="HIDDEN")
assert result == {"api_key": "HIDDEN"}
# ---------------------------------------------------------------------------
# filter_research_metadata()
# ---------------------------------------------------------------------------
class TestFilterResearchMetadata:
"""filter_research_metadata extracts only safe fields."""
def test_dict_with_is_news_search_true(self):
result = filter_research_metadata(
{"is_news_search": True, "settings_snapshot": {"api_key": "x"}}
)
assert result == {"is_news_search": True}
def test_dict_with_is_news_search_false(self):
result = filter_research_metadata({"is_news_search": False})
assert result == {"is_news_search": False}
def test_dict_missing_is_news_search(self):
result = filter_research_metadata({"other": "stuff"})
assert result == {"is_news_search": False}
def test_json_string_input(self):
result = filter_research_metadata('{"is_news_search": true}')
assert result == {"is_news_search": True}
def test_none_input(self):
result = filter_research_metadata(None)
assert result == {"is_news_search": False}
def test_non_dict_input(self):
result = filter_research_metadata([1, 2, 3])
assert result == {"is_news_search": False}
def test_invalid_json_string(self):
result = filter_research_metadata("{bad json")
assert result == {"is_news_search": False}
def test_empty_dict(self):
result = filter_research_metadata({})
assert result == {"is_news_search": False}
def test_int_truthy_coerced_to_bool(self):
result = filter_research_metadata({"is_news_search": 1})
assert result == {"is_news_search": True}
assert type(result["is_news_search"]) is bool
def test_string_truthy_coerced_to_bool(self):
result = filter_research_metadata({"is_news_search": "yes"})
assert result == {"is_news_search": True}
def test_json_string_non_dict_returns_default(self):
"""JSON string that parses to a list exercises the not-isinstance(meta, dict) branch."""
result = filter_research_metadata("[1, 2]")
assert result == {"is_news_search": False}
# ---------------------------------------------------------------------------
# strip_settings_snapshot()
# ---------------------------------------------------------------------------
class TestStripSettingsSnapshot:
"""strip_settings_snapshot removes settings_snapshot key."""
def test_removes_settings_snapshot(self):
data = {
"phase": "done",
"settings_snapshot": {"api_key": "x"},
"duration": 5,
}
result = strip_settings_snapshot(data)
assert result == {"phase": "done", "duration": 5}
def test_preserves_other_keys(self):
data = {"phase": "done", "error_type": None, "mode": "quick"}
result = strip_settings_snapshot(data)
assert result == data
def test_json_string_input(self):
data = json.dumps({"settings_snapshot": {}, "phase": "done"})
result = strip_settings_snapshot(data)
assert result == {"phase": "done"}
def test_none_input(self):
assert strip_settings_snapshot(None) == {}
def test_non_dict_input(self):
assert strip_settings_snapshot(42) == {}
def test_invalid_json(self):
assert strip_settings_snapshot("{invalid") == {}
def test_nested_settings_snapshot_survives(self):
data = {
"results": {"settings_snapshot": {"key": "val"}},
"phase": "done",
}
result = strip_settings_snapshot(data)
assert result == {
"results": {"settings_snapshot": {"key": "val"}},
"phase": "done",
}
def test_json_string_non_dict_returns_empty(self):
"""JSON string that parses to a list exercises the not-isinstance branch."""
assert strip_settings_snapshot("[1, 2]") == {}
def test_empty_dict(self):
assert strip_settings_snapshot({}) == {}
+253
View File
@@ -0,0 +1,253 @@
"""
Tests for security/decorators.py — require_json_body decorator.
Covers all three error formats and common edge cases:
empty body, non-dict JSON payloads, malformed JSON, and valid dicts.
"""
import json
import pytest
from flask import Flask
from local_deep_research.security.decorators import require_json_body
@pytest.fixture
def app():
"""Minimal Flask app with routes using each error format."""
app = Flask(__name__)
@app.route("/simple", methods=["POST"])
@require_json_body()
def simple_route():
return {"ok": True}
@app.route("/simple-custom", methods=["POST"])
@require_json_body(error_message="Query parameter is required")
def simple_custom_route():
return {"ok": True}
@app.route("/status", methods=["POST"])
@require_json_body(error_format="status")
def status_route():
return {"ok": True}
@app.route("/status-custom", methods=["POST"])
@require_json_body(
error_format="status", error_message="No settings data provided"
)
def status_custom_route():
return {"ok": True}
@app.route("/success", methods=["POST"])
@require_json_body(error_format="success")
def success_route():
return {"ok": True}
@app.route("/success-custom", methods=["POST"])
@require_json_body(
error_format="success", error_message="Missing required fields"
)
def success_custom_route():
return {"ok": True}
return app
@pytest.fixture
def client(app):
return app.test_client()
# ---------------------------------------------------------------------------
# Valid dict payloads — should always pass through
# ---------------------------------------------------------------------------
class TestValidPayloads:
def test_valid_dict_passes_simple(self, client):
resp = client.post(
"/simple",
data=json.dumps({"key": "value"}),
content_type="application/json",
)
assert resp.status_code == 200
assert resp.json["ok"] is True
def test_valid_dict_passes_status(self, client):
resp = client.post(
"/status",
data=json.dumps({"key": "value"}),
content_type="application/json",
)
assert resp.status_code == 200
def test_valid_dict_passes_success(self, client):
resp = client.post(
"/success",
data=json.dumps({"key": "value"}),
content_type="application/json",
)
assert resp.status_code == 200
def test_empty_dict_passes(self, client):
"""An empty dict {} is a valid JSON body."""
resp = client.post(
"/simple",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code == 200
def test_nested_dict_passes(self, client):
resp = client.post(
"/simple",
data=json.dumps({"nested": {"a": 1}, "list": [1, 2]}),
content_type="application/json",
)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Empty / missing body — should reject
# ---------------------------------------------------------------------------
class TestEmptyBody:
def test_no_body_simple(self, client):
resp = client.post("/simple")
assert resp.status_code == 400
assert resp.json["error"] == "Request body must be valid JSON"
def test_no_body_status(self, client):
resp = client.post("/status")
assert resp.status_code == 400
assert resp.json["status"] == "error"
assert resp.json["message"] == "Request body must be valid JSON"
def test_no_body_success(self, client):
resp = client.post("/success")
assert resp.status_code == 400
assert resp.json["success"] is False
assert resp.json["error"] == "Request body must be valid JSON"
def test_empty_string_body(self, client):
resp = client.post("/simple", data="", content_type="application/json")
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Malformed JSON — should reject
# ---------------------------------------------------------------------------
class TestMalformedJSON:
def test_invalid_json_string(self, client):
resp = client.post(
"/simple",
data="{not valid json",
content_type="application/json",
)
assert resp.status_code == 400
assert "error" in resp.json
def test_plain_text_body(self, client):
resp = client.post(
"/simple", data="hello world", content_type="text/plain"
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Non-dict JSON values — should reject
# ---------------------------------------------------------------------------
class TestNonDictJSON:
def test_json_null(self, client):
resp = client.post(
"/simple",
data=json.dumps(None),
content_type="application/json",
)
assert resp.status_code == 400
def test_json_list(self, client):
resp = client.post(
"/simple",
data=json.dumps([1, 2, 3]),
content_type="application/json",
)
assert resp.status_code == 400
def test_json_string(self, client):
resp = client.post(
"/simple",
data=json.dumps("just a string"),
content_type="application/json",
)
assert resp.status_code == 400
def test_json_number(self, client):
resp = client.post(
"/simple",
data=json.dumps(42),
content_type="application/json",
)
assert resp.status_code == 400
def test_json_boolean(self, client):
resp = client.post(
"/simple",
data=json.dumps(True),
content_type="application/json",
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# Custom error messages
# ---------------------------------------------------------------------------
class TestCustomMessages:
def test_simple_custom_message(self, client):
resp = client.post("/simple-custom")
assert resp.status_code == 400
assert resp.json["error"] == "Query parameter is required"
def test_status_custom_message(self, client):
resp = client.post("/status-custom")
assert resp.status_code == 400
assert resp.json["message"] == "No settings data provided"
def test_success_custom_message(self, client):
resp = client.post("/success-custom")
assert resp.status_code == 400
assert resp.json["error"] == "Missing required fields"
# ---------------------------------------------------------------------------
# Error format response structure
# ---------------------------------------------------------------------------
class TestErrorFormatStructure:
def test_simple_format_keys(self, client):
resp = client.post("/simple")
assert resp.status_code == 400
assert set(resp.json.keys()) == {"error"}
def test_status_format_keys(self, client):
resp = client.post("/status")
assert resp.status_code == 400
assert set(resp.json.keys()) == {"status", "message"}
def test_success_format_keys(self, client):
resp = client.post("/success")
assert resp.status_code == 400
assert set(resp.json.keys()) == {"success", "error"}
# ---------------------------------------------------------------------------
# Decorator preserves function metadata
# ---------------------------------------------------------------------------
class TestDecoratorMetadata:
def test_wraps_preserves_name(self, app):
"""@wraps should preserve the original function name."""
with app.test_request_context():
for rule in app.url_map.iter_rules():
if rule.endpoint == "simple_route":
view_func = app.view_functions[rule.endpoint]
assert view_func.__name__ == "simple_route"
return
pytest.fail("simple_route endpoint not found")
+101
View File
@@ -0,0 +1,101 @@
"""
Tests for the check-deprecated-db pre-commit hook.
Ensures the hook correctly detects usage of deprecated database connection
methods that bypass per-user encrypted databases. This prevents user data
from being written to shared, unencrypted storage.
"""
import sys
from importlib import import_module
from pathlib import Path
# Add the pre-commit hooks directory to path
HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks"
sys.path.insert(0, str(HOOKS_DIR))
hook_module = import_module("check-deprecated-db")
check_file_fn = hook_module.check_file
# Build the deprecated DB name dynamically so this test file itself
# is not flagged by the check-ldr-db pre-commit hook.
_DEPRECATED_DB = "ldr" + ".db"
def _write_and_check(tmp_path, code: str, filename: str = "src/module.py"):
"""Write code to a temp file and run the checker."""
p = tmp_path / filename
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(code, encoding="utf-8")
return check_file_fn(str(p))
class TestDetectsDeprecatedDbConnection:
"""Ensures get_db_connection() usage is flagged."""
def test_detects_get_db_connection_call(self, tmp_path):
code = "conn = get_db_connection()\ncursor = conn.cursor()\n"
issues = _write_and_check(tmp_path, code)
assert len(issues) >= 1
assert any("get_db_connection" in i for i in issues)
def test_detects_get_db_connection_import(self, tmp_path):
code = "from web.models.database import get_db_connection\n"
issues = _write_and_check(tmp_path, code)
assert len(issues) >= 1
def test_detects_get_db_connection_with_args(self, tmp_path):
code = 'conn = get_db_connection("some_path")\n'
issues = _write_and_check(tmp_path, code)
assert len(issues) >= 1
class TestDetectsRawSessionAccess:
"""Ensures db_manager.get_session() is flagged (leaks QueuePool FDs)."""
def test_detects_raw_get_session(self, tmp_path):
code = 'session = db_manager.get_session("user")\n'
issues = _write_and_check(tmp_path, code)
assert len(issues) >= 1
assert any("get_session" in i for i in issues)
def test_allows_noqa_comment(self, tmp_path):
code = 'session = db_manager.get_session("user") # noqa: raw-session\n'
issues = _write_and_check(tmp_path, code)
assert len(issues) == 0
def test_allows_commented_code(self, tmp_path):
code = '# session = db_manager.get_session("user")\n'
issues = _write_and_check(tmp_path, code)
assert len(issues) == 0
class TestDetectsSharedDbAccess:
"""Ensures direct SQLite connection to shared DB is flagged."""
def test_detects_sqlite_connect_to_shared_db(self, tmp_path):
code = f'conn = sqlite3.connect("{_DEPRECATED_DB}")\n'
issues = _write_and_check(tmp_path, code)
assert len(issues) >= 1
class TestAllowsSafePatterns:
"""Ensures correct patterns are NOT flagged."""
def test_allows_get_user_db_session(self, tmp_path):
code = """
with get_user_db_session(username) as session:
results = session.query(Journal).all()
"""
issues = _write_and_check(tmp_path, code)
assert len(issues) == 0
def test_allows_normal_code(self, tmp_path):
code = """
def some_function():
data = process_results()
return data
"""
issues = _write_and_check(tmp_path, code)
assert len(issues) == 0
@@ -0,0 +1,379 @@
"""Follow-up regression tests for ADAPTIVE scope resolution and
``context_from_snapshot`` cross-field coupling (PR #4300 egress policy).
These cover gaps not already exercised by
``tests/security/test_egress_policy.py``:
* ADAPTIVE resolution of *registered retriever* primaries (local => PRIVATE_ONLY,
public => PUBLIC_ONLY) via the retriever registry path inside
``_resolve_adaptive_scope``.
* Stray removed meta-engine names (auto / meta / parallel /
parallel_scientific) being unclassifiable and resolving to BOTH under
ADAPTIVE, and leaving STRICT intact (no ValueError) under STRICT.
* The PRIVATE_ONLY -> require_local_llm/require_local_embeddings *coupling* on a
direct (non-adaptive) PRIVATE_ONLY scope, and the deliberate absence of that
coupling under STRICT.
* Non-dict snapshot ValueError contract.
* ``allow_dns=False`` skipping ``_resolve_with_timeout`` and falling back to the
engine's static classification (vs. the DNS-driven result with allow_dns=True).
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.security.egress.policy import (
EgressScope,
PolicyDeniedError,
_resolve_adaptive_scope,
context_from_snapshot,
)
from local_deep_research.web_search_engines.retriever_registry import (
retriever_registry,
)
def _adaptive_snapshot(tool: str, **extra) -> dict:
"""Nested-value snapshot selecting ADAPTIVE scope with the given tool."""
snap = {
"policy.egress_scope": {"value": "adaptive"},
"search.tool": {"value": tool},
}
snap.update(extra)
return snap
# ---------------------------------------------------------------------------
# ADAPTIVE: concrete engine classification (allow + deny pairs)
# ---------------------------------------------------------------------------
def test_resolve_adaptive_concrete_public_engine_is_public_only():
"""_resolve_adaptive_scope: a concrete public engine -> PUBLIC_ONLY."""
scope = _resolve_adaptive_scope(
"arxiv",
{},
username=None,
local_hostnames=(),
)
assert scope == EgressScope.PUBLIC_ONLY
def test_resolve_adaptive_concrete_private_engine_is_private_only():
"""_resolve_adaptive_scope: a concrete local engine -> PRIVATE_ONLY."""
scope = _resolve_adaptive_scope(
"paperless",
{},
username=None,
local_hostnames=(),
)
assert scope == EgressScope.PRIVATE_ONLY
# ---------------------------------------------------------------------------
# ADAPTIVE: stray removed meta-engine names -> BOTH (unclassifiable fallback)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"picker", ["auto", "meta", "parallel", "parallel_scientific"]
)
def test_resolve_adaptive_stray_meta_names_resolve_to_both(picker):
# The meta engines were removed; a stray name left in the DB is simply
# unclassifiable and falls through to BOTH (no special-case branch).
scope = _resolve_adaptive_scope(
picker,
{},
username=None,
local_hostnames=(),
)
assert scope == EgressScope.BOTH
def test_resolve_adaptive_empty_primary_is_both():
"""A missing/empty primary cannot be classified -> permissive BOTH."""
scope = _resolve_adaptive_scope(
"",
{},
username=None,
local_hostnames=(),
)
assert scope == EgressScope.BOTH
@pytest.mark.parametrize("picker", ["meta", "parallel", "parallel_scientific"])
def test_context_adaptive_stray_meta_names_resolve_to_both(picker):
"""Through the public entrypoint: ADAPTIVE + each stray removed meta name
-> BOTH and leaves inference requirements untouched (no PRIVATE_ONLY
coupling)."""
ctx = context_from_snapshot(
_adaptive_snapshot(picker), primary_engine=picker
)
assert ctx.scope == EgressScope.BOTH
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
# ---------------------------------------------------------------------------
# ADAPTIVE: registered retriever primaries (registry path)
# ---------------------------------------------------------------------------
def test_resolve_adaptive_local_retriever_is_private_only():
"""A registered LOCAL retriever (not in ENGINE_REGISTRY) resolves via the
retriever registry to PRIVATE_ONLY."""
name = "_adaptive_followup_local_kb"
retriever_registry.register(name, MagicMock(), is_local=True)
try:
scope = _resolve_adaptive_scope(
name,
{},
username=None,
local_hostnames=(),
)
assert scope == EgressScope.PRIVATE_ONLY
finally:
retriever_registry.unregister(name)
def test_resolve_adaptive_public_retriever_is_public_only():
"""A registered PUBLIC retriever resolves to PUBLIC_ONLY."""
name = "_adaptive_followup_public_idx"
retriever_registry.register(name, MagicMock(), is_local=False)
try:
scope = _resolve_adaptive_scope(
name,
{},
username=None,
local_hostnames=(),
)
assert scope == EgressScope.PUBLIC_ONLY
finally:
retriever_registry.unregister(name)
def test_context_adaptive_local_retriever_forces_local_inference():
"""End-to-end: ADAPTIVE + a registered local retriever primary resolves to
PRIVATE_ONLY AND inherits the require_local_* coupling."""
name = "_adaptive_followup_local_kb2"
retriever_registry.register(name, MagicMock(), is_local=True)
try:
ctx = context_from_snapshot(
_adaptive_snapshot(name), primary_engine=name
)
assert ctx.scope == EgressScope.PRIVATE_ONLY
assert ctx.require_local_llm is True
assert ctx.require_local_embeddings is True
finally:
retriever_registry.unregister(name)
def test_context_adaptive_public_retriever_does_not_force_local():
"""ADAPTIVE + a registered public retriever -> PUBLIC_ONLY, which does NOT
force local inference (public scope is orthogonal to local-inference)."""
name = "_adaptive_followup_public_idx2"
retriever_registry.register(name, MagicMock(), is_local=False)
try:
ctx = context_from_snapshot(
_adaptive_snapshot(name), primary_engine=name
)
assert ctx.scope == EgressScope.PUBLIC_ONLY
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
finally:
retriever_registry.unregister(name)
def test_resolve_adaptive_unknown_primary_falls_back_to_both():
"""A name that is neither a static engine nor a registered retriever is
unclassifiable -> BOTH (permissive fallback, never a hard fail), and no
require_local coupling is applied."""
name = "_adaptive_followup_not_registered_anywhere"
# Ensure it is genuinely absent from the retriever registry.
assert retriever_registry.get_metadata(name) is None
ctx = context_from_snapshot(_adaptive_snapshot(name), primary_engine=name)
assert ctx.scope == EgressScope.BOTH
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
# ---------------------------------------------------------------------------
# PRIVATE_ONLY coupling vs STRICT non-coupling
# ---------------------------------------------------------------------------
def test_direct_private_only_forces_local_llm_and_embeddings():
"""A directly-selected PRIVATE_ONLY scope (not via ADAPTIVE) forces both
require_local_llm and require_local_embeddings even when the user left
those flags at their permissive default. This is the core coupling that
prevents silent exfiltration through cloud inference."""
ctx = context_from_snapshot(
{
"policy.egress_scope": {"value": "private_only"},
"llm.require_local_endpoint": {"value": False},
"embeddings.require_local": {"value": False},
},
primary_engine="arxiv",
)
assert ctx.scope == EgressScope.PRIVATE_ONLY
assert ctx.require_local_llm is True
assert ctx.require_local_embeddings is True
def test_strict_does_not_force_local_inference():
"""STRICT restricts the search-engine set but is deliberately orthogonal to
where inference runs: it must NOT force require_local_*."""
ctx = context_from_snapshot(
{
"policy.egress_scope": {"value": "strict"},
"llm.require_local_endpoint": {"value": False},
"embeddings.require_local": {"value": False},
},
primary_engine="arxiv",
)
assert ctx.scope == EgressScope.STRICT
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
def test_strict_preserves_explicit_local_inference_flags():
"""STRICT does not force the flags, but it must preserve a user who DID
opt in (no silent reset)."""
ctx = context_from_snapshot(
{
"policy.egress_scope": {"value": "strict"},
"llm.require_local_endpoint": {"value": True},
"embeddings.require_local": {"value": True},
},
primary_engine="arxiv",
)
assert ctx.scope == EgressScope.STRICT
assert ctx.require_local_llm is True
assert ctx.require_local_embeddings is True
# ---------------------------------------------------------------------------
# STRICT + stray removed meta name -> STRICT context (no ValueError); the
# stray name only ever matches itself under the STRICT identity check, and as
# an unknown engine it is denied (engine_unknown) anyway.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"picker", ["auto", "meta", "parallel", "parallel_scientific"]
)
def test_strict_with_stray_meta_name_builds_strict_context(picker):
ctx = context_from_snapshot(
{"policy.egress_scope": {"value": "strict"}},
primary_engine=picker,
)
assert ctx.scope == EgressScope.STRICT
def test_strict_with_concrete_primary_does_not_raise():
"""Mirror: STRICT + a concrete primary is the supported, allowed combo."""
ctx = context_from_snapshot(
{"policy.egress_scope": {"value": "strict"}},
primary_engine="arxiv",
)
assert ctx.scope == EgressScope.STRICT
# ---------------------------------------------------------------------------
# Snapshot validity contract
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"bad_snapshot",
[
["policy.egress_scope", "both"], # list
"policy.egress_scope=both", # str
42, # int
None, # None (the explicit "required" branch)
],
)
def test_non_dict_snapshot_raises_value_error(bad_snapshot):
with pytest.raises(ValueError):
context_from_snapshot(bad_snapshot, primary_engine="arxiv")
def test_unknown_scope_string_raises_policy_denied_with_target():
"""An unrecognised scope string is fail-closed as PolicyDeniedError whose
target carries the offending value (so the operator can see what was
rejected) rather than silently degrading to BOTH."""
with pytest.raises(PolicyDeniedError) as excinfo:
context_from_snapshot(
{"policy.egress_scope": {"value": "PUBLIC"}}, # not a valid member
primary_engine="arxiv",
)
assert excinfo.value.decision.reason == "unknown_egress_scope"
assert excinfo.value.target == "PUBLIC"
# ---------------------------------------------------------------------------
# allow_dns=False: skip _resolve_with_timeout, fall back to static flags
# ---------------------------------------------------------------------------
def test_adaptive_allow_dns_false_skips_dns_and_uses_static_flags():
"""With allow_dns=False the URL-configurable primary's hostname is NOT
DNS-resolved: _resolve_with_timeout must not be called, and resolution
falls back to the engine's STATIC classification (searxng is_public=True
-> PUBLIC_ONLY)."""
snap = _adaptive_snapshot(
"searxng",
**{
"search.engine.web.searxng.default_params.instance_url": (
"http://searx.internal.lab:8080"
)
},
)
with patch(
"local_deep_research.security.egress.policy._resolve_with_timeout"
) as mock_resolve:
ctx = context_from_snapshot(
snap, primary_engine="searxng", allow_dns=False
)
mock_resolve.assert_not_called()
# Static fallback: searxng's declared is_public=True -> PUBLIC_ONLY.
assert ctx.scope == EgressScope.PUBLIC_ONLY
def test_adaptive_allow_dns_true_uses_dns_resolution():
"""Contrast: with allow_dns=True the fail-up URL override DOES DNS-
resolve (via _resolve_with_timeout) — for a LOCAL-nature engine. A
paperless primary whose configured api_url resolves to a PUBLIC host is
reclassified public, so ADAPTIVE resolves PUBLIC_ONLY instead of
PRIVATE_ONLY. Proves allow_dns toggles the DNS path, not a no-op.
NB: searxng no longer exercises DNS here — engine nature comes from
static class flags and the URL override is fail-up only (it never
relaxes a public engine to private), so a localhost searxng stays
PUBLIC_ONLY with or without DNS."""
snap = _adaptive_snapshot(
"paperless",
**{
"search.engine.web.paperless.default_params.api_url": (
"http://paperless.example.org:8930"
)
},
)
# NB: a real public IP — the RFC 5737 documentation ranges
# (192.0.2.x / 198.51.100.x / 203.0.113.x) classify as PRIVATE under
# Python's ipaddress.is_private and would defeat the fail-up here.
public_addrinfo = [(None, None, None, None, ("93.184.216.34", 0))]
with patch(
"local_deep_research.security.egress.policy._resolve_with_timeout",
return_value=public_addrinfo,
) as mock_resolve:
ctx = context_from_snapshot(
snap, primary_engine="paperless", allow_dns=True
)
mock_resolve.assert_called()
assert ctx.scope == EgressScope.PUBLIC_ONLY
# A public-resolving primary does NOT force the local-inference coupling.
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
+467
View File
@@ -0,0 +1,467 @@
"""Tests for the PEP 578 sys.audit hook that gates socket.connect.
The hook is the secondary line of defense for the egress policy. These
tests pin the contract at three levels:
1. Hook plumbing (install idempotency, context get/set/clear, the
``active_egress_context`` manager).
2. Per-scope behaviour on real ``socket.connect`` (not via requests/
urllib — the point of the hook is to catch raw socket use, so the
test exercises the raw socket path it is meant to catch).
3. Thread isolation: setting a context in one thread does NOT leak to
another.
We use 127.0.0.1 / 8.8.8.8 / 192.168.x.x as classification targets. We
never need the connect to succeed — the hook fires BEFORE the kernel
ever sees the SYN, so a refused or timed-out connect is fine; we only
check whether the hook raised ``PolicyDeniedError`` or not.
"""
from __future__ import annotations
import socket
import threading
import pytest
from local_deep_research.security import (
active_egress_context,
clear_active_context,
get_active_context,
install_audit_hook,
is_audit_hook_installed,
set_active_context,
)
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
PolicyDeniedError,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _ctx(scope: EgressScope) -> EgressContext:
"""Build a minimal EgressContext for the given scope. No DNS cache
or denial counter state — each test gets a fresh ctx so denial
quotas across tests don't accumulate.
"""
return EgressContext(
scope=scope,
primary_engine="wikipedia",
require_local_llm=False,
require_local_embeddings=False,
)
@pytest.fixture(autouse=True)
def _clear_context_between_tests():
"""The hook reads from a process-wide thread-local. Even though
tests run on a single thread, leaking a context across tests would
produce confusing failures (one test's PRIVATE_ONLY context masking
the next test's setup). Clear it before and after every test.
"""
clear_active_context()
yield
clear_active_context()
def _attempt_connect(host: str, port: int = 80, timeout: float = 0.5):
"""Open a raw AF_INET socket and call connect. Returns the exception
raised (or None if the connect somehow succeeded — unlikely against
a port we don't expect to be open, but we return None either way).
The kernel-level outcome (refused, timed-out, ECONNRESET) is NOT what
we test — we only care about ``PolicyDeniedError`` raised by the
audit hook BEFORE the SYN is sent.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
return None
except PolicyDeniedError as exc:
return exc
except OSError as exc:
return exc
finally:
sock.close()
# ---------------------------------------------------------------------------
# 1. Hook plumbing
# ---------------------------------------------------------------------------
def test_install_audit_hook_is_idempotent():
"""Calling install_audit_hook multiple times must not stack hooks
(PEP 578 hooks are append-only and CANNOT be removed; stacking
would mean every connect fires the policy check N times).
"""
install_audit_hook()
install_audit_hook()
install_audit_hook()
assert is_audit_hook_installed() is True
def test_set_and_get_active_context():
ctx = _ctx(EgressScope.PRIVATE_ONLY)
assert get_active_context() is None
set_active_context(ctx)
assert get_active_context() is ctx
clear_active_context()
assert get_active_context() is None
def test_set_active_context_none_is_clear():
ctx = _ctx(EgressScope.PRIVATE_ONLY)
set_active_context(ctx)
set_active_context(None)
assert get_active_context() is None
def test_clear_active_context_safe_without_set():
"""Clearing a context that was never set must not raise (workers in
finally blocks call this unconditionally).
"""
clear_active_context()
clear_active_context()
def test_active_egress_context_manager_clears_on_exit():
ctx = _ctx(EgressScope.PRIVATE_ONLY)
with active_egress_context(ctx):
assert get_active_context() is ctx
assert get_active_context() is None
def test_active_egress_context_clears_on_exception():
"""Cleanup must happen even when the block raises — otherwise a
crashed worker leaks its context to whatever thread-pool task runs
next.
"""
ctx = _ctx(EgressScope.PRIVATE_ONLY)
with pytest.raises(RuntimeError):
with active_egress_context(ctx):
assert get_active_context() is ctx
raise RuntimeError("boom")
assert get_active_context() is None
# ---------------------------------------------------------------------------
# 2. Per-scope behaviour on real socket.connect
# ---------------------------------------------------------------------------
def test_no_active_context_lets_everything_through():
"""The hook is install-once-and-keep-forever per PEP 578, so the
no-context behaviour is the only thing standing between unrelated
code (test runners, import-time helpers, scripts) and a global
deny-everything regression.
"""
assert get_active_context() is None
err = _attempt_connect("8.8.8.8", 53)
# Socket-level outcome can vary (timeout vs refused vs ECONNRESET)
# but it MUST NOT be PolicyDeniedError.
assert not isinstance(err, PolicyDeniedError), (
f"hook fired without an active context: {err!r}"
)
def test_private_only_blocks_public_ip():
ctx = _ctx(EgressScope.PRIVATE_ONLY)
with active_egress_context(ctx):
err = _attempt_connect("8.8.8.8", 53)
assert isinstance(err, PolicyDeniedError), (
f"expected PolicyDeniedError, got {err!r}"
)
assert err.decision.reason == "scope_mismatch_private_only"
assert err.target == "8.8.8.8"
def test_private_only_allows_private_ip():
ctx = _ctx(EgressScope.PRIVATE_ONLY)
with active_egress_context(ctx):
err = _attempt_connect("127.0.0.1", 1) # very likely closed
# Whatever socket-level error happens, it must not be a policy denial.
assert not isinstance(err, PolicyDeniedError), (
f"hook wrongly blocked localhost under PRIVATE_ONLY: {err!r}"
)
def test_public_only_is_a_noop_at_the_audit_hook():
"""PUBLIC_ONLY governs which SEARCH ENGINES the user picks (no
local engines), not which hosts the process is allowed to reach at
the socket level. Local Ollama, local embeddings, the user's own
sqlite settings DB all use private IPs — blocking them here would
be a false positive against the user's actual intent. The
search-engine PEP (evaluate_engine) still gates engine selection.
"""
ctx = _ctx(EgressScope.PUBLIC_ONLY)
with active_egress_context(ctx):
for host in ("8.8.8.8", "192.168.42.1", "127.0.0.1"):
err = _attempt_connect(host, 1)
assert not isinstance(err, PolicyDeniedError), (
f"PUBLIC_ONLY wrongly blocked {host} at audit hook: {err!r}"
)
def test_both_scope_is_a_noop_at_the_audit_hook():
"""BOTH is the permissive default — every classified host is fine.
The audit hook has nothing to enforce.
"""
ctx = _ctx(EgressScope.BOTH)
with active_egress_context(ctx):
for host in ("8.8.8.8", "192.168.42.1", "127.0.0.1"):
err = _attempt_connect(host, 1)
assert not isinstance(err, PolicyDeniedError), (
f"BOTH scope wrongly blocked {host}: {err!r}"
)
def test_strict_scope_blocks_public_host():
"""STRICT permits private hosts (the user's local engine) but
refuses public ones — same contract as evaluate_url.
"""
ctx = _ctx(EgressScope.STRICT)
with active_egress_context(ctx):
err = _attempt_connect("8.8.8.8", 53)
assert isinstance(err, PolicyDeniedError)
assert err.decision.reason == "strict_public_host"
def test_strict_scope_allows_private_host():
ctx = _ctx(EgressScope.STRICT)
with active_egress_context(ctx):
err = _attempt_connect("127.0.0.1", 1)
assert not isinstance(err, PolicyDeniedError), (
f"STRICT wrongly blocked localhost: {err!r}"
)
def test_ipv6_loopback_allowed_under_private_only():
"""IPv6 socket family must take the same code path. ``::1`` is the
IPv6 loopback — has to be classified as private.
"""
ctx = _ctx(EgressScope.PRIVATE_ONLY)
with active_egress_context(ctx):
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
sock.settimeout(0.5)
try:
sock.connect(("::1", 1))
err = None
except PolicyDeniedError as exc:
err = exc
except OSError as exc:
err = exc
finally:
sock.close()
assert not isinstance(err, PolicyDeniedError), (
f"IPv6 loopback wrongly blocked under PRIVATE_ONLY: {err!r}"
)
def test_af_unix_not_gated():
"""AF_UNIX is off-network. The hook must not classify or block —
sqlite, syslog, dbus, etc. would all break otherwise.
"""
import tempfile
from pathlib import Path
ctx = _ctx(EgressScope.PRIVATE_ONLY)
# Pick a path that does not exist so connect errors with ENOENT.
# The point is to assert no PolicyDeniedError, not a successful
# connection.
tmpdir = Path(tempfile.mkdtemp())
sock_path = str(tmpdir / "nonexistent.sock")
try:
with active_egress_context(ctx):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(0.5)
try:
sock.connect(sock_path)
err = None
except PolicyDeniedError as exc:
err = exc
except OSError as exc:
err = exc
finally:
sock.close()
assert not isinstance(err, PolicyDeniedError), (
f"AF_UNIX wrongly gated: {err!r}"
)
finally:
tmpdir.rmdir()
# ---------------------------------------------------------------------------
# 3. Thread isolation
# ---------------------------------------------------------------------------
def test_thread_local_context_does_not_leak_across_threads():
"""Setting a PRIVATE_ONLY context in one thread must not block a
sibling thread's public-IP connect — the thread-local design is
what makes concurrent multi-research-per-process safe.
"""
ctx = _ctx(EgressScope.PRIVATE_ONLY)
set_active_context(ctx)
sibling_result = []
def sibling_work():
# Sibling has no active context → connect should not raise
# PolicyDeniedError. We capture whatever exception it does
# raise and assert it is not a policy denial.
err = _attempt_connect("8.8.8.8", 53)
sibling_result.append(err)
t = threading.Thread(target=sibling_work)
t.start()
t.join()
assert sibling_result, "sibling thread did not run"
assert not isinstance(sibling_result[0], PolicyDeniedError), (
f"main-thread context leaked into sibling: {sibling_result[0]!r}"
)
# Main thread still sees the context.
assert get_active_context() is ctx
def test_concurrent_threads_can_have_different_scopes():
"""Two threads with two different scopes must each see their own
contract — the policy is per-thread, not per-process.
"""
results = {}
def worker(scope, host, label):
ctx = _ctx(scope)
with active_egress_context(ctx):
results[label] = _attempt_connect(host, 1)
# PRIVATE_ONLY thread reaches a public IP → must be blocked.
# STRICT thread reaches a public IP → must also be blocked. Using
# two different enforcing scopes makes sure each thread is reading
# ITS context, not someone else's.
t_private = threading.Thread(
target=worker,
args=(EgressScope.PRIVATE_ONLY, "8.8.8.8", "private"),
)
t_strict = threading.Thread(
target=worker,
args=(EgressScope.STRICT, "1.1.1.1", "strict"),
)
t_private.start()
t_strict.start()
t_private.join()
t_strict.join()
assert isinstance(results["private"], PolicyDeniedError), (
f"PRIVATE_ONLY thread did not block public IP: {results['private']!r}"
)
assert isinstance(results["strict"], PolicyDeniedError), (
f"STRICT thread did not block public IP: {results['strict']!r}"
)
# Both threads should have cleared their context on exit.
assert get_active_context() is None
# ---------------------------------------------------------------------------
# Re-entrancy guard
# ---------------------------------------------------------------------------
class _FakeSock:
"""Minimal stand-in carrying just the .family the hook inspects."""
def __init__(self, family=socket.AF_INET):
self.family = family
def test_reentrancy_guard_makes_hook_a_noop_while_active():
"""The hook sets _hook_reentry.active around its own evaluate path so
its internal DNS/socket work can't recursively re-trigger itself. When
the flag is set, the hook must short-circuit even for a context+address
that would otherwise be denied.
Regression: the re-entrancy guard had no direct coverage. Without it
the hook would recurse (or self-deny) on its own lookups.
"""
from local_deep_research.security.egress.audit_hook import (
_audit_hook,
_hook_reentry,
)
# PRIVATE_ONLY would normally block a public IP at the hook.
set_active_context(_ctx(EgressScope.PRIVATE_ONLY))
args = (_FakeSock(), ("8.8.8.8", 80))
# Guard active => no-op, even though 8.8.8.8 is public under PRIVATE_ONLY.
_hook_reentry.active = True
try:
# Must NOT raise.
_audit_hook("socket.connect", args)
finally:
_hook_reentry.active = False
# Sanity: with the guard cleared, the SAME call is denied — proving the
# guard (not some other no-op condition) is what suppressed the block.
with pytest.raises(PolicyDeniedError):
_audit_hook("socket.connect", args)
# ---------------------------------------------------------------------------
# Review round 2 (audit-hook correctness) — regression tests
# ---------------------------------------------------------------------------
from local_deep_research.security.egress.audit_hook import ( # noqa: E402
_audit_hook,
_extract_host,
)
def test_bytes_host_is_classified_not_bypassed():
"""R2 #11: CPython fires socket.connect with a bytes host; the hook must
decode and classify it, not pass it through. A bytes metadata literal
under PRIVATE_ONLY must be blocked."""
assert _extract_host((b"169.254.169.254", 80)) == "169.254.169.254"
assert _extract_host((b"10.0.0.5", 80)) == "10.0.0.5"
set_active_context(_ctx(EgressScope.PRIVATE_ONLY))
# bytes public host under PRIVATE_ONLY must be denied (was a bypass).
with pytest.raises(PolicyDeniedError):
_audit_hook("socket.connect", (_FakeSock(), (b"8.8.8.8", 80)))
def test_extract_host_undecodable_bytes_passes_through():
"""Non-ASCII bytes host (not a real IP/host) returns None — preserves the
'failing to extract is never a reason to block' contract."""
assert _extract_host((b"\xff\xfe", 80)) is None
def test_set_active_context_rejects_adaptive_scope():
"""R2 #1: an unresolved ADAPTIVE context must not be storable — the hook
only gates PRIVATE_ONLY/STRICT, so ADAPTIVE would silently disarm it.
Fail fast instead."""
with pytest.raises(ValueError):
set_active_context(_ctx(EgressScope.ADAPTIVE))
# Nothing was stored.
assert get_active_context() is None
def test_active_egress_context_nests_and_restores_parent():
"""R2 #13: a nested active_egress_context must restore the parent on exit,
not wipe it (which would leave the parent's remaining work unprotected)."""
parent = _ctx(EgressScope.PRIVATE_ONLY)
child = _ctx(EgressScope.STRICT)
with active_egress_context(parent):
assert get_active_context() is parent
with active_egress_context(child):
assert get_active_context() is child
# Parent must be restored, not cleared.
assert get_active_context() is parent
assert get_active_context() is None
@@ -0,0 +1,306 @@
"""Regression tests for the audit-hook backstop *re-arming* paths.
The egress audit hook reads a per-thread ``EgressContext``. ``threading.local``
is NOT inherited by pool / scheduler worker threads, so several call-sites must
explicitly *re-arm* the backstop on the worker:
* ``BackgroundJobScheduler._arm_egress_backstop`` builds a context from the
user's saved settings and arms it on the APScheduler worker thread.
* ``parallel_search_engine`` captures the submitter's context once and
re-applies it per pool task (clearing it in a finally so it does not leak to
the next task on a reused worker).
* ``news_strategy`` captures the context and re-arms it via
``active_egress_context`` inside a ThreadPoolExecutor worker.
These tests pin the *real* end-to-end behaviour of those re-arm paths and the
two extraction edge cases (bytearray host, IPv6 4-tuple) that the existing
suites do not cover. They intentionally do NOT duplicate the unit cases already
in ``test_egress_audit_hook.py`` (ADAPTIVE rejection, bytes-under-PRIVATE_ONLY,
nesting) or ``test_scheduler.py`` (mocked private_only arm, bad-settings swallow).
"""
from __future__ import annotations
import concurrent.futures
import socket
from unittest.mock import MagicMock
import pytest
from local_deep_research.security import install_audit_hook
from local_deep_research.security.egress.audit_hook import (
_audit_hook,
_extract_host,
active_egress_context,
clear_active_context,
get_active_context,
set_active_context,
)
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
PolicyDeniedError,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_ctx(
scope: EgressScope = EgressScope.BOTH,
primary: str = "wikipedia",
require_local_llm: bool = False,
require_local_embeddings: bool = False,
) -> EgressContext:
"""Mirror of ``make_ctx`` in test_egress_policy.py."""
return EgressContext(
scope=scope,
primary_engine=primary,
require_local_llm=require_local_llm,
require_local_embeddings=require_local_embeddings,
)
@pytest.fixture(autouse=True)
def _clear_context_between_tests():
"""The hook reads a process-wide thread-local; a context leaking across
tests (or from an armed scheduler test) would mask the next test's setup.
"""
clear_active_context()
yield
clear_active_context()
def _probe(host: str, port: int = 53, timeout: float = 0.5):
"""Open a real AF_INET socket and attempt connect. The installed audit
hook fires BEFORE the SYN, so the kernel-level outcome is irrelevant; we
only care whether ``PolicyDeniedError`` was raised. Returns the raised
exception, or None if the connect somehow returned.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
return None
except PolicyDeniedError as exc:
return exc
except OSError as exc:
return exc
finally:
sock.close()
def _fake_settings_manager(snapshot, tool="auto"):
sm = MagicMock()
sm.get_settings_snapshot.return_value = snapshot
sm.get_setting.side_effect = lambda k, d=None: (
tool if k == "search.tool" else d
)
return sm
# ---------------------------------------------------------------------------
# 1. BackgroundJobScheduler._arm_egress_backstop — REAL wiring (not mocked)
# ---------------------------------------------------------------------------
def test_arm_backstop_really_arms_enforcing_context_and_blocks_public():
"""End-to-end: a private_only snapshot must arm a context that the
*installed* hook actually enforces — a public connect on the scheduler
thread is blocked. The existing scheduler test mocks set_active_context, so
it never proves the real thread-local + installed hook actually engage.
Also verifies the username is threaded into the built context (a settings
DB lookup for engine classification depends on it).
"""
install_audit_hook()
from local_deep_research.scheduler.background import BackgroundJobScheduler
sched = BackgroundJobScheduler()
sm = _fake_settings_manager(
{"policy.egress_scope": "private_only", "search.tool": "library"},
tool="library",
)
sched._arm_egress_backstop(sm, "alice")
ctx = get_active_context()
assert ctx is not None, "backstop did not arm a context"
assert ctx.scope == EgressScope.PRIVATE_ONLY
assert ctx.username == "alice", "username not threaded into context"
# The armed context must be live: a real public connect is denied.
err = _probe("8.8.8.8", 53)
assert isinstance(err, PolicyDeniedError), (
f"armed PRIVATE_ONLY backstop did not block public host: {err!r}"
)
assert err.decision.reason == "scope_mismatch_private_only"
def test_arm_backstop_resolves_adaptive_before_activation():
"""An ``adaptive`` scope must be RESOLVED to a concrete scope by
context_from_snapshot before set_active_context stores it. If resolution
regressed and a raw ADAPTIVE context reached set_active_context, that call
raises ValueError (swallowed best-effort) and NOTHING would be armed.
adaptive + a meta-picker primary ("auto") resolves to BOTH — concrete and
storable. We assert a context IS armed and it is not ADAPTIVE.
"""
install_audit_hook()
from local_deep_research.scheduler.background import BackgroundJobScheduler
sched = BackgroundJobScheduler()
sm = _fake_settings_manager(
{"policy.egress_scope": "adaptive", "search.tool": "auto"},
tool="auto",
)
sched._arm_egress_backstop(sm, "bob")
ctx = get_active_context()
assert ctx is not None, (
"adaptive backstop armed nothing — ADAPTIVE likely reached "
"set_active_context and was rejected"
)
assert ctx.scope == EgressScope.BOTH
assert ctx.scope != EgressScope.ADAPTIVE
# ---------------------------------------------------------------------------
# 2. Pool / worker capture-then-rearm pattern (parallel_search / news_strategy)
# ---------------------------------------------------------------------------
def test_news_strategy_rearm_pattern_enforces_in_worker_and_preserves_parent():
"""Mirror news_strategy: capture the parent thread's context with
get_active_context(), then re-arm it in a worker via active_egress_context.
The worker must enforce the captured PRIVATE_ONLY scope (a fresh worker
thread does NOT inherit the thread-local), and the parent's context must be
intact afterwards (the worker had no parent context, so the manager clears
on exit without disturbing the submitter thread).
"""
install_audit_hook()
set_active_context(make_ctx(EgressScope.PRIVATE_ONLY))
captured = get_active_context()
result = {}
def _worker():
# Worker thread starts with NO inherited context.
result["before"] = get_active_context()
with active_egress_context(captured):
result["err"] = _probe("8.8.8.8", 53)
result["after"] = get_active_context()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
ex.submit(_worker).result()
assert result["before"] is None, "thread-local leaked into fresh worker"
assert isinstance(result["err"], PolicyDeniedError), (
f"re-armed worker did not enforce PRIVATE_ONLY: {result['err']!r}"
)
assert result["err"].decision.reason == "scope_mismatch_private_only"
assert result["after"] is None, "worker did not clear re-armed context"
# Submitter thread's context is untouched by the worker's re-arm/clear.
assert get_active_context() is captured
def test_pool_wrapper_clears_context_so_it_does_not_leak_to_next_task():
"""Mirror parallel_search_engine._run_with_context: a pool worker re-arms
the captured context, runs, then clears in a finally. The CRITICAL property
is that a SECOND task landing on the SAME reused worker thread (with no
captured context) is not silently governed by the first task's PRIVATE_ONLY
policy. max_workers=1 forces both tasks onto one thread.
"""
install_audit_hook()
private_ctx = make_ctx(EgressScope.PRIVATE_ONLY)
def _pool_task(captured_ctx, host):
# Re-arm only if a context was captured (matches the production guard).
if captured_ctx is not None:
set_active_context(captured_ctx)
try:
return _probe(host, 53)
finally:
if captured_ctx is not None:
clear_active_context()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
first = ex.submit(_pool_task, private_ctx, "8.8.8.8").result()
# Second task: no captured context -> wrapper must not re-arm anything.
second = ex.submit(_pool_task, None, "8.8.8.8").result()
assert isinstance(first, PolicyDeniedError), (
f"first task did not enforce PRIVATE_ONLY: {first!r}"
)
assert not isinstance(second, PolicyDeniedError), (
"first task's PRIVATE_ONLY context leaked to the next task on the "
f"reused worker: {second!r}"
)
def test_rearm_preserves_allow_side_private_host():
"""The re-arm path must not over-block: a re-armed PRIVATE_ONLY context in
a worker still ALLOWS a private host (the local LLM / settings DB). Pairs
with the deny-side worker test above so a "deny everything" regression in
the worker path is caught.
"""
install_audit_hook()
captured = make_ctx(EgressScope.PRIVATE_ONLY)
result = {}
def _worker():
with active_egress_context(captured):
# 127.0.0.1 is private -> allowed; only a non-policy OSError may
# surface (refused/timeout), never PolicyDeniedError.
result["err"] = _probe("127.0.0.1", 1)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
ex.submit(_worker).result()
assert not isinstance(result["err"], PolicyDeniedError), (
f"re-armed PRIVATE_ONLY wrongly blocked a private host: {result['err']!r}"
)
# ---------------------------------------------------------------------------
# 3. _extract_host edge cases not covered elsewhere
# ---------------------------------------------------------------------------
def test_extract_host_decodes_bytearray():
"""CPython may fire socket.connect with a bytearray host; the existing
suite only covers ``bytes``. A bytearray must decode identically, else a
bytearray-host caller would bypass the PRIVATE_ONLY/STRICT backstop.
"""
assert _extract_host((bytearray(b"8.8.8.8"), 80)) == "8.8.8.8"
assert _extract_host((bytearray(b"10.0.0.5"), 443)) == "10.0.0.5"
def test_extract_host_ipv6_four_tuple():
"""AF_INET6 connect addresses are ``(host, port, flowinfo, scope_id)``.
_extract_host must read the host from a 4-tuple, not only a 2-tuple.
"""
assert _extract_host(("::1", 0, 0, 0)) == "::1"
assert _extract_host(("2001:4860:4860::8888", 53, 0, 0)) == (
"2001:4860:4860::8888"
)
def test_audit_hook_blocks_bytes_public_host_under_strict():
"""The existing bytes-host test only covers PRIVATE_ONLY. STRICT also bans
public hosts, and the bytes-decode path must apply there too — a bytes
public host under STRICT must be denied with the STRICT reason.
"""
set_active_context(make_ctx(EgressScope.STRICT))
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
with pytest.raises(PolicyDeniedError) as excinfo:
_audit_hook("socket.connect", (sock, (b"8.8.8.8", 80)))
finally:
sock.close()
assert excinfo.value.decision.reason == "strict_public_host"
@@ -0,0 +1,248 @@
"""Truth-table tests for the two-axis egress classification core (ADR-0007).
Stage A: the core is pure and not yet wired into any PEP. These tests pin the
four-quadrant combination rule so later wiring can't silently change it.
"""
from __future__ import annotations
import pytest
from local_deep_research.security.egress.classification import (
Component,
Exposure,
Label,
Mode,
Role,
Sensitivity,
evaluate_run,
)
S = Sensitivity.SENSITIVE
NS = Sensitivity.NON_SENSITIVE
EXP = Exposure.EXPOSING
CON = Exposure.CONTAINED
# ---------------------------------------------------------------------------
# Helpers — build the components a realistic run is made of
# ---------------------------------------------------------------------------
def source(name: str, sens: Sensitivity, exp: Exposure = CON) -> Component:
return Component(name, Role.SOURCE, Label(sens, exp))
def search_sink(name: str, exp: Exposure, sens: Sensitivity = NS) -> Component:
return Component(name, Role.SEARCH_SINK, Label(sens, exp))
def inference(name: str, exp: Exposure) -> Component:
return Component(name, Role.INFERENCE_SINK, Label(NS, exp))
def web_engine(name: str) -> list[Component]:
"""A public web engine: a non-sensitive source AND an exposing search sink."""
return [source(name, NS), search_sink(name, EXP)]
def dual_risk_store(name: str) -> list[Component]:
"""Quadrant 4: a sensitive source that is also an exposing search sink."""
return [source(name, S, EXP), search_sink(name, EXP, S)]
LOCAL_LLM = inference("llm:ollama", CON)
CLOUD_LLM = inference("llm:anthropic", EXP)
# ---------------------------------------------------------------------------
# Quadrant 1 — non-sensitive + contained: combines with anything
# ---------------------------------------------------------------------------
def test_all_non_sensitive_allows_any_sink():
run = [
source("collection_public", NS),
*web_engine("google"),
CLOUD_LLM,
]
d = evaluate_run(run)
assert d.allowed
assert d.reason == "no_sensitive_source"
# ---------------------------------------------------------------------------
# Quadrant 2 — sensitive + contained: only with other contained sources
# ---------------------------------------------------------------------------
def test_sensitive_collection_with_local_llm_allowed():
run = [source("collection_private", S), LOCAL_LLM]
d = evaluate_run(run)
assert d.allowed
assert d.reason == "sensitive_contained"
def test_two_sensitive_collections_with_local_llm_allowed():
run = [source("collection_a", S), source("collection_b", S), LOCAL_LLM]
assert evaluate_run(run).allowed
def test_sensitive_collection_with_cloud_llm_denied():
run = [source("collection_private", S), CLOUD_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
assert d.offending == ("llm:anthropic",)
def test_sensitive_collection_with_public_engine_denied():
run = [source("collection_private", S), *web_engine("arxiv"), LOCAL_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
assert d.offending == ("arxiv",)
# ---------------------------------------------------------------------------
# Quadrant 3 — non-sensitive + exposing: only with non-sensitive sources
# ---------------------------------------------------------------------------
def test_public_engine_with_public_collection_and_cloud_llm_allowed():
run = [
source("collection_public", NS),
*web_engine("google"),
CLOUD_LLM,
]
assert evaluate_run(run).allowed
# ---------------------------------------------------------------------------
# Quadrant 4 — sensitive + exposing: solo, with contained inference
# ---------------------------------------------------------------------------
def test_dual_risk_store_solo_with_local_inference_allowed():
run = [*dual_risk_store("elasticsearch"), LOCAL_LLM]
d = evaluate_run(run)
assert d.allowed
assert d.reason == "sensitive_contained"
def test_dual_risk_store_with_cloud_inference_denied():
run = [*dual_risk_store("elasticsearch"), CLOUD_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
def test_dual_risk_store_with_other_sensitive_source_denied():
run = [
*dual_risk_store("elasticsearch"),
source("collection_private", S),
LOCAL_LLM,
]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
assert d.offending == ("elasticsearch",)
def test_two_dual_risk_stores_denied():
run = [
*dual_risk_store("es_a"),
*dual_risk_store("es_b"),
LOCAL_LLM,
]
assert not evaluate_run(run).allowed
# ---------------------------------------------------------------------------
# Exposing embeddings count as an exposing inference sink
# ---------------------------------------------------------------------------
def test_sensitive_with_cloud_embeddings_denied():
run = [
source("collection_private", S),
LOCAL_LLM,
inference("embeddings:openai", EXP),
]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
assert d.offending == ("embeddings:openai",)
# ---------------------------------------------------------------------------
# Permissive mode suspends the invariant (caller still warns)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"run",
[
[source("collection_private", S), CLOUD_LLM],
[source("collection_private", S), *web_engine("arxiv"), LOCAL_LLM],
[*dual_risk_store("es"), CLOUD_LLM],
],
)
def test_permissive_mode_always_allows(run):
enforced = evaluate_run(run)
assert not enforced.allowed # denied when enforcing
d = evaluate_run(run, mode=Mode.PERMISSIVE)
assert d.allowed
# The would-be-enforced diagnostic is preserved for the stage-C banner.
assert d.reason == f"permissive:{enforced.reason}"
assert d.offending == enforced.offending
def test_quadrant4_store_plus_second_exposing_engine_denied():
# A dual-risk store is only allowed SOLO. Add a public web engine and the
# store's sensitive data can now reach that foreign exposing sink -> deny.
# The store's OWN sink is not offending (returning its data to itself is
# not a new leak); only the foreign "arxiv" sink is reported.
run = [*dual_risk_store("es"), *web_engine("arxiv"), LOCAL_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
assert d.offending == ("arxiv",)
def test_name_collision_does_not_exempt_non_sensitive_sink():
# A NON-sensitive exposing search sink that merely shares a name with an
# unrelated sensitive source must NOT be treated as the solo dual-risk case.
run = [
Component("x", Role.SOURCE, Label(S, CON)), # sensitive source "x"
Component("x", Role.SEARCH_SINK, Label(NS, EXP)), # unrelated sink "x"
LOCAL_LLM,
]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
def test_multi_violation_inference_takes_precedence():
run = [
source("collection_private", S),
*web_engine("google"),
CLOUD_LLM,
]
# Inference leak is checked first; the exposing search engine is masked.
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
# ---------------------------------------------------------------------------
# Degenerate inputs
# ---------------------------------------------------------------------------
def test_empty_run_allowed():
assert evaluate_run([]).allowed
def test_only_contained_inference_no_sources_allowed():
assert evaluate_run([LOCAL_LLM]).allowed
@@ -0,0 +1,310 @@
"""Follow-up regression tests for the NEW collection classification semantics.
A collection (``collection_<uuid>`` or the aggregate ``library``) is ALWAYS a
local knowledge base, and its ``is_public`` flag is ADDITIVE — marking a
collection public ALSO makes it eligible under PUBLIC_ONLY/cloud inference, but
NEVER removes its private (local) eligibility. Concretely, ``_engine_bucket``
returns ``(is_public, is_local=True)`` for any collection.
These tests pin that bucket shape directly, the DB-resolution fallback when no
metadata is supplied, and the ADAPTIVE-scope resolution when the PRIMARY engine
is a collection (public -> BOTH, private -> PRIVATE_ONLY forcing local
inference). They complement (do not duplicate) the metadata-driven evaluate_engine
cases already covered in tests/security/test_egress_policy.py by attacking the
``_engine_bucket`` / ``_resolve_collection_is_public`` / ``_resolve_adaptive_scope``
functions directly and the DB-lookup path.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
_engine_bucket,
_resolve_adaptive_scope,
_resolve_collection_is_public,
context_from_snapshot,
evaluate_engine,
)
# ---------------------------------------------------------------------------
# Helpers (mirrors make_ctx in tests/security/test_egress_policy.py)
# ---------------------------------------------------------------------------
def make_ctx(
scope: EgressScope = EgressScope.BOTH,
primary: str = "arxiv",
require_local_llm: bool = False,
require_local_embeddings: bool = False,
local_hostnames=(),
username=None,
) -> EgressContext:
return EgressContext(
scope=scope,
primary_engine=primary,
require_local_llm=require_local_llm,
require_local_embeddings=require_local_embeddings,
local_hostnames=tuple(local_hostnames),
username=username,
)
_POLICY = "local_deep_research.security.egress.policy"
# ---------------------------------------------------------------------------
# _engine_bucket — the (is_public, is_local=True) contract
# ---------------------------------------------------------------------------
def test_engine_bucket_public_collection_is_public_AND_local():
"""A public collection buckets as (True, True): the public flag is
ADDITIVE, it never strips the local-KB eligibility."""
ctx = make_ctx(primary="collection_pub")
bucket = _engine_bucket(
"collection_pub", ctx, {}, metadata={"is_public": True}
)
assert bucket == (True, True)
def test_engine_bucket_private_collection_is_local_only():
"""A private collection buckets as (False, True) — local, not public."""
ctx = make_ctx(primary="collection_priv")
bucket = _engine_bucket(
"collection_priv", ctx, {}, metadata={"is_public": False}
)
assert bucket == (False, True)
def test_engine_bucket_library_is_local_only_via_db_default():
"""``library`` is the always-private aggregate; with no metadata it
resolves via the (always-False) collection resolver -> (False, True)."""
ctx = make_ctx(primary="library")
bucket = _engine_bucket("library", ctx, {})
assert bucket == (False, True)
def test_engine_bucket_metadata_short_circuits_db_lookup():
"""When the caller supplies engine metadata, _engine_bucket must NOT
fall back to the DB resolver — the metadata flag wins outright."""
ctx = make_ctx(primary="collection_x")
with patch(f"{_POLICY}._resolve_collection_is_public") as mock_resolve:
mock_resolve.return_value = False # would mis-classify if consulted
bucket = _engine_bucket(
"collection_x", ctx, {}, metadata={"is_public": True}
)
mock_resolve.assert_not_called()
assert bucket == (True, True)
def test_engine_bucket_collection_no_metadata_consults_db_resolver():
"""Without metadata, _engine_bucket delegates classification to
_resolve_collection_is_public and threads is_local=True regardless."""
ctx = make_ctx(primary="collection_y", username="alice")
with patch(
f"{_POLICY}._resolve_collection_is_public", return_value=True
) as mock_resolve:
bucket = _engine_bucket("collection_y", ctx, {})
mock_resolve.assert_called_once_with("collection_y", "alice")
assert bucket == (True, True)
# ---------------------------------------------------------------------------
# evaluate_engine — library across ALL scopes
# ---------------------------------------------------------------------------
def test_library_engine_evaluate_across_all_scopes():
"""``library`` (always private/local): allowed under PRIVATE_ONLY and
BOTH, DENIED under PUBLIC_ONLY, allowed under STRICT only when primary."""
# PRIVATE_ONLY / BOTH -> allowed
for scope in (EgressScope.PRIVATE_ONLY, EgressScope.BOTH):
ctx = make_ctx(scope=scope, primary="library")
assert evaluate_engine("library", ctx, settings_snapshot={}).allowed, (
f"library should be allowed under {scope}"
)
# PUBLIC_ONLY -> denied (it is local-only)
ctx_pub = make_ctx(scope=EgressScope.PUBLIC_ONLY, primary="library")
denied = evaluate_engine("library", ctx_pub, settings_snapshot={})
assert denied.allowed is False
assert denied.reason == "scope_mismatch_public_only"
# STRICT + library is primary -> allowed
ctx_strict_ok = make_ctx(scope=EgressScope.STRICT, primary="library")
assert evaluate_engine(
"library", ctx_strict_ok, settings_snapshot={}
).allowed
# STRICT + library is NOT primary -> denied
ctx_strict_no = make_ctx(scope=EgressScope.STRICT, primary="arxiv")
d = evaluate_engine("library", ctx_strict_no, settings_snapshot={})
assert d.allowed is False
assert d.reason == "strict_not_primary"
# ---------------------------------------------------------------------------
# evaluate_engine — DB-resolution path (no metadata supplied)
# ---------------------------------------------------------------------------
def test_collection_db_default_private_denied_under_public_only():
"""A collection resolved to private via the DB path is excluded from
PUBLIC_ONLY — the additive flag must default closed (private)."""
ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY, primary="collection_z")
with patch(f"{_POLICY}._resolve_collection_is_public", return_value=False):
decision = evaluate_engine("collection_z", ctx, settings_snapshot={})
assert decision.allowed is False
assert decision.reason == "scope_mismatch_public_only"
def test_collection_db_public_allowed_under_public_only_without_metadata():
"""A collection the DB marks public is allowed under PUBLIC_ONLY even
when the caller passes NO metadata (the resolver is consulted)."""
ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY, primary="collection_z")
with patch(f"{_POLICY}._resolve_collection_is_public", return_value=True):
decision = evaluate_engine("collection_z", ctx, settings_snapshot={})
assert decision.allowed is True
def test_collection_db_public_still_allowed_under_private_only():
"""Additive semantics: a PUBLIC collection (no metadata, DB path) is
STILL local, so it remains allowed under PRIVATE_ONLY."""
ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY, primary="collection_z")
with patch(f"{_POLICY}._resolve_collection_is_public", return_value=True):
decision = evaluate_engine("collection_z", ctx, settings_snapshot={})
assert decision.allowed is True
# ---------------------------------------------------------------------------
# _resolve_collection_is_public — direct unit coverage of the DB path
# ---------------------------------------------------------------------------
def test_resolve_collection_is_public_library_always_private():
"""``library`` short-circuits to False without any DB access."""
assert _resolve_collection_is_public("library", "alice") is False
def test_resolve_collection_is_public_non_collection_name_false():
"""A non-collection engine name short-circuits to False."""
assert _resolve_collection_is_public("arxiv", "alice") is False
def test_resolve_collection_is_public_reads_db_row_true():
"""The DB path returns the row's is_public flag (True case)."""
mock_row = MagicMock()
mock_row.is_public = True
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = (
mock_row
)
cm = MagicMock()
cm.__enter__.return_value = mock_session
cm.__exit__.return_value = False
with patch(
"local_deep_research.database.session_context.get_user_db_session",
return_value=cm,
):
result = _resolve_collection_is_public("collection_pub123", "alice")
assert result is True
def test_resolve_collection_is_public_missing_row_fails_closed_private():
"""No matching collection row -> False (private), not an error."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = (
None
)
cm = MagicMock()
cm.__enter__.return_value = mock_session
cm.__exit__.return_value = False
with patch(
"local_deep_research.database.session_context.get_user_db_session",
return_value=cm,
):
result = _resolve_collection_is_public("collection_missing", "alice")
assert result is False
def test_resolve_collection_is_public_db_error_fails_closed_private():
"""Any DB error fails closed to private (False) — never public."""
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=RuntimeError("db down"),
):
result = _resolve_collection_is_public("collection_err", "alice")
assert result is False
# ---------------------------------------------------------------------------
# _resolve_adaptive_scope — collection primary classification
# ---------------------------------------------------------------------------
def test_adaptive_public_collection_primary_resolves_to_both():
"""A PUBLIC collection primary buckets (True, True); neither exclusive
branch fires, so ADAPTIVE resolves to BOTH (cloud inference allowed)."""
with patch(f"{_POLICY}._resolve_collection_is_public", return_value=True):
scope = _resolve_adaptive_scope(
"collection_pub",
{},
username="alice",
local_hostnames=(),
)
assert scope == EgressScope.BOTH
def test_adaptive_private_collection_primary_resolves_to_private_only():
"""A PRIVATE collection primary buckets (False, True) -> PRIVATE_ONLY."""
with patch(f"{_POLICY}._resolve_collection_is_public", return_value=False):
scope = _resolve_adaptive_scope(
"collection_priv",
{},
username="alice",
local_hostnames=(),
)
assert scope == EgressScope.PRIVATE_ONLY
def test_adaptive_library_primary_resolves_to_private_only():
"""``library`` is always private -> ADAPTIVE resolves to PRIVATE_ONLY."""
scope = _resolve_adaptive_scope(
"library",
{},
username="alice",
local_hostnames=(),
)
assert scope == EgressScope.PRIVATE_ONLY
# ---------------------------------------------------------------------------
# context_from_snapshot — ADAPTIVE + collection primary end-to-end
# ---------------------------------------------------------------------------
def test_context_adaptive_public_collection_both_no_force_local():
"""ADAPTIVE + public-collection primary -> resolved scope BOTH and the
local-inference flags are NOT forced (cloud LLM/embeddings permitted)."""
snapshot = {"policy.egress_scope": {"value": "adaptive"}}
with patch(f"{_POLICY}._resolve_collection_is_public", return_value=True):
ctx = context_from_snapshot(
snapshot, primary_engine="collection_pub", username="alice"
)
assert ctx.scope == EgressScope.BOTH
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
def test_context_adaptive_private_collection_private_forces_local():
"""ADAPTIVE + private-collection primary -> PRIVATE_ONLY, which implies
(forces) local LLM and local embeddings so the corpus can't leak."""
snapshot = {"policy.egress_scope": {"value": "adaptive"}}
with patch(f"{_POLICY}._resolve_collection_is_public", return_value=False):
ctx = context_from_snapshot(
snapshot, primary_engine="collection_priv", username="alice"
)
assert ctx.scope == EgressScope.PRIVATE_ONLY
assert ctx.require_local_llm is True
assert ctx.require_local_embeddings is True
@@ -0,0 +1,199 @@
"""Follow-up regression tests for downloader egress-policy threading.
These cover GAPS not already exercised by
``tests/content_fetcher/test_security.py`` (which only tests the direct
``_apply_egress_policy_to_downloader`` call with PRIVATE_ONLY / PUBLIC_ONLY /
no-context / no-session) and ``tests/security/test_egress_policy.py`` (which
only tests ``policy_aware_validate_url`` for PRIVATE_ONLY-allows-private +
metadata-blocked).
What is added here:
* the relaxation reaches a downloader through the REAL ``_get_downloader``
code path AND survives caching (a second fetch reuses the relaxed session);
* STRICT / BOTH scopes do NOT relax — only PRIVATE_ONLY does, mirroring
``policy_aware_validate_url``;
* a downloader whose ``.session`` lacks ``allow_private_ips`` is handled
without mutating it or raising;
* cloud-metadata IPs stay blocked by ``is_ip_blocked`` / ``validate_url``
even when private IPs are allowed (the relaxation must not open IMDS);
* ``policy_aware_validate_url`` scope behaviour: PRIVATE_ONLY permits private
hosts, every other scope (and no context) stays strict.
All tests import and call the real code under test; each is an allow+deny pair
or an explicit edge case that would FAIL if the guarded behaviour regressed.
"""
from __future__ import annotations
from local_deep_research.content_fetcher import ContentFetcher
from local_deep_research.content_fetcher.url_classifier import URLType
from local_deep_research.security.egress.fetch import policy_aware_validate_url
from local_deep_research.security.egress.policy import EgressScope
from local_deep_research.security.ssrf_validator import (
ALWAYS_BLOCKED_METADATA_IPS,
is_ip_blocked,
validate_url,
)
# Reuse the canonical context builder rather than duplicating a fixture.
from tests.security.test_egress_policy import make_ctx
# ---------------------------------------------------------------------------
# Real _get_downloader path: relaxation applied + survives caching
# ---------------------------------------------------------------------------
def test_get_downloader_real_path_private_only_relaxes_session():
"""Through the REAL ``_get_downloader`` (not a direct call to the helper),
a PRIVATE_ONLY context relaxes the constructed downloader's SafeSession."""
cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.PRIVATE_ONLY))
downloader = cf._get_downloader(URLType.PDF)
assert downloader is not None
assert downloader.session.allow_private_ips is True
def test_get_downloader_caches_relaxed_downloader():
"""The cached downloader (returned by a second ``_get_downloader`` call)
is the SAME object and keeps the relaxation — fetch reuses it per
fetcher.py, so the relaxation must persist across calls."""
cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.PRIVATE_ONLY))
first = cf._get_downloader(URLType.PDF)
second = cf._get_downloader(URLType.PDF)
assert first is second # cache hit, not re-constructed
assert second.session.allow_private_ips is True
def test_get_downloader_real_path_strict_does_not_relax():
"""Deny side: under STRICT the real ``_get_downloader`` must leave the
downloader session strict (only PRIVATE_ONLY relaxes)."""
cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.STRICT))
downloader = cf._get_downloader(URLType.PDF)
assert downloader is not None
assert downloader.session.allow_private_ips is False
# ---------------------------------------------------------------------------
# _apply_egress_policy_to_downloader: scope coverage (STRICT / BOTH) + edge
# ---------------------------------------------------------------------------
def _fake_downloader_with_session():
from local_deep_research.security.safe_requests import SafeSession
class _DL:
def __init__(self):
self.session = SafeSession()
return _DL()
def test_apply_strict_scope_keeps_session_strict():
"""STRICT must NOT relax — mirrors policy_aware_validate_url, which only
threads allow_private_ips for PRIVATE_ONLY. (test_security covers
PUBLIC_ONLY; STRICT is the uncovered scope.)"""
cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.STRICT))
dl = _fake_downloader_with_session()
cf._apply_egress_policy_to_downloader(dl)
assert dl.session.allow_private_ips is False
def test_apply_both_scope_keeps_session_strict():
"""BOTH must NOT relax either: it allows public hosts via normal SSRF
rules but does not blanket-permit private IPs the way PRIVATE_ONLY does."""
cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.BOTH))
dl = _fake_downloader_with_session()
cf._apply_egress_policy_to_downloader(dl)
assert dl.session.allow_private_ips is False
def test_apply_session_lacking_allow_private_ips_attr_is_handled():
"""A downloader whose ``.session`` is a non-SafeSession object lacking the
``allow_private_ips`` attribute must not raise and must not have the
attribute injected (the helper guards with ``hasattr``)."""
cf = ContentFetcher(egress_context=make_ctx(scope=EgressScope.PRIVATE_ONLY))
class _BareSession:
pass
class _DL:
def __init__(self):
self.session = _BareSession()
dl = _DL()
cf._apply_egress_policy_to_downloader(dl) # must not raise
assert not hasattr(dl.session, "allow_private_ips")
# ---------------------------------------------------------------------------
# Cloud-metadata IPs stay blocked even when private IPs are allowed
# ---------------------------------------------------------------------------
def test_is_ip_blocked_metadata_blocked_even_with_allow_private_ips():
"""The relaxation a PRIVATE_ONLY downloader receives is
``allow_private_ips=True``; that must NOT open cloud-metadata IPs.
``is_ip_blocked`` must still reject every IMDS endpoint."""
for ip in ALWAYS_BLOCKED_METADATA_IPS:
assert is_ip_blocked(ip, allow_private_ips=True) is True, (
f"metadata IP {ip} must stay blocked with allow_private_ips"
)
def test_is_ip_blocked_private_allow_deny_pair():
"""Allow+deny pair: an ordinary RFC1918 host is blocked by default but
permitted once allow_private_ips is set — proving the relaxation flag is
what unlocks private (and only private) reachability."""
assert is_ip_blocked("192.168.1.5") is True # strict default
assert is_ip_blocked("192.168.1.5", allow_private_ips=True) is False
def test_relaxed_validate_url_still_blocks_metadata():
"""SafeSession.request forwards allow_private_ips into validate_url. With
the relaxation active, a private host validates but a metadata host is
still rejected — the exact behaviour a relaxed downloader session relies
on (allow+deny pair)."""
assert (
validate_url("http://192.168.1.5/api", allow_private_ips=True) is True
)
assert (
validate_url(
"http://169.254.169.254/latest/meta-data/", allow_private_ips=True
)
is False
)
# ---------------------------------------------------------------------------
# policy_aware_validate_url scope behaviour
# ---------------------------------------------------------------------------
def test_policy_aware_validate_url_private_only_allows_private_hosts():
"""PRIVATE_ONLY permits loopback and RFC1918 lab hosts (this is the side
the downloader relaxation mirrors)."""
ctx = make_ctx(scope=EgressScope.PRIVATE_ONLY)
assert policy_aware_validate_url("http://127.0.0.1:11434", ctx) is True
assert policy_aware_validate_url("http://192.168.1.5/api", ctx) is True
assert policy_aware_validate_url("http://10.1.2.3/api", ctx) is True
def test_policy_aware_validate_url_non_private_scopes_stay_strict():
"""Deny side: STRICT, PUBLIC_ONLY and BOTH must NOT thread
allow_private_ips, so a private IP literal is rejected under each."""
private_url = "http://192.168.1.5/api"
for scope in (
EgressScope.STRICT,
EgressScope.PUBLIC_ONLY,
EgressScope.BOTH,
):
ctx = make_ctx(scope=scope)
assert policy_aware_validate_url(private_url, ctx) is False, (
f"private host must be rejected under {scope}"
)
def test_policy_aware_validate_url_no_context_is_strict():
"""No egress context falls back to strict validate_url: a private host is
rejected just like the un-relaxed default."""
assert policy_aware_validate_url("http://192.168.1.5/api", None) is False
@@ -0,0 +1,285 @@
"""End-to-end "nothing leaves the box" tripwire for the egress policy.
This is the marquee integration test: it arms a REAL ``EgressContext`` via
``set_active_context`` (the same call the run orchestrator makes) and then
asserts that *every* layer of the stack agrees on the same decision under one
armed context:
* the installed PEP 578 audit hook gating raw ``socket.connect`` (the
last-line backstop that fires BEFORE the SYN is sent), and
* the PDP call-site evaluators (``evaluate_url`` / ``evaluate_engine`` /
``evaluate_llm_endpoint``) that the explicit fetch / engine-factory /
LLM-factory PEPs consult.
Unlike the focused hook test (``test_egress_audit_hook.py``) and the PDP unit
tests (``test_egress_policy.py``), this file does not pick the layers apart — it
checks that with a single armed PRIVATE_ONLY (and STRICT) context the *whole*
stack refuses public egress while still permitting the user's own loopback /
local services, and that the permissive scopes (PUBLIC_ONLY / BOTH) do NOT arm
the socket-level hook.
No outbound network is ever required: under an armed enforcing context the audit
hook raises ``PolicyDeniedError`` before ``connect`` reaches the kernel, so even
a non-blocking connect to a public IP returns the policy denial with no packets
on the wire.
"""
from __future__ import annotations
import socket
import pytest
from local_deep_research.security import (
clear_active_context,
get_active_context,
install_audit_hook,
is_audit_hook_installed,
set_active_context,
)
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
PolicyDeniedError,
evaluate_engine,
evaluate_llm_endpoint,
evaluate_url,
)
# A public IP (Google DNS) and a public engine / cloud LLM provider used as the
# "should be denied" probes, plus a loopback IP used as the "should be allowed"
# probe. None of these need a live connection — see module docstring.
_PUBLIC_IP = "8.8.8.8"
_LOOPBACK_IP = "127.0.0.1"
_PUBLIC_ENGINE = "arxiv" # statically classified public in the engine registry
_CLOUD_LLM = "openai" # in _CLOUD_LLM_PROVIDERS
_METADATA_IP = "169.254.169.254" # AWS/GCE IMDS — never permitted, any scope
_METADATA_IP_OCTAL = "0251.0376.0251.0376" # alternate encoding of the above
@pytest.fixture(autouse=True)
def _ensure_hook_and_clean_context():
"""The audit hook is install-once-and-keep-forever (PEP 578 hooks cannot be
removed). Make sure it is installed for the socket-layer assertions, and
that no context leaks into or out of any test in this file — a leaked
enforcing context would make an unrelated test's public connect raise.
"""
install_audit_hook()
clear_active_context()
yield
clear_active_context()
def _make_ctx(
scope: EgressScope,
*,
require_local_llm: bool = False,
require_local_embeddings: bool = False,
) -> EgressContext:
"""Build a fresh minimal context for ``scope``. Fresh per test so the
per-run denial quota / DNS cache never carries across tests.
"""
return EgressContext(
scope=scope,
primary_engine="wikipedia",
require_local_llm=require_local_llm,
require_local_embeddings=require_local_embeddings,
)
def _attempt_connect(host: str, port: int = 80, timeout: float = 0.5):
"""Open a raw AF_INET socket and connect; return the raised exception (or
None). We only care whether the audit hook raised ``PolicyDeniedError`` —
the kernel-level outcome (refused / timeout) is irrelevant because the hook
fires first.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
return None
except PolicyDeniedError as exc:
return exc
except OSError as exc:
return exc
finally:
sock.close()
# ---------------------------------------------------------------------------
# PRIVATE_ONLY: the full stack agrees
# ---------------------------------------------------------------------------
def test_private_only_armed_blocks_public_socket_allows_loopback():
"""With a real armed PRIVATE_ONLY context, a raw socket.connect to a public
IP is refused by the audit hook (no network), while a connect to loopback
is NOT refused by the hook. Allow+deny pair: if the hook stopped gating,
the public probe would no longer raise PolicyDeniedError.
"""
assert is_audit_hook_installed() is True
ctx = _make_ctx(EgressScope.PRIVATE_ONLY)
set_active_context(ctx)
try:
public_err = _attempt_connect(_PUBLIC_IP, 53)
loopback_err = _attempt_connect(_LOOPBACK_IP, 1)
finally:
clear_active_context()
assert isinstance(public_err, PolicyDeniedError), (
f"armed PRIVATE_ONLY did not block public socket: {public_err!r}"
)
assert public_err.decision.reason == "scope_mismatch_private_only"
assert public_err.target == _PUBLIC_IP
assert not isinstance(loopback_err, PolicyDeniedError), (
f"armed PRIVATE_ONLY wrongly blocked loopback: {loopback_err!r}"
)
def test_private_only_armed_pdp_denies_public_url_engine_and_cloud_llm():
"""Under the SAME armed PRIVATE_ONLY context, every PDP call site agrees:
evaluate_url(public) / evaluate_engine(public) / evaluate_llm_endpoint(cloud)
all deny, while their private/local mirrors are allowed. The mirror halves
make each assertion fail if the scope check were reverted to allow-all.
"""
ctx = _make_ctx(
EgressScope.PRIVATE_ONLY,
require_local_llm=True,
require_local_embeddings=True,
)
set_active_context(ctx)
try:
armed = get_active_context()
assert armed is ctx
# evaluate_url: public denied, loopback allowed.
url_public = evaluate_url(f"http://{_PUBLIC_IP}/", armed)
url_private = evaluate_url(f"http://{_LOOPBACK_IP}/", armed)
# evaluate_engine: public engine denied, local engine allowed.
engine_public = evaluate_engine(
_PUBLIC_ENGINE, armed, settings_snapshot={}
)
engine_local = evaluate_engine("paperless", armed, settings_snapshot={})
# evaluate_llm_endpoint: cloud denied, local-default allowed.
llm_cloud = evaluate_llm_endpoint(
_CLOUD_LLM, armed, settings_snapshot={}
)
llm_local = evaluate_llm_endpoint("ollama", armed, settings_snapshot={})
finally:
clear_active_context()
assert not url_public.allowed
assert url_public.reason == "scope_mismatch_private_only"
assert url_private.allowed, "loopback URL wrongly denied under PRIVATE_ONLY"
assert not engine_public.allowed
assert engine_public.reason == "scope_mismatch_private_only"
assert engine_local.allowed, (
"local engine wrongly denied under PRIVATE_ONLY"
)
assert not llm_cloud.allowed
assert llm_cloud.reason == "provider_cloud_only"
assert llm_local.allowed, "local LLM wrongly denied under require_local_llm"
def test_private_only_armed_blocks_metadata_ip_canonical_and_octal():
"""Cloud-metadata IPs (IMDS) are NEVER permitted regardless of scope, and
classify with the explicit blocked_metadata_ip reason — including the
alternate octal encoding the libc resolver accepts. PRIVATE_ONLY would
otherwise ALLOW these (they look link-local/private), so this guards the
credential-theft path. Mirror: a real loopback URL is still allowed, proving
the denial is the metadata guard, not a blanket refusal.
"""
ctx = _make_ctx(EgressScope.PRIVATE_ONLY)
set_active_context(ctx)
try:
armed = get_active_context()
canonical = evaluate_url(
f"http://{_METADATA_IP}/latest/meta-data/", armed
)
octal = evaluate_url(f"http://{_METADATA_IP_OCTAL}/", armed)
loopback = evaluate_url(f"http://{_LOOPBACK_IP}/", armed)
finally:
clear_active_context()
assert not canonical.allowed
assert canonical.reason == "blocked_metadata_ip"
assert not octal.allowed
assert octal.reason == "blocked_metadata_ip"
assert loopback.allowed, "loopback wrongly denied — guard is over-broad"
# ---------------------------------------------------------------------------
# STRICT: same socket-layer contract as PRIVATE_ONLY
# ---------------------------------------------------------------------------
def test_strict_armed_socket_layer_matches_private_only():
"""STRICT must behave like PRIVATE_ONLY at the socket layer: public host
refused (with the strict_public_host reason), loopback permitted. Allow+deny
pair against the public/loopback probes.
"""
ctx = _make_ctx(EgressScope.STRICT)
set_active_context(ctx)
try:
public_err = _attempt_connect(_PUBLIC_IP, 53)
loopback_err = _attempt_connect(_LOOPBACK_IP, 1)
finally:
clear_active_context()
assert isinstance(public_err, PolicyDeniedError), (
f"armed STRICT did not block public socket: {public_err!r}"
)
assert public_err.decision.reason == "strict_public_host"
assert not isinstance(loopback_err, PolicyDeniedError), (
f"armed STRICT wrongly blocked loopback: {loopback_err!r}"
)
# ---------------------------------------------------------------------------
# PUBLIC_ONLY / BOTH: do NOT arm the socket-level hook
# ---------------------------------------------------------------------------
def test_public_only_armed_does_not_arm_socket_hook():
"""PUBLIC_ONLY governs ENGINE selection, not which hosts the process may
reach at the socket level (local Ollama / settings DB use private IPs). The
hook must therefore stay a no-op for ALL host classes under PUBLIC_ONLY —
otherwise legitimate local services would be false-positived. Contrast with
the PRIVATE_ONLY tripwire above, which DOES raise on the public probe.
"""
ctx = _make_ctx(EgressScope.PUBLIC_ONLY)
set_active_context(ctx)
try:
results = {
host: _attempt_connect(host, 1)
for host in (_PUBLIC_IP, "192.168.42.1", _LOOPBACK_IP)
}
finally:
clear_active_context()
for host, err in results.items():
assert not isinstance(err, PolicyDeniedError), (
f"PUBLIC_ONLY wrongly armed the hook for {host}: {err!r}"
)
def test_both_armed_does_not_arm_socket_hook():
"""BOTH is the permissive default — the audit hook has nothing to enforce,
so a public connect must pass through. This is the deny-side contrast to the
PRIVATE_ONLY public probe: same address, opposite outcome, proving the hook
keys off the armed scope and not the address alone.
"""
ctx = _make_ctx(EgressScope.BOTH)
set_active_context(ctx)
try:
err = _attempt_connect(_PUBLIC_IP, 53)
finally:
clear_active_context()
assert not isinstance(err, PolicyDeniedError), (
f"BOTH scope wrongly armed the hook: {err!r}"
)
+200
View File
@@ -0,0 +1,200 @@
"""Integration tests for the ``full_search.py`` egress Policy Enforcement Point:
``web_search_engines/engines/full_search.py`` — per-URL egress gating in the
full-content fetch path: when an ``egress_context`` is present, URLs that
``evaluate_url`` denies for the scope are dropped before any network fetch,
allowed ones are kept, and the denial audit log is URL-redacted.
These drive the REAL call-site code. Only unavoidable heavy deps are mocked
(the network ``batch_fetch_and_extract`` and the SSRF ``validate_url``, so the
SSRF axis is isolated from the orthogonal egress-scope axis under test). The
egress decision itself (``evaluate_url``) is exercised for real against IP
literals, so no DNS / network is required.
"""
from __future__ import annotations
import pytest
from loguru import logger
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
)
from local_deep_research.web_search_engines.engines import full_search as fs
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_ctx(scope: EgressScope) -> EgressContext:
return EgressContext(
scope=scope,
primary_engine="library",
require_local_llm=False,
require_local_embeddings=False,
)
class _FakeWebSearch:
"""Minimal stand-in for the inner web search engine (``.invoke``)."""
def __init__(self, results):
self._results = results
def invoke(self, query): # noqa: D401 - protocol method
return list(self._results)
# Public + private IP literals resolve locally (no DNS / network) and match
# the literal-classification path the existing edge-case tests use.
_PUBLIC_URL = "http://93.184.216.34/page"
_PRIVATE_URL = "http://10.0.0.5/api"
# ===========================================================================
# full_search per-URL egress gating
# ===========================================================================
@pytest.fixture
def patched_fetch(monkeypatch):
"""Capture the URLs that survive the gate and reach the fetcher; isolate
the SSRF axis by forcing ``validate_url`` True so only egress scope
decides which URLs pass."""
captured = {"urls": None, "calls": 0}
def fake_batch(urls, **kwargs):
captured["calls"] += 1
captured["urls"] = list(urls)
return {u: "content-for-" + u for u in urls}
monkeypatch.setattr(fs, "batch_fetch_and_extract", fake_batch)
monkeypatch.setattr(fs, "validate_url", lambda u: True)
return captured
def _engine(results, ctx):
return fs.FullSearchResults(
llm=None, # llm None -> check_urls returns results unfiltered
web_search=_FakeWebSearch(results),
egress_context=ctx,
)
def test_run_private_only_drops_public_keeps_private(patched_fetch):
results = [
{"title": "pub", "link": _PUBLIC_URL},
{"title": "priv", "link": _PRIVATE_URL},
]
engine = _engine(results, _make_ctx(EgressScope.PRIVATE_ONLY))
out = engine.run("q")
# Only the private URL reached the fetcher.
assert patched_fetch["urls"] == [_PRIVATE_URL]
# And only its result carries full content; the denied public one is None.
by_link = {r["link"]: r for r in out}
assert (
by_link[_PRIVATE_URL]["full_content"] == "content-for-" + _PRIVATE_URL
)
assert by_link[_PUBLIC_URL]["full_content"] is None
def test_run_public_only_drops_private_keeps_public(patched_fetch):
results = [
{"title": "pub", "link": _PUBLIC_URL},
{"title": "priv", "link": _PRIVATE_URL},
]
engine = _engine(results, _make_ctx(EgressScope.PUBLIC_ONLY))
out = engine.run("q")
assert patched_fetch["urls"] == [_PUBLIC_URL]
by_link = {r["link"]: r for r in out}
assert by_link[_PUBLIC_URL]["full_content"] == "content-for-" + _PUBLIC_URL
assert by_link[_PRIVATE_URL]["full_content"] is None
def test_run_all_denied_skips_fetch_entirely(patched_fetch):
"""PRIVATE_ONLY with only public URLs -> nothing passes the gate, the
network fetcher is never invoked, all results get null content."""
results = [{"title": "pub", "link": _PUBLIC_URL}]
engine = _engine(results, _make_ctx(EgressScope.PRIVATE_ONLY))
out = engine.run("q")
assert patched_fetch["calls"] == 0
assert out[0]["full_content"] is None
def test_run_without_egress_context_does_not_gate(patched_fetch):
"""Control: with no egress_context the per-URL scope gate is inactive, so
both URLs reach the fetcher. This proves the gate above is driven by the
egress context and not by some unrelated filter."""
results = [
{"title": "pub", "link": _PUBLIC_URL},
{"title": "priv", "link": _PRIVATE_URL},
]
engine = fs.FullSearchResults(
llm=None, web_search=_FakeWebSearch(results), egress_context=None
)
engine.run("q")
assert patched_fetch["urls"] == [_PUBLIC_URL, _PRIVATE_URL]
def test_get_full_content_gates_per_url(patched_fetch):
"""The secondary ``_get_full_content`` path enforces the same per-URL
scope gate."""
items = [
{"title": "pub", "link": _PUBLIC_URL},
{"title": "priv", "link": _PRIVATE_URL},
]
engine = _engine(items, _make_ctx(EgressScope.PRIVATE_ONLY))
out = engine._get_full_content(items)
assert patched_fetch["urls"] == [_PRIVATE_URL]
by_link = {r["link"]: r for r in out}
assert (
by_link[_PRIVATE_URL]["full_content"] == "content-for-" + _PRIVATE_URL
)
assert by_link[_PUBLIC_URL]["full_content"] is None
def test_denied_url_audit_log_is_redacted(patched_fetch):
"""The denial audit record must carry only scheme://host (no path / query /
token), proving sensitive URL components are not leaked into logs."""
sensitive_url = "http://93.184.216.34/secret/report?token=SUPERSECRET"
results = [{"title": "pub", "link": sensitive_url}]
records = []
def sink(message):
records.append(message.record)
# The package disables its own loguru namespace in __init__; enable it so
# the in-module audit warnings actually reach our sink. Restore in finally.
logger.enable("local_deep_research")
sink_id = logger.add(sink, level="WARNING")
try:
engine = _engine(results, _make_ctx(EgressScope.PRIVATE_ONLY))
engine.run("q")
finally:
logger.remove(sink_id)
logger.disable("local_deep_research")
# Find the policy-audit denial record for this URL.
audit = [
r
for r in records
if r["extra"].get("policy_audit") and "url" in r["extra"]
]
assert audit, "expected a policy_audit denial record"
rec = audit[0]
logged_url = rec["extra"]["url"]
assert logged_url == "http://93.184.216.34"
# The token / path must appear nowhere in the logged URL or message.
assert "SUPERSECRET" not in logged_url
assert "SUPERSECRET" not in rec["message"]
assert "/secret" not in logged_url
assert rec["extra"]["reason"] == "scope_mismatch_private_only"
+82
View File
@@ -0,0 +1,82 @@
"""The egress denial-guidance helper must turn machine reason codes into clear,
actionable user messages (what was blocked + how to allow it)."""
from __future__ import annotations
import pytest
from local_deep_research.security.egress.guidance import denial_guidance
@pytest.mark.parametrize(
"reason,target,must_contain",
[
# Scope blocks name the scope AND the setting to change.
(
"scope_mismatch_private_only",
"arxiv",
["Private only", "Egress Scope"],
),
(
"scope_mismatch_public_only",
"library",
["Public only", "Egress Scope"],
),
("strict_not_primary", "wikipedia", ["Strict", "primary"]),
# Inference blocks name the require-local toggle.
("provider_cloud_only", "openai", ["Require local LLM", "openai"]),
("provider_cloud", "openai", ["Require local embeddings"]),
("provider_remote", "ollama", ["local"]),
# Hard rule: metadata cannot be overridden.
("blocked_metadata_ip", None, ["cannot be overridden", "metadata"]),
("elasticsearch_cloud_id_public_egress", None, ["Cloud ID", "hosts"]),
("unknown_egress_scope", None, ["unrecognised", "Egress Scope"]),
],
)
def test_guidance_is_clear_and_actionable(reason, target, must_contain):
msg = denial_guidance(reason, target=target)
assert msg and len(msg) > 20
for needle in must_contain:
assert needle in msg, f"{reason}: expected '{needle}' in: {msg}"
def test_guidance_inserts_target():
msg = denial_guidance("scope_mismatch_private_only", target="arxiv")
assert "arxiv" in msg
def test_non_policy_reasons_get_honest_explanation_not_setting_advice():
# A malformed URL isn't a policy block the user fixes via settings.
msg = denial_guidance("url_malformed")
assert "malformed" in msg
assert "Egress Scope" not in msg
def test_unknown_reason_is_safe_and_nonempty():
msg = denial_guidance("brand_new_code_42", target="X")
assert "X" in msg
assert "brand_new_code_42" in msg # surfaced for support
assert msg # never empty
def test_target_none_falls_back_to_this_action():
msg = denial_guidance("scope_mismatch_private_only") # no target
assert msg.startswith("This action")
assert "Egress Scope" in msg
@pytest.mark.parametrize(
"reason,needle",
[
("no_hostname", "no host"),
("unsupported_scheme", "http/https"),
("dangerous_scheme", "non-web scheme"),
("host_unclassified", "resolved or classified"),
("internal_error", "internal error"),
],
)
def test_all_non_policy_reasons_explain_without_setting_advice(reason, needle):
msg = denial_guidance(reason)
assert needle in msg
# These are parse/format failures, not a policy the user toggles.
assert "Egress Scope" not in msg
@@ -0,0 +1,330 @@
"""Follow-up regression tests for the egress inference gates.
Deepens coverage of ``evaluate_llm_endpoint`` and ``evaluate_embeddings``
beyond what ``tests/security/test_egress_policy.py`` already exercises.
Focus areas (gaps not covered elsewhere):
- require_local_* False => always allowed (LLM + embeddings, incl. cloud).
- Full cloud-LLM block set under require-local (deepseek/xai/ionos as well
as the already-covered openai/anthropic/google/openrouter).
- Local-default providers WITHOUT a URL (lmstudio/llamacpp for LLM;
ollama/sentence_transformers for embeddings).
- URL-configured providers: local URL allowed, remote denied,
percent-encoded local URL allowed (both gates).
- Embeddings ollama URL handling + the embeddings.ollama.url -> llm.ollama.url
fallback, plus the provider_unknown branch.
- PDP-level snapshot-less branch (settings_snapshot=None) for both gates.
- The _is_user_registered_llm discriminator (built-in vs in-process code).
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
evaluate_embeddings,
evaluate_llm_endpoint,
_is_user_registered_llm,
)
# ---------------------------------------------------------------------------
# Helpers (mirrors make_ctx in tests/security/test_egress_policy.py)
# ---------------------------------------------------------------------------
def make_ctx(
scope: EgressScope = EgressScope.BOTH,
primary: str = "arxiv",
require_local_llm: bool = False,
require_local_embeddings: bool = False,
local_hostnames=(),
) -> EgressContext:
return EgressContext(
scope=scope,
primary_engine=primary,
require_local_llm=require_local_llm,
require_local_embeddings=require_local_embeddings,
local_hostnames=tuple(local_hostnames),
)
# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — require_local_llm False => always allowed
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"provider",
["openai", "anthropic", "deepseek", "xai", "ionos", "ollama", "lmstudio"],
)
def test_llm_no_local_requirement_allows_any_provider(provider):
"""With require_local_llm False the gate is a pass-through for every
provider — cloud included — and reports no_local_requirement."""
ctx = make_ctx(require_local_llm=False)
decision = evaluate_llm_endpoint(provider, ctx, settings_snapshot={})
assert decision.allowed is True
assert decision.reason == "no_local_requirement"
# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — full cloud-provider block set under require-local
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("provider", ["deepseek", "xai", "ionos"])
def test_llm_require_local_blocks_remaining_cloud_providers(provider):
"""The cloud block set must include deepseek/xai/ionos — the providers
the existing suite does NOT already enumerate. Each is blocked under
require_local_llm with provider_cloud_only, and allowed without it."""
blocked = evaluate_llm_endpoint(
provider, make_ctx(require_local_llm=True), settings_snapshot={}
)
assert blocked.allowed is False
assert blocked.reason == "provider_cloud_only"
# Mirror: the same provider is permitted when the toggle is off.
allowed = evaluate_llm_endpoint(
provider, make_ctx(require_local_llm=False), settings_snapshot={}
)
assert allowed.allowed is True
# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — local-default providers without a URL
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("provider", ["lmstudio", "llamacpp"])
def test_llm_local_default_providers_without_url_allowed(provider):
"""lmstudio/llamacpp (besides the already-tested ollama) fall back to
their localhost defaults when no URL override is configured."""
ctx = make_ctx(require_local_llm=True)
decision = evaluate_llm_endpoint(provider, ctx, settings_snapshot={})
assert decision.allowed is True
assert decision.reason == "provider_local_default"
# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — URL-configured: local allowed, remote denied
# ---------------------------------------------------------------------------
def test_llm_configured_local_url_allowed():
"""A local-default provider pointed at an explicit local URL classifies
via the URL (provider_local), not the static default."""
ctx = make_ctx(require_local_llm=True)
snapshot = {"llm.lmstudio.url": "http://192.168.1.50:1234/v1"}
decision = evaluate_llm_endpoint(
"lmstudio", ctx, settings_snapshot=snapshot
)
assert decision.allowed is True
assert decision.reason == "provider_local"
def test_llm_configured_remote_url_denied():
"""The mirror deny case: a configured remote URL is refused even for a
normally-local-default provider."""
ctx = make_ctx(require_local_llm=True)
snapshot = {"llm.lmstudio.url": "https://lmstudio.example.com"}
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False,
):
decision = evaluate_llm_endpoint(
"lmstudio", ctx, settings_snapshot=snapshot
)
assert decision.allowed is False
assert decision.reason == "provider_remote"
def test_llm_percent_encoded_local_url_allowed():
"""A percent-encoded local host (the HTTP client decodes it before
connect) must be decoded by the PDP and classified local. Without the
unquote() this reads as an unresolvable public host and is denied."""
ctx = make_ctx(require_local_llm=True)
snapshot = {"llm.ollama.url": "http://127%2e0%2e0%2e1:11434"}
decision = evaluate_llm_endpoint("ollama", ctx, settings_snapshot=snapshot)
assert decision.allowed is True
assert decision.reason == "provider_local"
# ---------------------------------------------------------------------------
# evaluate_llm_endpoint — PDP-level snapshot-less branch
# ---------------------------------------------------------------------------
def test_llm_none_snapshot_fails_closed():
"""settings_snapshot=None at the PDP fails closed regardless of the
require-local flag — the gate cannot read policy from a missing snapshot."""
ctx = make_ctx(require_local_llm=False)
decision = evaluate_llm_endpoint("ollama", ctx, settings_snapshot=None)
assert decision.allowed is False
assert decision.reason == "no_snapshot"
# ---------------------------------------------------------------------------
# evaluate_embeddings — require_local_embeddings False => always allowed
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"provider", ["openai", "ollama", "sentence_transformers", "cohere"]
)
def test_embeddings_no_local_requirement_allows_any_provider(provider):
"""With require_local_embeddings False the gate passes through for every
provider — including cloud openai and an unknown provider."""
ctx = make_ctx(require_local_embeddings=False)
decision = evaluate_embeddings(provider, ctx, settings_snapshot={})
assert decision.allowed is True
assert decision.reason == "no_local_requirement"
# ---------------------------------------------------------------------------
# evaluate_embeddings — ollama provider (gap: only openai/ST tested before)
# ---------------------------------------------------------------------------
def test_embeddings_ollama_without_url_allowed():
"""ollama embeddings with no URL override falls back to its localhost
default."""
ctx = make_ctx(require_local_embeddings=True)
decision = evaluate_embeddings("ollama", ctx, settings_snapshot={})
assert decision.allowed is True
assert decision.reason == "provider_local_default"
def test_embeddings_ollama_local_url_allowed():
ctx = make_ctx(require_local_embeddings=True)
snapshot = {"embeddings.ollama.url": "http://127.0.0.1:11434"}
decision = evaluate_embeddings("ollama", ctx, settings_snapshot=snapshot)
assert decision.allowed is True
assert decision.reason == "provider_local"
def test_embeddings_ollama_remote_url_denied():
ctx = make_ctx(require_local_embeddings=True)
snapshot = {"embeddings.ollama.url": "https://ollama.example.com"}
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False,
):
decision = evaluate_embeddings(
"ollama", ctx, settings_snapshot=snapshot
)
assert decision.allowed is False
assert decision.reason == "provider_remote"
def test_embeddings_ollama_percent_encoded_local_url_allowed():
"""Percent-encoded local ollama embeddings host must be decoded and
classified local (mirrors the LLM gate)."""
ctx = make_ctx(require_local_embeddings=True)
snapshot = {"embeddings.ollama.url": "http://127%2e0%2e0%2e1:11434"}
decision = evaluate_embeddings("ollama", ctx, settings_snapshot=snapshot)
assert decision.allowed is True
assert decision.reason == "provider_local"
def test_embeddings_ollama_url_falls_back_to_llm_ollama_url():
"""When embeddings.ollama.url is unset, the gate falls back to the shared
llm.ollama.url. A remote fallback value must still be denied (proves the
fallback is actually consulted, not silently treated as local-default)."""
ctx = make_ctx(require_local_embeddings=True)
snapshot = {"llm.ollama.url": "https://remote-ollama.example.com"}
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False,
):
decision = evaluate_embeddings(
"ollama", ctx, settings_snapshot=snapshot
)
assert decision.allowed is False
assert decision.reason == "provider_remote"
# ---------------------------------------------------------------------------
# evaluate_embeddings — openai base_url edge cases
# ---------------------------------------------------------------------------
def test_embeddings_openai_percent_encoded_local_base_url_allowed():
ctx = make_ctx(require_local_embeddings=True)
snapshot = {"embeddings.openai.base_url": "http://127%2e0%2e0%2e1:1234/v1"}
decision = evaluate_embeddings("openai", ctx, settings_snapshot=snapshot)
assert decision.allowed is True
assert decision.reason == "provider_local_endpoint"
def test_embeddings_openai_remote_base_url_denied():
"""A genuine cloud base_url stays denied under require-local."""
ctx = make_ctx(require_local_embeddings=True)
snapshot = {"embeddings.openai.base_url": "https://api.openai.com/v1"}
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False,
):
decision = evaluate_embeddings(
"openai", ctx, settings_snapshot=snapshot
)
assert decision.allowed is False
assert decision.reason == "provider_cloud"
# ---------------------------------------------------------------------------
# evaluate_embeddings — unknown provider + snapshot-less branches
# ---------------------------------------------------------------------------
def test_embeddings_unknown_provider_under_require_local_denied():
"""An embeddings provider outside the known set fails closed under
require-local (provider_unknown), but is allowed when the toggle is off."""
blocked = evaluate_embeddings(
"cohere", make_ctx(require_local_embeddings=True), settings_snapshot={}
)
assert blocked.allowed is False
assert blocked.reason == "provider_unknown"
def test_embeddings_none_snapshot_fails_closed():
ctx = make_ctx(require_local_embeddings=False)
decision = evaluate_embeddings(
"sentence_transformers", ctx, settings_snapshot=None
)
assert decision.allowed is False
assert decision.reason == "no_snapshot"
# ---------------------------------------------------------------------------
# _is_user_registered_llm discriminator (underpins the shadowing behavior)
# ---------------------------------------------------------------------------
def test_is_user_registered_llm_true_for_in_process_registration():
"""A freshly-registered name that is NOT an auto-discovered built-in is
user-supplied in-process code."""
from local_deep_research.llm.llm_registry import (
register_llm,
unregister_llm,
)
name = "_followup_inprocess_llm"
register_llm(name, lambda **kwargs: None)
try:
assert _is_user_registered_llm(name) is True
finally:
unregister_llm(name)
def test_is_user_registered_llm_false_for_builtin_cloud_name():
"""A built-in cloud provider name (auto-registered by discover_providers)
must NOT be treated as user code — this is what keeps a shadowing
registration on the strict cloud-block path."""
assert _is_user_registered_llm("openai") is False
def test_is_user_registered_llm_false_for_unregistered_name():
assert _is_user_registered_llm("_never_registered_followup") is False
@@ -0,0 +1,354 @@
"""Integration tests for the egress-policy PEP that pre-filters the LangGraph
lead-agent tool list in
``advanced_search_system/strategies/langgraph_agent_strategy.py``
(``LangGraphAgentStrategy._build_tools`` specialized-engine loop, ~L608-695).
This is the *silent-expansion* fix. The search-engine factory PEP already
refuses a forbidden engine at instantiation time, but that is a runtime stop:
the LLM still SEES the forbidden tool name in its schema, and the latency of a
denied tool call leaks policy state. Filtering the tool list HERE means a
forbidden engine's name never reaches ``create_agent()`` — the model never
learns it exists. These tests assert exactly that property: which engine names
survive into the built tool list under each scope.
The tests drive the REAL ``_build_tools`` loop and the REAL
``evaluate_engine`` / ``evaluate_retriever`` PDP. Only the leaf tool factories
(``_make_web_search_tool``, ``_make_specialized_search_tool``,
``build_fetch_tool``) and the engine/retriever *discovery* (``get_available_engines``,
``retriever_registry``) are mocked — so every assertion is about the FILTER
decision, made before any tool is actually constructed.
Engine classifications come from the live ``ENGINE_REGISTRY``:
- arxiv / wikipedia : public (is_public=True, is_local=False)
- paperless : local (is_public=False, is_local=True; no api_url in
the snapshot, so no DNS lookup occurs)
Each scope axis is covered with an allow+deny pair: reverting the filter (or
loosening a scope check) would let a denied name survive (or drop an allowed
one) and flip an assertion.
"""
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import local_deep_research.advanced_search_system.strategies.langgraph_agent_strategy as strat # noqa: E501
from local_deep_research.security import clear_active_context
# Engine discovery configs (what get_available_engines would return). The
# inner dicts mirror the real config shape consumed by the loop: description,
# strengths, is_retriever. For static engines the is_public/is_local
# classification is read from the engine CLASS, not from these dicts, so the
# decision under test is the real registry classification.
_PUBLIC_ENGINE = "arxiv"
_PUBLIC_ENGINE_2 = "wikipedia"
_PRIVATE_ENGINE = "paperless"
@pytest.fixture(autouse=True)
def _no_leaked_armed_context():
"""The tool-list filter reads scope from the snapshot, not the armed
audit-hook context. Clear the active context before and after each test so
a context leaked by another file can never mask a decision here, and so
this file never leaks one outward (hard rule: never leak armed context)."""
clear_active_context()
yield
clear_active_context()
@contextmanager
def _patched_tool_factories():
"""Replace the leaf tool builders with cheap name-carrying sentinels.
``_make_specialized_search_tool`` is a MagicMock whose side effect returns a
sentinel tagged with the engine name, so a DENIED engine is proven excluded
two ways: its name is absent from the built list AND the factory was never
called for it (the filter ``continue``s before construction). ``build_fetch_tool``
returns None so only the primary + surviving specialized tools remain.
"""
spec = MagicMock(
side_effect=lambda name, *a, **k: SimpleNamespace(name=name)
)
with (
patch.object(
strat,
"_make_web_search_tool",
lambda *a, **k: SimpleNamespace(name="web_search"),
),
patch.object(strat, "build_fetch_tool", lambda *a, **k: None),
patch.object(strat, "_make_specialized_search_tool", spec),
):
yield spec
def _build_tool_names(
scope,
available,
*,
retriever_meta=None,
snapshot_extra=None,
primary="searxng",
):
"""Construct a minimal strategy under ``scope`` and run the REAL
``_build_tools`` loop against the mocked engine discovery ``available``.
Returns ``(tool_names, spec_mock)`` where ``tool_names`` is the list of
``.name`` values on the built tools (the primary appears as "web_search")
and ``spec_mock`` is the specialized-tool factory mock for call assertions.
``retriever_meta`` (e.g. ``{"is_local": True}``) patches the retriever
registry's metadata lookup so the retriever branch can be exercised.
``primary`` sets ``search.tool`` (a real run always has one). Pass
``primary=None`` to omit it entirely and exercise the fail-closed path.
"""
snapshot = {
"policy.egress_scope": scope,
"search.fetch.mode": "disabled",
}
# A real run always has a configured primary. Explicit scopes ignore it
# (they don't resolve ADAPTIVE); the ADAPTIVE tests override it via
# snapshot_extra to drive scope resolution. primary=None omits it so
# resolve_run_primary_engine fails closed (raises) — by design.
if primary is not None:
snapshot["search.tool"] = primary
if snapshot_extra:
snapshot.update(snapshot_extra)
reg = MagicMock()
reg.get_metadata.return_value = retriever_meta
with _patched_tool_factories() as spec:
with (
patch(
"local_deep_research.web_search_engines.search_engines_config"
".get_available_engines",
return_value=available,
),
patch(
"local_deep_research.web_search_engines.retriever_registry"
".retriever_registry",
reg,
),
):
# search=MagicMock() so the primary web_search tool is present (its
# class name "magicmock" becomes the run's current/primary engine,
# which never collides with a real engine name). citation_handler
# is supplied so the strategy ctor skips building a real one.
strategy = strat.LangGraphAgentStrategy(
model=MagicMock(),
search=MagicMock(),
citation_handler=object(),
include_sub_research=False,
settings_snapshot=snapshot,
)
tools = strategy._build_tools("overall query")
return [t.name for t in tools], spec
def _specialized(names):
"""Names of specialized tools that survived (excluding the primary)."""
return {n for n in names if n != "web_search"}
# ---------------------------------------------------------------------------
# PRIVATE_ONLY: public engines are excluded from the tool list; local allowed.
# ---------------------------------------------------------------------------
class TestPrivateOnlyFilter:
def test_public_engine_name_never_reaches_agent(self):
names, spec = _build_tool_names(
"private_only",
{
_PUBLIC_ENGINE: {"description": "papers"},
_PRIVATE_ENGINE: {"description": "docs"},
},
)
# The public engine is silently excluded: its name is absent and the
# tool factory was never invoked for it.
assert _PUBLIC_ENGINE not in names
called = {c.args[0] for c in spec.call_args_list}
assert _PUBLIC_ENGINE not in called
# The local engine is allowed (allow side of the pair).
assert _PRIVATE_ENGINE in names
assert _specialized(names) == {_PRIVATE_ENGINE}
# ---------------------------------------------------------------------------
# PUBLIC_ONLY: local/collection engines excluded; public allowed.
# ---------------------------------------------------------------------------
class TestPublicOnlyFilter:
def test_private_engine_name_never_reaches_agent(self):
names, spec = _build_tool_names(
"public_only",
{
_PUBLIC_ENGINE: {"description": "papers"},
_PRIVATE_ENGINE: {"description": "docs"},
},
)
assert _PRIVATE_ENGINE not in names
called = {c.args[0] for c in spec.call_args_list}
assert _PRIVATE_ENGINE not in called
assert _PUBLIC_ENGINE in names
assert _specialized(names) == {_PUBLIC_ENGINE}
# ---------------------------------------------------------------------------
# STRICT: no specialized engines at all — only the primary web_search tool.
# ---------------------------------------------------------------------------
class TestStrictFilter:
def test_no_specialized_engines_registered(self):
names, spec = _build_tool_names(
"strict",
{
_PUBLIC_ENGINE: {"description": "papers"},
_PUBLIC_ENGINE_2: {"description": "wiki"},
_PRIVATE_ENGINE: {"description": "docs"},
},
)
# Deny side: every specialized engine is dropped, regardless of bucket,
# and none were ever constructed.
assert _specialized(names) == set()
spec.assert_not_called()
# Allow side: the primary tool IS present — STRICT yields a single-tool
# agent, not an empty one.
assert names == ["web_search"]
# ---------------------------------------------------------------------------
# BOTH: the filter does not over-exclude — every discovered engine survives.
# This is the allow baseline proving the PRIVATE/PUBLIC exclusions above are
# scope-driven, not a blanket drop.
# ---------------------------------------------------------------------------
class TestRetiredBothScopeBehavesAsAdaptive:
def test_both_coerces_to_adaptive_and_follows_primary(self):
# `both` is retired (ADR-0007): it no longer means "allow all". It
# coerces to ADAPTIVE, which follows the primary — here the default
# public primary (searxng) resolves PUBLIC_ONLY, so the private engine
# is dropped rather than combined with the public one.
names, _ = _build_tool_names(
"both",
{
_PUBLIC_ENGINE: {"description": "papers"},
_PRIVATE_ENGINE: {"description": "docs"},
},
)
assert _specialized(names) == {_PUBLIC_ENGINE}
# ---------------------------------------------------------------------------
# Retriever branch: registered retrievers route through evaluate_retriever
# (classified by registry is_local metadata), filtered by the same loop.
# ---------------------------------------------------------------------------
class TestRetrieverFilter:
_RET = "my_private_kb"
def test_local_retriever_excluded_under_public_only(self):
names, spec = _build_tool_names(
"public_only",
{self._RET: {"description": "kb", "is_retriever": True}},
retriever_meta={"is_local": True},
)
# Deny: a local retriever must not surface under PUBLIC_ONLY.
assert self._RET not in names
spec.assert_not_called()
assert _specialized(names) == set()
def test_local_retriever_allowed_under_private_only(self):
names, spec = _build_tool_names(
"private_only",
{self._RET: {"description": "kb", "is_retriever": True}},
retriever_meta={"is_local": True},
)
# Allow: same retriever IS registered under PRIVATE_ONLY.
assert self._RET in names
assert _specialized(names) == {self._RET}
assert spec.call_args_list[0].args[0] == self._RET
# ---------------------------------------------------------------------------
# ADAPTIVE (the DEFAULT scope): the concrete scope FOLLOWS the run's primary
# engine (search.tool) — the same value the factory PEP uses. These cover the
# real-world bug the explicit-scope tests above miss: a private collection
# primary must pull the run into PRIVATE_ONLY so public specialized engines
# never reach the agent. Regression for the silent under-filter where
# _build_egress_context derived the primary from the engine CLASS name instead
# of search.tool — a collection primary classified as unknown -> BOTH -> public
# engines (e.g. pubmed) stayed in the agent's tool list, and the factory then
# hard-denied them mid-run (scope_mismatch_private_only).
# ---------------------------------------------------------------------------
class TestAdaptiveScopeFollowsPrimary:
def test_private_primary_excludes_public_engine(self):
names, spec = _build_tool_names(
"adaptive",
{
_PUBLIC_ENGINE: {"description": "papers"},
_PRIVATE_ENGINE: {"description": "docs"},
},
snapshot_extra={"search.tool": _PRIVATE_ENGINE},
)
# ADAPTIVE + private primary => PRIVATE_ONLY: the public engine is
# filtered before it ever reaches the agent (name absent AND never
# constructed), the local engine survives.
assert _PUBLIC_ENGINE not in names
called = {c.args[0] for c in spec.call_args_list}
assert _PUBLIC_ENGINE not in called
assert _specialized(names) == {_PRIVATE_ENGINE}
def test_public_primary_excludes_private_engine(self):
names, spec = _build_tool_names(
"adaptive",
{
_PUBLIC_ENGINE: {"description": "papers"},
_PRIVATE_ENGINE: {"description": "docs"},
},
snapshot_extra={"search.tool": _PUBLIC_ENGINE},
)
# ADAPTIVE + public primary => PUBLIC_ONLY: the mirror image — the
# local engine is filtered, the public one survives.
assert _PRIVATE_ENGINE not in names
called = {c.args[0] for c in spec.call_args_list}
assert _PRIVATE_ENGINE not in called
assert _specialized(names) == {_PUBLIC_ENGINE}
# ---------------------------------------------------------------------------
# No configured primary: resolve_run_primary_engine raises ValueError inside
# _build_egress_context, which catches it and returns None — the advisory
# tool-list filter degrades to UNFILTERED (the factory PEP still enforces at
# instantiation) instead of crashing the agent. This locks in the fail-closed
# contract so the resolve call can't be moved back outside the try/except.
# ---------------------------------------------------------------------------
class TestMissingPrimaryDegradesToUnfiltered:
def test_no_primary_does_not_crash_and_leaves_tools_unfiltered(self):
# Restrictive scope + NO search.tool. Without the in-try catch this
# would raise out of _build_tools; with it, policy_ctx is None so the
# filter loop never runs and every discovered engine survives (the
# public one is refused later by the factory PEP, not here).
names, _ = _build_tool_names(
"private_only",
{
_PUBLIC_ENGINE: {"description": "papers"},
_PRIVATE_ENGINE: {"description": "docs"},
},
primary=None,
)
assert _specialized(names) == {_PUBLIC_ENGINE, _PRIVATE_ENGINE}
@@ -0,0 +1,342 @@
"""Integration tests for the egress policy at the news-subscription call site.
The helper ``_validate_subscription_policy`` is unit-tested elsewhere
(tests/security/test_egress_policy.py N14/N15 and
tests/news/test_subscription_policy.py). This module instead drives the REAL
call-site functions ``create_subscription`` / ``update_subscription`` in
``news/api.py`` and asserts the policy decision actually:
* blocks persistence (no ``session.add`` / ``session.commit``) when the
chosen search engine or LLM provider violates the user's egress scope,
* lets a coherent config through to persistence,
* still blocks a forbidden engine when the configured primary is a stray
removed meta-engine name (STRICT + "auto") rather than silently skipping
the check, and
* skips the (best-effort) pre-check when the settings backend or the request
context needed to resolve the user is unavailable — the execution-time
factory PEP remains the backstop.
Only unavoidable heavy deps are mocked (per-user DB session, settings manager,
scheduler notification, flask request context). The egress policy evaluation
itself runs for real.
"""
from __future__ import annotations
import contextlib
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.news import api as news_api
from local_deep_research.news.exceptions import (
SubscriptionCreationException,
SubscriptionUpdateException,
)
# Patch targets — the call site imports these lazily inside the functions, so
# we patch them at their definition module.
DB_SESSION_PATH = (
"local_deep_research.database.session_context.get_user_db_session"
)
SM_PATH = "local_deep_research.utilities.db_utils.get_settings_manager"
NOTIFY_PATH = (
"local_deep_research.news.api._notify_scheduler_about_subscription_change"
)
# Egress snapshots used to drive the real policy evaluation.
STRICT_ARXIV = {"policy.egress_scope": "strict", "search.tool": "arxiv"}
REQUIRE_LOCAL = {
"policy.egress_scope": "both",
"llm.require_local_endpoint": True,
}
# STRICT scope with a stray removed meta-engine primary: the context still
# builds as STRICT, so every engine except the (nonexistent) primary is denied.
STRAY_META_PRIMARY = {"policy.egress_scope": "strict", "search.tool": "auto"}
def _settings_manager(snapshot, primary="arxiv"):
"""Fake SettingsManager exposing the snapshot + search.tool primary."""
sm = MagicMock()
sm.get_settings_snapshot.return_value = snapshot
sm.get_setting.side_effect = lambda key, default=None: (
primary if key == "search.tool" else default
)
return sm
@contextlib.contextmanager
def _db_session():
"""Patch get_user_db_session to yield a mock session as a context manager."""
session = MagicMock()
with patch(DB_SESSION_PATH) as gud:
gud.return_value.__enter__.return_value = session
gud.return_value.__exit__.return_value = False
yield session
def _existing_subscription():
"""A persisted subscription with engine/provider unset by default so that
only the fields an update sets participate in the policy check."""
sub = MagicMock()
sub.search_engine = None
sub.model_provider = None
return sub
# ---------------------------------------------------------------------------
# create_subscription — engine gate
# ---------------------------------------------------------------------------
def test_create_blocked_engine_is_not_persisted():
"""STRICT + primary=arxiv: a non-primary engine (pubmed) is rejected at
create time and never written to the DB."""
sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
):
with pytest.raises(SubscriptionCreationException) as exc:
news_api.create_subscription(
user_id="alice",
query="AI",
search_engine="pubmed",
refresh_minutes=60,
)
assert "pubmed" in exc.value.message
assert exc.value.error_code == "SUBSCRIPTION_CREATE_FAILED"
session.add.assert_not_called()
session.commit.assert_not_called()
def test_create_allows_primary_engine():
"""The user's own primary engine passes the gate and is persisted."""
sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
):
result = news_api.create_subscription(
user_id="alice",
query="AI",
search_engine="arxiv",
refresh_minutes=60,
)
assert result["status"] == "success"
session.add.assert_called_once()
session.commit.assert_called_once()
# ---------------------------------------------------------------------------
# create_subscription — LLM provider gate
# ---------------------------------------------------------------------------
def test_create_blocked_cloud_provider_is_not_persisted():
"""require_local_endpoint=True: a cloud provider (openai) is rejected and
not persisted."""
sm = _settings_manager(REQUIRE_LOCAL, primary="arxiv")
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
):
with pytest.raises(SubscriptionCreationException) as exc:
news_api.create_subscription(
user_id="alice",
query="AI",
model_provider="openai",
refresh_minutes=60,
)
assert "provider" in exc.value.message.lower()
session.add.assert_not_called()
def test_create_allows_local_provider():
"""A localhost-default provider (ollama) passes under require_local."""
sm = _settings_manager(REQUIRE_LOCAL, primary="arxiv")
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
):
result = news_api.create_subscription(
user_id="alice",
query="AI",
model_provider="ollama",
refresh_minutes=60,
)
assert result["status"] == "success"
session.add.assert_called_once()
# ---------------------------------------------------------------------------
# create_subscription — stray removed meta primary still blocks (fail closed)
# ---------------------------------------------------------------------------
def test_create_blocks_engine_under_stray_meta_primary():
"""STRICT + a stray removed meta-engine primary ("auto") no longer raises
at context construction; the STRICT identity check must still reject a
non-primary engine at create time and block persistence — never a silent
allow."""
sm = _settings_manager(STRAY_META_PRIMARY, primary="auto")
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
):
with pytest.raises(SubscriptionCreationException) as exc:
news_api.create_subscription(
user_id="alice",
query="AI",
search_engine="pubmed",
refresh_minutes=60,
)
assert "strict_not_primary" in exc.value.message
session.add.assert_not_called()
# ---------------------------------------------------------------------------
# create_subscription — best-effort skip when backend is unavailable
# ---------------------------------------------------------------------------
def test_create_skips_precheck_without_settings_backend():
"""If the settings backend is unavailable the pre-check is skipped
(best-effort) and a config that WOULD be forbidden is still persisted — the
execution-time factory PEP is the backstop. Mirror of the blocked-engine
test with the backend removed."""
with (
_db_session() as session,
patch(SM_PATH, side_effect=RuntimeError("no settings DB")),
patch(NOTIFY_PATH),
):
result = news_api.create_subscription(
user_id="alice",
query="AI",
search_engine="pubmed", # would be denied if backend were present
refresh_minutes=60,
)
assert result["status"] == "success"
session.add.assert_called_once()
# ---------------------------------------------------------------------------
# update_subscription — engine / provider gate
# ---------------------------------------------------------------------------
def test_update_blocked_engine_is_not_committed():
"""Updating an existing subscription to a forbidden engine is rejected and
the transaction is not committed."""
sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
sub = _existing_subscription()
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
patch("flask.has_request_context", return_value=True),
patch("flask.session", {"username": "alice"}),
):
session.query.return_value.filter_by.return_value.first.return_value = (
sub
)
with pytest.raises(SubscriptionUpdateException) as exc:
news_api.update_subscription("sub-1", {"search_engine": "pubmed"})
assert exc.value.error_code == "SUBSCRIPTION_UPDATE_FAILED"
session.commit.assert_not_called()
def test_update_allows_coherent_engine():
"""Updating to the user's primary engine is accepted and committed."""
sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
sub = _existing_subscription()
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
patch("flask.has_request_context", return_value=True),
patch("flask.session", {"username": "alice"}),
):
session.query.return_value.filter_by.return_value.first.return_value = (
sub
)
result = news_api.update_subscription(
"sub-1", {"search_engine": "arxiv"}
)
assert result["status"] == "success"
session.commit.assert_called_once()
def test_update_blocked_cloud_provider_is_not_committed():
"""Updating to a cloud provider under require_local is rejected and not
committed."""
sm = _settings_manager(REQUIRE_LOCAL, primary="arxiv")
sub = _existing_subscription()
with (
_db_session() as session,
patch(SM_PATH, return_value=sm),
patch(NOTIFY_PATH),
patch("flask.has_request_context", return_value=True),
patch("flask.session", {"username": "alice"}),
):
session.query.return_value.filter_by.return_value.first.return_value = (
sub
)
with pytest.raises(SubscriptionUpdateException):
news_api.update_subscription("sub-1", {"model_provider": "openai"})
session.commit.assert_not_called()
# ---------------------------------------------------------------------------
# update_subscription — pre-check guards (engine/provider touched + user known)
# ---------------------------------------------------------------------------
def test_update_skips_policy_when_no_engine_or_provider_touched():
"""The pre-check only runs when the update touches the engine or provider.
A name-only update must NOT consult the settings backend at all (proves the
guard, not just that nothing happened to be wrong)."""
sub = _existing_subscription()
with (
_db_session() as session,
patch(SM_PATH) as sm_patch,
patch(NOTIFY_PATH),
patch("flask.has_request_context", return_value=True),
patch("flask.session", {"username": "alice"}),
):
session.query.return_value.filter_by.return_value.first.return_value = (
sub
)
result = news_api.update_subscription("sub-1", {"name": "Renamed"})
assert result["status"] == "success"
sm_patch.assert_not_called()
session.commit.assert_called_once()
def test_update_skips_policy_without_request_context():
"""Without a request context the user can't be resolved, so the pre-check is
skipped (best-effort) — a forbidden engine still persists and the execution
PEP backstops. Counterpart to test_update_blocked_engine_is_not_committed,
which has the request context."""
sm = _settings_manager(STRICT_ARXIV, primary="arxiv")
sub = _existing_subscription()
with (
_db_session() as session,
patch(SM_PATH, return_value=sm) as sm_patch,
patch(NOTIFY_PATH),
patch("flask.has_request_context", return_value=False),
):
session.query.return_value.filter_by.return_value.first.return_value = (
sub
)
result = news_api.update_subscription(
"sub-1", {"search_engine": "pubmed"}
)
assert result["status"] == "success"
session.commit.assert_called_once()
sm_patch.assert_not_called()
@@ -0,0 +1,237 @@
"""Integration tests for the egress-policy PEP inside the notification
dispatch path (``notifications/manager.py``).
These drive the REAL ``NotificationManager._filter_urls_by_egress_policy``
and the real ``send_notification`` / ``test_service`` call sites. They assert
the *policy decision* (vendor/cloud webhook filtered vs. allowed, fail-closed
on a corrupt scope, master-switch gating) which happens BEFORE any Apprise /
HTTP work. The only thing mocked is the dispatch boundary
(``service.send_event`` / ``service.test_service``) so nothing actually leaves
the box, plus the env master switches and — for http(s) hosts — the DNS-backed
host classifier, so the tests are in-process and deterministic.
Why filtering vendor webhooks matters: research results contain the user's
queries and retrieved local-corpus chunks. A ``discord://`` / ``slack://`` /
``https://hooks...`` notification URL would exfiltrate those to an external
vendor API. Under PRIVATE_ONLY ("nothing leaves the box") and, for public
http(s) hosts, STRICT, those URLs must be dropped before dispatch.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from local_deep_research.notifications.manager import NotificationManager
from local_deep_research.notifications.templates import EventType
# ---------------------------------------------------------------------------
# Construction helper. NotificationManager.__init__ reads two ENV-only
# master switches and builds a NotificationService (an in-process
# apprise.Apprise() — no network). We patch the env reads so the manager is
# built deterministically; everything else is the real code.
# ---------------------------------------------------------------------------
def _build_manager(snapshot, *, outbound=True, user_id="notif-egress-test"):
def fake_env(key, default=None):
if key == "notifications.allow_outbound":
return outbound
if key == "notifications.allow_private_ips":
return False
return default
with patch(
"local_deep_research.notifications.manager.get_env_setting",
side_effect=fake_env,
):
mgr = NotificationManager(snapshot, user_id=user_id)
# Sever the real dispatch path: any send must go through this mock, never
# apprise/HTTP. Tests assert on whether/with-what it is called.
mgr.service.send_event = MagicMock(return_value=True)
mgr.service.test_service = MagicMock(
return_value={"success": True, "message": "ok"}
)
return mgr
def _snapshot(scope, *, tool="arxiv", service_url=None):
snap = {
"policy.egress_scope": scope,
"search.tool": tool,
}
if service_url is not None:
snap["notifications.service_url"] = service_url
return snap
_CLASSIFY = "local_deep_research.security.egress.policy._classify_host"
# ---------------------------------------------------------------------------
# A. Vendor (non-http) scheme: the core exfiltration guard.
# ---------------------------------------------------------------------------
def test_private_only_filters_vendor_scheme_but_both_allows():
"""discord:// is a vendor API egress that cannot be verified local, so
PRIVATE_ONLY must drop it; BOTH (the permissive scope) passes it through.
"""
url = "discord://webhook_id/token"
deny_mgr = _build_manager(_snapshot("private_only"))
assert deny_mgr._filter_urls_by_egress_policy(url) == ""
allow_mgr = _build_manager(_snapshot("both"))
assert allow_mgr._filter_urls_by_egress_policy(url) == url
def test_private_only_mixed_urls_keeps_only_local_https():
"""A space-separated Apprise string mixing a vendor scheme, a public
cloud webhook, and a self-hosted local webhook: under PRIVATE_ONLY only
the local https survives. Drives both the non-http branch and the real
evaluate_url http branch in one shot.
"""
urls = (
"slack://tokA/tokB/tokC "
"https://hooks.bad.test/services/x "
"https://intranet.local/hook"
)
def fake_classify(host, ctx, *a, **k):
return host == "intranet.local"
mgr = _build_manager(_snapshot("private_only"))
with patch(_CLASSIFY, side_effect=fake_classify):
result = mgr._filter_urls_by_egress_policy(urls)
assert result == "https://intranet.local/hook"
# ---------------------------------------------------------------------------
# C. STRICT: refuses public http(s) hosts (provenance-spoofing risk).
# ---------------------------------------------------------------------------
def test_strict_filters_public_https_webhook_but_both_allows():
url = "https://hooks.bad.test/services/x"
# Public host classification (False), deterministic — no real DNS.
with patch(_CLASSIFY, return_value=False):
deny_mgr = _build_manager(_snapshot("strict"))
assert deny_mgr._filter_urls_by_egress_policy(url) == ""
allow_mgr = _build_manager(_snapshot("both"))
assert allow_mgr._filter_urls_by_egress_policy(url) == url
# ---------------------------------------------------------------------------
# D. PUBLIC_ONLY: refuses a private/intranet webhook, allows the public one.
# ---------------------------------------------------------------------------
def test_public_only_filters_local_webhook_but_allows_public():
local_url = "https://intranet.local/hook"
public_url = "https://hooks.bad.test/services/x"
def fake_classify(host, ctx, *a, **k):
return host == "intranet.local"
mgr = _build_manager(_snapshot("public_only"))
with patch(_CLASSIFY, side_effect=fake_classify):
assert mgr._filter_urls_by_egress_policy(local_url) == ""
assert mgr._filter_urls_by_egress_policy(public_url) == public_url
# ---------------------------------------------------------------------------
# E. Corrupt / unevaluable scope must fail CLOSED (filter, not dispatch).
# ---------------------------------------------------------------------------
def test_corrupt_scope_fails_closed():
url = "discord://webhook_id/token"
# A garbage scope makes context_from_snapshot raise PolicyDeniedError;
# the PEP must refuse ALL urls rather than fall open to unfiltered send.
deny_mgr = _build_manager(_snapshot("totally-not-a-scope"))
assert deny_mgr._filter_urls_by_egress_policy(url) == ""
# Sanity pair: a valid permissive scope on the same input passes it.
allow_mgr = _build_manager(_snapshot("both"))
assert allow_mgr._filter_urls_by_egress_policy(url) == url
# ---------------------------------------------------------------------------
# I. Snapshot-less back-compat branch: no snapshot => unchanged passthrough.
# ---------------------------------------------------------------------------
def test_snapshotless_manager_passes_through_but_private_only_filters():
url = "discord://webhook_id/token"
passthrough = _build_manager({}) # empty snapshot is falsy
assert passthrough._filter_urls_by_egress_policy(url) == url
filtered = _build_manager(_snapshot("private_only"))
assert filtered._filter_urls_by_egress_policy(url) == ""
# ---------------------------------------------------------------------------
# F. send_notification: the filter decision actually gates real dispatch.
# ---------------------------------------------------------------------------
def test_send_notification_blocks_dispatch_when_policy_filters_all():
snap_deny = _snapshot("private_only", service_url="discord://id/token")
deny_mgr = _build_manager(snap_deny, outbound=True)
# force=True bypasses per-user toggles + rate limit, NOT the egress filter.
sent = deny_mgr.send_notification(EventType.TEST, {}, force=True)
assert sent is False
deny_mgr.service.send_event.assert_not_called()
snap_allow = _snapshot("both", service_url="discord://id/token")
allow_mgr = _build_manager(snap_allow, outbound=True)
sent = allow_mgr.send_notification(EventType.TEST, {}, force=True)
assert sent is True
allow_mgr.service.send_event.assert_called_once()
# The allowed subset (the discord url) is what gets handed to dispatch.
_, kwargs = allow_mgr.service.send_event.call_args
assert kwargs["service_urls"] == "discord://id/token"
# ---------------------------------------------------------------------------
# G. Env master switch gates dispatch independently of the policy decision.
# ---------------------------------------------------------------------------
def test_outbound_master_switch_blocks_even_policy_allowed_url():
snap = _snapshot("both", service_url="discord://id/token")
# Outbound OFF: a BOTH-scope, policy-allowed url is still not dispatched.
off_mgr = _build_manager(snap, outbound=False)
assert off_mgr.send_notification(EventType.TEST, {}, force=True) is False
off_mgr.service.send_event.assert_not_called()
# Flipping ONLY the master switch on lets the same url through.
on_mgr = _build_manager(snap, outbound=True)
assert on_mgr.send_notification(EventType.TEST, {}, force=True) is True
on_mgr.service.send_event.assert_called_once()
# ---------------------------------------------------------------------------
# H. test_service (the /api/notifications/test-url PEP) honors the policy.
# ---------------------------------------------------------------------------
def test_test_service_endpoint_respects_private_only():
url = "discord://webhook_id/token"
deny_mgr = _build_manager(_snapshot("private_only"))
result = deny_mgr.test_service(url)
assert result["status"] == "error"
assert "egress policy" in result["message"].lower()
deny_mgr.service.test_service.assert_not_called()
allow_mgr = _build_manager(_snapshot("both"))
allow_mgr.test_service(url)
allow_mgr.service.test_service.assert_called_once_with(url)
+430
View File
@@ -0,0 +1,430 @@
"""Coverage for egress-policy enforcement points (PEPs) flagged as untested
by the PR #4300 multi-round review.
Each test exercises a real decision point against the real evaluate_url /
evaluate_engine / context_from_snapshot machinery (no mocking of the thing
under test), so a regression in either the PEP wiring OR the policy core
surfaces here.
Targets:
- fetch tools _enforce_url_policy (per-URL fetch PEP)
- egress_policy.evaluate_engine for dynamic collection_<id>/library engines
- app_factory handle_policy_denied Flask error handler
"""
import pytest
from local_deep_research.security.egress.policy import (
PolicyDeniedError,
context_from_snapshot,
evaluate_engine,
)
def _ctx(scope, primary="arxiv"):
return context_from_snapshot(
{"policy.egress_scope": scope}, primary_engine=primary
)
# ---------------------------------------------------------------------------
# fetch tools: _enforce_url_policy
# ---------------------------------------------------------------------------
class TestEnforceUrlPolicy:
"""The fetch tool's per-URL PEP. Raises PolicyDeniedError on a denied
URL so the agent's fetch_content tool can't egress outside scope."""
def _enforce(self, url, ctx):
from local_deep_research.advanced_search_system.tools.fetch import (
_enforce_url_policy,
)
return _enforce_url_policy(url, ctx)
def test_none_context_is_noop(self):
# No context configured (legacy callers) => never raises.
assert self._enforce("http://192.168.1.10/x", None) is None
def test_public_url_allowed_under_public_only(self):
# A public host under PUBLIC_ONLY passes (no raise).
ctx = _ctx("public_only")
assert self._enforce("https://arxiv.org/abs/1234", ctx) is None
def test_private_ip_denied_under_public_only(self):
# A private-IP literal under PUBLIC_ONLY is scope_mismatch => raise.
ctx = _ctx("public_only")
with pytest.raises(PolicyDeniedError):
self._enforce("http://192.168.1.10/internal", ctx)
def test_public_url_denied_under_private_only(self):
# Under PRIVATE_ONLY a public host must be refused.
ctx = _ctx("private_only")
with pytest.raises(PolicyDeniedError):
self._enforce("https://arxiv.org/abs/1234", ctx)
# ---------------------------------------------------------------------------
# Removed meta engines fail closed in evaluate_engine
# ---------------------------------------------------------------------------
class TestRemovedMetaEnginesUnknown:
"""The auto/meta/parallel/parallel_scientific meta engines were removed.
Their names no longer get a delegator carve-out in evaluate_engine — a
stray name left in a config must be denied as engine_unknown."""
@pytest.mark.parametrize(
"name", ["auto", "meta", "parallel", "parallel_scientific"]
)
def test_removed_meta_name_denied_as_unknown(self, name):
d = evaluate_engine(name, _ctx("both"), settings_snapshot={})
assert not d.allowed
assert d.reason == "engine_unknown"
# ---------------------------------------------------------------------------
# evaluate_engine: dynamic collection_<id> / library engines
# ---------------------------------------------------------------------------
class TestDynamicLocalEngines:
"""collection_<id> and library aren't in the static ENGINE_REGISTRY;
evaluate_engine classifies them local by name (egress_policy ~322).
Pin that distinct branch directly."""
@pytest.mark.parametrize("name", ["library", "collection_abc123"])
def test_local_under_private_only(self, name):
d = evaluate_engine(
name, _ctx("private_only", primary=name), settings_snapshot={}
)
assert d.allowed, f"{name} should be allowed under PRIVATE_ONLY"
@pytest.mark.parametrize("name", ["library", "collection_abc123"])
def test_filtered_under_public_only(self, name):
d = evaluate_engine(name, _ctx("public_only"), settings_snapshot={})
assert not d.allowed
assert d.reason == "scope_mismatch_public_only"
def test_unknown_dynamic_name_fails_closed(self):
# A name that is neither registered nor a known-local prefix must
# be refused (engine_unknown) — not silently allowed.
d = evaluate_engine(
"collflavor_not_a_real_prefix", _ctx("both"), settings_snapshot={}
)
assert not d.allowed
# ---------------------------------------------------------------------------
# journal_reputation_filter._should_skip_journal_fetch_for_scope
# ---------------------------------------------------------------------------
class TestJournalFetchScopeSkip:
"""Journal sources (OpenAlex/DOAJ/JabRef) are public, so under
PRIVATE_ONLY/STRICT the filter should skip the fetch. A corrupt scope
(PolicyDeniedError) must ALSO skip — fail closed — matching the
hardened notifications sibling. Regression: the bare
except previously swallowed PolicyDeniedError and returned False
(fail open)."""
def _filter(self, snapshot):
from local_deep_research.advanced_search_system.filters.journal_reputation_filter import (
JournalReputationFilter,
)
f = JournalReputationFilter.__new__(JournalReputationFilter)
# Name-mangled private attribute the method reads.
setattr(
f,
"_JournalReputationFilter__settings_snapshot",
snapshot,
)
return f
def test_no_snapshot_does_not_skip(self):
assert (
self._filter(None)._should_skip_journal_fetch_for_scope() is False
)
def test_private_only_skips(self):
f = self._filter(
{"policy.egress_scope": "private_only", "search.tool": "library"}
)
assert f._should_skip_journal_fetch_for_scope() is True
def test_public_only_does_not_skip(self):
f = self._filter(
{"policy.egress_scope": "public_only", "search.tool": "arxiv"}
)
assert f._should_skip_journal_fetch_for_scope() is False
def test_corrupt_scope_skips_fail_closed(self):
f = self._filter(
{"policy.egress_scope": "garbage", "search.tool": "arxiv"}
)
# Fail closed: corrupt scope => skip (do not fetch public journals).
assert f._should_skip_journal_fetch_for_scope() is True
# ---------------------------------------------------------------------------
# app_factory: handle_policy_denied Flask error handler
# ---------------------------------------------------------------------------
class TestPolicyDeniedErrorHandler:
"""A PolicyDeniedError escaping any request-path PEP must become a clean
400 (not a 500 stack trace) via the global Flask error handler."""
def test_policy_denied_returns_400_with_reason(self):
from flask import Flask
from local_deep_research.security.egress.policy import Decision
app = Flask(__name__)
# Register only the PolicyDeniedError handler the same way
# app_factory does, so the test is hermetic.
@app.errorhandler(PolicyDeniedError)
def _handler(error):
from flask import jsonify, make_response
reason = getattr(
getattr(error, "decision", None), "reason", "denied"
)
return make_response(
jsonify({"status": "error", "message": f"refused: {reason}"}),
400,
)
@app.route("/boom")
def _boom():
raise PolicyDeniedError(
Decision(False, "scope_mismatch_private_only"),
target="https://x.example",
)
app.config["TESTING"] = True
client = app.test_client()
resp = client.get("/boom")
assert resp.status_code == 400
body = resp.get_json()
assert body["status"] == "error"
assert "scope_mismatch_private_only" in body["message"]
# ---------------------------------------------------------------------------
# ContentFetcher.fetch — SSRF + scope enforcement at the fetch boundary
# ---------------------------------------------------------------------------
class TestContentFetcherEgress:
"""ContentFetcher.fetch returns a structured error dict (not a raise)
when a URL is SSRF-blocked or scope-incompatible. Denial returns early,
before any downloader fires."""
def _fetcher(self, scope):
from local_deep_research.content_fetcher.fetcher import ContentFetcher
return ContentFetcher(egress_context=_ctx(scope))
def test_public_url_denied_under_private_only(self):
# arxiv.org is public; PRIVATE_ONLY must refuse it at the scope axis.
result = self._fetcher("private_only").fetch("https://arxiv.org/abs/1")
assert result["status"] == "error"
assert "egress policy" in result["error"].lower()
def test_private_ip_blocked_under_public_only(self):
# A private IP under PUBLIC_ONLY is blocked at the SSRF axis
# (policy_aware_validate_url falls back to strict validate_url).
result = self._fetcher("public_only").fetch("http://192.168.1.10/x")
assert result["status"] == "error"
# Either SSRF or scope rejection is acceptable — both are denials.
assert (
"ssrf" in result["error"].lower()
or "egress policy" in result["error"].lower()
or "security validation" in result["error"].lower()
)
def test_no_context_does_not_scope_block_public_url(self):
from local_deep_research.content_fetcher.fetcher import ContentFetcher
# No egress context => no scope axis; a public URL is not refused
# for policy reasons (it may still fail later for network reasons,
# but not with an egress-policy error).
f = ContentFetcher(egress_context=None)
# Use an invalid scheme so fetch short-circuits without network:
result = f.fetch("ftp://example.com/x")
assert result["status"] == "error"
assert "egress policy" not in result["error"].lower()
# ---------------------------------------------------------------------------
# rag_service_factory._enforce_embeddings_policy
# ---------------------------------------------------------------------------
class TestEnforceEmbeddingsPolicy:
"""Pre-flight embeddings gate built from a SettingsManager (no full
snapshot). Pins the llm.ollama.url fallback fix: ollama configured only
via the shared llm.ollama.url must classify as local."""
def _mgr(self, settings):
from unittest.mock import Mock
m = Mock()
m.get_setting.side_effect = lambda key, default=None: settings.get(
key, default
)
return m
def _enforce(self, provider, settings):
from local_deep_research.research_library.services.rag_service_factory import (
_enforce_embeddings_policy,
)
return _enforce_embeddings_policy(provider, self._mgr(settings), "u")
def test_retired_both_coerces_to_adaptive_forcing_local(self):
# `both` is retired (ADR-0007): it coerces to ADAPTIVE, and the RAG gate
# classifies against the library corpus (sensitive) -> PRIVATE_ONLY ->
# local embeddings forced -> a cloud provider is DENIED even with the
# require_local flag left False.
from local_deep_research.security.egress.policy import (
PolicyDeniedError,
)
with pytest.raises(PolicyDeniedError):
self._enforce(
"openai",
{
"policy.egress_scope": "both",
"embeddings.require_local": False,
},
)
def test_adaptive_default_denies_cloud_for_library(self):
# A missing scope falls back to the registered default ADAPTIVE.
# The gate classifies against primary_engine="library" (local
# nature), so adaptive resolves PRIVATE_ONLY and forces local
# embeddings — cloud providers are denied even with the
# require_local flag at its default False.
from local_deep_research.security.egress.policy import (
PolicyDeniedError,
)
with pytest.raises(PolicyDeniedError):
self._enforce("openai", {"embeddings.require_local": False})
def test_ollama_via_llm_url_allowed(self):
# Regression: ollama configured ONLY via the shared llm.ollama.url
# (embeddings.ollama.url unset) must be classified local. Before the
# fix the minimal snapshot omitted llm.ollama.url -> wrongly denied.
assert (
self._enforce(
"ollama",
{
"embeddings.require_local": True,
"llm.ollama.url": "http://127.0.0.1:11434",
},
)
is None
)
def test_openai_cloud_denied_under_require_local(self):
with pytest.raises(PolicyDeniedError):
self._enforce("openai", {"embeddings.require_local": True})
# ---------------------------------------------------------------------------
# Scope -> require_local coupling (120-agent audit root fix)
# ---------------------------------------------------------------------------
class TestScopeForcesLocalInference:
"""PRIVATE_ONLY means "my data stays on this box" — that only holds if
BOTH inference paths are local. context_from_snapshot forces
require_local_llm + require_local_embeddings under PRIVATE_ONLY even
when the raw flags are at their default False. STRICT is search-only
and is intentionally NOT coupled."""
def test_private_only_forces_both_require_local(self):
ctx = _ctx("private_only", primary="library")
assert ctx.require_local_llm is True
assert ctx.require_local_embeddings is True
def test_strict_does_not_force_require_local(self):
ctx = _ctx("strict", primary="arxiv")
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
def test_both_does_not_force_require_local(self):
ctx = _ctx("both", primary="arxiv")
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
def test_explicit_flags_still_honored_under_both(self):
# The flags remain an independent opt-in for the non-private scopes.
ctx = context_from_snapshot(
{
"policy.egress_scope": "both",
"llm.require_local_endpoint": True,
"embeddings.require_local": True,
},
primary_engine="arxiv",
)
assert ctx.require_local_llm is True
assert ctx.require_local_embeddings is True
def test_embeddings_gate_fires_under_private_only_without_flag(self):
"""A PRIVATE_ONLY run with embeddings.require_local at its default
False must STILL refuse a cloud embedder — the gate reads the
scope-aware ctx.require_local_embeddings, not the raw flag.
Regression: previously the gate branched on the raw flag and a
PRIVATE_ONLY corpus could be embedded by openai."""
from local_deep_research.embeddings.embeddings_config import (
get_embeddings,
)
with pytest.raises(PolicyDeniedError):
get_embeddings(
provider="openai",
settings_snapshot={"policy.egress_scope": "private_only"},
)
# ---------------------------------------------------------------------------
# Percent-encoded hostname bypass (evaluate_url)
# ---------------------------------------------------------------------------
class TestPercentEncodedHostBypass:
"""evaluate_url must classify the DECODED host. HTTP clients decode
percent-encoding in the netloc before connecting, so the encoded form
must not read as a different (public/unresolvable) host than the one
the socket actually reaches."""
def test_encoded_private_ip_denied_under_public_only(self):
from local_deep_research.security.egress.policy import evaluate_url
ctx = _ctx("public_only")
d = evaluate_url("http://192%2e168%2e1%2e1/internal", ctx)
assert not d.allowed
assert d.reason == "scope_mismatch_public_only"
def test_encoded_loopback_denied_under_public_only(self):
from local_deep_research.security.egress.policy import evaluate_url
ctx = _ctx("public_only")
d = evaluate_url("http://127%2e0%2e0%2e1/x", ctx)
assert not d.allowed
def test_encoded_private_ip_allowed_under_private_only(self):
# The decoded host IS private, so PRIVATE_ONLY should permit it
# (matching where the client actually connects).
from local_deep_research.security.egress.policy import evaluate_url
ctx = _ctx("private_only", primary="library")
d = evaluate_url("http://192%2e168%2e1%2e1/x", ctx)
assert d.allowed
@@ -0,0 +1,228 @@
"""Integration tests for the egress-policy PEP at the search-engine factory
call site: ``web_search_engines.search_engine_factory.create_search_engine``.
These drive the REAL factory function (this file deliberately does NOT inherit
the ``tests/core`` factory fixtures that bypass the PDP), so the real
``evaluate_engine`` / ``context_from_snapshot`` enforcement actually fires.
Only the engine's heavy constructor is stubbed out (via ``get_safe_module_class``),
so every assertion is about the POLICY decision — which the factory makes
BEFORE it ever loads or instantiates the engine class.
Engine classifications used here come from the live ``ENGINE_REGISTRY``:
- arxiv / wikipedia : public (is_public=True, is_local=False)
- paperless : local (is_public=False, is_local=True)
Each scope axis is covered with an allow+deny pair so that reverting the PEP
(or loosening a scope check) flips a deny into an allow and fails the suite.
"""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.security import clear_active_context
from local_deep_research.security.egress.policy import PolicyDeniedError
import local_deep_research.web_search_engines.search_engine_factory as factory
class _FakeEngine:
"""Lightweight stand-in for a real search-engine class.
Constructing it does no network/DB work, so the factory *reaching* this
constructor is proof the PEP ALLOWED the engine — the denial path raises
long before instantiation.
"""
needs_llm_relevance_filter = False
def __init__(self, **kwargs):
self.llm = kwargs.get("llm")
self.init_kwargs = kwargs
@pytest.fixture(autouse=True)
def _no_leaked_armed_context():
"""The factory PEP reads scope from the snapshot, not the armed audit-hook
context. But clear the active context before and after each test anyway so
a context leaked by another test file can never mask a decision here, and so
this file never leaks one outward (hard rule: never leak armed context)."""
clear_active_context()
yield
clear_active_context()
def _snapshot(scope, primary, *engines, **extra):
"""Build a minimal settings snapshot the factory accepts.
``scope`` -> ``policy.egress_scope``; ``primary`` -> ``search.tool`` (the
engine the EgressContext treats as primary); every name in ``engines`` is
injected under ``search.engine.web`` so ``search_config()`` lists it — the
factory fails closed on unknown names BEFORE the PEP, so a real engine must
be present for the policy decision to be the thing under test.
"""
snap = {
"policy.egress_scope": scope,
"search.tool": primary,
}
for name in engines:
snap[f"search.engine.web.{name}.display_name"] = name
snap.update(extra)
return snap
@contextmanager
def _stub_engine_construction():
"""Patch the factory's class loader so an ALLOWED engine is built as a cheap
``_FakeEngine`` instead of doing real network/DB construction.
Yields the ``MagicMock`` loader so tests can assert whether construction was
even attempted: for a DENIED engine the PEP must raise first and the loader
must never be called. (``evaluate_engine`` reads engine class *flags* via a
separate import in the policy module, so this mock only tracks the factory's
own instantiation call site.)
"""
loader = MagicMock(return_value=_FakeEngine)
with patch.object(factory, "get_safe_module_class", loader):
yield loader
# ---------------------------------------------------------------------------
# PRIVATE_ONLY: public engines refused, local engines allowed.
# ---------------------------------------------------------------------------
class TestPrivateOnlyScope:
def test_public_engine_denied(self):
# arxiv is a public engine; PRIVATE_ONLY must refuse it at the factory
# PEP, before any engine class is loaded.
snap = _snapshot("private_only", "library", "arxiv")
with _stub_engine_construction() as loader:
with pytest.raises(PolicyDeniedError) as exc:
factory.create_search_engine("arxiv", settings_snapshot=snap)
assert exc.value.decision.reason == "scope_mismatch_private_only"
loader.assert_not_called()
def test_local_engine_allowed(self):
# paperless is a local engine; PRIVATE_ONLY permits it (allow pair).
snap = _snapshot("private_only", "paperless", "paperless")
with _stub_engine_construction() as loader:
engine = factory.create_search_engine(
"paperless", settings_snapshot=snap
)
assert isinstance(engine, _FakeEngine)
loader.assert_called()
# ---------------------------------------------------------------------------
# PUBLIC_ONLY: local engines refused, public engines allowed.
# ---------------------------------------------------------------------------
class TestPublicOnlyScope:
def test_local_engine_denied(self):
# paperless is local; PUBLIC_ONLY must refuse it before construction.
snap = _snapshot("public_only", "arxiv", "paperless")
with _stub_engine_construction() as loader:
with pytest.raises(PolicyDeniedError) as exc:
factory.create_search_engine(
"paperless", settings_snapshot=snap
)
assert exc.value.decision.reason == "scope_mismatch_public_only"
loader.assert_not_called()
def test_public_engine_allowed(self):
# arxiv is public; PUBLIC_ONLY permits it (allow pair).
snap = _snapshot("public_only", "arxiv", "arxiv")
with _stub_engine_construction() as loader:
engine = factory.create_search_engine(
"arxiv", settings_snapshot=snap
)
assert isinstance(engine, _FakeEngine)
loader.assert_called()
# ---------------------------------------------------------------------------
# STRICT: only the primary engine may be built.
# ---------------------------------------------------------------------------
class TestStrictScope:
def test_non_primary_engine_denied(self):
# STRICT permits ONLY the primary. With primary=arxiv, asking for a
# different (also public) engine must be refused as strict_not_primary,
# proving STRICT is enforced on the engine identity, not just the
# public/local bucket.
snap = _snapshot("strict", "arxiv", "arxiv", "wikipedia")
with _stub_engine_construction() as loader:
with pytest.raises(PolicyDeniedError) as exc:
factory.create_search_engine(
"wikipedia", settings_snapshot=snap
)
assert exc.value.decision.reason == "strict_not_primary"
loader.assert_not_called()
def test_primary_engine_allowed(self):
# The primary itself is permitted under STRICT (allow pair).
snap = _snapshot("strict", "arxiv", "arxiv")
with _stub_engine_construction() as loader:
engine = factory.create_search_engine(
"arxiv", settings_snapshot=snap
)
assert isinstance(engine, _FakeEngine)
loader.assert_called()
# ---------------------------------------------------------------------------
# Removed meta-pickers (auto/meta/parallel/parallel_scientific) fail closed.
# ---------------------------------------------------------------------------
class TestRemovedMetaEnginesFailClosed:
"""The meta-picker engines were removed. Their names no longer get a
skip-the-PEP fast path at the factory: they are unknown engine names and
must be rejected (ValueError) before any construction — never silently
delegated or rewritten to a concrete engine."""
@pytest.mark.parametrize(
"name", ["auto", "meta", "parallel", "parallel_scientific"]
)
def test_removed_meta_name_raises_value_error(self, name):
snap = _snapshot("both", "arxiv", "arxiv")
with _stub_engine_construction() as loader:
with pytest.raises(ValueError, match="removed"):
factory.create_search_engine(name, settings_snapshot=snap)
loader.assert_not_called()
def test_strict_with_stray_meta_primary_still_denies_non_primary(self):
# A stray 'meta' primary left in the DB under STRICT no longer raises
# at context construction; the STRICT identity check still refuses any
# concrete engine that isn't the (nonexistent) primary — fail closed.
snap = _snapshot("strict", "meta", "arxiv")
with _stub_engine_construction() as loader:
with pytest.raises(PolicyDeniedError) as exc:
factory.create_search_engine("arxiv", settings_snapshot=snap)
assert exc.value.decision.reason == "strict_not_primary"
loader.assert_not_called()
# ---------------------------------------------------------------------------
# Unknown engine names fail closed (must not silently become 'auto' and
# bypass evaluate_engine — see factory comment at the unknown-name branch).
# ---------------------------------------------------------------------------
class TestUnknownEngineFailsClosed:
def test_unknown_name_raises_value_error_not_constructed(self):
# A name that isn't a registered engine, retriever, or collection must
# be rejected (ValueError) rather than silently rewritten to 'auto',
# which historically bypassed the egress PEP entirely.
snap = _snapshot("both", "arxiv", "arxiv")
with _stub_engine_construction() as loader:
with pytest.raises(ValueError):
factory.create_search_engine(
"definitely_not_a_real_engine", settings_snapshot=snap
)
loader.assert_not_called()
@@ -0,0 +1,321 @@
"""Integration tests for the REAL fetch/download Policy Enforcement Points.
These exercise the call sites that actually consult the egress PDP — NOT the
PDP functions themselves (those have their own ~390 unit tests). The point is
to prove that the policy decision actually fires at the call site:
* ``content_fetcher.fetcher.ContentFetcher.fetch`` returns the structured
egress-policy error for a scope-incompatible URL and never reaches a
downloader, while a scope-compatible URL passes the gate.
* ``ContentFetcher._apply_egress_policy_to_downloader`` relaxes a
downloader's SafeSession only under PRIVATE_ONLY.
* ``security.egress.fetch.policy_aware_validate_url`` threads the scope
through SSRF validation (private IPs only under PRIVATE_ONLY; cloud
metadata always blocked).
* ``research_library.services.download_service.DownloadService.
_check_url_against_policy`` denies / allows per scope and always blocks
cloud-metadata.
All tests use literal IPs so no DNS lookup or outbound connect ever happens;
the policy gate decides before any network work. Each assertion is paired
(allow + deny) so it fails if the security property were reverted.
"""
from __future__ import annotations
from unittest.mock import Mock, patch
import pytest
from local_deep_research.content_fetcher.fetcher import ContentFetcher
from local_deep_research.security.egress.audit_hook import (
clear_active_context,
get_active_context,
)
from local_deep_research.security.egress.fetch import policy_aware_validate_url
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
)
# Literal IPs — classified without any DNS lookup.
PUBLIC_IP_URL = "http://8.8.8.8/paper.html"
PRIVATE_IP_URL = "http://192.168.0.50/paper.html"
METADATA_IP_URL = "http://169.254.169.254/latest/meta-data/"
def make_ctx(scope: EgressScope, primary: str = "arxiv") -> EgressContext:
"""Build a concrete-scope EgressContext directly (no snapshot/DNS)."""
return EgressContext(
scope=scope,
primary_engine=primary,
require_local_llm=False,
require_local_embeddings=False,
)
@pytest.fixture(autouse=True)
def _no_armed_context():
"""Keep the audit-hook thread-local clean so a leaked armed context from
another test can't anchor evaluate_url's per-run denial quota here, and so
we never leak one ourselves.
"""
clear_active_context()
try:
yield
finally:
clear_active_context()
assert get_active_context() is None
class _FakeResult:
"""Minimal stand-in for a downloader's download_with_result() return."""
def __init__(self, content=b"hello world", is_success=True):
self.is_success = is_success
self.content = content
self.skip_reason = None
# ---------------------------------------------------------------------------
# ContentFetcher.fetch — the central URL PEP
# ---------------------------------------------------------------------------
class TestContentFetcherFetchPEP:
def test_public_url_denied_under_private_only_never_reaches_downloader(
self,
):
"""PRIVATE_ONLY must refuse a public host with the structured egress
error AND short-circuit before any downloader is constructed/called.
"""
fetcher = ContentFetcher(
egress_context=make_ctx(EgressScope.PRIVATE_ONLY)
)
with patch.object(fetcher, "_get_downloader") as mock_get:
result = fetcher.fetch(PUBLIC_IP_URL)
assert result["status"] == "error"
assert "egress policy" in result["error"]
# The gate fired BEFORE downloader selection — the security property.
mock_get.assert_not_called()
def test_private_url_allowed_under_private_only_reaches_downloader(self):
"""The allow half of the pair: a private host under PRIVATE_ONLY passes
both the SSRF and the egress gate and is handed to a downloader.
"""
fetcher = ContentFetcher(
egress_context=make_ctx(EgressScope.PRIVATE_ONLY)
)
downloader = Mock()
downloader.download_with_result.return_value = _FakeResult()
downloader.get_metadata.return_value = {"title": "T"}
with patch.object(fetcher, "_get_downloader", return_value=downloader):
result = fetcher.fetch(PRIVATE_IP_URL)
assert result["status"] == "success"
assert "egress policy" not in (result.get("error") or "")
downloader.download_with_result.assert_called_once()
def test_private_url_denied_under_public_only(self):
"""Mirror scope: PUBLIC_ONLY refuses the same private host that
PRIVATE_ONLY allowed — proves the decision is scope-driven, not a
blanket private-IP block.
"""
fetcher = ContentFetcher(
egress_context=make_ctx(EgressScope.PUBLIC_ONLY)
)
with patch.object(fetcher, "_get_downloader") as mock_get:
result = fetcher.fetch(PRIVATE_IP_URL)
assert result["status"] == "error"
# Private host under PUBLIC_ONLY fails SSRF first (strict default),
# so either the SSRF error or the egress error is acceptable — the
# security property is simply "denied, no downloader".
assert "egress policy" in result["error"] or "SSRF" in result["error"]
mock_get.assert_not_called()
def test_public_url_allowed_under_public_only(self):
fetcher = ContentFetcher(
egress_context=make_ctx(EgressScope.PUBLIC_ONLY)
)
downloader = Mock()
downloader.download_with_result.return_value = _FakeResult()
downloader.get_metadata.return_value = {}
with patch.object(fetcher, "_get_downloader", return_value=downloader):
result = fetcher.fetch(PUBLIC_IP_URL)
assert result["status"] == "success"
downloader.download_with_result.assert_called_once()
def test_metadata_url_denied_even_under_private_only(self):
"""Cloud-metadata is NEVER fetchable, even under the scope that allows
private hosts. Without an egress_context there is no scope gate, so we
arm PRIVATE_ONLY and confirm the metadata IP is still refused.
"""
fetcher = ContentFetcher(
egress_context=make_ctx(EgressScope.PRIVATE_ONLY)
)
with patch.object(fetcher, "_get_downloader") as mock_get:
result = fetcher.fetch(METADATA_IP_URL)
assert result["status"] == "error"
mock_get.assert_not_called()
# ---------------------------------------------------------------------------
# policy_aware_validate_url — the SSRF/scope-threading PEP helper
# ---------------------------------------------------------------------------
class TestPolicyAwareValidateUrl:
def test_private_ip_allowed_only_under_private_only(self):
# Deny under BOTH (strict SSRF default) ...
assert (
policy_aware_validate_url(
PRIVATE_IP_URL, make_ctx(EgressScope.BOTH)
)
is False
)
# ... and under no-context (back-compat strict) ...
assert policy_aware_validate_url(PRIVATE_IP_URL, None) is False
# ... but allowed under PRIVATE_ONLY (the documented relaxation).
assert (
policy_aware_validate_url(
PRIVATE_IP_URL, make_ctx(EgressScope.PRIVATE_ONLY)
)
is True
)
def test_metadata_ip_blocked_even_under_private_only(self):
# A public IP under PRIVATE_ONLY relaxation passes SSRF, proving the
# relaxation is live; the metadata IP under the SAME scope is still
# blocked — the always-blocked invariant.
assert (
policy_aware_validate_url(
PUBLIC_IP_URL, make_ctx(EgressScope.PRIVATE_ONLY)
)
is True
)
assert (
policy_aware_validate_url(
METADATA_IP_URL, make_ctx(EgressScope.PRIVATE_ONLY)
)
is False
)
# ---------------------------------------------------------------------------
# ContentFetcher._apply_egress_policy_to_downloader — session relaxation PEP
# ---------------------------------------------------------------------------
class TestApplyEgressPolicyToDownloader:
def _downloader_with_session(self):
downloader = Mock()
session = Mock()
session.allow_private_ips = False
downloader.session = session
return downloader, session
def test_relaxes_session_under_private_only(self):
fetcher = ContentFetcher(
egress_context=make_ctx(EgressScope.PRIVATE_ONLY)
)
downloader, session = self._downloader_with_session()
fetcher._apply_egress_policy_to_downloader(downloader)
assert session.allow_private_ips is True
def test_does_not_relax_session_under_public_only(self):
fetcher = ContentFetcher(
egress_context=make_ctx(EgressScope.PUBLIC_ONLY)
)
downloader, session = self._downloader_with_session()
fetcher._apply_egress_policy_to_downloader(downloader)
assert session.allow_private_ips is False
def test_does_not_relax_session_under_both(self):
fetcher = ContentFetcher(egress_context=make_ctx(EgressScope.BOTH))
downloader, session = self._downloader_with_session()
fetcher._apply_egress_policy_to_downloader(downloader)
assert session.allow_private_ips is False
def test_no_context_does_not_relax_session(self):
fetcher = ContentFetcher(egress_context=None)
downloader, session = self._downloader_with_session()
fetcher._apply_egress_policy_to_downloader(downloader)
assert session.allow_private_ips is False
# ---------------------------------------------------------------------------
# DownloadService._check_url_against_policy — library download PEP
# ---------------------------------------------------------------------------
def _make_download_service(tmp_path, scope_value):
"""Construct a real DownloadService whose EgressContext is built from a
snapshot carrying ``scope_value``. Heavy deps (settings backend, retry
manager, library dir) are mocked; the policy gate under test is real.
"""
mock_settings = Mock()
mock_settings.get_setting.side_effect = lambda key, default=None: {
"research_library.storage_path": str(tmp_path),
"search.engine.web.semantic_scholar.api_key": "",
}.get(key, default)
snapshot = {
"policy.egress_scope": scope_value,
"search.tool": "arxiv",
}
with (
patch(
"local_deep_research.research_library.services."
"download_service.get_settings_manager",
return_value=mock_settings,
),
patch(
"local_deep_research.research_library.services."
"download_service.RetryManager",
),
patch(
"local_deep_research.research_library.services."
"download_service.get_library_directory",
return_value=tmp_path,
),
):
from local_deep_research.research_library.services.download_service import ( # noqa: E501
DownloadService,
)
return DownloadService("tester", "pw", settings_snapshot=snapshot)
class TestDownloadServiceCheckUrlPEP:
def test_public_denied_private_allowed_under_private_only(self, tmp_path):
service = _make_download_service(tmp_path, "private_only")
try:
assert service._egress_context is not None
assert service._egress_context.scope == EgressScope.PRIVATE_ONLY
pub_allowed, _ = service._check_url_against_policy(PUBLIC_IP_URL)
priv_allowed, _ = service._check_url_against_policy(PRIVATE_IP_URL)
assert pub_allowed is False
assert priv_allowed is True
finally:
service.close()
def test_public_allowed_private_denied_under_public_only(self, tmp_path):
service = _make_download_service(tmp_path, "public_only")
try:
assert service._egress_context.scope == EgressScope.PUBLIC_ONLY
pub_allowed, _ = service._check_url_against_policy(PUBLIC_IP_URL)
priv_allowed, _ = service._check_url_against_policy(PRIVATE_IP_URL)
assert pub_allowed is True
assert priv_allowed is False
finally:
service.close()
def test_metadata_url_always_denied(self, tmp_path):
# Denied under the scope that otherwise permits private hosts.
service = _make_download_service(tmp_path, "private_only")
try:
allowed, reason = service._check_url_against_policy(METADATA_IP_URL)
assert allowed is False
assert reason == "blocked_metadata_ip"
finally:
service.close()
@@ -0,0 +1,532 @@
"""Integration tests for the egress-policy PEPs at the inference call sites.
These do NOT re-test the PDP (``evaluate_llm_endpoint`` /
``evaluate_embeddings``) — that lives in tests/security/test_egress_policy.py
and test_egress_inference_gates_followup.py. Instead they drive the REAL
enforcement points:
config.llm_config.get_llm(...)
embeddings.embeddings_config.get_embeddings(...)
and assert the policy gate fires (PolicyDeniedError) BEFORE any model/client
construction happens. Built-in providers are auto-registered in the LLM
registry by ``discover_providers()`` (triggered on importing llm_config), so a
real ``get_llm`` call dispatches through ``get_llm_from_registry`` — we mock
ONLY that final construction step (and the embeddings ``create_embeddings``
classmethods) so what's exercised is the gate + the dispatch decision, never
the network/heavy model build.
Each allow/deny pair is constructed so the deny assertion FAILS if the gate
were removed: the construction mock returns a valid model, so a reverted gate
would let the cloud provider succeed instead of raising — and the
``assert_not_called`` on the construction mock proves the gate short-circuits
upstream of it.
"""
from __future__ import annotations
from unittest.mock import Mock, patch
import pytest
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
from local_deep_research.config.llm_config import get_llm
from local_deep_research.embeddings.embeddings_config import get_embeddings
from local_deep_research.embeddings.providers.implementations.ollama import (
OllamaEmbeddingsProvider,
)
from local_deep_research.embeddings.providers.implementations.openai import (
OpenAIEmbeddingsProvider,
)
from local_deep_research.embeddings.providers.implementations.sentence_transformers import (
SentenceTransformersProvider,
)
from local_deep_research.llm.llm_registry import register_llm, unregister_llm
from local_deep_research.security.egress.policy import PolicyDeniedError
# Snapshot fragments that force local-only inference, via the two distinct
# routes the call sites must both honor:
# - PRIVATE_ONLY scope: context_from_snapshot IMPLIES require_local_*.
# - the explicit per-knob flag with an otherwise-permissive scope.
# All include a "search.tool": a real run always has a configured primary, and
# the inference PEPs now resolve it via resolve_run_primary_engine (fail closed
# on a missing primary). The explicit scope still drives the decision — the
# primary value here is irrelevant to it.
PRIVATE_ONLY = {"policy.egress_scope": "private_only", "search.tool": "searxng"}
LLM_REQUIRE_LOCAL_FLAG = {
"policy.egress_scope": "both",
"llm.require_local_endpoint": True,
"search.tool": "searxng",
}
EMB_REQUIRE_LOCAL_FLAG = {
"policy.egress_scope": "both",
"embeddings.require_local": True,
"search.tool": "searxng",
}
@pytest.fixture(autouse=True)
def _no_leaked_active_context():
"""Belt-and-suspenders: these PEPs gate inside get_llm/get_embeddings
without arming the audit hook, but if any test ever does, never let the
armed context leak into a sibling test's socket path."""
from local_deep_research.security.egress.audit_hook import (
clear_active_context,
)
try:
yield
finally:
clear_active_context()
# ---------------------------------------------------------------------------
# get_llm — cloud providers blocked under require-local (both routes)
# ---------------------------------------------------------------------------
class TestGetLlmCloudBlocked:
@pytest.mark.parametrize("provider", ["openai", "anthropic"])
def test_cloud_provider_denied_under_private_only(self, provider):
"""A cloud LLM under PRIVATE_ONLY must raise PolicyDeniedError before
the registry dispatch — proven by asserting the construction mock is
never reached."""
snapshot = {
**PRIVATE_ONLY,
"llm.openai.api_key": "k", # gitleaks:allow
"llm.anthropic.api_key": "k", # gitleaks:allow
}
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
with pytest.raises(PolicyDeniedError) as exc:
get_llm(
provider=provider,
model_name="some-cloud-model",
settings_snapshot=snapshot,
)
assert exc.value.decision.reason == "provider_cloud_only"
# The gate fired upstream of model construction.
mock_registry.assert_not_called()
def test_cloud_provider_denied_under_explicit_require_local_flag(self):
"""The explicit llm.require_local_endpoint=true flag (scope BOTH) must
gate cloud providers the same way the PRIVATE_ONLY scope does."""
snapshot = {
**LLM_REQUIRE_LOCAL_FLAG,
"llm.openai.api_key": "k",
} # gitleaks:allow
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
with pytest.raises(PolicyDeniedError):
get_llm(
provider="openai",
model_name="gpt-4o",
settings_snapshot=snapshot,
)
mock_registry.assert_not_called()
def test_cloud_provider_allowed_when_no_local_requirement(self):
"""Mirror (allow side): with scope BOTH and the flag off, the SAME
cloud provider passes the gate and reaches construction. Without this
pairing the deny tests could pass for an unrelated reason."""
snapshot = {
"policy.egress_scope": "both",
"llm.openai.api_key": "k", # gitleaks:allow
"search.tool": "searxng",
}
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
result = get_llm(
provider="openai",
model_name="gpt-4o",
settings_snapshot=snapshot,
)
assert result is not None
mock_registry.assert_called_once()
# ---------------------------------------------------------------------------
# get_llm — local providers allowed under require-local
# ---------------------------------------------------------------------------
class TestGetLlmLocalAllowed:
@pytest.mark.parametrize("provider", ["ollama", "lmstudio"])
def test_local_default_provider_allowed_under_private_only(self, provider):
"""A localhost-default provider (no URL override) passes the gate under
PRIVATE_ONLY and reaches construction (provider_local_default)."""
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
result = get_llm(
provider=provider,
model_name="local-model",
settings_snapshot=PRIVATE_ONLY,
)
assert result is not None
# The real registry dispatch ran (gate did not short-circuit it).
mock_registry.assert_called_once()
def test_local_provider_with_remote_url_denied(self):
"""The deny half of the local-provider pair: ollama pointed at a REMOTE
url is refused even though it is normally a local-default provider —
the gate classifies the configured endpoint, not the provider name."""
snapshot = {
**PRIVATE_ONLY,
"llm.ollama.url": "https://ollama.example.com",
}
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False,
):
with pytest.raises(PolicyDeniedError) as exc:
get_llm(
provider="ollama",
model_name="llama3",
settings_snapshot=snapshot,
)
assert exc.value.decision.reason == "provider_remote"
mock_registry.assert_not_called()
# ---------------------------------------------------------------------------
# get_llm — user-registered in-process LLM is exempt (allowed)
# ---------------------------------------------------------------------------
class TestGetLlmUserRegistered:
def test_user_registered_llm_allowed_under_private_only(self):
"""An operator-injected in-process LLM (programmatic API / plugin) has
no endpoint to classify, so the gate allows it under PRIVATE_ONLY
(user_registered_llm). The audit hook is the backstop for any stray
socket it might open."""
name = "_pep_callsite_inproc_llm"
register_llm(name, Mock(spec=BaseChatModel))
try:
result = get_llm(
provider=name,
settings_snapshot=PRIVATE_ONLY,
)
assert result is not None
finally:
unregister_llm(name)
def test_shadowing_registration_of_cloud_name_still_blocked(self):
"""Deny half: registering an in-process object under a BUILT-IN cloud
name ("openai") must NOT smuggle it past the gate — the cloud-block
fires first on the discovered name. Guards the _is_user_registered_llm
shadowing discriminator at the real call site."""
register_llm("openai", Mock(spec=BaseChatModel))
try:
with pytest.raises(PolicyDeniedError) as exc:
get_llm(
provider="openai",
model_name="gpt-4o",
settings_snapshot=PRIVATE_ONLY,
)
assert exc.value.decision.reason == "provider_cloud_only"
finally:
# Restore the auto-discovered built-in factory so we don't leak a
# mock into other tests' registry.
from local_deep_research.llm.providers import discover_providers
discover_providers(force_refresh=True)
# ---------------------------------------------------------------------------
# get_llm — snapshot-less fail-closed allow-list
# ---------------------------------------------------------------------------
class TestGetLlmSnapshotless:
def test_snapshotless_cloud_provider_fails_closed(self):
"""No snapshot => the require-local toggle is unreadable, so a non-local
provider must fail closed (no_snapshot_for_provider) rather than
silently instantiate a cloud client."""
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
with pytest.raises(PolicyDeniedError) as exc:
get_llm(
provider="openai",
model_name="gpt-4o",
settings_snapshot=None,
)
assert exc.value.decision.reason == "no_snapshot_for_provider"
mock_registry.assert_not_called()
def test_snapshotless_local_default_provider_allowed(self):
"""Mirror: a localhost-default provider is on the snapshot-less
allow-list and proceeds to construction even with no snapshot."""
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
result = get_llm(
provider="ollama",
model_name="llama3",
settings_snapshot=None,
)
assert result is not None
mock_registry.assert_called_once()
# ---------------------------------------------------------------------------
# get_embeddings — cloud embedder blocked under require-local
# ---------------------------------------------------------------------------
class TestGetEmbeddingsCloudBlocked:
def test_openai_denied_under_explicit_require_local_flag(self):
"""A cloud embedder under embeddings.require_local=true must raise
before create_embeddings (which would ship the corpus to the cloud)."""
with patch.object(
OpenAIEmbeddingsProvider, "create_embeddings"
) as mock_create:
with pytest.raises(PolicyDeniedError) as exc:
get_embeddings(
provider="openai",
settings_snapshot=EMB_REQUIRE_LOCAL_FLAG,
)
assert exc.value.decision.reason == "provider_cloud"
mock_create.assert_not_called()
def test_openai_allowed_when_no_local_requirement(self):
"""Mirror: with require-local off, the same cloud embedder reaches
construction — so the deny test isn't passing for an unrelated reason."""
with patch.object(
OpenAIEmbeddingsProvider,
"create_embeddings",
return_value=Mock(spec=Embeddings),
) as mock_create:
result = get_embeddings(
provider="openai",
settings_snapshot={
"policy.egress_scope": "both",
"embeddings.require_local": False,
"search.tool": "searxng",
},
)
assert result is not None
mock_create.assert_called_once()
def test_openai_denied_under_private_only_scope(self):
"""PRIVATE_ONLY implies require_local_embeddings even with the raw flag
left at its default — a cloud embedder is refused via the scope route."""
with patch.object(
OpenAIEmbeddingsProvider, "create_embeddings"
) as mock_create:
with pytest.raises(PolicyDeniedError):
get_embeddings(
provider="openai",
settings_snapshot=PRIVATE_ONLY,
)
mock_create.assert_not_called()
# ---------------------------------------------------------------------------
# get_embeddings — local embedders allowed under require-local
# ---------------------------------------------------------------------------
class TestGetEmbeddingsLocalAllowed:
def test_sentence_transformers_allowed_under_require_local(self):
"""In-process sentence_transformers passes the gate and constructs."""
with patch.object(
SentenceTransformersProvider,
"create_embeddings",
return_value=Mock(spec=Embeddings),
) as mock_create:
result = get_embeddings(
provider="sentence_transformers",
settings_snapshot=EMB_REQUIRE_LOCAL_FLAG,
)
assert result is not None
mock_create.assert_called_once()
def test_ollama_local_default_allowed_under_require_local(self):
"""ollama embeddings (no URL override) falls back to its localhost
default and is allowed."""
with patch.object(
OllamaEmbeddingsProvider,
"create_embeddings",
return_value=Mock(spec=Embeddings),
) as mock_create:
result = get_embeddings(
provider="ollama",
settings_snapshot=EMB_REQUIRE_LOCAL_FLAG,
)
assert result is not None
mock_create.assert_called_once()
def test_ollama_remote_url_denied_under_require_local(self):
"""Deny half of the ollama pair: a remote ollama endpoint is refused
even under the local-default provider name."""
snapshot = {
**EMB_REQUIRE_LOCAL_FLAG,
"embeddings.ollama.url": "https://ollama.example.com",
}
with patch.object(
OllamaEmbeddingsProvider, "create_embeddings"
) as mock_create:
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False,
):
with pytest.raises(PolicyDeniedError) as exc:
get_embeddings(
provider="ollama", settings_snapshot=snapshot
)
assert exc.value.decision.reason == "provider_remote"
mock_create.assert_not_called()
# ---------------------------------------------------------------------------
# get_embeddings — snapshot-less fail-closed allow-list
# ---------------------------------------------------------------------------
class TestGetEmbeddingsSnapshotless:
def test_snapshotless_openai_fails_closed(self):
"""No snapshot => cloud embedder fails closed (no_snapshot_for_provider)
rather than silently embedding the local corpus in the cloud."""
with patch.object(
OpenAIEmbeddingsProvider, "create_embeddings"
) as mock_create:
with pytest.raises(PolicyDeniedError) as exc:
get_embeddings(provider="openai", settings_snapshot=None)
assert exc.value.decision.reason == "no_snapshot_for_provider"
mock_create.assert_not_called()
def test_snapshotless_sentence_transformers_allowed(self):
"""Mirror: an in-process local embedder is on the snapshot-less
allow-list and constructs even with no snapshot."""
with patch.object(
SentenceTransformersProvider,
"create_embeddings",
return_value=Mock(spec=Embeddings),
) as mock_create:
result = get_embeddings(
provider="sentence_transformers", settings_snapshot=None
)
assert result is not None
mock_create.assert_called_once()
# ---------------------------------------------------------------------------
# Inference-path fail-OPEN closed: a snapshot with NO primary must not default
# to the public searxng (-> PUBLIC_ONLY -> require_local off) and admit a cloud
# provider for a run whose actual posture is private. The PEPs resolve the
# primary via resolve_run_primary_engine (no default) and fail closed.
# ---------------------------------------------------------------------------
class TestInferencePepsFailClosedOnMissingPrimary:
def test_llm_refuses_cloud_provider_when_no_primary(self):
# A present cloud key but NO search.tool (and no explicit scope =>
# ADAPTIVE). Pre-fix: searxng default -> PUBLIC_ONLY -> require_local
# off -> openai ADMITTED. Post-fix: fail closed before construction.
snapshot = {"llm.openai.api_key": "k"} # gitleaks:allow
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
with pytest.raises(PolicyDeniedError) as exc:
get_llm(
provider="openai",
model_name="gpt-4o",
settings_snapshot=snapshot,
)
assert exc.value.decision.reason == "invalid_policy_config"
mock_registry.assert_not_called()
def test_embeddings_refuses_cloud_provider_when_no_primary(self):
with patch.object(
OpenAIEmbeddingsProvider,
"create_embeddings",
return_value=Mock(spec=Embeddings),
) as mock_create:
with pytest.raises(PolicyDeniedError) as exc:
get_embeddings(
provider="openai",
settings_snapshot={"embeddings.require_local": False},
)
assert exc.value.decision.reason == "invalid_policy_config"
mock_create.assert_not_called()
# ---------------------------------------------------------------------------
# The PR's HEADLINE behavior (not just the missing-primary guardrail): with a
# PRESENT primary under the default ADAPTIVE scope, the inference PEPs now
# resolve the SAME scope the factory does. A private primary ("library") forces
# PRIVATE_ONLY -> a cloud provider is denied (provider_cloud_only) — exactly the
# exfil scenario from the PR body (search_tool="library"). A public primary
# ("searxng") resolves PUBLIC_ONLY -> the same cloud provider is admitted. The
# "library" key classifies private without any DB/username lookup.
# ---------------------------------------------------------------------------
class TestInferencePepFollowsAdaptivePrimary:
def test_llm_private_primary_denies_cloud_under_adaptive(self):
snapshot = {
"search.tool": "library", # private primary, scope defaults ADAPTIVE
"llm.openai.api_key": "k", # gitleaks:allow
}
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
with pytest.raises(PolicyDeniedError) as exc:
get_llm(
provider="openai",
model_name="gpt-4o",
settings_snapshot=snapshot,
)
assert exc.value.decision.reason == "provider_cloud_only"
mock_registry.assert_not_called()
def test_llm_public_primary_admits_cloud_under_adaptive(self):
# Mirror: a public primary under ADAPTIVE => PUBLIC_ONLY => admitted.
snapshot = {
"search.tool": "searxng",
"llm.openai.api_key": "k", # gitleaks:allow
}
with patch(
"local_deep_research.config.llm_config.get_llm_from_registry"
) as mock_registry:
mock_registry.return_value = Mock(spec=BaseChatModel)
result = get_llm(
provider="openai",
model_name="gpt-4o",
settings_snapshot=snapshot,
)
assert result is not None
mock_registry.assert_called_once()
def test_embeddings_private_primary_denies_cloud_under_adaptive(self):
snapshot = {"search.tool": "library"} # private primary, ADAPTIVE
with patch.object(
OpenAIEmbeddingsProvider,
"create_embeddings",
return_value=Mock(spec=Embeddings),
) as mock_create:
with pytest.raises(PolicyDeniedError) as exc:
get_embeddings(provider="openai", settings_snapshot=snapshot)
assert exc.value.decision.reason in (
"provider_cloud_only",
"provider_cloud",
)
mock_create.assert_not_called()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,499 @@
"""Tests for the stage-B egress resolver/assembly/audit (ADR-0007).
Covers label resolution from declared attributes + dynamic refinements
(collection is_public, store URL fail-up), run assembly, and the audit-mode
decision (which must never raise).
"""
from __future__ import annotations
from unittest.mock import patch
from local_deep_research.security.egress import run_classification as rc
from local_deep_research.security.egress.classification import (
Exposure,
Label,
Role,
Sensitivity,
)
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
)
S = Sensitivity.SENSITIVE
NS = Sensitivity.NON_SENSITIVE
EXP = Exposure.EXPOSING
CON = Exposure.CONTAINED
POLICY = "local_deep_research.security.egress.policy"
def ctx(primary: str = "arxiv", username=None) -> EgressContext:
return EgressContext(
scope=EgressScope.BOTH,
primary_engine=primary,
require_local_llm=False,
require_local_embeddings=False,
username=username,
)
class _PublicEngine:
egress_sensitivity = NS
egress_exposure = EXP
class _LocalStore:
egress_sensitivity = S
egress_exposure = CON
url_setting = "search.engine.web.paperless.default_params.api_url"
# ---------------------------------------------------------------------------
# engine_label — static engines + URL fail-up
# ---------------------------------------------------------------------------
def test_public_engine_label():
with patch(f"{POLICY}._get_engine_class", return_value=_PublicEngine):
assert rc.engine_label("arxiv", {}, ctx()) == Label(NS, EXP)
def test_unknown_engine_label_is_none():
with patch(f"{POLICY}._get_engine_class", return_value=None):
assert rc.engine_label("mystery", {}, ctx()) is None
def test_local_store_contained_on_local_url():
with (
patch(f"{POLICY}._get_engine_class", return_value=_LocalStore),
patch(f"{POLICY}._classify_engine_url", return_value=True),
):
assert rc.engine_label("paperless", {}, ctx()) == Label(S, CON)
def test_local_store_fails_up_to_exposing_on_public_url():
# Quadrant 4: sensitive data + exposing sink (public host).
with (
patch(f"{POLICY}._get_engine_class", return_value=_LocalStore),
patch(f"{POLICY}._classify_engine_url", return_value=False),
):
assert rc.engine_label("paperless", {}, ctx()) == Label(S, EXP)
# ---------------------------------------------------------------------------
# engine_label — collections / library (sensitivity from is_public)
# ---------------------------------------------------------------------------
def test_private_collection_is_sensitive():
with patch(f"{POLICY}._resolve_collection_is_public", return_value=False):
assert rc.engine_label("collection_abc", {}, ctx()) == Label(S, CON)
def test_public_collection_is_non_sensitive():
with patch(f"{POLICY}._resolve_collection_is_public", return_value=True):
assert rc.engine_label("collection_abc", {}, ctx()) == Label(NS, CON)
def test_library_aggregate_is_sensitive():
with patch(f"{POLICY}._resolve_collection_is_public", return_value=False):
assert rc.engine_label("library", {}, ctx()) == Label(S, CON)
# ---------------------------------------------------------------------------
# provider labels (inference sinks — exposure only)
# ---------------------------------------------------------------------------
def test_llm_labels():
assert rc.llm_label("ollama") == Label(NS, CON)
assert rc.llm_label("anthropic") == Label(NS, EXP)
assert rc.llm_label("totally-unknown") == Label(NS, EXP) # fail closed
def test_embeddings_labels():
assert rc.embeddings_label("ollama") == Label(NS, CON)
assert rc.embeddings_label("sentence_transformers") == Label(NS, CON)
assert rc.embeddings_label("openai") == Label(NS, EXP)
# ---------------------------------------------------------------------------
# classify_run — assembly into SOURCE + SEARCH_SINK + INFERENCE_SINK
# ---------------------------------------------------------------------------
def test_classify_run_assembles_roles():
with patch.object(rc, "engine_label", return_value=Label(NS, EXP)):
comps = rc.classify_run(
{}, ctx(), engines=["arxiv"], llm_provider="anthropic"
)
got = {(c.name, c.role) for c in comps}
assert got == {
("arxiv", Role.SOURCE),
("arxiv", Role.SEARCH_SINK),
("llm:anthropic", Role.INFERENCE_SINK),
}
def test_classify_run_fails_closed_on_unknown_engine():
with patch.object(rc, "engine_label", return_value=None):
comps = rc.classify_run(
{}, ctx(), engines=["mystery"], llm_provider="ollama"
)
# Unknown engine is NOT dropped — it fails closed to sensitive+exposing.
by = {(c.name, c.role): c for c in comps}
assert by[("mystery", Role.SOURCE)].label == Label(S, EXP)
assert ("mystery", Role.SEARCH_SINK) in by
assert ("llm:ollama", Role.INFERENCE_SINK) in by
# ---------------------------------------------------------------------------
# audit_run — decision + never-raises contract
# ---------------------------------------------------------------------------
def test_audit_run_denies_sensitive_plus_cloud_llm():
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run(
{}, ctx(), engines=["collection_x"], llm_provider="anthropic"
)
assert d is not None and not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
def test_audit_run_allows_public_plus_local_llm():
with patch.object(rc, "engine_label", return_value=Label(NS, EXP)):
d = rc.audit_run({}, ctx(), engines=["arxiv"], llm_provider="ollama")
assert d is not None and d.allowed
def test_audit_run_permissive_under_unprotected_scope():
# A normally-denied combo is ALLOWED (permissive) under the escape hatch,
# with the diagnostic preserved.
unprot = EgressContext(
scope=EgressScope.UNPROTECTED,
primary_engine="arxiv",
require_local_llm=False,
require_local_embeddings=False,
)
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run(
{}, unprot, engines=["collection_x"], llm_provider="anthropic"
)
assert d is not None and d.allowed
assert d.reason.startswith("permissive:")
def test_audit_run_fails_closed_on_internal_error():
# An uncomputable decision must refuse the run (fail closed) and never
# raise — silent degradation to the scope PEPs would hide the failure.
with patch.object(rc, "engine_label", side_effect=RuntimeError("boom")):
d = rc.audit_run({}, ctx(), engines=["x"], llm_provider="ollama")
assert d is not None and not d.allowed and d.reason == "audit_error"
def test_fail_closed_decision_needs_no_policy_import():
# _fail_closed_decision runs from the except blocks that handle failures
# (including a .policy import failure), so it must NOT import .policy. It
# denies for a normal ctx and allows only under unprotected, using the
# ctx's str-enum scope value directly.
assert not rc._fail_closed_decision(ctx()).allowed
unprot = EgressContext(
scope=EgressScope.UNPROTECTED,
primary_engine="arxiv",
require_local_llm=False,
require_local_embeddings=False,
)
assert rc._fail_closed_decision(unprot).allowed
def test_audit_run_internal_error_never_blocks_under_unprotected():
# Fail-closed must not override the escape hatch: an internal error under
# UNPROTECTED is still allowed.
unprot = EgressContext(
scope=EgressScope.UNPROTECTED,
primary_engine="arxiv",
require_local_llm=False,
require_local_embeddings=False,
)
with patch.object(rc, "engine_label", side_effect=RuntimeError("boom")):
d = rc.audit_run({}, unprot, engines=["x"], llm_provider="ollama")
assert d is not None and d.allowed
def test_audit_run_unknown_engine_fails_closed_and_denies():
# Unknown engine -> sensitive+exposing source; a cloud LLM -> exposing
# inference sink -> the run is denied (not silently allowed).
with patch.object(rc, "engine_label", return_value=None):
d = rc.audit_run(
{}, ctx(), engines=["mystery"], llm_provider="anthropic"
)
assert d is not None and not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
# ---------------------------------------------------------------------------
# audit_run_from_snapshot — shared route/worker entry point (covers all run
# entry points: API precheck, follow-up, chat, queue)
# ---------------------------------------------------------------------------
def test_audit_run_from_snapshot_denies_private_collection_plus_cloud_llm():
# The worker-chokepoint case that a follow-up / chat / queue run reaches:
# a private collection primary + a cloud LLM under a `both` scope must be
# refused, matching the /api/start_research route.
snap = {"llm.provider": "anthropic"}
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
snap, ctx(primary="collection_x"), "collection_x"
)
assert d is not None and not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
def test_audit_run_from_snapshot_unwraps_dict_wrapped_snapshot():
# get_all_settings snapshots store each value as {"value": ...}; the helper
# must unwrap it, else the LLM provider is dropped and the check is blind.
snap = {"llm.provider": {"value": "anthropic"}}
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
snap, ctx(primary="collection_x"), "collection_x"
)
assert d is not None and not d.allowed
def test_audit_run_from_snapshot_permissive_under_unprotected():
unprot = EgressContext(
scope=EgressScope.UNPROTECTED,
primary_engine="collection_x",
require_local_llm=False,
require_local_embeddings=False,
)
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
{"llm.provider": "anthropic"}, unprot, "collection_x"
)
assert d is not None and d.allowed
def test_audit_run_from_snapshot_missing_llm_provider_uses_run_default():
# A snapshot without llm.provider must resolve to the run's own default
# (ollama, local) via get_setting_from_snapshot — the same value the run
# would use — not be silently read as "no LLM". Sensitive source + local
# default inference is quadrant 2 (allowed), matching the run's behaviour.
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
{}, ctx(primary="collection_x"), "collection_x"
)
assert d is not None and d.allowed
def test_audit_run_from_snapshot_includes_embeddings_for_collection():
# A RAG run over a collection embeds, so a cloud embedder is an exposing
# sink for the sensitive source -> refused even with a local LLM. The RAG
# engine reads local_search_embedding_provider, so the audit must too.
snap = {
"llm.provider": "ollama",
"local_search_embedding_provider": "openai",
}
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
snap, ctx(primary="collection_x"), "collection_x"
)
assert d is not None and not d.allowed
def test_audit_run_from_snapshot_ignores_embeddings_for_lexical_store():
# A non-collection sensitive store (paperless/elasticsearch) never embeds,
# so a configured cloud embedder must NOT be pulled in: sensitive source +
# local LLM is admissible (quadrant 2), not refused.
snap = {
"llm.provider": "ollama",
"local_search_embedding_provider": "openai",
}
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
snap, ctx(primary="paperless"), "paperless"
)
assert d is not None and d.allowed
def test_audit_run_from_snapshot_empty_llm_provider_resolves_from_snapshot():
# Chat passes model_provider="" (not None) when no override is set. An empty
# string must be treated as "resolve from snapshot", not passed through —
# else classify_run's truthy guard silently drops the LLM sink and a
# sensitive source + cloud LLM is wrongly allowed.
snap = {"llm.provider": "anthropic"} # cloud provider in the snapshot
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
snap, ctx(primary="collection_x"), "collection_x", llm_provider=""
)
assert d is not None and not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
def test_audit_run_from_snapshot_audits_llm_provider_override():
# The run's per-request model_provider WINS over the snapshot (get_llm uses
# the explicit arg). A saved local default must not mask a cloud override
# chosen for this one run — else queue/follow-up/chat runs leak.
snap = {"llm.provider": "ollama"} # saved default is local/contained
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run_from_snapshot(
snap,
ctx(primary="collection_x"),
"collection_x",
llm_provider="anthropic", # per-request cloud override
)
assert d is not None and not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
def test_audit_run_from_snapshot_fails_closed_on_bad_snapshot():
# Provider resolution runs inside the fail-closed guard: a malformed
# snapshot refuses the run rather than escaping the helper uncaught.
d = rc.audit_run_from_snapshot(
"not-a-dict", ctx(primary="collection_x"), "collection_x"
)
assert d is not None and not d.allowed and d.reason == "audit_error"
# ---------------------------------------------------------------------------
# provider endpoint URL fail-up (only ever tightens)
# ---------------------------------------------------------------------------
def test_llm_label_local_custom_endpoint_is_contained():
# A self-hosted OpenAI-compatible endpoint on a local IP must be CONTAINED
# (regression: previously mislabeled EXPOSING -> false 400 on local runs).
snap = {"llm.openai_endpoint.url": "http://127.0.0.1:8000/v1"}
assert rc.llm_label("openai_endpoint", snap, ctx()) == Label(NS, CON)
def test_llm_label_public_custom_endpoint_is_exposing():
snap = {"llm.openai_endpoint.url": "http://8.8.8.8:8000/v1"}
assert rc.llm_label("openai_endpoint", snap, ctx()) == Label(NS, EXP)
# ---------------------------------------------------------------------------
# Real declared label values — guards against a typo in any engine class
# ---------------------------------------------------------------------------
def test_registered_engines_declare_consistent_labels():
from local_deep_research.security.egress.policy import _get_engine_class
from local_deep_research.web_search_engines.engine_registry import (
ENGINE_REGISTRY,
)
assert ENGINE_REGISTRY, "engine registry unexpectedly empty"
checked = 0
for name in ENGINE_REGISTRY:
cls = _get_engine_class(name) # the real class the resolver reads
if cls is None:
continue
sens = getattr(cls, "egress_sensitivity", None)
exp = getattr(cls, "egress_exposure", None)
assert sens is not None, f"{name} missing egress_sensitivity"
assert exp is not None, f"{name} missing egress_exposure"
if getattr(cls, "is_public", False):
assert sens is Sensitivity.NON_SENSITIVE, name
assert exp is Exposure.EXPOSING, name
elif getattr(cls, "is_local", False):
assert sens is Sensitivity.SENSITIVE, name
assert exp is Exposure.CONTAINED, name
checked += 1
assert checked > 10, f"only checked {checked} engines"
def test_llm_provider_declared_exposure_matches_resolver():
# The resolver classifies a provider's endpoint via the enforcing PEP; with
# no configured URL it falls back to the static cloud/local split. Assert
# the resolver AND (where the class is resolvable) the declared attribute
# agree, across every statically-classifiable provider — url-configurable
# ones (openai_endpoint / anthropic_endpoint) are refined by URL and are
# covered by the dedicated local/public endpoint tests above.
from local_deep_research.llm.providers import get_provider_class
expected = {
"ollama": Exposure.CONTAINED,
"lmstudio": Exposure.CONTAINED,
"llamacpp": Exposure.CONTAINED,
"openai": Exposure.EXPOSING,
"anthropic": Exposure.EXPOSING,
"google": Exposure.EXPOSING,
"openrouter": Exposure.EXPOSING,
"deepseek": Exposure.EXPOSING,
"xai": Exposure.EXPOSING,
"ionos": Exposure.EXPOSING,
}
for name, exp in expected.items():
assert rc.llm_label(name).exposure is exp, name
cls = None
try:
cls = get_provider_class(name) or get_provider_class(name.upper())
except Exception:
cls = None
if cls is not None:
assert getattr(cls, "egress_exposure", None) is exp, name
# ---------------------------------------------------------------------------
# Per-destination trust (stage D) — relaxes an exposing sink to contained
# ---------------------------------------------------------------------------
def test_llm_trust_relaxes_cloud_provider_to_contained():
trusted = {"policy.trusted_inference_providers": ["anthropic"]}
assert rc.llm_label("anthropic", trusted, ctx()).exposure is CON
# untrusted cloud stays exposing
assert rc.llm_label("anthropic", {}, ctx()).exposure is EXP
def test_embeddings_trust_relaxes_cloud_provider_to_contained():
trusted = {"policy.trusted_inference_providers": ["openai"]}
assert rc.embeddings_label("openai", trusted, ctx()).exposure is CON
def test_engine_trust_relaxes_exposing_store_to_contained():
# A sensitive store on a public URL is quadrant 4 (exposing); trusting it
# moves exposure to contained while keeping its data sensitive.
trusted = {"policy.trusted_search_engines": ["paperless"]}
with (
patch(f"{POLICY}._get_engine_class", return_value=_LocalStore),
patch(f"{POLICY}._classify_engine_url", return_value=False),
):
assert rc.engine_label("paperless", trusted, ctx()) == Label(S, CON)
def test_trusted_names_handles_json_string_and_garbage():
assert rc._trusted_names("k", {"k": '["Anthropic", "ollama"]'}) == {
"anthropic",
"ollama",
}
assert rc._trusted_names("k", {"k": "not json"}) == set()
assert rc._trusted_names("k", None) == set()
def test_trusted_cloud_llm_allows_sensitive_run_end_to_end():
# The stage-D payoff: a sensitive source + a TRUSTED cloud LLM is allowed.
trusted = {"policy.trusted_inference_providers": ["anthropic"]}
with patch.object(rc, "engine_label", return_value=Label(S, CON)):
d = rc.audit_run(
trusted, ctx(), engines=["collection_x"], llm_provider="anthropic"
)
assert d is not None and d.allowed
def test_engine_trust_does_not_relax_public_engine():
# Trusting an inherently-public engine must NOT contain it (no laundering
# a public search sink past the sensitive->exposing rule).
class _PublicWithName:
is_public = True
egress_sensitivity = NS
egress_exposure = EXP
trusted = {"policy.trusted_search_engines": ["searxng"]}
with patch(f"{POLICY}._get_engine_class", return_value=_PublicWithName):
assert rc.engine_label("searxng", trusted, ctx()) == Label(NS, EXP)
@@ -0,0 +1,278 @@
"""Integration tests for the settings-save egress validators.
These drive the REAL cross-field policy validators in
``security/egress/validators.py`` exactly as the settings write routes invoke
them (``web/routes/settings_routes.py``: ``save_all_settings`` /
``save_settings`` / ``api_update_setting``):
- ``validate_allowed_local_hostnames``: a PUBLIC hostname may not be smuggled
into ``llm.allowed_local_hostnames`` (the host classifier would then trust
external hosts as "local"); private / loopback hosts are accepted.
Each guarded property is asserted with an allow+deny pair so the test fails if
the rule were reverted. Direct-validator tests use realistic
``form_data`` / ``all_db_settings`` inputs; route-level tests drive the real
``api_update_setting`` PUT endpoint with only the settings/DB backend mocked,
proving the validation error is surfaced (not silently dropped).
All host classification uses literal IPs (8.8.8.8 public, 10.0.0.1 / 127.0.0.1
private), which the real classifier resolves OFFLINE via getaddrinfo on a
literal — no network round-trip, fully deterministic.
"""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from local_deep_research.security.egress import validators
from local_deep_research.security.egress.validators import (
validate_allowed_local_hostnames,
)
from local_deep_research.web.routes.settings_routes import settings_bp
HOSTS_KEY = "llm.allowed_local_hostnames"
SCOPE_KEY = "policy.egress_scope"
ENGINE_KEY = "search.tool"
MODULE = "local_deep_research.web.routes.settings_routes"
DECORATOR_MODULE = "local_deep_research.web.utils.route_decorators"
# ---------------------------------------------------------------------------
# Removed validator regression guard
# ---------------------------------------------------------------------------
def test_validate_strict_meta_combo_is_gone():
"""The meta-picker engines were removed, so the STRICT+meta-picker
save-time validator must no longer exist (stray meta names are denied at
runtime by ``evaluate_engine`` as ``engine_unknown`` instead)."""
assert not hasattr(validators, "validate_strict_meta_combo")
# ---------------------------------------------------------------------------
# validate_allowed_local_hostnames (no PUBLIC host in the local allowlist)
# ---------------------------------------------------------------------------
def test_public_host_in_local_allowlist_is_rejected():
"""A public IP would let the policy treat an external host as local."""
err = validate_allowed_local_hostnames({HOSTS_KEY: ["8.8.8.8"]}, {})
assert err is not None
assert err["key"] == HOSTS_KEY
assert "8.8.8.8" in err["error"]
def test_private_host_in_local_allowlist_is_accepted():
"""Allow side: an RFC1918 private address is a legitimate local host."""
assert (
validate_allowed_local_hostnames({HOSTS_KEY: ["10.0.0.1"]}, {}) is None
)
def test_loopback_host_in_local_allowlist_is_accepted():
"""Loopback is local and must be accepted."""
assert (
validate_allowed_local_hostnames({HOSTS_KEY: ["127.0.0.1"]}, {}) is None
)
def test_mixed_list_rejects_only_the_public_entries():
"""A list mixing private + public is rejected, naming only the public host."""
err = validate_allowed_local_hostnames(
{HOSTS_KEY: ["10.0.0.1", "8.8.8.8"]}, {}
)
assert err is not None
assert "8.8.8.8" in err["error"]
assert "10.0.0.1" not in err["error"]
def test_unresolvable_host_is_accepted_fail_open():
"""A name that does not resolve (DNS down / split-horizon) is accepted so a
flaky-network user can still save; runtime classification still gates it.
Resolution is stubbed to None to keep this deterministic and offline."""
with patch.object(validators, "_resolve_with_timeout", return_value=None):
assert (
validate_allowed_local_hostnames(
{HOSTS_KEY: ["intranet.corp.example"]}, {}
)
is None
)
def test_public_host_still_rejected_when_resolution_succeeds():
"""Deny counterpart to the fail-open test: when resolution returns an
address, a public host is rejected (the stub must not blanket-accept)."""
addrinfo = [(2, 1, 6, "", ("8.8.8.8", 0))]
with patch.object(
validators, "_resolve_with_timeout", return_value=addrinfo
):
err = validate_allowed_local_hostnames(
{HOSTS_KEY: ["resolves-public.example"]}, {}
)
assert err is not None
assert "resolves-public.example" in err["error"]
def test_json_string_list_of_private_host_is_accepted():
"""The save pipeline may hand the JSON-typed value as a JSON string."""
assert (
validate_allowed_local_hostnames({HOSTS_KEY: '["10.0.0.1"]'}, {})
is None
)
def test_json_string_list_of_public_host_is_rejected():
"""Deny counterpart for the JSON-string decode path."""
err = validate_allowed_local_hostnames({HOSTS_KEY: '["8.8.8.8"]'}, {})
assert err is not None
assert "8.8.8.8" in err["error"]
def test_malformed_json_string_is_rejected():
"""A non-JSON string for a JSON-typed setting is a hard validation error."""
err = validate_allowed_local_hostnames({HOSTS_KEY: "not json"}, {})
assert err is not None
assert err["key"] == HOSTS_KEY
def test_non_list_value_is_rejected():
"""A scalar (non-list) value is rejected — must be a list of hostnames."""
err = validate_allowed_local_hostnames({HOSTS_KEY: 5}, {})
assert err is not None
def test_hosts_key_absent_returns_none():
"""The guard is inert when its key is not part of the save."""
assert validate_allowed_local_hostnames({"other.key": "x"}, {}) is None
# ---------------------------------------------------------------------------
# Route-level wiring: api_update_setting PUT surfaces the validator errors
# ---------------------------------------------------------------------------
def _make_setting(key, value, ui_element="text"):
s = MagicMock()
s.key = key
s.value = value
s.ui_element = ui_element
s.editable = True
return s
@contextmanager
def _routed_client(existing_settings):
"""Drive api_update_setting with the auth + DB backend mocked.
``existing_settings`` is a list of mock Setting rows returned by
``db_session.query(Setting).all()``; the per-key lookup
``.filter(...).first()`` returns the row whose key matches the request.
"""
app = Flask(__name__)
app.config["SECRET_KEY"] = "test-secret"
app.config["WTF_CSRF_ENABLED"] = False
app.register_blueprint(settings_bp)
by_key = {s.key: s for s in existing_settings}
db_session = MagicMock()
def _query(_model):
q = MagicMock()
q.all.return_value = list(existing_settings)
def _filter(*_a, **_k):
fq = MagicMock()
# api_update_setting filters on Setting.key == <key>; the key is
# bound in the route, so resolve it from the live request.
from flask import request as _req
requested = _req.view_args.get("key")
fq.first.return_value = by_key.get(requested)
return fq
q.filter.side_effect = _filter
return q
db_session.query.side_effect = _query
@contextmanager
def _fake_user_session(_username):
yield db_session
fake_db_manager = MagicMock()
fake_db_manager.is_user_connected.return_value = True
patches = [
patch(
"local_deep_research.web.auth.decorators.db_manager",
fake_db_manager,
),
patch(
f"{DECORATOR_MODULE}.get_user_db_session",
side_effect=_fake_user_session,
),
patch(f"{MODULE}.settings_limit", lambda f: f),
# Isolate the egress decision from adjacent type/coercion concerns.
patch(f"{MODULE}.validate_setting", return_value=(True, None)),
patch(
f"{MODULE}.coerce_setting_for_write",
side_effect=lambda **kw: kw["value"],
),
patch(f"{MODULE}.set_setting", return_value=True),
patch(f"{MODULE}.calculate_warnings", return_value=[]),
patch(f"{MODULE}.invalidate_settings_caches", return_value=None),
]
for p in patches:
p.start()
try:
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "testuser"
sess["session_id"] = "sid"
yield client
finally:
for p in reversed(patches):
p.stop()
def test_route_rejects_public_local_hostname():
"""PUT llm.allowed_local_hostnames=[public IP] -> 400 with the policy error."""
settings = [_make_setting(HOSTS_KEY, [], ui_element="json")]
with _routed_client(settings) as client:
resp = client.put(
f"/settings/api/{HOSTS_KEY}", json={"value": ["8.8.8.8"]}
)
assert resp.status_code == 400
assert "8.8.8.8" in resp.get_json()["error"]
def test_route_accepts_private_local_hostname():
"""Allow counterpart: a private IP passes the validator and is saved (200)."""
settings = [_make_setting(HOSTS_KEY, [], ui_element="json")]
with _routed_client(settings) as client:
resp = client.put(
f"/settings/api/{HOSTS_KEY}", json={"value": ["10.0.0.1"]}
)
assert resp.status_code == 200
assert "error" not in resp.get_json()
def test_route_accepts_strict_scope_with_db_concrete_engine():
"""Allow counterpart: STRICT scope with a concrete DB engine saves (200)."""
settings = [
_make_setting(SCOPE_KEY, "both"),
_make_setting(ENGINE_KEY, "arxiv"),
]
with _routed_client(settings) as client:
resp = client.put(
f"/settings/api/{SCOPE_KEY}", json={"value": "strict"}
)
assert resp.status_code == 200
assert "error" not in resp.get_json()
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))
@@ -0,0 +1,322 @@
"""Integration tests: egress audit-hook backstop propagation into worker threads.
These are NOT PDP-unit tests. They drive the REAL runtime wiring that re-arms
the PEP-578 socket audit hook inside worker threads, because the hook reads its
active context from a ``threading.local`` that stdlib ``ThreadPoolExecutor``
workers do NOT inherit. Two real call-site mechanisms are exercised:
1. ``utilities.thread_context.preserve_research_context`` — the decorator the
global search pool wraps tasks with. It captures the submitter thread's
egress context at decoration time and re-arms/clears it per task.
2. The hand-rolled capture-then-rearm pattern used by ``news_strategy`` and
``parallel_search_engine`` (``get_active_context()`` on submit, then
``active_egress_context()`` / ``set_active_context()`` + finally-clear in the
worker).
The security property under test: under an armed PRIVATE_ONLY context, a raw
``socket.connect`` to a public IP must raise ``PolicyDeniedError`` *inside the
worker* — and must NOT when the context was never propagated (proving the
re-arm is what supplies the protection, not ambient state). The hook fires
BEFORE the SYN, so no test depends on a real outbound connection completing.
Each test uses an allow+deny pair so it fails if the propagation were reverted.
Armed context is always cleared in a finally so the thread-local does not leak
to other tests.
"""
from __future__ import annotations
import concurrent.futures
import socket
import threading
import pytest
from local_deep_research.security import (
active_egress_context,
clear_active_context,
get_active_context,
install_audit_hook,
set_active_context,
)
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
PolicyDeniedError,
)
from local_deep_research.utilities.thread_context import (
preserve_research_context,
)
def _ctx(scope: EgressScope) -> EgressContext:
"""Minimal EgressContext for a given scope (fresh per test, no shared
denial-quota or DNS-cache state)."""
return EgressContext(
scope=scope,
primary_engine="wikipedia",
require_local_llm=False,
require_local_embeddings=False,
)
def _attempt_connect(host: str, port: int = 53, timeout: float = 0.3):
"""Raw AF_INET connect; return the exception raised (or None on success).
Under an armed enforcing context the audit hook raises PolicyDeniedError
BEFORE any network I/O, so the kernel-level outcome for the allow-cases
(refused/timeout) is irrelevant — we only classify whether the policy
fired.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect((host, port))
return None
except PolicyDeniedError as exc:
return exc
except OSError as exc:
return exc
finally:
sock.close()
@pytest.fixture(autouse=True)
def _hook_and_clean_context():
"""Hook must be installed (it is at security import time, but be explicit
and idempotent) and the thread-local must be clean around each test so a
leaked PRIVATE_ONLY context can't mask another test's setup."""
install_audit_hook()
clear_active_context()
yield
clear_active_context()
_PUBLIC_HOST = "8.8.8.8"
_PRIVATE_HOST = "127.0.0.1"
# ---------------------------------------------------------------------------
# preserve_research_context (the global-pool decorator)
# ---------------------------------------------------------------------------
def test_preserve_research_context_rearms_hook_in_worker_and_blocks_public():
"""A task wrapped with preserve_research_context, submitted to a stdlib
ThreadPoolExecutor under an armed PRIVATE_ONLY context, must (a) run on a
DIFFERENT thread, (b) see the SAME armed context inside the worker, and
(c) have a public socket.connect blocked by the hook in that worker.
"""
armed = _ctx(EgressScope.PRIVATE_ONLY)
main_tid = threading.get_ident()
set_active_context(armed)
try:
# Decoration happens here, WHILE armed — this is where the egress
# context is captured off the submitter thread.
def _worker():
return {
"tid": threading.get_ident(),
"ctx": get_active_context(),
"err": _attempt_connect(_PUBLIC_HOST),
}
wrapped = preserve_research_context(_worker)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
result = ex.submit(wrapped).result()
finally:
clear_active_context()
assert result["tid"] != main_tid, "task did not run on a worker thread"
assert result["ctx"] is armed, (
"wrapper did not re-arm the submitter's egress context in the worker"
)
assert isinstance(result["err"], PolicyDeniedError), (
f"hook did not fire in re-armed worker: {result['err']!r}"
)
assert result["err"].decision.reason == "scope_mismatch_private_only"
def test_unwrapped_worker_has_no_context_so_hook_is_inactive():
"""Deny-pair to the test above: the SAME public connect, the SAME armed
main thread, but the task is NOT wrapped. Because threading.local is not
inherited by pool workers, the worker has no active context and the hook
is inactive there — demonstrating exactly why the wrapper is required.
"""
armed = _ctx(EgressScope.PRIVATE_ONLY)
set_active_context(armed)
try:
def _worker():
return {
"ctx": get_active_context(),
"err": _attempt_connect(_PUBLIC_HOST),
}
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
result = ex.submit(_worker).result() # unwrapped on purpose
finally:
clear_active_context()
assert result["ctx"] is None, (
"unexpected ambient context in fresh worker — test is not isolating "
"the propagation mechanism"
)
assert not isinstance(result["err"], PolicyDeniedError), (
"hook fired in a worker that never received the context — would mean "
"the context leaked rather than being explicitly propagated"
)
def test_preserve_research_context_clears_context_so_reused_worker_is_clean():
"""The wrapper must clear the egress context in its finally so a reused
pool worker does not inherit PRIVATE_ONLY into the next (possibly
unrelated) task. Run a wrapped armed task, then a bare probe on the SAME
single-worker executor and assert the probe lands on the same thread with
no context and an un-gated public connect.
"""
armed = _ctx(EgressScope.PRIVATE_ONLY)
set_active_context(armed)
try:
def _armed_task():
return {
"tid": threading.get_ident(),
"err": _attempt_connect(_PUBLIC_HOST),
}
wrapped = preserve_research_context(_armed_task)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
first = ex.submit(wrapped).result()
# Bare probe (NOT wrapped) reused on the same idle worker.
def _probe():
return {
"tid": threading.get_ident(),
"ctx": get_active_context(),
"err": _attempt_connect(_PUBLIC_HOST),
}
second = ex.submit(_probe).result()
finally:
clear_active_context()
# The armed task really was protected (otherwise the "clear" claim is moot).
assert isinstance(first["err"], PolicyDeniedError)
# Same worker thread reused.
assert second["tid"] == first["tid"], (
"probe ran on a different worker — cannot prove no per-worker leak"
)
assert second["ctx"] is None, "wrapper leaked egress context to next task"
assert not isinstance(second["err"], PolicyDeniedError), (
"PRIVATE_ONLY leaked into the reused worker's next task"
)
# ---------------------------------------------------------------------------
# Capture-then-rearm pattern (news_strategy / parallel_search_engine)
# ---------------------------------------------------------------------------
def _run_news_style(submit_armed: bool, host: str):
"""Replicate news_strategy: capture get_active_context() on the submitter
thread, then re-arm it in the worker via active_egress_context(). Returns
the connect outcome observed inside the worker.
"""
if submit_armed:
set_active_context(_ctx(EgressScope.PRIVATE_ONLY))
try:
# Capture on the submitter thread (mirrors news_strategy line 262).
captured = get_active_context()
def _analyze():
with active_egress_context(captured):
return _attempt_connect(host)
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
return ex.submit(_analyze).result()
finally:
clear_active_context()
def test_news_strategy_capture_then_rearm_blocks_public_under_private_only():
"""news_strategy's capture+active_egress_context re-arm must block a public
connect in the worker when armed PRIVATE_ONLY, and must NOT block when the
submitter had no context (captured is None → no-op)."""
blocked = _run_news_style(submit_armed=True, host=_PUBLIC_HOST)
assert isinstance(blocked, PolicyDeniedError), (
f"re-armed worker failed to block public host: {blocked!r}"
)
assert blocked.decision.reason == "scope_mismatch_private_only"
# Deny-pair: nothing captured → re-arm is a no-op → not blocked.
allowed = _run_news_style(submit_armed=False, host=_PUBLIC_HOST)
assert not isinstance(allowed, PolicyDeniedError), (
f"hook fired with no captured context (active_egress_context(None) "
f"should be a no-op): {allowed!r}"
)
def test_news_strategy_rearm_still_allows_private_host_under_private_only():
"""Re-arming PRIVATE_ONLY in the worker must not over-block: a private
(loopback) target stays allowed, same contract as on the main thread."""
err = _run_news_style(submit_armed=True, host=_PRIVATE_HOST)
assert not isinstance(err, PolicyDeniedError), (
f"re-armed PRIVATE_ONLY wrongly blocked a private host: {err!r}"
)
def test_parallel_engine_capture_set_then_finally_clear_no_worker_leak():
"""Replicate parallel_search_engine._run_with_context: capture the egress
context on submit, set_active_context() in the worker, and clear it in a
finally. Verify (a) the worker blocks a public connect while armed, and
(b) the finally-clear leaves the reused worker clean for the next task.
"""
set_active_context(_ctx(EgressScope.PRIVATE_ONLY))
try:
submitter_egress_ctx = get_active_context()
def _run_with_context(host):
# Mirrors the engine's wrapper: set on entry, clear in finally.
if submitter_egress_ctx is not None:
set_active_context(submitter_egress_ctx)
try:
return {
"tid": threading.get_ident(),
"ctx": get_active_context(),
"err": _attempt_connect(host),
}
finally:
if submitter_egress_ctx is not None:
clear_active_context()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
armed_result = ex.submit(_run_with_context, _PUBLIC_HOST).result()
# Bare probe reused on the same worker — must see no leak.
def _probe():
return {
"tid": threading.get_ident(),
"ctx": get_active_context(),
"err": _attempt_connect(_PUBLIC_HOST),
}
probe_result = ex.submit(_probe).result()
finally:
clear_active_context()
assert armed_result["ctx"] is submitter_egress_ctx
assert isinstance(armed_result["err"], PolicyDeniedError), (
f"engine-style worker failed to block public host: "
f"{armed_result['err']!r}"
)
assert probe_result["tid"] == armed_result["tid"], (
"probe ran on a different worker — cannot prove the finally-clear"
)
assert probe_result["ctx"] is None, (
"engine wrapper's finally-clear failed: context leaked to next task"
)
assert not isinstance(probe_result["err"], PolicyDeniedError), (
"PRIVATE_ONLY leaked into reused worker via missing finally-clear"
)
+119
View File
@@ -0,0 +1,119 @@
"""Escape-hatch (UNPROTECTED scope) tests — ADR-0007 stage C.
UNPROTECTED disables the egress-scope restrictions (the opt-out) but must NOT
lift the hard SSRF / cloud-metadata invariant, and must lift the forced
local-inference requirements.
"""
from __future__ import annotations
from local_deep_research.security.egress.policy import (
EgressContext,
EgressScope,
context_from_snapshot,
evaluate_engine,
evaluate_llm_endpoint,
evaluate_retriever,
evaluate_url,
)
def unprot_ctx(primary: str = "arxiv") -> EgressContext:
return EgressContext(
scope=EgressScope.UNPROTECTED,
primary_engine=primary,
require_local_llm=False,
require_local_embeddings=False,
)
def test_engine_any_allowed_under_unprotected():
# A normally-unclassified/unknown engine is permitted by the escape hatch.
d = evaluate_engine(
"some_unknown_engine", unprot_ctx(), settings_snapshot={}
)
assert d.allowed
assert d.reason == "egress_unprotected"
def test_retriever_allowed_under_unprotected():
assert evaluate_retriever("anything", unprot_ctx()).allowed
def test_url_public_allowed_under_unprotected():
assert evaluate_url("http://example.com/", unprot_ctx()).allowed
def test_metadata_ssrf_still_blocked_under_unprotected():
# The hard invariant survives the escape hatch.
d = evaluate_url("http://169.254.169.254/latest/meta-data/", unprot_ctx())
assert not d.allowed
def test_cloud_llm_allowed_under_unprotected():
# require_local_llm is False under the hatch -> cloud provider allowed.
assert evaluate_llm_endpoint(
"openai", unprot_ctx(), settings_snapshot={}
).allowed
def test_context_unprotected_lifts_local_requirements():
# Even with the require-local flags saved on, UNPROTECTED lifts them.
snap = {
"policy.egress_scope": "unprotected",
"llm.require_local_endpoint": True,
"embeddings.require_local": True,
"search.tool": "arxiv",
}
ctx = context_from_snapshot(snap, "arxiv")
assert ctx.scope is EgressScope.UNPROTECTED
assert ctx.require_local_llm is False
assert ctx.require_local_embeddings is False
def test_unprotected_warning_fires_and_is_non_dismissible():
from local_deep_research.security.egress.warnings import (
check_unprotected_egress,
)
w = check_unprotected_egress("unprotected")
assert w is not None
assert w["type"] == "egress_unprotected"
assert w["dismissKey"] is None # non-dismissible
assert check_unprotected_egress("adaptive") is None
assert check_unprotected_egress("private_only") is None
def test_trusted_destinations_warning():
from local_deep_research.security.egress.warnings import (
check_trusted_destinations,
)
assert check_trusted_destinations([], []) is None
w = check_trusted_destinations(["anthropic"], [])
assert w is not None
assert w["type"] == "egress_trusted_destinations"
assert "anthropic" in w["message"]
# tolerates the JSON-string shape a snapshot may carry
w2 = check_trusted_destinations('["openai"]', "[]")
assert w2 is not None and "openai" in w2["message"]
def test_validate_trusted_search_engines_rejects_public():
from local_deep_research.security.egress.validators import (
validate_trusted_search_engines,
)
# An inherently-public engine cannot be trusted (would launder a sink).
err = validate_trusted_search_engines(
{"policy.trusted_search_engines": ["searxng"]}, {}
)
assert err is not None and "searxng" in err["error"]
# A local-nature store / unknown name is accepted (runtime still gates).
assert (
validate_trusted_search_engines(
{"policy.trusted_search_engines": ["elasticsearch"]}, {}
)
is None
)
assert validate_trusted_search_engines({}, {}) is None
@@ -0,0 +1,312 @@
"""Follow-up edge-case tests for ``evaluate_url`` (egress policy, Stage 1a).
These DEEPEN the existing ``tests/security/test_egress_policy.py`` coverage
without duplicating it. They focus on three properties that the base suite
only touches partially:
1. The metadata invariant ("cloud-metadata is NEVER fetchable, regardless of
scope") across encodings the base suite doesn't exercise: short-form
octal/integer IPv4, uppercase-hex, percent-encoded literals, mixed/upper
case GCP hostnames — all denying with ``blocked_metadata_ip`` under EVERY
scope, while a NORMAL public IP / hostname is NOT over-blocked.
2. The denial-quota accounting: dangerous (javascript/data/file/vbscript/
about) and unsupported (ftp/mailto/...) and ``no_hostname`` denials must
NOT tick ``MAX_DENIED_FETCHES_PER_RUN``, whereas scope-mismatch denials DO
and eventually return ``denial_quota_exceeded``.
3. Percent-encoded private/metadata hosts are decoded BEFORE classification,
so the policy sees the real connect target the HTTP client will reach.
It also pins the per-scope reason codes for ordinary private/public hosts
(``allowed_private_host_under_strict``, ``strict_public_host``,
``allowed_private_host``, ``scope_mismatch_*``) which the base suite asserts
only via the boolean ``allowed`` flag.
The ``make_ctx`` helper is imported from the base suite rather than
re-defined, per the no-duplicated-fixtures rule.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from local_deep_research.security.egress.policy import (
EgressScope,
MAX_DENIED_FETCHES_PER_RUN,
evaluate_url,
)
# Reuse the canonical EgressContext factory (do NOT re-define it here).
from tests.security.test_egress_policy import make_ctx
_ALL_SCOPES = (
EgressScope.STRICT,
EgressScope.PUBLIC_ONLY,
EgressScope.PRIVATE_ONLY,
EgressScope.BOTH,
)
# ---------------------------------------------------------------------------
# Metadata invariant — encodings the base suite does not cover
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("scope", _ALL_SCOPES)
@pytest.mark.parametrize(
"host",
[
# Short-form octal/decimal IPv4 the libc resolver expands to the AWS
# metadata IP (169.254.<16-bit>). inet_aton accepts it; ip_address
# does not, so it must go through the alt-encoding normalizer.
"169.254.43518",
# Uppercase hex 0X prefix — inet_aton accepts it; the base suite only
# tests the lowercase form.
"0XA9FEA9FE",
# Percent-encoded canonical metadata literal: the HTTP client decodes
# the host before connect, so the policy must classify the DECODED IP.
"169%2e254%2e169%2e254",
],
)
def test_alt_and_encoded_metadata_blocked_every_scope(scope, host):
"""Short-form / uppercase-hex / percent-encoded metadata literals all
resolve to 169.254.169.254 at connect time and must deny with
``blocked_metadata_ip`` under EVERY scope — never leaking into the
scope-mismatch bucket and never (under STRICT/PRIVATE_ONLY) being
mistaken for an allowed 'local' link-local host."""
decision = evaluate_url(
f"http://{host}/latest/meta-data/", make_ctx(scope=scope)
)
assert decision.allowed is False, f"{host} allowed under {scope}"
assert decision.reason == "blocked_metadata_ip", (
f"{host} under {scope} -> {decision.reason}"
)
@pytest.mark.parametrize("scope", _ALL_SCOPES)
@pytest.mark.parametrize(
"host",
[
"Metadata.Google.Internal", # mixed case
"METADATA.GOOG", # uppercase short GCP name
"metadata.google.internal.", # trailing dot, lower
"Metadata.Goog.", # mixed case + trailing dot
],
)
def test_gcp_metadata_hostnames_case_insensitive_blocked(scope, host):
"""GCP IMDS hostnames are matched case-insensitively and with the
trailing dot stripped, under every scope. is_ip_blocked cannot see these
(they are not IP literals), so the explicit hostname block must hold."""
decision = evaluate_url(f"http://{host}/", make_ctx(scope=scope))
assert decision.allowed is False, f"{host} allowed under {scope}"
assert decision.reason == "blocked_metadata_ip", (
f"{host} under {scope} -> {decision.reason}"
)
def test_metadata_hostname_match_is_exact_not_substring():
"""The GCP hostname guard must be an EXACT match, not a substring check:
an attacker-controlled host that merely *starts with* the metadata name
(``metadata.google.internal.attacker.example``) resolves to the
attacker's box and must be classified normally — not over-blocked as a
metadata IP (which would mask the real classification) and not allowed
as if it were the real IMDS."""
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False, # attacker host resolves public
):
decision = evaluate_url(
"http://metadata.google.internal.attacker.example/",
make_ctx(scope=EgressScope.PUBLIC_ONLY),
)
assert decision.reason != "blocked_metadata_ip"
assert decision.allowed is True
assert decision.reason == "allowed_public_host"
# ---------------------------------------------------------------------------
# No over-block of ordinary public hosts (allow side of the metadata guard)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"scope,expected_allowed,expected_reason",
[
(EgressScope.PUBLIC_ONLY, True, "allowed_public_host"),
(EgressScope.BOTH, True, "allowed_both_scope"),
(EgressScope.PRIVATE_ONLY, False, "scope_mismatch_private_only"),
(EgressScope.STRICT, False, "strict_public_host"),
],
)
def test_normal_public_ip_reason_codes(
scope, expected_allowed, expected_reason
):
"""A normal public IP (documentation-range 93.184.216.34) is NOT
over-blocked by the metadata/alt-encoding machinery; it gets the correct
per-scope reason code. Literal IPs never trigger network DNS."""
decision = evaluate_url("http://93.184.216.34/", make_ctx(scope=scope))
assert decision.allowed is expected_allowed
assert decision.reason == expected_reason
@pytest.mark.parametrize(
"scope,expected_allowed,expected_reason",
[
(EgressScope.PUBLIC_ONLY, True, "allowed_public_host"),
(EgressScope.STRICT, False, "strict_public_host"),
(EgressScope.PRIVATE_ONLY, False, "scope_mismatch_private_only"),
],
)
def test_normal_public_hostname_reason_codes(
scope, expected_allowed, expected_reason
):
"""A normal public HOSTNAME (DNS path, mocked to resolve public) is not
over-blocked and carries the correct per-scope reason. Exercises the
non-literal classification branch with reason-code assertions the base
suite leaves to the boolean flag."""
with patch(
"local_deep_research.security.egress.policy._classify_host",
return_value=False,
):
decision = evaluate_url(
"https://example.com/page", make_ctx(scope=scope)
)
assert decision.allowed is expected_allowed
assert decision.reason == expected_reason
# ---------------------------------------------------------------------------
# Per-scope reason codes for ordinary PRIVATE hosts
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"scope,expected_allowed,expected_reason",
[
(EgressScope.STRICT, True, "allowed_private_host_under_strict"),
(EgressScope.PRIVATE_ONLY, True, "allowed_private_host"),
(EgressScope.PUBLIC_ONLY, False, "scope_mismatch_public_only"),
(EgressScope.BOTH, True, "allowed_both_scope"),
],
)
def test_normal_private_ip_reason_codes(
scope, expected_allowed, expected_reason
):
"""A normal RFC1918 host (10.0.0.5) is allowed under STRICT/PRIVATE_ONLY/
BOTH with the scope-specific allow reason, and denied under PUBLIC_ONLY —
pinning the reason codes the metadata guard must not clobber."""
decision = evaluate_url("http://10.0.0.5/api", make_ctx(scope=scope))
assert decision.allowed is expected_allowed
assert decision.reason == expected_reason
# ---------------------------------------------------------------------------
# Percent-encoded hosts decoded BEFORE classification
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"scope,expected_allowed,expected_reason",
[
(EgressScope.PRIVATE_ONLY, True, "allowed_private_host"),
(EgressScope.STRICT, True, "allowed_private_host_under_strict"),
(EgressScope.BOTH, True, "allowed_both_scope"),
(EgressScope.PUBLIC_ONLY, False, "scope_mismatch_public_only"),
],
)
def test_percent_encoded_private_host_decoded(
scope, expected_allowed, expected_reason
):
"""``http://192%2e168%2e1%2e1/`` decodes to the private 192.168.1.1 — the
address the HTTP client actually connects to. The policy must classify
the DECODED host (private), not the encoded form (which would fail DNS
and read as public, a PUBLIC_ONLY scope bypass)."""
decision = evaluate_url("http://192%2e168%2e1%2e1/", make_ctx(scope=scope))
assert decision.allowed is expected_allowed
assert decision.reason == expected_reason
# ---------------------------------------------------------------------------
# Denial-quota accounting: which reasons tick the per-run quota
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"url,expected_reason",
[
("javascript:alert(1)", "dangerous_scheme"),
("data:text/html,<b>x</b>", "dangerous_scheme"),
("file:///etc/passwd", "dangerous_scheme"),
("vbscript:msgbox(1)", "dangerous_scheme"),
("about:blank", "dangerous_scheme"),
("ftp://example.com/x", "unsupported_scheme"),
("mailto:a@example.com", "unsupported_scheme"),
("tel:+15551234", "unsupported_scheme"),
("http:///path-only", "no_hostname"),
("", "url_malformed"),
],
)
def test_scheme_and_parse_denial_reason_codes(url, expected_reason):
"""Each non-fetchable scheme / parse failure denies with its specific
machine reason. Breadth over the dangerous + unsupported scheme sets
plus the no-hostname / malformed parse failures."""
decision = evaluate_url(url, make_ctx(scope=EgressScope.PUBLIC_ONLY))
assert decision.allowed is False
assert decision.reason == expected_reason
def test_benign_denials_never_tick_quota_then_legit_url_passes():
"""A document full of dangerous/unsupported/no-hostname hrefs must NOT
exhaust the anti-loop quota: flooding well past MAX_DENIED_FETCHES_PER_RUN
leaves the counter at zero, and a legitimate public URL still passes.
Covers file:/vbscript:/about:/ftp:/no_hostname together (the base suite
only floods javascript:/data: and mailto: individually)."""
ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY)
for _ in range(MAX_DENIED_FETCHES_PER_RUN + 5):
assert not evaluate_url("file:///etc/passwd", ctx).allowed
assert not evaluate_url("ftp://host/x", ctx).allowed
assert not evaluate_url("vbscript:x", ctx).allowed
assert not evaluate_url("about:blank", ctx).allowed
assert not evaluate_url("http:///nohost", ctx).allowed
assert ctx._fetch_denial_count["count"] == 0
# The quota was never consumed, so a real public URL is still allowed.
decision = evaluate_url("http://93.184.216.34/", ctx)
assert decision.allowed is True
assert decision.reason == "allowed_public_host"
def test_scope_mismatch_denials_eventually_exhaust_quota():
"""Scope-mismatch denials ARE security-relevant and DO tick the quota:
exactly MAX_DENIED_FETCHES_PER_RUN private-host denials under PUBLIC_ONLY
are processed normally, then the next call fails closed with
``denial_quota_exceeded``. Uses a literal RFC1918 IP so no DNS fires."""
ctx = make_ctx(scope=EgressScope.PUBLIC_ONLY)
for _ in range(MAX_DENIED_FETCHES_PER_RUN):
d = evaluate_url("http://10.0.0.5/", ctx)
assert d.allowed is False
assert d.reason == "scope_mismatch_public_only"
capped = evaluate_url("http://10.0.0.5/", ctx)
assert capped.allowed is False
assert capped.reason == "denial_quota_exceeded"
def test_metadata_denials_count_toward_quota():
"""A metadata-IP denial is security-relevant (``blocked_metadata_ip`` is
NOT in the non-quota set), so it ticks the counter — preventing an
injected doc from looping the agent on IMDS targets indefinitely."""
ctx = make_ctx(scope=EgressScope.BOTH)
before = ctx._fetch_denial_count["count"]
evaluate_url("http://169.254.169.254/", ctx)
assert ctx._fetch_denial_count["count"] == before + 1
def test_quota_cap_precedes_metadata_check():
"""Once the quota is exhausted, even a metadata URL short-circuits to
``denial_quota_exceeded`` (the quota gate is the first check in
evaluate_url) — the run is uniformly fail-closed past the cap."""
ctx = make_ctx(scope=EgressScope.BOTH)
ctx._fetch_denial_count["count"] = MAX_DENIED_FETCHES_PER_RUN
decision = evaluate_url("http://169.254.169.254/", ctx)
assert decision.allowed is False
assert decision.reason == "denial_quota_exceeded"
+133
View File
@@ -0,0 +1,133 @@
"""
Tests for the check-env-vars pre-commit hook.
Ensures the hook enforces centralized configuration through SettingsManager
instead of direct os.environ access, which prevents hardcoded secrets and
ensures consistent config management.
"""
import ast
import sys
from importlib import import_module
from pathlib import Path
HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks"
sys.path.insert(0, str(HOOKS_DIR))
hook_module = import_module("check-env-vars")
EnvVarChecker = hook_module.EnvVarChecker
def _check_code(
code: str, filename: str = "src/local_deep_research/module.py"
) -> list:
"""Parse code and run the env var checker."""
tree = ast.parse(code)
checker = EnvVarChecker(filename)
checker.visit(tree)
return checker.errors
class TestDetectsDirectEnvAccess:
"""Ensures os.environ usage is flagged in application code."""
def test_detects_os_environ_get(self):
code = 'import os\nval = os.environ.get("LDR_SECRET_KEY")\n'
errors = _check_code(code)
assert len(errors) >= 1
assert any("SettingsManager" in e[1] for e in errors)
def test_detects_os_environ_bracket(self):
code = 'import os\nval = os.environ["LDR_DATA_DIR"]\n'
errors = _check_code(code)
assert len(errors) >= 1
def test_detects_os_getenv(self):
code = 'import os\nval = os.getenv("LDR_API_KEY")\n'
errors = _check_code(code)
assert len(errors) >= 1
def test_detects_from_os_import_environ(self):
code = "from os import environ\n"
errors = _check_code(code)
assert len(errors) >= 1
def test_detects_environ_alias(self):
code = "import os\nenv = os.environ\n"
errors = _check_code(code)
assert len(errors) >= 1
def test_detects_in_environ_check(self):
code = 'import os\nif "LDR_KEY" in os.environ:\n pass\n'
errors = _check_code(code)
assert len(errors) >= 1
class TestAllowsSystemVars:
"""Ensures standard system env vars are not flagged."""
def test_allows_path(self):
code = 'import os\npath = os.environ.get("PATH")\n'
errors = _check_code(code)
assert len(errors) == 0
def test_allows_home(self):
code = 'import os\nhome = os.environ.get("HOME")\n'
errors = _check_code(code)
assert len(errors) == 0
def test_allows_ci(self):
code = 'import os\nis_ci = os.environ.get("CI")\n'
errors = _check_code(code)
assert len(errors) == 0
def test_allows_pytest_current_test(self):
code = 'import os\ntest = os.environ.get("PYTEST_CURRENT_TEST")\n'
errors = _check_code(code)
assert len(errors) == 0
class TestAllowsExemptFiles:
"""Ensures settings/config/test files are exempt."""
def test_allows_settings_files(self):
code = 'import os\nval = os.environ.get("LDR_KEY")\n'
errors = _check_code(code, filename="src/settings/manager.py")
assert len(errors) == 0
def test_allows_config_files(self):
code = 'import os\nval = os.environ.get("LDR_KEY")\n'
errors = _check_code(code, filename="src/config/paths.py")
assert len(errors) == 0
def test_allows_test_files(self):
code = 'import os\nval = os.environ.get("LDR_KEY")\n'
errors = _check_code(code, filename="tests/test_something.py")
assert len(errors) == 0
def test_allows_migration_files(self):
code = 'import os\nval = os.environ.get("LDR_KEY")\n'
errors = _check_code(code, filename="migrations/env.py")
assert len(errors) == 0
class TestAllowsSafePatterns:
"""Ensures normal code without env access is not flagged."""
def test_allows_normal_code(self):
code = """
def process_data(data):
return data.upper()
"""
errors = _check_code(code)
assert len(errors) == 0
def test_allows_settings_manager_usage(self):
code = """
from settings.manager import SettingsManager
settings = SettingsManager()
val = settings.get_setting("my_key")
"""
errors = _check_code(code)
assert len(errors) == 0
@@ -0,0 +1,266 @@
"""Regression tests for in-scope API-key redaction in factory exception logs.
A third-party review of issue #4183 found that ``create_search_engine`` and
``_create_full_search_wrapper`` called ``redact_secrets(str(e))`` with NO
secret arguments. ``redact_secrets`` does literal-substring replacement, so
with an empty secret list it's a no-op: an exception message echoing the
API key (e.g. a constructor validation error) survived into logs verbatim.
The fix passes the in-scope key(s) — ``api_key`` in ``create_search_engine``,
and ``api_key`` / ``serpapi_api_key`` from ``wrapper_params`` in
``_create_full_search_wrapper`` — and wraps with ``sanitize_error_message``
for the shape-based pass too.
These tests use a credential with NO distinctive prefix (not ``sk-``, not
``AIza``, etc.) so that ``sanitize_error_message``'s regexes won't catch it
on shape alone — proving the literal-substring pass is what closes the leak.
The factory logs these failures via the diagnose-gated
``security.secure_logging`` wrapper at ERROR level; the levelname
assertions below guard against a re-downgrade to WARNING.
"""
from unittest.mock import Mock, patch
import pytest
from local_deep_research.web_search_engines.search_engine_factory import (
_create_full_search_wrapper,
)
FACTORY_MODULE = "local_deep_research.web_search_engines.search_engine_factory"
# 24 chars, no sk-/pk-/AIza/ghp_ prefix — survives sanitize_error_message,
# so only the literal redact_secrets(..., api_key) pass catches it.
_BARE_KEY = "test-brave-key-XYZ789"
class _RaisingWrapperBrave:
"""Mock full-search wrapper whose init echoes the brave api_key."""
def __init__(self, api_key=None, **kwargs):
raise ValueError(f"Brave wrapper rejected key: {api_key}")
class _RaisingWrapperSerpAPI:
"""Mock full-search wrapper whose init echoes the serpapi key."""
def __init__(self, serpapi_api_key=None, **kwargs):
raise ValueError(f"SerpAPI wrapper rejected key: {serpapi_api_key}")
@pytest.fixture(autouse=True)
def _bypass_engine_pdp():
"""Bypass the egress PEP so mock engine names aren't rejected.
The PEP itself is exercised in tests/security/test_egress_policy.py.
"""
from local_deep_research.security.egress.policy import Decision
with (
patch(
"local_deep_research.security.egress.policy.evaluate_engine",
return_value=Decision(True, "test_bypass"),
),
patch(
"local_deep_research.security.egress.policy.evaluate_retriever",
return_value=Decision(True, "test_bypass"),
),
):
yield
class TestCreateSearchEngineRedactsApiKey:
def test_constructor_exception_does_not_leak_api_key(self, loguru_caplog):
"""``api_key`` resolved from settings must be literal-redacted."""
from local_deep_research.web_search_engines.search_engine_factory import (
create_search_engine,
)
class _RaisingEngine:
def __init__(self, api_key=None, **kwargs):
raise ValueError(f"Bad key received: {api_key}")
settings_snapshot = {
"search.engine.web.test_engine.api_key": {"value": _BARE_KEY}
}
with (
patch(f"{FACTORY_MODULE}.retriever_registry") as mock_registry,
patch(f"{FACTORY_MODULE}.search_config") as mock_config,
patch(
f"{FACTORY_MODULE}.get_safe_module_class",
return_value=_RaisingEngine,
),
):
mock_registry.get.return_value = None
mock_config.return_value = {
"test_engine": {
"module_path": ".engines.test",
"class_name": "TestEngine",
"requires_api_key": True,
}
}
with loguru_caplog.at_level("ERROR"):
result = create_search_engine(
engine_name="test_engine",
settings_snapshot=settings_snapshot,
)
assert result is None
assert _BARE_KEY not in loguru_caplog.text
assert "Bad key received" in loguru_caplog.text
assert "***REDACTED***" in loguru_caplog.text
# Regression guard: the factory failure must stay at ERROR, not be
# re-downgraded to WARNING. Filter by message so an unrelated
# record can't satisfy (or break) the assertion.
matching = [
r
for r in loguru_caplog.records
if "Bad key received" in r.getMessage()
]
assert matching
assert all(r.levelname == "ERROR" for r in matching)
class TestCreateFullSearchWrapperRedactsKeys:
def test_brave_wrapper_exception_does_not_leak_api_key(self, loguru_caplog):
base_engine = Mock()
engine_config = {
"full_search_module": ".engines.full_search",
"full_search_class": "FullSearchResults",
}
settings_snapshot = {
"search.engine.web.brave.api_key": {"value": _BARE_KEY}
}
with patch(
f"{FACTORY_MODULE}.get_safe_module_class",
return_value=_RaisingWrapperBrave,
):
with loguru_caplog.at_level("ERROR"):
result = _create_full_search_wrapper(
"brave",
base_engine,
engine_config,
Mock(),
{},
settings_snapshot=settings_snapshot,
)
assert result is base_engine
assert _BARE_KEY not in loguru_caplog.text
assert "Brave wrapper rejected key" in loguru_caplog.text
assert "***REDACTED***" in loguru_caplog.text
matching = [
r
for r in loguru_caplog.records
if "Brave wrapper rejected key" in r.getMessage()
]
assert matching
assert all(r.levelname == "ERROR" for r in matching)
def test_serpapi_wrapper_exception_does_not_leak_key(self, loguru_caplog):
base_engine = Mock()
engine_config = {
"full_search_module": ".engines.full_search",
"full_search_class": "FullSearchResults",
}
settings_snapshot = {
"search.engine.web.serpapi.api_key": {"value": _BARE_KEY}
}
with patch(
f"{FACTORY_MODULE}.get_safe_module_class",
return_value=_RaisingWrapperSerpAPI,
):
with loguru_caplog.at_level("ERROR"):
result = _create_full_search_wrapper(
"serpapi",
base_engine,
engine_config,
Mock(),
{},
settings_snapshot=settings_snapshot,
)
assert result is base_engine
assert _BARE_KEY not in loguru_caplog.text
assert "SerpAPI wrapper rejected key" in loguru_caplog.text
assert "***REDACTED***" in loguru_caplog.text
matching = [
r
for r in loguru_caplog.records
if "SerpAPI wrapper rejected key" in r.getMessage()
]
assert matching
assert all(r.levelname == "ERROR" for r in matching)
class TestHoistedApiKeyHandlesNoKeyRequiredEngines:
def test_no_key_required_engine_exception_does_not_nameerror(
self, loguru_caplog
):
"""``api_key = None`` hoist must keep the scrub call safe on the
no-key path.
Before the hoist, ``api_key`` was only bound inside
``if requires_api_key:``. The except handler at the end of
``create_search_engine`` references ``api_key`` unconditionally —
so a constructor exception for a ``requires_api_key=False`` engine
would have raised ``NameError`` instead of logging the fallback
error. This test pins the hoist: the error log fires and the
engine returns ``None``.
"""
class _RaisingEngineNoKey:
def __init__(self, **kwargs):
raise RuntimeError("engine init blew up")
from local_deep_research.web_search_engines.search_engine_factory import (
create_search_engine,
)
# Non-empty snapshot — the factory rejects empty/falsy snapshots in
# thread context. The engine_config mock below is what actually
# drives the requires_api_key=False path; the snapshot just needs to
# be truthy to clear the thread-context guard.
settings_snapshot = {"_test_marker": True}
with (
patch(f"{FACTORY_MODULE}.retriever_registry") as mock_registry,
patch(f"{FACTORY_MODULE}.search_config") as mock_config,
patch(
f"{FACTORY_MODULE}.get_safe_module_class",
return_value=_RaisingEngineNoKey,
),
):
mock_registry.get.return_value = None
mock_config.return_value = {
"test_engine_no_key": {
"module_path": ".engines.test",
"class_name": "TestEngineNoKey",
"requires_api_key": False,
}
}
with loguru_caplog.at_level("ERROR"):
result = create_search_engine(
engine_name="test_engine_no_key",
settings_snapshot=settings_snapshot,
)
assert result is None
# The error log fired (no NameError swallowed it) and carries the
# exception message — tie them together so the assertion can't
# false-pass on an unrelated ERROR line.
assert any(
"Failed to create search engine" in line
and "engine init blew up" in line
for line in loguru_caplog.text.splitlines()
)
matching = [
r
for r in loguru_caplog.records
if "engine init blew up" in r.getMessage()
]
assert matching
assert all(r.levelname == "ERROR" for r in matching)
@@ -0,0 +1,986 @@
"""
Comprehensive tests for FileIntegrityManager to increase coverage.
Tests all public and internal methods with mocked DB sessions and verifiers.
"""
import pytest
from unittest.mock import patch, MagicMock
from pathlib import Path
from datetime import datetime, UTC
from contextlib import contextmanager
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_mock_session():
"""Create a mock DB session with chainable query interface."""
session = MagicMock()
return session
@contextmanager
def _mock_session_cm(session):
"""Context manager wrapper for mock session."""
yield session
def _make_verifier(
should=True,
file_type="faiss_index",
algorithm="sha256",
checksum="abc123",
allows_mod=False,
):
"""Create a mock verifier with configurable behavior."""
v = MagicMock()
v.should_verify.return_value = should
v.get_file_type.return_value = file_type
v.get_algorithm.return_value = algorithm
v.calculate_checksum.return_value = checksum
v.allows_modifications.return_value = allows_mod
return v
def _make_record(**overrides):
"""Create a mock FileIntegrityRecord with sensible defaults."""
defaults = dict(
id=1,
file_path="/resolved/path",
file_type="faiss_index",
checksum="abc123",
algorithm="sha256",
file_size=1024,
file_mtime=1000.0,
verify_on_load=True,
allow_modifications=False,
total_verifications=5,
last_verified_at=datetime(2025, 1, 1, tzinfo=UTC),
last_verification_passed=True,
consecutive_successes=3,
consecutive_failures=0,
created_at=datetime(2025, 1, 1, tzinfo=UTC),
updated_at=None,
related_entity_type=None,
related_entity_id=None,
)
defaults.update(overrides)
record = MagicMock()
for k, v in defaults.items():
setattr(record, k, v)
return record
# Patch path for the session context used inside integrity_manager module
_SESSION_PATCH = (
"local_deep_research.security.file_integrity.integrity_manager"
".get_user_db_session"
)
_HAS_CTX_PATCH = (
"local_deep_research.security.file_integrity.integrity_manager"
"._has_session_context"
)
@pytest.fixture
def integrity_manager():
"""
Build a FileIntegrityManager with mocked session context.
Yields (manager, session). Patcher is automatically stopped on teardown.
"""
session = _make_mock_session()
with patch(_HAS_CTX_PATCH, True), patch(_SESSION_PATCH) as mock_get_session:
mock_get_session.return_value = _mock_session_cm(session)
# cleanup_all_old_failures queries count then returns
session.query.return_value.count.return_value = 0
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
mgr = FileIntegrityManager(username="testuser", password="testpass")
# Re-patch for subsequent calls
patcher = patch(
_SESSION_PATCH, side_effect=lambda u, p: _mock_session_cm(session)
)
patcher.start()
mgr._test_session = session
yield mgr, session
patcher.stop()
def _build_manager(session=None):
"""
Build a FileIntegrityManager with mocked session context.
Returns (manager, session). Caller MUST call mgr._test_patcher.stop()
or use the integrity_manager fixture instead.
NOTE: Prefer the integrity_manager fixture for new tests.
This helper is kept for TestInit tests that need custom session setup.
"""
if session is None:
session = _make_mock_session()
with patch(_HAS_CTX_PATCH, True), patch(_SESSION_PATCH) as mock_get_session:
mock_get_session.return_value = _mock_session_cm(session)
# cleanup_all_old_failures queries count then returns
session.query.return_value.count.return_value = 0
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
mgr = FileIntegrityManager(username="testuser", password="testpass")
# Re-patch for subsequent calls
patcher = patch(
_SESSION_PATCH, side_effect=lambda u, p: _mock_session_cm(session)
)
patcher.start()
mgr._test_patcher = patcher
mgr._test_session = session
return mgr, session
# ---------------------------------------------------------------------------
# Tests: __init__
# ---------------------------------------------------------------------------
class TestInit:
def test_raises_import_error_when_no_session_context(self):
with patch(_HAS_CTX_PATCH, False):
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
with pytest.raises(ImportError, match="requires Flask"):
FileIntegrityManager(username="user")
def test_successful_init_sets_attributes(self):
mgr, _session = _build_manager()
assert mgr.username == "testuser"
assert mgr.password == "testpass"
assert mgr.verifiers == []
mgr._test_patcher.stop()
def test_startup_cleanup_logs_when_deleted(self):
session = _make_mock_session()
# First call to cleanup_all_old_failures: count returns > MAX
with (
patch(_HAS_CTX_PATCH, True),
patch(_SESSION_PATCH) as mock_get_session,
):
mock_get_session.return_value = _mock_session_cm(session)
# total_failures > MAX_TOTAL_FAILURES to trigger deletion
session.query.return_value.count.return_value = 10005
session.query.return_value.order_by.return_value.limit.return_value.all.return_value = [
MagicMock() for _ in range(5)
]
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
mgr = FileIntegrityManager(username="user")
mgr._test_patcher = patch(_SESSION_PATCH) # dummy
# No assertion needed beyond not raising
def test_startup_cleanup_handles_exception(self):
"""If startup cleanup raises, __init__ should still succeed."""
with (
patch(_HAS_CTX_PATCH, True),
patch(_SESSION_PATCH) as mock_get_session,
):
mock_get_session.side_effect = RuntimeError("db down")
from local_deep_research.security.file_integrity.integrity_manager import (
FileIntegrityManager,
)
# Should not raise thanks to try/except in __init__
mgr = FileIntegrityManager(username="user")
assert mgr.username == "user"
# ---------------------------------------------------------------------------
# Tests: _normalize_path
# ---------------------------------------------------------------------------
class TestNormalizePath:
def test_returns_resolved_string(self, integrity_manager):
mgr, _ = integrity_manager
p = Path("/some/../some/file.txt")
result = mgr._normalize_path(p)
assert result == str(p.resolve())
assert isinstance(result, str)
def test_relative_path_becomes_absolute(self, integrity_manager):
mgr, _ = integrity_manager
p = Path("relative/file.txt")
result = mgr._normalize_path(p)
assert Path(result).is_absolute()
# ---------------------------------------------------------------------------
# Tests: register_verifier
# ---------------------------------------------------------------------------
class TestRegisterVerifier:
def test_appends_verifier(self, integrity_manager):
mgr, _ = integrity_manager
v1 = _make_verifier()
v2 = _make_verifier(file_type="pdf")
mgr.register_verifier(v1)
mgr.register_verifier(v2)
assert mgr.verifiers == [v1, v2]
# ---------------------------------------------------------------------------
# Tests: _get_verifier_for_file
# ---------------------------------------------------------------------------
class TestGetVerifierForFile:
def test_returns_matching_verifier(self, integrity_manager):
mgr, _ = integrity_manager
v1 = _make_verifier(should=False)
v2 = _make_verifier(should=True, file_type="pdf")
mgr.register_verifier(v1)
mgr.register_verifier(v2)
result = mgr._get_verifier_for_file(Path("/some/file.pdf"))
assert result is v2
def test_returns_first_matching(self, integrity_manager):
mgr, _ = integrity_manager
v1 = _make_verifier(should=True, file_type="faiss")
v2 = _make_verifier(should=True, file_type="pdf")
mgr.register_verifier(v1)
mgr.register_verifier(v2)
result = mgr._get_verifier_for_file(Path("/file"))
assert result is v1
def test_returns_none_when_no_match(self, integrity_manager):
mgr, _ = integrity_manager
v1 = _make_verifier(should=False)
mgr.register_verifier(v1)
result = mgr._get_verifier_for_file(Path("/file"))
assert result is None
def test_returns_none_with_no_verifiers(self, integrity_manager):
mgr, _ = integrity_manager
result = mgr._get_verifier_for_file(Path("/file"))
assert result is None
# ---------------------------------------------------------------------------
# Tests: _needs_verification
# ---------------------------------------------------------------------------
class TestNeedsVerification:
def test_file_missing_returns_true(self, integrity_manager):
mgr, _ = integrity_manager
record = _make_record()
path = MagicMock(spec=Path)
path.exists.return_value = False
assert mgr._needs_verification(record, path) is True
def test_never_verified_returns_true(self, integrity_manager):
mgr, _ = integrity_manager
record = _make_record(last_verified_at=None)
path = MagicMock(spec=Path)
path.exists.return_value = True
assert mgr._needs_verification(record, path) is True
def test_no_stored_mtime_returns_true(self, integrity_manager):
mgr, _ = integrity_manager
record = _make_record(file_mtime=None)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_mtime = 1000.0
assert mgr._needs_verification(record, path) is True
def test_mtime_changed_returns_true(self, integrity_manager):
mgr, _ = integrity_manager
record = _make_record(file_mtime=1000.0)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_mtime = 2000.0 # clearly different
assert mgr._needs_verification(record, path) is True
def test_mtime_unchanged_returns_false(self, integrity_manager):
mgr, _ = integrity_manager
record = _make_record(file_mtime=1000.0)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_mtime = 1000.0
assert mgr._needs_verification(record, path) is False
def test_mtime_tiny_float_difference_returns_false(self, integrity_manager):
"""Differences within 0.001 threshold should not trigger verification."""
mgr, _ = integrity_manager
record = _make_record(file_mtime=1000.0)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_mtime = 1000.0005 # within threshold
assert mgr._needs_verification(record, path) is False
# ---------------------------------------------------------------------------
# Tests: _do_verification
# ---------------------------------------------------------------------------
class TestDoVerification:
def test_file_missing(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record()
path = MagicMock(spec=Path)
path.exists.return_value = False
passed, reason = mgr._do_verification(record, path, session)
assert passed is False
assert reason == "file_missing"
def test_no_verifier(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record()
path = MagicMock(spec=Path)
path.exists.return_value = True
# No verifiers registered
passed, reason = mgr._do_verification(record, path, session)
assert passed is False
assert reason == "no_verifier"
def test_checksum_match(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="abc123")
mgr.register_verifier(v)
record = _make_record(checksum="abc123")
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_mtime = 1234.0
passed, reason = mgr._do_verification(record, path, session)
assert passed is True
assert reason is None
# mtime should be updated on record
assert record.file_mtime == 1234.0
def test_checksum_mismatch(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="different_checksum")
mgr.register_verifier(v)
record = _make_record(checksum="abc123")
path = MagicMock(spec=Path)
path.exists.return_value = True
passed, reason = mgr._do_verification(record, path, session)
assert passed is False
assert reason == "checksum_mismatch"
def test_checksum_calculation_exception(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier()
v.calculate_checksum.side_effect = IOError("read error")
mgr.register_verifier(v)
record = _make_record()
path = MagicMock(spec=Path)
path.exists.return_value = True
passed, reason = mgr._do_verification(record, path, session)
assert passed is False
assert "checksum_calculation_failed" in reason
assert "read error" in reason
# ---------------------------------------------------------------------------
# Tests: _update_stats
# ---------------------------------------------------------------------------
class TestUpdateStats:
def test_passed(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(
total_verifications=5,
consecutive_successes=2,
consecutive_failures=1,
)
mgr._update_stats(record, True, session)
assert record.total_verifications == 6
assert record.consecutive_successes == 3
assert record.consecutive_failures == 0
assert record.last_verification_passed is True
assert record.last_verified_at is not None
def test_failed(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(
total_verifications=10,
consecutive_successes=5,
consecutive_failures=0,
)
mgr._update_stats(record, False, session)
assert record.total_verifications == 11
assert record.consecutive_successes == 0
assert record.consecutive_failures == 1
assert record.last_verification_passed is False
# ---------------------------------------------------------------------------
# Tests: _log_failure
# ---------------------------------------------------------------------------
class TestLogFailure:
def test_logs_with_existing_file(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="actual_cs")
mgr.register_verifier(v)
record = _make_record(id=1, checksum="expected_cs")
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_size = 2048
# Setup session for cleanup queries
session.query.return_value.filter_by.return_value.count.return_value = 0
mgr._log_failure(record, path, "checksum_mismatch", session)
session.add.assert_called_once()
failure_obj = session.add.call_args[0][0]
assert failure_obj.expected_checksum == "expected_cs"
assert failure_obj.actual_checksum == "actual_cs"
assert failure_obj.file_size == 2048
assert failure_obj.failure_reason == "checksum_mismatch"
def test_logs_with_missing_file(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(id=1, checksum="expected_cs")
path = MagicMock(spec=Path)
path.exists.return_value = False
session.query.return_value.filter_by.return_value.count.return_value = 0
mgr._log_failure(record, path, "file_missing", session)
session.add.assert_called_once()
failure_obj = session.add.call_args[0][0]
assert failure_obj.actual_checksum is None
assert failure_obj.file_size is None
assert failure_obj.failure_reason == "file_missing"
def test_logs_with_verifier_exception(self, integrity_manager):
"""If verifier.calculate_checksum raises, actual_checksum stays None."""
mgr, session = integrity_manager
v = _make_verifier()
v.calculate_checksum.side_effect = IOError("cannot read")
mgr.register_verifier(v)
record = _make_record(id=1)
path = MagicMock(spec=Path)
path.exists.return_value = True
session.query.return_value.filter_by.return_value.count.return_value = 0
mgr._log_failure(record, path, "error", session)
failure_obj = session.add.call_args[0][0]
assert failure_obj.actual_checksum is None
def test_triggers_global_cleanup_on_id_mod_100(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(id=200) # 200 % 100 == 0
path = MagicMock(spec=Path)
path.exists.return_value = False
# Per-file cleanup: count under limit
session.query.return_value.filter_by.return_value.count.return_value = 0
# Global cleanup: count under threshold
session.query.return_value.count.return_value = 5000
mgr._log_failure(record, path, "test", session)
# count() was called for both per-file and global checks
assert (
session.query.return_value.count.called
or session.query.return_value.filter_by.return_value.count.called
)
# ---------------------------------------------------------------------------
# Tests: _cleanup_old_failures
# ---------------------------------------------------------------------------
class TestCleanupOldFailures:
def test_no_cleanup_when_under_limit(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(id=1)
session.query.return_value.filter_by.return_value.count.return_value = (
50
)
mgr._cleanup_old_failures(record, session)
# Should not attempt to delete
session.delete.assert_not_called()
def test_cleanup_when_over_limit(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(id=1)
session.query.return_value.filter_by.return_value.count.return_value = (
110
)
old_failures = [MagicMock() for _ in range(10)]
session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures
mgr._cleanup_old_failures(record, session)
assert session.delete.call_count == 10
def test_cleanup_at_exactly_limit(self, integrity_manager):
"""Exactly at MAX_FAILURES_PER_FILE should not trigger cleanup."""
mgr, session = integrity_manager
record = _make_record(id=1)
session.query.return_value.filter_by.return_value.count.return_value = (
100
)
mgr._cleanup_old_failures(record, session)
session.delete.assert_not_called()
# ---------------------------------------------------------------------------
# Tests: _check_global_cleanup_needed
# ---------------------------------------------------------------------------
class TestCheckGlobalCleanupNeeded:
def test_no_cleanup_under_threshold(self, integrity_manager):
mgr, session = integrity_manager
# threshold = 10000 * 1.2 = 12000
session.query.return_value.count.return_value = 11000
mgr._check_global_cleanup_needed(session)
session.delete.assert_not_called()
def test_cleanup_over_threshold(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.count.return_value = 13000 # > 12000
old_failures = [MagicMock() for _ in range(3000)] # 13000 - 10000
session.query.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures
mgr._check_global_cleanup_needed(session)
assert session.delete.call_count == 3000
def test_exactly_at_threshold_no_cleanup(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.count.return_value = 12000 # == threshold
mgr._check_global_cleanup_needed(session)
session.delete.assert_not_called()
# ---------------------------------------------------------------------------
# Tests: record_file
# ---------------------------------------------------------------------------
class TestRecordFile:
def test_new_file(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(
checksum="newcs", file_type="faiss_index", algorithm="sha256"
)
mgr.register_verifier(v)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/new_file")
path.stat.return_value.st_size = 4096
path.stat.return_value.st_mtime = 2000.0
# No existing record
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
mgr.record_file(
path, related_entity_type="rag_index", related_entity_id=42
)
session.add.assert_called_once()
session.commit.assert_called_once()
session.refresh.assert_called_once()
def test_existing_file_updates(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="updatedcs")
mgr.register_verifier(v)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/existing")
path.stat.return_value.st_size = 8192
path.stat.return_value.st_mtime = 3000.0
existing_record = _make_record()
session.query.return_value.filter_by.return_value.first.return_value = (
existing_record
)
mgr.record_file(path)
assert existing_record.checksum == "updatedcs"
assert existing_record.file_size == 8192
assert existing_record.file_mtime == 3000.0
session.commit.assert_called_once()
# session.add should NOT be called for updates
session.add.assert_not_called()
def test_file_not_found(self, integrity_manager):
mgr, _ = integrity_manager
path = MagicMock(spec=Path)
path.exists.return_value = False
with pytest.raises(FileNotFoundError, match="File not found"):
mgr.record_file(path)
def test_no_verifier(self, integrity_manager):
mgr, _ = integrity_manager
path = MagicMock(spec=Path)
path.exists.return_value = True
# No verifiers registered
with pytest.raises(ValueError, match="No verifier registered"):
mgr.record_file(path)
# ---------------------------------------------------------------------------
# Tests: verify_file
# ---------------------------------------------------------------------------
class TestVerifyFile:
def test_no_record_rejects_file(self, integrity_manager):
# Reject-unknown: a file with no integrity record must fail closed
# rather than being auto-registered (trust-on-first-use gap). The
# security property is that record_file is NOT called on this path.
mgr, session = integrity_manager
v = _make_verifier(checksum="cs")
mgr.register_verifier(v)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/path")
path.stat.return_value.st_size = 100
path.stat.return_value.st_mtime = 500.0
# No record found -> must be rejected, not auto-created
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
with patch.object(mgr, "record_file") as mock_record:
passed, reason = mgr.verify_file(path)
assert passed is False
assert reason == "no_integrity_record"
mock_record.assert_not_called()
def test_no_record_rejects_even_without_verifier(self, integrity_manager):
# Rejection happens before any verifier lookup, so a missing record
# is rejected identically whether or not a verifier is registered.
mgr, session = integrity_manager
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/path")
# No record found, no verifiers registered
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
with patch.object(mgr, "record_file") as mock_record:
passed, reason = mgr.verify_file(path)
assert passed is False
assert reason == "no_integrity_record"
mock_record.assert_not_called()
def test_skips_if_unchanged(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(file_mtime=1000.0)
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/path")
path.stat.return_value.st_mtime = 1000.0 # unchanged
passed, reason = mgr.verify_file(path)
assert passed is True
assert reason is None
def test_verify_passes(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="abc123")
mgr.register_verifier(v)
record = _make_record(
checksum="abc123",
file_mtime=1000.0,
total_verifications=5,
consecutive_successes=2,
consecutive_failures=0,
)
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/path")
path.stat.return_value.st_mtime = (
2000.0 # changed, triggers verification
)
passed, reason = mgr.verify_file(path, force=True)
assert passed is True
assert reason is None
session.commit.assert_called()
def test_verify_fails(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="wrong")
mgr.register_verifier(v)
record = _make_record(
id=1,
checksum="abc123",
file_mtime=1000.0,
total_verifications=5,
consecutive_successes=2,
consecutive_failures=0,
)
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
# Per-file cleanup count
session.query.return_value.filter_by.return_value.count.return_value = 0
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/path")
path.stat.return_value.st_mtime = 2000.0
path.stat.return_value.st_size = 1024
passed, reason = mgr.verify_file(path, force=True)
assert passed is False
assert reason == "checksum_mismatch"
session.commit.assert_called()
def test_force_overrides_skip(self, integrity_manager):
"""force=True should verify even when mtime unchanged."""
mgr, session = integrity_manager
v = _make_verifier(checksum="abc123")
mgr.register_verifier(v)
record = _make_record(checksum="abc123", file_mtime=1000.0)
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.resolve.return_value = Path("/resolved/path")
path.stat.return_value.st_mtime = 1000.0 # unchanged
passed, reason = mgr.verify_file(path, force=True)
assert passed is True
# Should have called _do_verification (verifier was used)
v.calculate_checksum.assert_called_once()
# ---------------------------------------------------------------------------
# Tests: update_checksum
# ---------------------------------------------------------------------------
class TestUpdateChecksum:
def test_success(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="newcs")
mgr.register_verifier(v)
record = _make_record()
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_size = 5000
path.stat.return_value.st_mtime = 9000.0
mgr.update_checksum(path)
assert record.checksum == "newcs"
assert record.file_size == 5000
assert record.file_mtime == 9000.0
session.commit.assert_called_once()
def test_file_not_found(self, integrity_manager):
mgr, _ = integrity_manager
path = MagicMock(spec=Path)
path.exists.return_value = False
with pytest.raises(FileNotFoundError):
mgr.update_checksum(path)
def test_no_verifier(self, integrity_manager):
mgr, _ = integrity_manager
path = MagicMock(spec=Path)
path.exists.return_value = True
with pytest.raises(ValueError, match="No verifier registered"):
mgr.update_checksum(path)
def test_no_record(self, integrity_manager):
mgr, session = integrity_manager
v = _make_verifier(checksum="cs")
mgr.register_verifier(v)
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
path = MagicMock(spec=Path)
path.exists.return_value = True
path.stat.return_value.st_size = 100
path.stat.return_value.st_mtime = 100.0
with pytest.raises(ValueError, match="No integrity record exists"):
mgr.update_checksum(path)
# ---------------------------------------------------------------------------
# Tests: get_file_stats
# ---------------------------------------------------------------------------
class TestGetFileStats:
def test_with_record(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(
total_verifications=10,
last_verified_at=datetime(2025, 6, 1, tzinfo=UTC),
last_verification_passed=True,
consecutive_successes=5,
consecutive_failures=0,
file_type="faiss_index",
created_at=datetime(2025, 1, 1, tzinfo=UTC),
)
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
result = mgr.get_file_stats(Path("/some/file"))
assert result is not None
assert result["total_verifications"] == 10
assert result["consecutive_successes"] == 5
assert result["consecutive_failures"] == 0
assert result["file_type"] == "faiss_index"
assert result["last_verification_passed"] is True
def test_without_record(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
result = mgr.get_file_stats(Path("/nonexistent"))
assert result is None
# ---------------------------------------------------------------------------
# Tests: get_failure_history
# ---------------------------------------------------------------------------
class TestGetFailureHistory:
def test_with_records(self, integrity_manager):
mgr, session = integrity_manager
record = _make_record(id=1)
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
failures = [MagicMock(), MagicMock()]
session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = failures
result = mgr.get_failure_history(Path("/some/file"), limit=50)
assert len(result) == 2
# Each failure should be expunged from session
assert session.expunge.call_count == 2
def test_without_record(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.filter_by.return_value.first.return_value = (
None
)
result = mgr.get_failure_history(Path("/no/record"))
assert result == []
def test_default_limit(self, integrity_manager):
"""Default limit should be 100."""
mgr, session = integrity_manager
record = _make_record(id=1)
session.query.return_value.filter_by.return_value.first.return_value = (
record
)
session.query.return_value.filter_by.return_value.order_by.return_value.limit.return_value.all.return_value = []
mgr.get_failure_history(Path("/file"))
# Verify limit was called via the chain
session.query.return_value.filter_by.return_value.order_by.return_value.limit.assert_called()
# ---------------------------------------------------------------------------
# Tests: cleanup_all_old_failures
# ---------------------------------------------------------------------------
class TestCleanupAllOldFailures:
def test_under_limit(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.count.return_value = 5000 # under 10000
result = mgr.cleanup_all_old_failures()
assert result == 0
session.delete.assert_not_called()
def test_over_limit(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.count.return_value = 10500
old_failures = [MagicMock() for _ in range(500)]
session.query.return_value.order_by.return_value.limit.return_value.all.return_value = old_failures
result = mgr.cleanup_all_old_failures()
assert result == 500
assert session.delete.call_count == 500
session.commit.assert_called()
def test_exactly_at_limit(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.count.return_value = 10000
result = mgr.cleanup_all_old_failures()
assert result == 0
# ---------------------------------------------------------------------------
# Tests: get_total_failure_count
# ---------------------------------------------------------------------------
class TestGetTotalFailureCount:
def test_returns_count(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.count.return_value = 42
result = mgr.get_total_failure_count()
assert result == 42
def test_returns_zero(self, integrity_manager):
mgr, session = integrity_manager
session.query.return_value.count.return_value = 0
result = mgr.get_total_failure_count()
assert result == 0
@@ -0,0 +1,466 @@
"""
Tests for FileUploadValidator security module.
"""
from unittest.mock import Mock, patch
from local_deep_research.security.file_upload_validator import (
FileUploadValidator,
)
class TestFileUploadValidatorConstants:
"""Tests for FileUploadValidator constants."""
def test_max_file_size_defined(self):
"""MAX_FILE_SIZE is defined and matches the 3 GB server-config default."""
assert FileUploadValidator.MAX_FILE_SIZE > 0
assert FileUploadValidator.MAX_FILE_SIZE == 3 * 1024 * 1024 * 1024
def test_max_files_per_request_defined(self):
"""MAX_FILES_PER_REQUEST is defined and reasonable."""
assert FileUploadValidator.MAX_FILES_PER_REQUEST > 0
assert FileUploadValidator.MAX_FILES_PER_REQUEST == 200
def test_pdf_magic_bytes_correct(self):
"""PDF_MAGIC_BYTES is correct."""
assert FileUploadValidator.PDF_MAGIC_BYTES == b"%PDF"
def test_allowed_mime_types_includes_pdf(self):
"""ALLOWED_MIME_TYPES includes PDF."""
assert "application/pdf" in FileUploadValidator.ALLOWED_MIME_TYPES
class TestResolveMaxFileSize:
"""The per-file cap honors LDR_SECURITY_UPLOAD_MAX_FILE_SIZE_MB."""
def test_default_when_env_unset(self):
from local_deep_research.security import file_upload_validator as mod
with patch.object(mod, "check_env_setting", return_value=None):
assert (
mod._resolve_max_file_size()
== mod._DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
)
def test_env_value_overrides_default(self):
from local_deep_research.security import file_upload_validator as mod
# 750MB override (env vars always arrive as strings).
with patch.object(mod, "check_env_setting", return_value="750"):
assert mod._resolve_max_file_size() == 750 * 1024 * 1024
def test_unparseable_env_falls_back_to_default(self):
from local_deep_research.security import file_upload_validator as mod
with patch.object(
mod, "check_env_setting", return_value="not-a-number"
):
assert (
mod._resolve_max_file_size()
== mod._DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
)
def test_zero_env_value_falls_back_to_default(self):
"""A misconfigured cap of 0 would silently break all uploads."""
from local_deep_research.security import file_upload_validator as mod
with patch.object(mod, "check_env_setting", return_value="0"):
assert (
mod._resolve_max_file_size()
== mod._DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
)
def test_negative_env_value_falls_back_to_default(self):
"""Negative values are nonsensical for a size cap — reject them."""
from local_deep_research.security import file_upload_validator as mod
with patch.object(mod, "check_env_setting", return_value="-1"):
assert (
mod._resolve_max_file_size()
== mod._DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024
)
class TestValidateFileSize:
"""Tests for FileUploadValidator.validate_file_size()."""
def test_valid_content_length(self):
"""Accepts valid content length."""
is_valid, error = FileUploadValidator.validate_file_size(1000)
assert is_valid is True
assert error is None
def test_valid_file_content(self):
"""Accepts valid file content size."""
content = b"x" * 1000
is_valid, error = FileUploadValidator.validate_file_size(None, content)
assert is_valid is True
assert error is None
def test_content_length_exceeds_max(self):
"""Rejects content length exceeding max."""
large_size = FileUploadValidator.MAX_FILE_SIZE + 1
is_valid, error = FileUploadValidator.validate_file_size(large_size)
assert is_valid is False
assert "too large" in error.lower()
def test_file_content_exceeds_max(self):
"""Rejects file content exceeding max.
Patches the cap to a small value so the test doesn't have to
allocate gigabytes to cross the real default.
"""
with patch.object(FileUploadValidator, "MAX_FILE_SIZE", 1024):
large_content = b"x" * 2048 # 2KB over a 1KB cap
is_valid, error = FileUploadValidator.validate_file_size(
None, large_content
)
assert is_valid is False
assert "too large" in error.lower()
def test_both_none_is_valid(self):
"""Accepts when both parameters are None."""
is_valid, error = FileUploadValidator.validate_file_size(None, None)
assert is_valid is True
assert error is None
def test_zero_content_length_is_valid(self):
"""Accepts zero content length."""
is_valid, error = FileUploadValidator.validate_file_size(0)
assert is_valid is True
assert error is None
def test_error_message_includes_size(self):
"""Error message includes file size information."""
with patch.object(
FileUploadValidator, "MAX_FILE_SIZE", 50 * 1024 * 1024
):
large_size = 60 * 1024 * 1024 # 60MB, over a patched 50MB cap
is_valid, error = FileUploadValidator.validate_file_size(large_size)
assert is_valid is False
assert "60" in error or "MB" in error
class TestValidateFileCount:
"""Tests for FileUploadValidator.validate_file_count()."""
def test_valid_file_count(self):
"""Accepts valid file count."""
is_valid, error = FileUploadValidator.validate_file_count(10)
assert is_valid is True
assert error is None
def test_single_file_valid(self):
"""Accepts single file."""
is_valid, error = FileUploadValidator.validate_file_count(1)
assert is_valid is True
assert error is None
def test_max_files_valid(self):
"""Accepts exactly max files."""
is_valid, error = FileUploadValidator.validate_file_count(
FileUploadValidator.MAX_FILES_PER_REQUEST
)
assert is_valid is True
assert error is None
def test_exceeds_max_files(self):
"""Rejects count exceeding max."""
is_valid, error = FileUploadValidator.validate_file_count(
FileUploadValidator.MAX_FILES_PER_REQUEST + 1
)
assert is_valid is False
assert "too many" in error.lower()
def test_zero_files_invalid(self):
"""Rejects zero files."""
is_valid, error = FileUploadValidator.validate_file_count(0)
assert is_valid is False
assert "no files" in error.lower()
def test_negative_files_invalid(self):
"""Rejects negative file count."""
is_valid, error = FileUploadValidator.validate_file_count(-1)
assert is_valid is False
assert "no files" in error.lower()
class TestValidateMimeType:
"""Tests for FileUploadValidator.validate_mime_type()."""
def test_valid_pdf(self):
"""Accepts valid PDF file."""
content = b"%PDF-1.4 test content"
is_valid, error = FileUploadValidator.validate_mime_type(
"document.pdf", content
)
assert is_valid is True
assert error is None
def test_valid_pdf_uppercase_extension(self):
"""Accepts PDF with uppercase extension."""
content = b"%PDF-1.4 test content"
is_valid, error = FileUploadValidator.validate_mime_type(
"document.PDF", content
)
assert is_valid is True
assert error is None
def test_invalid_extension(self):
"""Rejects non-PDF extension."""
content = b"%PDF-1.4 test content"
is_valid, error = FileUploadValidator.validate_mime_type(
"document.txt", content
)
assert is_valid is False
assert "only pdf" in error.lower()
def test_invalid_magic_bytes(self):
"""Rejects file with wrong magic bytes."""
content = b"PK\x03\x04" # ZIP magic bytes
is_valid, error = FileUploadValidator.validate_mime_type(
"document.pdf", content
)
assert is_valid is False
assert "signature" in error.lower()
def test_empty_content(self):
"""Rejects empty file content."""
is_valid, error = FileUploadValidator.validate_mime_type(
"document.pdf", b""
)
assert is_valid is False
assert "signature" in error.lower()
def test_too_short_content(self):
"""Rejects content shorter than magic bytes."""
is_valid, error = FileUploadValidator.validate_mime_type(
"document.pdf", b"%PD"
)
assert is_valid is False
assert "signature" in error.lower()
class TestValidatePdfStructure:
"""Tests for FileUploadValidator.validate_pdf_structure()."""
def test_valid_pdf_structure(self, mock_pdf_content):
"""Accepts valid PDF structure."""
with patch(
"local_deep_research.security.file_upload_validator.pdfplumber"
) as mock_pdfplumber:
mock_pdf = Mock()
mock_page = Mock()
mock_page.width = 612
mock_page.height = 792
mock_pdf.pages = [mock_page]
mock_pdfplumber.open.return_value.__enter__ = Mock(
return_value=mock_pdf
)
mock_pdfplumber.open.return_value.__exit__ = Mock(
return_value=False
)
is_valid, error = FileUploadValidator.validate_pdf_structure(
"document.pdf", mock_pdf_content
)
assert is_valid is True
assert error is None
def test_pdf_no_pages(self, mock_pdf_content):
"""Rejects PDF with no pages."""
with patch(
"local_deep_research.security.file_upload_validator.pdfplumber"
) as mock_pdfplumber:
mock_pdf = Mock()
mock_pdf.pages = []
mock_pdfplumber.open.return_value.__enter__ = Mock(
return_value=mock_pdf
)
mock_pdfplumber.open.return_value.__exit__ = Mock(
return_value=False
)
is_valid, error = FileUploadValidator.validate_pdf_structure(
"document.pdf", mock_pdf_content
)
assert is_valid is False
assert "no pages" in error.lower()
def test_pdf_parse_error(self, mock_pdf_content):
"""Rejects PDF that cannot be parsed."""
with patch(
"local_deep_research.security.file_upload_validator.pdfplumber"
) as mock_pdfplumber:
mock_pdfplumber.open.side_effect = Exception("PDF parsing error")
is_valid, error = FileUploadValidator.validate_pdf_structure(
"corrupt.pdf", mock_pdf_content
)
assert is_valid is False
assert "invalid" in error.lower() or "corrupted" in error.lower()
def test_pdf_none_pages(self, mock_pdf_content):
"""Rejects PDF with None pages."""
with patch(
"local_deep_research.security.file_upload_validator.pdfplumber"
) as mock_pdfplumber:
mock_pdf = Mock()
mock_pdf.pages = None
mock_pdfplumber.open.return_value.__enter__ = Mock(
return_value=mock_pdf
)
mock_pdfplumber.open.return_value.__exit__ = Mock(
return_value=False
)
is_valid, error = FileUploadValidator.validate_pdf_structure(
"document.pdf", mock_pdf_content
)
assert is_valid is False
assert "no pages" in error.lower()
class TestValidateUpload:
"""Tests for FileUploadValidator.validate_upload()."""
def test_valid_upload(self, mock_pdf_content):
"""Accepts valid file upload."""
with patch(
"local_deep_research.security.file_upload_validator.pdfplumber"
) as mock_pdfplumber:
mock_pdf = Mock()
mock_page = Mock()
mock_page.width = 612
mock_page.height = 792
mock_pdf.pages = [mock_page]
mock_pdfplumber.open.return_value.__enter__ = Mock(
return_value=mock_pdf
)
mock_pdfplumber.open.return_value.__exit__ = Mock(
return_value=False
)
is_valid, error = FileUploadValidator.validate_upload(
"document.pdf", mock_pdf_content
)
assert is_valid is True
assert error is None
def test_upload_file_too_large(self):
"""Rejects upload that's too large."""
with patch.object(FileUploadValidator, "MAX_FILE_SIZE", 1024):
large_content = b"%PDF-1.4" + b"x" * 2048 # 2KB over 1KB cap
is_valid, error = FileUploadValidator.validate_upload(
"document.pdf", large_content
)
assert is_valid is False
assert "too large" in error.lower()
def test_upload_wrong_extension(self, mock_pdf_content):
"""Rejects upload with wrong extension."""
is_valid, error = FileUploadValidator.validate_upload(
"document.txt", mock_pdf_content
)
assert is_valid is False
assert "only pdf" in error.lower()
def test_upload_wrong_magic_bytes(self):
"""Rejects upload with wrong magic bytes."""
content = b"not a pdf"
is_valid, error = FileUploadValidator.validate_upload(
"document.pdf", content
)
assert is_valid is False
assert "signature" in error.lower()
def test_upload_corrupted_pdf(self, mock_pdf_content):
"""Rejects corrupted PDF."""
with patch(
"local_deep_research.security.file_upload_validator.pdfplumber"
) as mock_pdfplumber:
mock_pdfplumber.open.side_effect = Exception("Corrupted")
is_valid, error = FileUploadValidator.validate_upload(
"document.pdf", mock_pdf_content
)
assert is_valid is False
assert "invalid" in error.lower() or "corrupted" in error.lower()
def test_upload_with_content_length(self, mock_pdf_content):
"""Validates with content length header."""
with patch(
"local_deep_research.security.file_upload_validator.pdfplumber"
) as mock_pdfplumber:
mock_pdf = Mock()
mock_page = Mock()
mock_page.width = 612
mock_page.height = 792
mock_pdf.pages = [mock_page]
mock_pdfplumber.open.return_value.__enter__ = Mock(
return_value=mock_pdf
)
mock_pdfplumber.open.return_value.__exit__ = Mock(
return_value=False
)
is_valid, error = FileUploadValidator.validate_upload(
"document.pdf",
mock_pdf_content,
content_length=len(mock_pdf_content),
)
assert is_valid is True
assert error is None
def test_upload_content_length_mismatch_large(self, mock_pdf_content):
"""Rejects when content length indicates too large."""
# Content length header says file is too large
is_valid, error = FileUploadValidator.validate_upload(
"document.pdf",
mock_pdf_content,
content_length=FileUploadValidator.MAX_FILE_SIZE + 1,
)
assert is_valid is False
assert "too large" in error.lower()
class TestSecurityScenarios:
"""Integration tests for security scenarios."""
def test_disguised_executable(self):
"""Rejects executable disguised as PDF."""
# Windows executable magic bytes
content = b"MZ" + b"\x00" * 100
is_valid, error = FileUploadValidator.validate_upload(
"malware.pdf", content
)
assert is_valid is False
def test_zip_bomb_prevention(self):
"""Rejects suspiciously large claimed size."""
# Content length claims enormous size
content = b"%PDF-1.4 small content"
is_valid, error = FileUploadValidator.validate_upload(
"bomb.pdf",
content,
content_length=10 * 1024 * 1024 * 1024, # 10GB
)
assert is_valid is False
def test_polyglot_file_rejection(self):
"""Rejects files that start with valid PDF but have wrong extension."""
content = b"%PDF-1.4 this is actually executable code"
is_valid, error = FileUploadValidator.validate_upload(
"script.js", content
)
assert is_valid is False
def test_html_in_pdf_extension(self):
"""Rejects HTML file with PDF extension."""
content = b"<html><script>alert('xss')</script></html>"
is_valid, error = FileUploadValidator.validate_upload(
"attack.pdf", content
)
assert is_valid is False
assert "signature" in error.lower()
+466
View File
@@ -0,0 +1,466 @@
"""
Comprehensive tests for security/file_write_verifier.py
Tests cover:
- _sanitize_sensitive_data function
- write_file_verified function
- write_json_verified function
- FileWriteSecurityError exception
"""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
class TestSanitizeSensitiveData:
"""Tests for the _sanitize_sensitive_data function."""
def test_sanitizes_password_key(self):
"""Test that password keys are redacted."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {"username": "admin", "password": "secret123"}
result = _sanitize_sensitive_data(data)
assert result["username"] == "admin"
assert result["password"] == "[REDACTED]"
def test_sanitizes_api_key(self):
"""Test that api_key is redacted."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {"api_key": "sk-123456", "name": "test"}
result = _sanitize_sensitive_data(data)
assert result["api_key"] == "[REDACTED]"
assert result["name"] == "test"
def test_sanitizes_various_sensitive_keys(self):
"""Test that all sensitive key variations are redacted."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {
"apikey": "value1",
"api-key": "value2",
"secret": "value3",
"secret_key": "value4",
"token": "value5",
"access_token": "value6",
"refresh_token": "value7",
"private_key": "value8",
"credentials": "value9",
"auth": "value10",
"authorization": "value11",
}
result = _sanitize_sensitive_data(data)
for key in data:
assert result[key] == "[REDACTED]"
def test_case_insensitive_matching(self):
"""Test that matching is case insensitive."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {"PASSWORD": "secret", "Api_Key": "key123", "TOKEN": "tok"}
result = _sanitize_sensitive_data(data)
assert result["PASSWORD"] == "[REDACTED]"
assert result["Api_Key"] == "[REDACTED]"
assert result["TOKEN"] == "[REDACTED]"
def test_sanitizes_nested_dicts(self):
"""Test that nested dictionaries are sanitized."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {
"config": {
"api_key": "secret",
"settings": {"password": "hidden", "name": "test"},
}
}
result = _sanitize_sensitive_data(data)
assert result["config"]["api_key"] == "[REDACTED]"
assert result["config"]["settings"]["password"] == "[REDACTED]"
assert result["config"]["settings"]["name"] == "test"
def test_sanitizes_lists_of_dicts(self):
"""Test that lists containing dicts are sanitized."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {
"users": [
{"name": "user1", "password": "pass1"},
{"name": "user2", "password": "pass2"},
]
}
result = _sanitize_sensitive_data(data)
assert result["users"][0]["password"] == "[REDACTED]"
assert result["users"][1]["password"] == "[REDACTED]"
assert result["users"][0]["name"] == "user1"
def test_preserves_non_sensitive_data(self):
"""Test that non-sensitive data is preserved."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {"name": "test", "value": 123, "flag": True, "items": [1, 2, 3]}
result = _sanitize_sensitive_data(data)
assert result == data
def test_handles_primitive_values(self):
"""Test that primitive values pass through unchanged."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
assert _sanitize_sensitive_data("string") == "string"
assert _sanitize_sensitive_data(123) == 123
assert _sanitize_sensitive_data(True) is True
assert _sanitize_sensitive_data(None) is None
def test_handles_list_of_primitives(self):
"""Test that lists of primitives pass through."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = [1, 2, "test", True]
result = _sanitize_sensitive_data(data)
assert result == data
def test_handles_non_string_keys(self):
"""Test handling of non-string dictionary keys."""
from local_deep_research.security.file_write_verifier import (
_sanitize_sensitive_data,
)
data = {1: "value1", "password": "secret"}
result = _sanitize_sensitive_data(data)
assert result[1] == "value1"
assert result["password"] == "[REDACTED]"
class TestFileWriteSecurityError:
"""Tests for the FileWriteSecurityError exception."""
def test_exception_can_be_raised(self):
"""Test that the exception can be raised and caught."""
from local_deep_research.security.file_write_verifier import (
FileWriteSecurityError,
)
with pytest.raises(FileWriteSecurityError) as exc_info:
raise FileWriteSecurityError("Test error message")
assert "Test error message" in str(exc_info.value)
def test_exception_inherits_from_exception(self):
"""Test that FileWriteSecurityError inherits from Exception."""
from local_deep_research.security.file_write_verifier import (
FileWriteSecurityError,
)
assert issubclass(FileWriteSecurityError, Exception)
class TestWriteFileVerified:
"""Tests for the write_file_verified function."""
@pytest.fixture
def mock_get_setting(self):
"""Fixture to mock the get_setting_from_snapshot function."""
with patch(
"local_deep_research.config.search_config.get_setting_from_snapshot"
) as mock:
yield mock
def test_writes_file_when_setting_matches(self, tmp_path, mock_get_setting):
"""Test that file is written when setting matches required value."""
from local_deep_research.security.file_write_verifier import (
write_file_verified,
)
filepath = tmp_path / "test.txt"
content = "test content"
mock_get_setting.return_value = True
write_file_verified(
filepath, content, "test.setting", required_value=True
)
assert filepath.exists()
assert filepath.read_text() == content
def test_raises_error_when_setting_mismatch(
self, tmp_path, mock_get_setting
):
"""Test that error is raised when setting doesn't match."""
from local_deep_research.security.file_write_verifier import (
write_file_verified,
FileWriteSecurityError,
)
filepath = tmp_path / "test.txt"
mock_get_setting.return_value = False
with pytest.raises(FileWriteSecurityError) as exc_info:
write_file_verified(
filepath,
"content",
"test.setting",
required_value=True,
context="test operation",
)
assert "test operation" in str(exc_info.value)
assert "test.setting=True" in str(exc_info.value)
def test_raises_error_when_setting_not_found(
self, tmp_path, mock_get_setting
):
"""Test that error is raised when setting doesn't exist."""
from local_deep_research.security.file_write_verifier import (
write_file_verified,
FileWriteSecurityError,
)
filepath = tmp_path / "test.txt"
mock_get_setting.side_effect = KeyError("Setting not found")
with pytest.raises(FileWriteSecurityError):
write_file_verified(filepath, "content", "nonexistent.setting")
def test_writes_binary_file(self, tmp_path, mock_get_setting):
"""Test writing binary content."""
from local_deep_research.security.file_write_verifier import (
write_file_verified,
)
filepath = tmp_path / "test.bin"
content = b"\x00\x01\x02\x03"
mock_get_setting.return_value = True
write_file_verified(filepath, content, "test.setting", mode="wb")
assert filepath.exists()
assert filepath.read_bytes() == content
def test_uses_custom_encoding(self, tmp_path, mock_get_setting):
"""Test writing with custom encoding."""
from local_deep_research.security.file_write_verifier import (
write_file_verified,
)
filepath = tmp_path / "test.txt"
content = "日本語テスト"
mock_get_setting.return_value = True
write_file_verified(filepath, content, "test.setting", encoding="utf-8")
assert filepath.exists()
assert filepath.read_text(encoding="utf-8") == content
def test_passes_settings_snapshot(self, tmp_path, mock_get_setting):
"""Test that settings_snapshot is passed to get_setting_from_snapshot."""
from local_deep_research.security.file_write_verifier import (
write_file_verified,
)
filepath = tmp_path / "test.txt"
snapshot = {"test.setting": True}
mock_get_setting.return_value = True
write_file_verified(
filepath, "content", "test.setting", settings_snapshot=snapshot
)
mock_get_setting.assert_called_once_with(
"test.setting", settings_snapshot=snapshot
)
def test_accepts_path_object(self, tmp_path, mock_get_setting):
"""Test that Path objects are accepted."""
from local_deep_research.security.file_write_verifier import (
write_file_verified,
)
filepath = Path(tmp_path) / "test.txt"
mock_get_setting.return_value = True
write_file_verified(filepath, "content", "test.setting")
assert filepath.exists()
class TestWriteJsonVerified:
"""Tests for the write_json_verified function."""
@pytest.fixture
def mock_get_setting(self):
"""Fixture to mock the get_setting_from_snapshot function."""
with patch(
"local_deep_research.config.search_config.get_setting_from_snapshot"
) as mock:
yield mock
def test_writes_json_when_setting_matches(self, tmp_path, mock_get_setting):
"""Test that JSON is written when setting matches."""
from local_deep_research.security.file_write_verifier import (
write_json_verified,
)
filepath = tmp_path / "test.json"
data = {"key": "value", "number": 42}
mock_get_setting.return_value = True
write_json_verified(filepath, data, "test.setting")
assert filepath.exists()
written_data = json.loads(filepath.read_text())
assert written_data == data
def test_sanitizes_sensitive_data(self, tmp_path, mock_get_setting):
"""Test that sensitive data is sanitized before writing."""
from local_deep_research.security.file_write_verifier import (
write_json_verified,
)
filepath = tmp_path / "test.json"
data = {"name": "test", "password": "secret123", "api_key": "key456"}
mock_get_setting.return_value = True
write_json_verified(filepath, data, "test.setting")
written_data = json.loads(filepath.read_text())
assert written_data["name"] == "test"
assert written_data["password"] == "[REDACTED]"
assert written_data["api_key"] == "[REDACTED]"
def test_uses_default_indent(self, tmp_path, mock_get_setting):
"""Test that default indent of 2 is used."""
from local_deep_research.security.file_write_verifier import (
write_json_verified,
)
filepath = tmp_path / "test.json"
data = {"key": "value"}
mock_get_setting.return_value = True
write_json_verified(filepath, data, "test.setting")
content = filepath.read_text()
# With indent=2, there should be newlines and spaces
assert "\n" in content
assert " " in content
def test_custom_json_kwargs(self, tmp_path, mock_get_setting):
"""Test that custom JSON kwargs are passed through."""
from local_deep_research.security.file_write_verifier import (
write_json_verified,
)
filepath = tmp_path / "test.json"
data = {"b": 2, "a": 1}
mock_get_setting.return_value = True
write_json_verified(
filepath, data, "test.setting", sort_keys=True, indent=4
)
content = filepath.read_text()
# With sort_keys, 'a' should come before 'b'
assert content.index('"a"') < content.index('"b"')
# With indent=4, should have 4 spaces
assert " " in content
def test_writes_list_data(self, tmp_path, mock_get_setting):
"""Test writing list data."""
from local_deep_research.security.file_write_verifier import (
write_json_verified,
)
filepath = tmp_path / "test.json"
data = [{"name": "item1"}, {"name": "item2", "password": "secret"}]
mock_get_setting.return_value = True
write_json_verified(filepath, data, "test.setting")
written_data = json.loads(filepath.read_text())
assert len(written_data) == 2
assert written_data[1]["password"] == "[REDACTED]"
def test_raises_error_when_setting_mismatch(
self, tmp_path, mock_get_setting
):
"""Test that error is raised when setting doesn't match."""
from local_deep_research.security.file_write_verifier import (
write_json_verified,
FileWriteSecurityError,
)
filepath = tmp_path / "test.json"
mock_get_setting.return_value = False
with pytest.raises(FileWriteSecurityError):
write_json_verified(filepath, {"key": "value"}, "test.setting")
class TestSensitiveKeys:
"""Tests for SENSITIVE_KEYS constant."""
def test_sensitive_keys_is_frozenset(self):
"""Test that SENSITIVE_KEYS is a frozenset."""
from local_deep_research.security.file_write_verifier import (
SENSITIVE_KEYS,
)
assert isinstance(SENSITIVE_KEYS, frozenset)
def test_sensitive_keys_contains_expected_values(self):
"""Test that SENSITIVE_KEYS contains expected sensitive key names."""
from local_deep_research.security.file_write_verifier import (
SENSITIVE_KEYS,
)
expected_keys = {
"password",
"api_key",
"secret",
"token",
"credentials",
}
for key in expected_keys:
assert key in SENSITIVE_KEYS
+69
View File
@@ -0,0 +1,69 @@
"""Tests for filename sanitization."""
import pytest
from local_deep_research.security.filename_sanitizer import (
UnsafeFilenameError,
sanitize_filename,
)
class TestSanitizeFilename:
"""Tests for sanitize_filename()."""
def test_normal_filename(self):
assert sanitize_filename("report.pdf") == "report.pdf"
def test_path_traversal(self):
result = sanitize_filename("../../etc/passwd.pdf")
assert ".." not in result
assert result == "etc_passwd.pdf"
def test_null_bytes_stripped(self):
result = sanitize_filename("file\x00name.pdf")
assert "\x00" not in result
assert result == "filename.pdf"
def test_empty_filename_raises(self):
with pytest.raises(UnsafeFilenameError, match="No filename"):
sanitize_filename("")
def test_none_filename_raises(self):
with pytest.raises(UnsafeFilenameError, match="No filename"):
sanitize_filename(None)
def test_sanitizes_to_empty_raises(self):
with pytest.raises(UnsafeFilenameError, match="no safe characters"):
sanitize_filename("../../../")
def test_allowed_extensions_pass(self):
result = sanitize_filename("doc.pdf", allowed_extensions={".pdf"})
assert result == "doc.pdf"
def test_disallowed_extension_raises(self):
with pytest.raises(UnsafeFilenameError, match="not allowed"):
sanitize_filename("script.exe", allowed_extensions={".pdf", ".txt"})
def test_extension_check_case_insensitive(self):
result = sanitize_filename("doc.PDF", allowed_extensions={".pdf"})
assert result == "doc.PDF"
def test_max_length_truncates(self):
long_name = "a" * 300 + ".pdf"
result = sanitize_filename(long_name, max_length=50)
assert len(result) <= 50
assert result.endswith(".pdf")
def test_max_length_no_extension(self):
long_name = "a" * 300
result = sanitize_filename(long_name, max_length=50)
assert len(result) <= 50
def test_spaces_in_filename(self):
result = sanitize_filename("my report file.pdf")
assert result == "my_report_file.pdf"
def test_special_characters(self):
result = sanitize_filename("file@#$%.pdf")
assert result # should not be empty
assert ".." not in result
+386
View File
@@ -0,0 +1,386 @@
"""
Tests for security/ip_ranges.py
Tests cover:
- PRIVATE_IP_RANGES constant contains expected networks
- Private IP detection works correctly
- IPv4 and IPv6 address validation
"""
import ipaddress
def _ip_is_private(ip_str: str) -> bool:
"""Module-level helper used across the IPv6-transition-prefix test
classes. (TestPrivateIPDetection has its own copy that returns
False for invalid IPs; this one is for callers that pass only
well-formed addresses.)"""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
ip = ipaddress.ip_address(ip_str)
return any(ip in network for network in PRIVATE_IP_RANGES)
class TestPrivateIPRanges:
"""Tests for PRIVATE_IP_RANGES constant."""
def test_private_ip_ranges_is_list(self):
"""PRIVATE_IP_RANGES should be a list."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
assert isinstance(PRIVATE_IP_RANGES, list)
def test_private_ip_ranges_not_empty(self):
"""PRIVATE_IP_RANGES should not be empty."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
assert len(PRIVATE_IP_RANGES) > 0
def test_private_ip_ranges_contains_ip_networks(self):
"""All entries should be ip_network objects."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
for network in PRIVATE_IP_RANGES:
assert isinstance(
network, (ipaddress.IPv4Network, ipaddress.IPv6Network)
)
def test_contains_loopback_ipv4(self):
"""Should contain IPv4 loopback (127.0.0.0/8)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
loopback = ipaddress.ip_network("127.0.0.0/8")
assert loopback in PRIVATE_IP_RANGES
def test_contains_loopback_ipv6(self):
"""Should contain IPv6 loopback (::1/128)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
loopback = ipaddress.ip_network("::1/128")
assert loopback in PRIVATE_IP_RANGES
def test_contains_rfc1918_class_a(self):
"""Should contain RFC1918 Class A private (10.0.0.0/8)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
private_a = ipaddress.ip_network("10.0.0.0/8")
assert private_a in PRIVATE_IP_RANGES
def test_contains_rfc1918_class_b(self):
"""Should contain RFC1918 Class B private (172.16.0.0/12)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
private_b = ipaddress.ip_network("172.16.0.0/12")
assert private_b in PRIVATE_IP_RANGES
def test_contains_rfc1918_class_c(self):
"""Should contain RFC1918 Class C private (192.168.0.0/16)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
private_c = ipaddress.ip_network("192.168.0.0/16")
assert private_c in PRIVATE_IP_RANGES
def test_contains_cgnat(self):
"""Should contain CGNAT range (100.64.0.0/10)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
cgnat = ipaddress.ip_network("100.64.0.0/10")
assert cgnat in PRIVATE_IP_RANGES
def test_contains_link_local_ipv4(self):
"""Should contain IPv4 link-local (169.254.0.0/16)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
link_local = ipaddress.ip_network("169.254.0.0/16")
assert link_local in PRIVATE_IP_RANGES
def test_contains_link_local_ipv6(self):
"""Should contain IPv6 link-local (fe80::/10)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
link_local = ipaddress.ip_network("fe80::/10")
assert link_local in PRIVATE_IP_RANGES
def test_contains_ipv6_unique_local(self):
"""Should contain IPv6 unique local (fc00::/7)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
unique_local = ipaddress.ip_network("fc00::/7")
assert unique_local in PRIVATE_IP_RANGES
def test_contains_ipv4_unspecified(self):
"""Should contain 0.0.0.0/8 ('this' network — IPv4 unspecified).
Linux routes connect() to 0.0.0.0 to local host."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
unspecified_v4 = ipaddress.ip_network("0.0.0.0/8")
assert unspecified_v4 in PRIVATE_IP_RANGES
def test_contains_ipv6_unspecified(self):
"""Should contain ::/128 (IPv6 unspecified). Linux routes
connect() to [::] to local host (same semantics as 0.0.0.0).
Added in PR #3873 after empirical bypass discovery."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
unspecified_v6 = ipaddress.ip_network("::/128")
assert unspecified_v6 in PRIVATE_IP_RANGES
def test_contains_6to4_prefix(self):
"""Should contain 2002::/16 (6to4 transition prefix). Wraps
private IPv4 destinations on hosts with sit0 routes."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
sixto4 = ipaddress.ip_network("2002::/16")
assert sixto4 in PRIVATE_IP_RANGES
def test_contains_nat64_prefix(self):
"""Should contain 64:ff9b::/96 (NAT64 well-known prefix)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
nat64 = ipaddress.ip_network("64:ff9b::/96")
assert nat64 in PRIVATE_IP_RANGES
def test_contains_nat64_local_use_prefix(self):
"""Should contain 64:ff9b:1::/48 (RFC 8215 NAT64 local-use prefix)
— same SSRF threat class as the well-known prefix; missing it is
the exact bypass paid out as a HackerOne bounty against
ssrf_filter."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
nat64_local = ipaddress.ip_network("64:ff9b:1::/48")
assert nat64_local in PRIVATE_IP_RANGES
def test_nat64_prefixes_constant_exposes_both(self):
"""NAT64_PREFIXES must contain exactly the two NAT64 prefixes —
used by validators to identify which deny entries the
security.allow_nat64 env carve-out should skip."""
from local_deep_research.security.ip_ranges import NAT64_PREFIXES
assert ipaddress.ip_network("64:ff9b::/96") in NAT64_PREFIXES
assert ipaddress.ip_network("64:ff9b:1::/48") in NAT64_PREFIXES
assert len(NAT64_PREFIXES) == 2
def test_contains_teredo_prefix(self):
"""Should contain 2001::/32 (Teredo)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
teredo = ipaddress.ip_network("2001::/32")
assert teredo in PRIVATE_IP_RANGES
def test_contains_ipv6_discard_prefix(self):
"""Should contain 100::/64 (RFC 6666 discard)."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
discard = ipaddress.ip_network("100::/64")
assert discard in PRIVATE_IP_RANGES
def test_contains_ipv4_compatible_ipv6_prefix(self):
"""Should contain ::/96 (RFC 4291 IPv4-Compatible IPv6 — DEPRECATED).
Same SSRF threat class as the transition prefixes: embeds an IPv4
address in the low 32 bits and is routable on hosts with ::/96
routes configured. [::169.254.169.254] would otherwise reach IMDS."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
ipv4_compat = ipaddress.ip_network("::/96")
assert ipv4_compat in PRIVATE_IP_RANGES
class TestPrivateIPDetection:
"""Tests for using PRIVATE_IP_RANGES to detect private IPs."""
def _is_private(self, ip_str: str) -> bool:
"""Helper to check if IP is in private ranges."""
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
try:
ip = ipaddress.ip_address(ip_str)
return any(ip in network for network in PRIVATE_IP_RANGES)
except ValueError:
return False
def test_localhost_is_private(self):
"""127.0.0.1 should be detected as private."""
assert self._is_private("127.0.0.1") is True
def test_localhost_ipv6_is_private(self):
"""::1 should be detected as private."""
assert self._is_private("::1") is True
def test_10_network_is_private(self):
"""10.x.x.x addresses should be detected as private."""
assert self._is_private("10.0.0.1") is True
assert self._is_private("10.255.255.255") is True
def test_172_16_network_is_private(self):
"""172.16-31.x.x addresses should be detected as private."""
assert self._is_private("172.16.0.1") is True
assert self._is_private("172.31.255.255") is True
def test_172_32_is_not_private(self):
"""172.32.x.x should NOT be detected as private."""
assert self._is_private("172.32.0.1") is False
def test_192_168_network_is_private(self):
"""192.168.x.x addresses should be detected as private."""
assert self._is_private("192.168.0.1") is True
assert self._is_private("192.168.255.255") is True
def test_cgnat_is_private(self):
"""100.64.x.x CGNAT addresses should be detected as private."""
assert self._is_private("100.64.0.1") is True
assert self._is_private("100.127.255.255") is True
def test_link_local_is_private(self):
"""169.254.x.x link-local addresses should be detected as private."""
assert self._is_private("169.254.1.1") is True
def test_public_ip_is_not_private(self):
"""Public IPs should NOT be detected as private."""
assert self._is_private("8.8.8.8") is False
assert self._is_private("1.1.1.1") is False
assert self._is_private("93.184.216.34") is False # example.com
def test_ipv6_public_is_not_private(self):
"""Public IPv6 addresses should NOT be detected as private."""
assert self._is_private("2001:4860:4860::8888") is False # Google DNS
class TestIPv6TransitionPrefixesAntiCollision:
"""Anti-regression: confirm the new IPv6 transition-prefix entries
do NOT swallow legitimate global IPv6 allocations at their boundaries.
Each prefix is precisely scoped at the bit level:
- 2001::/32 fixes the second hextet to 0x0000 (Teredo only).
- 2002::/16 fixes the first hextet to 0x2002 (6to4 only).
- 64:ff9b::/96 fixes the first 96 bits (the well-known NAT64 prefix
only; RFC 8215 local-use 64:ff9b:1::/48 must NOT match).
- 100::/64 fixes the first 64 bits (RFC 6666 discard only; the rest
of 100::/8 is unallocated, not discard).
"""
_is_private = staticmethod(_ip_is_private)
def test_google_dns_v6_not_blocked(self):
"""2001:4860:4860::8888 — second hextet 0x4860, outside 2001::/32."""
assert self._is_private("2001:4860:4860::8888") is False
def test_cloudflare_dns_v6_not_blocked(self):
"""2606:4700:4700::1111 — first hextet 0x2606, far from 2001/2002."""
assert self._is_private("2606:4700:4700::1111") is False
def test_documentation_prefix_v6_not_blocked(self):
"""2001:db8::/32 (RFC 3849) — second hextet 0x0db8, outside Teredo."""
assert self._is_private("2001:db8::1") is False
def test_root_server_v6_not_blocked(self):
"""2001:500::/30 (root-server allocation) — second hextet 0x0500."""
assert self._is_private("2001:500:88::1") is False
def test_he_tunnelbroker_v6_not_blocked(self):
"""2001:470::/32 (Hurricane Electric) — second hextet 0x0470."""
assert self._is_private("2001:470:1f04::1") is False
def test_neighbor_above_6to4_not_blocked(self):
"""2003::/16 (Deutsche Telekom) sits adjacent to 2002::/16."""
assert self._is_private("2003::1") is False
def test_neighbor_below_6to4_not_blocked(self):
"""2001:ffff::1 — last address of 2001:: space, not in 2002::/16."""
assert self._is_private("2001:ffff::1") is False
# NOTE: RFC 8215's 64:ff9b:1::/48 (NAT64 local-use) IS now blocked —
# see TestPrivateIPRanges::test_contains_nat64_local_use_prefix. It
# is the same SSRF threat class as the well-known /96 and missing
# it has been paid out as a HackerOne bounty against ssrf_filter.
def test_ipv6_discard_prefix_neighbor_not_blocked(self):
"""100:1::/64 — second hextet 0x0001, outside the /64 discard
block. The surrounding 100::/8 is reserved-unallocated, not
discard, so we must not over-block it."""
assert self._is_private("100:1::1") is False
class TestIPv6TransitionPrefixesPositiveDetection:
"""Confirm the new transition prefixes detect their full address space,
including embedded private-IPv4 wraps relevant to SSRF."""
_is_private = staticmethod(_ip_is_private)
def test_6to4_wraps_loopback(self):
"""[2002:7f00:1::] — 6to4 wrap of 127.0.0.1."""
assert self._is_private("2002:7f00:1::") is True
def test_6to4_wraps_rfc1918_class_a(self):
"""[2002:0a00:1::] — 6to4 wrap of 10.0.0.1."""
assert self._is_private("2002:0a00:1::") is True
def test_6to4_wraps_rfc1918_class_b(self):
"""[2002:ac10:1::] — 6to4 wrap of 172.16.0.1."""
assert self._is_private("2002:ac10:1::") is True
def test_6to4_wraps_rfc1918_class_c(self):
"""[2002:c0a8:101::] — 6to4 wrap of 192.168.1.1."""
assert self._is_private("2002:c0a8:101::") is True
def test_6to4_wraps_aws_metadata(self):
"""[2002:a9fe:a9fe::] — 6to4 wrap of 169.254.169.254 (AWS IMDS).
High-value SSRF target; must be caught by the prefix block."""
assert self._is_private("2002:a9fe:a9fe::") is True
def test_6to4_upper_boundary(self):
"""Last address in 2002::/16."""
assert (
self._is_private("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff") is True
)
def test_nat64_wraps_loopback(self):
"""[64:ff9b::7f00:1] — NAT64 wrap of 127.0.0.1."""
assert self._is_private("64:ff9b::7f00:1") is True
def test_nat64_wraps_rfc1918_class_a(self):
"""[64:ff9b::a00:1] — NAT64 wrap of 10.0.0.1."""
assert self._is_private("64:ff9b::a00:1") is True
def test_nat64_wraps_rfc1918_class_b(self):
"""[64:ff9b::ac10:1] — NAT64 wrap of 172.16.0.1."""
assert self._is_private("64:ff9b::ac10:1") is True
def test_nat64_wraps_aws_metadata(self):
"""[64:ff9b::a9fe:a9fe] — NAT64 wrap of 169.254.169.254 (AWS IMDS)."""
assert self._is_private("64:ff9b::a9fe:a9fe") is True
def test_teredo_lower_boundary(self):
"""2001:0:0:0:0:0:0:0 — first address in 2001::/32."""
assert self._is_private("2001::") is True
def test_teredo_upper_boundary(self):
"""2001:0:ffff:ffff:ffff:ffff:ffff:ffff — last address in 2001::/32."""
assert self._is_private("2001:0:ffff:ffff:ffff:ffff:ffff:ffff") is True
def test_discard_prefix_upper_boundary(self):
"""Last address in 100::/64."""
assert self._is_private("100::ffff:ffff:ffff:ffff") is True
class TestIPRangesUsedByValidators:
"""Tests to verify PRIVATE_IP_RANGES is correctly imported by validators."""
def test_ssrf_validator_uses_shared_ranges(self):
"""SSRF validator should import BLOCKED_IP_RANGES from ip_ranges."""
from local_deep_research.security.ssrf_validator import (
BLOCKED_IP_RANGES,
)
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
# Should be the same object (imported, not copied)
assert BLOCKED_IP_RANGES is PRIVATE_IP_RANGES
def test_notification_validator_uses_shared_ranges(self):
"""Notification validator should use shared ranges."""
from local_deep_research.security.notification_validator import (
NotificationURLValidator,
)
from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES
# Should be the same object
assert NotificationURLValidator.PRIVATE_IP_RANGES is PRIVATE_IP_RANGES
@@ -0,0 +1,118 @@
"""
Tests for the check-journal-quality-readonly pre-commit hook.
Ensures the compiled journal quality DB is only opened read-only at runtime.
The only allowed writer is build_db() in journal_quality/db.py.
"""
import sys
from importlib import import_module
from pathlib import Path
HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks"
sys.path.insert(0, str(HOOKS_DIR))
hook_module = import_module("check-journal-quality-readonly")
check_file_fn = hook_module.check_file
# Build the DB name dynamically so this test file is not flagged by
# the check-journal-quality-readonly hook itself.
_JQ_DB = "journal_" + "quality.db"
_JR_DB = "journal_" + "reference.db"
def _write_and_check(tmp_path, code: str, filename: str = "src/module.py"):
"""Write code to a temp file and check it."""
p = tmp_path / filename
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(code, encoding="utf-8")
return check_file_fn(p)
class TestDetectsWritableOpens:
"""Ensures writable opens of the journal quality DB are caught."""
def test_detects_sqlite_connect_without_mode_ro(self, tmp_path):
code = f'conn = sqlite3.connect("{_JQ_DB}")\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) >= 1
assert any("mode=ro" in e for e in errors)
def test_detects_create_engine_without_mode_ro(self, tmp_path):
code = f'engine = create_engine("sqlite:///{_JQ_DB}")\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) >= 1
def test_detects_legacy_journal_reference_db(self, tmp_path):
code = f'conn = sqlite3.connect("{_JR_DB}")\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) >= 1
def test_detects_connect_in_fstring(self, tmp_path):
code = f'conn = sqlite3.connect(f"{{path}}/{_JQ_DB}")\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) >= 1
class TestAllowsReadOnlyOpens:
"""Ensures read-only opens are allowed."""
def test_allows_mode_ro(self, tmp_path):
code = f'conn = sqlite3.connect(f"file:{{path}}/{_JQ_DB}?mode=ro&immutable=1", uri=True)\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) == 0
def test_allows_mode_ro_uppercase(self, tmp_path):
"""SQLite accepts case-insensitive URI values — the hook should
recognise `mode=RO` (and mixed case) as read-only intent too."""
code = f'conn = sqlite3.connect(f"file:{{path}}/{_JQ_DB}?mode=RO&immutable=1", uri=True)\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) == 0
def test_allows_mode_ro_mixed_case(self, tmp_path):
code = f'conn = sqlite3.connect(f"file:{{path}}/{_JQ_DB}?mode=Ro", uri=True)\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) == 0
def test_allows_mode_ro_with_whitespace(self, tmp_path):
code = f'conn = sqlite3.connect(f"file:{{path}}/{_JQ_DB}?mode = ro", uri=True)\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) == 0
class TestAllowsWriterModule:
"""Ensures the designated writer module can open writable."""
def test_allows_build_db_writer(self, tmp_path):
code = f'conn = sqlite3.connect("{_JQ_DB}")\n'
errors = _write_and_check(
tmp_path,
code,
filename="src/local_deep_research/journal_quality/db.py",
)
assert len(errors) == 0
class TestAllowsSafePatterns:
"""Ensures non-connect references are not flagged."""
def test_allows_comments(self, tmp_path):
code = f"# See {_JQ_DB} for data\n"
errors = _write_and_check(tmp_path, code)
assert len(errors) == 0
def test_allows_path_existence_check(self, tmp_path):
code = f'if Path("{_JQ_DB}").exists():\n pass\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) == 0
def test_allows_log_message(self, tmp_path):
code = f'logger.info(f"Loading {_JQ_DB} from {{path}}")\n'
errors = _write_and_check(tmp_path, code)
assert len(errors) == 0
def test_ignores_non_python_files(self, tmp_path):
code = f'conn = sqlite3.connect("{_JQ_DB}")\n'
errors = _write_and_check(tmp_path, code, filename="docs/readme.md")
assert len(errors) == 0
+74
View File
@@ -0,0 +1,74 @@
"""
Tests for the check-ldr-db pre-commit hook.
Ensures the hook detects any reference to the deprecated shared database,
forcing all data storage through per-user encrypted databases.
"""
import sys
from importlib import import_module
from pathlib import Path
HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks"
sys.path.insert(0, str(HOOKS_DIR))
hook_module = import_module("check-ldr-db")
check_file_fn = hook_module.check_file_for_ldr_db
# Build the deprecated DB name dynamically so this test file itself
# is not flagged by the check-ldr-db pre-commit hook.
_DEPRECATED_DB = "ldr" + ".db"
def _write_and_check(tmp_path, code: str, filename: str = "module.py"):
p = tmp_path / filename
p.write_text(code, encoding="utf-8")
return check_file_fn(str(p))
class TestDetectsLdrDbUsage:
"""Ensures references to the deprecated shared DB are caught."""
def test_detects_sqlite_connect(self, tmp_path):
code = f'conn = sqlite3.connect("{_DEPRECATED_DB}")\n'
matches = _write_and_check(tmp_path, code)
assert len(matches) >= 1
def test_detects_in_path(self, tmp_path):
code = f'db_path = "/data/{_DEPRECATED_DB}"\n'
matches = _write_and_check(tmp_path, code)
assert len(matches) >= 1
def test_detects_case_insensitive(self, tmp_path):
code = f'path = "{_DEPRECATED_DB.upper()}"\n'
matches = _write_and_check(tmp_path, code)
assert len(matches) >= 1
def test_detects_in_f_string(self, tmp_path):
code = f'path = f"{{base}}/{_DEPRECATED_DB}"\n'
matches = _write_and_check(tmp_path, code)
assert len(matches) >= 1
class TestAllowsSafePatterns:
"""Ensures comments and safe code are not flagged."""
def test_allows_comment(self, tmp_path):
code = f"# The old {_DEPRECATED_DB} is deprecated\n"
matches = _write_and_check(tmp_path, code)
assert len(matches) == 0
def test_allows_docstring_style(self, tmp_path):
code = f'"""See {_DEPRECATED_DB} migration guide"""\n'
matches = _write_and_check(tmp_path, code)
assert len(matches) == 0
def test_allows_normal_code(self, tmp_path):
code = """
from database.session_context import get_user_db_session
with get_user_db_session(username) as session:
pass
"""
matches = _write_and_check(tmp_path, code)
assert len(matches) == 0
+484
View File
@@ -0,0 +1,484 @@
"""Tests for log string sanitization."""
import pytest
from local_deep_research.security.log_sanitizer import (
redact_secrets,
sanitize_error_for_client,
sanitize_for_log,
scrub_error,
strip_control_chars,
)
class TestLogSanitizer:
"""Unit tests for sanitize_for_log."""
def test_normal_string_passes_through(self):
assert sanitize_for_log("hello") == "hello"
def test_non_printable_chars_stripped(self):
assert sanitize_for_log("hello\x00world\x07") == "helloworld"
def test_truncation_respects_max_length(self):
result = sanitize_for_log("a" * 100, max_length=10)
assert result == "a" * 7 + "..."
assert len(result) == 10
def test_no_truncation_at_exact_max_length(self):
result = sanitize_for_log("a" * 50, max_length=50)
assert result == "a" * 50
def test_empty_string(self):
assert sanitize_for_log("") == ""
def test_newlines_stripped(self):
assert sanitize_for_log("line1\nline2") == "line1line2"
def test_tabs_stripped(self):
assert sanitize_for_log("col1\tcol2") == "col1col2"
def test_unicode_preserved(self):
assert sanitize_for_log("café") == "café"
def test_cjk_preserved(self):
assert sanitize_for_log("你好") == "你好"
def test_control_chars_stripped_unicode_preserved(self):
assert sanitize_for_log("café\x00\x07") == "café"
class TestStripControlChars:
"""Unit tests for strip_control_chars."""
def test_strips_c0_control_chars(self):
assert strip_control_chars("a\x00b\x1fc") == "abc"
def test_strips_c1_control_chars(self):
assert strip_control_chars("a\x7fb\x9fc") == "abc"
def test_preserves_normal_text(self):
assert strip_control_chars("hello world") == "hello world"
def test_preserves_unicode(self):
assert strip_control_chars("café 你好 émoji") == "café 你好 émoji"
def test_strips_rlo_override(self):
assert strip_control_chars("hello\u202eworld") == "helloworld"
def test_strips_arabic_letter_mark(self):
assert strip_control_chars("hello\u061cworld") == "helloworld"
def test_strips_zero_width_space(self):
assert strip_control_chars("hello\u200bworld") == "helloworld"
def test_strips_bom(self):
assert strip_control_chars("\ufeffhello") == "hello"
def test_strips_word_joiner(self):
assert strip_control_chars("hello\u2060world") == "helloworld"
def test_strips_digit_shape_controls(self):
assert strip_control_chars("hello\u206aworld") == "helloworld"
def test_strips_mixed_format_chars(self):
assert (
strip_control_chars("café\u202e\u200b\ufeff 你好\u2060")
== "café 你好"
)
def test_empty_string(self):
assert strip_control_chars("") == ""
class TestRedactSecrets:
"""Unit tests for redact_secrets."""
def test_redacts_single_secret(self):
result = redact_secrets(
"call to ?key=sk-abc1234567 failed", "sk-abc1234567"
)
assert result == "call to ?key=***REDACTED*** failed"
def test_redacts_multiple_secrets(self):
result = redact_secrets(
"user=alice12345 token=tok-xyz98765",
"alice12345",
"tok-xyz98765",
)
assert "alice12345" not in result
assert "tok-xyz98765" not in result
assert result.count("***REDACTED***") == 2
def test_redacts_all_occurrences(self):
result = redact_secrets("X sk-12345678 Y sk-12345678 Z", "sk-12345678")
assert result == "X ***REDACTED*** Y ***REDACTED*** Z"
def test_none_secret_ignored(self):
assert redact_secrets("message stays put", None) == "message stays put"
def test_empty_secret_ignored(self):
# Replacing the empty string would insert the token between every
# character of the message; this is the load-bearing guard.
assert redact_secrets("message stays put", "") == "message stays put"
def test_short_secret_below_min_length_ignored(self):
# 7 characters is below the default min_length of 8.
result = redact_secrets("password is hunter", "hunter")
assert result == "password is hunter"
def test_min_length_parameter_lowers_threshold(self):
result = redact_secrets("password is hunter", "hunter", min_length=6)
assert result == "password is ***REDACTED***"
def test_no_secrets_returns_message_unchanged(self):
assert redact_secrets("hello world") == "hello world"
def test_empty_message_returned_unchanged(self):
assert redact_secrets("", "sk-abc1234567") == ""
def test_message_without_secret_returned_unchanged(self):
assert redact_secrets("hello world", "sk-abc1234567") == "hello world"
def test_custom_replacement(self):
result = redact_secrets(
"key=sk-abc1234567", "sk-abc1234567", replacement="[KEY]"
)
assert result == "key=[KEY]"
def test_empty_replacement_strips_secret(self):
# Replacement may be empty — strips the secret entirely. This
# is the right answer when the secret's presence itself is
# sensitive (not just its value).
result = redact_secrets(
"before sk-abc1234567 after", "sk-abc1234567", replacement=""
)
assert result == "before after"
def test_overlapping_secrets_redacted_longest_first(self):
# If two secrets overlap (one is a substring of the other), the
# function must apply the longer one first so a shorter
# secret cannot consume part of the longer match. Without
# length-sorting, the test fails: redacting "abc12345" first
# would leave "sk-***REDACTED***" in the message, then the
# longer "sk-abc12345" no longer matches.
result = redact_secrets(
"found sk-abc12345 here", "abc12345", "sk-abc12345"
)
assert result == "found ***REDACTED*** here"
assert "abc12345" not in result
def test_redaction_is_not_recursive(self):
# If a secret happens to equal the replacement token, the
# function does not loop forever — it does a single pass per
# secret and ``str.replace`` is not recursive.
result = redact_secrets("X ***REDACTED*** Y", "***REDACTED***")
# The pre-existing token gets replaced with the same token —
# net no-op, but importantly: no recursion, no exception.
assert result == "X ***REDACTED*** Y"
def test_importable_from_security_package(self):
# ``redact_secrets`` is exported from
# ``local_deep_research.security`` so future callers don't need
# to know the submodule path.
from local_deep_research.security import (
redact_secrets as exported,
)
assert exported is redact_secrets
@pytest.mark.parametrize(
"secret",
[
"sk-abc1234567890",
"AIzaSy-mock-google-key-12345",
"sk-ant-api03-very-long-anthropic-key-12345",
],
)
def test_realistic_provider_key_shapes_redacted(self, secret):
message = f"upstream failed: ?key={secret}&model=x"
assert secret not in redact_secrets(message, secret)
def test_literal_substring_match_only(self):
# Document the contract: URL-encoded or otherwise transformed
# forms are NOT redacted. Callers must pass each form they need
# to scrub.
result = redact_secrets("encoded=%2Bsk-abc12345", "+sk-abc12345")
assert "%2Bsk-abc12345" in result
class TestSanitizeErrorForClient:
"""The client-facing composition used for exception text returned over
HTTP/SSE (e.g. the library download stream)."""
def test_redacts_api_key_in_url(self):
msg = (
"HTTPError for https://api.example.com/doc?api_key=secret1234567890"
)
result = sanitize_error_for_client(msg)
assert "secret1234567890" not in result
def test_redacts_url_embedded_credentials(self):
msg = "ConnectionError: https://alice:hunter2pass@host/file.pdf failed"
result = sanitize_error_for_client(msg)
assert "hunter2pass" not in result
def test_truncates_to_max_length(self):
result = sanitize_error_for_client("e" * 500, max_length=200)
assert len(result) <= 200
def test_credential_scrubbed_before_truncation(self):
# The whole point of scrub-before-truncate: a key sitting past the
# max_length boundary must still be redacted, not merely cut off
# (a truncate-first order would leave the leading chars exposed if
# the cut landed mid-token).
key = "sk-abcdefghijklmnopqrstuvwxyz0123456789"
msg = "x" * 190 + " " + key
result = sanitize_error_for_client(msg, max_length=400)
assert key not in result
assert "sk-abcdefghij" not in result
@pytest.mark.parametrize(
"msg, secret",
[
(
"err https://api.x.com?access_token=SECRET_AT_123 fail",
"SECRET_AT_123",
),
("https://api.x.com?refresh_token=SECRET_RT_123", "SECRET_RT_123"),
(
"https://x.cognitive.microsoft.com?subscription-key=SECRET_SUB1",
"SECRET_SUB1",
),
("oauth ?client_secret=SECRET_CS_123&grant=x", "SECRET_CS_123"),
("?secret_key=SECRET_SK_123&page=2", "SECRET_SK_123"),
(
"401 Authorization: Basic SECRETBASICVALUE123 denied",
"SECRETBASICVALUE123",
),
(
"Authorization: token ghp_TOKENVALUE1234567890 x",
"ghp_TOKENVALUE1234567890",
),
(
"req x-api-key: SECRETXAPIKEYVALUE12345 blocked",
"SECRETXAPIKEYVALUE12345",
),
# Schemeless Authorization carrying a token-shaped raw value.
(
"Authorization: ghp_RAWTOKEN1234567890 rejected",
"ghp_RAWTOKEN1234567890",
),
# Anchored (scheme / x-api-key) values redact regardless of shape,
# incl. purely-alphabetic ones — the anchor rules out prose.
(
"Authorization: Basic ABCDEFGHIJKLMNOP denied",
"ABCDEFGHIJKLMNOP",
),
(
"x-api-key: ALPHAONLYKEYVALUEABCDEFGH blocked",
"ALPHAONLYKEYVALUEABCDEFGH",
),
(
"google AIzaSyD-SECRET_GOOGLE_KEY_1234567890 abc",
"AIzaSyD-SECRET_GOOGLE_KEY_1234567890",
),
# Case-insensitive query params (capitalized names must not bypass).
("https://x.com?API_KEY=SECRET_UP1", "SECRET_UP1"),
("https://x.com?Access_Token=SECRET_MIX1", "SECRET_MIX1"),
],
)
def test_redacts_additional_credential_formats(self, msg, secret):
"""OAuth tokens, Azure subscription keys, Authorization/x-api-key
headers, Google API keys, and case-variant query params must all be
scrubbed (#4633 follow-up)."""
assert secret not in sanitize_error_for_client(msg)
@pytest.mark.parametrize(
"msg, preserved",
[
("page request ?page=2&limit=10", "page=2"),
("Basic understanding of the topic", "understanding"),
("key concepts explained here", "concepts"),
("fetched https://example.com/path?id=42 ok", "id=42"),
# Schemeless "Authorization:" followed by an all-alphabetic word
# is prose, not a token — the token-shaped requirement on the
# schemeless branch keeps it intact.
("401: Authorization: required for this endpoint", "required"),
("Authorization: denied because user lacks role", "denied"),
# Short values below the floors are left intact (x-api-key needs
# >=16, the scheme branch needs >=8).
("Response 403: x-api-key: invalid or expired", "invalid"),
("Header x-api-key: missing from request", "missing"),
("Authorization: bearer ab12 failed", "ab12"),
("x-api-key: abc12 next", "abc12"),
],
)
def test_does_not_over_redact_innocuous_text(self, msg, preserved):
"""Non-credential query params, schemeless-Authorization prose, and
sub-floor values must survive untouched (no false-positive
redaction)."""
result = sanitize_error_for_client(msg)
assert preserved in result
assert "REDACTED" not in result
@pytest.mark.parametrize(
"msg, secret",
[
(
"failed ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 here",
"ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
),
(
"github_pat_11ABCDE0aBcDeFgHiJ_kLmNoPqRsTuVwXyZ0123456789AbCdEf x",
"github_pat_11ABCDE0aBcDeFgHiJ_kLmNoPqRsTuVwXyZ0123456789AbCdEf",
),
("AWS AKIAIOSFODNN7EXAMPLE rejected", "AKIAIOSFODNN7EXAMPLE"),
("creds ASIAY34FZKBOKMUTVV7A expired", "ASIAY34FZKBOKMUTVV7A"),
(
# Prefix split ("xox" + ...) so the literal isn't a scannable
# Slack-token shape that trips GitHub push protection.
"slack " + "xox" + "b-2345678901-abcdefABCDEF123456 failed",
"xox" + "b-2345678901-abcdefABCDEF123456",
),
(
"cfg " + "xox" + "e-1-AB-2345678901-abcdef123456 expired",
"xox" + "e-1-AB-2345678901-abcdef123456",
),
(
"app " + "xa" + "pp-1-A012345-678901234567-abcdef0123 bad",
"xa" + "pp-1-A012345-678901234567-abcdef0123",
),
(
"token ya29.a0AfH6SMBsecretOAUTH_token-12345 expired",
"ya29.a0AfH6SMBsecretOAUTH_token-12345",
),
(
"auth eyJhbGciOiJIUzI1Ni2.eyJzdWIiOiIxMjM0NTY3OD2.dozjgNryP4 bad",
# assert the WHOLE JWT is gone, not just one segment
"eyJhbGciOiJIUzI1Ni2.eyJzdWIiOiIxMjM0NTY3OD2.dozjgNryP4",
),
],
)
def test_redacts_provider_token_prefixes(self, msg, secret):
"""GitHub/AWS/Slack/Google-OAuth tokens and JWTs (canonical gitleaks
prefixes) must be scrubbed from error text."""
assert secret not in sanitize_error_for_client(msg)
@pytest.mark.parametrize(
"msg, preserved",
[
("the AKIApattern doc explains this", "AKIApattern"),
("ghp build pipeline failed", "ghp"),
("see ya29 release notes for details", "ya29"),
("eyJot just an ordinary word here", "eyJot"),
# The generic Slack ``xapp``/``xox`` prefixes require a long
# numeric workspace ID, so hyphenated prose that merely starts
# with them (and carries no 9+ digit run) survives untouched.
("xapp-release-notes-2026 published", "xapp-release-notes-2026"),
("rolling out xapp-config-reload now", "xapp-config-reload"),
("xoxe-release-bundle-2026 attached", "xoxe-release-bundle-2026"),
("module xoxb-internal-doc loaded", "xoxb-internal-doc"),
],
)
def test_token_prefixes_do_not_over_redact_prose(self, msg, preserved):
"""The prefix patterns are distinctive/length-floored — ordinary
prose that merely starts with a prefix is not redacted."""
result = sanitize_error_for_client(msg)
assert preserved in result
assert "REDACTED" not in result
def test_importable_from_security_package(self):
from local_deep_research.security import (
sanitize_error_for_client as exported,
)
assert exported is sanitize_error_for_client
class TestScrubError:
"""Unit tests for scrub_error (the shared dual-scrub helper)."""
def test_scrubs_credential_shapes_from_exception(self):
err = ValueError("401 for Bearer sk-abc1234567890abcdef")
result = scrub_error(err)
assert "sk-abc1234567890abcdef" not in result
def test_redacts_literal_secrets(self):
# A bare key with no recognizable shape only falls to the
# literal pass — the reason the dual-scrub exists.
err = RuntimeError("auth failed for key plainkey12345678")
result = scrub_error(err, "plainkey12345678")
assert "plainkey12345678" not in result
assert "***REDACTED***" in result
def test_accepts_prebuilt_message_string(self):
result = scrub_error("token tok-xyz9876543 rejected", "tok-xyz9876543")
assert "tok-xyz9876543" not in result
def test_non_string_secret_coerced(self):
# A misconfigured numeric secret must not crash the except
# handler that calls this (redact_secrets calls len()).
err = RuntimeError("key 123456789012 invalid")
result = scrub_error(err, 123456789012)
assert "123456789012" not in result
def test_none_and_falsy_secrets_skipped(self):
msg = "plain message"
assert scrub_error(RuntimeError(msg), None, "", 0) == msg
def test_multiple_secrets_all_redacted(self):
# Kills the "forward only the first secret" mutant: every
# truthy secret must survive the coercion. (Longest-first
# ordering itself is pinned by
# test_overlapping_secrets_redacted_longest_first.)
err = RuntimeError("user token1234567 key sk-abc12345 done")
result = scrub_error(err, "token1234567", "abc12345", "sk-abc12345")
assert "token1234567" not in result
assert "abc12345" not in result
def test_pathological_secret_does_not_crash(self):
# The never-raise contract covers secrets too: a value whose
# __bool__/__str__ raises is skipped, not propagated out of the
# except handler; the other secrets are still redacted.
class BadBool:
def __bool__(self):
raise ValueError("nobool")
err = RuntimeError("key goodsecret123456 invalid")
result = scrub_error(err, BadBool(), "goodsecret123456")
assert "goodsecret123456" not in result
def test_unprintable_exception_guarded(self):
class Unprintable(Exception):
def __str__(self):
raise RuntimeError("boom")
result = scrub_error(Unprintable())
assert result == "<unprintable Unprintable>"
def test_matches_engine_scrub_error(self):
# BaseSearchEngine._scrub_error delegates here; the two must
# produce identical output for the same inputs.
from local_deep_research.web_search_engines.search_engine_base import (
BaseSearchEngine,
)
class _Engine(BaseSearchEngine):
def _get_previews(self, query):
return []
def _get_full_content(self, relevant_items):
return relevant_items
# __new__ skips BaseSearchEngine.__init__ — only _scrub_error's
# _secret_attrs lookup matters here.
engine = _Engine.__new__(_Engine)
engine.api_key = "literalsecret123"
err = RuntimeError("denied for literalsecret123")
assert engine._scrub_error(err) == scrub_error(err, "literalsecret123")
def test_exported_from_security_package(self):
from local_deep_research.security import scrub_error as exported
assert exported is scrub_error
@@ -0,0 +1,35 @@
"""Edge-case tests for sanitize_for_log() max_length boundary behavior.
The main test_log_sanitizer.py tests normal truncation (max_length > 3) but
misses the branch at line 42 where max_length <= 3 skips the ellipsis.
"""
from local_deep_research.security.log_sanitizer import sanitize_for_log
class TestSanitizeForLogMaxLengthBoundary:
"""Tests for max_length <= 3 branch in sanitize_for_log()."""
def test_max_length_3_no_ellipsis(self):
"""max_length=3 with long input: max_length > 3 is False, so no ellipsis."""
result = sanitize_for_log("abcdef", max_length=3)
# max_length > 3 is False → falls to else → cleaned[:3]
assert result == "abc"
def test_max_length_2_truncates_without_ellipsis(self):
result = sanitize_for_log("abcdef", max_length=2)
assert result == "ab"
def test_max_length_1_single_char(self):
result = sanitize_for_log("abcdef", max_length=1)
assert result == "a"
def test_max_length_0_returns_empty(self):
result = sanitize_for_log("abcdef", max_length=0)
assert result == ""
def test_max_length_4_gets_one_char_plus_ellipsis(self):
"""max_length=4 is > 3, so ellipsis branch applies."""
result = sanitize_for_log("abcdef", max_length=4)
assert result == "a..."
assert len(result) == 4
@@ -0,0 +1,323 @@
"""
Tests verifying that ``@login_required`` rejects unauthenticated requests
correctly across the URL shapes the app actually exposes.
Guards two things:
* Page routes (e.g. ``/news/``, ``/news/subscriptions``) redirect
unauthenticated callers to the login page.
* API routes — including nested blueprints like ``/news/api/...`` —
return a JSON ``401`` instead of an HTML redirect. This is the
regression case that motivated extending ``_is_api_path`` to match
``/api/`` anywhere in the path, not only as a top-level prefix.
The real news/research blueprints pull in heavy optional dependencies
(langchain, pdfplumber, etc.) and are exercised elsewhere. Here we only
care about the auth decorator's behavior at each URL shape, so we
register synthetic routes whose URL paths and decorator stacks mirror
production.
"""
from unittest.mock import patch
import pytest
from flask import Blueprint, Flask, jsonify
from local_deep_research.web.auth.decorators import login_required
@pytest.fixture
def app():
app = Flask(__name__)
app.config["SECRET_KEY"] = "test-secret-key"
app.config["WTF_CSRF_ENABLED"] = False
app.config["TESTING"] = True
# Stub auth blueprint so url_for("auth.login") works for redirects.
auth = Blueprint("auth", __name__)
@auth.route("/login")
def login():
return "Login Page"
app.register_blueprint(auth, url_prefix="/auth")
# Mirror production: news_bp at /news with a nested news_api_bp at /api,
# so the API surfaces at /news/api/... — same nesting that broke
# JSON-vs-HTML detection before _is_api_path() used a substring match.
news_bp = Blueprint("news", __name__)
news_api_bp = Blueprint("news_api", __name__, url_prefix="/api")
@news_bp.route("/")
@login_required
def news_page():
return "News"
@news_bp.route("/subscriptions")
@login_required
def subscriptions_page():
return "Subscriptions"
@news_bp.route("/subscriptions/new")
@login_required
def new_subscription_page():
return "New"
@news_bp.route("/subscriptions/<sid>/edit")
@login_required
def edit_subscription_page(sid):
return f"Edit {sid}"
@news_bp.route("/health")
def news_health():
return jsonify({"status": "ok"})
@news_api_bp.route("/categories", methods=["GET"])
@login_required
def get_categories():
return jsonify({"categories": []})
@news_api_bp.route("/subscribe", methods=["POST"])
@login_required
def subscribe():
return jsonify({"ok": True})
news_bp.register_blueprint(news_api_bp)
app.register_blueprint(news_bp, url_prefix="/news")
# research_bp registered at root, /api/config/limits is a top-level API.
research_bp = Blueprint("research", __name__)
@research_bp.route("/api/config/limits", methods=["GET"])
@login_required
def get_upload_limits():
return jsonify({"limit": 0})
app.register_blueprint(research_bp)
return app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture(autouse=True)
def patch_db_manager():
"""Stub the auth db_manager so we don't touch a real database.
Default is_user_connected=False so the unauthenticated-rejection
tests don't need to opt in. Tests that want to exercise the
authenticated 200 path use the `authenticated_client` fixture
below, which flips this to True and seeds the session.
"""
with patch("local_deep_research.web.auth.decorators.db_manager") as mock_dm:
mock_dm.is_user_connected.return_value = False
yield mock_dm
@pytest.fixture
def authenticated_client(client, patch_db_manager):
"""Test client that passes both auth checks in `login_required`:
a `username` in the session AND `db_manager.is_user_connected` True.
Without this, every test in the file is one-sided (rejection-only)
and a regression that breaks the *allow* path of the decorator —
e.g. the post-rejection branch swallowing valid requests — would
not be caught by any existing test.
"""
patch_db_manager.is_user_connected.return_value = True
with client.session_transaction() as sess:
sess["username"] = "test-user"
return client
class TestNewsPageRoutesRequireAuth:
"""The four news page routes added in PR #3129 must redirect to login
when the caller has no session."""
@pytest.mark.parametrize(
"path",
[
"/news/",
"/news/subscriptions",
"/news/subscriptions/new",
"/news/subscriptions/abc-123/edit",
],
)
def test_unauthenticated_page_redirects_to_login(self, client, path):
response = client.get(path)
assert response.status_code == 302
assert "/auth/login" in response.location
class TestNewsApiRoutesRequireAuth:
"""/news/api/categories must return JSON 401, not an HTML redirect.
This is the regression case for nested-blueprint API path detection."""
def test_unauthenticated_categories_returns_json_401(self, client):
response = client.get("/news/api/categories")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
# Must not be an HTML redirect to /auth/login.
location = response.headers.get("Location") or ""
assert "/auth/login" not in location
class TestResearchApiRoutesRequireAuth:
"""/api/config/limits added in PR #3129 must return JSON 401 for
unauthenticated callers."""
def test_unauthenticated_upload_limits_returns_json_401(self, client):
response = client.get("/api/config/limits")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
class TestNewsApiNonGetMethodsRequireAuth:
"""The decorator runs before the route, so it should reject POST/PUT/
DELETE the same way as GET. Probe one non-GET API endpoint to lock
that in."""
def test_unauthenticated_post_returns_json_401(self, client):
response = client.post("/news/api/subscribe", json={})
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
class TestNewsHealthRouteIsPublic:
"""/news/health is intentionally exempt from @login_required (per PR
#3129). Lock that contract in."""
def test_health_does_not_require_auth(self, client):
response = client.get("/news/health")
assert response.status_code == 200
class TestAuthenticatedRequestsArePassedThrough:
"""Happy-path coverage. Without these tests, the entire file only
verifies that the decorator REJECTS — a regression where it stopped
ALLOWING valid requests would slip through. Mirrors each rejection
test class with a positive case at the same URL shape.
"""
@pytest.mark.parametrize(
"path",
[
"/news/",
"/news/subscriptions",
"/news/subscriptions/new",
"/news/subscriptions/abc-123/edit",
],
)
def test_authenticated_page_returns_200(self, authenticated_client, path):
response = authenticated_client.get(path)
assert response.status_code == 200
def test_authenticated_news_api_categories_returns_200(
self, authenticated_client
):
"""Counterpart to test_unauthenticated_categories_returns_json_401."""
response = authenticated_client.get("/news/api/categories")
assert response.status_code == 200
assert response.is_json
assert response.json == {"categories": []}
def test_authenticated_research_api_limits_returns_200(
self, authenticated_client
):
"""Counterpart to test_unauthenticated_upload_limits_returns_json_401."""
response = authenticated_client.get("/api/config/limits")
assert response.status_code == 200
assert response.is_json
assert response.json == {"limit": 0}
def test_authenticated_post_returns_200(self, authenticated_client):
"""Counterpart to test_unauthenticated_post_returns_json_401."""
response = authenticated_client.post("/news/api/subscribe", json={})
assert response.status_code == 200
assert response.is_json
assert response.json == {"ok": True}
class TestAuthenticatedButDisconnectedDb:
"""Second branch of the decorator: `username` is in session but
`db_manager.is_user_connected` is False. This happens when the
session outlives the encrypted-DB connection (server restart with
encrypted databases enabled). API paths must get JSON 401 with
a different error message ("Database connection required") and
page paths must redirect with the session cleared.
"""
@pytest.fixture
def session_without_db(self, client, patch_db_manager):
# is_user_connected stays False (autouse default), but seed the
# session so we hit the second decorator branch, not the first.
patch_db_manager.is_user_connected.return_value = False
with client.session_transaction() as sess:
sess["username"] = "test-user"
return client
def test_api_path_returns_json_401_db_required(self, session_without_db):
response = session_without_db.get("/news/api/categories")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Database connection required"
def test_page_path_redirects_to_login(self, session_without_db):
response = session_without_db.get("/news/subscriptions")
assert response.status_code == 302
assert "/auth/login" in response.location
class TestUnauthorized401ErrorHandler:
"""The 401 error handler in app_factory.register_error_handlers must
use the same _is_api_path detection as the decorator, so that
abort(401) on a nested API path returns JSON, not an HTML redirect."""
def _build_app(self):
from flask import Flask, abort
from local_deep_research.web.app_factory import register_error_handlers
app = Flask(__name__)
app.config["SECRET_KEY"] = "test"
# Stub auth.login so the redirect target resolves.
auth = Blueprint("auth", __name__)
@auth.route("/login")
def login():
return "Login"
app.register_blueprint(auth, url_prefix="/auth")
@app.route("/news/api/raises")
def raises_nested_api():
abort(401)
@app.route("/dashboard/raises")
def raises_page():
abort(401)
register_error_handlers(app)
return app
def test_abort_401_on_nested_api_returns_json(self):
app = self._build_app()
with app.test_client() as client:
response = client.get("/news/api/raises")
assert response.status_code == 401
assert response.is_json
assert response.json["error"] == "Authentication required"
def test_abort_401_on_page_redirects(self):
app = self._build_app()
with app.test_client() as client:
response = client.get("/dashboard/raises")
assert response.status_code == 302
assert "/auth/login" in response.location
+476
View File
@@ -0,0 +1,476 @@
"""
Module Whitelist Tests
Tests for the dynamic import security module that prevents arbitrary code execution
through user-controlled configuration values.
Security model:
- Only allows relative imports (starting with ".")
- Validates module paths against a strict whitelist of trusted search engine modules
- Validates class names against a strict whitelist of legitimate search engine classes
- Blocks dangerous modules like os, subprocess, sys, etc.
- Prevents path traversal attacks in module paths
"""
import pytest
from unittest.mock import MagicMock, patch
from local_deep_research.security.module_whitelist import (
ALLOWED_CLASS_NAMES,
ALLOWED_MODULE_PATHS,
ModuleNotAllowedError,
SecurityError,
get_safe_module_class,
validate_module_import,
)
class TestAllowedModulePaths:
"""Test the ALLOWED_MODULE_PATHS constant."""
def test_constants_are_relative_paths(self):
"""All allowed module paths should start with '.' (relative imports)."""
for module_path in ALLOWED_MODULE_PATHS:
assert module_path.startswith("."), (
f"Module path '{module_path}' should be a relative import "
f"(starting with '.'). Absolute imports are not allowed for security."
)
def test_whitelist_is_frozen_set(self):
"""ALLOWED_MODULE_PATHS should be a frozenset (immutable)."""
assert isinstance(ALLOWED_MODULE_PATHS, frozenset)
def test_whitelist_contains_expected_engines(self):
"""Whitelist should contain expected search engine modules."""
expected_engines = [
".engines.search_engine_brave",
".engines.search_engine_ddg",
".engines.search_engine_tavily",
".engines.search_engine_wikipedia",
]
for engine in expected_engines:
assert engine in ALLOWED_MODULE_PATHS, (
f"Expected engine '{engine}' not found in whitelist"
)
class TestAllowedClassNames:
"""Test the ALLOWED_CLASS_NAMES constant."""
def test_class_names_whitelist_is_frozen_set(self):
"""ALLOWED_CLASS_NAMES should be a frozenset (immutable)."""
assert isinstance(ALLOWED_CLASS_NAMES, frozenset)
def test_whitelist_contains_expected_classes(self):
"""Whitelist should contain expected search engine classes."""
expected_classes = [
"BraveSearchEngine",
"DuckDuckGoSearchEngine",
"TavilySearchEngine",
"WikipediaSearchEngine",
"BaseSearchEngine",
]
for cls_name in expected_classes:
assert cls_name in ALLOWED_CLASS_NAMES, (
f"Expected class '{cls_name}' not found in whitelist"
)
class TestLegacyAliases:
"""Test backward compatibility aliases."""
def test_module_not_allowed_error_is_security_error(self):
"""ModuleNotAllowedError should be an alias for SecurityError."""
assert ModuleNotAllowedError is SecurityError
class TestValidateModuleImport:
"""Test the validate_module_import function."""
def test_valid_module_and_class(self):
"""Should return True for valid whitelisted module and class."""
result = validate_module_import(
".engines.search_engine_brave", "BraveSearchEngine"
)
assert result is True
def test_valid_base_search_engine(self):
"""Should return True for the base search engine module."""
result = validate_module_import(
".search_engine_base", "BaseSearchEngine"
)
assert result is True
def test_rejects_absolute_path(self):
"""Should reject absolute module paths."""
# Absolute path attempt
result = validate_module_import(
"local_deep_research.web_search_engines.engines.search_engine_brave",
"BraveSearchEngine",
)
assert result is False
def test_rejects_subprocess_module(self):
"""Should reject dangerous subprocess module."""
result = validate_module_import("subprocess", "Popen")
assert result is False
def test_rejects_os_module(self):
"""Should reject dangerous os module."""
result = validate_module_import("os", "system")
assert result is False
def test_rejects_sys_module(self):
"""Should reject dangerous sys module."""
result = validate_module_import("sys", "exit")
assert result is False
def test_case_sensitivity(self):
"""Module and class names should be case-sensitive."""
# Lowercase - should fail
result = validate_module_import(
".engines.search_engine_brave", "bravesearchengine"
)
assert result is False
# Uppercase - should fail
result = validate_module_import(
".ENGINES.SEARCH_ENGINE_BRAVE", "BraveSearchEngine"
)
assert result is False
def test_empty_module_path(self):
"""Should reject empty module path."""
result = validate_module_import("", "BraveSearchEngine")
assert result is False
def test_empty_class_name(self):
"""Should reject empty class name."""
result = validate_module_import(".engines.search_engine_brave", "")
assert result is False
def test_none_module_path(self):
"""Should reject None module path."""
result = validate_module_import(None, "BraveSearchEngine")
assert result is False
def test_none_class_name(self):
"""Should reject None class name."""
result = validate_module_import(".engines.search_engine_brave", None)
assert result is False
def test_valid_module_invalid_class(self):
"""Should reject valid module with invalid class name."""
result = validate_module_import(
".engines.search_engine_brave", "MaliciousClass"
)
assert result is False
def test_invalid_module_valid_class(self):
"""Should reject invalid module with valid class name."""
result = validate_module_import(
".engines.nonexistent_engine", "BraveSearchEngine"
)
assert result is False
class TestGetSafeModuleClass:
"""Test the get_safe_module_class function."""
def test_loads_valid_search_engine(self):
"""Should successfully load a valid whitelisted search engine class."""
# Use a module that exists and should be loadable
cls = get_safe_module_class(".search_engine_base", "BaseSearchEngine")
assert cls is not None
assert cls.__name__ == "BaseSearchEngine"
def test_raises_security_error_for_invalid_module(self):
"""Should raise SecurityError for non-whitelisted module."""
with pytest.raises(SecurityError) as exc_info:
get_safe_module_class("os", "system")
assert "not in the security whitelist" in str(exc_info.value)
def test_rejects_absolute_import_from_own_package(self):
"""Should reject absolute paths — normalization was removed."""
with pytest.raises(SecurityError):
get_safe_module_class(
"local_deep_research.web_search_engines.search_engine_base",
"BaseSearchEngine",
)
def test_raises_security_error_for_foreign_absolute_import(self):
"""Should raise SecurityError for absolute import paths outside our package."""
with pytest.raises(SecurityError):
get_safe_module_class(
"local_deep_research.security.module_whitelist",
"SecurityError",
)
def test_handles_module_not_found(self):
"""Should raise ModuleNotFoundError if whitelisted module doesn't exist."""
# First, temporarily add a fake module to the whitelist for testing
# We can't actually test this without modifying the whitelist, so we mock
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module",
side_effect=ModuleNotFoundError("No module named 'fake'"),
):
with pytest.raises(ModuleNotFoundError):
get_safe_module_class(".engines.fake_module", "FakeEngine")
def test_handles_attribute_not_found(self):
"""Should raise AttributeError if class doesn't exist in module."""
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
mock_module = MagicMock(spec=[]) # Module without the class
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module",
return_value=mock_module,
):
with pytest.raises(AttributeError):
get_safe_module_class(
".engines.search_engine_brave",
"NonExistentClass",
)
def test_uses_default_package_for_relative_imports(self):
"""Should use default package when none provided for relative imports."""
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module"
) as mock_import:
mock_module = MagicMock()
mock_module.FakeEngine = type("FakeEngine", (), {})
mock_import.return_value = mock_module
get_safe_module_class(".engines.fake", "FakeEngine")
# Check that default package was used
mock_import.assert_called_once_with(
".engines.fake",
package="local_deep_research.web_search_engines",
)
def test_uses_custom_package_when_provided(self):
"""Should use custom package when explicitly provided."""
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module"
) as mock_import:
mock_module = MagicMock()
mock_module.FakeEngine = type("FakeEngine", (), {})
mock_import.return_value = mock_module
get_safe_module_class(
".engines.fake",
"FakeEngine",
package="custom.package",
)
mock_import.assert_called_once_with(
".engines.fake",
package="custom.package",
)
def test_rejects_collection_engine_absolute_path(self):
"""Absolute paths are rejected — normalization was removed."""
with pytest.raises(SecurityError):
get_safe_module_class(
"local_deep_research.web_search_engines.engines.search_engine_collection",
"CollectionSearchEngine",
)
def test_rejects_exact_prefix_as_absolute(self):
"""Exact prefix without module suffix is rejected as absolute path."""
with pytest.raises(SecurityError):
get_safe_module_class(
"local_deep_research.web_search_engines",
"BaseSearchEngine",
)
def test_prefix_boundary_rejects_similar_prefix(self):
"""Prefix that extends the package name without a dot should NOT be normalized.
'local_deep_research.web_search_enginesXXX.evil' should not match
the normalization prefix 'local_deep_research.web_search_engines.'.
"""
with pytest.raises(SecurityError):
get_safe_module_class(
"local_deep_research.web_search_enginesXXX.evil",
"BaseSearchEngine",
)
def test_rejects_absolute_path_from_own_package_without_normalization(self):
"""Absolute paths are rejected — registry injection means normalization is unnecessary."""
with pytest.raises(SecurityError):
get_safe_module_class(
"local_deep_research.web_search_engines.engines.search_engine_searxng",
"SearXNGSearchEngine",
)
class TestSecurityScenarios:
"""Test specific security attack scenarios."""
def test_blocks_os_system_import(self):
"""Should block attempts to import os.system for command execution."""
with pytest.raises(SecurityError):
get_safe_module_class("os", "system")
def test_blocks_subprocess_import(self):
"""Should block attempts to import subprocess for command execution."""
with pytest.raises(SecurityError):
get_safe_module_class("subprocess", "Popen")
with pytest.raises(SecurityError):
get_safe_module_class("subprocess", "call")
with pytest.raises(SecurityError):
get_safe_module_class("subprocess", "run")
def test_path_traversal_in_module_path(self):
"""Should block path traversal attempts in module paths."""
# Attempt to break out of expected package
malicious_paths = [
"..os",
"....subprocess",
".engines/../../../os",
".engines/..\\..\\subprocess", # Windows-style
".engines%2F..%2F..%2Fos", # URL-encoded
]
for path in malicious_paths:
result = validate_module_import(path, "system")
assert result is False, (
f"Path traversal attempt '{path}' should be blocked"
)
def test_blocks_builtins_module(self):
"""Should block attempts to import builtins."""
with pytest.raises(SecurityError):
get_safe_module_class("builtins", "eval")
with pytest.raises(SecurityError):
get_safe_module_class("builtins", "__import__")
def test_blocks_importlib_module(self):
"""Should block attempts to import importlib directly."""
with pytest.raises(SecurityError):
get_safe_module_class("importlib", "import_module")
def test_blocks_code_module(self):
"""Should block attempts to import code execution modules."""
with pytest.raises(SecurityError):
get_safe_module_class("code", "interact")
def test_blocks_pickle_module(self):
"""Should block attempts to import pickle (deserialization attacks)."""
with pytest.raises(SecurityError):
get_safe_module_class("pickle", "loads")
def test_blocks_ctypes_module(self):
"""Should block attempts to import ctypes (memory manipulation)."""
with pytest.raises(SecurityError):
get_safe_module_class("ctypes", "CDLL")
def test_blocks_socket_module(self):
"""Should block attempts to import socket (network access)."""
with pytest.raises(SecurityError):
get_safe_module_class("socket", "socket")
def test_sql_injection_style_attack(self):
"""Should block SQL-injection-style attacks in module names."""
malicious_inputs = [
(".engines; import os;", "system"),
('.engines"; import os; "', "system"),
(".engines' OR '1'='1", "BraveSearchEngine"),
]
for module_path, class_name in malicious_inputs:
result = validate_module_import(module_path, class_name)
assert result is False, (
f"Injection attempt '{module_path}' should be blocked"
)
def test_unicode_bypass_attempt(self):
"""Should handle unicode normalization bypass attempts."""
# These are unlikely to work but good to test
unicode_attempts = [
"\u006fs", # 'o' followed by 's' -> 'os'
"o\u0073", # 'o' followed by 's' -> 'os'
]
for module_path in unicode_attempts:
# Even if they normalize to 'os', should still be blocked
result = validate_module_import(module_path, "system")
assert result is False
class TestSecurityErrorException:
"""Test the SecurityError exception class."""
def test_security_error_is_exception(self):
"""SecurityError should be an Exception."""
assert issubclass(SecurityError, Exception)
def test_security_error_message(self):
"""SecurityError should contain helpful error message."""
error = SecurityError("Test error message")
assert "Test error message" in str(error)
def test_security_error_can_be_raised_and_caught(self):
"""SecurityError should be raisable and catchable."""
with pytest.raises(SecurityError) as exc_info:
raise SecurityError("blocked import")
assert "blocked import" in str(exc_info.value)
class TestDocumentation:
"""Documentation tests explaining the security model."""
@pytest.mark.skip(reason="documentation/placeholder test - not implemented")
def test_security_model_documentation(self):
"""
Document the module whitelist security model.
WHY THIS EXISTS:
- Dynamic imports from user configuration can lead to arbitrary code execution
- Attackers could inject malicious module paths to run arbitrary code
- Example: config.module = "os"; config.class = "system" -> command execution
THE WHITELIST APPROACH:
- Only explicitly approved modules can be loaded
- Only explicitly approved class names can be instantiated
- All imports must be relative (starting with '.') for additional safety
- Relative imports are anchored to 'local_deep_research.web_search_engines'
DEFENSE IN DEPTH:
- Whitelist validation before any import attempt
- Relative import requirement prevents direct access to dangerous modules
- Class name whitelist prevents instantiation of dangerous classes
- Logging of blocked attempts for security auditing
SECURITY MODEL:
| Input Type | Action | Reason |
|-------------------------|------------|-------------------------------------|
| Relative + listed | ALLOWED | Trusted search engine module |
| Absolute (any) | BLOCKED | Could access dangerous modules |
| Unlisted module | BLOCKED | Not in trusted whitelist |
| Unlisted class | BLOCKED | Could be dangerous class |
| Empty/None | BLOCKED | Invalid input |
"""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: DELETE).
assert True # Documentation test
@@ -0,0 +1,353 @@
"""
Behavioral tests for security/module_whitelist module.
Tests the whitelist validation logic, security error handling,
and module/class name filtering.
"""
import pytest
class TestAllowedModulePaths:
"""Tests for ALLOWED_MODULE_PATHS constant."""
def test_is_frozenset(self):
"""ALLOWED_MODULE_PATHS is a frozenset."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
assert isinstance(ALLOWED_MODULE_PATHS, frozenset)
def test_is_non_empty(self):
"""ALLOWED_MODULE_PATHS is non-empty."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
assert len(ALLOWED_MODULE_PATHS) > 0
def test_all_paths_start_with_dot(self):
"""All paths start with '.' (relative imports only)."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
for path in ALLOWED_MODULE_PATHS:
assert path.startswith("."), (
f"Path {path!r} does not start with '.'"
)
def test_all_paths_are_strings(self):
"""All paths are strings."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
for path in ALLOWED_MODULE_PATHS:
assert isinstance(path, str)
def test_contains_brave_engine(self):
"""Contains brave search engine module."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
assert ".engines.search_engine_brave" in ALLOWED_MODULE_PATHS
def test_contains_searxng_engine(self):
"""Contains SearXNG search engine module."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
assert ".engines.search_engine_searxng" in ALLOWED_MODULE_PATHS
def test_contains_base_engine(self):
"""Contains search_engine_base module."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
assert ".search_engine_base" in ALLOWED_MODULE_PATHS
def test_does_not_contain_os(self):
"""Does not contain 'os' module."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
assert "os" not in ALLOWED_MODULE_PATHS
def test_does_not_contain_subprocess(self):
"""Does not contain 'subprocess' module."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
)
assert "subprocess" not in ALLOWED_MODULE_PATHS
def test_legacy_alias_matches(self):
"""ALLOWED_MODULES is the same as ALLOWED_MODULE_PATHS."""
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
ALLOWED_MODULES,
)
assert ALLOWED_MODULES is ALLOWED_MODULE_PATHS
class TestAllowedClassNames:
"""Tests for ALLOWED_CLASS_NAMES constant."""
def test_is_frozenset(self):
"""ALLOWED_CLASS_NAMES is a frozenset."""
from local_deep_research.security.module_whitelist import (
ALLOWED_CLASS_NAMES,
)
assert isinstance(ALLOWED_CLASS_NAMES, frozenset)
def test_is_non_empty(self):
"""ALLOWED_CLASS_NAMES is non-empty."""
from local_deep_research.security.module_whitelist import (
ALLOWED_CLASS_NAMES,
)
assert len(ALLOWED_CLASS_NAMES) > 0
def test_all_names_are_strings(self):
"""All class names are strings."""
from local_deep_research.security.module_whitelist import (
ALLOWED_CLASS_NAMES,
)
for name in ALLOWED_CLASS_NAMES:
assert isinstance(name, str)
def test_contains_brave_search_engine(self):
"""Contains BraveSearchEngine class."""
from local_deep_research.security.module_whitelist import (
ALLOWED_CLASS_NAMES,
)
assert "BraveSearchEngine" in ALLOWED_CLASS_NAMES
def test_contains_base_search_engine(self):
"""Contains BaseSearchEngine class."""
from local_deep_research.security.module_whitelist import (
ALLOWED_CLASS_NAMES,
)
assert "BaseSearchEngine" in ALLOWED_CLASS_NAMES
def test_does_not_contain_generic_names(self):
"""Does not contain generic dangerous class names."""
from local_deep_research.security.module_whitelist import (
ALLOWED_CLASS_NAMES,
)
assert "Popen" not in ALLOWED_CLASS_NAMES
assert "system" not in ALLOWED_CLASS_NAMES
class TestSecurityError:
"""Tests for SecurityError exception."""
def test_is_exception_subclass(self):
"""SecurityError is an Exception subclass."""
from local_deep_research.security.module_whitelist import SecurityError
assert issubclass(SecurityError, Exception)
def test_can_be_raised_and_caught(self):
"""SecurityError can be raised and caught."""
from local_deep_research.security.module_whitelist import SecurityError
with pytest.raises(SecurityError):
raise SecurityError("test")
def test_message_preserved(self):
"""Error message is preserved."""
from local_deep_research.security.module_whitelist import SecurityError
err = SecurityError("blocked import")
assert str(err) == "blocked import"
def test_legacy_alias(self):
"""ModuleNotAllowedError is same as SecurityError."""
from local_deep_research.security.module_whitelist import (
SecurityError,
ModuleNotAllowedError,
)
assert ModuleNotAllowedError is SecurityError
class TestValidateModuleImport:
"""Tests for validate_module_import function."""
def test_valid_module_and_class(self):
"""Returns True for whitelisted module and class."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(
".engines.search_engine_brave", "BraveSearchEngine"
)
assert result is True
def test_invalid_module_path(self):
"""Returns False for non-whitelisted module."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(
".engines.evil_module", "BraveSearchEngine"
)
assert result is False
def test_invalid_class_name(self):
"""Returns False for non-whitelisted class."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(
".engines.search_engine_brave", "EvilClass"
)
assert result is False
def test_both_invalid(self):
"""Returns False when both module and class are invalid."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(".evil.module", "EvilClass")
assert result is False
def test_empty_module_path(self):
"""Returns False for empty module path."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import("", "BraveSearchEngine")
assert result is False
def test_empty_class_name(self):
"""Returns False for empty class name."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(".engines.search_engine_brave", "")
assert result is False
def test_none_module_path(self):
"""Returns False for None module path."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(None, "BraveSearchEngine")
assert result is False
def test_none_class_name(self):
"""Returns False for None class name."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(".engines.search_engine_brave", None)
assert result is False
def test_rejects_absolute_import_os(self):
"""Rejects absolute import of 'os' module."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import("os", "system")
assert result is False
def test_rejects_absolute_import_subprocess(self):
"""Rejects absolute import of 'subprocess' module."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import("subprocess", "Popen")
assert result is False
def test_rejects_non_relative_whitelisted_path(self):
"""Rejects non-relative path even if it looks similar to whitelisted one."""
from local_deep_research.security.module_whitelist import (
validate_module_import,
)
result = validate_module_import(
"engines.search_engine_brave", "BraveSearchEngine"
)
assert result is False
class TestGetSafeModuleClass:
"""Tests for get_safe_module_class function security validation."""
def test_raises_for_non_whitelisted_module(self):
"""Raises SecurityError for non-whitelisted module."""
from local_deep_research.security.module_whitelist import (
get_safe_module_class,
SecurityError,
)
with pytest.raises(SecurityError) as exc_info:
get_safe_module_class(".evil.module", "EvilClass")
assert "not in the security whitelist" in str(exc_info.value)
def test_raises_for_non_whitelisted_class(self):
"""Raises SecurityError for non-whitelisted class name."""
from local_deep_research.security.module_whitelist import (
get_safe_module_class,
SecurityError,
)
with pytest.raises(SecurityError) as exc_info:
get_safe_module_class(".engines.search_engine_brave", "EvilClass")
assert "not in the security whitelist" in str(exc_info.value)
def test_raises_for_absolute_import(self):
"""Raises SecurityError for absolute import path."""
from local_deep_research.security.module_whitelist import (
get_safe_module_class,
SecurityError,
)
with pytest.raises(SecurityError):
get_safe_module_class("os", "system")
def test_error_message_includes_module_path(self):
"""Error message includes the rejected module path."""
from local_deep_research.security.module_whitelist import (
get_safe_module_class,
SecurityError,
)
with pytest.raises(SecurityError) as exc_info:
get_safe_module_class(".evil.module", "EvilClass")
assert ".evil.module" in str(exc_info.value)
def test_error_message_includes_class_name(self):
"""Error message includes the rejected class name."""
from local_deep_research.security.module_whitelist import (
get_safe_module_class,
SecurityError,
)
with pytest.raises(SecurityError) as exc_info:
get_safe_module_class(".evil.module", "EvilClass")
assert "EvilClass" in str(exc_info.value)
@@ -0,0 +1,494 @@
"""
Extended tests for the module whitelist security system.
Tests the whitelist constants, validation functions, error hierarchy,
and security-critical edge cases in local_deep_research.security.module_whitelist.
"""
import pytest
from unittest.mock import MagicMock, patch
from local_deep_research.security.module_whitelist import (
ALLOWED_MODULE_PATHS,
ALLOWED_CLASS_NAMES,
ALLOWED_MODULES,
SecurityError,
ModuleNotAllowedError,
validate_module_import,
get_safe_module_class,
)
# ---------------------------------------------------------------------------
# ALLOWED_MODULE_PATHS
# ---------------------------------------------------------------------------
class TestAllowedModulePathsExtended:
"""Extended tests for the ALLOWED_MODULE_PATHS constant."""
def test_is_frozenset(self):
"""ALLOWED_MODULE_PATHS must be a frozenset (immutable)."""
assert isinstance(ALLOWED_MODULE_PATHS, frozenset)
def test_contains_brave_engine(self):
"""Whitelist contains the Brave search engine module."""
assert ".engines.search_engine_brave" in ALLOWED_MODULE_PATHS
def test_contains_arxiv_engine(self):
"""Whitelist contains the ArXiv search engine module."""
assert ".engines.search_engine_arxiv" in ALLOWED_MODULE_PATHS
def test_all_entries_start_with_dot(self):
"""Every entry must start with '.' enforcing relative-import-only policy."""
for path in ALLOWED_MODULE_PATHS:
assert path.startswith("."), (
f"Path {path!r} violates the relative-import-only policy"
)
def test_does_not_contain_os(self):
"""Whitelist must never contain the 'os' module."""
assert "os" not in ALLOWED_MODULE_PATHS
def test_does_not_contain_subprocess(self):
"""Whitelist must never contain the 'subprocess' module."""
assert "subprocess" not in ALLOWED_MODULE_PATHS
def test_does_not_contain_sys(self):
"""Whitelist must never contain the 'sys' module."""
assert "sys" not in ALLOWED_MODULE_PATHS
def test_no_dangerous_stdlib_paths(self):
"""Whitelist must not contain any well-known dangerous stdlib modules."""
dangerous = {
"os",
"sys",
"subprocess",
"shutil",
"ctypes",
"socket",
"pickle",
"shelve",
"code",
"codeop",
"compileall",
"importlib",
"builtins",
"io",
"signal",
}
for module in dangerous:
assert module not in ALLOWED_MODULE_PATHS, (
f"Dangerous module {module!r} must not appear in whitelist"
)
def test_immutability(self):
"""frozenset must refuse mutation attempts."""
with pytest.raises(AttributeError):
ALLOWED_MODULE_PATHS.add("os") # type: ignore[attr-defined]
with pytest.raises(AttributeError):
ALLOWED_MODULE_PATHS.discard( # type: ignore[attr-defined]
".engines.search_engine_brave"
)
# ---------------------------------------------------------------------------
# ALLOWED_CLASS_NAMES
# ---------------------------------------------------------------------------
class TestAllowedClassNamesExtended:
"""Extended tests for the ALLOWED_CLASS_NAMES constant."""
def test_is_frozenset(self):
"""ALLOWED_CLASS_NAMES must be a frozenset (immutable)."""
assert isinstance(ALLOWED_CLASS_NAMES, frozenset)
def test_contains_brave_search_engine(self):
"""Whitelist contains the BraveSearchEngine class."""
assert "BraveSearchEngine" in ALLOWED_CLASS_NAMES
def test_contains_arxiv_search_engine(self):
"""Whitelist contains the ArXivSearchEngine class."""
assert "ArXivSearchEngine" in ALLOWED_CLASS_NAMES
def test_does_not_contain_system(self):
"""Whitelist must not contain 'system' (os.system target)."""
assert "system" not in ALLOWED_CLASS_NAMES
def test_does_not_contain_exec(self):
"""Whitelist must not contain 'exec' (code execution target)."""
assert "exec" not in ALLOWED_CLASS_NAMES
def test_does_not_contain_eval(self):
"""Whitelist must not contain 'eval' (code execution target)."""
assert "eval" not in ALLOWED_CLASS_NAMES
def test_no_dangerous_class_names(self):
"""Whitelist must not contain any well-known dangerous class/function names."""
dangerous = {
"system",
"exec",
"eval",
"Popen",
"call",
"run",
"loads",
"load",
"CDLL",
"interact",
"__import__",
"import_module",
"compile",
}
for name in dangerous:
assert name not in ALLOWED_CLASS_NAMES, (
f"Dangerous name {name!r} must not appear in class whitelist"
)
def test_immutability(self):
"""frozenset must refuse mutation attempts."""
with pytest.raises(AttributeError):
ALLOWED_CLASS_NAMES.add("system") # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# ALLOWED_MODULES (legacy alias)
# ---------------------------------------------------------------------------
class TestAllowedModulesAlias:
"""Tests for the ALLOWED_MODULES legacy alias."""
def test_is_same_object_as_allowed_module_paths(self):
"""ALLOWED_MODULES must be the exact same object as ALLOWED_MODULE_PATHS."""
assert ALLOWED_MODULES is ALLOWED_MODULE_PATHS
# ---------------------------------------------------------------------------
# SecurityError and ModuleNotAllowedError
# ---------------------------------------------------------------------------
class TestSecurityErrorHierarchy:
"""Tests for the SecurityError and ModuleNotAllowedError exception classes."""
def test_security_error_is_exception_subclass(self):
"""SecurityError must be a subclass of Exception."""
assert issubclass(SecurityError, Exception)
def test_module_not_allowed_error_is_security_error(self):
"""ModuleNotAllowedError must be the same class as SecurityError (legacy alias)."""
assert ModuleNotAllowedError is SecurityError
def test_security_error_is_catchable_as_exception(self):
"""SecurityError instances must be catchable as plain Exception."""
with pytest.raises(Exception):
raise SecurityError("test")
def test_module_not_allowed_error_is_catchable_as_security_error(self):
"""ModuleNotAllowedError instances must be catchable as SecurityError."""
with pytest.raises(SecurityError):
raise ModuleNotAllowedError("test")
def test_security_error_preserves_message(self):
"""SecurityError must preserve its error message."""
err = SecurityError("blocked import attempt")
assert str(err) == "blocked import attempt"
# ---------------------------------------------------------------------------
# validate_module_import
# ---------------------------------------------------------------------------
class TestValidateModuleImportExtended:
"""Extended tests for the validate_module_import function."""
# --- happy-path ---
def test_valid_module_and_valid_class_returns_true(self):
"""Returns True when both module and class are whitelisted."""
assert (
validate_module_import(
".engines.search_engine_brave", "BraveSearchEngine"
)
is True
)
# --- single-invalid dimension ---
def test_invalid_module_valid_class_returns_false(self):
"""Returns False when only the module is not whitelisted."""
assert (
validate_module_import(
".engines.nonexistent_engine", "BraveSearchEngine"
)
is False
)
def test_valid_module_invalid_class_returns_false(self):
"""Returns False when only the class is not whitelisted."""
assert (
validate_module_import(".engines.search_engine_brave", "EvilClass")
is False
)
# --- both-invalid dimension ---
def test_both_invalid_returns_false(self):
"""Returns False when neither module nor class is whitelisted."""
assert validate_module_import(".evil.module", "EvilClass") is False
# --- empty / None inputs ---
def test_empty_module_path_returns_false(self):
"""Returns False for an empty string module_path."""
assert validate_module_import("", "BraveSearchEngine") is False
def test_empty_class_name_returns_false(self):
"""Returns False for an empty string class_name."""
assert (
validate_module_import(".engines.search_engine_brave", "") is False
)
def test_none_module_path_returns_false(self):
"""Returns False when module_path is None."""
assert validate_module_import(None, "BraveSearchEngine") is False
def test_none_class_name_returns_false(self):
"""Returns False when class_name is None."""
assert (
validate_module_import(".engines.search_engine_brave", None)
is False
)
def test_both_none_returns_false(self):
"""Returns False when both arguments are None."""
assert validate_module_import(None, None) is False
# --- CRITICAL SECURITY: non-relative paths ---
def test_non_relative_os_returns_false(self):
"""CRITICAL: bare 'os' module must be rejected as non-relative path."""
assert validate_module_import("os", "system") is False
def test_absolute_fully_qualified_path_returns_false(self):
"""Absolute fully-qualified module path must be rejected."""
assert (
validate_module_import(
"local_deep_research.web_search_engines.engines.search_engine_brave",
"BraveSearchEngine",
)
is False
)
def test_double_dot_prefix_returns_false(self):
"""Path starting with '..' must be rejected (not in whitelist)."""
assert validate_module_import("..os", "system") is False
def test_triple_dot_prefix_returns_false(self):
"""Path starting with '...' must be rejected."""
assert validate_module_import("...os", "system") is False
def test_non_relative_subprocess_returns_false(self):
"""Bare 'subprocess' module must be rejected."""
assert validate_module_import("subprocess", "Popen") is False
def test_non_relative_sys_returns_false(self):
"""Bare 'sys' module must be rejected."""
assert validate_module_import("sys", "exit") is False
# --- path traversal attempts ---
def test_path_traversal_slash(self):
"""Path traversal using forward slashes must be rejected."""
assert validate_module_import(".engines/../../../os", "system") is False
def test_path_traversal_backslash(self):
"""Path traversal using backslashes must be rejected."""
assert (
validate_module_import(".engines\\..\\..\\subprocess", "Popen")
is False
)
# --- case sensitivity ---
def test_case_sensitive_module_path(self):
"""Module paths are case-sensitive -- upper-cased variant must be rejected."""
assert (
validate_module_import(
".ENGINES.SEARCH_ENGINE_BRAVE", "BraveSearchEngine"
)
is False
)
def test_case_sensitive_class_name(self):
"""Class names are case-sensitive -- lower-cased variant must be rejected."""
assert (
validate_module_import(
".engines.search_engine_brave", "bravesearchengine"
)
is False
)
# --- whitespace / special chars ---
def test_whitespace_module_path_returns_false(self):
"""A whitespace-only module_path must be rejected (not starting with '.')."""
assert validate_module_import(" ", "BraveSearchEngine") is False
def test_module_path_with_trailing_space_returns_false(self):
"""Module path with trailing space must not match whitelist entry."""
assert (
validate_module_import(
".engines.search_engine_brave ", "BraveSearchEngine"
)
is False
)
def test_class_name_with_trailing_space_returns_false(self):
"""Class name with trailing space must not match whitelist entry."""
assert (
validate_module_import(
".engines.search_engine_brave", "BraveSearchEngine "
)
is False
)
# --- injection-style inputs ---
def test_semicolon_injection_in_module_path(self):
"""Semicolon-based injection attempt in module path must be rejected."""
assert validate_module_import(".engines; import os;", "system") is False
def test_newline_injection_in_module_path(self):
"""Newline injection attempt in module path must be rejected."""
assert validate_module_import(".engines\nimport os", "system") is False
# ---------------------------------------------------------------------------
# get_safe_module_class
# ---------------------------------------------------------------------------
class TestGetSafeModuleClassExtended:
"""Extended tests for the get_safe_module_class function."""
def test_raises_security_error_for_non_whitelisted_module(self):
"""Raises SecurityError when the module path is not whitelisted."""
with pytest.raises(SecurityError):
get_safe_module_class(".evil.module", "BraveSearchEngine")
def test_raises_security_error_for_non_whitelisted_class(self):
"""Raises SecurityError when the class name is not whitelisted."""
with pytest.raises(SecurityError):
get_safe_module_class(
".engines.search_engine_brave", "MaliciousClass"
)
def test_raises_security_error_for_absolute_os(self):
"""Raises SecurityError for absolute path 'os'."""
with pytest.raises(SecurityError):
get_safe_module_class("os", "system")
def test_raises_security_error_for_subprocess(self):
"""Raises SecurityError for absolute path 'subprocess'."""
with pytest.raises(SecurityError):
get_safe_module_class("subprocess", "Popen")
def test_error_message_contains_module_path(self):
"""The SecurityError message must include the rejected module path."""
with pytest.raises(SecurityError, match=r"\.evil\.module"):
get_safe_module_class(".evil.module", "EvilClass")
def test_error_message_contains_class_name(self):
"""The SecurityError message must include the rejected class name."""
with pytest.raises(SecurityError, match="EvilClass"):
get_safe_module_class(".evil.module", "EvilClass")
def test_error_message_mentions_whitelist(self):
"""The SecurityError message must mention 'whitelist' for clarity."""
with pytest.raises(SecurityError, match="whitelist"):
get_safe_module_class("os", "system")
# --- default package for relative imports ---
def test_default_package_used_for_relative_import(self):
"""When no package kwarg is given, the default package is used for relative imports."""
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module",
) as mock_import:
mock_module = MagicMock()
mock_module.FakeEngine = type("FakeEngine", (), {})
mock_import.return_value = mock_module
get_safe_module_class(".engines.fake", "FakeEngine")
mock_import.assert_called_once_with(
".engines.fake",
package="local_deep_research.web_search_engines",
)
# --- custom package parameter ---
def test_custom_package_parameter_is_used(self):
"""When a custom package kwarg is given, it overrides the default."""
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module",
) as mock_import:
mock_module = MagicMock()
mock_module.FakeEngine = type("FakeEngine", (), {})
mock_import.return_value = mock_module
get_safe_module_class(
".engines.fake",
"FakeEngine",
package="my.custom.package",
)
mock_import.assert_called_once_with(
".engines.fake",
package="my.custom.package",
)
# --- downstream exception propagation ---
def test_module_not_found_error_propagates(self):
"""ModuleNotFoundError propagates when a whitelisted module cannot be found."""
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module",
side_effect=ModuleNotFoundError("No module named 'fake'"),
):
with pytest.raises(ModuleNotFoundError):
get_safe_module_class(".engines.fake_module", "FakeEngine")
def test_attribute_error_propagates(self):
"""AttributeError propagates when the class is missing from the module."""
with patch(
"local_deep_research.security.module_whitelist.validate_module_import",
return_value=True,
):
mock_module = MagicMock(spec=[])
with patch(
"local_deep_research.security.module_whitelist.importlib.import_module",
return_value=mock_module,
):
with pytest.raises(AttributeError):
get_safe_module_class(
".engines.search_engine_brave",
"NonExistentClass",
)
@@ -0,0 +1,236 @@
"""High-value tests for security/module_whitelist.py - Module Import Security."""
from unittest.mock import patch, MagicMock
import pytest
from local_deep_research.security.module_whitelist import (
validate_module_import,
get_safe_module_class,
ALLOWED_MODULE_PATHS,
ALLOWED_CLASS_NAMES,
ALLOWED_MODULES,
SecurityError,
ModuleNotAllowedError,
)
# ---------------------------------------------------------------------------
# ALLOWED_MODULE_PATHS constraints
# ---------------------------------------------------------------------------
class TestAllowedModulePaths:
"""Whitelist structure validation."""
def test_is_frozenset(self):
assert isinstance(ALLOWED_MODULE_PATHS, frozenset)
def test_all_start_with_dot(self):
for path in ALLOWED_MODULE_PATHS:
assert path.startswith("."), (
f"Module path {path!r} must start with '.'"
)
def test_legacy_alias_same_object(self):
assert ALLOWED_MODULES is ALLOWED_MODULE_PATHS
def test_known_engine_paths_present(self):
expected = [
".engines.search_engine_brave",
".engines.search_engine_ddg",
".engines.search_engine_arxiv",
".engines.search_engine_wikipedia",
".engines.search_engine_tavily",
]
for path in expected:
assert path in ALLOWED_MODULE_PATHS
def test_not_empty(self):
assert len(ALLOWED_MODULE_PATHS) > 0
# ---------------------------------------------------------------------------
# ALLOWED_CLASS_NAMES constraints
# ---------------------------------------------------------------------------
class TestAllowedClassNames:
"""Class name whitelist validation."""
def test_is_frozenset(self):
assert isinstance(ALLOWED_CLASS_NAMES, frozenset)
def test_known_classes_present(self):
expected = [
"BraveSearchEngine",
"DuckDuckGoSearchEngine",
"ArXivSearchEngine",
"WikipediaSearchEngine",
"TavilySearchEngine",
"BaseSearchEngine",
]
for cls_name in expected:
assert cls_name in ALLOWED_CLASS_NAMES
def test_not_empty(self):
assert len(ALLOWED_CLASS_NAMES) > 0
# ---------------------------------------------------------------------------
# SecurityError / ModuleNotAllowedError
# ---------------------------------------------------------------------------
class TestSecurityError:
def test_is_exception(self):
assert issubclass(SecurityError, Exception)
def test_alias_is_same_class(self):
assert ModuleNotAllowedError is SecurityError
def test_can_raise_and_catch(self):
with pytest.raises(SecurityError):
raise SecurityError("blocked")
# ---------------------------------------------------------------------------
# validate_module_import()
# ---------------------------------------------------------------------------
class TestValidateModuleImport:
"""Module + class pair validation."""
def test_valid_pair_accepted(self):
assert (
validate_module_import(
".engines.search_engine_brave", "BraveSearchEngine"
)
is True
)
def test_non_relative_path_rejected(self):
assert validate_module_import("os", "system") is False
def test_absolute_dotted_path_rejected(self):
assert (
validate_module_import(
"local_deep_research.web_search_engines.engines.search_engine_brave",
"BraveSearchEngine",
)
is False
)
def test_empty_module_rejected(self):
assert validate_module_import("", "BraveSearchEngine") is False
def test_empty_class_rejected(self):
assert (
validate_module_import(".engines.search_engine_brave", "") is False
)
def test_both_empty_rejected(self):
assert validate_module_import("", "") is False
def test_unlisted_module_rejected(self):
assert (
validate_module_import(
".engines.search_engine_evil", "BraveSearchEngine"
)
is False
)
def test_unlisted_class_rejected(self):
assert (
validate_module_import(".engines.search_engine_brave", "EvilEngine")
is False
)
def test_both_unlisted_rejected(self):
assert validate_module_import(".evil_module", "EvilClass") is False
# ---------------------------------------------------------------------------
# get_safe_module_class()
# ---------------------------------------------------------------------------
class TestGetSafeModuleClass:
"""Safe dynamic import."""
def test_invalid_module_raises_security_error(self):
with pytest.raises(
SecurityError, match="not in the security whitelist"
):
get_safe_module_class("os", "system")
def test_invalid_class_raises_security_error(self):
with pytest.raises(
SecurityError, match="not in the security whitelist"
):
get_safe_module_class(".engines.search_engine_brave", "EvilClass")
def test_whitelisted_class_non_whitelisted_module_rejected(self):
"""Whitelisted class name with non-whitelisted module still raises."""
with pytest.raises(
SecurityError, match="not in the security whitelist"
):
get_safe_module_class(
".engines.search_engine_evil", "BraveSearchEngine"
)
@patch(
"local_deep_research.security.module_whitelist.importlib.import_module"
)
def test_valid_pair_imports_and_returns_class(self, mock_import):
mock_module = MagicMock()
mock_module.BraveSearchEngine = type("BraveSearchEngine", (), {})
mock_import.return_value = mock_module
cls = get_safe_module_class(
".engines.search_engine_brave", "BraveSearchEngine"
)
assert cls is mock_module.BraveSearchEngine
mock_import.assert_called_once_with(
".engines.search_engine_brave",
package="local_deep_research.web_search_engines",
)
@patch(
"local_deep_research.security.module_whitelist.importlib.import_module"
)
def test_custom_package_used(self, mock_import):
mock_module = MagicMock()
mock_module.BraveSearchEngine = type("BraveSearchEngine", (), {})
mock_import.return_value = mock_module
get_safe_module_class(
".engines.search_engine_brave",
"BraveSearchEngine",
package="custom.package",
)
mock_import.assert_called_once_with(
".engines.search_engine_brave", package="custom.package"
)
@patch(
"local_deep_research.security.module_whitelist.importlib.import_module"
)
def test_module_not_found_propagates(self, mock_import):
mock_import.side_effect = ModuleNotFoundError("No module")
with pytest.raises(ModuleNotFoundError):
get_safe_module_class(
".engines.search_engine_brave", "BraveSearchEngine"
)
@patch(
"local_deep_research.security.module_whitelist.importlib.import_module"
)
def test_attribute_error_propagates(self, mock_import):
mock_module = MagicMock(spec=[]) # no attributes
mock_import.return_value = mock_module
with pytest.raises(AttributeError):
get_safe_module_class(
".engines.search_engine_brave", "BraveSearchEngine"
)
+161
View File
@@ -0,0 +1,161 @@
"""Tests for network_utils module - IP address classification."""
from local_deep_research.security.network_utils import is_private_ip
class TestIsPrivateIPLocalhost:
"""Tests for localhost detection."""
def test_localhost_string(self):
"""Should detect 'localhost' as private."""
assert is_private_ip("localhost") is True
def test_loopback_ipv4(self):
"""Should detect 127.0.0.1 as private."""
assert is_private_ip("127.0.0.1") is True
def test_loopback_ipv6_bracketed(self):
"""Should detect [::1] as private."""
assert is_private_ip("[::1]") is True
def test_all_zeros_ipv4(self):
"""Should detect 0.0.0.0 as private."""
assert is_private_ip("0.0.0.0") is True
class TestIsPrivateIPIPv4Ranges:
"""Tests for IPv4 private address range detection."""
def test_10_0_0_0_range(self):
"""Should detect 10.x.x.x as private (RFC 1918)."""
assert is_private_ip("10.0.0.1") is True
assert is_private_ip("10.255.255.255") is True
assert is_private_ip("10.100.50.25") is True
def test_172_16_0_0_range(self):
"""Should detect 172.16-31.x.x as private (RFC 1918)."""
assert is_private_ip("172.16.0.1") is True
assert is_private_ip("172.31.255.255") is True
assert is_private_ip("172.20.10.5") is True
def test_192_168_0_0_range(self):
"""Should detect 192.168.x.x as private (RFC 1918)."""
assert is_private_ip("192.168.0.1") is True
assert is_private_ip("192.168.255.255") is True
assert is_private_ip("192.168.1.100") is True
def test_link_local_range(self):
"""Should detect 169.254.x.x as private (link-local)."""
assert is_private_ip("169.254.1.1") is True
assert is_private_ip("169.254.255.255") is True
def test_loopback_range(self):
"""Should detect 127.x.x.x as private (loopback)."""
assert is_private_ip("127.0.0.1") is True
assert is_private_ip("127.255.255.255") is True
assert is_private_ip("127.100.50.25") is True
class TestIsPrivateIPIPv6:
"""Tests for IPv6 private address detection."""
def test_ipv6_loopback(self):
"""Should detect IPv6 loopback as private."""
assert is_private_ip("::1") is True
def test_ipv6_unique_local(self):
"""Should detect fc00::/7 as private (unique local)."""
assert is_private_ip("fc00::1") is True
assert is_private_ip("fd00::1") is True
def test_ipv6_link_local(self):
"""Should detect fe80::/10 as private (link-local)."""
assert is_private_ip("fe80::1") is True
def test_ipv6_bracketed(self):
"""Should handle bracketed IPv6 addresses."""
assert is_private_ip("[::1]") is True
assert is_private_ip("[fc00::1]") is True
assert is_private_ip("[fe80::1]") is True
class TestIsPrivateIPPublicAddresses:
"""Tests for public address detection."""
def test_public_ipv4_google_dns(self):
"""Should not detect Google DNS (8.8.8.8) as private."""
assert is_private_ip("8.8.8.8") is False
def test_public_ipv4_cloudflare_dns(self):
"""Should not detect Cloudflare DNS (1.1.1.1) as private."""
assert is_private_ip("1.1.1.1") is False
def test_public_ipv4_example(self):
"""Should not detect example.com IP as private."""
assert is_private_ip("93.184.216.34") is False
def test_public_ipv4_various(self):
"""Should not detect various public IPs as private."""
# Note: 203.0.113.x is TEST-NET-3 (RFC 5737), considered private/reserved
assert is_private_ip("104.26.10.222") is False # Cloudflare
assert is_private_ip("142.250.190.46") is False # Google
assert is_private_ip("151.101.1.140") is False # Reddit
def test_public_ipv6(self):
"""Should not detect public IPv6 addresses as private."""
assert is_private_ip("2001:4860:4860::8888") is False # Google DNS
assert is_private_ip("2606:4700:4700::1111") is False # Cloudflare
class TestIsPrivateIPHostnames:
"""Tests for hostname handling."""
def test_public_hostname(self):
"""Should not detect public hostnames as private."""
assert is_private_ip("example.com") is False
assert is_private_ip("api.openai.com") is False
assert is_private_ip("google.com") is False
def test_local_domain(self):
"""Should detect .local domains as private (mDNS)."""
assert is_private_ip("mydevice.local") is True
assert is_private_ip("printer.local") is True
assert is_private_ip("server.local") is True
def test_local_domain_case_sensitive(self):
"""Should only match lowercase .local."""
# Note: actual behavior depends on implementation
# The current implementation is case-sensitive for .local
assert is_private_ip("mydevice.local") is True
# These won't match the .local check, but aren't valid IPs either
# so they'll return False
def test_internal_subdomain(self):
"""Should not detect internal.corp as private (no DNS resolution)."""
# The function doesn't resolve DNS for security reasons
assert is_private_ip("internal.corp") is False
assert is_private_ip("server.internal") is False
class TestIsPrivateIPEdgeCases:
"""Tests for edge cases."""
def test_172_15_not_private(self):
"""172.15.x.x should NOT be private (only 172.16-31 are)."""
# This is technically public IP space
assert is_private_ip("172.15.0.1") is False
def test_172_32_not_private(self):
"""172.32.x.x should NOT be private (only 172.16-31 are)."""
assert is_private_ip("172.32.0.1") is False
def test_empty_string(self):
"""Should handle empty string."""
# Empty string is not a valid IP and doesn't end with .local
assert is_private_ip("") is False
def test_invalid_ip_format(self):
"""Should handle invalid IP formats."""
assert is_private_ip("not.an.ip.address") is False
assert is_private_ip("256.256.256.256") is False
assert is_private_ip("192.168.1") is False
@@ -0,0 +1,323 @@
"""
Behavioral tests for network_utils module.
Tests the is_private_ip function which determines if IP addresses
or hostnames are private/local.
"""
class TestIsPrivateIPv4Loopback:
"""Tests for IPv4 loopback address detection."""
def test_localhost_string_is_private(self):
"""Localhost hostname is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("localhost") is True
def test_127_0_0_1_is_private(self):
"""127.0.0.1 loopback is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("127.0.0.1") is True
def test_127_0_0_0_is_private(self):
"""Start of loopback range is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("127.0.0.0") is True
def test_127_255_255_255_is_private(self):
"""End of loopback range is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("127.255.255.255") is True
def test_0_0_0_0_is_private(self):
"""0.0.0.0 is treated as private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("0.0.0.0") is True
class TestIsPrivateIPv4RFC1918:
"""Tests for RFC 1918 private IPv4 ranges."""
def test_10_0_0_1_is_private(self):
"""10.0.0.1 (Class A private) is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("10.0.0.1") is True
def test_10_255_255_255_is_private(self):
"""End of 10.x.x.x range is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("10.255.255.255") is True
def test_172_16_0_1_is_private(self):
"""172.16.0.1 (Class B private start) is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("172.16.0.1") is True
def test_172_31_255_255_is_private(self):
"""172.31.255.255 (Class B private end) is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("172.31.255.255") is True
def test_172_15_0_1_is_not_private(self):
"""172.15.0.1 is outside private range."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("172.15.0.1") is False
def test_172_32_0_1_is_not_private(self):
"""172.32.0.1 is outside private range."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("172.32.0.1") is False
def test_192_168_0_1_is_private(self):
"""192.168.0.1 (Class C private) is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("192.168.0.1") is True
def test_192_168_255_255_is_private(self):
"""192.168.255.255 (Class C private end) is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("192.168.255.255") is True
class TestIsPrivateIPv4Public:
"""Tests for public IPv4 addresses."""
def test_8_8_8_8_is_not_private(self):
"""Google DNS is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("8.8.8.8") is False
def test_1_1_1_1_is_not_private(self):
"""Cloudflare DNS is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("1.1.1.1") is False
def test_208_67_222_222_is_not_private(self):
"""OpenDNS is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("208.67.222.222") is False
def test_93_184_216_34_is_not_private(self):
"""example.com IP is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("93.184.216.34") is False
class TestIsPrivateIPv4LinkLocal:
"""Tests for link-local IPv4 addresses."""
def test_169_254_0_1_is_private(self):
"""Link-local start is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("169.254.0.1") is True
def test_169_254_255_254_is_private(self):
"""Link-local end is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("169.254.255.254") is True
def test_169_253_0_1_is_not_link_local(self):
"""Address just below link-local range."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("169.253.0.1") is False
class TestIsPrivateIPv6:
"""Tests for IPv6 addresses."""
def test_ipv6_loopback_is_private(self):
"""IPv6 loopback is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("::1") is True
def test_bracketed_ipv6_loopback_is_private(self):
"""Bracketed IPv6 loopback is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("[::1]") is True
def test_fc00_prefix_is_private(self):
"""fc00::/7 unique local is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("fc00::1") is True
def test_fd00_prefix_is_private(self):
"""fd00:: unique local is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("fd00::1") is True
def test_fe80_prefix_is_private(self):
"""fe80:: link-local is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("fe80::1") is True
def test_2001_4860_is_not_private(self):
"""Google IPv6 is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("2001:4860:4860::8888") is False
def test_bracketed_ipv6_public(self):
"""Bracketed public IPv6 is not private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("[2001:4860:4860::8888]") is False
class TestIsPrivateMDNS:
"""Tests for mDNS .local domains."""
def test_local_domain_is_private(self):
"""Host.local is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("myhost.local") is True
def test_nested_local_domain_is_private(self):
"""subdomain.host.local is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("sub.myhost.local") is True
def test_local_only_is_private(self):
"""Just .local is private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("something.local") is True
def test_localhost_is_local_domain(self):
"""localhost ends with local but is handled separately."""
from local_deep_research.security.network_utils import is_private_ip
# localhost is handled as special case before .local check
assert is_private_ip("localhost") is True
class TestIsPrivateHostnames:
"""Tests for regular hostnames."""
def test_public_hostname_is_not_private(self):
"""api.openai.com is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("api.openai.com") is False
def test_google_hostname_is_not_private(self):
"""google.com is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("google.com") is False
def test_example_hostname_is_not_private(self):
"""example.com is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("example.com") is False
def test_github_hostname_is_not_private(self):
"""github.com is public."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("github.com") is False
def test_localhost_subdomain_is_not_special(self):
"""subdomain.localhost is not treated as localhost."""
from local_deep_research.security.network_utils import is_private_ip
# subdomain.localhost is not in the special list
# and doesn't end with .local
assert is_private_ip("sub.localhost") is False
class TestIsPrivateEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_empty_string_is_not_private(self):
"""Empty string returns False."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("") is False
def test_invalid_ip_format(self):
"""Invalid IP format is not private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("999.999.999.999") is False
def test_partial_ip_is_not_private(self):
"""Partial IP like 192.168 is not valid."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("192.168") is False
def test_ip_with_port_is_hostname(self):
"""IP:port is treated as hostname."""
from local_deep_research.security.network_utils import is_private_ip
# 192.168.1.1:8080 is not a valid IP address
# It will be treated as a hostname
assert is_private_ip("192.168.1.1:8080") is False
def test_negative_octets_invalid(self):
"""Negative octets are invalid."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("-1.0.0.0") is False
def test_whitespace_not_private(self):
"""Whitespace-only string is not private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip(" ") is False
def test_special_characters_not_private(self):
"""Special characters not private."""
from local_deep_research.security.network_utils import is_private_ip
assert is_private_ip("@#$%") is False
class TestIsPrivateCarrierGradeNAT:
"""Tests for Carrier-Grade NAT (CGNAT) addresses."""
def test_100_64_0_1_is_private(self):
"""100.64.0.1 (CGNAT) is private."""
from local_deep_research.security.network_utils import is_private_ip
# CGNAT range is 100.64.0.0/10, which is_private considers private
# because it's in the shared address space
result = is_private_ip("100.64.0.1")
# This could be True or False depending on implementation
# CGNAT is technically "shared" not "private" in RFC terms
assert isinstance(result, bool)
def test_100_127_255_255_is_in_cgnat_range(self):
"""100.127.255.255 (end of CGNAT) boundary check."""
from local_deep_research.security.network_utils import is_private_ip
result = is_private_ip("100.127.255.255")
assert isinstance(result, bool)
@@ -0,0 +1,901 @@
"""Tests for notification_validator module - notification service URL validation."""
from unittest.mock import patch
import socket
import pytest
from local_deep_research.security.notification_validator import (
NotificationURLValidationError,
NotificationURLValidator,
)
class TestNotificationURLValidationError:
"""Tests for NotificationURLValidationError exception."""
def test_inherits_from_value_error(self):
"""Should inherit from ValueError."""
assert issubclass(NotificationURLValidationError, ValueError)
def test_can_be_raised_with_message(self):
"""Should be raisable with a message."""
with pytest.raises(
NotificationURLValidationError, match="test message"
):
raise NotificationURLValidationError("test message")
class TestIsPrivateIP:
"""Tests for _is_private_ip static method."""
def test_localhost_string(self):
"""Should detect 'localhost' as private."""
assert NotificationURLValidator._is_private_ip("localhost") is True
def test_localhost_uppercase(self):
"""Should detect 'LOCALHOST' as private (case-insensitive)."""
assert NotificationURLValidator._is_private_ip("LOCALHOST") is True
def test_loopback_ipv4(self):
"""Should detect 127.0.0.1 as private."""
assert NotificationURLValidator._is_private_ip("127.0.0.1") is True
def test_loopback_ipv6(self):
"""Should detect ::1 as private."""
assert NotificationURLValidator._is_private_ip("::1") is True
def test_all_zeros_ipv4(self):
"""Should detect 0.0.0.0 as private."""
assert NotificationURLValidator._is_private_ip("0.0.0.0") is True
def test_all_zeros_ipv6(self):
"""Should detect :: as private."""
assert NotificationURLValidator._is_private_ip("::") is True
def test_private_10_range(self):
"""Should detect 10.x.x.x as private."""
assert NotificationURLValidator._is_private_ip("10.0.0.1") is True
assert NotificationURLValidator._is_private_ip("10.255.255.255") is True
def test_private_172_range(self):
"""Should detect 172.16-31.x.x as private."""
assert NotificationURLValidator._is_private_ip("172.16.0.1") is True
assert NotificationURLValidator._is_private_ip("172.31.255.255") is True
def test_private_192_range(self):
"""Should detect 192.168.x.x as private."""
assert NotificationURLValidator._is_private_ip("192.168.0.1") is True
assert (
NotificationURLValidator._is_private_ip("192.168.255.255") is True
)
def test_link_local_ipv4(self):
"""Should detect link-local 169.254.x.x as private."""
assert NotificationURLValidator._is_private_ip("169.254.1.1") is True
def test_public_ipv4(self):
"""Should not detect public IPs as private."""
assert NotificationURLValidator._is_private_ip("8.8.8.8") is False
assert NotificationURLValidator._is_private_ip("1.1.1.1") is False
assert NotificationURLValidator._is_private_ip("93.184.216.34") is False
def test_hostname_resolving_to_public_ip(self):
"""Should return False for hostnames that resolve to public IPs."""
fake_result = [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0)),
]
with patch("socket.getaddrinfo", return_value=fake_result):
assert (
NotificationURLValidator._is_private_ip("example.com") is False
)
def test_hostname_resolving_to_private_ip(self):
"""Should return True for hostnames that resolve to private IPs."""
fake_result = [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 0)),
]
with patch("socket.getaddrinfo", return_value=fake_result):
assert (
NotificationURLValidator._is_private_ip("evil.example.com")
is True
)
def test_hostname_dns_failure_returns_false(self):
"""Should return False when DNS resolution fails."""
with patch(
"socket.getaddrinfo",
side_effect=socket.gaierror("Name not resolved"),
):
assert (
NotificationURLValidator._is_private_ip("nonexistent.invalid")
is False
)
class TestValidateServiceUrl:
"""Tests for validate_service_url static method."""
def test_empty_url_rejected(self):
"""Should reject empty URLs."""
is_valid, error = NotificationURLValidator.validate_service_url("")
assert is_valid is False
assert "non-empty string" in error
def test_none_url_rejected(self):
"""Should reject None URLs."""
is_valid, error = NotificationURLValidator.validate_service_url(None)
assert is_valid is False
assert "non-empty string" in error
def test_non_string_url_rejected(self):
"""Should reject non-string URLs."""
is_valid, error = NotificationURLValidator.validate_service_url(123)
assert is_valid is False
assert "non-empty string" in error
def test_url_without_scheme_rejected(self):
"""Should reject URLs without protocol scheme."""
is_valid, error = NotificationURLValidator.validate_service_url(
"example.com/webhook"
)
assert is_valid is False
assert "must have a protocol" in error
def test_parse_error_does_not_leak_exception_text(self):
"""A urlparse failure must return a generic message, not the
exception text. The validator error is surfaced to the user by the
/api/notifications/test-url endpoint, so leaking the exception would
expose stack-trace fragments (CWE-209, CodeQL py/stack-trace-exposure,
alert #4775)."""
secret_marker = "INTERNAL-PARSER-DETAIL-do-not-leak"
with patch(
"local_deep_research.security.notification_validator.urlparse",
side_effect=ValueError(secret_marker),
):
is_valid, error = NotificationURLValidator.validate_service_url(
"https://example.com/webhook"
)
assert is_valid is False
assert secret_marker not in error
assert error == "Invalid URL format"
def test_parse_error_real_input_does_not_leak(self):
"""Real-input companion to the mocked test: an unbalanced IPv6
bracket makes the stdlib urlparse raise ``ValueError: Invalid IPv6
URL``, which must surface as the generic message. Guards the reachable
path against a refactor that stops calling urlparse (or a future
CPython that stops raising) — something the mocked test cannot catch."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://[::1"
)
assert is_valid is False
assert error == "Invalid URL format"
def test_file_scheme_blocked(self):
"""Should block file:// scheme."""
is_valid, error = NotificationURLValidator.validate_service_url(
"file:///etc/passwd"
)
assert is_valid is False
assert "Blocked unsafe protocol" in error
assert "file" in error
def test_ftp_scheme_blocked(self):
"""Should block ftp:// scheme."""
is_valid, error = NotificationURLValidator.validate_service_url(
"ftp://ftp.example.com"
)
assert is_valid is False
assert "Blocked unsafe protocol" in error
def test_javascript_scheme_blocked(self):
"""Should block javascript: scheme."""
is_valid, error = NotificationURLValidator.validate_service_url(
"javascript:alert(1)"
)
assert is_valid is False
assert "Blocked unsafe protocol" in error
def test_data_scheme_blocked(self):
"""Should block data: scheme."""
is_valid, error = NotificationURLValidator.validate_service_url(
"data:text/html,<script>alert(1)</script>"
)
assert is_valid is False
assert "Blocked unsafe protocol" in error
def test_unknown_scheme_rejected(self):
"""Should reject unknown/unsupported schemes."""
is_valid, error = NotificationURLValidator.validate_service_url(
"custom://example.com"
)
assert is_valid is False
assert "Unsupported protocol" in error
def test_https_valid(self):
"""Should accept https:// URLs to public hosts."""
is_valid, error = NotificationURLValidator.validate_service_url(
"https://webhook.example.com/notify"
)
assert is_valid is True
assert error is None
def test_http_valid(self):
"""Should accept http:// URLs to public hosts."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://webhook.example.com/notify"
)
assert is_valid is True
assert error is None
def test_discord_scheme_valid(self):
"""Should accept discord:// URLs."""
is_valid, error = NotificationURLValidator.validate_service_url(
"discord://webhook_id/webhook_token"
)
assert is_valid is True
assert error is None
def test_slack_scheme_valid(self):
"""Should accept slack:// URLs."""
is_valid, error = NotificationURLValidator.validate_service_url(
"slack://token_a/token_b/token_c"
)
assert is_valid is True
assert error is None
def test_telegram_scheme_valid(self):
"""Should accept telegram:// URLs."""
is_valid, error = NotificationURLValidator.validate_service_url(
"telegram://bot_token/chat_id"
)
assert is_valid is True
assert error is None
def test_mailto_scheme_valid(self):
"""Should accept mailto: URLs."""
is_valid, error = NotificationURLValidator.validate_service_url(
"mailto://user@example.com"
)
assert is_valid is True
assert error is None
def test_ntfy_scheme_valid(self):
"""Should accept ntfy:// URLs."""
is_valid, error = NotificationURLValidator.validate_service_url(
"ntfy://topic"
)
assert is_valid is True
assert error is None
def test_ntfys_scheme_valid(self):
"""Should accept ntfys:// URLs (HTTPS variant of ntfy)."""
is_valid, error = NotificationURLValidator.validate_service_url(
"ntfys://topic"
)
assert is_valid is True
assert error is None
def test_http_localhost_blocked(self):
"""Should block http://localhost by default."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://localhost:5000/webhook"
)
assert is_valid is False
assert "Blocked private/internal IP" in error
def test_http_127_blocked(self):
"""Should block http://127.0.0.1 by default."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://127.0.0.1/webhook"
)
assert is_valid is False
assert "Blocked private/internal IP" in error
def test_http_private_ip_blocked(self):
"""Should block http to private IPs by default."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://192.168.1.100/webhook"
)
assert is_valid is False
assert "Blocked private/internal IP" in error
def test_http_localhost_allowed_with_flag(self):
"""Should allow localhost when allow_private_ips=True."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://localhost:5000/webhook", allow_private_ips=True
)
assert is_valid is True
assert error is None
def test_http_private_ip_allowed_with_flag(self):
"""Should allow private IPs when allow_private_ips=True."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://192.168.1.100/webhook", allow_private_ips=True
)
assert is_valid is True
assert error is None
def test_whitespace_stripped(self):
"""Should strip whitespace from URL."""
is_valid, error = NotificationURLValidator.validate_service_url(
" https://example.com/webhook "
)
assert is_valid is True
assert error is None
class TestParserDifferentialBypass:
"""
Tests for the parser-differential SSRF bypass (GHSA-g23j-2vwm-5c25)
in the notification flow. The same bypass that affected
``ssrf_validator.validate_url`` also affected
``NotificationURLValidator.validate_service_url`` because both used
``urlparse(url).hostname`` for the SSRF check.
"""
def test_advisory_canonical_payload_blocked(self):
is_valid, error = NotificationURLValidator.validate_service_url(
"http://127.0.0.1:6666\\@1.1.1.1"
)
assert is_valid is False
assert error is not None
def test_post_prepare_canonicalised_form_blocked(self):
"""Layer-2 verification on the notification flow."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://127.0.0.1:6666/%5C@1.1.1.1"
)
assert is_valid is False
assert error is not None
assert "127.0.0.1" in error # Layer 2 reports the actual host
def test_backslash_no_port(self):
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://127.0.0.1\\@1.1.1.1"
)
assert is_valid is False
def test_tab_in_url_blocked(self):
is_valid, _ = NotificationURLValidator.validate_service_url(
"https://example.com/path\there"
)
assert is_valid is False
def test_null_byte_blocked(self):
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://127.0.0.1\x00@1.1.1.1"
)
assert is_valid is False
def test_apprise_discord_still_works(self):
is_valid, error = NotificationURLValidator.validate_service_url(
"discord://webhook_id/token"
)
assert is_valid is True
assert error is None
def test_apprise_slack_still_works(self):
is_valid, error = NotificationURLValidator.validate_service_url(
"slack://TestApp@TokenA/TokenB/TokenC"
)
assert is_valid is True
assert error is None
def test_apprise_mailto_with_credentials(self):
is_valid, error = NotificationURLValidator.validate_service_url(
"mailto://user:pass@smtp.gmail.com"
)
assert is_valid is True
assert error is None
def test_apprise_signal_url_accepted(self):
"""signal:// (Apprise's Signal-API-REST transport) is allowed.
Regression test for #4006: the validator previously rejected the
Signal scheme with "Unsupported protocol". Apprise handles its
own host validation for non-http schemes, so private-IP hosts
like signal-api-rest containers on the LAN must round-trip.
"""
is_valid, error = NotificationURLValidator.validate_service_url(
"signal://192.168.50.20:8739/+15551234567/+15557654321"
)
assert is_valid is True
assert error is None
def test_ipv6_unspecified_blocked(self):
"""``::`` (and equivalent forms) routes to local host on Linux."""
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://[::]/"
)
assert is_valid is False
def test_ipv6_unspecified_zero_form_blocked(self):
"""``0::`` bypasses the literal-string ``::`` allow-list at
``_is_private_ip`` — must be caught via the ip_address normalisation
path against ``::/128`` in BLOCKED_IP_RANGES."""
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://[0::]/"
)
assert is_valid is False
def test_ipv6_unspecified_full_form_blocked(self):
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://[0:0:0:0:0:0:0:0]/"
)
assert is_valid is False
class TestCloudMetadataBlockedForPluginSchemes:
"""Plugin-scheme IMDS guard.
Apprise translates schemes like signal://host/... into HTTP requests
against the URL host (e.g. POST http://host/v2/send), so cloud-
metadata IPs reached through a plugin scheme would otherwise bypass
the IMDS protection enforced for http/https. ``validate_service_url``
must reject them under every flag combination.
"""
METADATA_IPS = (
"169.254.169.254", # AWS IMDSv1/v2, Azure, OCI, DigitalOcean
"169.254.170.2", # AWS ECS task metadata v3
"169.254.170.23", # AWS ECS task metadata v4
"169.254.0.23", # Tencent Cloud
"100.100.100.200", # AlibabaCloud
)
# Plugin schemes that resolve a user-supplied host into an outbound
# HTTP request under Apprise. (Schemes whose "host" slot holds an
# opaque token — discord, slack, telegram, pushover, teams — are
# covered by the existing positive tests; an IP-shaped token would
# still trip this guard, which is fine.)
HOST_BEARING_SCHEMES = (
"signal",
"gotify",
"ntfy",
"ntfys",
"mattermost",
"rocketchat",
"matrix",
"json",
"xml",
"form",
)
@pytest.mark.parametrize("ip", METADATA_IPS)
@pytest.mark.parametrize("scheme", HOST_BEARING_SCHEMES)
def test_metadata_ip_blocked_by_default(self, scheme, ip):
url = f"{scheme}://{ip}/path"
is_valid, error = NotificationURLValidator.validate_service_url(url)
assert is_valid is False
assert "cloud-metadata" in error.lower()
@pytest.mark.parametrize("ip", METADATA_IPS)
@pytest.mark.parametrize("scheme", HOST_BEARING_SCHEMES)
def test_metadata_ip_blocked_even_with_allow_private_ips(self, scheme, ip):
"""allow_private_ips=True unlocks LAN reach, NOT IMDS."""
url = f"{scheme}://{ip}/path"
is_valid, error = NotificationURLValidator.validate_service_url(
url, allow_private_ips=True
)
assert is_valid is False
assert "cloud-metadata" in error.lower()
def test_mailto_metadata_host_blocked(self):
"""mailto://user@169.254.169.254/... must not reach IMDS."""
is_valid, error = NotificationURLValidator.validate_service_url(
"mailto://user:pass@169.254.169.254/recipient"
)
assert is_valid is False
assert "cloud-metadata" in error.lower()
def test_signal_lan_host_still_allowed(self):
"""LAN signal-api-rest container (#4006 use case) keeps working."""
is_valid, error = NotificationURLValidator.validate_service_url(
"signal://192.168.50.20:8739/+15551234567/+15557654321"
)
assert is_valid is True
assert error is None
def test_gotify_lan_host_still_allowed(self):
is_valid, error = NotificationURLValidator.validate_service_url(
"gotify://10.0.0.5:8080/AbCdEf123"
)
assert is_valid is True
assert error is None
def test_signal_loopback_still_allowed(self):
"""Plugin schemes pointing at localhost (same-host self-hosted
container) round-trip without the operator opt-in — only the
absolute IMDS block fires for plugin schemes."""
is_valid, error = NotificationURLValidator.validate_service_url(
"signal://127.0.0.1:8739/+15551234567/+15557654321"
)
assert is_valid is True
assert error is None
def test_plugin_scheme_token_host_unaffected(self):
"""Schemes whose 'host' slot is an opaque token (discord, slack,
telegram, pushover, teams) keep working — the IMDS check is a
no-op against non-IP strings."""
for url in (
"discord://webhook_id/token",
"slack://TestApp@TokenA/TokenB/TokenC",
"telegram://bottoken/ChatID",
"pushover://user@token",
"teams://group@token1/token2/token3",
):
is_valid, error = NotificationURLValidator.validate_service_url(url)
assert is_valid is True, f"{url} should be valid, got: {error}"
def test_signal_metadata_hostname_via_dns_blocked(self):
"""DNS-resolved hostname pointing at IMDS is rejected — closes
the easy ``signal://imds.attacker.example/...`` variant of the
bypass. (The full DNS-rebinding TOCTOU window is a separately
documented residual risk; this test only covers single-resolve
attackers.)"""
with patch("socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("169.254.169.254", 0),
)
]
is_valid, error = NotificationURLValidator.validate_service_url(
"signal://imds.attacker.example/+15551234567/+15557654321"
)
assert is_valid is False
assert "cloud-metadata" in error.lower()
class TestValidateServiceUrlStrict:
"""Tests for validate_service_url_strict static method."""
def test_valid_url_returns_true(self):
"""Should return True for valid URLs."""
result = NotificationURLValidator.validate_service_url_strict(
"https://example.com/webhook"
)
assert result is True
def test_invalid_url_raises_exception(self):
"""Should raise NotificationURLValidationError for invalid URLs."""
with pytest.raises(NotificationURLValidationError) as exc_info:
NotificationURLValidator.validate_service_url_strict(
"file:///etc/passwd"
)
assert "validation failed" in str(exc_info.value)
def test_private_ip_raises_exception(self):
"""Should raise exception for private IPs by default."""
with pytest.raises(NotificationURLValidationError) as exc_info:
NotificationURLValidator.validate_service_url_strict(
"http://localhost/webhook"
)
assert "Blocked private/internal IP" in str(exc_info.value)
def test_private_ip_allowed_with_flag(self):
"""Should not raise when allow_private_ips=True."""
result = NotificationURLValidator.validate_service_url_strict(
"http://localhost/webhook", allow_private_ips=True
)
assert result is True
class TestValidateMultipleUrls:
"""Tests for validate_multiple_urls static method."""
def test_empty_urls_rejected(self):
"""Should reject empty URL string."""
is_valid, error = NotificationURLValidator.validate_multiple_urls("")
assert is_valid is False
assert "non-empty string" in error
def test_none_urls_rejected(self):
"""Should reject None."""
is_valid, error = NotificationURLValidator.validate_multiple_urls(None)
assert is_valid is False
assert "non-empty string" in error
def test_only_separators_rejected(self):
"""Should reject string with only separators."""
is_valid, error = NotificationURLValidator.validate_multiple_urls(",,,")
assert is_valid is False
assert "No valid URLs found" in error
def test_single_valid_url(self):
"""Should accept single valid URL."""
is_valid, error = NotificationURLValidator.validate_multiple_urls(
"https://example.com/webhook"
)
assert is_valid is True
assert error is None
def test_multiple_valid_urls(self):
"""Should accept multiple valid URLs."""
urls = "https://example.com/webhook,discord://id/token,slack://token"
is_valid, error = NotificationURLValidator.validate_multiple_urls(urls)
assert is_valid is True
assert error is None
def test_one_invalid_url_fails_all(self):
"""Should fail if any URL is invalid."""
urls = "https://example.com/webhook,file:///etc/passwd"
is_valid, error = NotificationURLValidator.validate_multiple_urls(urls)
assert is_valid is False
assert "file" in error.lower()
def test_whitespace_in_urls_stripped(self):
"""Should handle whitespace around URLs."""
urls = " https://example.com/webhook , discord://id/token "
is_valid, error = NotificationURLValidator.validate_multiple_urls(urls)
assert is_valid is True
assert error is None
def test_custom_separator(self):
"""Should support custom separator."""
urls = "https://example.com/webhook|discord://id/token"
is_valid, error = NotificationURLValidator.validate_multiple_urls(
urls, separator="|"
)
assert is_valid is True
assert error is None
def test_private_ip_in_multiple_blocked(self):
"""Should block private IPs in multiple URLs."""
urls = "https://example.com/webhook,http://localhost/webhook"
is_valid, error = NotificationURLValidator.validate_multiple_urls(urls)
assert is_valid is False
assert "Blocked private/internal IP" in error
def test_private_ip_allowed_with_flag(self):
"""Should allow private IPs when flag is set."""
urls = "https://example.com/webhook,http://localhost/webhook"
is_valid, error = NotificationURLValidator.validate_multiple_urls(
urls, allow_private_ips=True
)
assert is_valid is True
assert error is None
class TestClassConstants:
"""Tests for class constants."""
def test_blocked_schemes_contains_dangerous_protocols(self):
"""BLOCKED_SCHEMES should contain dangerous protocols."""
blocked = NotificationURLValidator.BLOCKED_SCHEMES
assert "file" in blocked
assert "ftp" in blocked
assert "javascript" in blocked
assert "data" in blocked
def test_allowed_schemes_contains_common_services(self):
"""ALLOWED_SCHEMES should contain common notification services."""
allowed = NotificationURLValidator.ALLOWED_SCHEMES
assert "http" in allowed
assert "https" in allowed
assert "discord" in allowed
assert "slack" in allowed
assert "telegram" in allowed
assert "mailto" in allowed
assert "ntfys" in allowed
assert "signal" in allowed
def test_private_ip_ranges_exist(self):
"""PRIVATE_IP_RANGES should contain RFC1918 and other private ranges."""
ranges = NotificationURLValidator.PRIVATE_IP_RANGES
assert len(ranges) > 0
# Check some expected ranges are present
range_strings = [str(r) for r in ranges]
assert "127.0.0.0/8" in range_strings
assert "10.0.0.0/8" in range_strings
assert "192.168.0.0/16" in range_strings
class TestNat64EnvOptOutInNotificationValidator:
"""Mirror of ssrf_validator's TestNat64EnvOptOut for the notification
path. The notification validator must honor the same operator
opt-in semantics AND keep the cloud-metadata block absolute."""
def test_nat64_wkp_blocked_when_env_unset(self, monkeypatch):
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
# 64:ff9b::a00:1 is the NAT64 wrap of 10.0.0.1.
assert NotificationURLValidator._is_private_ip("64:ff9b::a00:1") is True
def test_nat64_wkp_allowed_when_env_true(self, monkeypatch):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
# NAT64 wrap of 8.8.8.8 — canonical IPv6-only-deployment use case.
assert (
NotificationURLValidator._is_private_ip("64:ff9b::808:808") is False
)
def test_nat64_local_use_allowed_when_env_true(self, monkeypatch):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b:1::808:808")
is False
)
def test_imds_via_nat64_wkp_wrap_blocked_under_env_true(self, monkeypatch):
"""[64:ff9b::a9fe:a9fe] — NAT64 WKP wrap of 169.254.169.254.
Must remain blocked even with the operator opt-in. Mirrors the
ssrf_validator embedded-IPv4 IMDS check."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b::a9fe:a9fe")
is True
)
def test_imds_via_nat64_local_use_wrap_blocked_under_env_true(
self, monkeypatch
):
"""Same lock-in for the RFC 8215 local-use prefix wrap."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b:1::a9fe:a9fe")
is True
)
def test_ecs_metadata_via_nat64_wrap_blocked_under_env_true(
self, monkeypatch
):
"""169.254.170.2 = 0xa9feaa02 — AWS ECS task metadata v3."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b::a9fe:aa02")
is True
)
def test_alibaba_metadata_via_nat64_wrap_blocked_under_env_true(
self, monkeypatch
):
"""100.100.100.200 = 0x646464c8 — AlibabaCloud metadata."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("64:ff9b::6464:64c8")
is True
)
def test_env_does_not_unblock_6to4_in_notification_path(self, monkeypatch):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert (
NotificationURLValidator._is_private_ip("2002:c0a8:101::") is True
)
def test_env_does_not_unblock_teredo_in_notification_path(
self, monkeypatch
):
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
assert NotificationURLValidator._is_private_ip("2001::1") is True
def test_imds_via_nat64_wrap_blocked_when_env_unset(self, monkeypatch):
"""Sanity: the IMDS embedded-IPv4 check fires regardless of env
state — when env is unset, the NAT64 prefix entry already blocks
directly, but the embedded-IPv4 path is still well-formed."""
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
assert (
NotificationURLValidator._is_private_ip("64:ff9b::a9fe:a9fe")
is True
)
def test_ipv4_mapped_imds_blocked(self, monkeypatch):
"""Cross-validator parity: ssrf_validator unwraps IPv4-mapped
IPv6 (``::ffff:169.254.169.254``) before the IMDS literal check.
notification_validator must do the same — otherwise an attacker
who can configure a webhook URL can reach IMDS via the IPv4-
mapped form. Pre-PR this was a real gap; locked in here so it
cannot regress."""
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
assert (
NotificationURLValidator._is_private_ip("::ffff:169.254.169.254")
is True
)
def test_ipv4_mapped_loopback_blocked(self, monkeypatch):
"""Same parity check for the loopback IPv4-mapped form."""
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
assert (
NotificationURLValidator._is_private_ip("::ffff:127.0.0.1") is True
)
def test_ipv4_mapped_public_ip_passes(self, monkeypatch):
"""Anti-collision: the unwrap must not over-block public IPv4."""
monkeypatch.delenv("LDR_SECURITY_ALLOW_NAT64", raising=False)
assert (
NotificationURLValidator._is_private_ip("::ffff:8.8.8.8") is False
)
def test_validate_service_url_imds_blocked_under_allow_private_ips(self):
"""Round-3 audit regression: validate_service_url with
allow_private_ips=True previously short-circuited the entire
host check, allowing http://169.254.169.254/ through. The opt-in
is for self-hosted webhooks on internal networks, not for IMDS
exfiltration. ALWAYS_BLOCKED_METADATA_IPS must remain absolute."""
is_valid, error = NotificationURLValidator.validate_service_url(
"http://169.254.169.254/latest/meta-data/",
allow_private_ips=True,
)
assert is_valid is False
assert error is not None
def test_validate_service_url_imds_v6_mapped_blocked_under_allow_private_ips(
self,
):
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://[::ffff:169.254.169.254]/", allow_private_ips=True
)
assert is_valid is False
def test_validate_service_url_imds_via_nat64_wkp_blocked_under_allow_private_ips(
self,
):
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://[64:ff9b::a9fe:a9fe]/", allow_private_ips=True
)
assert is_valid is False
def test_validate_service_url_imds_via_nat64_local_use_blocked_under_allow_private_ips(
self,
):
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://[64:ff9b:1::a9fe:a9fe]/", allow_private_ips=True
)
assert is_valid is False
def test_validate_service_url_alibaba_metadata_blocked_under_allow_private_ips(
self,
):
"""100.100.100.200 is in ALWAYS_BLOCKED_METADATA_IPS and ALSO in
the CGNAT range (100.64.0.0/10) — pre-fix the carve-out for
CGNAT under allow_private_ips=True would have leaked it."""
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://100.100.100.200/", allow_private_ips=True
)
assert is_valid is False
def test_validate_service_url_rfc1918_allowed_under_allow_private_ips(self):
"""Anti-collision: the fix must not over-block legitimate
self-hosted webhook destinations. allow_private_ips=True is
designed for exactly this case."""
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://192.168.1.100/webhook", allow_private_ips=True
)
assert is_valid is True
def test_validate_service_url_localhost_allowed_under_allow_private_ips(
self,
):
is_valid, _ = NotificationURLValidator.validate_service_url(
"http://localhost:5000/webhook", allow_private_ips=True
)
assert is_valid is True
def test_dns_resolved_imds_via_nat64_blocked_under_env_true(
self, monkeypatch
):
"""Hostname-resolution branch: a hostname that resolves to a
NAT64-wrapped IMDS IPv4 must still be blocked under env opt-in.
This exercises the second call site of _ip_matches_blocked_range."""
monkeypatch.setenv("LDR_SECURITY_ALLOW_NAT64", "true")
# AF_INET6 result tuple: (family, type, proto, canonname, sockaddr)
# sockaddr for IPv6 is (host, port, flowinfo, scopeid)
with patch(
"socket.getaddrinfo",
return_value=[
(
socket.AF_INET6,
socket.SOCK_STREAM,
6,
"",
("64:ff9b::a9fe:a9fe", 0, 0, 0),
)
],
):
assert (
NotificationURLValidator._is_private_ip("imds.attacker.example")
is True
)
+45
View File
@@ -0,0 +1,45 @@
"""Tests for pagination bounds enforcement (PR #1956).
Verifies:
- History routes clamp limit/offset/page/per_page to safe ranges
- Context overflow time series capped at 1000 for short periods
"""
import inspect
class TestHistoryRoutesPagination:
"""Verify get_history enforces limit/offset bounds."""
def _get_source(self):
from local_deep_research.web.routes.history_routes import get_history
return inspect.getsource(get_history)
def test_limit_clamped_to_max_500(self):
"""Source should clamp limit to max 500."""
source = self._get_source()
assert "min(limit, 500)" in source or "min(limit,500)" in source
def test_limit_clamped_to_min_1(self):
"""Source should clamp limit to min 1."""
source = self._get_source()
assert "max(1," in source
def test_offset_clamped_to_min_0(self):
"""Source should clamp offset to min 0."""
source = self._get_source()
assert "max(0, offset)" in source or "max(0,offset)" in source
class TestContextOverflowCap:
"""Verify time series query has a limit cap."""
def test_short_period_capped_at_1000(self):
"""Short period time series queries should be capped at 1000."""
from local_deep_research.web.routes.context_overflow_api import (
get_context_overflow_metrics,
)
source = inspect.getsource(get_context_overflow_metrics)
assert "limit(1000)" in source
+832
View File
@@ -0,0 +1,832 @@
"""Tests that ``logger.exception`` sites in ``database/encrypted_db.py``,
``web/queue/processor_v2.py``, and ``scheduler/background.py`` never leak
the SQLCipher master password into log output.
Companion fix: issue #4182 (follow-up to #4131). API keys are rotatable,
but SQLCipher master passwords are not (TRUST.md §5) — a leaked password
in a CI log or shared error report forces the user to abandon their
encrypted DB.
The fix pattern, established in PRs #4168/#4175/#4181, is:
except Exception as e:
safe_msg = redact_secrets(str(e), password)
logger.warning(f"... {safe_msg}")
These tests use ``loguru_caplog_full`` to capture the rendered exception
block (the existing ``loguru_caplog`` would false-pass on a leak that
lives only in the traceback frames). Each test is mutation-verified —
revert the production wrap and the test fails with the sentinel visible.
"""
import base64
from unittest.mock import MagicMock, patch
from urllib.parse import quote, quote_plus
import pytest
_LEAKED_PASSWORD = "ldr-master-pw-SHOULD-NEVER-LEAK-987654321"
_LEAKED_OLD_PASSWORD = "ldr-old-master-pw-ALSO-NEVER-LEAK-123"
def _all_encodings_of(secret: str) -> list:
"""Return every encoding the sentinel might appear under in a log.
Mirrors the helper in ``test_api_key_leakage.py``. Extend when a
fixed call site introduces a new transformation (e.g., bcrypt hash,
JWT payload).
"""
return [
secret,
quote(secret, safe=""),
quote_plus(secret),
repr(secret)[1:-1],
base64.b64encode(secret.encode()).decode(),
secret[:8],
]
def _assert_no_leak(text: str, secret: str, where: str) -> None:
"""Helper: assert no encoding of *secret* appears in *text*."""
for encoding in _all_encodings_of(secret):
assert encoding not in text, (
f"password leaked at {where} as encoding {encoding!r}. The "
f"except handler must call redact_secrets(str(e), password) "
f"and use logger.warning (not logger.exception)."
)
# ---------------------------------------------------------------------------
# database/encrypted_db.py
# ---------------------------------------------------------------------------
class TestEncryptedDBPasswordLeakage:
"""``DatabaseManager`` lifecycle methods receive the user's SQLCipher
master password and pass it through to ``set_sqlcipher_key`` /
``create_sqlcipher_connection`` / ``BackupService``. An exception in
any of those paths can carry frame locals containing the plaintext
password under loguru ``diagnose=True``. The except handlers must
drop the traceback chain and redact str(e).
"""
def test_change_password_does_not_leak_either_password(
self, loguru_caplog_full, tmp_path
):
"""``change_password`` has BOTH ``old_password`` and
``new_password`` in lexical scope. A failure in
``open_user_database`` or ``set_sqlcipher_rekey`` whose exception
message embeds either password must not surface to logs.
"""
from local_deep_research.database.encrypted_db import DatabaseManager
manager = DatabaseManager.__new__(DatabaseManager)
manager.has_encryption = True
manager.connections = {}
manager._connections_lock = MagicMock()
manager._connections_lock.__enter__ = lambda *a: None
manager._connections_lock.__exit__ = lambda *a: None
# Drive _get_user_db_path -> a real, existing path so the
# ``if not db_path.exists()`` short-circuit doesn't fire.
fake_db = tmp_path / "fake.db"
fake_db.touch()
manager._get_user_db_path = lambda u: fake_db
manager.close_user_database = lambda u: None
# Construct an exception whose str() embeds BOTH passwords —
# simulates a SQLAlchemy OperationalError that carries frame
# locals from set_sqlcipher_rekey's traceback.
exc = RuntimeError(
f"sqlcipher rekey failed: old={_LEAKED_OLD_PASSWORD} "
f"new={_LEAKED_PASSWORD} target={fake_db}"
)
manager.open_user_database = MagicMock(side_effect=exc)
with loguru_caplog_full.at_level("DEBUG"):
result = manager.change_password(
"alice", _LEAKED_OLD_PASSWORD, _LEAKED_PASSWORD
)
assert result is False
_assert_no_leak(
loguru_caplog_full.text, _LEAKED_PASSWORD, "change_password new_pw"
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_OLD_PASSWORD,
"change_password old_pw",
)
assert "Failed to change password" in loguru_caplog_full.text, (
"test did not exercise the except branch — check the fixtures"
)
def test_open_user_database_outer_catch_does_not_leak(
self, loguru_caplog_full, tmp_path
):
"""The outer ``except Exception`` in ``open_user_database`` (was
line 688 in the issue's listing) fires when the engine fails to
open. ``password`` is in scope and was passed into the engine
creator closure via ``set_sqlcipher_key`` — an OperationalError
traceback could leak it.
"""
from local_deep_research.database.encrypted_db import DatabaseManager
manager = DatabaseManager.__new__(DatabaseManager)
# has_encryption=True drives the SQLCipher-engine branch, which
# uses a custom creator with ``sqlite://`` (no event.listen).
manager.has_encryption = True
manager.connections = {}
# __init__ is bypassed here; mirror its per-user init-lock dict so
# open_user_database -> _get_init_lock has its backing store.
manager._init_locks = {}
manager._connections_lock = MagicMock()
manager._connections_lock.__enter__ = lambda *a: None
manager._connections_lock.__exit__ = lambda *a: None
manager._pool_class = type("StubPool", (), {})
manager._get_pool_kwargs = lambda: {}
db_path = tmp_path / "alice.db"
db_path.touch()
manager._get_user_db_path = lambda u: db_path
# Drive an exception from the SELECT 1 test connect that
# follows engine creation.
exc = RuntimeError(
f"connect failed: dsn=sqlite:///x?key={_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.encrypted_db.create_engine"
) as mock_create:
with patch(
"local_deep_research.database.encrypted_db.has_per_database_salt",
return_value=True,
):
mock_engine = MagicMock()
mock_engine.connect.side_effect = exc
mock_create.return_value = mock_engine
result = manager.open_user_database(
"alice", _LEAKED_PASSWORD
)
assert result is None
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"open_user_database outer catch",
)
assert "Failed to open database" in loguru_caplog_full.text
def test_open_user_database_migration_failure_sanitizes_typed_error(
self, loguru_caplog_full, tmp_path
):
"""A migration failure re-raises as ``DatabaseInitializationError``.
The typed error must carry a redacted message and no exception
chain (``from None``, ADR-0003): callers (e.g.
``thread_local_session``) log it, and the broken chain plus
redacted message ensure the original exception — and its frame
locals holding the password — can't be rendered, defeating the
redaction applied at the raise site.
"""
from local_deep_research.database.encrypted_db import (
DatabaseInitializationError,
DatabaseManager,
)
manager = DatabaseManager.__new__(DatabaseManager)
manager.has_encryption = True
manager.connections = {}
# __init__ is bypassed here; mirror its per-user init-lock dict so
# open_user_database -> _get_init_lock has its backing store.
manager._init_locks = {}
manager._connections_lock = MagicMock()
manager._connections_lock.__enter__ = lambda *a: None
manager._connections_lock.__exit__ = lambda *a: None
manager._pool_class = type("StubPool", (), {})
manager._get_pool_kwargs = lambda: {}
db_path = tmp_path / "alice.db"
db_path.touch()
manager._get_user_db_path = lambda u: db_path
# The SELECT 1 connection check passes; the migration step then
# raises with the password embedded (simulating an
# OperationalError that carries frame locals).
exc = RuntimeError(
f"migration failed: dsn=sqlite:///x?key={_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.encrypted_db.create_engine",
return_value=MagicMock(),
):
with patch(
"local_deep_research.database.encrypted_db.has_per_database_salt",
return_value=True,
):
with patch(
"local_deep_research.database.alembic_runner.needs_migration",
return_value=False,
):
with patch(
"local_deep_research.database.initialize.initialize_database",
side_effect=exc,
):
with pytest.raises(
DatabaseInitializationError
) as excinfo:
manager.open_user_database(
"alice", _LEAKED_PASSWORD
)
# The typed error's own message must be redacted, and the chain
# broken so a downstream logger.exception can't render init_err.
_assert_no_leak(
str(excinfo.value),
_LEAKED_PASSWORD,
"DatabaseInitializationError message",
)
assert excinfo.value.__cause__ is None
assert excinfo.value.__suppress_context__ is True
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"open_user_database migration catch",
)
assert "Database migration failed" in loguru_caplog_full.text
# ---------------------------------------------------------------------------
# web/queue/processor_v2.py
# ---------------------------------------------------------------------------
class TestProcessorV2PasswordLeakage:
"""``QueueProcessorV2._start_research_directly`` receives the user's
password and forwards it to ``start_research_process``. A failure in
the research startup path (or in the active-record DB write) whose
exception embeds the password must not reach logs.
"""
def test_start_research_directly_does_not_leak_on_research_failure(
self, loguru_caplog_full
):
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2.__new__(QueueProcessorV2)
exc = RuntimeError(
f"could not start research thread, "
f"connection url=sqlite:///x.db?pwd={_LEAKED_PASSWORD}"
)
# The initial active-record create + status update succeeds, and
# then start_research_process raises with the password in the
# exception message.
fake_session = MagicMock()
fake_session.__enter__ = lambda s: s
fake_session.__exit__ = lambda s, *a: None
fake_session.query.return_value.filter_by.return_value.first.return_value = None
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.web.queue.processor_v2.get_user_db_session",
return_value=fake_session,
):
with patch(
"local_deep_research.web.queue.processor_v2.UserQueueService"
):
with patch(
"local_deep_research.web.queue.processor_v2.start_research_process",
side_effect=exc,
):
processor._start_research_directly(
username="alice",
research_id="r1",
password=_LEAKED_PASSWORD,
query="test",
mode="quick",
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_start_research_directly",
)
# Sanity: the failure branch ran.
assert (
"Failed to start research" in loguru_caplog_full.text
or "Failed to create active research record"
in loguru_caplog_full.text
)
def test_notify_research_queued_does_not_leak(self, loguru_caplog_full):
"""``notify_research_queued`` retrieves the session password and
opens the user DB in its direct-execution branch, then falls back
to a queue-status update. Both except handlers have ``password``
in scope; an exception from either path must not leak it.
"""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2.__new__(QueueProcessorV2)
exc = RuntimeError(f"open failed: key={_LEAKED_PASSWORD} db=alice.db")
with loguru_caplog_full.at_level("DEBUG"):
with (
patch(
"local_deep_research.web.queue.processor_v2.session_password_store"
) as mock_store,
patch(
"local_deep_research.web.queue.processor_v2.db_manager"
) as mock_db,
patch(
"local_deep_research.web.queue.processor_v2.get_user_db_session",
side_effect=exc,
),
):
mock_store.get_session_password.return_value = _LEAKED_PASSWORD
mock_db.open_user_database.side_effect = exc
processor.notify_research_queued(
"alice", "r1", session_id="s1", query="q", mode="quick"
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"notify_research_queued",
)
# Sanity: both except branches ran (direct-execution error, then
# the queue-status fallback error).
assert "Error in direct execution" in loguru_caplog_full.text
assert "Failed to update queue status" in loguru_caplog_full.text
def test_process_user_queue_does_not_leak(self, loguru_caplog_full):
"""``_process_user_queue`` retrieves the session password before
its try block and passes it into ``open_user_database`` /
``get_user_db_session``. The catch-all must redact it.
"""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2.__new__(QueueProcessorV2)
exc = RuntimeError(f"sqlcipher key rejected: {_LEAKED_PASSWORD}")
with loguru_caplog_full.at_level("DEBUG"):
with (
patch(
"local_deep_research.web.queue.processor_v2.session_password_store"
) as mock_store,
patch(
"local_deep_research.web.queue.processor_v2.db_manager"
) as mock_db,
):
mock_store.get_session_password.return_value = _LEAKED_PASSWORD
mock_db.open_user_database.side_effect = exc
result = processor._process_user_queue("alice", "s1")
assert result is False
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_process_user_queue",
)
assert "Error processing queue for user" in loguru_caplog_full.text
def test_notify_research_completed_does_not_leak(self, loguru_caplog_full):
"""``notify_research_completed`` has ``user_password`` as a
parameter and passes it into ``get_user_db_session``.
"""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2.__new__(QueueProcessorV2)
exc = RuntimeError(
f"session open failed: dsn=sqlite:///x?key={_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
with (
patch(
"local_deep_research.web.queue.processor_v2.get_user_db_session",
side_effect=exc,
),
patch(
"local_deep_research.research_library.search.services.research_history_indexer.auto_convert_research"
),
):
processor.notify_research_completed(
"alice", "r1", user_password=_LEAKED_PASSWORD
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"notify_research_completed",
)
assert "Failed to update completion status" in loguru_caplog_full.text
def test_notify_research_failed_does_not_leak(self, loguru_caplog_full):
"""``notify_research_failed`` — same contract as the completed
notification.
"""
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2.__new__(QueueProcessorV2)
exc = RuntimeError(
f"session open failed: dsn=sqlite:///x?key={_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.web.queue.processor_v2.get_user_db_session",
side_effect=exc,
):
processor.notify_research_failed(
"alice",
"r1",
error_message="boom",
user_password=_LEAKED_PASSWORD,
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"notify_research_failed",
)
assert "Failed to update failure status" in loguru_caplog_full.text
def test_start_queued_researches_spawn_failure_does_not_leak(
self, loguru_caplog_full
):
"""The spawn-failure handler in ``_start_queued_researches`` has
``password`` as a parameter. A spawn exception whose message
embeds it must be redacted — and the rollback-failure debug log
in the same handler must not re-render the traceback.
"""
import threading
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2.__new__(QueueProcessorV2)
processor._spawn_retry_counts = {}
processor._spawn_retry_counts_lock = threading.Lock()
processor._reclaim_stranded_queue_rows = lambda *a: None
exc = RuntimeError(
f"spawn failed: thread env carried pwd={_LEAKED_PASSWORD}"
)
processor._start_research = MagicMock(side_effect=exc)
queued_item = MagicMock()
queued_item.research_id = "r1"
db_session = MagicMock()
query_chain = db_session.query.return_value.filter_by.return_value
query_chain.order_by.return_value.limit.return_value.all.return_value = [
queued_item
]
query_chain.update.return_value = 1 # claim succeeds
# Rollback after the spawn failure raises too, with the password
# embedded — exercises the redacted debug path.
db_session.rollback.side_effect = RuntimeError(
f"rollback failed: {_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
processor._start_queued_researches(
db_session,
MagicMock(), # queue_service
"alice",
_LEAKED_PASSWORD,
available_slots=1,
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_start_queued_researches spawn failure",
)
assert "Error starting queued research" in loguru_caplog_full.text
def test_process_user_request_does_not_leak(self, loguru_caplog_full):
"""``process_user_request`` retrieves the session password inside
its try block (hence the ``password = None`` pre-declaration) and
opens the user DB. The catch-all must redact it.
"""
import threading
from local_deep_research.web.queue.processor_v2 import (
QueueProcessorV2,
)
processor = QueueProcessorV2.__new__(QueueProcessorV2)
processor._users_to_check = set()
processor._users_lock = threading.Lock()
exc = RuntimeError(f"open failed, key in dsn: {_LEAKED_PASSWORD}")
with loguru_caplog_full.at_level("DEBUG"):
with (
patch(
"local_deep_research.web.queue.processor_v2.session_password_store"
) as mock_store,
patch(
"local_deep_research.web.queue.processor_v2.db_manager"
) as mock_db,
):
mock_store.get_session_password.return_value = _LEAKED_PASSWORD
mock_db.open_user_database.side_effect = exc
result = processor.process_user_request("alice", "s1")
assert result == 0
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"process_user_request",
)
assert "Error in process_user_request" in loguru_caplog_full.text
# ---------------------------------------------------------------------------
# scheduler/background.py
# ---------------------------------------------------------------------------
class TestSchedulerBackgroundPasswordLeakage:
"""The scheduler retrieves user passwords from its credential store
and passes them into encrypted-DB session contexts. Every
``except Exception`` site in those flows must scrub the password
from str(e) before logging.
"""
@pytest.fixture
def scheduler(self):
"""Build a ``BackgroundJobScheduler`` with a populated credential
store but no actual APScheduler running — enough for the except
handlers to be exercised in isolation.
"""
from local_deep_research.scheduler.background import (
BackgroundJobScheduler,
SchedulerCredentialStore,
)
sched = BackgroundJobScheduler.__new__(BackgroundJobScheduler)
sched._credential_store = SchedulerCredentialStore(ttl_hours=1)
sched._credential_store.store("alice", _LEAKED_PASSWORD)
sched.user_sessions = {
"alice": {"last_activity": None, "scheduled_jobs": set()}
}
sched.lock = MagicMock()
sched.lock.__enter__ = lambda *a: None
sched.lock.__exit__ = lambda *a: None
sched.scheduler = MagicMock()
sched.is_running = True
sched.config = {}
return sched
def test_trigger_subscription_research_sync_does_not_leak(
self, loguru_caplog_full, scheduler
):
"""``_trigger_subscription_research_sync`` retrieves the
password from the credential store and passes it through to
``get_user_db_session`` and ``quick_summary``. A SQLAlchemy
/ requests exception from any of those paths must not surface
the password.
"""
exc = RuntimeError(f"db session open failed: pwd={_LEAKED_PASSWORD}")
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=exc,
):
scheduler._trigger_subscription_research_sync(
"alice",
{"id": 42, "name": "test sub", "query": "test"},
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_trigger_subscription_research_sync",
)
assert "Error triggering research" in loguru_caplog_full.text
def test_schedule_user_subscriptions_does_not_leak(
self, loguru_caplog_full, scheduler
):
"""``_schedule_user_subscriptions`` retrieves the password and
opens an encrypted DB session. Failure inside that block must
not leak the password.
"""
exc = RuntimeError(
f"NewsSubscription query failed: dsn=sqlite:///x?pwd={_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=exc,
):
# _schedule_user_subscriptions also calls
# _schedule_document_processing at the end — stub it so
# we isolate the failure to the subscription branch.
scheduler._schedule_document_processing = lambda u: None
scheduler._schedule_user_subscriptions("alice")
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_schedule_user_subscriptions",
)
assert "Error scheduling subscriptions" in loguru_caplog_full.text
def test_check_user_overdue_subscriptions_does_not_leak(
self, loguru_caplog_full, scheduler
):
"""``_check_user_overdue_subscriptions`` retrieves the password
and opens an encrypted DB session. Same contract.
"""
exc = RuntimeError(
f"overdue query failed, pwd={_LEAKED_PASSWORD} in dsn"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=exc,
):
scheduler._check_user_overdue_subscriptions("alice")
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_check_user_overdue_subscriptions",
)
assert "Error checking overdue subscriptions" in loguru_caplog_full.text
def test_check_subscription_does_not_leak(
self, loguru_caplog_full, scheduler
):
"""``_check_subscription`` retrieves the password from the
credential store and opens an encrypted DB session. A failure
from that path must not surface the password.
"""
exc = RuntimeError(
f"subscription refresh failed: dsn=sqlite:///x?pwd={_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=exc,
):
scheduler._check_subscription("alice", 42)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_check_subscription",
)
assert "Error checking subscription 42" in loguru_caplog_full.text
def test_get_document_scheduler_settings_does_not_leak(
self, loguru_caplog_full, scheduler
):
"""``_get_document_scheduler_settings`` retrieves the password
before its try block and passes it into ``get_user_db_session``.
On failure it must log redacted and fall back to defaults.
"""
import threading
scheduler._settings_cache = {}
scheduler._settings_cache_lock = threading.Lock()
exc = RuntimeError(
f"settings fetch failed: key={_LEAKED_PASSWORD} db=alice"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=exc,
):
settings = scheduler._get_document_scheduler_settings("alice")
# Falls back to defaults rather than raising.
assert settings is not None
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_get_document_scheduler_settings",
)
assert "Error fetching settings" in loguru_caplog_full.text
def test_store_research_result_does_not_leak(
self, loguru_caplog_full, scheduler
):
"""``_store_research_result`` receives the password as a
function parameter and passes it into ``get_user_db_session``.
A failure anywhere inside the storage block must not surface
the password.
"""
exc = RuntimeError(
f"history insert failed: dsn=sqlite:///x?pwd={_LEAKED_PASSWORD}"
)
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=exc,
):
scheduler._store_research_result(
username="alice",
password=_LEAKED_PASSWORD,
research_id="r1",
subscription_id=42,
result={},
subscription={},
)
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_store_research_result",
)
assert "Error storing research result" in loguru_caplog_full.text
def test_process_user_documents_snapshot_failure_does_not_leak(
self, loguru_caplog_full, scheduler
):
"""The settings-snapshot handler inside ``_process_user_documents``
runs nested inside ``get_user_db_session(username, password)`` —
the password is live in the frame. A failure building the
snapshot must not surface it.
"""
exc = RuntimeError(
f"snapshot build failed: dsn=sqlite:///x?pwd={_LEAKED_PASSWORD}"
)
# Document-scheduler settings: one processing flag enabled so
# the function reaches the snapshot block instead of returning
# early; no last_run so the date filter is skipped.
doc_settings = MagicMock(
download_pdfs=True,
extract_text=False,
generate_rag=False,
last_run=None,
)
scheduler._get_document_scheduler_settings = lambda u: doc_settings
scheduler._arm_egress_backstop = lambda sm, u: None
# One fake completed research session so the snapshot block is
# reached (empty result returns before it).
fake_research = MagicMock(id="r1", title="t", completed_at=None)
fake_db = MagicMock()
fake_db.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [
fake_research
]
fake_session = MagicMock()
fake_session.__enter__ = lambda s: fake_db
fake_session.__exit__ = lambda s, *a: None
with loguru_caplog_full.at_level("DEBUG"):
with patch(
"local_deep_research.database.session_context.get_user_db_session",
return_value=fake_session,
):
with patch(
"local_deep_research.settings.manager.SettingsManager"
) as mock_sm:
mock_sm.return_value.get_settings_snapshot.side_effect = exc
with patch(
"local_deep_research.research_library.services.download_service.DownloadService"
):
scheduler._process_user_documents("alice")
_assert_no_leak(
loguru_caplog_full.text,
_LEAKED_PASSWORD,
"_process_user_documents snapshot handler",
)
assert "Could not build settings snapshot" in loguru_caplog_full.text
@@ -0,0 +1,309 @@
"""AST invariant: no traceback-rendering log call with a password in scope.
Companion to ``test_password_leakage.py`` (issue #4182, PR #4530). That
file proves representative handlers redact correctly at runtime; this one
mechanically enforces the sweep's contract across ALL handlers, so a new
``logger.exception`` (or ``logger.debug(..., exc_info=True)``) added to a
credential-bearing function fails CI instead of silently re-opening the
leak.
Why this matters: loguru ``diagnose=True`` renders frame locals into the
exception block. Any function that holds the user's SQLCipher master
password in a local/parameter and renders a traceback can therefore
persist the plaintext password to log sinks. Unlike API keys, the master
password is unrecoverable (TRUST.md §5).
The required pattern at such sites is::
except Exception as e:
safe_msg = redact_secrets(str(e), password)
logger.warning(f"... {safe_msg}")
Scope and limitations (deliberate, keep in sync with the sweep):
- Only the modules in ``SWEPT_MODULES`` are checked: the files swept by
#4530 plus the password-bearing session helpers added in the #4182
follow-up. Extend ``SWEPT_MODULES`` when a new module starts handling
the master password.
- A "password name" is any identifier containing ``password`` (case-
insensitive), excluding ``session_password_store`` (a module-level store
object, not a credential value).
- The check is per innermost function: a traceback-rendering log call is a
violation if any password name is bound anywhere in the same function.
This is slightly conservative (the name might be bound on a disjoint
branch) — that is fine; the fix is cheap and the false-negative
direction is the dangerous one.
Known blind spots (none triggered by the swept files today; keep logging at
the top function level and rooted at the bare ``logger`` so they stay safe):
- Nested/inner functions: the check attributes a log call to its *innermost*
enclosing function. A traceback log inside a nested function that closes
over an outer ``password`` is NOT flagged, even though diagnose=True
renders outer frames too. Don't put traceback logs in inner functions of
password-bearing functions.
- Aliased loggers: only call chains rooted at the literal name ``logger`` are
inspected. ``from loguru import logger as log`` (or stdlib ``logging``)
would evade the check. The swept files all use the bare ``logger``.
"""
import ast
from pathlib import Path
import pytest
import local_deep_research.database.encrypted_db as encrypted_db_module
import local_deep_research.scheduler.background as background_module
import local_deep_research.web.queue.processor_v2 as processor_v2_module
import local_deep_research.database.thread_local_session as thread_local_session_module
import local_deep_research.library.download_management.status_tracker as status_tracker_module
import local_deep_research.database.thread_metrics as thread_metrics_module
import local_deep_research.metrics.search_tracker as search_tracker_module
import local_deep_research.metrics.token_counter as token_counter_module
import local_deep_research.database.library_init as library_init_module
import local_deep_research.database.backup.backup_executor as backup_executor_module
import local_deep_research.web_search_engines.rate_limiting.tracker as rate_limit_tracker_module
import local_deep_research.research_library.search.services.research_history_indexer as research_history_indexer_module
SWEPT_MODULES = [
encrypted_db_module,
background_module,
processor_v2_module,
# Consumers of ``DatabaseInitializationError`` that hold the master
# password in scope while logging the catch (#4182 follow-up): the
# raise site redacts, but these callers re-render via the logger.
thread_local_session_module,
status_tracker_module,
# Holds the master password in scope while opening a per-thread
# metrics session and logging the failure (#4182 follow-up).
thread_metrics_module,
# Credential-centric helpers that open an encrypted session with the
# master password and log failures (#4182 targeted sweep). The big
# multi-purpose route/service modules are NOT listed: they are
# protected at the sink level (log_utils forces diagnose=False on the
# persisted DB / frontend sinks) so their unrelated-error tracebacks
# stay useful.
search_tracker_module,
token_counter_module,
library_init_module,
backup_executor_module,
rate_limit_tracker_module,
research_history_indexer_module,
]
# Names that match the password heuristic but are not credential values.
_ALLOWED_PASSWORD_NAMES = {"session_password_store"}
def _password_names_in(func_node: ast.AST) -> set:
"""Collect credential-looking identifiers bound or used in *func_node*."""
names = set()
for node in ast.walk(func_node):
if isinstance(node, ast.Name) and "password" in node.id.lower():
names.add(node.id)
if isinstance(node, ast.arg) and "password" in node.arg.lower():
names.add(node.arg)
return names - _ALLOWED_PASSWORD_NAMES
def _logger_method_name(call: ast.Call):
"""If *call* is a method call ultimately rooted at the bare ``logger``
name, return the final method name (e.g. ``"exception"``, ``"debug"``);
otherwise ``None``.
Follows chained loguru calls so ``logger.bind(...).debug(...)`` and
``logger.opt(...).exception(...)`` resolve to ``"debug"`` /
``"exception"`` — that ``.bind()`` form is used live in the swept files
(scheduler/background.py), so missing it would leave a real blind spot.
Aliased loggers (``from loguru import logger as log``) are NOT followed —
the root must be the literal name ``logger``. None of the swept files
alias it; see the limitation note in the module docstring.
"""
func = call.func
if not isinstance(func, ast.Attribute):
return None
method = func.attr
node = func.value
# Walk the receiver chain to its root, stepping through intermediate
# method calls (logger.bind(...)/opt(...)) and attribute accesses.
while True:
if isinstance(node, ast.Name):
return method if node.id == "logger" else None
if isinstance(node, ast.Call):
node = node.func
elif isinstance(node, ast.Attribute):
node = node.value
else:
return None
def _renders_traceback(call: ast.Call) -> bool:
"""True if *call* is a logger call that renders an exception traceback.
Matches ``logger.exception(...)`` and any ``logger.<level>(...,
exc_info=<anything>)`` (including ``logger.bind(...).<level>`` chains).
A literal ``exc_info=False`` is treated as a violation too — it serves
no purpose and invites a flip to ``True``.
"""
method = _logger_method_name(call)
if method is None:
return False
if method == "exception":
return True
return any(kw.arg == "exc_info" for kw in call.keywords)
def find_unredacted_traceback_logs(source: str, filename: str) -> list:
"""Return ``(lineno, function, password_names)`` violations in *source*."""
tree = ast.parse(source, filename=filename)
violations = []
class Visitor(ast.NodeVisitor):
def __init__(self):
self.func_stack = []
def _visit_func(self, node):
self.func_stack.append(node)
self.generic_visit(node)
self.func_stack.pop()
visit_FunctionDef = _visit_func
visit_AsyncFunctionDef = _visit_func
def visit_Call(self, node):
if _renders_traceback(node) and self.func_stack:
func = self.func_stack[-1]
names = _password_names_in(func)
if names:
violations.append((node.lineno, func.name, sorted(names)))
self.generic_visit(node)
Visitor().visit(tree)
return violations
@pytest.mark.parametrize(
"module",
SWEPT_MODULES,
ids=lambda m: m.__name__.rsplit(".", 1)[-1],
)
def test_no_traceback_log_with_password_in_scope(module):
"""Every traceback-rendering log call in the swept files must live in
a function with no password-named variable in scope.
"""
path = Path(module.__file__)
source = path.read_text(encoding="utf-8")
violations = find_unredacted_traceback_logs(source, str(path))
assert not violations, (
f"{path.name} has traceback-rendering log calls in functions that "
f"hold the SQLCipher master password — loguru diagnose=True would "
f"render it via frame locals. Replace each with "
f"`safe_msg = redact_secrets(str(e), password)` + "
f"`logger.warning(...)` (see test_password_leakage.py and PR "
f"#4530):\n"
+ "\n".join(
f" line {lineno}: {func}() — password names in scope: {names}"
for lineno, func, names in violations
)
)
class TestCheckerSelfTest:
"""Prove the checker actually detects violations — without this, a
refactor that silently breaks the AST walk would make the invariant
test pass vacuously.
"""
def test_flags_logger_exception_with_password_param(self):
src = (
"def handler(username, password):\n"
" try:\n"
" open_db(username, password)\n"
" except Exception:\n"
" logger.exception('boom')\n"
)
violations = find_unredacted_traceback_logs(src, "<test>")
assert violations == [(5, "handler", ["password"])]
def test_flags_bind_chain_exc_info_with_password(self):
# The logger.bind(...).debug(..., exc_info=True) form is used live
# in scheduler/background.py — the checker must follow the chain.
src = (
"def handler(username, password):\n"
" try:\n"
" open_db(username, password)\n"
" except Exception:\n"
" logger.bind(audit=True).debug('boom', exc_info=True)\n"
)
violations = find_unredacted_traceback_logs(src, "<test>")
assert violations == [(5, "handler", ["password"])]
def test_flags_bind_chain_exception_with_password(self):
src = (
"def handler(username, password):\n"
" try:\n"
" open_db(username, password)\n"
" except Exception:\n"
" logger.bind(audit=True).exception('boom')\n"
)
violations = find_unredacted_traceback_logs(src, "<test>")
assert violations == [(5, "handler", ["password"])]
def test_ignores_aliased_logger(self):
# Only chains rooted at the literal name ``logger`` are inspected;
# an aliased logger is a documented blind spot.
src = (
"def handler(username, password):\n"
" try:\n"
" open_db(username, password)\n"
" except Exception:\n"
" log.exception('boom')\n"
)
assert find_unredacted_traceback_logs(src, "<test>") == []
def test_flags_exc_info_with_password_local(self):
src = (
"def handler(username):\n"
" user_password = store.retrieve(username)\n"
" try:\n"
" open_db(username, user_password)\n"
" except Exception:\n"
" logger.debug('boom', exc_info=True)\n"
)
violations = find_unredacted_traceback_logs(src, "<test>")
assert violations == [(6, "handler", ["user_password"])]
def test_ignores_logger_exception_without_password(self):
src = (
"def handler(config):\n"
" try:\n"
" reload(config)\n"
" except Exception:\n"
" logger.exception('boom')\n"
)
assert find_unredacted_traceback_logs(src, "<test>") == []
def test_ignores_redacted_warning_with_password(self):
src = (
"def handler(username, password):\n"
" try:\n"
" open_db(username, password)\n"
" except Exception as e:\n"
" safe_msg = redact_secrets(str(e), password)\n"
" logger.warning(f'boom: {safe_msg}')\n"
)
assert find_unredacted_traceback_logs(src, "<test>") == []
def test_session_password_store_alone_is_not_a_credential(self):
src = (
"def handler(username):\n"
" session_password_store.touch(username)\n"
" try:\n"
" work()\n"
" except Exception:\n"
" logger.exception('boom')\n"
)
assert find_unredacted_traceback_logs(src, "<test>") == []
+58
View File
@@ -0,0 +1,58 @@
"""Tests for password strength validation."""
from local_deep_research.security.password_validator import PasswordValidator
class TestPasswordValidator:
"""Unit tests for PasswordValidator.validate_strength."""
def test_valid_password(self):
errors = PasswordValidator.validate_strength("strongp4ss")
assert errors == []
def test_too_short(self):
errors = PasswordValidator.validate_strength("ab1")
assert any("8 characters" in e for e in errors)
def test_missing_lowercase(self):
errors = PasswordValidator.validate_strength("UPPERCASE1")
assert any("lowercase" in e for e in errors)
def test_missing_digit(self):
errors = PasswordValidator.validate_strength("nodigitshere")
assert any("digit" in e for e in errors)
def test_multiple_errors_for_weak_password(self):
errors = PasswordValidator.validate_strength("abc")
# Should flag: too short, no digit
assert len(errors) >= 2
def test_exactly_8_chars_valid(self):
errors = PasswordValidator.validate_strength("abcdef1x")
assert errors == []
def test_7_chars_invalid(self):
errors = PasswordValidator.validate_strength("abcde1x")
assert any("8 characters" in e for e in errors)
def test_special_chars_allowed(self):
errors = PasswordValidator.validate_strength("p@ssw0rd!")
assert errors == []
def test_very_long_password_valid(self):
errors = PasswordValidator.validate_strength("a1" + "a" * 200)
assert errors == []
def test_get_requirements_returns_non_empty_list(self):
reqs = PasswordValidator.get_requirements()
assert isinstance(reqs, list)
assert len(reqs) > 0
assert all(isinstance(r, str) for r in reqs)
def test_get_requirements_count_matches_validate_strength_checks(self):
"""The number of requirements should match the number of checks
in validate_strength (one error per check when all fail)."""
reqs = PasswordValidator.get_requirements()
# Empty string fails every check
errors = PasswordValidator.validate_strength("")
assert len(reqs) == len(errors)
+504
View File
@@ -0,0 +1,504 @@
"""
Tests for PathValidator security module.
"""
import sys
import tempfile
from pathlib import Path
import pytest
from local_deep_research.security.path_validator import PathValidator
class TestValidateSafePath:
"""Tests for PathValidator.validate_safe_path()."""
@pytest.fixture
def temp_base_dir(self):
"""Create a temporary base directory for tests."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
def test_valid_relative_path(self, temp_base_dir):
"""Accepts valid relative path."""
result = PathValidator.validate_safe_path(
"subdir/file.txt", temp_base_dir
)
assert result is not None
assert str(temp_base_dir) in str(result)
def test_valid_simple_filename(self, temp_base_dir):
"""Accepts simple filename."""
result = PathValidator.validate_safe_path("file.txt", temp_base_dir)
assert result is not None
assert result.name == "file.txt"
def test_path_traversal_blocked(self, temp_base_dir):
"""Blocks path traversal attempts."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_safe_path("../etc/passwd", temp_base_dir)
assert "traversal" in str(exc_info.value).lower()
def test_path_traversal_double_dots(self, temp_base_dir):
"""Blocks multiple .. components."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_safe_path("../../secret.txt", temp_base_dir)
assert "traversal" in str(exc_info.value).lower()
def test_path_traversal_mixed(self, temp_base_dir):
"""Blocks mixed path with traversal."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_safe_path(
"valid/../../../escape.txt", temp_base_dir
)
assert "traversal" in str(exc_info.value).lower()
def test_empty_path_rejected(self, temp_base_dir):
"""Rejects empty path."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_safe_path("", temp_base_dir)
assert "invalid" in str(exc_info.value).lower()
def test_none_path_rejected(self, temp_base_dir):
"""Rejects None path."""
with pytest.raises(ValueError):
PathValidator.validate_safe_path(None, temp_base_dir)
def test_required_extensions(self, temp_base_dir):
"""Validates required file extensions."""
result = PathValidator.validate_safe_path(
"config.json",
temp_base_dir,
required_extensions=(".json", ".yaml"),
)
assert result.suffix == ".json"
def test_wrong_extension_rejected(self, temp_base_dir):
"""Rejects file with wrong extension."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_safe_path(
"script.py",
temp_base_dir,
required_extensions=(".json", ".yaml"),
)
assert "allowed" in str(exc_info.value).lower()
def test_whitespace_stripped(self, temp_base_dir):
"""Strips whitespace from path."""
result = PathValidator.validate_safe_path(" file.txt ", temp_base_dir)
assert result.name == "file.txt"
class TestValidateLocalFilesystemPath:
"""Tests for PathValidator.validate_local_filesystem_path()."""
@pytest.fixture
def temp_safe_dir(self):
"""Create a temporary safe directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
def test_valid_absolute_path(self, temp_safe_dir):
"""Accepts valid absolute path."""
test_path = str(temp_safe_dir / "subdir")
result = PathValidator.validate_local_filesystem_path(test_path)
assert result is not None
@pytest.mark.skipif(
Path.home() == Path("/root"),
reason="Skipping in Docker/CI where home is /root (restricted directory)",
)
def test_home_expansion(self):
"""Expands ~ to home directory."""
result = PathValidator.validate_local_filesystem_path("~/Documents")
assert str(result).startswith(str(Path.home()))
def test_null_byte_rejected(self):
"""Rejects path with null bytes."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path("/path/with\x00null")
assert "null" in str(exc_info.value).lower()
def test_control_characters_rejected(self):
"""Rejects path with control characters."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path(
"/path/with\x01control"
)
assert "control" in str(exc_info.value).lower()
def test_empty_path_rejected(self):
"""Rejects empty path."""
with pytest.raises(ValueError):
PathValidator.validate_local_filesystem_path("")
def test_none_path_rejected(self):
"""Rejects None path."""
with pytest.raises(ValueError):
PathValidator.validate_local_filesystem_path(None)
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_etc_blocked(self):
"""Blocks access to /etc directory."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path("/etc/passwd")
assert "system" in str(exc_info.value).lower()
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_proc_blocked(self):
"""Blocks access to /proc directory."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path("/proc/1/status")
assert "system" in str(exc_info.value).lower()
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_sys_blocked(self):
"""Blocks access to /sys directory."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path("/sys/class")
assert "system" in str(exc_info.value).lower()
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_dev_blocked(self):
"""Blocks access to /dev directory."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path("/dev/null")
assert "system" in str(exc_info.value).lower()
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_root_blocked(self):
"""Blocks access to /root directory.
Also a regression guard for #3090: this must surface as a clean
ValueError. The removed pre-resolve is_symlink() lstat() raised an
uncaught PermissionError for /root/.bashrc on non-root hosts (where it
escaped to a Flask 500); pytest.raises(ValueError) here would not catch
that, so this test fails loudly if the EACCES leak ever returns.
"""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path("/root/.bashrc")
assert "system" in str(exc_info.value).lower()
@pytest.mark.skipif(
sys.platform == "win32",
reason="symlink creation needs privileges on Windows",
)
def test_symlinked_directory_is_permitted(self, temp_safe_dir):
"""Regression for #3090: a symlinked directory is accepted.
The removed pre-resolve symlink check rejected ALL symlinks, breaking
legitimate setups (Docker/k8s bind mounts, macOS /tmp -> /private/tmp).
Containment is still enforced by resolve()+restricted_dirs.
"""
real = temp_safe_dir / "real"
real.mkdir()
link = temp_safe_dir / "link"
link.symlink_to(real, target_is_directory=True)
result = PathValidator.validate_local_filesystem_path(
str(link), restricted_dirs=[]
)
assert result is not None
@pytest.mark.skipif(sys.platform == "win32", reason="Unix-specific test")
def test_symlink_escaping_to_system_dir_still_blocked(self, temp_safe_dir):
"""A symlink that resolves into a restricted system dir is still blocked:
resolve() follows it and the restricted-dir check catches the target.
This is the real symlink control (vs. the removed leaf-only lstat).
"""
link = temp_safe_dir / "escape"
link.symlink_to("/etc", target_is_directory=True)
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_local_filesystem_path(str(link / "passwd"))
assert "system" in str(exc_info.value).lower()
def test_custom_restricted_dirs(self, temp_safe_dir):
"""Respects custom restricted directories."""
restricted = [temp_safe_dir]
with pytest.raises(ValueError):
PathValidator.validate_local_filesystem_path(
str(temp_safe_dir / "subdir"),
restricted_dirs=restricted,
)
def test_empty_restricted_dirs_allows_all(self, temp_safe_dir):
"""Empty restricted dirs allows all paths."""
result = PathValidator.validate_local_filesystem_path(
str(temp_safe_dir),
restricted_dirs=[],
)
assert result is not None
def test_restricted_dir_block_does_not_log_user_path(self, temp_safe_dir):
"""The restricted-dir block must log WHICH restricted dir was hit, not
the user's submitted path — a resolved local path can contain a
username."""
from loguru import logger
secret_segment = "alice-private-7f3a2b"
target = str(temp_safe_dir / secret_segment / "file.txt")
# The package disables loguru by default (__init__.py); enable it so the
# error emitted from inside the module reaches our sink.
logger.enable("local_deep_research")
logged: list[str] = []
sink_id = logger.add(
lambda m: logged.append(m.record["message"]), level="ERROR"
)
try:
with pytest.raises(ValueError):
PathValidator.validate_local_filesystem_path(
target, restricted_dirs=[temp_safe_dir]
)
finally:
logger.remove(sink_id)
logger.disable("local_deep_research")
blocked = [m for m in logged if "restricted directory" in m.lower()]
assert blocked, "expected a restricted-dir block error log"
assert all(secret_segment not in m for m in blocked)
class TestValidateConfigPath:
"""Tests for PathValidator.validate_config_path()."""
@pytest.fixture
def temp_config_dir(self):
"""Create temp config directory with test files."""
with tempfile.TemporaryDirectory() as tmpdir:
config_file = Path(tmpdir) / "settings.json"
config_file.write_text('{"key": "value"}')
yaml_file = Path(tmpdir) / "config.yaml"
yaml_file.write_text("key: value")
yield Path(tmpdir)
def test_valid_relative_config_path(self, temp_config_dir):
"""Accepts valid relative config path."""
result = PathValidator.validate_config_path(
"settings.json",
config_root=str(temp_config_dir),
)
assert result.exists()
@pytest.mark.skip(
reason="Absolute paths to temp dirs not supported by current implementation"
)
def test_valid_absolute_config_path(self, temp_config_dir):
"""Accepts valid absolute config path."""
# NOTE: Current implementation of validate_config_path uses safe_join("/", path)
# for absolute paths, which doesn't work with arbitrary temp directories.
# This test would need the implementation to be updated to handle
# arbitrary absolute paths or config_root parameter for absolute paths.
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
config_file = temp_config_dir / "settings.json"
result = PathValidator.validate_config_path(
str(config_file),
config_root=str(temp_config_dir),
)
assert result.exists()
def test_traversal_blocked(self, temp_config_dir):
"""Blocks path traversal in config path."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_config_path(
"../../../etc/passwd",
config_root=str(temp_config_dir),
)
assert "traversal" in str(exc_info.value).lower()
def test_null_bytes_rejected(self, temp_config_dir):
"""Rejects null bytes in config path."""
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_config_path(
"set\x00tings.json",
config_root=str(temp_config_dir),
)
def test_etc_prefix_blocked(self, temp_config_dir):
"""Blocks paths starting with /etc/."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_config_path("/etc/passwd")
assert "restricted" in str(exc_info.value).lower()
def test_proc_prefix_blocked(self, temp_config_dir):
"""Blocks paths starting with /proc/."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_config_path("/proc/1/status")
assert "restricted" in str(exc_info.value).lower()
def test_wrong_extension_blocked(self, temp_config_dir):
"""Blocks config files with wrong extension."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_config_path(
"script.py",
config_root=str(temp_config_dir),
)
assert (
"allowed" in str(exc_info.value).lower()
or "type" in str(exc_info.value).lower()
)
def test_valid_yaml_extension(self, temp_config_dir):
"""Accepts YAML config files."""
result = PathValidator.validate_config_path(
"config.yaml",
config_root=str(temp_config_dir),
)
assert result.suffix == ".yaml"
class TestValidateModelPath:
"""Tests for PathValidator.validate_model_path()."""
@pytest.fixture
def temp_model_dir(self):
"""Create temp model directory with test files."""
with tempfile.TemporaryDirectory() as tmpdir:
model_file = Path(tmpdir) / "model.gguf"
model_file.write_text("fake model content")
yield Path(tmpdir)
def test_valid_model_path(self, temp_model_dir):
"""Accepts valid model path."""
result = PathValidator.validate_model_path(
"model.gguf",
model_root=str(temp_model_dir),
)
assert result.exists()
def test_model_not_found(self, temp_model_dir):
"""Raises error for non-existent model."""
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_model_path(
"missing.gguf",
model_root=str(temp_model_dir),
)
assert "not found" in str(exc_info.value).lower()
def test_model_path_is_directory(self, temp_model_dir):
"""Raises error when path is directory."""
subdir = temp_model_dir / "subdir"
subdir.mkdir()
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_model_path(
"subdir",
model_root=str(temp_model_dir),
)
assert "not a file" in str(exc_info.value).lower()
def test_model_traversal_blocked(self, temp_model_dir):
"""Blocks path traversal in model path."""
with pytest.raises(ValueError):
PathValidator.validate_model_path(
"../../../etc/passwd",
model_root=str(temp_model_dir),
)
class TestValidateDataPath:
"""Tests for PathValidator.validate_data_path()."""
@pytest.fixture
def temp_data_dir(self):
"""Create temp data directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
def test_valid_data_path(self, temp_data_dir):
"""Accepts valid data path."""
result = PathValidator.validate_data_path(
"data/file.txt",
str(temp_data_dir),
)
assert result is not None
def test_data_path_traversal_blocked(self, temp_data_dir):
"""Blocks path traversal in data path."""
with pytest.raises(ValueError):
PathValidator.validate_data_path(
"../secret.txt",
str(temp_data_dir),
)
class TestPathValidatorConstants:
"""Tests for PathValidator constants."""
def test_safe_path_pattern_exists(self):
"""SAFE_PATH_PATTERN is defined."""
assert PathValidator.SAFE_PATH_PATTERN is not None
def test_config_extensions_defined(self):
"""CONFIG_EXTENSIONS is defined."""
assert PathValidator.CONFIG_EXTENSIONS is not None
assert ".json" in PathValidator.CONFIG_EXTENSIONS
assert ".yaml" in PathValidator.CONFIG_EXTENSIONS
assert ".yml" in PathValidator.CONFIG_EXTENSIONS
class TestSecurityScenarios:
"""Integration tests for security scenarios."""
@pytest.fixture
def temp_base_dir(self):
"""Create a temporary base directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
def test_unicode_normalization_attack(self, temp_base_dir):
"""Unicode look-alike traversal is rejected.
Full-width periods (U+FF0E '') NFKC-normalize to '.', which a
downstream consumer might apply silently. PathValidator rejects
the input before that happens.
"""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
with pytest.raises(ValueError, match="unicode traversal"):
PathValidator.validate_safe_path(
"../etc/passwd", # Full-width periods
temp_base_dir,
)
def test_url_encoded_traversal(self, temp_base_dir):
"""URL-encoded path traversal is rejected.
Some downstream consumers urldecode strings before joining them
into paths; werkzeug.safe_join's literal-'..' check can't see
through '%2e%2e'. PathValidator catches this earlier.
"""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
with pytest.raises(ValueError, match="encoded traversal"):
PathValidator.validate_safe_path(
"%2e%2e/etc/passwd",
temp_base_dir,
)
def test_double_encoded_traversal(self, temp_base_dir):
"""Double-encoded path traversal is rejected.
'%252e%252e' decodes once to '%2e%2e' and again to '..';
detection runs two levels of unquote() to catch this layered
encoding.
"""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: REVISIT).
with pytest.raises(ValueError, match="encoded traversal"):
PathValidator.validate_safe_path(
"%252e%252e/etc/passwd",
temp_base_dir,
)
def test_symlink_escape_prevention(self, temp_base_dir):
"""Path resolution prevents symlink escapes."""
# Create a symlink pointing outside base dir
symlink_path = temp_base_dir / "escape"
try:
symlink_path.symlink_to("/etc")
# Attempting to access via symlink should work for the symlink itself
# but not for paths through it
result = PathValidator.validate_safe_path("escape", temp_base_dir)
# The result should be within base_dir (the symlink itself)
assert str(temp_base_dir) in str(result)
except OSError:
pytest.skip("Symlink creation not supported")
@@ -0,0 +1,282 @@
"""Coverage tests for security/path_validator.py.
Focuses on adversarial inputs and uncovered branches:
- Null bytes in all methods
- Path traversal attempts (../)
- URL-encoded traversal
- validate_local_filesystem_path: control chars, ~ expansion,
restricted dirs, relative paths
- validate_safe_path: extension filtering, None result
- sanitize_for_filesystem_ops: non-absolute path
- validate_config_path: restricted prefixes, absolute paths,
missing config files
- validate_model_path: missing/non-file model
- validate_data_path success/failure
"""
from pathlib import Path
from unittest.mock import patch
import pytest
from local_deep_research.security.path_validator import PathValidator
MODULE = "local_deep_research.security.path_validator"
# ---------------------------------------------------------------------------
# validate_safe_path
# ---------------------------------------------------------------------------
class TestValidateSafePath:
def test_null_byte_raises(self):
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_safe_path("file\x00name.txt", "/tmp")
def test_empty_string_raises(self):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_safe_path("", "/tmp")
def test_none_input_raises(self):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_safe_path(None, "/tmp")
def test_path_traversal_blocked(self):
with pytest.raises(ValueError, match="traversal|Invalid path"):
PathValidator.validate_safe_path("../etc/passwd", "/tmp")
def test_double_dot_deeply_nested_allowed_by_werkzeug(self):
"""werkzeug safe_join resolves a/b/../../etc/passwd within the base, so it's
accepted (it resolves to base/etc/passwd, not an escape). No exception expected."""
result = PathValidator.validate_safe_path(
"a/b/../../c/file.txt", "/tmp"
)
assert result is not None
def test_valid_relative_path_accepted(self, tmp_path):
result = PathValidator.validate_safe_path(
"subdir/file.txt", str(tmp_path)
)
assert result is not None
assert "subdir/file.txt" in str(result)
def test_wrong_extension_raises(self, tmp_path):
with pytest.raises(ValueError, match="Invalid file type"):
PathValidator.validate_safe_path(
"config.txt",
str(tmp_path),
required_extensions=(".json", ".yaml"),
)
def test_correct_extension_accepted(self, tmp_path):
result = PathValidator.validate_safe_path(
"config.json",
str(tmp_path),
required_extensions=(".json", ".yaml"),
)
assert result.suffix == ".json"
def test_whitespace_stripped_from_input(self, tmp_path):
result = PathValidator.validate_safe_path(" file.txt ", str(tmp_path))
assert result is not None
# ---------------------------------------------------------------------------
# validate_local_filesystem_path
# ---------------------------------------------------------------------------
class TestValidateLocalFilesystemPath:
def test_null_byte_raises(self):
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_local_filesystem_path("/tmp/file\x00.txt")
def test_control_characters_raise(self):
with pytest.raises(ValueError, match="Control characters"):
PathValidator.validate_local_filesystem_path("/tmp/file\x01.txt")
def test_double_dot_traversal_blocked(self):
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd")
def test_tilde_expansion(self, tmp_path):
"""~ is expanded to home directory."""
with patch.object(Path, "home", return_value=tmp_path):
result = PathValidator.validate_local_filesystem_path("~/subdir")
assert str(tmp_path) in str(result)
def test_tilde_only_expansion(self, tmp_path):
"""~ alone expands to home directory."""
with patch.object(Path, "home", return_value=tmp_path):
result = PathValidator.validate_local_filesystem_path("~")
assert str(tmp_path) in str(result)
def test_restricted_etc_blocked(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/etc/passwd")
def test_restricted_proc_blocked(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/proc/cpuinfo")
def test_restricted_sys_blocked(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/sys/kernel")
def test_restricted_dev_blocked(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/dev/null")
def test_home_directory_allowed(self, tmp_path):
"""User home dir is not restricted."""
result = PathValidator.validate_local_filesystem_path(str(tmp_path))
assert result is not None
def test_empty_string_raises(self):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_local_filesystem_path("")
def test_none_raises(self):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_local_filesystem_path(None)
def test_relative_path_resolves(self, tmp_path):
"""Relative paths are resolved against cwd."""
with patch("os.getcwd", return_value=str(tmp_path)):
result = PathValidator.validate_local_filesystem_path("subdir")
assert result is not None
def test_custom_restricted_dirs(self, tmp_path):
"""Custom restricted_dirs are respected."""
restricted = tmp_path / "secret"
restricted.mkdir()
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path(
str(restricted), restricted_dirs=[restricted]
)
# ---------------------------------------------------------------------------
# sanitize_for_filesystem_ops
# ---------------------------------------------------------------------------
class TestSanitizeForFilesystemOps:
def test_non_absolute_path_raises(self):
with pytest.raises(ValueError, match="must be absolute"):
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
def test_absolute_path_returned(self, tmp_path):
result = PathValidator.sanitize_for_filesystem_ops(tmp_path)
assert result.is_absolute()
assert str(tmp_path) in str(result)
# ---------------------------------------------------------------------------
# validate_config_path
# ---------------------------------------------------------------------------
class TestValidateConfigPath:
def test_null_byte_raises(self):
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_config_path("config\x00.json")
def test_empty_string_raises(self):
with pytest.raises(ValueError, match="Invalid config path input"):
PathValidator.validate_config_path("")
def test_none_raises(self):
with pytest.raises(ValueError, match="Invalid config path input"):
PathValidator.validate_config_path(None)
def test_double_dot_traversal_blocked(self):
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_config_path("../../etc/passwd.json")
def test_restricted_etc_prefix_blocked(self):
with pytest.raises(ValueError, match="restricted system directory"):
PathValidator.validate_config_path("/etc/shadow.json")
def test_restricted_proc_prefix_blocked(self):
with pytest.raises(ValueError, match="restricted system directory"):
PathValidator.validate_config_path("proc/something")
def test_absolute_path_invalid_extension_blocked(self):
"""Absolute path starting with / causes safe_join(/, path) -> None -> error."""
# safe_join("/", "/some/path") returns None for any absolute path
# so any absolute /path causes "Invalid absolute path" or a traversal error
with pytest.raises(ValueError):
PathValidator.validate_config_path("/some/config.exe")
def test_absolute_path_causes_safe_join_failure(self):
"""validate_config_path with /absolute/path raises ValueError since
safe_join('/','/'+ path) returns None (werkzeug treats it as traversal)."""
with pytest.raises(ValueError):
PathValidator.validate_config_path("/home/user/config.json")
def test_relative_path_with_valid_extension_accepted(self, tmp_path):
"""Relative config path with valid extension goes through validate_safe_path."""
# Create a json file inside tmp_path so the path is valid
config_file = tmp_path / "my_config.json"
config_file.write_text("{}")
result = PathValidator.validate_config_path(
"my_config.json", config_root=str(tmp_path)
)
assert result.suffix == ".json"
# ---------------------------------------------------------------------------
# validate_model_path
# ---------------------------------------------------------------------------
class TestValidateModelPath:
def test_missing_model_file_raises(self, tmp_path):
with pytest.raises(ValueError, match="not found"):
PathValidator.validate_model_path(
"nonexistent.gguf", model_root=str(tmp_path)
)
def test_path_is_directory_not_file_raises(self, tmp_path):
subdir = tmp_path / "models"
subdir.mkdir()
with pytest.raises(ValueError, match="not a file|not found"):
PathValidator.validate_model_path(
"models", model_root=str(tmp_path)
)
def test_valid_model_file_accepted(self, tmp_path):
model = tmp_path / "model.gguf"
model.write_bytes(b"fake gguf data")
result = PathValidator.validate_model_path(
"model.gguf", model_root=str(tmp_path)
)
assert result == model
def test_path_traversal_in_model_path_blocked(self, tmp_path):
with pytest.raises(ValueError, match="traversal|Invalid path"):
PathValidator.validate_model_path(
"../etc/passwd", model_root=str(tmp_path)
)
# ---------------------------------------------------------------------------
# validate_data_path
# ---------------------------------------------------------------------------
class TestValidateDataPath:
def test_traversal_blocked(self, tmp_path):
with pytest.raises(ValueError, match="traversal|Invalid path"):
PathValidator.validate_data_path("../../secret", str(tmp_path))
def test_valid_data_path_accepted(self, tmp_path):
result = PathValidator.validate_data_path("data/file.db", str(tmp_path))
assert result is not None
assert "data/file.db" in str(result)
def test_null_byte_blocked(self, tmp_path):
with pytest.raises(ValueError, match="Null bytes|Invalid path"):
PathValidator.validate_data_path("file\x00.db", str(tmp_path))
+174
View File
@@ -0,0 +1,174 @@
"""
Deep coverage tests for security/path_validator.py.
Targets uncovered paths:
- sanitize_for_filesystem_ops: relative path rejection, safe_join None
- validate_local_filesystem_path: tilde-only expansion, relative path, path traversal
- validate_config_path: empty input, sys/dev prefix, Windows absolute, absolute non-config ext
- validate_safe_path: exception re-raise wrapping
"""
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from local_deep_research.security.path_validator import PathValidator
class TestSanitizeForFilesystemOps:
"""Cover sanitize_for_filesystem_ops."""
def test_rejects_relative_path(self):
"""Relative paths raise ValueError."""
with pytest.raises(ValueError, match="must be absolute"):
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
def test_accepts_absolute_path(self):
"""Valid absolute path passes through."""
result = PathValidator.sanitize_for_filesystem_ops(Path("/tmp/test"))
assert result == Path("/tmp/test")
def test_returns_none_when_safe_join_fails(self):
"""Raises ValueError when safe_join returns None."""
with patch(
"local_deep_research.security.path_validator.safe_join",
return_value=None,
):
with pytest.raises(ValueError, match="security sanitization"):
PathValidator.sanitize_for_filesystem_ops(
Path("/some/absolute/path")
)
class TestValidateLocalFilesystemPathAdditional:
"""Cover additional branches in validate_local_filesystem_path."""
def test_tilde_only_expands_to_home(self):
"""~ alone expands to home directory."""
home = Path.home()
# Skip if home is a restricted dir
restricted = [
Path("/etc"),
Path("/sys"),
Path("/proc"),
Path("/dev"),
Path("/root"),
Path("/boot"),
Path("/var/log"),
]
for r in restricted:
if home == r or str(home).startswith(str(r)):
pytest.skip(f"Home dir {home} is restricted")
result = PathValidator.validate_local_filesystem_path("~")
assert result == home.resolve()
def test_path_traversal_double_dot_blocked(self):
"""Paths with .. are blocked."""
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd")
def test_relative_path_resolved_from_cwd(self):
"""Relative paths resolve from cwd."""
with tempfile.TemporaryDirectory() as tmpdir:
with patch("os.getcwd", return_value=tmpdir):
result = PathValidator.validate_local_filesystem_path(
"subdir", restricted_dirs=[]
)
assert result == Path(tmpdir).resolve() / "subdir"
def test_relative_path_safe_join_none(self):
"""Relative path raises when safe_join returns None."""
with patch(
"local_deep_research.security.path_validator.safe_join",
return_value=None,
):
with pytest.raises(ValueError, match="security validation"):
PathValidator.validate_local_filesystem_path("some_path")
@pytest.mark.skipif(sys.platform == "win32", reason="Unix test")
def test_var_log_blocked(self):
"""Blocks /var/log directory."""
with pytest.raises(ValueError, match="system"):
PathValidator.validate_local_filesystem_path("/var/log/syslog")
@pytest.mark.skipif(sys.platform == "win32", reason="Unix test")
def test_boot_blocked(self):
"""Blocks /boot directory."""
with pytest.raises(ValueError, match="system"):
PathValidator.validate_local_filesystem_path("/boot/vmlinuz")
class TestValidateConfigPathAdditional:
"""Cover additional branches in validate_config_path."""
def test_empty_config_path_rejected(self):
"""Empty string raises ValueError."""
with pytest.raises(ValueError, match="Invalid config path"):
PathValidator.validate_config_path("")
def test_none_config_path_rejected(self):
"""None raises ValueError."""
with pytest.raises(ValueError):
PathValidator.validate_config_path(None)
def test_sys_prefix_blocked(self):
"""Paths starting with /sys/ are blocked."""
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/sys/config.json")
def test_dev_prefix_blocked(self):
"""Paths starting with /dev are blocked."""
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/dev/null")
def test_bare_restricted_dir_blocked(self):
"""Bare restricted dir name (no trailing slash) is blocked."""
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/etc")
def test_absolute_path_wrong_extension_rejected(self):
"""Absolute path with non-config extension is rejected."""
# Create temp file in a path that safe_join("/", ...) can resolve
with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f:
path = f.name
try:
with pytest.raises(ValueError) as exc_info:
PathValidator.validate_config_path(path)
error_msg = str(exc_info.value).lower()
assert (
"config file type" in error_msg or "absolute path" in error_msg
)
finally:
import os
os.unlink(path)
def test_absolute_path_nonexistent_file_rejected(self):
"""Absolute path to nonexistent config file is rejected."""
# safe_join("/", path) may return None for some temp paths
# so we just check for ValueError regardless of exact message
with pytest.raises(ValueError):
PathValidator.validate_config_path("/tmp/nonexistent_config.json")
def test_absolute_safe_join_returns_none(self):
"""Absolute path raises when safe_join returns None."""
with patch(
"local_deep_research.security.path_validator.safe_join",
return_value=None,
):
with pytest.raises(ValueError, match="absolute path"):
PathValidator.validate_config_path("/valid/config.json")
class TestValidateSafePathExceptionWrapping:
"""Cover the exception wrapping in validate_safe_path."""
def test_null_byte_in_safe_path(self):
"""Null bytes in path raise ValueError."""
with tempfile.TemporaryDirectory() as tmpdir:
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_safe_path("file\x00.txt", Path(tmpdir))
@@ -0,0 +1,231 @@
"""
Tests for uncovered code paths in PathValidator.
Targets:
- validate_config_path: absolute path branch, restricted prefixes, null bytes
- sanitize_for_filesystem_ops: non-absolute path, safe_join failure
- validate_model_path: non-existent file, not-a-file, model_root creation
- validate_local_filesystem_path: control characters, tilde expansion, relative path
"""
import os
from pathlib import Path
import pytest
from local_deep_research.security.path_validator import PathValidator
class TestValidateConfigPathAbsolute:
"""Tests for validate_config_path with absolute paths."""
def test_absolute_path_rejected_by_safe_join(self, tmp_path):
"""Absolute Unix paths are rejected by safe_join in validate_config_path.
werkzeug safe_join("/", "/absolute/path") returns None because the
second argument starts with "/" which is treated as escaping the base.
This covers the `if safe_path is None: raise ValueError` branch.
"""
config_file = tmp_path / "test.json"
config_file.write_text("{}")
with pytest.raises(ValueError, match="Invalid"):
PathValidator.validate_config_path(str(config_file))
def test_config_path_null_bytes(self):
"""Config path with null bytes raises ValueError."""
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_config_path("/some/path\x00.json")
def test_config_path_empty_string(self):
"""Empty config path raises ValueError."""
with pytest.raises(ValueError, match="Invalid config path input"):
PathValidator.validate_config_path("")
def test_config_path_non_string(self):
"""Non-string config path raises ValueError."""
with pytest.raises(ValueError, match="Invalid config path input"):
PathValidator.validate_config_path(None) # type: ignore
def test_config_path_traversal_attempt(self):
"""Config path with .. raises ValueError."""
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_config_path("../../../etc/passwd")
class TestConfigPathRestrictedPrefixes:
"""Tests for restricted system directory prefixes in config path."""
@pytest.mark.parametrize(
"path",
[
"etc/passwd",
"/etc/passwd",
"proc/self/status",
"sys/kernel",
"dev/null",
],
)
def test_restricted_prefixes_blocked(self, path):
"""Config paths targeting system directories are blocked."""
with pytest.raises(ValueError, match="restricted system directory"):
PathValidator.validate_config_path(path)
class TestConfigPathRelative:
"""Tests for validate_config_path with relative paths."""
def test_relative_config_path_with_valid_extension(self, tmp_path):
"""Relative config path resolves within config_root."""
config_file = tmp_path / "settings.yaml"
config_file.write_text("key: value")
result = PathValidator.validate_config_path(
"settings.yaml", config_root=str(tmp_path)
)
assert result == config_file
def test_relative_config_path_invalid_extension(self, tmp_path):
"""Relative config path with wrong extension raises ValueError."""
with pytest.raises(ValueError, match="Invalid file type"):
PathValidator.validate_config_path(
"script.sh", config_root=str(tmp_path)
)
class TestSanitizeForFilesystemOps:
"""Tests for sanitize_for_filesystem_ops."""
def test_valid_absolute_path(self, tmp_path):
"""Valid absolute path is returned unchanged."""
result = PathValidator.sanitize_for_filesystem_ops(tmp_path)
assert result == tmp_path
def test_non_absolute_path_raises(self):
"""Relative path raises ValueError."""
with pytest.raises(ValueError, match="Path must be absolute"):
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
class TestValidateModelPath:
"""Tests for validate_model_path edge cases."""
def test_model_path_nonexistent_file(self, tmp_path):
"""Model path to nonexistent file raises ValueError."""
with pytest.raises(ValueError, match="Model file not found"):
PathValidator.validate_model_path(
"nonexistent.bin", model_root=str(tmp_path)
)
def test_model_path_is_directory(self, tmp_path):
"""Model path pointing to directory raises ValueError."""
subdir = tmp_path / "model_dir"
subdir.mkdir()
with pytest.raises(ValueError, match="not a file"):
PathValidator.validate_model_path(
"model_dir", model_root=str(tmp_path)
)
def test_model_path_valid_file(self, tmp_path):
"""Valid model file is returned."""
model_file = tmp_path / "model.bin"
model_file.write_bytes(b"fake model data")
result = PathValidator.validate_model_path(
"model.bin", model_root=str(tmp_path)
)
assert result == model_file
def test_model_path_creates_root_directory(self, tmp_path):
"""Model root directory is created if it doesn't exist."""
new_root = tmp_path / "new_model_root"
# The directory doesn't exist yet but will be created
# Then the file won't exist, so it should raise
with pytest.raises(ValueError, match="Model file not found"):
PathValidator.validate_model_path(
"model.bin", model_root=str(new_root)
)
# But the directory should have been created
assert new_root.exists()
class TestValidateLocalFilesystemPath:
"""Tests for validate_local_filesystem_path edge cases."""
def test_control_characters_rejected(self):
"""Paths with control characters are rejected."""
with pytest.raises(ValueError, match="Control characters"):
PathValidator.validate_local_filesystem_path("/tmp/test\x01file")
def test_tilde_expansion(self):
"""Tilde is expanded to home directory."""
# Pass empty restricted_dirs to avoid /root being blocked in CI
result = PathValidator.validate_local_filesystem_path(
"~", restricted_dirs=[]
)
assert result == Path.home().resolve()
def test_tilde_with_subpath(self):
"""Tilde with subpath is expanded correctly."""
# Pass empty restricted_dirs to avoid /root being blocked in CI
result = PathValidator.validate_local_filesystem_path(
"~/Documents", restricted_dirs=[]
)
expected = (Path.home() / "Documents").resolve()
assert result == expected
def test_relative_path_resolved_to_cwd(self):
"""Relative path is resolved relative to CWD."""
result = PathValidator.validate_local_filesystem_path("some_dir")
expected = (Path(os.getcwd()) / "some_dir").resolve()
assert result == expected
def test_restricted_directories_blocked(self):
"""System directories like /etc are blocked."""
with pytest.raises(
ValueError, match="Cannot access system directories"
):
PathValidator.validate_local_filesystem_path("/etc/passwd")
def test_custom_restricted_dirs(self, tmp_path):
"""Custom restricted directories are respected."""
restricted = tmp_path / "restricted"
restricted.mkdir()
with pytest.raises(
ValueError, match="Cannot access system directories"
):
PathValidator.validate_local_filesystem_path(
str(restricted / "file.txt"),
restricted_dirs=[restricted],
)
def test_path_traversal_blocked(self):
"""Path traversal with .. is blocked."""
with pytest.raises(ValueError, match="Path traversal"):
PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd")
def test_null_bytes_blocked(self):
"""Null bytes in path are blocked."""
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_local_filesystem_path("/tmp/file\x00.txt")
class TestValidateDataPath:
"""Tests for validate_data_path."""
def test_valid_data_path(self, tmp_path):
"""Valid relative data path is accepted."""
result = PathValidator.validate_data_path(
"subdir/file.txt", str(tmp_path)
)
assert result.parent.name == "subdir"
assert result.name == "file.txt"
def test_data_path_traversal_blocked(self, tmp_path):
"""Data path traversal is blocked."""
with pytest.raises(ValueError):
PathValidator.validate_data_path("../../etc/passwd", str(tmp_path))
@@ -0,0 +1,70 @@
"""Edge-case tests for path_validator — control chars, null bytes, and case normalization."""
import tempfile
from pathlib import Path
import pytest
from local_deep_research.security.path_validator import PathValidator
class TestControlCharactersRejected:
"""Verify various control characters (ord < 32) are rejected."""
def test_tab_character_rejected(self):
"""Tab (\\t, ord 9) should be rejected by validate_local_filesystem_path."""
with pytest.raises(ValueError, match="[Cc]ontrol"):
PathValidator.validate_local_filesystem_path("/tmp/test\tdir")
def test_bell_character_rejected(self):
"""Bell (\\x07, ord 7) should be rejected by validate_local_filesystem_path."""
with pytest.raises(ValueError, match="[Cc]ontrol"):
PathValidator.validate_local_filesystem_path("/tmp/test\x07dir")
def test_newline_in_path_rejected(self):
"""Newline (\\n, ord 10) should be rejected by validate_local_filesystem_path."""
with pytest.raises(ValueError, match="[Cc]ontrol"):
PathValidator.validate_local_filesystem_path("/tmp/test\ndir")
class TestNullBytesRejected:
"""Verify validate_config_path rejects null bytes."""
def test_multiple_null_bytes_rejected(self):
"""Multiple null bytes in config path should raise ValueError."""
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_config_path(
"s\x00e\x00ttings.json",
config_root="/tmp",
)
class TestEtcWithDifferentCasingBlocked:
"""Verify .lower() normalization blocks case-varied restricted paths."""
def test_etc_with_different_casing_blocked(self):
"""/ETC/passwd should be blocked via .lower() normalization in validate_config_path."""
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/ETC/passwd")
def test_etc_mixed_case_blocked(self):
"""/Etc/shadow should also be blocked."""
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/Etc/shadow")
class TestSafeJoinReturnsNoneRaisesValueError:
"""Verify path traversal via safe_join → None → ValueError."""
@pytest.fixture
def temp_base_dir(self):
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)
def test_safe_join_returns_none_raises_valueerror(self, temp_base_dir):
"""Path traversal attempt where safe_join returns None must raise ValueError."""
with pytest.raises(ValueError):
PathValidator.validate_safe_path(
"../../../../../etc/passwd",
temp_base_dir,
)
@@ -0,0 +1,212 @@
"""Extended tests for security/path_validator.py - targeting untested paths.
Covers:
- sanitize_for_filesystem_ops() (entirely untested)
- validate_local_filesystem_path() relative path handling
- validate_local_filesystem_path() path traversal via .. explicit check
- validate_config_path() absolute path branch
- validate_config_path() non-string input
- validate_config_path() default config_root
- validate_model_path() default model_root
"""
from pathlib import Path
from unittest.mock import patch
import pytest
from local_deep_research.security.path_validator import PathValidator
# ── sanitize_for_filesystem_ops ──────────────────────────────────
class TestSanitizeForFilesystemOps:
"""Tests for sanitize_for_filesystem_ops (lines 225-235, entirely untested)."""
def test_returns_path_for_valid_absolute(self):
"""Valid absolute path returns a Path object."""
result = PathValidator.sanitize_for_filesystem_ops(Path("/tmp/test"))
assert isinstance(result, Path)
assert str(result).startswith("/")
def test_preserves_path_components(self):
"""Path components are preserved after sanitization."""
p = Path("/usr/local/share/data.txt")
result = PathValidator.sanitize_for_filesystem_ops(p)
assert result.name == "data.txt"
assert "local" in str(result)
def test_raises_for_relative_path(self):
"""Relative path raises ValueError."""
with pytest.raises(ValueError, match="absolute"):
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
def test_raises_for_dot_relative(self):
"""Dot-relative path raises ValueError."""
with pytest.raises(ValueError, match="absolute"):
PathValidator.sanitize_for_filesystem_ops(Path("./here"))
# ── validate_local_filesystem_path: relative path handling ───────
class TestLocalFilesystemPathRelative:
"""Tests for relative path handling in validate_local_filesystem_path (lines 160-167)."""
def test_relative_path_resolved_from_cwd(self, tmp_path):
"""Relative path is resolved relative to current working directory."""
with patch("os.getcwd", return_value=str(tmp_path)):
result = PathValidator.validate_local_filesystem_path(
"some_folder", restricted_dirs=[]
)
assert result == (tmp_path / "some_folder").resolve()
def test_relative_nested_path(self, tmp_path):
"""Nested relative path resolves correctly."""
with patch("os.getcwd", return_value=str(tmp_path)):
result = PathValidator.validate_local_filesystem_path(
"a/b/c", restricted_dirs=[]
)
assert str(result).endswith("a/b/c")
def test_path_traversal_in_input_blocked(self):
"""Explicit .. in path input is blocked before resolution."""
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_local_filesystem_path("../escape")
def test_double_dot_in_middle_blocked(self):
"""Path with .. in the middle is blocked."""
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_local_filesystem_path("safe/../escape")
# ── validate_local_filesystem_path: home expansion edge cases ────
class TestLocalFilesystemPathHomeExpansion:
"""Tests for home directory expansion edge cases."""
@pytest.mark.skipif(
Path.home() == Path("/root"),
reason="Skipping in Docker/CI where home is /root (restricted)",
)
def test_bare_tilde_resolves_to_home(self):
"""Bare ~ resolves to home directory."""
result = PathValidator.validate_local_filesystem_path("~")
assert result == Path.home().resolve()
@pytest.mark.skipif(
Path.home() == Path("/root"),
reason="Skipping in Docker/CI where home is /root (restricted)",
)
def test_tilde_with_relative_part(self):
"""~/path expands home and joins relative part."""
result = PathValidator.validate_local_filesystem_path("~/some/path")
expected = (Path.home() / "some/path").resolve()
assert result == expected
# ── validate_config_path: absolute path branch ──────────────────
class TestConfigPathAbsolute:
"""Tests for absolute config path validation (lines 361-380).
Note: werkzeug's safe_join("/", "/absolute/path") returns None,
so absolute paths are always rejected as "Invalid absolute path".
This test documents that security-conservative behavior.
"""
def test_absolute_config_rejected_by_safe_join(self, tmp_path):
"""Absolute path is rejected because safe_join returns None."""
config_file = tmp_path / "settings.json"
config_file.write_text('{"key": "value"}')
with pytest.raises(ValueError, match="Invalid absolute path"):
PathValidator.validate_config_path(str(config_file))
def test_absolute_config_wrong_extension_also_rejected(self, tmp_path):
"""Absolute path with wrong extension is also rejected (before ext check)."""
script = tmp_path / "evil.py"
script.write_text("import os")
with pytest.raises(ValueError, match="Invalid absolute path"):
PathValidator.validate_config_path(str(script))
def test_absolute_config_nonexistent_also_rejected(self, tmp_path):
"""Absolute path to non-existent config is also rejected (before existence check)."""
with pytest.raises(ValueError, match="Invalid absolute path"):
PathValidator.validate_config_path(str(tmp_path / "missing.json"))
# ── validate_config_path: input validation ───────────────────────
class TestConfigPathInputValidation:
"""Tests for config path input validation edge cases."""
def test_non_string_input_raises(self):
"""Non-string input raises ValueError."""
with pytest.raises(ValueError, match="Invalid"):
PathValidator.validate_config_path(123)
def test_none_input_raises(self):
"""None input raises ValueError."""
with pytest.raises(ValueError, match="Invalid"):
PathValidator.validate_config_path(None)
def test_empty_string_raises(self):
"""Empty string raises ValueError."""
with pytest.raises(ValueError, match="Invalid"):
PathValidator.validate_config_path("")
def test_sys_prefix_blocked(self):
"""Path starting with sys/ is blocked as restricted."""
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/sys/class/net")
def test_dev_prefix_blocked(self):
"""Path starting with dev/ is blocked as restricted."""
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/dev/null")
# ── validate_config_path: default config_root ────────────────────
class TestConfigPathDefaultRoot:
"""Tests for config path with default config_root (lines 383-386)."""
def test_uses_default_root_when_none(self, tmp_path):
"""When config_root is None, get_data_directory() is used."""
config_file = tmp_path / "test.json"
config_file.write_text("{}")
# get_data_directory is imported locally inside validate_config_path
with patch(
"local_deep_research.config.paths.get_data_directory",
return_value=str(tmp_path),
):
result = PathValidator.validate_config_path("test.json")
assert result.name == "test.json"
# ── validate_model_path: default model_root ──────────────────────
class TestModelPathDefaultRoot:
"""Tests for model path with default model_root (line 256)."""
def test_uses_default_root_when_none(self, tmp_path):
"""When model_root is None, get_models_directory() is used."""
model_file = tmp_path / "model.gguf"
model_file.write_text("fake model")
with patch(
"local_deep_research.security.path_validator.get_models_directory",
return_value=tmp_path,
):
result = PathValidator.validate_model_path("model.gguf")
assert result.exists()
assert result.name == "model.gguf"
@@ -0,0 +1,186 @@
"""
Tests targeting specific uncovered lines in path_validator.py.
Covers:
- Line 140: Malformed Windows path on win32
- Line 153: safe_join returns None for unix absolute path
- Lines 157-162: Windows absolute path handling (drive letter parsing)
- Lines 185-186: Windows system directory restrictions
- Line 274: validate_model_path when validate_safe_path returns None
- Line 308: validate_data_path when validate_safe_path returns None
- Lines 375-385: Absolute config path validation (suffix + existence)
- Line 400: Relative config path when validate_safe_path returns None
"""
from pathlib import Path
from unittest.mock import patch
import pytest
from local_deep_research.security.path_validator import PathValidator
# ---------------------------------------------------------------------------
# Line 140: Malformed Windows path on win32
# The function does `import sys` locally (line 112), so we patch `sys.platform`
# on the actual sys module.
# ---------------------------------------------------------------------------
class TestMalformedWindowsPath:
"""Line 140: raise ValueError('Malformed Windows path input')"""
@patch("sys.platform", "win32")
def test_malformed_windows_path_slash_colon(self):
"""'/C:/Windows' on win32 triggers the malformed path guard."""
with pytest.raises(ValueError, match="Malformed Windows path input"):
PathValidator.validate_local_filesystem_path("/C:/Windows")
@patch("sys.platform", "win32")
def test_malformed_windows_path_backslash_colon(self):
"""'\\C:\\Windows' on win32 triggers the malformed path guard."""
with pytest.raises(ValueError, match="Malformed Windows path input"):
PathValidator.validate_local_filesystem_path("\\C:\\Windows")
# ---------------------------------------------------------------------------
# Line 153: safe_join returns None for unix absolute path
# ---------------------------------------------------------------------------
class TestSafeJoinReturnsNoneUnix:
"""Line 153: raise ValueError('Invalid path - failed security validation')"""
@patch(
"local_deep_research.security.path_validator.safe_join",
return_value=None,
)
def test_safe_join_none_for_absolute_path(self, mock_safe_join):
with pytest.raises(ValueError, match="failed security validation"):
PathValidator.validate_local_filesystem_path("/home/user/docs")
# ---------------------------------------------------------------------------
# Lines 157-162: Windows absolute path handling (drive letter parsing)
# On Linux, a path like 'C:\Users\test' is not absolute (no leading /),
# so it enters the drive-letter branch at line 155.
# ---------------------------------------------------------------------------
class TestWindowsDriveLetterParsing:
"""Lines 157-162: Windows drive-letter absolute path branch."""
@patch("local_deep_research.security.path_validator.safe_join")
def test_windows_drive_letter_path_success(self, mock_safe_join):
"""Drive-letter path where safe_join succeeds (lines 157-162)."""
mock_safe_join.return_value = "/tmp/fake_resolved"
result = PathValidator.validate_local_filesystem_path("C:\\Users\\test")
assert result is not None
@patch(
"local_deep_research.security.path_validator.safe_join",
return_value=None,
)
def test_windows_drive_letter_safe_join_none(self, mock_safe_join):
"""Drive-letter path where safe_join returns None (line 160-161)."""
with pytest.raises(ValueError, match="failed security validation"):
PathValidator.validate_local_filesystem_path("C:\\Users\\test")
# ---------------------------------------------------------------------------
# Lines 185-186: Windows system directory restrictions
# When sys.platform == "win32" and restricted_dirs is None, extra Windows
# system dirs are added. We mock safe_join and Path.resolve so that the
# validated path falls inside one of those restricted dirs.
# ---------------------------------------------------------------------------
class TestWindowsSystemDirRestrictions:
"""Lines 184-193: Windows restricted directories added on win32."""
@patch("sys.platform", "win32")
@patch("local_deep_research.security.path_validator.safe_join")
def test_windows_restricted_dirs_block_access(self, mock_safe_join):
"""On win32, C:\\Windows is restricted by default."""
# The path 'C:\Windows\System32\cmd.exe' has len>2, [1]==':',
# so it enters the drive-letter branch.
# It does NOT start with / or \, so the malformed guard (line 135-140)
# is skipped.
fake = Path("C:\\Windows\\System32\\cmd.exe")
mock_safe_join.return_value = str(fake)
with patch.object(Path, "resolve", return_value=fake):
with patch.object(Path, "is_relative_to", return_value=True):
with pytest.raises(
ValueError, match="Cannot access system directories"
):
PathValidator.validate_local_filesystem_path(
"C:\\Windows\\System32\\cmd.exe"
)
# ---------------------------------------------------------------------------
# Line 274: validate_model_path - validate_safe_path returns None
# ---------------------------------------------------------------------------
class TestValidateModelPathNone:
"""Line 274: raise ValueError('Invalid model path')"""
@patch.object(PathValidator, "validate_safe_path", return_value=None)
def test_model_path_returns_none(self, mock_vsp):
with pytest.raises(ValueError, match="Invalid model path"):
PathValidator.validate_model_path(
"bad_model.bin", model_root="/tmp"
)
# ---------------------------------------------------------------------------
# Line 308: validate_data_path - validate_safe_path returns None
# ---------------------------------------------------------------------------
class TestValidateDataPathNone:
"""Line 308: raise ValueError('Invalid data path')"""
@patch.object(PathValidator, "validate_safe_path", return_value=None)
def test_data_path_returns_none(self, mock_vsp):
with pytest.raises(ValueError, match="Invalid data path"):
PathValidator.validate_data_path("bad_file.dat", data_root="/tmp")
# ---------------------------------------------------------------------------
# Lines 375-385: Absolute config path validation
# safe_join("/", config_path) returns None when config_path starts with "/",
# so on Unix these lines are unreachable through normal flow.
# We mock safe_join to return a value so we can exercise lines 375-385.
# ---------------------------------------------------------------------------
class TestAbsoluteConfigPath:
"""Lines 375-385: absolute config path suffix and existence checks."""
@patch("local_deep_research.security.path_validator.safe_join")
def test_absolute_config_bad_extension(self, mock_safe_join):
"""Line 378-379: suffix not in CONFIG_EXTENSIONS."""
mock_safe_join.return_value = "/tmp/config.exe"
with pytest.raises(ValueError, match="Invalid config file type"):
PathValidator.validate_config_path("/tmp/config.exe")
@patch("local_deep_research.security.path_validator.safe_join")
def test_absolute_config_does_not_exist(self, mock_safe_join, tmp_path):
"""Line 382-383: valid extension but file does not exist."""
nonexistent = str(tmp_path / "missing.json")
mock_safe_join.return_value = nonexistent
with pytest.raises(ValueError, match="Config file not found"):
PathValidator.validate_config_path(nonexistent)
@patch("local_deep_research.security.path_validator.safe_join")
def test_absolute_config_valid(self, mock_safe_join, tmp_path):
"""Lines 375-385: happy path - valid extension, file exists."""
cfg = tmp_path / "settings.yaml"
cfg.write_text("key: value")
cfg_str = str(cfg)
mock_safe_join.return_value = cfg_str
result = PathValidator.validate_config_path(cfg_str)
assert result == Path(cfg_str)
# ---------------------------------------------------------------------------
# Line 400: Relative config path - validate_safe_path returns None
# ---------------------------------------------------------------------------
class TestRelativeConfigPathNone:
"""Line 400: raise ValueError('Invalid config path: ...')"""
@patch.object(PathValidator, "validate_safe_path", return_value=None)
def test_relative_config_path_none(self, mock_vsp):
with pytest.raises(ValueError, match="Invalid config path"):
PathValidator.validate_config_path(
"my_config.yaml", config_root="/tmp"
)
@@ -0,0 +1,276 @@
"""High-value tests for security/path_validator.py - Path Validation."""
from pathlib import Path
import pytest
from local_deep_research.security.path_validator import PathValidator
# ---------------------------------------------------------------------------
# validate_safe_path()
# ---------------------------------------------------------------------------
class TestValidateSafePath:
"""Safe path validation with base directory containment."""
def test_null_bytes_rejected(self, tmp_path):
with pytest.raises(ValueError, match="[Nn]ull"):
PathValidator.validate_safe_path("file\x00.txt", tmp_path)
def test_traversal_blocked(self, tmp_path):
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_safe_path("../../etc/passwd", tmp_path)
def test_valid_relative_path(self, tmp_path):
result = PathValidator.validate_safe_path("subdir/file.txt", tmp_path)
assert result is not None
assert str(tmp_path) in str(result)
def test_extension_filtering_valid(self, tmp_path):
result = PathValidator.validate_safe_path(
"config.json", tmp_path, required_extensions=(".json", ".yaml")
)
assert result is not None
assert result.suffix == ".json"
def test_extension_filtering_invalid(self, tmp_path):
with pytest.raises(ValueError, match="file type"):
PathValidator.validate_safe_path(
"script.py", tmp_path, required_extensions=(".json",)
)
def test_empty_input_rejected(self, tmp_path):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_safe_path("", tmp_path)
def test_none_input_rejected(self, tmp_path):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_safe_path(None, tmp_path)
def test_simple_filename(self, tmp_path):
result = PathValidator.validate_safe_path("readme.txt", tmp_path)
assert result.name == "readme.txt"
# ---------------------------------------------------------------------------
# validate_local_filesystem_path()
# ---------------------------------------------------------------------------
class TestValidateLocalFilesystemPath:
"""Local filesystem path validation."""
def test_etc_restricted(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/etc/passwd")
def test_sys_restricted(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/sys/kernel")
def test_proc_restricted(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/proc/self/environ")
def test_dev_restricted(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/dev/null")
def test_tilde_expansion(self):
result = PathValidator.validate_local_filesystem_path(
"~/Documents", restricted_dirs=[]
)
assert str(result).startswith(str(Path.home()))
def test_tilde_alone(self):
result = PathValidator.validate_local_filesystem_path(
"~", restricted_dirs=[]
)
assert result == Path.home().resolve()
def test_control_chars_rejected(self):
with pytest.raises(ValueError, match="Control characters"):
PathValidator.validate_local_filesystem_path("/tmp/file\x01name")
def test_null_bytes_rejected(self):
with pytest.raises(ValueError, match="[Nn]ull"):
PathValidator.validate_local_filesystem_path("/tmp/file\x00name")
def test_dotdot_blocked(self):
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd")
def test_absolute_unix_path_valid(self):
result = PathValidator.validate_local_filesystem_path("/tmp")
assert result.is_absolute()
def test_relative_path_resolved(self):
result = PathValidator.validate_local_filesystem_path("somedir")
assert result.is_absolute()
def test_empty_rejected(self):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_local_filesystem_path("")
def test_none_rejected(self):
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_local_filesystem_path(None)
def test_custom_restricted_dirs(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path(
"/custom/restricted/path",
restricted_dirs=[Path("/custom/restricted")],
)
def test_boot_restricted(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/boot/vmlinuz")
def test_var_log_restricted(self):
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/var/log/syslog")
# ---------------------------------------------------------------------------
# sanitize_for_filesystem_ops()
# ---------------------------------------------------------------------------
class TestSanitizeForFilesystemOps:
"""Re-sanitization for static analyzers."""
def test_non_absolute_rejected(self):
with pytest.raises(ValueError, match="absolute"):
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
def test_absolute_path_passes(self):
result = PathValidator.sanitize_for_filesystem_ops(Path("/tmp/safe"))
assert result == Path("/tmp/safe")
def test_root_path(self):
result = PathValidator.sanitize_for_filesystem_ops(Path("/"))
assert result == Path("/")
# ---------------------------------------------------------------------------
# validate_config_path()
# ---------------------------------------------------------------------------
class TestValidateConfigPath:
"""Config file path validation."""
def test_etc_prefix_rejected(self):
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/etc/config.json")
def test_proc_prefix_rejected(self):
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/proc/config.yaml")
def test_sys_prefix_rejected(self):
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/sys/config.toml")
def test_dev_prefix_rejected(self):
with pytest.raises(ValueError, match="restricted"):
PathValidator.validate_config_path("/dev/config.ini")
def test_dotdot_blocked(self):
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_config_path("../etc/passwd")
def test_empty_rejected(self):
with pytest.raises(ValueError, match="Invalid config path"):
PathValidator.validate_config_path("")
def test_none_rejected(self):
with pytest.raises(ValueError, match="Invalid config path"):
PathValidator.validate_config_path(None)
def test_null_bytes_rejected(self):
with pytest.raises(ValueError, match="[Nn]ull"):
PathValidator.validate_config_path("/tmp/config\x00.json")
def test_relative_path_uses_config_root(self, tmp_path):
result = PathValidator.validate_config_path(
"settings.json", config_root=str(tmp_path)
)
assert str(tmp_path) in str(result)
def test_invalid_extension_rejected(self, tmp_path):
# Create a file with invalid extension
test_file = tmp_path / "script.py"
test_file.write_text("data")
with pytest.raises(ValueError, match="file type"):
PathValidator.validate_config_path(
"script.py", config_root=str(tmp_path)
)
def test_relative_config_path_with_config_root(self, tmp_path):
cfg = tmp_path / "cfg.json"
cfg.write_text("{}")
# validate_config_path uses safe_join("/", path) which requires
# stripping the leading slash; pass relative-to-root form
result = PathValidator.validate_config_path(
"cfg.json", config_root=str(tmp_path)
)
assert result.name == "cfg.json"
def test_absolute_config_path_rejected_for_invalid_extension(
self, tmp_path
):
script = tmp_path / "config.py"
script.write_text("data")
with pytest.raises(ValueError, match="file type|Invalid absolute path"):
PathValidator.validate_config_path(str(script))
# ---------------------------------------------------------------------------
# SAFE_PATH_PATTERN regex
# ---------------------------------------------------------------------------
class TestSafePathPattern:
"""SAFE_PATH_PATTERN regex validation."""
def test_alphanumeric(self):
assert PathValidator.SAFE_PATH_PATTERN.match("hello123")
def test_dots_and_dashes(self):
assert PathValidator.SAFE_PATH_PATTERN.match("file-name.txt")
def test_slashes(self):
assert PathValidator.SAFE_PATH_PATTERN.match("dir/sub/file.txt")
def test_spaces_rejected(self):
assert PathValidator.SAFE_PATH_PATTERN.match("hello world") is None
def test_special_chars_rejected(self):
assert PathValidator.SAFE_PATH_PATTERN.match("file;rm -rf") is None
def test_shell_injection_rejected(self):
assert PathValidator.SAFE_PATH_PATTERN.match("$(whoami)") is None
def test_underscore_allowed(self):
assert PathValidator.SAFE_PATH_PATTERN.match("my_file.txt")
# ---------------------------------------------------------------------------
# CONFIG_EXTENSIONS
# ---------------------------------------------------------------------------
class TestConfigExtensions:
def test_json_included(self):
assert ".json" in PathValidator.CONFIG_EXTENSIONS
def test_yaml_included(self):
assert ".yaml" in PathValidator.CONFIG_EXTENSIONS
assert ".yml" in PathValidator.CONFIG_EXTENSIONS
def test_toml_included(self):
assert ".toml" in PathValidator.CONFIG_EXTENSIONS
@@ -0,0 +1,334 @@
"""Tests for PathValidator — security-sensitive path validation edge cases."""
from pathlib import Path
import pytest
from local_deep_research.security.path_validator import PathValidator
# ===========================================================================
# 1. validate_safe_path
# ===========================================================================
class TestValidateSafePath:
"""Verify validate_safe_path input validation and traversal blocking."""
def test_non_string_input_rejected(self):
"""Non-string input raises ValueError."""
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_safe_path(123, "/tmp")
def test_none_input_rejected(self):
"""None input raises ValueError."""
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_safe_path(None, "/tmp")
def test_empty_string_rejected(self):
"""Empty string raises ValueError."""
with pytest.raises(ValueError, match="Invalid path input"):
PathValidator.validate_safe_path("", "/tmp")
def test_null_bytes_rejected_in_safe_path(self, tmp_path):
"""Null bytes in validate_safe_path raise ValueError."""
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_safe_path("file\x00.txt", str(tmp_path))
def test_traversal_blocked(self, tmp_path):
"""Path traversal attempt via .. is blocked."""
with pytest.raises(ValueError):
PathValidator.validate_safe_path("../../etc/passwd", str(tmp_path))
def test_valid_relative_path(self, tmp_path):
"""Valid relative path returns resolved Path."""
result = PathValidator.validate_safe_path(
"subdir/file.txt", str(tmp_path)
)
assert result is not None
assert str(tmp_path) in str(result)
def test_extension_check_rejects_wrong_extension(self, tmp_path):
"""Wrong extension raises ValueError."""
with pytest.raises(ValueError, match="Invalid file type"):
PathValidator.validate_safe_path(
"file.txt",
str(tmp_path),
required_extensions=(".json", ".yaml"),
)
def test_extension_check_accepts_correct_extension(self, tmp_path):
"""Correct extension passes validation."""
result = PathValidator.validate_safe_path(
"config.json",
str(tmp_path),
required_extensions=(".json", ".yaml"),
)
assert result.suffix == ".json"
def test_extension_case_sensitivity(self, tmp_path):
"""Extension check is case-sensitive — .JSON != .json."""
with pytest.raises(ValueError, match="Invalid file type"):
PathValidator.validate_safe_path(
"config.JSON",
str(tmp_path),
required_extensions=(".json",),
)
def test_whitespace_stripped(self, tmp_path):
"""Leading/trailing whitespace in input is stripped."""
result = PathValidator.validate_safe_path(" file.txt ", str(tmp_path))
assert result is not None
assert "file.txt" in str(result)
# ===========================================================================
# 2. validate_local_filesystem_path — home dir expansion
# ===========================================================================
class TestHomeDirExpansion:
"""Verify ~ expansion edge cases."""
def test_tilde_slash_expands_to_home(self):
"""'~/' expands to home directory."""
home = str(Path.home())
result = PathValidator.validate_local_filesystem_path(
"~/documents",
restricted_dirs=[],
)
assert str(result).startswith(home)
def test_tilde_alone_expands_to_home(self):
"""'~' alone expands to home directory."""
home = Path.home().resolve()
result = PathValidator.validate_local_filesystem_path(
"~",
restricted_dirs=[],
)
assert result == home
def test_tilde_with_trailing_slashes(self):
"""'~/' with no relative part yields home directory."""
home = Path.home().resolve()
result = PathValidator.validate_local_filesystem_path(
"~/",
restricted_dirs=[],
)
assert result == home
def test_null_bytes_rejected(self):
"""Null bytes in path raise ValueError."""
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_local_filesystem_path("/tmp/file\x00.txt")
def test_control_characters_rejected(self):
"""Control characters raise ValueError."""
with pytest.raises(ValueError, match="Control characters"):
PathValidator.validate_local_filesystem_path("/tmp/file\x01name")
def test_path_traversal_blocked(self):
""".. in path raises ValueError."""
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_local_filesystem_path("/tmp/../etc/passwd")
def test_restricted_dir_blocked(self):
"""Access to /etc is blocked by default restrictions."""
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path("/etc/passwd")
def test_custom_restricted_dir(self, tmp_path):
"""Custom restricted dirs are enforced."""
restricted = tmp_path / "secret"
restricted.mkdir()
target = restricted / "file.txt"
target.touch()
with pytest.raises(ValueError, match="system directories"):
PathValidator.validate_local_filesystem_path(
str(target),
restricted_dirs=[restricted],
)
# ===========================================================================
# 3. sanitize_for_filesystem_ops — entirely uncovered
# ===========================================================================
class TestSanitizeForFilesystemOps:
"""Verify sanitize_for_filesystem_ops validation."""
def test_relative_path_rejected(self):
"""Relative path raises ValueError."""
with pytest.raises(ValueError, match="must be absolute"):
PathValidator.sanitize_for_filesystem_ops(Path("relative/path"))
def test_absolute_path_passes(self, tmp_path):
"""Absolute path is returned as a Path."""
result = PathValidator.sanitize_for_filesystem_ops(tmp_path)
assert isinstance(result, Path)
assert result.is_absolute()
def test_root_path(self):
"""Root path '/' passes sanitization."""
result = PathValidator.sanitize_for_filesystem_ops(Path("/"))
assert result == Path("/")
def test_deep_path_preserved(self, tmp_path):
"""Deep nested path structure is preserved."""
deep = tmp_path / "a" / "b" / "c"
result = PathValidator.sanitize_for_filesystem_ops(deep)
assert str(result).endswith("a/b/c")
# ===========================================================================
# 4. validate_model_path
# ===========================================================================
class TestValidateModelPath:
"""Verify validate_model_path checks."""
def test_nonexistent_model_file(self, tmp_path):
"""Non-existent model file raises ValueError."""
with pytest.raises(ValueError, match="Model file not found"):
PathValidator.validate_model_path(
"model.bin", model_root=str(tmp_path)
)
def test_directory_not_file_rejected(self, tmp_path):
"""Directory path raises 'not a file' error."""
subdir = tmp_path / "model_dir"
subdir.mkdir()
with pytest.raises(ValueError, match="not a file"):
PathValidator.validate_model_path(
"model_dir", model_root=str(tmp_path)
)
def test_valid_model_file(self, tmp_path):
"""Valid model file returns resolved path."""
model_file = tmp_path / "model.gguf"
model_file.touch()
result = PathValidator.validate_model_path(
"model.gguf", model_root=str(tmp_path)
)
assert result == model_file.resolve()
def test_traversal_in_model_path(self, tmp_path):
"""Path traversal in model path is blocked."""
with pytest.raises(ValueError):
PathValidator.validate_model_path(
"../../../etc/passwd", model_root=str(tmp_path)
)
def test_model_root_created_if_missing(self, tmp_path):
"""Model root directory is created if it doesn't exist."""
new_root = tmp_path / "new_models"
# Will fail because file doesn't exist, but root should be created
try:
PathValidator.validate_model_path(
"test.bin", model_root=str(new_root)
)
except ValueError:
pass
assert new_root.exists()
# ===========================================================================
# 5. validate_config_path — restricted prefixes and null byte rejection
# ===========================================================================
class TestValidateConfigPath:
"""Verify validate_config_path security checks."""
def test_non_string_rejected(self):
"""Non-string input raises ValueError."""
with pytest.raises(ValueError, match="Invalid config path"):
PathValidator.validate_config_path(123)
def test_empty_string_rejected(self):
"""Empty string raises ValueError."""
with pytest.raises(ValueError, match="Invalid config path"):
PathValidator.validate_config_path("")
def test_null_bytes_rejected(self, tmp_path):
"""Null bytes in config paths raise ValueError."""
with pytest.raises(ValueError, match="Null bytes"):
PathValidator.validate_config_path(
"config\x00.json",
config_root=str(tmp_path),
)
def test_traversal_rejected(self):
""".. in config path raises ValueError."""
with pytest.raises(ValueError, match="traversal"):
PathValidator.validate_config_path(
"../etc/passwd", config_root="/tmp"
)
@pytest.mark.parametrize(
"restricted", ["etc/passwd", "proc/self", "sys/class", "dev/null"]
)
def test_restricted_prefixes_blocked(self, restricted):
"""System directory prefixes are blocked."""
with pytest.raises(ValueError, match="restricted system directory"):
PathValidator.validate_config_path(f"/{restricted}")
def test_restricted_prefix_exact_match(self):
"""Exact restricted directory name (no trailing path) is blocked."""
with pytest.raises(ValueError, match="restricted system directory"):
PathValidator.validate_config_path("/etc")
def test_invalid_extension_rejected(self, tmp_path):
"""Non-config extension is rejected."""
txt_file = tmp_path / "file.txt"
txt_file.touch()
with pytest.raises(ValueError, match="Invalid"):
PathValidator.validate_config_path(
"file.txt", config_root=str(tmp_path)
)
def test_valid_config_extensions(self, tmp_path):
"""All valid config extensions are accepted."""
for ext in (".json", ".yaml", ".yml", ".toml", ".ini", ".conf"):
config_file = tmp_path / f"config{ext}"
config_file.write_text("")
result = PathValidator.validate_config_path(
f"config{ext}",
config_root=str(tmp_path),
)
assert result is not None
def test_absolute_config_path_rejected_by_safe_join(self, tmp_path):
"""Absolute config paths fail safe_join validation (safe_join rejects absolute second args)."""
config_file = tmp_path / "app.json"
config_file.write_text("{}")
# safe_join("/", "/tmp/.../app.json") returns None because the path is absolute
with pytest.raises(ValueError, match="Invalid absolute path"):
PathValidator.validate_config_path(str(config_file))
def test_restricted_prefix_case_insensitive(self):
"""Restricted prefix check is case-insensitive."""
with pytest.raises(ValueError, match="restricted system directory"):
PathValidator.validate_config_path("/ETC/passwd")
# ===========================================================================
# 6. validate_data_path
# ===========================================================================
class TestValidateDataPath:
"""Verify validate_data_path basics."""
def test_valid_data_path(self, tmp_path):
"""Valid relative path returns resolved path."""
result = PathValidator.validate_data_path("data.db", str(tmp_path))
assert "data.db" in str(result)
def test_traversal_in_data_path(self, tmp_path):
"""Path traversal is blocked."""
with pytest.raises(ValueError):
PathValidator.validate_data_path("../../etc/passwd", str(tmp_path))
+152
View File
@@ -0,0 +1,152 @@
"""Tests for path traversal protection in PDF storage manager (PR #1890).
Verifies:
- _get_safe_file_path blocks traversal, symlinks, and sentinel values
- library_root is resolved on init
- get_absolute_path_from_settings validates resolved paths
"""
import pytest
from unittest.mock import MagicMock, patch
from local_deep_research.constants import (
FILE_PATH_METADATA_ONLY,
FILE_PATH_TEXT_ONLY,
FILE_PATH_BLOB_DELETED,
)
from local_deep_research.research_library.services.pdf_storage_manager import (
PDFStorageManager,
)
class TestValidateFilesystemPath:
"""Test _get_safe_file_path blocks unsafe paths."""
def test_traversal_blocked(self, tmp_path):
"""Path with .. escaping library root returns None."""
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
result = manager._get_safe_file_path("../../etc/passwd")
assert result is None
def test_valid_relative_path(self, tmp_path):
"""Valid relative path inside library root returns resolved Path."""
sub = tmp_path / "docs"
sub.mkdir(exist_ok=True)
test_file = sub / "paper.pdf"
test_file.write_text("pdf content")
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
result = manager._get_safe_file_path("docs/paper.pdf")
assert result is not None
assert result.is_relative_to(tmp_path)
def test_metadata_only_returns_none(self, tmp_path):
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
result = manager._get_safe_file_path(FILE_PATH_METADATA_ONLY)
assert result is None
def test_text_only_returns_none(self, tmp_path):
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
result = manager._get_safe_file_path(FILE_PATH_TEXT_ONLY)
assert result is None
def test_blob_deleted_returns_none(self, tmp_path):
"""blob_deleted sentinel should return None (not construct a path)."""
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
result = manager._get_safe_file_path(FILE_PATH_BLOB_DELETED)
assert result is None
def test_empty_string_returns_none(self, tmp_path):
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
result = manager._get_safe_file_path("")
assert result is None
def test_none_returns_none(self, tmp_path):
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
result = manager._get_safe_file_path(None)
assert result is None
def test_symlink_blocked(self, tmp_path):
"""Symlinks are blocked before resolve() dereferences them."""
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
# Create a symlink pointing outside library root
link_path = tmp_path / "evil_link"
try:
link_path.symlink_to("/etc/passwd")
except OSError:
pytest.skip("Cannot create symlinks in this environment")
result = manager._get_safe_file_path("evil_link")
assert result is None
class TestLibraryRootResolved:
"""Verify library_root is resolved on init (prevents late traversal)."""
def test_library_root_is_resolved(self, tmp_path):
"""library_root should be an absolute resolved path."""
manager = PDFStorageManager(
library_root=str(tmp_path), storage_mode="filesystem"
)
assert manager.library_root == tmp_path.resolve()
assert manager.library_root.is_absolute()
class TestGetAbsolutePathFromSettings:
"""Test path traversal protection in get_absolute_path_from_settings."""
@patch("local_deep_research.utilities.db_utils.get_settings_manager")
def test_traversal_returns_none(self, mock_settings_mgr, tmp_path):
from local_deep_research.research_library.utils import (
get_absolute_path_from_settings,
)
mock_mgr = MagicMock()
mock_mgr.get_setting.return_value = str(tmp_path)
mock_settings_mgr.return_value = mock_mgr
result = get_absolute_path_from_settings("../../etc/passwd")
assert result is None
@patch("local_deep_research.utilities.db_utils.get_settings_manager")
def test_valid_path_returns_resolved(self, mock_settings_mgr, tmp_path):
from local_deep_research.research_library.utils import (
get_absolute_path_from_settings,
)
mock_mgr = MagicMock()
mock_mgr.get_setting.return_value = str(tmp_path)
mock_settings_mgr.return_value = mock_mgr
result = get_absolute_path_from_settings("docs/file.pdf")
assert result.is_relative_to(tmp_path.resolve())
@patch("local_deep_research.utilities.db_utils.get_settings_manager")
def test_empty_path_returns_library_root(self, mock_settings_mgr, tmp_path):
from local_deep_research.research_library.utils import (
get_absolute_path_from_settings,
)
mock_mgr = MagicMock()
mock_mgr.get_setting.return_value = str(tmp_path)
mock_settings_mgr.return_value = mock_mgr
result = get_absolute_path_from_settings("")
assert result == tmp_path.resolve()
+656
View File
@@ -0,0 +1,656 @@
"""Tests for security/rate_limiter.py."""
import importlib
import pytest
from unittest.mock import MagicMock, patch
from flask import Flask
def _reload_rate_limiter_with_limits(user_limit, ip_limit):
"""Reload security.rate_limiter with patched upload limits.
Decorators bind the limit string at module-import time, so functional
tests must reload the module after patching `load_server_config` to
re-bind the upload decorators with the test-controlled values.
"""
from local_deep_research.web import server_config as sc
fake_config = {
"host": "0.0.0.0",
"port": 5000,
"debug": False,
"use_https": True,
"allow_registrations": True,
"rate_limit_default": "5000 per hour;50000 per day",
"rate_limit_login": "5 per 15 minutes",
"rate_limit_registration": "3 per hour",
"rate_limit_settings": "30 per minute",
"rate_limit_upload_user": user_limit,
"rate_limit_upload_ip": ip_limit,
}
with patch.object(sc, "load_server_config", return_value=fake_config):
from local_deep_research.security import rate_limiter
importlib.reload(rate_limiter)
return rate_limiter
@pytest.fixture
def app():
"""Create test Flask application."""
app = Flask(__name__)
app.config["TESTING"] = True
return app
@pytest.fixture
def restore_rate_limiter_module():
"""Snapshot rate_limiter's module dict and restore it after the test.
The functional tests reload the module to re-bind the upload decorators
under test-controlled limits. Restoring the snapshot puts back the
original objects (the module-level Limiter instance included), instead
of reloading a third time under a hardcoded fake config — which would
leave fake limits and a fresh Limiter in the module for later tests
while earlier `from rate_limiter import ...` bindings kept the
originals.
"""
from local_deep_research.security import rate_limiter
snapshot = dict(rate_limiter.__dict__)
yield
rate_limiter.__dict__.clear()
rate_limiter.__dict__.update(snapshot)
class TestGetClientIp:
"""Tests for get_client_ip function."""
def test_returns_first_ip_from_x_forwarded_for(self, app):
"""Test that first IP from X-Forwarded-For chain is returned."""
with app.test_request_context(
environ_base={
"HTTP_X_FORWARDED_FOR": "192.168.1.100, 10.0.0.1, 172.16.0.1"
}
):
from local_deep_research.security.rate_limiter import get_client_ip
result = get_client_ip()
assert result == "192.168.1.100"
def test_strips_whitespace_from_forwarded_ip(self, app):
"""Test that whitespace is stripped from forwarded IP."""
with app.test_request_context(
environ_base={"HTTP_X_FORWARDED_FOR": " 192.168.1.100 , 10.0.0.1"}
):
from local_deep_research.security.rate_limiter import get_client_ip
result = get_client_ip()
assert result == "192.168.1.100"
def test_returns_x_real_ip_when_no_forwarded_for(self, app):
"""Test that X-Real-IP is used when X-Forwarded-For is absent."""
with app.test_request_context(
environ_base={"HTTP_X_REAL_IP": "10.20.30.40"}
):
from local_deep_research.security.rate_limiter import get_client_ip
result = get_client_ip()
assert result == "10.20.30.40"
def test_strips_whitespace_from_real_ip(self, app):
"""Test that whitespace is stripped from X-Real-IP."""
with app.test_request_context(
environ_base={"HTTP_X_REAL_IP": " 10.20.30.40 "}
):
from local_deep_research.security.rate_limiter import get_client_ip
result = get_client_ip()
assert result == "10.20.30.40"
def test_falls_back_to_remote_address(self, app):
"""Test fallback to get_remote_address when no proxy headers."""
with app.test_request_context():
from local_deep_research.security.rate_limiter import get_client_ip
# When no proxy headers are set, get_remote_address returns the remote addr
result = get_client_ip()
# Result should be some IP (default is typically 127.0.0.1)
assert result is not None
def test_prefers_x_forwarded_for_over_x_real_ip(self, app):
"""Test that X-Forwarded-For takes precedence over X-Real-IP."""
with app.test_request_context(
environ_base={
"HTTP_X_FORWARDED_FOR": "192.168.1.1",
"HTTP_X_REAL_IP": "10.0.0.1",
}
):
from local_deep_research.security.rate_limiter import get_client_ip
result = get_client_ip()
assert result == "192.168.1.1"
class TestRateLimiterConfiguration:
"""Tests for rate limiter configuration."""
def test_limiter_uses_get_client_ip_as_key_func(self):
"""Test that limiter is configured with get_client_ip as key function."""
from local_deep_research.security.rate_limiter import (
limiter,
get_client_ip,
)
assert limiter._key_func == get_client_ip
def test_limiter_uses_memory_storage(self):
"""Test that limiter uses in-memory storage by default."""
from local_deep_research.security.rate_limiter import limiter
# The storage URI should be memory
assert limiter._storage_uri == "memory://"
def test_limiter_has_headers_enabled(self):
"""Test that rate limit headers are enabled."""
from local_deep_research.security.rate_limiter import limiter
assert limiter._headers_enabled is True
class TestRateLimitConstants:
"""Tests for rate limit configuration constants."""
def test_default_rate_limit_loaded_from_config(self):
"""Test that DEFAULT_RATE_LIMIT is loaded from server config."""
from local_deep_research.security.rate_limiter import (
DEFAULT_RATE_LIMIT,
)
# Should be a string like "X per hour" or similar
assert isinstance(DEFAULT_RATE_LIMIT, str)
assert DEFAULT_RATE_LIMIT # Not empty
def test_login_rate_limit_loaded_from_config(self):
"""Test that LOGIN_RATE_LIMIT is loaded from server config."""
from local_deep_research.security.rate_limiter import LOGIN_RATE_LIMIT
assert isinstance(LOGIN_RATE_LIMIT, str)
assert LOGIN_RATE_LIMIT # Not empty
def test_registration_rate_limit_loaded_from_config(self):
"""Test that REGISTRATION_RATE_LIMIT is loaded from server config."""
from local_deep_research.security.rate_limiter import (
REGISTRATION_RATE_LIMIT,
)
assert isinstance(REGISTRATION_RATE_LIMIT, str)
assert REGISTRATION_RATE_LIMIT # Not empty
def test_settings_rate_limit_loaded_from_config(self):
"""Test that SETTINGS_RATE_LIMIT is loaded from config with default fallback."""
from local_deep_research.security.rate_limiter import (
SETTINGS_RATE_LIMIT,
)
assert isinstance(SETTINGS_RATE_LIMIT, str)
assert SETTINGS_RATE_LIMIT # Not empty
# Should contain a rate expression like "30 per minute"
assert "per" in SETTINGS_RATE_LIMIT
class TestSettingsLimit:
"""Tests for settings_limit shared rate limiter (PR #2021)."""
def test_settings_limit_is_shared_limit(self):
"""Test that settings_limit is a SharedLimitItem from flask-limiter."""
from local_deep_research.security.rate_limiter import settings_limit
# SharedLimitItem has a __call__ method (it's a decorator)
assert callable(settings_limit)
def test_settings_limit_can_decorate_function(self):
"""Test that settings_limit can be used as a decorator."""
from local_deep_research.security.rate_limiter import settings_limit
@settings_limit
def dummy_view():
return "ok"
# The decorated function should still be callable
assert callable(dummy_view)
def test_settings_limit_default_value(self):
"""Test that SETTINGS_RATE_LIMIT defaults to 30 per minute."""
from local_deep_research.security.rate_limiter import (
SETTINGS_RATE_LIMIT,
)
# The config.get uses "30 per minute" as default
# If not set in config, it should be "30 per minute"
assert isinstance(SETTINGS_RATE_LIMIT, str)
assert SETTINGS_RATE_LIMIT # Not empty
class TestApiRateLimit:
"""Tests for api_rate_limit shared limiter."""
def test_api_rate_limit_is_callable(self):
"""Test that api_rate_limit can be used as a decorator."""
from local_deep_research.security.rate_limiter import api_rate_limit
assert callable(api_rate_limit)
def test_get_user_api_rate_limit_default(self, app):
"""Test that _get_user_api_rate_limit returns default when no user."""
with app.test_request_context():
from local_deep_research.security.rate_limiter import (
_get_user_api_rate_limit,
)
result = _get_user_api_rate_limit()
assert result == 60
def test_get_api_rate_limit_string_format(self, app):
"""Test that _get_api_rate_limit_string returns correct format."""
from unittest.mock import patch
with app.test_request_context():
with patch(
"local_deep_research.security.rate_limiter._get_user_api_rate_limit",
return_value=30,
):
from local_deep_research.security.rate_limiter import (
_get_api_rate_limit_string,
)
result = _get_api_rate_limit_string()
assert result == "30 per minute"
def test_is_api_rate_limit_exempt_no_user(self, app):
"""Test that unauthenticated requests are exempt."""
with app.test_request_context():
from local_deep_research.security.rate_limiter import (
_is_api_rate_limit_exempt,
)
assert _is_api_rate_limit_exempt() is True
def test_is_api_rate_limit_exempt_zero_limit(self, app):
"""Test that rate_limit=0 means exempt."""
from unittest.mock import patch
app.config["SECRET_KEY"] = "test-secret"
with app.test_request_context():
from flask import g
g.current_user = "testuser"
with patch(
"local_deep_research.security.rate_limiter._get_user_api_rate_limit",
return_value=0,
):
from local_deep_research.security.rate_limiter import (
_is_api_rate_limit_exempt,
)
assert _is_api_rate_limit_exempt() is True
def test_get_api_user_key_with_user(self, app):
"""Test that key function returns user-prefixed key."""
with app.test_request_context():
from flask import g
g.current_user = "alice"
from local_deep_research.security.rate_limiter import (
_get_api_user_key,
)
result = _get_api_user_key()
assert result == "api_user:alice"
class TestUploadRateLimit:
"""Tests for upload_rate_limit_user and upload_rate_limit_ip shared limiters."""
def test_upload_rate_limit_user_exists_and_callable(self):
"""Test that upload_rate_limit_user is defined and callable."""
from local_deep_research.security.rate_limiter import (
upload_rate_limit_user,
)
assert upload_rate_limit_user is not None
assert callable(upload_rate_limit_user)
def test_upload_rate_limit_ip_exists_and_callable(self):
"""Test that upload_rate_limit_ip is defined and callable."""
from local_deep_research.security.rate_limiter import (
upload_rate_limit_ip,
)
assert upload_rate_limit_ip is not None
assert callable(upload_rate_limit_ip)
def test_get_upload_user_key_with_user(self, app):
"""Test that upload key function returns user-prefixed key."""
with app.test_request_context():
from flask import g
g.current_user = "bob"
from local_deep_research.security.rate_limiter import (
_get_upload_user_key,
)
result = _get_upload_user_key()
assert result == "upload_user:bob"
def test_get_upload_user_key_no_user(self, app):
"""Test that upload key function falls back to IP when no user."""
with app.test_request_context():
from local_deep_research.security.rate_limiter import (
_get_upload_user_key,
)
result = _get_upload_user_key()
assert result.startswith("upload_ip:")
class TestUploadRateLimitFunctional:
"""Functional tests for dual-key upload rate limiting."""
def test_upload_rate_limit_enforces_per_user(
self, restore_rate_limiter_module
):
"""Per-user upload limit blocks after threshold."""
rate_limiter = _reload_rate_limiter_with_limits(
user_limit="3 per minute", ip_limit="100 per minute"
)
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test"
test_app.config["TESTING"] = True
test_app.config["RATELIMIT_ENABLED"] = True
test_app.config["RATELIMIT_STRATEGY"] = "moving-window"
@test_app.route("/upload", methods=["POST"])
@rate_limiter.upload_rate_limit_user
@rate_limiter.upload_rate_limit_ip
def upload():
return "ok"
rate_limiter.limiter.init_app(test_app)
with test_app.test_client() as c:
with c.session_transaction() as sess:
sess["username"] = "uploader"
# Per-user limit is "3 per minute" — first 3 pass, 4th is blocked
for i in range(3):
resp = c.post("/upload")
assert resp.status_code == 200, f"Request {i + 1} should pass"
resp = c.post("/upload")
assert resp.status_code == 429
def test_upload_per_user_limit_is_independent(
self, restore_rate_limiter_module
):
"""Per-user upload bucket is keyed by username, not shared."""
rate_limiter = _reload_rate_limiter_with_limits(
user_limit="3 per minute", ip_limit="100 per minute"
)
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test"
test_app.config["TESTING"] = True
test_app.config["RATELIMIT_ENABLED"] = True
test_app.config["RATELIMIT_STRATEGY"] = "moving-window"
# Only per-user limit (no per-IP) to isolate user-key behavior
@test_app.route("/upload", methods=["POST"])
@rate_limiter.upload_rate_limit_user
def upload():
return "ok"
rate_limiter.limiter.init_app(test_app)
# User A exhausts their per-user limit
client_a = test_app.test_client()
with client_a.session_transaction() as sess:
sess["username"] = "user_a"
for _ in range(3):
client_a.post("/upload")
assert client_a.post("/upload").status_code == 429
# User B is unaffected (different user bucket)
client_b = test_app.test_client()
with client_b.session_transaction() as sess:
sess["username"] = "user_b"
assert client_b.post("/upload").status_code == 200
def test_upload_rate_limit_respects_env_var_override(
self, restore_rate_limiter_module
):
"""End-to-end: patched config flows through to decorator binding.
Reloading rate_limiter after patching load_server_config simulates
the env-var-set-at-process-start scenario without relying on a real
env var (the module captures _config at import time).
"""
rate_limiter = _reload_rate_limiter_with_limits(
user_limit="2 per minute", ip_limit="2 per minute"
)
assert rate_limiter._UPLOAD_RATE_LIMIT_USER == "2 per minute"
assert rate_limiter._UPLOAD_RATE_LIMIT_IP == "2 per minute"
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test"
test_app.config["TESTING"] = True
test_app.config["RATELIMIT_ENABLED"] = True
test_app.config["RATELIMIT_STRATEGY"] = "moving-window"
@test_app.route("/upload", methods=["POST"])
@rate_limiter.upload_rate_limit_user
@rate_limiter.upload_rate_limit_ip
def upload():
return "ok"
rate_limiter.limiter.init_app(test_app)
with test_app.test_client() as c:
with c.session_transaction() as sess:
sess["username"] = "envtest"
assert c.post("/upload").status_code == 200
assert c.post("/upload").status_code == 200
assert c.post("/upload").status_code == 429
class TestApiRateLimitCaching:
"""Tests for g-caching and DB fallback in _get_user_api_rate_limit."""
def test_g_cache_hit_skips_db(self, app):
"""Second call returns cached value without DB access."""
with app.test_request_context():
from flask import g
from local_deep_research.security.rate_limiter import (
_get_user_api_rate_limit,
)
g._api_rate_limit = 42
result = _get_user_api_rate_limit()
assert result == 42
def test_db_exception_falls_back_to_default(self, app):
"""DB failure returns default 60 without raising."""
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from flask import g
from local_deep_research.security.rate_limiter import (
_get_user_api_rate_limit,
)
g.current_user = "testuser"
with patch(
"local_deep_research.database.session_context.get_user_db_session",
side_effect=RuntimeError("DB down"),
):
result = _get_user_api_rate_limit()
assert result == 60
def test_custom_rate_limit_from_db(self, app):
"""User-configured rate limit is returned from DB."""
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from flask import g
from local_deep_research.security.rate_limiter import (
_get_user_api_rate_limit,
)
g.current_user = "testuser"
mock_sm = MagicMock()
mock_sm.get_setting.return_value = 30
mock_session = MagicMock()
with patch(
"local_deep_research.database.session_context.get_user_db_session"
) as mock_ctx:
mock_ctx.return_value.__enter__ = MagicMock(
return_value=mock_session
)
mock_ctx.return_value.__exit__ = MagicMock(return_value=None)
with patch(
"local_deep_research.utilities.db_utils.get_settings_manager",
return_value=mock_sm,
):
result = _get_user_api_rate_limit()
assert result == 30
assert g._api_rate_limit == 30
class TestSessionFallback:
"""Tests for session fallback when g.current_user is not set."""
def test_api_user_key_uses_session_fallback(self, app):
"""Key function uses session username when g.current_user absent."""
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from local_deep_research.security.rate_limiter import (
_get_api_user_key,
)
with app.test_client() as c:
with c.session_transaction() as sess:
sess["username"] = "session_user"
with c.application.test_request_context():
from flask import session as s
s["username"] = "session_user"
result = _get_api_user_key()
assert result == "api_user:session_user"
def test_upload_user_key_uses_session_fallback(self, app):
"""Upload key function uses session username when g.current_user absent."""
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from local_deep_research.security.rate_limiter import (
_get_upload_user_key,
)
with app.test_client() as c:
with c.application.test_request_context():
from flask import session as s
s["username"] = "session_uploader"
result = _get_upload_user_key()
assert result == "upload_user:session_uploader"
def test_g_current_user_takes_priority_over_session(self, app):
"""g.current_user is preferred over session username."""
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from flask import g, session
from local_deep_research.security.rate_limiter import (
_get_api_user_key,
)
g.current_user = "g_user"
session["username"] = "session_user"
result = _get_api_user_key()
assert result == "api_user:g_user"
def test_exempt_uses_session_fallback(self, app):
"""Exempt check uses session username when g.current_user absent."""
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from flask import session
from local_deep_research.security.rate_limiter import (
_is_api_rate_limit_exempt,
)
# No g.current_user, no session username → exempt
assert _is_api_rate_limit_exempt() is True
# Set session username → not exempt (has user, default limit > 0)
session["username"] = "session_user"
with patch(
"local_deep_research.security.rate_limiter._get_user_api_rate_limit",
return_value=60,
):
assert _is_api_rate_limit_exempt() is False
class TestGetCurrentUsername:
"""Tests for get_current_username helper."""
def test_returns_g_current_user(self, app):
with app.test_request_context():
from flask import g
from local_deep_research.security.rate_limiter import (
get_current_username,
)
g.current_user = "alice"
assert get_current_username() == "alice"
def test_returns_session_when_no_g_current_user(self, app):
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from flask import session
from local_deep_research.security.rate_limiter import (
get_current_username,
)
session["username"] = "bob"
assert get_current_username() == "bob"
def test_returns_none_when_neither_set(self, app):
with app.test_request_context():
from local_deep_research.security.rate_limiter import (
get_current_username,
)
assert get_current_username() is None
def test_g_current_user_priority_over_session(self, app):
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from flask import g, session
from local_deep_research.security.rate_limiter import (
get_current_username,
)
g.current_user = "g_user"
session["username"] = "session_user"
assert get_current_username() == "g_user"
def test_empty_string_g_current_user_falls_to_session(self, app):
"""Empty string g.current_user is falsy — should fall through to session."""
app.config["SECRET_KEY"] = "test"
with app.test_request_context():
from flask import g, session
from local_deep_research.security.rate_limiter import (
get_current_username,
)
g.current_user = ""
session["username"] = "session_user"
assert get_current_username() == "session_user"
@@ -0,0 +1,118 @@
"""
Tests for uncovered code paths in security/rate_limiter.py.
Targets:
- get_client_ip: X-Forwarded-For, X-Real-IP, fallback
- get_current_username: from g.current_user, from session, missing
- _get_upload_user_key: authenticated vs unauthenticated
"""
from unittest.mock import patch
MODULE = "local_deep_research.security.rate_limiter"
class TestGetClientIp:
def test_x_forwarded_for_first_ip(self, app):
"""Uses first IP from X-Forwarded-For header."""
from local_deep_research.security.rate_limiter import get_client_ip
with app.test_request_context(
environ_base={"HTTP_X_FORWARDED_FOR": "1.2.3.4, 5.6.7.8"}
):
assert get_client_ip() == "1.2.3.4"
def test_x_real_ip(self, app):
"""Uses X-Real-IP when no X-Forwarded-For."""
from local_deep_research.security.rate_limiter import get_client_ip
with app.test_request_context(
environ_base={"HTTP_X_REAL_IP": "10.0.0.1"}
):
assert get_client_ip() == "10.0.0.1"
def test_fallback_to_remote_addr(self, app):
"""Falls back to remote address when no proxy headers."""
from local_deep_research.security.rate_limiter import get_client_ip
with app.test_request_context(
environ_base={"REMOTE_ADDR": "127.0.0.1"}
):
result = get_client_ip()
assert result is not None
class TestGetCurrentUsername:
def test_from_g_current_user(self, app):
"""Returns username from g.current_user."""
from flask import g
from local_deep_research.security.rate_limiter import (
get_current_username,
)
with app.test_request_context():
g.current_user = "alice"
assert get_current_username() == "alice"
def test_from_session_fallback(self, app):
"""Falls back to session username."""
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: DELETE).
with app.test_request_context():
# No g.current_user set
with app.test_client() as client:
with client.session_transaction() as sess:
sess["username"] = "bob"
def test_returns_none_when_no_user(self, app):
"""Returns None when no user info available."""
from local_deep_research.security.rate_limiter import (
get_current_username,
)
with app.test_request_context():
result = get_current_username()
assert result is None
class TestGetUploadUserKey:
def test_authenticated_user_key(self, app):
"""Returns user-keyed string for authenticated users."""
from local_deep_research.security.rate_limiter import (
_get_upload_user_key,
)
with app.test_request_context():
with patch(f"{MODULE}.get_current_username", return_value="alice"):
result = _get_upload_user_key()
assert result == "upload_user:alice"
def test_unauthenticated_ip_key(self, app):
"""Returns IP-keyed string for unauthenticated requests."""
from local_deep_research.security.rate_limiter import (
_get_upload_user_key,
)
with app.test_request_context():
with patch(f"{MODULE}.get_current_username", return_value=None):
with patch(f"{MODULE}.get_client_ip", return_value="1.2.3.4"):
result = _get_upload_user_key()
assert result == "upload_ip:1.2.3.4"
class TestModuleConstants:
def test_shared_limits_exist(self):
"""Shared limit decorators are created."""
from local_deep_research.security.rate_limiter import (
login_limit,
registration_limit,
settings_limit,
upload_rate_limit_user,
)
assert login_limit is not None
assert registration_limit is not None
assert settings_limit is not None
assert upload_rate_limit_user is not None
@@ -0,0 +1,290 @@
"""
Regression-prevention fixtures for the SSRF hardening (PR #3873, #3882).
This is a defensive regression net: if a future change to ``validate_url``
accidentally rejects URLs LDR is documented to fetch, these tests fail
loudly. Patterns extracted from a real codebase audit of:
- ``src/local_deep_research/research_library/downloaders/`` (academic)
- ``src/local_deep_research/web_search_engines/engines/`` (search)
- ``src/local_deep_research/llm/providers/implementations/`` (LLM)
- ``src/local_deep_research/notifications/`` (Apprise)
Plus a complementary list of attack URLs that MUST stay blocked, and a
behaviour-change lock-in class for the deliberate semantic changes in
PR #3873 (None handling, whitespace stripping) and PR #3882 (log
redaction).
"""
import time
import pytest
from unittest.mock import patch
# DNS resolution mock — return a public IP so the validation pipeline
# reaches the IP-block check (which is the only thing that needs network).
_PUBLIC_DNS_RESPONSE = [(2, 1, 6, "", ("93.184.216.34", 0))]
# -----------------------------------------------------------------------
# REAL-WORLD URLS THAT MUST PASS
# -----------------------------------------------------------------------
# If any of these stops passing validate_url, an LDR user feature breaks.
# Categories: academic, search, llm, notifications, idn, edge, ipv6.
REAL_WORLD_URLS_THAT_MUST_PASS = [
# ---- Academic paper sources ----
("https://arxiv.org/abs/2401.12345", "academic"),
("https://arxiv.org/pdf/2401.12345v1.pdf", "academic"),
("https://export.arxiv.org/api/query?id_list=2401.12345", "academic"),
("https://pubmed.ncbi.nlm.nih.gov/35123456/", "academic"),
("https://www.ncbi.nlm.nih.gov/pmc/articles/PMC1234567/", "academic"),
(
"https://www.ebi.ac.uk/europepmc/webservices/rest/search",
"academic",
),
(
"https://eutils.ncbi.nlm.nih.gov/entrez/eutils/elink.fcgi",
"academic",
),
(
"https://www.biorxiv.org/content/10.1101/2024.01.01.123456v1.full.pdf",
"academic",
),
(
"https://api.openalex.org/works?filter=doi:10.1038/nature.2024.12345",
"academic",
),
(
"https://api.semanticscholar.org/graph/v1/paper/CORPUSID:12345",
"academic",
),
("https://doi.org/10.1038/nature12345", "academic"),
("https://ui.adsabs.harvard.edu/abs/2024ApJ...123..456A", "academic"),
# ---- Search / reference ----
("https://en.wikipedia.org/wiki/Machine_learning", "search"),
# encoded umlaut — common Wikipedia article URL form
("https://en.wikipedia.org/wiki/M%C3%BCnchen", "search"),
(
"https://web.archive.org/cdx/search/cdx?url=example.com",
"search",
),
("https://api.tavily.com/search", "search"),
("https://api.exa.ai/search", "search"),
(
"https://openlibrary.org/api/books?bibkeys=ISBN:0451524934",
"search",
),
("https://www.gutenberg.org/ebooks/12345", "search"),
("https://content.guardianapis.com/search", "search"),
(
"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/cid/2244/JSON",
"search",
),
# ---- LLM provider default endpoints (from openai_base/google/etc.) ----
# If these stop passing, LDR cannot talk to its own configured providers.
("https://api.openai.com/v1/models", "llm"),
("https://api.anthropic.com/v1/messages", "llm"),
("https://openrouter.ai/api/v1/chat/completions", "llm"),
("https://api.x.ai/v1/chat/completions", "llm"),
("https://generativelanguage.googleapis.com/v1beta/openai", "llm"),
("https://openai.inference.de-txl.ionos.com/v1", "llm"),
# ---- IDN / non-Latin domains (urllib3 auto-Punycodes) ----
# These exercise the urllib3 host-extraction path and confirm that
# users in Asia / Cyrillic / Han regions are not blocked.
("https://例え.jp/", "idn"),
("https://привет.рф/", "idn"),
("https://中国.cn/", "idn"),
("https://xn--mnchen-3ya.de/", "idn"), # pre-Punycoded München
# ---- Edge cases — RFC-legal patterns ----
("https://api.example.com/v1?keys[]=foo&keys[]=bar", "edge"),
(
"https://api.example.com/v1/items?since=2024-01-01T00:00:00Z",
"edge",
),
("https://example.com/path/with+plus", "edge"),
("https://example.com/?q=hello+world", "edge"),
("https://user:pass@example.com/", "edge"),
("https://example.com./", "edge"), # FQDN trailing dot
("https://example.com/file.pdf;jsessionid=abc123", "edge"),
# encoded backslash in PATH is RFC-legal — distinct from %5C in netloc
("https://example.com/path%5Cfile", "edge"),
(
"https://example.com/path/with-hyphens_and_underscores.html",
"edge",
),
# ---- IPv6 public addresses ----
("https://[2001:db8::1]/", "ipv6"),
("https://[2001:db8::1]:8080/", "ipv6"),
]
# -----------------------------------------------------------------------
# REAL-WORLD URLS THAT MUST FAIL (security sentinels)
# -----------------------------------------------------------------------
# If any of these starts passing, the SSRF hardening has regressed.
REAL_WORLD_URLS_THAT_MUST_FAIL = [
# ---- GHSA-g23j-2vwm-5c25 canonical ----
("http://127.0.0.1:6666\\@1.1.1.1", "advisory_canonical"),
("http://127.0.0.1:6666/%5C@1.1.1.1", "advisory_post_prepare"),
# ---- IPv6 unspecified bypass (caught in PR #3873 review) ----
("http://[::]/", "ipv6_unspecified"),
("http://[0::]/", "ipv6_unspecified_alt"),
("http://[0:0:0:0:0:0:0:0]/", "ipv6_unspecified_full"),
# ---- Cloud metadata — always blocked under every flag ----
("http://169.254.169.254/latest/meta-data/", "aws_imds"),
("http://169.254.170.2/v2/credentials/", "aws_ecs_v3"),
("http://169.254.170.23/v4/credentials/", "aws_ecs_v4"),
("http://169.254.0.23/", "tencent"),
("http://100.100.100.200/latest/meta-data/", "alibaba"),
# ---- Loopback / private (default flags) ----
("http://127.0.0.1/", "ipv4_loopback"),
("http://[::1]/", "ipv6_loopback"),
# ---- Forbidden chars (Layer 1) ----
("http://example.com/path with space", "whitespace"),
("http://example.com\t/", "tab"),
("http://example.com\n/", "newline"),
]
class TestRealWorldUrlsRegressionPrevention:
"""Lock in that legitimate URL patterns LDR fetches keep working."""
@pytest.mark.parametrize("url,category", REAL_WORLD_URLS_THAT_MUST_PASS)
def test_legitimate_url_passes(self, url, category):
from local_deep_research.security.ssrf_validator import (
validate_url,
)
with patch("socket.getaddrinfo", return_value=_PUBLIC_DNS_RESPONSE):
assert validate_url(url) is True, (
f"Legitimate {category} URL {url!r} unexpectedly "
f"rejected. This breaks an LDR user flow."
)
class TestSecuritySentinelsStayBlocked:
"""Lock in that the SSRF fix continues to block known attack
payloads. If any of these starts passing, the hardening has
silently regressed."""
@pytest.mark.parametrize("url,category", REAL_WORLD_URLS_THAT_MUST_FAIL)
def test_attack_url_blocked(self, url, category):
from local_deep_research.security.ssrf_validator import (
validate_url,
)
assert validate_url(url) is False, (
f"{category} attack URL {url!r} unexpectedly passed. "
f"SSRF hardening has regressed."
)
class TestBehaviorChangeLockIn:
"""Lock in deliberate behaviour changes from PR #3873 / #3882 so a
future revert doesn't silently undo them."""
def test_validate_url_with_none_returns_false_not_raises(self):
"""PR #3873 changed ``validate_url(None)`` from raising
``TypeError`` to returning ``False``. Callers that depended on
the exception would already be broken; lock in the new contract.
"""
from local_deep_research.security.ssrf_validator import (
validate_url,
)
assert validate_url(None) is False
assert validate_url(123) is False
assert validate_url([]) is False
def test_validate_url_strips_surrounding_whitespace(self):
"""PR #3873 added ``url.strip()`` at the top so URLs pasted from
clipboard with surrounding whitespace are accepted."""
from local_deep_research.security.ssrf_validator import (
validate_url,
)
with patch("socket.getaddrinfo", return_value=_PUBLIC_DNS_RESPONSE):
assert validate_url(" https://example.com/ ") is True
assert validate_url("\thttps://example.com/\n") is True
def test_validate_url_internal_whitespace_still_rejected(self):
"""Strip handles SURROUNDING whitespace; INTERIOR whitespace is
still an RFC 3986 violation and Layer 1 rejects it."""
from local_deep_research.security.ssrf_validator import (
validate_url,
)
assert validate_url("https://example.com/ /path") is False
assert validate_url("https://example.com/\tpath") is False
def test_redact_url_for_log_normalizes_to_origin(self):
"""Helper strips userinfo, path, query, AND fragment — leaving
only ``scheme://host[:port]`` (the URL origin per RFC 6454)."""
from local_deep_research.security.ssrf_validator import (
redact_url_for_log,
)
assert (
redact_url_for_log("http://user:pass@example.com/p?q=1#f")
== "http://example.com"
)
def test_redact_url_for_log_preserves_port(self):
from local_deep_research.security.ssrf_validator import (
redact_url_for_log,
)
assert (
redact_url_for_log("http://example.com:8080/path")
== "http://example.com:8080"
)
def test_redact_url_for_log_handles_ipv6(self):
from local_deep_research.security.ssrf_validator import (
redact_url_for_log,
)
assert redact_url_for_log("http://[::1]:8080/") == "http://[::1]:8080"
def test_idn_domain_auto_punycoded_via_urllib3(self):
"""urllib3 auto-Punycodes raw IDN before the ASCII guard. Lock
in this behaviour — if a future urllib3 stops doing this, IDN
URLs would silently break for users in Asia/Cyrillic regions."""
from urllib3.util import parse_url
u = parse_url("http://例え.jp/")
assert u.host == "xn--r8jz45g.jp", (
"urllib3 changed its Punycode behaviour. IDN URLs may now "
"break in LDR's SSRF validation — file an issue."
)
class TestPerformance:
"""Sanity check: validate_url must be cheap. ~10k calls in a
research session shouldn't add meaningful latency."""
def test_validate_url_under_5ms_per_call(self):
"""Generous 5ms-per-call budget absorbs noisy CI runners while
still catching genuine regressions. Local measurement is ~63µs,
so 5ms is ~80× headroom. A 100-URL research session at the
threshold would add 500ms; a real regression that breached it
would be worth investigating."""
from local_deep_research.security.ssrf_validator import (
validate_url,
)
url = "https://api.openalex.org/works?filter=doi:10.1038/nature"
with patch("socket.getaddrinfo", return_value=_PUBLIC_DNS_RESPONSE):
t0 = time.perf_counter()
for _ in range(1000):
validate_url(url)
elapsed = time.perf_counter() - t0
per_call_us = elapsed * 1000 # 1000 calls -> µs per call
assert per_call_us < 5000, (
f"validate_url is too slow: {per_call_us:.1f}µs per call "
f"(target: <5000µs). 100-URL research session would add "
f"{per_call_us / 10:.1f}ms latency."
)
@@ -0,0 +1,190 @@
"""Tests for DataSanitizer.redact_settings_snapshot.
The plain DataSanitizer.redact() does NOT redact secrets in nested-with-metadata
snapshots like {"llm.openai.api_key": {"value": "sk-...", "ui_element": "password"}}
— the outer compound key isn't in the sensitive-name set, and the inner key
"value" isn't sensitive either. redact_settings_snapshot fixes that for the
specific shape produced by SettingsManager.get_all_settings().
"""
from local_deep_research.security.data_sanitizer import DataSanitizer
def test_redacts_value_when_ui_element_is_password():
snap = {
"llm.openai.api_key": {
"value": "sk-secret123",
"ui_element": "password",
"type": "LLM",
}
}
out = DataSanitizer.redact_settings_snapshot(snap)
assert out["llm.openai.api_key"]["value"] == "[REDACTED]"
def test_preserves_metadata_alongside_redacted_value():
"""ui_element/type/etc. survive so YAML diffs still show the field exists."""
snap = {
"llm.openai.api_key": {
"value": "sk-secret",
"ui_element": "password",
"type": "LLM",
"description": "OpenAI key",
}
}
out = DataSanitizer.redact_settings_snapshot(snap)
assert out["llm.openai.api_key"]["ui_element"] == "password"
assert out["llm.openai.api_key"]["type"] == "LLM"
assert out["llm.openai.api_key"]["description"] == "OpenAI key"
def test_does_not_redact_non_secret_settings():
snap = {
"search.fetch.mode": {
"value": "summary_focus_query",
"ui_element": "select",
"type": "SEARCH",
}
}
out = DataSanitizer.redact_settings_snapshot(snap)
assert out["search.fetch.mode"]["value"] == "summary_focus_query"
def test_empty_secret_value_stays_empty():
"""An unset API key reads as "" — leaving it empty is more useful than
"[REDACTED]" when diffing two runs to spot which had the key set."""
snap = {"llm.lmstudio.api_key": {"value": "", "ui_element": "password"}}
out = DataSanitizer.redact_settings_snapshot(snap)
assert out["llm.lmstudio.api_key"]["value"] == ""
def test_none_secret_value_stays_none():
snap = {"llm.lmstudio.api_key": {"value": None, "ui_element": "password"}}
out = DataSanitizer.redact_settings_snapshot(snap)
assert out["llm.lmstudio.api_key"]["value"] is None
def test_defense_in_depth_via_key_suffix():
"""ui_element=text + key suffix in DEFAULT_SENSITIVE_KEYS still redacts.
Catches developer error: a future plugin author who registers
`plugin.x.api_key` with the wrong ui_element should still have the
secret redacted because `api_key` is in DEFAULT_SENSITIVE_KEYS.
"""
snap = {"plugin.x.api_key": {"value": "sk-leaked", "ui_element": "text"}}
out = DataSanitizer.redact_settings_snapshot(snap)
assert out["plugin.x.api_key"]["value"] == "[REDACTED]"
def test_input_is_not_mutated():
"""Pure-function contract — the in-memory snapshot used by the running
benchmark thread must remain unredacted; only the persisted copy is
redacted."""
snap = {
"llm.openai.api_key": {
"value": "sk-secret",
"ui_element": "password",
}
}
DataSanitizer.redact_settings_snapshot(snap)
assert snap["llm.openai.api_key"]["value"] == "sk-secret"
def test_passes_through_non_metadata_entries():
"""Tolerates flat-shape entries (bare key→value) so the helper is safe
on mixed snapshots without crashing."""
snap = {"flat_key": "raw_value", "no_value_key": {"ui_element": "text"}}
out = DataSanitizer.redact_settings_snapshot(snap)
assert out["flat_key"] == "raw_value"
assert out["no_value_key"] == {"ui_element": "text"}
def test_passes_through_non_dict_input():
"""Defensive — a None or list snapshot returns unchanged."""
assert DataSanitizer.redact_settings_snapshot(None) is None
assert DataSanitizer.redact_settings_snapshot([1, 2, 3]) == [1, 2, 3]
def test_custom_redaction_text():
snap = {"llm.openai.api_key": {"value": "sk-x", "ui_element": "password"}}
out = DataSanitizer.redact_settings_snapshot(snap, redaction_text="***")
assert out["llm.openai.api_key"]["value"] == "***"
def test_is_sensitive_setting_predicate():
"""The shared predicate the GET redactor and the write-back guards both
use: password ui_element OR a sensitive key suffix."""
# ui_element wins regardless of key
assert DataSanitizer.is_sensitive_setting("anything.here", "password")
# sensitive suffix wins regardless of ui_element
assert DataSanitizer.is_sensitive_setting("llm.openai.api_key", "text")
assert DataSanitizer.is_sensitive_setting("x.password", None)
assert DataSanitizer.is_sensitive_setting("x.secret", "textarea")
# neither -> not sensitive
assert not DataSanitizer.is_sensitive_setting("llm.model", "text")
assert not DataSanitizer.is_sensitive_setting("search.tool", None)
def test_redactor_and_predicate_agree():
"""Anything the predicate calls sensitive (and has a non-empty value)
must be redacted by redact_settings_snapshot — they share one source."""
snap = {
"llm.openai.api_key": {"value": "sk-x", "ui_element": "text"},
"foo.password": {"value": "pw", "ui_element": "text"},
"llm.model": {"value": "gpt", "ui_element": "text"},
}
out = DataSanitizer.redact_settings_snapshot(snap)
for key, entry in snap.items():
sensitive = DataSanitizer.is_sensitive_setting(key, entry["ui_element"])
if sensitive:
assert out[key]["value"] == DataSanitizer.REDACTION_TEXT
else:
assert out[key]["value"] == entry["value"]
# ---------------------------------------------------------------------------
# redact_value — the single-value primitive that the singular GET, the bulk
# GET and redact_settings_snapshot all delegate to.
# ---------------------------------------------------------------------------
def test_redact_value_masks_password_ui_element():
"""A set secret on a password-typed setting returns the sentinel."""
assert (
DataSanitizer.redact_value("llm.openai.api_key", "password", "sk-real")
== DataSanitizer.REDACTION_TEXT
)
def test_redact_value_masks_by_suffix_when_no_ui_element():
"""The bulk GET has no ui_element, so redact_value falls back to the
suffix arm of the predicate (key ends in a sensitive name)."""
assert (
DataSanitizer.redact_value("llm.openai.api_key", None, "sk-real")
== DataSanitizer.REDACTION_TEXT
)
def test_redact_value_passes_through_non_secret():
"""A non-sensitive setting is returned unchanged."""
assert DataSanitizer.redact_value("llm.model", "text", "gpt") == "gpt"
def test_redact_value_leaves_empty_values_readable():
"""Empty values are never masked — the UI must tell 'unset' from 'set'
without the sentinel implying a secret exists."""
for empty in (None, "", [], {}):
assert (
DataSanitizer.redact_value("llm.openai.api_key", "password", empty)
== empty
)
def test_redact_value_respects_custom_redaction_text():
"""Callers can override the sentinel."""
assert (
DataSanitizer.redact_value(
"x.password", None, "pw", redaction_text="***"
)
== "***"
)
+408
View File
@@ -0,0 +1,408 @@
# allow: no-sut-import — guardian; asserts repo file-whitelist, CODEOWNERS, and .gitignore structural invariants
"""Tests that repository integrity guardrails remain intact.
These tests verify structural invariants of the file whitelist,
CODEOWNERS, and .gitignore that protect against accidental re-introduction
of broad file-type exceptions (especially binary wildcards) and of dead
dotfile negations placed before the `.*` catch-all.
"""
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
def _in_git_work_tree():
"""True if `git` is on PATH and REPO_ROOT is inside a git work tree.
`git check-ignore` needs a work tree even with `--no-index`, so the
git-semantics guard below is skipped (not failed) when run from a
detached source tree (e.g. an unpacked tarball without `.git`) or an
environment without git installed.
"""
if not shutil.which("git"):
return False
try:
return (
subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=REPO_ROOT,
capture_output=True,
timeout=10,
).returncode
== 0
)
except (OSError, subprocess.SubprocessError):
# git missing (FileNotFoundError) or hung (TimeoutExpired) — treat as
# "no git" and skip the git-semantics guard rather than break collection.
return False
_IN_GIT_WORK_TREE = _in_git_work_tree()
# Representative dotfile / dot-dir paths that MUST stay trackable — the exact
# regression targets from #4909 and #4922. Each was (or would have been)
# silently dropped by `git add` when its `.gitignore` negation was dead. This
# is git-verified (real `git check-ignore` semantics), so it also catches
# breakage the positional check can't: a deleted negation, a later re-ignore,
# or a dir negation missing its `!.dir/` re-allow. These are concrete paths
# known to be intended-trackable, so there is no false-positive risk.
DOTFILES_THAT_MUST_STAY_TRACKABLE = (
".dockerignore",
".gitkeep",
"any_new_dir/.gitkeep", # the whole point of .gitkeep: a fresh empty dir
".nvmrc",
".pre-commit-config.yaml",
"any_subdir/.gitignore", # nested per-directory .gitignore
".gitleaks.toml",
".semgrepignore",
".trivyignore",
".safety-policy.yml",
".semgrep/rules/some_new_rule.yml", # loaded by semgrep.yml --config
"src/local_deep_research/defaults/.env.template",
".file-whitelist.txt", # the security whitelist file itself
".hadolint.yaml",
".yamllint.yaml",
".zap/some_new_rule.tsv", # ZAP config dir: type-scoped .tsv re-allow
)
# Non-dotfile root config / community-health files masked by the `/*.*` (and
# `/*.json`) ROOT catch-all — same tracked-but-masked class as the dotfiles
# above, just a different catch-all. Regression targets from the follow-up to
# #4932. Guarded the same way (real `git check-ignore` semantics).
ROOT_FILES_THAT_MUST_STAY_TRACKABLE = (
"SECURITY.md",
"vite.config.js", # COPY'd by the Dockerfile — build-breaking if dropped
"eslint.config.js",
"playwright.config.js",
"lighthouserc.json",
)
# Binary extensions that MUST be path-anchored (start with ^) in the whitelist.
# Broad binary wildcards like `\.png$` permanently bloat git history.
BINARY_EXTENSIONS = {
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"tiff",
"webp",
"svg",
"ico",
"mp3",
"mp4",
"wav",
"ogg",
"m4a",
"webm",
"exe",
"dll",
"so",
"dylib",
"bin",
"zip",
"tar",
"gz",
"rar",
"7z",
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"db",
"sqlite",
"sqlite3",
"woff",
"woff2",
"ttf",
"eot",
}
# These guardrail paths must be the LAST CODEOWNERS entries (last-match-wins).
CODEOWNERS_GUARDRAIL_PATHS = {
"/.gitignore",
"/.file-whitelist.txt",
"/.pre-commit-hooks/file-whitelist-check.sh",
"/.github/scripts/file-whitelist-check.sh",
"/.github/CODEOWNERS",
}
class TestFileWhitelistGuardrails:
"""Verify .file-whitelist.txt doesn't contain broad binary wildcards."""
@pytest.fixture()
def whitelist_patterns(self):
"""Load non-comment, non-empty lines from .file-whitelist.txt."""
whitelist = REPO_ROOT / ".file-whitelist.txt"
assert whitelist.exists(), ".file-whitelist.txt not found at repo root"
lines = []
for line in whitelist.read_text().splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
lines.append(stripped)
return lines
def test_binary_patterns_are_path_anchored(self, whitelist_patterns):
"""Binary extension patterns must start with ^ (path-scoped).
A broad pattern like `\\.png$` would allow PNGs anywhere in the repo,
defeating the purpose of the whitelist. Binary patterns must be
anchored to specific paths, e.g. `^docs/images/specific\\.png$`.
"""
violations = []
for pattern in whitelist_patterns:
# Check if this pattern matches a binary extension
for ext in BINARY_EXTENSIONS:
# Pattern ends with the extension (escaped dot + ext + $)
if re.search(rf"\\\.{re.escape(ext)}\)?(\|.*)?$", pattern):
if not pattern.startswith("^"):
violations.append(
f"Unanchored binary wildcard: '{pattern}'"
f"must start with ^ to scope to a specific path"
)
break
assert not violations, (
"Binary file patterns in .file-whitelist.txt must be path-anchored "
"(start with ^) to prevent repo bloat.\n"
+ "\n".join(f" - {v}" for v in violations)
)
def test_no_broad_binary_wildcards(self, whitelist_patterns):
"""Binary extension patterns must not use .* wildcards.
A pattern like `^docs/images/.*\\.png$` allows unlimited binary files
in a directory. Binary files should be listed by explicit path to
prevent repo bloat. Each new binary file should be a deliberate,
reviewed addition.
"""
violations = []
for pattern in whitelist_patterns:
for ext in BINARY_EXTENSIONS:
if re.search(rf"\\\.{re.escape(ext)}\)?(\|.*)?$", pattern):
if ".*" in pattern:
violations.append(
f"Broad binary wildcard: '{pattern}'"
f"uses .* which allows unlimited binary files; "
f"list each binary file explicitly instead"
)
break
assert not violations, (
"Binary file patterns must not use .* wildcards. "
"List each binary file by explicit path to prevent repo bloat.\n"
+ "\n".join(f" - {v}" for v in violations)
)
def test_whitelist_file_exists(self):
"""The shared whitelist file must exist."""
assert (REPO_ROOT / ".file-whitelist.txt").exists()
def test_whitelist_has_patterns(self, whitelist_patterns):
"""Whitelist must contain at least some patterns."""
assert len(whitelist_patterns) > 10, (
f"Whitelist only has {len(whitelist_patterns)} patterns — "
"seems too few, file may be corrupted"
)
class TestCodeownersGuardrails:
"""Verify CODEOWNERS guardrail rules remain at the bottom."""
@pytest.fixture()
def codeowners_rules(self):
"""Load non-comment, non-empty lines from CODEOWNERS."""
codeowners = REPO_ROOT / ".github" / "CODEOWNERS"
assert codeowners.exists(), ".github/CODEOWNERS not found"
rules = []
for line in codeowners.read_text().splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
rules.append(stripped)
return rules
def test_guardrail_rules_are_last(self, codeowners_rules):
"""Guardrail CODEOWNERS entries must be the last rules in the file.
GitHub CODEOWNERS uses last-match-wins. If someone adds a rule
after the guardrails (e.g. `* @someone`), it would override the
maintainer-only restriction on .gitignore and whitelist files.
"""
last_n = codeowners_rules[-len(CODEOWNERS_GUARDRAIL_PATHS) :]
actual_paths = {line.split()[0] for line in last_n}
assert actual_paths == CODEOWNERS_GUARDRAIL_PATHS, (
f"CODEOWNERS guardrail rules are not the last entries!\n"
f"Expected last {len(CODEOWNERS_GUARDRAIL_PATHS)} rules to be: "
f"{CODEOWNERS_GUARDRAIL_PATHS}\n"
f"Got: {actual_paths}\n"
"Someone may have added rules after the guardrails, which would "
"override the maintainer-only restriction (last-match-wins)."
)
def test_guardrail_rules_are_maintainer_only(self, codeowners_rules):
"""Guardrail rules must be restricted to @LearningCircuit only."""
last_n = codeowners_rules[-len(CODEOWNERS_GUARDRAIL_PATHS) :]
for rule in last_n:
parts = rule.split()
path = parts[0]
owners = parts[1:]
if path in CODEOWNERS_GUARDRAIL_PATHS:
assert owners == ["@LearningCircuit"], (
f"Guardrail rule '{path}' has owners {owners}"
"must be restricted to @LearningCircuit only"
)
class TestGitignoreDotfileNegationOrdering:
"""Verify .gitignore dotfile negations sit AFTER the `.*` catch-all.
.gitignore has a `.*` / `.*/` catch-all that ignores every dotfile and
dot-directory. A `!` re-include (negation) for a dotfile placed BEFORE
that catch-all is a DEAD rule: the later catch-all re-ignores it. Such a
file stays tracked only if it was committed before the catch-all existed
(git never ignores an already-tracked path), which masks the bug until a
NEW matching file — a fresh `.gitkeep`, a `.semgrep/rules/*` rule, a
nested `.gitignore` — is silently dropped by `git add`. See PR #4922.
Two complementary checks:
* ``test_dotfile_negations_are_after_catch_all`` — a fast, deterministic,
false-positive-free *structural* guard for the exact recurring bug: a
LITERAL dot-component negation placed before the catch-all.
* ``test_known_paths_remain_trackable`` — a *git-semantics* guard on
the concrete regression targets, which also catches dead negations the
structural check can't (deleted negation, later re-ignore, missing
parent-dir re-allow).
Scope of the structural check (intentional limits — the git check above
backstops the known targets): it does NOT flag a glob negation that also
matches a dotfile (``!*.env`` — git's wildmatch has no FNM_PERIOD, so
``*``/``?``/``[`` match a leading dot too), because flagging every
``*``-leading negation would wrongly trip legitimate ones like ``!*.js``.
A general ``git check-ignore``-over-every-negation approach was prototyped
and rejected: this repo's broad-ignore + per-type/dir "enabler" negations
(``!*/``, ``!.github/``, ``!*.js`` …) make per-negation and per-tracked-file
git evaluation produce many false positives.
"""
@pytest.fixture()
def gitignore_lines(self):
"""Load (line_index, text) for non-comment, non-empty .gitignore lines.
line_index is the 0-based position in the full file, so ordering
against the catch-all is preserved.
"""
gitignore = REPO_ROOT / ".gitignore"
assert gitignore.exists(), ".gitignore not found at repo root"
lines = []
for idx, line in enumerate(
gitignore.read_text(encoding="utf-8").splitlines()
):
stripped = line.strip()
if stripped and not stripped.startswith("#"):
lines.append((idx, stripped))
return lines
@staticmethod
def _catch_all_index(lines):
"""Return the line index of the last `.*` / `.*/` dotfile catch-all."""
catch_all = [idx for idx, text in lines if text in (".*", ".*/")]
assert catch_all, (
".gitignore has no `.*` dotfile catch-all — this guardrail "
"assumes one exists. If the structure changed, update this test."
)
return max(catch_all)
@staticmethod
def _targets_dotfile(pattern):
"""True if a `!` negation names a LITERAL dotfile / dot-directory.
A path component that literally starts with `.` (`.gitkeep`,
`.semgrep/`, `.github/**/*.yml`, or a deep `src/.../.env.template`) is
a dotfile the `.*` catch-all re-ignores, so its negation must sit after
the catch-all. Components like `package.json` or `src/**/*.py` have no
leading-dot literal and are not at risk here.
Only LITERAL leading dots are matched — see the class docstring for why
glob-leading negations (`!*.env`) are deliberately out of scope.
"""
body = pattern[1:] # strip leading '!'
return any(comp.startswith(".") for comp in body.split("/") if comp)
def test_dotfile_negations_are_after_catch_all(self, gitignore_lines):
"""Every dotfile `!` negation must come AFTER the `.*` catch-all.
A dotfile negation before the catch-all is silently overridden, so a
newly added matching file is dropped by `git add` without warning.
Move such negations below the catch-all, next to `!.gitleaksignore` /
`!.dockerignore`.
"""
catch_all_idx = self._catch_all_index(gitignore_lines)
violations = [
f"line {idx + 1}: '{text}'"
for idx, text in gitignore_lines
if text.startswith("!")
and self._targets_dotfile(text)
and idx < catch_all_idx
]
assert not violations, (
"Dotfile negation(s) found BEFORE the `.*` catch-all in .gitignore "
"— these are DEAD rules (the catch-all re-ignores them; new "
"matching files are silently dropped by `git add`). Move them below "
"the catch-all, next to `!.gitleaksignore` / `!.dockerignore`. "
"See PR #4922.\n" + "\n".join(f" - {v}" for v in violations)
)
@pytest.mark.skipif(
not _IN_GIT_WORK_TREE,
reason="git check-ignore requires git and a git work tree",
)
def test_known_paths_remain_trackable(self):
"""Regression targets must be trackable per real git-ignore semantics.
Uses `git check-ignore --no-index` (evaluates the whole rule set the
way git does) on the concrete files fixed across #4909/#4922/#4932 and
its follow-up — dotfiles masked by the `.*` catch-all and root configs
masked by the `/*.*` catch-all. Catches dead negations the positional
check can't see — a deleted negation, a later re-ignore, or a `.dir/`
negation missing its parent-dir re-allow. `--no-index` ignores
tracked-status, so this fails even while the file is still committed
(that masking is exactly the bug).
"""
ignored = []
for path in (
DOTFILES_THAT_MUST_STAY_TRACKABLE
+ ROOT_FILES_THAT_MUST_STAY_TRACKABLE
):
# `-q` is required: it sets exit status only (rc 0 = ignored,
# 1 = not ignored). Do NOT switch to `-v` — with `-v` a path
# matched by a `!` negation also returns rc 0, which would
# invert this test's meaning.
result = subprocess.run(
["git", "check-ignore", "--no-index", "-q", path],
cwd=REPO_ROOT,
capture_output=True,
timeout=10,
)
assert result.returncode in (0, 1), (
f"git check-ignore failed for '{path}' (rc={result.returncode}): "
f"{result.stderr.decode(errors='replace')}"
)
if result.returncode == 0:
ignored.append(path)
assert not ignored, (
"These files would be silently dropped by `git add` — their "
".gitignore negation is dead (missing, moved above the `.*`/`/*.*` "
"catch-all, missing a parent-dir re-allow, or killed by a later "
"re-ignore). See PRs #4922 / #4932.\n"
+ "\n".join(f" - {p}" for p in ignored)
)
def test_gitignore_exists(self):
"""The root .gitignore must exist."""
assert (REPO_ROOT / ".gitignore").exists()
+786
View File
@@ -0,0 +1,786 @@
"""Tests for safe_requests module - SSRF-protected HTTP requests."""
import pytest
from unittest.mock import patch, MagicMock
import requests
from local_deep_research.security import ssrf_validator
from local_deep_research.security.safe_requests import (
safe_get,
safe_post,
SafeSession,
DEFAULT_TIMEOUT,
MAX_RESPONSE_SIZE,
)
class TestSafeGetFunction:
"""Tests for safe_get function."""
def test_valid_url_makes_request(self):
"""Should make request to valid external URL."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
response = safe_get("https://example.com")
mock_get.assert_called_once()
assert response == mock_response
def test_rejects_invalid_url(self):
"""Should raise ValueError for URLs failing SSRF validation."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=False,
):
with pytest.raises(ValueError, match="SSRF"):
safe_get("http://127.0.0.1/admin")
def test_uses_default_timeout(self):
"""Should use default timeout when not specified."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
safe_get("https://example.com")
_, kwargs = mock_get.call_args
assert kwargs["timeout"] == DEFAULT_TIMEOUT
def test_custom_timeout(self):
"""Should use custom timeout when provided."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
safe_get("https://example.com", timeout=60)
_, kwargs = mock_get.call_args
assert kwargs["timeout"] == 60
def test_underlying_requests_always_gets_redirects_disabled(self):
"""The underlying requests.get() always receives allow_redirects=False;
safe_get handles redirects manually with SSRF validation."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
safe_get("https://example.com")
_, kwargs = mock_get.call_args
assert kwargs["allow_redirects"] is False
def test_can_enable_redirects(self):
"""With allow_redirects=True, redirects are still disabled at
requests level (handled manually for SSRF validation)."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
safe_get("https://example.com", allow_redirects=True)
_, kwargs = mock_get.call_args
# Redirects are always disabled at the requests level;
# safe_get handles them manually with SSRF validation
assert kwargs["allow_redirects"] is False
def test_passes_params(self):
"""Should pass URL parameters."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
params = {"q": "test", "page": "1"}
safe_get("https://example.com", params=params)
args, _ = mock_get.call_args
assert args == ("https://example.com",)
_, kwargs = mock_get.call_args
assert "params" not in kwargs or kwargs.get("params") == params
def test_oversized_response_raises_error(self):
"""Oversized responses are rejected with ValueError."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {
"Content-Length": str(MAX_RESPONSE_SIZE + 1)
}
mock_get.return_value = mock_response
with pytest.raises(ValueError, match="Response too large"):
safe_get("https://example.com")
def test_accepts_response_within_limit(self):
"""Should accept response within size limit."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {"Content-Length": str(1024)}
mock_get.return_value = mock_response
response = safe_get("https://example.com")
assert response == mock_response
def test_handles_invalid_content_length(self):
"""Should ignore invalid Content-Length values."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {"Content-Length": "not-a-number"}
mock_get.return_value = mock_response
# Should not raise
response = safe_get("https://example.com")
assert response == mock_response
def test_reraises_timeout(self):
"""Should re-raise timeout exceptions."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get",
side_effect=requests.Timeout("timeout"),
):
with pytest.raises(requests.Timeout):
safe_get("https://example.com")
def test_reraises_request_exception(self):
"""Should re-raise request exceptions."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.get",
side_effect=requests.RequestException("connection error"),
):
with pytest.raises(requests.RequestException):
safe_get("https://example.com")
def test_allow_localhost_parameter(self):
"""Should pass allow_localhost to validate_url."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
) as mock_validate:
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
safe_get("http://localhost:8080", allow_localhost=True)
mock_validate.assert_called_once_with(
"http://localhost:8080",
allow_localhost=True,
allow_private_ips=False,
)
def test_allow_private_ips_parameter(self):
"""Should pass allow_private_ips to validate_url."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
) as mock_validate:
with patch(
"local_deep_research.security.safe_requests.requests.get"
) as mock_get:
mock_response = MagicMock()
mock_response.headers = {}
mock_get.return_value = mock_response
safe_get("http://192.168.1.1", allow_private_ips=True)
mock_validate.assert_called_once_with(
"http://192.168.1.1",
allow_localhost=False,
allow_private_ips=True,
)
class TestSafePostFunction:
"""Tests for safe_post function."""
def test_valid_url_makes_request(self):
"""Should make POST request to valid external URL."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post"
) as mock_post:
mock_response = MagicMock()
mock_response.headers = {}
mock_post.return_value = mock_response
response = safe_post(
"https://example.com/api", json={"key": "value"}
)
mock_post.assert_called_once()
assert response == mock_response
def test_rejects_invalid_url(self):
"""Should raise ValueError for URLs failing SSRF validation."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=False,
):
with pytest.raises(ValueError, match="SSRF"):
safe_post("http://169.254.169.254/metadata")
def test_passes_data_parameter(self):
"""Should pass data parameter for form data."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post"
) as mock_post:
mock_response = MagicMock()
mock_response.headers = {}
mock_post.return_value = mock_response
safe_post("https://example.com", data="raw data")
_, kwargs = mock_post.call_args
assert kwargs.get("data") == "raw data"
def test_passes_json_parameter(self):
"""Should pass json parameter for JSON data."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post"
) as mock_post:
mock_response = MagicMock()
mock_response.headers = {}
mock_post.return_value = mock_response
json_data = {"key": "value"}
safe_post("https://example.com", json=json_data)
_, kwargs = mock_post.call_args
assert kwargs.get("json") == json_data
def test_uses_default_timeout(self):
"""Should use default timeout when not specified."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post"
) as mock_post:
mock_response = MagicMock()
mock_response.headers = {}
mock_post.return_value = mock_response
safe_post("https://example.com")
_, kwargs = mock_post.call_args
assert kwargs["timeout"] == DEFAULT_TIMEOUT
def test_underlying_requests_always_gets_redirects_disabled(self):
"""The underlying requests.post() always receives allow_redirects=False;
safe_post handles redirects manually with SSRF validation."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post"
) as mock_post:
mock_response = MagicMock()
mock_response.headers = {}
mock_post.return_value = mock_response
safe_post("https://example.com")
_, kwargs = mock_post.call_args
assert kwargs["allow_redirects"] is False
def test_can_enable_redirects(self):
"""With allow_redirects=True, redirects are still disabled at
requests level (handled manually for SSRF validation)."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post"
) as mock_post:
mock_response = MagicMock()
mock_response.headers = {}
mock_post.return_value = mock_response
safe_post("https://example.com", allow_redirects=True)
_, kwargs = mock_post.call_args
# Redirects are always disabled at the requests level;
# safe_post handles them manually with SSRF validation
assert kwargs["allow_redirects"] is False
def test_oversized_response_raises_error(self):
"""Oversized responses are rejected with ValueError."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post"
) as mock_post:
mock_response = MagicMock()
mock_response.headers = {
"Content-Length": str(MAX_RESPONSE_SIZE + 1)
}
mock_post.return_value = mock_response
with pytest.raises(ValueError, match="Response too large"):
safe_post("https://example.com")
def test_reraises_timeout(self):
"""Should re-raise timeout exceptions."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch(
"local_deep_research.security.safe_requests.requests.post",
side_effect=requests.Timeout("timeout"),
):
with pytest.raises(requests.Timeout):
safe_post("https://example.com")
class TestSafeSession:
"""Tests for SafeSession class."""
def test_init_default_values(self):
"""Should initialize with default security settings."""
session = SafeSession()
assert session.allow_localhost is False
assert session.allow_private_ips is False
def test_init_allow_localhost(self):
"""Should accept allow_localhost parameter."""
session = SafeSession(allow_localhost=True)
assert session.allow_localhost is True
assert session.allow_private_ips is False
def test_init_allow_private_ips(self):
"""Should accept allow_private_ips parameter."""
session = SafeSession(allow_private_ips=True)
assert session.allow_localhost is False
assert session.allow_private_ips is True
def test_request_validates_url(self):
"""Should validate URL before making request."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=False,
):
session = SafeSession()
with pytest.raises(ValueError, match="SSRF"):
session.request("GET", "http://127.0.0.1/admin")
def test_request_makes_call_on_valid_url(self):
"""Should make request when URL is valid."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch.object(requests.Session, "request") as mock_request:
mock_response = MagicMock()
mock_request.return_value = mock_response
session = SafeSession()
response = session.request("GET", "https://example.com")
mock_request.assert_called_once()
assert response == mock_response
def test_request_uses_default_timeout(self):
"""Should set default timeout if not provided."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch.object(requests.Session, "request") as mock_request:
mock_response = MagicMock()
mock_request.return_value = mock_response
session = SafeSession()
session.request("GET", "https://example.com")
_, kwargs = mock_request.call_args
assert kwargs["timeout"] == DEFAULT_TIMEOUT
def test_request_respects_custom_timeout(self):
"""Should respect custom timeout when provided."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with patch.object(requests.Session, "request") as mock_request:
mock_response = MagicMock()
mock_request.return_value = mock_response
session = SafeSession()
session.request("GET", "https://example.com", timeout=120)
_, kwargs = mock_request.call_args
assert kwargs["timeout"] == 120
def test_context_manager(self):
"""Should work as context manager."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
):
with SafeSession() as session:
assert isinstance(session, SafeSession)
def test_passes_allow_localhost_to_validate(self):
"""Should pass allow_localhost to validate_url."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
) as mock_validate:
with patch.object(requests.Session, "request"):
session = SafeSession(allow_localhost=True)
session.request("GET", "http://localhost:8080")
mock_validate.assert_called_once_with(
"http://localhost:8080",
allow_localhost=True,
allow_private_ips=False,
)
def test_passes_allow_private_ips_to_validate(self):
"""Should pass allow_private_ips to validate_url."""
with patch.object(
ssrf_validator,
"validate_url",
return_value=True,
) as mock_validate:
with patch.object(requests.Session, "request"):
session = SafeSession(allow_private_ips=True)
session.request("GET", "http://192.168.1.1")
mock_validate.assert_called_once_with(
"http://192.168.1.1",
allow_localhost=False,
allow_private_ips=True,
)
class TestConstants:
"""Tests for module constants."""
def test_default_timeout_reasonable(self):
"""DEFAULT_TIMEOUT should be a reasonable value."""
assert DEFAULT_TIMEOUT == 30
assert isinstance(DEFAULT_TIMEOUT, int)
def test_max_response_size_reasonable(self):
"""MAX_RESPONSE_SIZE should be a reasonable value (1GB)."""
assert MAX_RESPONSE_SIZE == 1024 * 1024 * 1024 # 1GB
assert isinstance(MAX_RESPONSE_SIZE, int)
class TestUserAgentInjection:
"""The project User-Agent should be injected automatically when the
caller doesn't supply one, and preserved when they do.
This locks in the behavior added by PR #3081 — academic API endpoints
(arXiv, OpenAlex, PubMed, …) need to identify the caller, and we
don't want every call site to remember to set the header manually.
"""
def _make_response(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {}
mock_response.url = "https://example.com"
return mock_response
def test_safe_get_injects_user_agent_when_missing(self):
"""safe_get with no headers should auto-inject the project UA."""
from local_deep_research.constants import USER_AGENT
with (
patch.object(ssrf_validator, "validate_url", return_value=True),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=self._make_response(),
) as mock_get,
):
safe_get("https://example.com")
_, kwargs = mock_get.call_args
assert "headers" in kwargs
assert kwargs["headers"]["User-Agent"] == USER_AGENT
def test_safe_get_preserves_explicit_user_agent(self):
"""A caller-supplied User-Agent must NOT be overwritten."""
custom_ua = "Mozilla/5.0 Custom/1.0"
with (
patch.object(ssrf_validator, "validate_url", return_value=True),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=self._make_response(),
) as mock_get,
):
safe_get("https://example.com", headers={"User-Agent": custom_ua})
_, kwargs = mock_get.call_args
assert kwargs["headers"]["User-Agent"] == custom_ua
def test_safe_get_user_agent_check_is_case_insensitive(self):
"""A `user-agent` (lowercase) header should be respected."""
custom_ua = "Mozilla/5.0 Custom/1.0"
with (
patch.object(ssrf_validator, "validate_url", return_value=True),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=self._make_response(),
) as mock_get,
):
safe_get("https://example.com", headers={"user-agent": custom_ua})
_, kwargs = mock_get.call_args
# Original lowercase key preserved; no second User-Agent added
assert kwargs["headers"]["user-agent"] == custom_ua
assert "User-Agent" not in kwargs["headers"]
def test_safe_get_does_not_mutate_caller_headers_dict(self):
"""safe_get must not add User-Agent to the caller's dict."""
caller_headers = {"X-Custom": "value"}
with (
patch.object(ssrf_validator, "validate_url", return_value=True),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=self._make_response(),
),
):
safe_get("https://example.com", headers=caller_headers)
# Caller's dict is untouched — User-Agent injection is on a
# copy, not the original.
assert "User-Agent" not in caller_headers
assert caller_headers == {"X-Custom": "value"}
def test_safe_post_injects_user_agent_when_missing(self):
"""safe_post should auto-inject the project UA."""
from local_deep_research.constants import USER_AGENT
with (
patch.object(ssrf_validator, "validate_url", return_value=True),
patch(
"local_deep_research.security.safe_requests.requests.post",
return_value=self._make_response(),
) as mock_post,
):
safe_post("https://example.com", data={"k": "v"})
_, kwargs = mock_post.call_args
assert kwargs["headers"]["User-Agent"] == USER_AGENT
def test_safe_post_preserves_explicit_user_agent(self):
custom_ua = "Mozilla/5.0 Custom/1.0"
with (
patch.object(ssrf_validator, "validate_url", return_value=True),
patch(
"local_deep_research.security.safe_requests.requests.post",
return_value=self._make_response(),
) as mock_post,
):
safe_post(
"https://example.com",
data={"k": "v"},
headers={"User-Agent": custom_ua},
)
_, kwargs = mock_post.call_args
assert kwargs["headers"]["User-Agent"] == custom_ua
class TestParserDifferentialEndToEnd:
"""
End-to-end integration tests for the parser-differential SSRF bypass
fix (GHSA-g23j-2vwm-5c25).
Approach: bind a TCP socket on 127.0.0.1:<random> WITHOUT calling
listen() — the kernel responds RST to any incoming connect. If the
fix regresses and ``safe_get`` actually attempts to connect to the
bound port, ``requests`` raises ``ConnectionError`` (kernel RST), so
a strict ``pytest.raises(ValueError, match=...)`` distinguishes
"validator caught it" from "validator failed and the kernel saved us".
"""
@staticmethod
def _bind_unused_port():
import socket as _socket
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
return sock
def test_safe_get_blocks_parser_differential_no_socket_connect(self):
from local_deep_research.security.safe_requests import safe_get
sock = self._bind_unused_port()
try:
port = sock.getsockname()[1]
bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
with pytest.raises(ValueError, match="SSRF|security validation"):
safe_get(bypass_url, timeout=2)
finally:
sock.close()
def test_safe_post_blocks_parser_differential(self):
from local_deep_research.security.safe_requests import safe_post
sock = self._bind_unused_port()
try:
port = sock.getsockname()[1]
bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
with pytest.raises(ValueError, match="SSRF|security validation"):
safe_post(bypass_url, data={"k": "v"}, timeout=2)
finally:
sock.close()
def test_safesession_blocks_parser_differential(self):
"""SafeSession validates at both request() and send() — exercises
the double-validation path. This URL contains ``\\`` so Layer 1
catches it at request() before .prepare() canonicalises it."""
from local_deep_research.security.safe_requests import SafeSession
sock = self._bind_unused_port()
try:
port = sock.getsockname()[1]
bypass_url = f"http://127.0.0.1:{port}\\@1.1.1.1"
with SafeSession() as sess:
with pytest.raises(
ValueError, match="SSRF|security validation"
):
sess.get(bypass_url, timeout=2)
finally:
sock.close()
def test_safesession_send_blocks_canonicalised_form(self):
"""
Layer-2 verification: ``SafeSession.send()`` is called with a
``PreparedRequest`` whose URL contains ``%5C`` (the canonicalised
form of ``\\``). Layer 1 doesn't match ``%5C``, so Layer 2's
urllib3-based hostname extraction is what blocks this — proving
Layer 2 carries the load on this path.
"""
from local_deep_research.security.safe_requests import SafeSession
with SafeSession() as sess:
req = requests.Request("GET", "http://127.0.0.1:6666/%5C@1.1.1.1")
prepared = sess.prepare_request(req)
with pytest.raises(ValueError, match="SSRF|security validation"):
sess.send(prepared, timeout=2)
+225
View File
@@ -0,0 +1,225 @@
"""
Tests for the check-safe-requests pre-commit hook.
Ensures the hook correctly detects unsafe requests usage that bypasses SSRF protection.
"""
import ast
import sys
from importlib import import_module
from pathlib import Path
# Add the pre-commit hooks directory to path
HOOKS_DIR = Path(__file__).parent.parent.parent / ".pre-commit-hooks"
sys.path.insert(0, str(HOOKS_DIR))
# Import the checker from the hook (must be after sys.path modification)
hook_module = import_module("check-safe-requests") # noqa: E402
RequestsChecker = hook_module.RequestsChecker
class TestRequestsChecker:
"""Tests for the RequestsChecker AST visitor."""
def _check_code(self, code: str, filename: str = "src/module.py") -> list:
"""Helper to check code and return errors."""
tree = ast.parse(code)
checker = RequestsChecker(filename)
checker.visit(tree)
return checker.errors
def test_detects_requests_get(self):
"""Should detect requests.get() calls."""
code = """
import requests
response = requests.get("http://example.com")
"""
errors = self._check_code(code)
assert len(errors) == 1
assert "requests.get()" in errors[0][1]
assert "safe_get()" in errors[0][1]
def test_detects_requests_post(self):
"""Should detect requests.post() calls."""
code = """
import requests
response = requests.post("http://example.com", json={"key": "value"})
"""
errors = self._check_code(code)
assert len(errors) == 1
assert "requests.post()" in errors[0][1]
assert "safe_post()" in errors[0][1]
def test_detects_requests_session(self):
"""Should detect requests.Session() instantiation."""
code = """
import requests
session = requests.Session()
"""
errors = self._check_code(code)
assert len(errors) == 1
assert "requests.Session()" in errors[0][1]
assert "SafeSession()" in errors[0][1]
def test_detects_requests_put(self):
"""Should detect requests.put() calls."""
code = """
import requests
response = requests.put("http://example.com", data="test")
"""
errors = self._check_code(code)
assert len(errors) == 1
assert "requests.put()" in errors[0][1]
def test_detects_requests_delete(self):
"""Should detect requests.delete() calls."""
code = """
import requests
response = requests.delete("http://example.com/resource/1")
"""
errors = self._check_code(code)
assert len(errors) == 1
assert "requests.delete()" in errors[0][1]
def test_detects_multiple_violations(self):
"""Should detect multiple violations in one file."""
code = """
import requests
session = requests.Session()
response = requests.get("http://example.com")
data = requests.post("http://example.com/api", json={})
"""
errors = self._check_code(code)
assert len(errors) == 3
def test_allows_safe_requests_module(self):
"""Should allow direct requests in safe_requests.py (the wrapper itself)."""
code = """
import requests
response = requests.get("http://example.com")
"""
errors = self._check_code(
code, filename="src/security/safe_requests.py"
)
assert len(errors) == 0
def test_allows_test_files(self):
"""Should allow direct requests in test files."""
code = """
import requests
response = requests.get("http://example.com")
"""
# Test various test file patterns
assert len(self._check_code(code, filename="tests/test_api.py")) == 0
assert (
len(self._check_code(code, filename="tests/security/test_ssrf.py"))
== 0
)
assert len(self._check_code(code, filename="src/module_test.py")) == 0
def test_ignores_safe_get(self):
"""Should not flag safe_get() calls."""
code = """
from security import safe_get
response = safe_get("http://example.com")
"""
errors = self._check_code(code)
assert len(errors) == 0
def test_ignores_safe_post(self):
"""Should not flag safe_post() calls."""
code = """
from security import safe_post
response = safe_post("http://example.com", json={})
"""
errors = self._check_code(code)
assert len(errors) == 0
def test_ignores_safe_session(self):
"""Should not flag SafeSession() calls."""
code = """
from security import SafeSession
session = SafeSession(allow_localhost=True)
"""
errors = self._check_code(code)
assert len(errors) == 0
def test_ignores_other_modules_get(self):
"""Should not flag get() calls on other modules."""
code = """
import os
value = os.environ.get("KEY")
my_dict = {"key": "value"}
result = my_dict.get("key")
"""
errors = self._check_code(code)
assert len(errors) == 0
def test_reports_correct_line_numbers(self):
"""Should report the correct line number for violations."""
code = """
import requests
# Some comment
x = 1
response = requests.get("http://example.com")
"""
errors = self._check_code(code)
assert len(errors) == 1
assert errors[0][0] == 7 # Line 7
class TestHookIntegration:
"""Integration tests for the hook as a whole."""
def test_check_file_returns_true_for_clean_file(self, tmp_path):
"""check_file should return True for files without violations."""
clean_file = tmp_path / "clean.py"
clean_file.write_text("""
from security import safe_get
response = safe_get("http://example.com")
""")
assert hook_module.check_file(str(clean_file)) is True
def test_check_file_returns_false_for_violations(
self, tmp_path, monkeypatch
):
"""check_file should return False for files with violations."""
# Use a filename that doesn't match exclusion patterns
bad_file = tmp_path / "my_module.py"
bad_file.write_text("""
import requests
response = requests.get("http://example.com")
""")
# Monkeypatch the file path to avoid pytest temp dir containing "test_"
def patched_check(filename):
# Replace the actual path with a clean one for pattern matching
import ast
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
tree = ast.parse(content, filename=filename)
# Use a clean filename for pattern matching
checker = RequestsChecker("src/some_module.py")
checker.visit(tree)
return len(checker.errors) == 0
result = patched_check(str(bad_file))
assert result is False
def test_check_file_handles_non_python_files(self, tmp_path):
"""check_file should return True for non-Python files."""
txt_file = tmp_path / "readme.txt"
txt_file.write_text("This is not Python")
assert hook_module.check_file(str(txt_file)) is True
def test_check_file_handles_syntax_errors(self, tmp_path):
"""check_file should handle files with syntax errors gracefully."""
bad_syntax = tmp_path / "syntax_error.py"
bad_syntax.write_text("def broken(:\n pass")
# Should not raise, just return True (let other tools handle syntax)
assert hook_module.check_file(str(bad_syntax)) is True
@@ -0,0 +1,138 @@
"""Redirect-handling tests for ``safe_get``.
``safe_requests.py`` already follows redirects manually and validates
every hop against the SSRF allowlist, but the previous test suite only
covered the first hop. These tests exercise the redirect loop itself:
private-IP targets, AWS metadata targets, redirect-count caps, and
per-hop validator re-evaluation (earlier approvals do not confer
trust on later redirect targets).
"""
from unittest.mock import MagicMock, patch
import pytest
from local_deep_research.security import safe_requests
def _redirect(location: str, status: int = 302) -> MagicMock:
r = MagicMock()
r.status_code = status
r.headers = {"Location": location}
r.url = "https://example.com/"
r.close = MagicMock()
return r
def _ok() -> MagicMock:
r = MagicMock()
r.status_code = 200
r.headers = {}
r.url = "https://example.com/final"
r.content = b""
r.close = MagicMock()
return r
def test_redirect_to_private_ip_is_blocked():
def fake_validate(url, allow_localhost=False, allow_private_ips=False):
return "127.0.0.1" not in url and "169.254" not in url
with (
patch.object(
safe_requests.ssrf_validator,
"validate_url",
side_effect=fake_validate,
),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=_redirect("http://127.0.0.1/admin"),
),
):
with pytest.raises(ValueError, match="SSRF validation"):
safe_requests.safe_get("https://example.com/")
def test_redirect_to_aws_metadata_is_blocked():
def fake_validate(url, allow_localhost=False, allow_private_ips=False):
return "169.254.169.254" not in url
with (
patch.object(
safe_requests.ssrf_validator,
"validate_url",
side_effect=fake_validate,
),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=_redirect("http://169.254.169.254/latest/meta-data/"),
),
):
with pytest.raises(ValueError, match="SSRF validation"):
safe_requests.safe_get("https://example.com/")
def test_too_many_redirects_raises():
# Always redirect to another hop — should exhaust the 10-hop cap.
hop = _redirect("https://example.com/loop")
with (
patch.object(
safe_requests.ssrf_validator, "validate_url", return_value=True
),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=hop,
),
):
with pytest.raises(ValueError, match="Too many redirects"):
safe_requests.safe_get("https://example.com/start")
def test_second_hop_blocked_when_validator_rejects_redirect_target():
"""Validator's verdict on the redirect target trumps its earlier pass.
The redirect loop in ``safe_get`` re-runs ``validate_url`` on every
hop. If hop-N passes but hop-(N+1) is rejected, the caller sees a
``ValueError`` and no further network activity occurs. This is the
mechanism the SSRF defence relies on — earlier approvals do not
confer trust on later targets.
Note: this is *not* a DNS-rebinding test. Modelling rebinding
requires mocking at the ``socket.getaddrinfo`` layer so the same
hostname resolves differently across calls. That coverage belongs
alongside the validator's unit tests (``tests/security/test_ssrf_validator.py``)
and is not currently covered there either — tracked as a follow-up.
"""
results = [True, False]
def fake_validate(url, allow_localhost=False, allow_private_ips=False):
return results.pop(0)
with (
patch.object(
safe_requests.ssrf_validator,
"validate_url",
side_effect=fake_validate,
),
patch(
"local_deep_research.security.safe_requests.requests.get",
return_value=_redirect("https://evil.example.com/"),
),
):
with pytest.raises(ValueError, match="SSRF validation"):
safe_requests.safe_get("https://example.com/")
def test_legitimate_redirect_is_followed():
hops = [_redirect("https://example.com/final"), _ok()]
with (
patch.object(
safe_requests.ssrf_validator, "validate_url", return_value=True
),
patch(
"local_deep_research.security.safe_requests.requests.get",
side_effect=hops,
),
):
resp = safe_requests.safe_get("https://example.com/")
assert resp.status_code == 200
@@ -0,0 +1,365 @@
"""Exponential-backoff retry wrapper for `safe_get`."""
import email.utils
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
import requests
from local_deep_research.security.safe_requests import (
safe_get_with_retries,
)
def _mock_response(status_code=200, headers=None, content=b""):
r = MagicMock(spec=requests.Response)
r.status_code = status_code
r.headers = headers or {}
r.content = content
return r
def _mock_response_body_raises(exc):
"""Response whose ``.content`` access raises ``exc`` on first read.
Models the real production failure where ``safe_get`` returns a
Response (headers received), then ``ChunkedEncodingError`` /
``ReadTimeout`` fires later when the body is consumed.
"""
r = MagicMock(spec=requests.Response)
r.status_code = 200
r.headers = {}
type(r).content = property(lambda self: (_ for _ in ()).throw(exc))
return r
def test_first_attempt_success_returns_immediately():
ok = _mock_response(200)
with patch(
"local_deep_research.security.safe_requests.safe_get",
return_value=ok,
) as mock_get:
resp = safe_get_with_retries("https://example.com/x")
assert resp.status_code == 200
assert mock_get.call_count == 1
def test_connection_error_retries_then_succeeds():
ok = _mock_response(200)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[requests.ConnectionError("boom"), ok],
) as mock_get,
patch(
"local_deep_research.security.safe_requests.time.sleep"
) as mock_sleep,
):
resp = safe_get_with_retries(
"https://example.com/x", backoff_times=(0, 0, 0)
)
assert resp.status_code == 200
assert mock_get.call_count == 2
assert mock_sleep.called
def test_timeout_retries_then_gives_up():
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=requests.Timeout("slow"),
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
with pytest.raises(requests.Timeout):
safe_get_with_retries(
"https://example.com/x",
max_retries=2,
backoff_times=(0, 0, 0),
)
# 1 initial + 2 retries = 3 attempts total
assert mock_get.call_count == 3
def test_http_500_retries_then_succeeds():
bad = _mock_response(500)
ok = _mock_response(200)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[bad, ok],
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
resp = safe_get_with_retries(
"https://example.com/x", backoff_times=(0, 0, 0)
)
assert resp.status_code == 200
assert mock_get.call_count == 2
def test_http_429_honors_retry_after():
rate_limited = _mock_response(429, headers={"Retry-After": "7"})
ok = _mock_response(200)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[rate_limited, ok],
),
patch(
"local_deep_research.security.safe_requests.time.sleep"
) as mock_sleep,
):
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
# Retry-After was 7, must override the 1-second schedule.
assert mock_sleep.call_args[0][0] == 7
def test_ssrf_validation_error_is_not_retried():
"""ValueError means SSRF rejection — retrying would just re-fail."""
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=ValueError("URL failed security validation"),
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
with pytest.raises(ValueError):
safe_get_with_retries(
"http://169.254.169.254/", backoff_times=(0, 0, 0)
)
assert mock_get.call_count == 1
def test_http_404_is_not_retried():
"""4xx (other than 429) is caller's fault — no retry."""
not_found = _mock_response(404)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
return_value=not_found,
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
resp = safe_get_with_retries(
"https://example.com/x", backoff_times=(0, 0, 0)
)
assert resp.status_code == 404
assert mock_get.call_count == 1
def test_retry_after_integer_is_capped_at_max():
"""A hostile Retry-After: 86400 must not pin the worker for a day."""
rate_limited = _mock_response(429, headers={"Retry-After": "86400"})
ok = _mock_response(200)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[rate_limited, ok],
),
patch(
"local_deep_research.security.safe_requests.time.sleep"
) as mock_sleep,
):
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
assert mock_sleep.call_args[0][0] == 300
def test_retry_after_http_date_form_is_parsed():
"""RFC 7231 HTTP-date form must be parsed, not silently ignored."""
future = datetime.now(timezone.utc) + timedelta(seconds=60)
http_date = email.utils.formatdate(future.timestamp(), usegmt=True)
rate_limited = _mock_response(429, headers={"Retry-After": http_date})
ok = _mock_response(200)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[rate_limited, ok],
),
patch(
"local_deep_research.security.safe_requests.time.sleep"
) as mock_sleep,
):
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
slept = mock_sleep.call_args[0][0]
# 30s band absorbs clock jitter and test-harness latency.
assert 45 <= slept <= 75, f"expected ~60s, got {slept}s"
def test_retry_after_unparseable_falls_back_to_schedule():
"""Garbage Retry-After falls back to the backoff schedule."""
rate_limited = _mock_response(429, headers={"Retry-After": "garbage"})
ok = _mock_response(200)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[rate_limited, ok],
),
patch(
"local_deep_research.security.safe_requests.time.sleep"
) as mock_sleep,
):
safe_get_with_retries("https://example.com/x", backoff_times=(1, 2, 4))
assert mock_sleep.call_args[0][0] == 1
def test_retry_after_negative_integer_is_clamped_to_zero():
"""time.sleep(-5) raises; Retry-After: -5 must be clamped to 0."""
rate_limited = _mock_response(429, headers={"Retry-After": "-5"})
ok = _mock_response(200)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[rate_limited, ok],
),
patch(
"local_deep_research.security.safe_requests.time.sleep"
) as mock_sleep,
):
safe_get_with_retries("https://example.com/x", backoff_times=(1, 1, 1))
assert mock_sleep.call_args[0][0] == 0
# ---------------------------------------------------------------------------
# consume_body=True: retry on body-stream transients
# ---------------------------------------------------------------------------
def test_consume_body_default_does_not_read_body():
"""Default (``consume_body=False``) must not touch ``response.content``.
The wrapper has historically returned the response without reading
the body. Callers that stream large bodies (e.g., NDJSON line-by-line)
rely on this — a regression that pre-reads would balloon memory.
"""
body_marker = MagicMock()
body_marker.__bool__ = MagicMock(side_effect=AssertionError("touched"))
resp = _mock_response(200)
type(resp).content = property(lambda self: body_marker)
with patch(
"local_deep_research.security.safe_requests.safe_get",
return_value=resp,
):
# Should not raise — default behavior leaves .content untouched.
safe_get_with_retries("https://example.com/x")
def test_consume_body_retries_on_chunked_encoding_error():
"""Mid-stream ``ChunkedEncodingError`` retries the whole fetch."""
bad = _mock_response_body_raises(
requests.exceptions.ChunkedEncodingError(
"IncompleteRead(835082 bytes read, 1262437 more expected)"
)
)
ok = _mock_response(200, content=b"final")
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[bad, ok],
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
resp = safe_get_with_retries(
"https://example.com/x",
consume_body=True,
backoff_times=(0, 0, 0),
)
assert resp.content == b"final"
assert mock_get.call_count == 2
bad.close.assert_called_once()
def test_consume_body_retries_on_read_timeout():
"""``ReadTimeout`` (Timeout but NOT ConnectionError) is also retried.
Pinning this explicitly: the obvious ``except ConnectionError``
catch would silently miss ReadTimeout (it's a Timeout subclass,
not a ConnectionError subclass), so the implementation must list
``Timeout`` separately.
"""
bad = _mock_response_body_raises(
requests.exceptions.ReadTimeout("body read stalled")
)
ok = _mock_response(200, content=b"final")
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
side_effect=[bad, ok],
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
resp = safe_get_with_retries(
"https://example.com/x",
consume_body=True,
backoff_times=(0, 0, 0),
)
assert resp.content == b"final"
assert mock_get.call_count == 2
def test_consume_body_gives_up_after_max_retries():
"""Persistent body-read failures eventually surface to the caller."""
bad = _mock_response_body_raises(
requests.exceptions.ChunkedEncodingError("broken")
)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
return_value=bad,
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
with pytest.raises(requests.exceptions.ChunkedEncodingError):
safe_get_with_retries(
"https://example.com/x",
consume_body=True,
max_retries=2,
backoff_times=(0, 0, 0),
)
# 1 initial + 2 retries = 3 attempts
assert mock_get.call_count == 3
def test_consume_body_does_not_retry_value_error_from_body_guard():
"""``ValueError`` from oversized-body guard must NOT be retried.
A 1 GB+ response isn't a transient network error — retrying just
burns more bandwidth on the same outcome. The guard's ValueError
must propagate immediately on the first attempt.
"""
bad = _mock_response_body_raises(
ValueError("Response body too large: >1073741825 bytes")
)
with (
patch(
"local_deep_research.security.safe_requests.safe_get",
return_value=bad,
) as mock_get,
patch("local_deep_research.security.safe_requests.time.sleep"),
):
with pytest.raises(ValueError):
safe_get_with_retries(
"https://example.com/x",
consume_body=True,
backoff_times=(0, 0, 0),
)
# Single attempt — no retry on ValueError.
assert mock_get.call_count == 1
def test_consume_body_returns_cached_body_to_caller():
"""After a successful retry, ``response.content`` is cached.
The point of consuming inside the loop is so the caller doesn't
repeat the read (and risk a second transient). Verify the body
is available without a second read.
"""
ok = _mock_response(200, content=b"hello world")
with patch(
"local_deep_research.security.safe_requests.safe_get",
return_value=ok,
):
resp = safe_get_with_retries("https://example.com/x", consume_body=True)
assert resp.content == b"hello world"
@@ -0,0 +1,397 @@
"""Tests for the standalone ``sanitize_error_message`` function.
Verifies that the regex-based sanitizer catches common credential formats
in exception messages. This function was extracted from
``BaseSearchEngine._sanitize_error_message`` so it can be used outside the
search-engine inheritance tree (e.g. in LLM config and error handling).
"""
from local_deep_research.security.log_sanitizer import sanitize_error_message
class TestSanitizeErrorMessageStandalone:
"""Unit tests for the pattern-based credential sanitizer."""
def test_bearer_token_redacted(self):
msg = 'Error: Connection refused, token="Bearer sk-proj-abc123xyz456def789ghi012"'
result = sanitize_error_message(msg)
assert "sk-proj-abc123xyz456def789ghi012" not in result
assert "Bearer [REDACTED]" in result
def test_url_query_api_key_redacted(self):
msg = "HTTPSConnectionPool: Max retries exceeded with url: /v1/models?api_key=sk-secret-key-value-12345&q=test"
result = sanitize_error_message(msg)
assert "sk-secret-key-value-12345" not in result
assert "api_key=[REDACTED]" in result
def test_url_query_apikey_no_underscore_redacted(self):
msg = "?apikey=my-secret-api-key-value-here&format=json"
result = sanitize_error_message(msg)
assert "my-secret-api-key-value-here" not in result
assert "apikey=[REDACTED]" in result
def test_url_query_api_key_hyphenated_redacted(self):
"""Guardian uses ``api-key`` (with hyphen) as the param name."""
msg = "?api-key=guardian-secret-key-value-here&q=test"
result = sanitize_error_message(msg)
assert "guardian-secret-key-value-here" not in result
assert "api-key=[REDACTED]" in result
def test_url_query_key_redacted(self):
"""Google PSE uses ``key`` as the param name."""
msg = "?key=google-pse-secret-key-value&q=test"
result = sanitize_error_message(msg)
assert "google-pse-secret-key-value" not in result
assert "key=[REDACTED]" in result
def test_url_query_named_secrets_redacted(self):
"""Secret-named query params beyond the classic tokens must also be
redacted, matching DataSanitizer.DEFAULT_SENSITIVE_KEYS."""
for name in (
"client_secret",
"secret_key",
"bearer_token",
"api_secret",
"app_secret",
):
value = f"{name}-oauth-value-here-1234"
result = sanitize_error_message(f"POST /cb?{name}={value}&x=1")
assert value not in result, name
assert f"{name}=[REDACTED]" in result, name
def test_url_query_client_id_not_redacted(self):
"""client_id is a public identifier, not a secret — stays readable."""
msg = "?client_id=public-app-id-value-12345&scope=read"
result = sanitize_error_message(msg)
assert "public-app-id-value-12345" in result
def test_url_query_token_redacted(self):
msg = "?token=secret-token-value-1234567890&page=1"
result = sanitize_error_message(msg)
assert "secret-token-value-1234567890" not in result
assert "token=[REDACTED]" in result
def test_url_query_secret_redacted(self):
msg = "&secret=super-secret-value-abcde12345&format=json"
result = sanitize_error_message(msg)
assert "super-secret-value-abcde12345" not in result
assert "secret=[REDACTED]" in result
def test_url_credentials_redacted(self):
msg = "Connection to https://admin:supersecretpassword@api.example.com failed"
result = sanitize_error_message(msg)
assert "admin" not in result
assert "supersecretpassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_url_credentials_inside_api_key_param_value(self):
"""URL-embedded credentials must survive being an api-key param value.
Regression test for pattern ordering: when the param value is itself
a URL with embedded credentials, the param pattern used to consume
the ``https`` scheme first (``api-key=[REDACTED]://user:pass@...``),
leaving the credentials unredacted. The URL-credentials pattern now
runs before the URL-param pattern.
"""
msg = "Request failed for ?api-key=https://admin:supersecret@internal.example.com/v1"
result = sanitize_error_message(msg)
assert "supersecret" not in result
assert "admin" not in result
def test_url_credentials_inside_key_param_value(self):
"""Same ordering interaction via the ``key=`` spelling."""
msg = "?key=https://user:hunter2@host.example.com/path"
result = sanitize_error_message(msg)
assert "hunter2" not in result
assert "user:" not in result
def test_url_credentials_inside_token_param_value(self):
"""Same ordering interaction via the ``token=`` spelling."""
msg = "retry failed: &token=http://svc:p4ssw0rd@10.0.0.5:8080/auth"
result = sanitize_error_message(msg)
assert "p4ssw0rd" not in result
assert "svc:" not in result
class TestDatabaseDsnCredentials:
"""URL-credential redaction covers database DSNs, not just http(s).
A raw SQLAlchemy / driver connection error is the most common way a
credential-bearing string reaches an exception message. The userinfo in
any URL scheme is a credential, so it is redacted regardless of scheme.
"""
def test_postgres_dsn_password_redacted(self):
msg = (
"OperationalError: could not connect to "
"postgresql://newsuser:s3cr3tPw0rd@db.internal:5432/news"
)
result = sanitize_error_message(msg)
assert "s3cr3tPw0rd" not in result
assert "newsuser" not in result
assert "[REDACTED]:[REDACTED]@db.internal" in result
def test_sqlalchemy_dialect_driver_scheme_redacted(self):
# SQLAlchemy DSNs carry a `dialect+driver` scheme. Each DSN is built
# by f-string concatenation so no literal "user:password@" pair
# appears in source — keeps regex-based secret scanners from flagging
# classic default-cred shapes like root:toor alongside the MongoDB
# fixture that originally triggered alert #2.
pg_user, pg_pw = "user", "pa55"
my_user, my_pw = "root", "toor"
mongo_user, mongo_pw = "appuser", "letmein"
for msg in (
f"postgresql+psycopg2://{pg_user}:{pg_pw}@dbhost/appdb",
f"mysql+pymysql://{my_user}:{my_pw}@localhost:3306/db",
f"mongodb+srv://{mongo_user}:{mongo_pw}@cluster0.mongodb.net/test",
):
result = sanitize_error_message(msg)
assert "[REDACTED]:[REDACTED]@" in result, msg
assert my_pw not in sanitize_error_message(
f"mysql+pymysql://{my_user}:{my_pw}@localhost:3306/db"
)
def test_password_only_redis_dsn_redacted(self):
# redis uses a password-only DSN (empty username).
msg = "redis://:mypassword@127.0.0.1:6379/0 connection reset"
result = sanitize_error_message(msg)
assert "mypassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_credential_less_dsn_with_port_unchanged(self):
# No userinfo (just host:port) must NOT be touched — the trailing `@`
# is required and `/` is excluded from the userinfo.
for msg in (
"connect failed to postgresql://dbhost:5432/news",
"redis://cache.local:6379/0 timed out",
):
assert sanitize_error_message(msg) == msg
def test_http_credentials_still_redacted(self):
# Regression guard: generalizing the scheme must not lose http(s).
msg = "Connection to https://admin:supersecretpassword@api.example.com failed"
result = sanitize_error_message(msg)
assert "supersecretpassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_uppercase_scheme_redacted(self):
# URL schemes are case-insensitive (RFC 3986); an uppercase/mixed-case
# DSN scheme must not defeat redaction.
for msg in (
"POSTGRESQL://newsuser:s3cr3tPw0rd@db.internal:5432/news",
"Postgresql+Psycopg2://user:pa55@dbhost/appdb",
):
result = sanitize_error_message(msg)
assert "s3cr3tPw0rd" not in result
assert "pa55" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_url_glued_to_preceding_word_char_still_redacted(self):
# Regression guard against a leading `\b` anchor: a credential URL
# glued to a preceding word char must still have its userinfo redacted
# (the scheme's first char is a word char, so `\b` would fail to anchor
# here and leak the password).
msg = "prefixXhttps://admin:supersecretpassword@host/path"
result = sanitize_error_message(msg)
assert "supersecretpassword" not in result
assert "[REDACTED]:[REDACTED]@" in result
def test_sk_prefix_redacted(self):
"""OpenAI-style key with modern hyphenated format (sk-proj-...)."""
msg = "Invalid API key: sk-proj-abc123xyz456def789ghi012jkl"
result = sanitize_error_message(msg)
assert "sk-proj-abc123xyz456def789ghi012jkl" not in result
assert "[REDACTED_KEY]" in result
def test_sk_prefix_pure_alphanumeric(self):
"""Old-style OpenAI key (pure alphanumeric after sk-)."""
msg = "key: sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
result = sanitize_error_message(msg)
assert "sk-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" not in result
assert "[REDACTED_KEY]" in result
def test_pk_prefix_redacted(self):
msg = "public key pk-test-abc123xyz456def789ghi012jkl345"
result = sanitize_error_message(msg)
assert "pk-test-abc123xyz456def789ghi012jkl345" not in result
assert "[REDACTED_KEY]" in result
def test_empty_string_passthrough(self):
assert sanitize_error_message("") == ""
def test_no_secrets_unchanged(self):
msg = (
"Connection timeout after 10s for https://api.example.com/v1/models"
)
assert sanitize_error_message(msg) == msg
def test_multiple_secrets_in_one_message(self):
msg = (
"Bearer sk-proj-abc123xyz456def789ghi012 "
"with ?api_key=my-secret-key-12345&token=another-secret-value-67890"
)
result = sanitize_error_message(msg)
assert "sk-proj-abc123xyz456def789ghi012" not in result
assert "my-secret-key-12345" not in result
assert "another-secret-value-67890" not in result
assert "Bearer [REDACTED]" in result
assert "api_key=[REDACTED]" in result
assert "token=[REDACTED]" in result
def test_sk_prefix_too_short_not_redacted(self):
"""Short strings like ``sk-abc`` should NOT be redacted (false positive risk)."""
msg = "prefix: sk-abc"
assert sanitize_error_message(msg) == msg
def test_delegate_preserves_backward_compat(self):
"""The BaseSearchEngine method delegates to the standalone function,
so this test verifies the output matches the old inline behavior
for a representative message."""
from local_deep_research.web_search_engines.search_engine_base import (
BaseSearchEngine,
)
msg = "Error: ?api_key=secret12345678&key=mykey1234567890123456"
expected = sanitize_error_message(msg)
# Create a minimal subclass instance to call the instance method.
class _MinimalEngine(BaseSearchEngine):
def _get_previews(self, query, *args, **kwargs):
return []
engine = _MinimalEngine.__new__(_MinimalEngine)
actual = engine._sanitize_error_message(msg)
assert actual == expected
class TestSanitizeErrorDetails:
"""Unit tests for the shared ``sanitize_error_details`` helper used by the
exception ``to_dict()`` backstops (NewsAPIException / WebAPIException)."""
def test_str_leaf_credential_redacted(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
out = sanitize_error_details(
{"note": "Authorization: Bearer sk-abc123DEF456ghi789"}
)
assert "sk-abc123DEF456ghi789" not in out["note"]
assert "REDACTED" in out["note"]
def test_benign_and_non_string_leaves_pass_through(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
value = {
"query": "reset my password",
"count": 3,
"ok": True,
"x": None,
}
assert sanitize_error_details(value) == value
def test_nested_dict_list_recursion(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
out = sanitize_error_details(
{"outer": {"inner": ["?api_key=AIzaSyD-EXAMPLE_1234567890", 7]}}
)
leaf = out["outer"]["inner"][0]
assert "AIzaSyD-EXAMPLE_1234567890" not in leaf
assert "REDACTED" in leaf
assert out["outer"]["inner"][1] == 7
def test_tuple_normalizes_to_list(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
assert sanitize_error_details({"pair": ("a", "b")})["pair"] == [
"a",
"b",
]
def test_namedtuple_does_not_crash(self):
from collections import namedtuple
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
Pair = namedtuple("Pair", ["a", "b"])
out = sanitize_error_details(
{"pair": Pair("Bearer sk-abc123DEF456ghi789", "x")}
)["pair"]
assert isinstance(out, list)
assert "sk-abc123DEF456ghi789" not in out[0]
assert "REDACTED" in out[0]
assert out[1] == "x"
def test_input_is_not_mutated(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
original = {"note": "Bearer sk-secret999value000", "keep": [1, 2]}
out = sanitize_error_details(original)
assert original["note"] == "Bearer sk-secret999value000"
assert out["note"] != original["note"]
assert out["keep"] is not original["keep"]
def test_non_container_scalar_passthrough(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
assert sanitize_error_details(42) == 42
assert sanitize_error_details(None) is None
def test_dataclass_leaf_is_recursed_and_redacted(self):
# Flask's default JSON provider serializes a dataclass via asdict(), so
# a credential in a dataclass field would ship to the client unless the
# helper recurses into it.
import dataclasses
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
@dataclasses.dataclass
class Ctx:
note: str
count: int
out = sanitize_error_details(
{"ctx": Ctx("Authorization: Bearer sk-abc123DEF456ghi789", 5)}
)
assert "sk-abc123DEF456ghi789" not in out["ctx"]["note"]
assert "REDACTED" in out["ctx"]["note"]
assert out["ctx"]["count"] == 5
def test_credential_shaped_dict_key_redacted(self):
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
out = sanitize_error_details(
{"Authorization: Bearer sk-abc123DEF456ghi789": "v"}
)
assert not any("sk-abc123DEF456ghi789" in k for k in out)
# benign field-name keys are untouched
assert sanitize_error_details({"username": "bob"}) == {
"username": "bob"
}
def test_set_leaf_passes_through(self):
# A set is not JSON-serializable, so it is left as-is (fails closed at
# jsonify rather than leaking) — pin the passthrough behavior.
from local_deep_research.security.log_sanitizer import (
sanitize_error_details,
)
s = {"a", "b"}
assert sanitize_error_details({"tags": s})["tags"] == s
+562
View File
@@ -0,0 +1,562 @@
"""Comprehensive coverage tests for data_sanitizer and log_sanitizer modules.
Module 1 -- data_sanitizer:
DataSanitizer.sanitize, DataSanitizer.redact, sanitize_data, redact_data,
filter_research_metadata, strip_settings_snapshot
Module 2 -- log_sanitizer:
strip_control_chars, sanitize_for_log
"""
import json
import pytest
from local_deep_research.security.data_sanitizer import (
DataSanitizer,
filter_research_metadata,
redact_data,
sanitize_data,
strip_settings_snapshot,
)
from local_deep_research.security.log_sanitizer import (
sanitize_for_log,
strip_control_chars,
)
# ===================================================================
# DataSanitizer.sanitize
# ===================================================================
class TestSanitizeNestedDicts:
"""sanitize removes sensitive keys inside deeply nested dicts."""
def test_two_levels_deep(self):
data = {"outer": {"api_key": "abc", "safe": 1}}
result = DataSanitizer.sanitize(data)
assert result == {"outer": {"safe": 1}}
def test_three_levels_deep(self):
data = {"a": {"b": {"password": "pw", "x": 9}}}
result = DataSanitizer.sanitize(data)
assert result == {"a": {"b": {"x": 9}}}
def test_multiple_sensitive_keys_at_different_depths(self):
data = {
"secret": "top",
"level1": {
"refresh_token": "tok",
"level2": {"private_key": "pk", "ok": True},
},
}
result = DataSanitizer.sanitize(data)
assert result == {"level1": {"level2": {"ok": True}}}
class TestSanitizeLists:
"""sanitize recurses into lists and sanitizes dicts inside them."""
def test_list_of_dicts(self):
data = [{"api_key": "k", "id": 1}, {"password": "p", "id": 2}]
result = DataSanitizer.sanitize(data)
assert result == [{"id": 1}, {"id": 2}]
def test_empty_list(self):
assert DataSanitizer.sanitize([]) == []
def test_list_of_primitives_unchanged(self):
data = [1, "hello", None, True]
assert DataSanitizer.sanitize(data) == [1, "hello", None, True]
class TestSanitizeSensitiveKeyRemoval:
"""Verify all default sensitive keys are removed."""
@pytest.mark.parametrize(
"key", sorted(DataSanitizer.DEFAULT_SENSITIVE_KEYS)
)
def test_each_default_key_removed(self, key):
data = {key: "value", "keep_me": 42}
result = DataSanitizer.sanitize(data)
assert key not in result
assert result["keep_me"] == 42
class TestSanitizeCaseInsensitive:
"""sanitize matches keys case-insensitively."""
def test_uppercase_key(self):
data = {"API_KEY": "secret", "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "API_KEY" not in result
assert result == {"name": "ok"}
def test_mixed_case_key(self):
data = {"PaSsWoRd": "secret", "name": "ok"}
result = DataSanitizer.sanitize(data)
assert "PaSsWoRd" not in result
def test_title_case_key(self):
data = {"Access_Token": "tok", "status": "active"}
result = DataSanitizer.sanitize(data)
assert "Access_Token" not in result
assert result == {"status": "active"}
class TestSanitizeJsonStringInput:
"""sanitize treats a JSON string as a primitive -- it is NOT parsed."""
def test_json_string_returned_as_is(self):
json_str = json.dumps({"api_key": "secret"})
result = DataSanitizer.sanitize(json_str)
assert result == json_str
def test_json_array_string_returned_as_is(self):
json_str = json.dumps([{"password": "pw"}])
assert DataSanitizer.sanitize(json_str) == json_str
class TestSanitizeCustomKeys:
"""sanitize accepts a custom set of sensitive keys."""
def test_custom_keys_only(self):
data = {"my_token": "t", "api_key": "k", "name": "n"}
result = DataSanitizer.sanitize(data, sensitive_keys={"my_token"})
assert "my_token" not in result
assert "api_key" in result # not in custom set
def test_empty_custom_keys_removes_nothing(self):
data = {"api_key": "k", "password": "p"}
result = DataSanitizer.sanitize(data, sensitive_keys=set())
assert result == data
# ===================================================================
# DataSanitizer.redact
# ===================================================================
class TestRedactBasic:
"""redact replaces sensitive values with placeholder text."""
def test_default_redaction_text(self):
data = {"api_key": "secret123", "name": "ok"}
result = DataSanitizer.redact(data)
assert result["api_key"] == "[REDACTED]"
assert result["name"] == "ok"
def test_key_preserved_value_replaced(self):
data = {"password": "mypass"}
result = DataSanitizer.redact(data)
assert "password" in result
assert result["password"] == "[REDACTED]"
class TestRedactCustomText:
"""redact accepts custom redaction placeholder text."""
def test_custom_redaction_text(self):
data = {"api_key": "secret", "name": "ok"}
result = DataSanitizer.redact(data, redaction_text="***")
assert result["api_key"] == "***"
assert result["name"] == "ok"
def test_empty_string_redaction_text(self):
data = {"secret": "val"}
result = DataSanitizer.redact(data, redaction_text="")
assert result["secret"] == ""
def test_long_redaction_text(self):
text = "SENSITIVE DATA REMOVED BY SECURITY FILTER"
data = {"password": "pw"}
result = DataSanitizer.redact(data, redaction_text=text)
assert result["password"] == text
class TestRedactNestedStructures:
"""redact recurses into nested dicts and lists."""
def test_nested_dict(self):
data = {"config": {"auth_token": "tok", "host": "localhost"}}
result = DataSanitizer.redact(data)
assert result["config"]["auth_token"] == "[REDACTED]"
assert result["config"]["host"] == "localhost"
def test_list_of_dicts(self):
data = [{"session_token": "s1"}, {"csrf_token": "c1"}]
result = DataSanitizer.redact(data)
assert result[0]["session_token"] == "[REDACTED]"
assert result[1]["csrf_token"] == "[REDACTED]"
def test_dict_in_list_in_dict(self):
data = {"items": [{"private_key": "pk", "id": 1}]}
result = DataSanitizer.redact(data)
assert result["items"][0]["private_key"] == "[REDACTED]"
assert result["items"][0]["id"] == 1
def test_original_data_not_mutated(self):
data = {"api_key": "original_value", "name": "ok"}
DataSanitizer.redact(data)
assert data["api_key"] == "original_value"
class TestRedactCaseInsensitive:
"""redact matches keys case-insensitively."""
def test_uppercase_key_redacted(self):
data = {"SECRET": "val"}
result = DataSanitizer.redact(data)
assert result["SECRET"] == "[REDACTED]"
def test_mixed_case_key_redacted(self):
data = {"Apikey": "val", "name": "ok"}
result = DataSanitizer.redact(data)
assert result["Apikey"] == "[REDACTED]"
assert result["name"] == "ok"
# ===================================================================
# sanitize_data wrapper
# ===================================================================
class TestSanitizeDataWrapper:
"""sanitize_data convenience function delegates to DataSanitizer.sanitize."""
def test_removes_default_sensitive_keys(self):
data = {"api_key": "k", "name": "n"}
result = sanitize_data(data)
assert result == {"name": "n"}
def test_accepts_custom_sensitive_keys(self):
data = {"custom_field": "v", "api_key": "k"}
result = sanitize_data(data, sensitive_keys={"custom_field"})
assert "custom_field" not in result
assert "api_key" in result
def test_primitive_passthrough(self):
assert sanitize_data(99) == 99
assert sanitize_data(None) is None
# ===================================================================
# redact_data wrapper
# ===================================================================
class TestRedactDataWrapper:
"""redact_data convenience function delegates to DataSanitizer.redact."""
def test_redacts_default_sensitive_keys(self):
data = {"password": "pw", "name": "n"}
result = redact_data(data)
assert result["password"] == "[REDACTED]"
assert result["name"] == "n"
def test_accepts_custom_keys_and_text(self):
data = {"my_key": "v", "password": "pw"}
result = redact_data(
data, sensitive_keys={"my_key"}, redaction_text="XXX"
)
assert result["my_key"] == "XXX"
assert result["password"] == "pw"
def test_primitive_passthrough(self):
assert redact_data("hello") == "hello"
assert redact_data(None) is None
# ===================================================================
# filter_research_metadata
# ===================================================================
class TestFilterResearchMetadata:
"""filter_research_metadata extracts only safe allowlisted fields."""
def test_extracts_is_news_search_true(self):
result = filter_research_metadata(
{"is_news_search": True, "extra": "x"}
)
assert result == {"is_news_search": True}
assert "extra" not in result
def test_extracts_is_news_search_false(self):
result = filter_research_metadata({"is_news_search": False})
assert result == {"is_news_search": False}
def test_missing_is_news_search_defaults_false(self):
result = filter_research_metadata({"some_other_key": 123})
assert result == {"is_news_search": False}
def test_none_input(self):
result = filter_research_metadata(None)
assert result == {"is_news_search": False}
def test_json_string_input_dict(self):
meta_json = json.dumps(
{"is_news_search": True, "settings_snapshot": {}}
)
result = filter_research_metadata(meta_json)
assert result == {"is_news_search": True}
assert "settings_snapshot" not in result
def test_json_string_input_false(self):
meta_json = json.dumps({"is_news_search": False})
result = filter_research_metadata(meta_json)
assert result == {"is_news_search": False}
def test_invalid_json_string(self):
result = filter_research_metadata("{bad json!!!")
assert result == {"is_news_search": False}
def test_integer_input_returns_default(self):
result = filter_research_metadata(42)
assert result == {"is_news_search": False}
def test_is_news_search_truthy_int_coerced(self):
"""Non-zero int is truthy, coerced to True via bool()."""
result = filter_research_metadata({"is_news_search": 1})
assert result["is_news_search"] is True
assert type(result["is_news_search"]) is bool
def test_settings_snapshot_never_leaks(self):
meta = {
"is_news_search": False,
"settings_snapshot": {"api_key": "SUPER_SECRET"},
}
result = filter_research_metadata(meta)
assert "settings_snapshot" not in result
# ===================================================================
# strip_settings_snapshot
# ===================================================================
class TestStripSettingsSnapshot:
"""strip_settings_snapshot removes settings_snapshot, preserves the rest."""
def test_removes_settings_snapshot(self):
meta = {"phase": "done", "settings_snapshot": {"api_key": "k"}}
result = strip_settings_snapshot(meta)
assert "settings_snapshot" not in result
assert result["phase"] == "done"
def test_preserves_all_other_keys(self):
meta = {
"mode": "quick",
"duration": 5.2,
"is_news_search": True,
"settings_snapshot": {},
}
result = strip_settings_snapshot(meta)
assert result == {
"mode": "quick",
"duration": 5.2,
"is_news_search": True,
}
def test_none_input(self):
result = strip_settings_snapshot(None)
assert result == {}
def test_string_metadata_json(self):
meta_json = json.dumps(
{"phase": "complete", "settings_snapshot": {"k": "v"}}
)
result = strip_settings_snapshot(meta_json)
assert result == {"phase": "complete"}
assert "settings_snapshot" not in result
def test_no_settings_snapshot_key_noop(self):
meta = {"phase": "done", "duration": 3.0}
result = strip_settings_snapshot(meta)
assert result == meta
def test_invalid_json_string_returns_empty(self):
result = strip_settings_snapshot("not valid json {{{")
assert result == {}
def test_non_dict_type_returns_empty(self):
assert strip_settings_snapshot(123) == {}
assert strip_settings_snapshot([1, 2]) == {}
# ===================================================================
# strip_control_chars (log_sanitizer)
# ===================================================================
class TestStripControlCharsComprehensive:
"""strip_control_chars removes C0/C1 control, zero-width, and format chars."""
def test_c0_null_and_bell(self):
assert strip_control_chars("a\x00b\x07c") == "abc"
def test_c0_tab_newline_carriage_return(self):
assert strip_control_chars("a\tb\nc\rd") == "abcd"
def test_c1_range(self):
"""C1 control characters (0x80-0x9F) are stripped."""
assert strip_control_chars("a\x80b\x8fc\x9fd") == "abcd"
def test_del_character(self):
"""DEL (0x7F) is stripped."""
assert strip_control_chars("hello\x7fworld") == "helloworld"
def test_zero_width_space(self):
assert strip_control_chars("hello\u200bworld") == "helloworld"
def test_zero_width_non_joiner(self):
assert strip_control_chars("a\u200cb") == "ab"
def test_zero_width_joiner(self):
assert strip_control_chars("a\u200db") == "ab"
def test_ltr_rtl_marks(self):
assert strip_control_chars("a\u200eb\u200fc") == "abc"
def test_unicode_format_chars_rlo(self):
"""Right-to-left override (RLO) is stripped."""
assert strip_control_chars("hello\u202eworld") == "helloworld"
def test_unicode_embedding_chars(self):
"""LRE, RLE, PDF, LRO are stripped."""
assert strip_control_chars("\u202a\u202b\u202c\u202dtext") == "text"
def test_bom_stripped(self):
assert strip_control_chars("\ufeffhello") == "hello"
def test_word_joiner_stripped(self):
assert strip_control_chars("a\u2060b") == "ab"
def test_arabic_letter_mark_stripped(self):
assert strip_control_chars("a\u061cb") == "ab"
def test_isolate_chars_stripped(self):
"""LRI, RLI, FSI, PDI are stripped."""
assert strip_control_chars("\u2066\u2067\u2068\u2069text") == "text"
def test_preserves_valid_unicode(self):
assert (
strip_control_chars("cafe\u0301") == "cafe\u0301"
) # e-acute combining
def test_preserves_emoji(self):
result = strip_control_chars("hello world")
assert result == "hello world"
def test_preserves_cjk(self):
assert strip_control_chars("test") == "test"
def test_empty_string(self):
assert strip_control_chars("") == ""
def test_all_control_chars_yields_empty(self):
assert strip_control_chars("\x00\x01\x02\x7f\x80") == ""
# ===================================================================
# sanitize_for_log (log_sanitizer)
# ===================================================================
class TestSanitizeForLogTruncation:
"""sanitize_for_log truncates with '...' suffix when exceeding max_length."""
def test_long_string_truncated_with_ellipsis(self):
result = sanitize_for_log("a" * 100, max_length=50)
assert result == "a" * 47 + "..."
assert len(result) == 50
def test_default_max_length_50(self):
result = sanitize_for_log("b" * 200)
assert len(result) == 50
assert result.endswith("...")
def test_exact_max_length_no_truncation(self):
result = sanitize_for_log("c" * 50, max_length=50)
assert result == "c" * 50
assert "..." not in result
def test_shorter_than_max_no_truncation(self):
result = sanitize_for_log("short", max_length=50)
assert result == "short"
class TestSanitizeForLogMaxLengthEdgeCases:
"""Edge-case max_length values: 0, 1, 3, 50."""
def test_max_length_zero(self):
"""max_length=0 returns empty string (no room for anything)."""
result = sanitize_for_log("hello", max_length=0)
assert result == ""
def test_max_length_one(self):
"""max_length=1 -- too short for '...' so just truncates."""
result = sanitize_for_log("hello", max_length=1)
assert len(result) <= 1
assert result == "h"
def test_max_length_three(self):
"""max_length=3 -- exactly the length of '...', uses raw truncation."""
result = sanitize_for_log("hello", max_length=3)
assert len(result) <= 3
assert result == "hel"
def test_max_length_four_uses_ellipsis(self):
"""max_length=4 -- enough room for 1 char + '...'."""
result = sanitize_for_log("hello world", max_length=4)
assert result == "h..."
assert len(result) == 4
def test_max_length_50_default(self):
result = sanitize_for_log("x" * 60, max_length=50)
assert len(result) == 50
assert result == "x" * 47 + "..."
class TestSanitizeForLogEmptyString:
"""sanitize_for_log on empty input."""
def test_empty_string_returns_empty(self):
assert sanitize_for_log("") == ""
def test_empty_string_with_explicit_max_length(self):
assert sanitize_for_log("", max_length=10) == ""
class TestSanitizeForLogControlCharsAndTruncation:
"""Control chars are stripped BEFORE truncation is applied."""
def test_control_chars_removed_then_truncated(self):
# 10 visible chars + control chars = still only 10 visible after strip
raw = "abcde\x00\x01\x02fghij"
result = sanitize_for_log(raw, max_length=50)
assert result == "abcdefghij"
def test_control_chars_reduce_length_below_max(self):
"""After stripping, string may become shorter than max_length."""
raw = "\x00\x01\x02abc"
result = sanitize_for_log(raw, max_length=50)
assert result == "abc"
def test_control_chars_stripped_still_needs_truncation(self):
"""Even after stripping, result may still exceed max_length."""
raw = "a" * 40 + "\x00" * 5 + "b" * 40
result = sanitize_for_log(raw, max_length=50)
# After strip: 80 visible chars, needs truncation to 50
assert len(result) == 50
assert result.endswith("...")
def test_all_control_chars_yields_empty(self):
result = sanitize_for_log("\x00\x01\x7f", max_length=50)
assert result == ""
def test_unicode_preserved_controls_stripped(self):
raw = "cafe\u0301\x00\x07 world"
result = sanitize_for_log(raw, max_length=50)
assert result == "cafe\u0301 world"
+214
View File
@@ -0,0 +1,214 @@
"""Tests for the diagnose-gated ``SecureLogger`` wrapper.
``security.secure_logging.logger.exception()`` must log at ERROR level
while attaching the active exception to the record ONLY when both
``LDR_APP_DEBUG`` and ``LDR_LOGURU_DIAGNOSE`` are truthy. With the
exception stripped, no sink can render the traceback or the
``__cause__`` chain — the #4183 leak class (headers/URLs embedded in
wrapped exceptions) cannot reach any log output.
"""
import pytest
from local_deep_research.security.secure_logging import (
SecureLogger,
env_truthy,
is_diagnose_mode,
logger,
)
_DEBUG_VAR = "LDR_APP_DEBUG"
_DIAGNOSE_VAR = "LDR_LOGURU_DIAGNOSE"
def _set_env(monkeypatch, debug, diagnose):
"""Set/unset both gating vars. ``None`` means unset."""
for var, value in ((_DEBUG_VAR, debug), (_DIAGNOSE_VAR, diagnose)):
if value is None:
monkeypatch.delenv(var, raising=False)
else:
monkeypatch.setenv(var, value)
def _log_wrapped_exception(message="request failed: scrubbed"):
"""Log *message* from inside a handler for a cause-chained exception.
The inner ``ValueError`` carries a fake credential — the same shape
as the #4183 leak, where the secret lived only in the ``__cause__``
chain, not in the logged message.
"""
try:
try:
raise ValueError("api_key=sk-LEAK-0123456789abcdef")
except ValueError as inner:
raise RuntimeError("wrapped") from inner
except RuntimeError:
logger.exception(message)
class TestEnvTruthy:
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", " YES "])
def test_truthy_values(self, monkeypatch, value):
monkeypatch.setenv(_DEBUG_VAR, value)
assert env_truthy(_DEBUG_VAR) is True
@pytest.mark.parametrize("value", ["", "0", "false", "no", "on", "2"])
def test_falsy_values(self, monkeypatch, value):
monkeypatch.setenv(_DEBUG_VAR, value)
assert env_truthy(_DEBUG_VAR) is False
def test_unset_is_falsy(self, monkeypatch):
monkeypatch.delenv(_DEBUG_VAR, raising=False)
assert env_truthy(_DEBUG_VAR) is False
class TestIsDiagnoseMode:
@pytest.mark.parametrize(
("debug", "diagnose", "expected"),
[
(None, None, False),
("1", None, False),
(None, "1", False),
("0", "1", False),
("1", "0", False),
("1", "1", True),
("true", "yes", True),
("YES ", "TRUE", True),
],
)
def test_truth_table(self, monkeypatch, debug, diagnose, expected):
_set_env(monkeypatch, debug, diagnose)
assert is_diagnose_mode() is expected
class TestExceptionDefaultMode:
"""Both flags unset — production behaviour."""
def test_logs_at_error_level_without_traceback(
self, monkeypatch, loguru_caplog_full
):
_set_env(monkeypatch, None, None)
with loguru_caplog_full.at_level("ERROR"):
_log_wrapped_exception()
records = [
r
for r in loguru_caplog_full.records
if "request failed: scrubbed" in r.getMessage()
]
assert records, "the event must still be logged"
assert all(r.levelname == "ERROR" for r in records)
assert all(r.exc_info is None for r in records)
assert "Traceback" not in loguru_caplog_full.text
def test_cause_chain_secret_never_reaches_output(
self, monkeypatch, loguru_caplog_full
):
"""#4183 regression: the fixture renders ``{exception}`` with
``backtrace=True``, so if the exception were attached the
``__cause__`` chain (holding the credential) WOULD appear here."""
_set_env(monkeypatch, None, None)
with loguru_caplog_full.at_level("ERROR"):
_log_wrapped_exception()
assert "request failed: scrubbed" in loguru_caplog_full.text
assert "sk-LEAK" not in loguru_caplog_full.text
assert "api_key" not in loguru_caplog_full.text
@pytest.mark.parametrize(
("debug", "diagnose"),
[("1", None), (None, "1"), ("1", "0"), ("0", "1")],
)
def test_single_flag_does_not_enable_traceback(
self, monkeypatch, loguru_caplog_full, debug, diagnose
):
_set_env(monkeypatch, debug, diagnose)
with loguru_caplog_full.at_level("ERROR"):
_log_wrapped_exception()
assert "request failed: scrubbed" in loguru_caplog_full.text
assert "Traceback" not in loguru_caplog_full.text
assert "sk-LEAK" not in loguru_caplog_full.text
class TestExceptionDiagnoseMode:
def test_both_flags_render_traceback(self, monkeypatch, loguru_caplog_full):
_set_env(monkeypatch, "1", "1")
with loguru_caplog_full.at_level("ERROR"):
_log_wrapped_exception()
records = [
r
for r in loguru_caplog_full.records
if "request failed: scrubbed" in r.getMessage()
]
assert records
assert all(r.levelname == "ERROR" for r in records)
assert "Traceback" in loguru_caplog_full.text
assert "RuntimeError" in loguru_caplog_full.text
class TestRecordAttribution:
def test_record_points_at_caller_not_wrapper(self, monkeypatch):
"""``depth=1`` must attribute the record to the calling module so
log locations stay useful and ``logger.disable()`` namespacing
keys off the caller, not ``secure_logging``."""
_set_env(monkeypatch, None, None)
captured = []
handler_id = logger.add(
lambda message: captured.append(message.record),
format="{message}",
level="ERROR",
)
try:
try:
raise RuntimeError("boom")
except RuntimeError:
logger.exception("attribution check")
finally:
logger.remove(handler_id)
records = [r for r in captured if r["message"] == "attribution check"]
assert records
record = records[-1]
assert record["module"] == "test_secure_logging"
assert record["function"] == "test_record_points_at_caller_not_wrapper"
assert record["exception"] is None
class TestWrapperChaining:
def test_bind_keeps_gated_exception(self, monkeypatch, loguru_caplog_full):
_set_env(monkeypatch, None, None)
bound = logger.bind(component="test")
assert isinstance(bound, SecureLogger)
with loguru_caplog_full.at_level("ERROR"):
try:
try:
raise ValueError("api_key=sk-LEAK-0123456789abcdef")
except ValueError as inner:
raise RuntimeError("wrapped") from inner
except RuntimeError:
bound.exception("bound scrubbed message")
assert "bound scrubbed message" in loguru_caplog_full.text
assert "Traceback" not in loguru_caplog_full.text
assert "sk-LEAK" not in loguru_caplog_full.text
def test_patch_returns_wrapper(self):
patched = logger.patch(lambda record: None)
assert isinstance(patched, SecureLogger)
def test_no_active_exception_does_not_raise(
self, monkeypatch, loguru_caplog_full
):
_set_env(monkeypatch, None, None)
with loguru_caplog_full.at_level("ERROR"):
logger.exception("called outside except block")
assert "called outside except block" in loguru_caplog_full.text
def test_delegates_other_methods_to_loguru(
self, monkeypatch, loguru_caplog_full
):
with loguru_caplog_full.at_level("INFO"):
logger.info("plain info via wrapper")
assert "plain info via wrapper" in loguru_caplog_full.text

Some files were not shown because too many files have changed in this diff Show More