mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
* test(integration): wait for customer spend rows and use owned OpenRouter cost * ci: isolate integration workers and clean up resources after crashes * docs: describe isolated extensions workers and shard census * fix(ci): restore integration service and fixture guarantees * fix(integration): preserve replay IDs and native route fixtures * fix(tests): scope integration harness imports to each unit case * fix(ci): preserve fixtures and wait for owned workers * fix(ci): isolate nested runs and telemetry cases * fix(ci): make worker readiness and fault tests deterministic * chore(ci): drop MCP fixture repair already covered by main * chore(ci): keep the local catalog fix from main --------- Co-authored-by: yuneng <yuneng@berri.ai>
314 lines
15 KiB
Python
314 lines
15 KiB
Python
"""Sensitivity controls: every sweep must find a marker where prompts are legitimately stored.
|
|
|
|
A sweep that cannot see its surface would pass every credential slot vacuously. Each test here
|
|
sends a fresh marker in message content with ``store_prompts_in_spend_logs`` on and requires
|
|
each sweep to report it at the place it belongs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import gzip
|
|
import json
|
|
import uuid
|
|
from collections import Counter
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
from integration._support.client import Gateway, eventually, string_value
|
|
from integration._support.wire import Reply, Request, wire_server
|
|
from integration.security._canary import DECODE_BUDGET_BYTES, MARKER, SLOTS, DecodeBudgetExceeded, canary, find_canary
|
|
from integration.security._sinks import CONFIG_MODEL, GENERIC_SINK, Rig, canary_rig, chat_upstream, settle, team_caller
|
|
from integration.security._sweeps import (
|
|
ADMIN_ONLY_ALLOWANCES,
|
|
ALLOWANCE_SLOT_FAMILIES,
|
|
PROVIDER_PASSTHROUGH_REASON,
|
|
assert_marker_seen,
|
|
get_routes,
|
|
record_route_sweep,
|
|
route_allowance,
|
|
route_denied,
|
|
scoped_queries,
|
|
sweep_all,
|
|
sweep_redis,
|
|
sweep_routes,
|
|
sweep_sink,
|
|
)
|
|
from redis import Redis
|
|
|
|
from tests.integration._support.database import read_rows, write_rows
|
|
from tests.integration._support.redis_process import owned_redis
|
|
from tests.integration._support.tls import server_context, write_self_signed_cert
|
|
from tests.integration._support.workers import worker_services
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
|
|
with canary_rig(tmp_path_factory.mktemp("canary-sensitivity")) as value:
|
|
yield value
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", ["", "u:", "us:", "use:"], ids=["align0", "align1", "align2", "align3"])
|
|
def test_find_canary_decodes_base64_at_every_alignment_and_gzip(prefix: str) -> None:
|
|
marker: Final = canary(MARKER)
|
|
basic: Final = base64.b64encode(f"{prefix}{marker.value}".encode()).decode()
|
|
urlsafe: Final = base64.urlsafe_b64encode(f"{prefix}{marker.value}".encode()).decode().rstrip("=")
|
|
assert [match.slot for match in find_canary(f"Authorization: Basic {basic}", (marker,))] == [MARKER]
|
|
assert [match.slot for match in find_canary(f'{{"token":"{urlsafe}"}}', (marker,))] == [MARKER]
|
|
assert [match.slot for match in find_canary(gzip.compress(f"Basic {basic}".encode()), (marker,))] == [MARKER]
|
|
embedded: Final = b"prefix:" + gzip.compress(f"Basic {basic}".encode()) + b":suffix"
|
|
assert [match.slot for match in find_canary(embedded, (marker,))] == [MARKER]
|
|
members: Final = gzip.compress(b"first member") + gzip.compress(f"Basic {basic}".encode())
|
|
assert [match.slot for match in find_canary(members, (marker,))] == [MARKER]
|
|
binary_wrapper: Final = bytes(range(256)) + f" Basic {basic} ".encode() + bytes(range(256))
|
|
assert [match.slot for match in find_canary(base64.b64encode(binary_wrapper), (marker,))] == [MARKER]
|
|
assert find_canary(f"Basic {basic}".replace(basic[10:20], "A" * 10), (marker,)) == ()
|
|
assert find_canary(f"sk-...{marker.core[-4:]}", (marker,)) == ()
|
|
|
|
|
|
def test_find_canary_fails_loudly_past_its_decode_budget() -> None:
|
|
marker: Final = canary(MARKER)
|
|
bomb: Final = gzip.compress(b"\0" * (1024 * 1024 + 1))
|
|
with pytest.raises(DecodeBudgetExceeded):
|
|
find_canary(bomb, (marker,), budget_bytes=1024 * 1024)
|
|
assert find_canary(gzip.compress(b"\0" * 1024) + marker.value.encode(), (marker,), budget_bytes=1024 * 1024)
|
|
assert DECODE_BUDGET_BYTES >= 256 * 1024 * 1024
|
|
|
|
|
|
def test_rig_with_an_overridden_master_key_resolves_the_config_deployment(tmp_path: Path) -> None:
|
|
master_key: Final = f"sk-canary-override-{uuid.uuid4().hex}"
|
|
with canary_rig(tmp_path, environment={"LITELLM_MASTER_KEY": master_key}) as overridden:
|
|
assert overridden.proxy.key == master_key
|
|
assert overridden.model_id
|
|
assert overridden.proxy.request("GET", "/model/info").status_code == 200
|
|
assert overridden.proxy.request("GET", "/public/whats_new").status_code == 200
|
|
assert overridden.egress() == ()
|
|
|
|
|
|
def _skills_upstream(request: Request) -> Reply:
|
|
skill: Final = {
|
|
"id": "owned-skill",
|
|
"display_title": "Owned skill",
|
|
"source": "custom",
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"updated_at": "2026-01-01T00:00:00Z",
|
|
}
|
|
if request.target.startswith("/v1/skills/"):
|
|
return Reply(body=json.dumps(skill).encode())
|
|
if request.target.startswith("/v1/skills"):
|
|
return Reply(body=json.dumps({"data": [skill], "has_more": False}).encode())
|
|
return chat_upstream(request)
|
|
|
|
|
|
def test_skills_reads_use_the_owned_provider_despite_ambient_credentials(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
monkeypatch.setenv("ANTHROPIC_API_BASE", "https://ambient-provider.invalid")
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://ambient-provider.invalid")
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "ambient-provider-key")
|
|
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "ambient-provider-token")
|
|
with canary_rig(tmp_path, upstream=_skills_upstream) as owned:
|
|
for path in ("/v1/skills", "/v1/skills/owned-skill"):
|
|
response: Final = owned.proxy.request("GET", path)
|
|
assert response.status_code == 200, response.text
|
|
requests: Final = owned.provider.requests()
|
|
assert tuple(request.target.split("?", 1)[0] for request in requests) == (
|
|
"/v1/skills",
|
|
"/v1/skills/owned-skill",
|
|
)
|
|
assert all(request.headers.get("x-api-key") == owned.canaries["B1"].value for request in requests)
|
|
assert all("authorization" not in request.headers for request in requests)
|
|
assert owned.egress() == ()
|
|
|
|
|
|
def test_route_allowances_match_only_their_exact_route_and_caller() -> None:
|
|
routes: Final = get_routes()
|
|
callers: Final = ("admin", "internal_user", "Admin", "admin ", "")
|
|
for route, caller in ADMIN_ONLY_ALLOWANCES:
|
|
assert route in routes, f"Allowance names a route the proxy no longer registers: {route}"
|
|
for variant in (route + "/", route.upper(), route.rstrip("s"), "/v1" + route):
|
|
assert route_allowance(variant, caller) is None, variant
|
|
allowed: Final = {(route, caller) for route in routes for caller in callers if route_allowance(route, caller)}
|
|
assert allowed == set(ADMIN_ONLY_ALLOWANCES), allowed
|
|
assert all(route_denied(route) is None for route, _ in ADMIN_ONLY_ALLOWANCES)
|
|
assert set(ALLOWANCE_SLOT_FAMILIES) == set(ADMIN_ONLY_ALLOWANCES)
|
|
for (route, caller), families in ALLOWANCE_SLOT_FAMILIES.items():
|
|
for family in families:
|
|
assert route_allowance(route, caller, family + "1") is not None
|
|
for slot in SLOTS:
|
|
if not slot.startswith(families):
|
|
assert route_allowance(route, caller, slot) is None, (route, caller, slot)
|
|
|
|
|
|
def test_only_provider_passthrough_routes_match_the_passthrough_deny_rule() -> None:
|
|
denied: Final = {route for route in get_routes() if route_denied(route) == PROVIDER_PASSTHROUGH_REASON}
|
|
assert "/openai/{endpoint:path}" in denied and "/langfuse/{endpoint:path}" in denied
|
|
assert all(route.endswith("/{endpoint:path}") and route.count("{") == 1 for route in denied), denied
|
|
for swept in ("/v1/files/{file_id:path}", "/spend/logs/ui/{request_id}", "/v1/memory/{key:path}"):
|
|
assert route_denied(swept) is None, swept
|
|
|
|
|
|
def test_sink_own_header_allows_only_that_header_and_slot() -> None:
|
|
own: Final = canary("B1")
|
|
other: Final = canary(MARKER)
|
|
request: Final = Request(
|
|
"POST",
|
|
"/",
|
|
{"authorization": f"Bearer {own.value}", "x-extra": f"Bearer {own.value}", "x-other": other.value},
|
|
f'{{"copied": "{own.value}"}}'.encode(),
|
|
)
|
|
hits: Final = sweep_sink("double", (request,), (own, other), own_header=("authorization", own.slot))
|
|
assert {(hit.slot, hit.location) for hit in hits} == {
|
|
(MARKER, "double[0] POST / header x-other"),
|
|
("B1", "double[0] POST / body"),
|
|
("B1", "double[0] POST / header x-extra"),
|
|
}
|
|
|
|
|
|
@pytest.mark.timeout(240) # full S1/S2 walk: every table and ~400 GET routes as two callers
|
|
def test_every_sweep_finds_the_stored_prompt_marker(rig: Rig, request: pytest.FixtureRequest) -> None:
|
|
marker: Final = canary(MARKER)
|
|
started: Final = datetime.now(UTC)
|
|
with rig.proxy.scenario() as scenario:
|
|
caller: Final = team_caller(scenario)
|
|
response: Final = rig.proxy.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{"model": CONFIG_MODEL, "messages": [{"role": "user", "content": f"sensitivity {marker.value}"}]},
|
|
key=caller.key,
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert len(rig.provider.carrying(marker.value)) == 1
|
|
request_id: Final = string_value(response.json()["id"])
|
|
settle(rig, request_id, marker)
|
|
eventually(lambda: sweep_redis((marker,)), bool, seconds=10)
|
|
|
|
ids: Final = {
|
|
"request_id": request_id,
|
|
"team_id": caller.team_id,
|
|
"user_id": caller.user_id,
|
|
"model_id": rig.model_id,
|
|
"model": CONFIG_MODEL,
|
|
}
|
|
report: Final = sweep_all(
|
|
rig.proxy,
|
|
(marker,),
|
|
responses=(response,),
|
|
sinks={name: sink.requests() for name, sink in rig.sinks.items()},
|
|
ids=ids,
|
|
callers=caller.callers(rig),
|
|
own_headers=rig.own_headers,
|
|
since=started,
|
|
)
|
|
record_route_sweep(report.routes, request.node.nodeid)
|
|
assert_marker_seen(
|
|
report,
|
|
{
|
|
"S1": "LiteLLM_SpendLogs.proxy_server_request",
|
|
"S2": f"GET /spend/logs/ui/{request_id} as admin -> 200",
|
|
"S3": "response[0] POST /v1/chat/completions -> 200 body",
|
|
"S4": f"{GENERIC_SINK}[",
|
|
"S5": "redis value",
|
|
},
|
|
)
|
|
assert_marker_seen(report, {"S2": f"GET /spend/logs?request_id={request_id} as admin -> 200"})
|
|
assert_marker_seen(report, {"S2": f"GET /spend/logs?user_id={caller.user_id} as admin -> 200"})
|
|
assert_marker_seen(report, {"S2": f"GET /spend/logs/ui/{request_id} as internal_user -> 200"})
|
|
for route in ("/spend/logs/ui", "/spend/logs/v2"):
|
|
filtered = tuple(query for query in scoped_queries(route, ids, started) if "_id=" in query)
|
|
assert len(filtered) == 2, filtered
|
|
for query in filtered:
|
|
assert report.routes.statuses.get(f"GET {route}{query} as admin") == 200, (route, query)
|
|
listed = rig.proxy.request("GET", route + query)
|
|
assert request_id in listed.text, f"{route}{query} does not list the scenario's row"
|
|
assert report.credential_hits() == ()
|
|
|
|
|
|
def test_security_worker_services_isolate_identical_database_and_cache_keys(tmp_path: Path) -> None:
|
|
with (
|
|
worker_services(tmp_path / "first") as first,
|
|
worker_services(tmp_path / "second") as second,
|
|
):
|
|
for environment, marker in ((first, "first"), (second, "second")):
|
|
write_rows(
|
|
"CREATE TABLE IF NOT EXISTS isolation_probe (id text PRIMARY KEY, marker text NOT NULL)",
|
|
(),
|
|
database_url=environment["DATABASE_URL"],
|
|
)
|
|
write_rows(
|
|
"INSERT INTO isolation_probe VALUES (%s, %s) ON CONFLICT (id) DO UPDATE SET marker=EXCLUDED.marker",
|
|
("same-id", marker),
|
|
database_url=environment["DATABASE_URL"],
|
|
)
|
|
with Redis(host=environment["REDIS_HOST"], port=int(environment["REDIS_PORT"])) as cache:
|
|
cache.set("same-key", marker)
|
|
for environment, marker in ((first, "first"), (second, "second")):
|
|
assert read_rows(
|
|
"SELECT marker FROM isolation_probe WHERE id=%s",
|
|
("same-id",),
|
|
database_url=environment["DATABASE_URL"],
|
|
) == [{"marker": marker}]
|
|
with Redis(
|
|
host=environment["REDIS_HOST"], port=int(environment["REDIS_PORT"]), decode_responses=True
|
|
) as cache:
|
|
assert cache.get("same-key") == marker
|
|
|
|
|
|
def test_route_sweep_keeps_connections_auth_and_response_cookies_isolated() -> None:
|
|
callers: Final = {"admin": "sk-sweep-admin", "internal_user": "sk-sweep-user"}
|
|
with wire_server(_response_with_cookie, keep_alive=True) as peer:
|
|
with httpx.Client(base_url=peer.url, trust_env=False) as client:
|
|
report: Final = sweep_routes(Gateway(client, callers["admin"], peer.url), (), {}, callers=callers)
|
|
requests: Final = peer.drain()
|
|
requested: Final = tuple(called.split(" ", 1) for called in report.called)
|
|
expected: Final = Counter((path, f"Bearer {callers[caller]}") for caller, path in requested)
|
|
assert report.called
|
|
assert len(requests) == len(report.called)
|
|
assert Counter((request.target, request.headers["authorization"]) for request in requests) == expected
|
|
assert all("cookie" not in request.headers for request in requests)
|
|
assert peer.connections() == len(requests)
|
|
assert report.errors == report.unreachable == ()
|
|
|
|
|
|
def _response_with_cookie(_: Request) -> Reply:
|
|
return Reply(headers={"set-cookie": "session=another-caller; Path=/"})
|
|
|
|
|
|
def test_route_sweep_rejects_an_untrusted_https_peer(tmp_path: Path) -> None:
|
|
cert, key = write_self_signed_cert(tmp_path)
|
|
with wire_server(_response_with_cookie, tls=server_context(cert, key), keep_alive=True) as peer:
|
|
with httpx.Client(base_url=peer.url, trust_env=False) as client:
|
|
report: Final = sweep_routes(Gateway(client, "sk-sweep-admin", peer.url), (), {})
|
|
assert report.called
|
|
assert len(report.unreachable) == len(report.called)
|
|
assert peer.drain() == ()
|
|
|
|
|
|
def test_route_sweep_preserves_truncated_response_errors_separately(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
|
|
) -> None:
|
|
monkeypatch.setenv("INTEGRATION_RESULTS_DIR", str(tmp_path))
|
|
with (
|
|
owned_redis(tmp_path) as cache,
|
|
wire_server(lambda _: Reply(chunks=(b"first", b"missing"), abort_after=1)) as peer,
|
|
):
|
|
monkeypatch.setenv("REDIS_HOST", cache.host)
|
|
monkeypatch.setenv("REDIS_PORT", str(cache.port))
|
|
with (
|
|
httpx.Client(base_url=peer.url, trust_env=False) as client,
|
|
pytest.raises(AssertionError, match="GET routes returned no response"),
|
|
):
|
|
sweep_all(Gateway(client, "sk-sweep-admin", peer.url), (), responses=(), sinks={}, ids={})
|
|
received: Final = peer.drain()
|
|
|
|
saved: Final = json.loads((tmp_path / "security-route-sweep-failures.jsonl").read_text())
|
|
assert received
|
|
assert request.node.nodeid in saved["node"]
|
|
assert saved["called"] == len(received)
|
|
assert len(saved["unreachable"]) == len(received)
|
|
assert all(error.partition("RemoteProtocolError: ")[2] for error in saved["unreachable"])
|
|
assert not (tmp_path / "security-route-sweep.jsonl").exists()
|