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
286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""End-to-end "nothing leaves the box" tripwire for the egress policy.
|
|
|
|
This is the marquee integration test: it arms a REAL ``EgressContext`` via
|
|
``set_active_context`` (the same call the run orchestrator makes) and then
|
|
asserts that *every* layer of the stack agrees on the same decision under one
|
|
armed context:
|
|
|
|
* the installed PEP 578 audit hook gating raw ``socket.connect`` (the
|
|
last-line backstop that fires BEFORE the SYN is sent), and
|
|
* the PDP call-site evaluators (``evaluate_url`` / ``evaluate_engine`` /
|
|
``evaluate_llm_endpoint``) that the explicit fetch / engine-factory /
|
|
LLM-factory PEPs consult.
|
|
|
|
Unlike the focused hook test (``test_egress_audit_hook.py``) and the PDP unit
|
|
tests (``test_egress_policy.py``), this file does not pick the layers apart — it
|
|
checks that with a single armed PRIVATE_ONLY (and STRICT) context the *whole*
|
|
stack refuses public egress while still permitting the user's own loopback /
|
|
local services, and that the permissive scopes (PUBLIC_ONLY / BOTH) do NOT arm
|
|
the socket-level hook.
|
|
|
|
No outbound network is ever required: under an armed enforcing context the audit
|
|
hook raises ``PolicyDeniedError`` before ``connect`` reaches the kernel, so even
|
|
a non-blocking connect to a public IP returns the policy denial with no packets
|
|
on the wire.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.security import (
|
|
clear_active_context,
|
|
get_active_context,
|
|
install_audit_hook,
|
|
is_audit_hook_installed,
|
|
set_active_context,
|
|
)
|
|
from local_deep_research.security.egress.policy import (
|
|
EgressContext,
|
|
EgressScope,
|
|
PolicyDeniedError,
|
|
evaluate_engine,
|
|
evaluate_llm_endpoint,
|
|
evaluate_url,
|
|
)
|
|
|
|
# A public IP (Google DNS) and a public engine / cloud LLM provider used as the
|
|
# "should be denied" probes, plus a loopback IP used as the "should be allowed"
|
|
# probe. None of these need a live connection — see module docstring.
|
|
_PUBLIC_IP = "8.8.8.8"
|
|
_LOOPBACK_IP = "127.0.0.1"
|
|
_PUBLIC_ENGINE = "arxiv" # statically classified public in the engine registry
|
|
_CLOUD_LLM = "openai" # in _CLOUD_LLM_PROVIDERS
|
|
_METADATA_IP = "169.254.169.254" # AWS/GCE IMDS — never permitted, any scope
|
|
_METADATA_IP_OCTAL = "0251.0376.0251.0376" # alternate encoding of the above
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ensure_hook_and_clean_context():
|
|
"""The audit hook is install-once-and-keep-forever (PEP 578 hooks cannot be
|
|
removed). Make sure it is installed for the socket-layer assertions, and
|
|
that no context leaks into or out of any test in this file — a leaked
|
|
enforcing context would make an unrelated test's public connect raise.
|
|
"""
|
|
install_audit_hook()
|
|
clear_active_context()
|
|
yield
|
|
clear_active_context()
|
|
|
|
|
|
def _make_ctx(
|
|
scope: EgressScope,
|
|
*,
|
|
require_local_llm: bool = False,
|
|
require_local_embeddings: bool = False,
|
|
) -> EgressContext:
|
|
"""Build a fresh minimal context for ``scope``. Fresh per test so the
|
|
per-run denial quota / DNS cache never carries across tests.
|
|
"""
|
|
return EgressContext(
|
|
scope=scope,
|
|
primary_engine="wikipedia",
|
|
require_local_llm=require_local_llm,
|
|
require_local_embeddings=require_local_embeddings,
|
|
)
|
|
|
|
|
|
def _attempt_connect(host: str, port: int = 80, timeout: float = 0.5):
|
|
"""Open a raw AF_INET socket and connect; return the raised exception (or
|
|
None). We only care whether the audit hook raised ``PolicyDeniedError`` —
|
|
the kernel-level outcome (refused / timeout) is irrelevant because the hook
|
|
fires first.
|
|
"""
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
try:
|
|
sock.connect((host, port))
|
|
return None
|
|
except PolicyDeniedError as exc:
|
|
return exc
|
|
except OSError as exc:
|
|
return exc
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PRIVATE_ONLY: the full stack agrees
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_private_only_armed_blocks_public_socket_allows_loopback():
|
|
"""With a real armed PRIVATE_ONLY context, a raw socket.connect to a public
|
|
IP is refused by the audit hook (no network), while a connect to loopback
|
|
is NOT refused by the hook. Allow+deny pair: if the hook stopped gating,
|
|
the public probe would no longer raise PolicyDeniedError.
|
|
"""
|
|
assert is_audit_hook_installed() is True
|
|
ctx = _make_ctx(EgressScope.PRIVATE_ONLY)
|
|
set_active_context(ctx)
|
|
try:
|
|
public_err = _attempt_connect(_PUBLIC_IP, 53)
|
|
loopback_err = _attempt_connect(_LOOPBACK_IP, 1)
|
|
finally:
|
|
clear_active_context()
|
|
|
|
assert isinstance(public_err, PolicyDeniedError), (
|
|
f"armed PRIVATE_ONLY did not block public socket: {public_err!r}"
|
|
)
|
|
assert public_err.decision.reason == "scope_mismatch_private_only"
|
|
assert public_err.target == _PUBLIC_IP
|
|
assert not isinstance(loopback_err, PolicyDeniedError), (
|
|
f"armed PRIVATE_ONLY wrongly blocked loopback: {loopback_err!r}"
|
|
)
|
|
|
|
|
|
def test_private_only_armed_pdp_denies_public_url_engine_and_cloud_llm():
|
|
"""Under the SAME armed PRIVATE_ONLY context, every PDP call site agrees:
|
|
evaluate_url(public) / evaluate_engine(public) / evaluate_llm_endpoint(cloud)
|
|
all deny, while their private/local mirrors are allowed. The mirror halves
|
|
make each assertion fail if the scope check were reverted to allow-all.
|
|
"""
|
|
ctx = _make_ctx(
|
|
EgressScope.PRIVATE_ONLY,
|
|
require_local_llm=True,
|
|
require_local_embeddings=True,
|
|
)
|
|
set_active_context(ctx)
|
|
try:
|
|
armed = get_active_context()
|
|
assert armed is ctx
|
|
|
|
# evaluate_url: public denied, loopback allowed.
|
|
url_public = evaluate_url(f"http://{_PUBLIC_IP}/", armed)
|
|
url_private = evaluate_url(f"http://{_LOOPBACK_IP}/", armed)
|
|
|
|
# evaluate_engine: public engine denied, local engine allowed.
|
|
engine_public = evaluate_engine(
|
|
_PUBLIC_ENGINE, armed, settings_snapshot={}
|
|
)
|
|
engine_local = evaluate_engine("paperless", armed, settings_snapshot={})
|
|
|
|
# evaluate_llm_endpoint: cloud denied, local-default allowed.
|
|
llm_cloud = evaluate_llm_endpoint(
|
|
_CLOUD_LLM, armed, settings_snapshot={}
|
|
)
|
|
llm_local = evaluate_llm_endpoint("ollama", armed, settings_snapshot={})
|
|
finally:
|
|
clear_active_context()
|
|
|
|
assert not url_public.allowed
|
|
assert url_public.reason == "scope_mismatch_private_only"
|
|
assert url_private.allowed, "loopback URL wrongly denied under PRIVATE_ONLY"
|
|
|
|
assert not engine_public.allowed
|
|
assert engine_public.reason == "scope_mismatch_private_only"
|
|
assert engine_local.allowed, (
|
|
"local engine wrongly denied under PRIVATE_ONLY"
|
|
)
|
|
|
|
assert not llm_cloud.allowed
|
|
assert llm_cloud.reason == "provider_cloud_only"
|
|
assert llm_local.allowed, "local LLM wrongly denied under require_local_llm"
|
|
|
|
|
|
def test_private_only_armed_blocks_metadata_ip_canonical_and_octal():
|
|
"""Cloud-metadata IPs (IMDS) are NEVER permitted regardless of scope, and
|
|
classify with the explicit blocked_metadata_ip reason — including the
|
|
alternate octal encoding the libc resolver accepts. PRIVATE_ONLY would
|
|
otherwise ALLOW these (they look link-local/private), so this guards the
|
|
credential-theft path. Mirror: a real loopback URL is still allowed, proving
|
|
the denial is the metadata guard, not a blanket refusal.
|
|
"""
|
|
ctx = _make_ctx(EgressScope.PRIVATE_ONLY)
|
|
set_active_context(ctx)
|
|
try:
|
|
armed = get_active_context()
|
|
canonical = evaluate_url(
|
|
f"http://{_METADATA_IP}/latest/meta-data/", armed
|
|
)
|
|
octal = evaluate_url(f"http://{_METADATA_IP_OCTAL}/", armed)
|
|
loopback = evaluate_url(f"http://{_LOOPBACK_IP}/", armed)
|
|
finally:
|
|
clear_active_context()
|
|
|
|
assert not canonical.allowed
|
|
assert canonical.reason == "blocked_metadata_ip"
|
|
assert not octal.allowed
|
|
assert octal.reason == "blocked_metadata_ip"
|
|
assert loopback.allowed, "loopback wrongly denied — guard is over-broad"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# STRICT: same socket-layer contract as PRIVATE_ONLY
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_strict_armed_socket_layer_matches_private_only():
|
|
"""STRICT must behave like PRIVATE_ONLY at the socket layer: public host
|
|
refused (with the strict_public_host reason), loopback permitted. Allow+deny
|
|
pair against the public/loopback probes.
|
|
"""
|
|
ctx = _make_ctx(EgressScope.STRICT)
|
|
set_active_context(ctx)
|
|
try:
|
|
public_err = _attempt_connect(_PUBLIC_IP, 53)
|
|
loopback_err = _attempt_connect(_LOOPBACK_IP, 1)
|
|
finally:
|
|
clear_active_context()
|
|
|
|
assert isinstance(public_err, PolicyDeniedError), (
|
|
f"armed STRICT did not block public socket: {public_err!r}"
|
|
)
|
|
assert public_err.decision.reason == "strict_public_host"
|
|
assert not isinstance(loopback_err, PolicyDeniedError), (
|
|
f"armed STRICT wrongly blocked loopback: {loopback_err!r}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PUBLIC_ONLY / BOTH: do NOT arm the socket-level hook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_public_only_armed_does_not_arm_socket_hook():
|
|
"""PUBLIC_ONLY governs ENGINE selection, not which hosts the process may
|
|
reach at the socket level (local Ollama / settings DB use private IPs). The
|
|
hook must therefore stay a no-op for ALL host classes under PUBLIC_ONLY —
|
|
otherwise legitimate local services would be false-positived. Contrast with
|
|
the PRIVATE_ONLY tripwire above, which DOES raise on the public probe.
|
|
"""
|
|
ctx = _make_ctx(EgressScope.PUBLIC_ONLY)
|
|
set_active_context(ctx)
|
|
try:
|
|
results = {
|
|
host: _attempt_connect(host, 1)
|
|
for host in (_PUBLIC_IP, "192.168.42.1", _LOOPBACK_IP)
|
|
}
|
|
finally:
|
|
clear_active_context()
|
|
|
|
for host, err in results.items():
|
|
assert not isinstance(err, PolicyDeniedError), (
|
|
f"PUBLIC_ONLY wrongly armed the hook for {host}: {err!r}"
|
|
)
|
|
|
|
|
|
def test_both_armed_does_not_arm_socket_hook():
|
|
"""BOTH is the permissive default — the audit hook has nothing to enforce,
|
|
so a public connect must pass through. This is the deny-side contrast to the
|
|
PRIVATE_ONLY public probe: same address, opposite outcome, proving the hook
|
|
keys off the armed scope and not the address alone.
|
|
"""
|
|
ctx = _make_ctx(EgressScope.BOTH)
|
|
set_active_context(ctx)
|
|
try:
|
|
err = _attempt_connect(_PUBLIC_IP, 53)
|
|
finally:
|
|
clear_active_context()
|
|
|
|
assert not isinstance(err, PolicyDeniedError), (
|
|
f"BOTH scope wrongly armed the hook: {err!r}"
|
|
)
|