litellm/tests/integration/security/test_sweep_sensitivity.py
yuneng-jiang 9b53ee878f
perf(ci): isolate and shard integration suites (#44146)
* perf(ci): distribute unit cases and reuse security sweep connections

* revert(ci): reject first speed trial after new failures

* perf(ci): avoid repeated test discovery and parsing

* perf(ci): isolate parallel security workers

* revert(ci): restore measured security execution isolation

* perf(tests): reuse source scans and deduplicate cache cleanup

* perf(ci): restore parallel security with safe log batching

* test(ci): isolate fixture state and schema comparisons

* test(ci): make schema and scheduler fixtures deterministic

* test(ci): drain queued logging before fixture teardown

* test(ci): preserve logging delivery without idle worker restarts

* test(ci): keep guardrail sinks available during parallel bursts

* test(ci): isolate full proxy lifecycle from gateway imports

* test(ci): preserve first failure responses and sweep evidence

* test(ci): restore documentation URL settings between cases

* test(ci): isolate open MCP tools in private gateways

* test(ci): group private MCP cases and handle exited workers

* test(ci): retain proxy exits and MCP failure details

* test(ci): repair merge interactions with main

Key the schema shard's full clone on artifact-name, since main removed the
unit-flag input. Give each owned Redis its own log file again. Hand the
realtime transcription tests main added a logging object whose dispatch
handlers are awaitable, so the per-test logging drain no longer waits on a
MagicMock task forever

* perf(ci): shard owned integration cases and close lifecycle leaks

* fix(ci): qualify shard execution and harden interrupted cleanup

* fix(ci): bound worker supervision and own fault traffic

* perf(ci): reuse unparametrized fixtures across bounded case groups

* fix(ci): preserve boot fault allowances and complete exit records

* test(ci): tolerate startup retries and retain readiness details

* perf(ci): balance integration shards by recorded duration

* fix(ci): load migration database helpers only for workers
2026-10-10 11:15:19 -07:00

311 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 redis import Redis
from integration._support.client import Gateway, eventually, string_value
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._support.wire import Reply, Request, wire_server
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
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,
)
@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
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()