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
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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']}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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({}) == {}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>") == []
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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="***"
|
||||
)
|
||||
== "***"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user