learningcircuit--local-deep-research
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
201 行
7.0 KiB
Python
201 行
7.0 KiB
Python
"""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"
|