Files
learningcircuit--local-deep…/tests/security/test_egress_notifications_callsite.py
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

238 lines
9.7 KiB
Python

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