Files
wehub-resource-sync 7a0da7932b
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

912 lines
37 KiB
Python

"""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."
)