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
912 lines
37 KiB
Python
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."
|
|
)
|