litellm/tests/integration/security/_canary.py
yucheng-berri 3572d359a1
test(integration): stored-config credential canary slots (#43309)
* test(integration): credential canary suite harness

Adds tests/integration/security with canary generation and search, sweeps over the database, GET routes, client responses, sink doubles and Redis, an owned proxy rig, a sweep sensitivity self-test and the config deployment api_key slot. Registers the security group in run.py, the manifest and the CircleCI integration matrix.

* test(integration): widen canary route sweep and harden the rig

Enumerate lazily registered feature routers, call parameterized routes with placeholder ids, fail on routes that return no response, skip provider pass-through routes, add an explicit admin-only route allowance, let the sink double use a configurable token, inflate gzip members anywhere in a blob, sweep Redis before the route walk, and trap outbound connections from the owned proxy.

* test(integration): descend into any decoded value that can still hold an encoded canary

* test(integration): bound canary decoding by depth and decoded bytes

* test(integration): scope log-table and spend-log reads to the scenario window

* test(integration): sweep spend-log rows in the scenario date window

* test(integration): keep spend-log date window summarized

* test(integration): stored-config credential canary slots

Add canary slots for credentials the proxy holds in its env, config or
database: virtual key raw value, master key, deployment api_key via
/model/new, named credentials, AWS secret key, Vertex service-account
JSON and its minted token, team model_config credential overrides,
config guardrail api_key, and sink credentials from env.

* test(integration): resolve the config guardrail id and require detail routes to return 200

* test(integration): drop repeated timeout comments

* test(integration): resolve deployment ids, scope paginated log lists, key allowances by slot

* test(integration): expect 404 from the caller-scoped team membership route

* test(integration): use the rig's own master key and expect 404 from submission lookups

* test(integration): check the overridden rig key without assuming the default key is unknown

* test(integration): sweep config-deployment routes with the real model id and use the rig admin for the master-key slot

* test(integration): mark the configure-hook config edits as intended

* test(integration): drop suppression markers that suppress nothing

* Use claude-haiku-4-5 for the Bedrock stored-credential test model
2026-09-28 17:23:30 -07:00

186 lines
8.3 KiB
Python

"""Canary values and the canary search used by every credential sweep.
A canary is a unique fake credential planted in one slot (one place the proxy can hold a
credential). Its value is ``<field prefix>lkc-<slot id>-<32 lowercase hex core>``; the slot id
names the source when a sweep finds it, and the random core is what every sweep searches for.
API:
- ``SLOTS``: slot id -> ``Slot(identity, description, prefix)``. Stacked suites add their slots
here. ``MARKER`` is not a credential; it is the sensitivity marker sent in message content to
prove that a sweep can see the surface it walks.
- ``canary(slot_id) -> Canary``: a fresh value per call. Call it inside the test (or the fixture
that owns the config holding it), never at import time, so leftovers from earlier runs cannot
match.
- ``find_canary(blob, canaries, *, budget_bytes=DECODE_BUDGET_BYTES) -> tuple[Match, ...]``:
every canary whose core occurs in ``blob`` either raw, inside any base64-looking run after
decoding it (standard and URL-safe alphabets, padded or not, at every 4-character alignment),
or inside a gzip member wherever it starts in the blob. Decoding is applied recursively, so a
gzip body carrying a ``Basic`` header value is still searched. JSON and URL encoding leave a
hex core unchanged, so the raw search covers them. A properly masked value such as
``sk-...e71b`` is not a match. The search is bounded (three nested layers and ``budget_bytes``
of decoded output per blob) and raises ``DecodeBudgetExceeded`` rather than returning a
partial result.
"""
from __future__ import annotations
import binascii
import re
import uuid
import zlib
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
_BASE64_RUN: Final = re.compile(rb"[A-Za-z0-9+/_-]{24,}={0,2}")
_GZIP_MAGIC: Final = b"\x1f\x8b"
_TO_STANDARD: Final = bytes.maketrans(b"-_", b"+/")
_MAX_DEPTH: Final = 3
DECODE_BUDGET_BYTES: Final = 512 * 1024 * 1024
class DecodeBudgetExceeded(AssertionError):
"""A blob needs more decoded bytes than the search budget; the sweep cannot vouch for it."""
@dataclass(slots=True)
class _Budget:
remaining: int
def spend(self, size: int) -> None:
self.remaining -= size
if self.remaining < 0:
raise DecodeBudgetExceeded("find_canary needed more decoded bytes than its budget for one blob")
@dataclass(frozen=True, slots=True)
class Slot:
identity: str
description: str
prefix: str = ""
@dataclass(frozen=True, slots=True)
class Canary:
slot: str
core: str
value: str
@dataclass(frozen=True, slots=True)
class Match:
slot: str
encoding: str
MARKER: Final = "M0"
SLOTS: Final = MappingProxyType(
{
MARKER: Slot(MARKER, "Sensitivity marker in message content; must appear where prompts are stored"),
"A1": Slot("A1", "Virtual key raw value, set as a custom key through /key/generate", prefix="sk-"),
"A2": Slot("A2", "Proxy master key from the LITELLM_MASTER_KEY environment variable", prefix="sk-"),
"B1": Slot("B1", "Deployment api_key declared in the proxy config.yaml model_list"),
"B2": Slot("B2", "Deployment api_key added through /model/new and stored encrypted"),
"B3": Slot("B3", "Credentials table api_key referenced by a deployment's litellm_credential_name"),
"B4": Slot("B4", "Deployment aws_secret_access_key added through /model/new"),
"B4v": Slot("B4v", "Vertex service-account JSON added through /model/new, traced by its private_key_id"),
"B4t": Slot("B4t", "Vertex access token the token endpoint mints for that service account"),
"B5": Slot("B5", "Credentials table api_key applied by a team model_config credential override"),
"E1": Slot("E1", "Guardrail api_key declared in the proxy config.yaml guardrails"),
"G1": Slot("G1", "generic_api sink bearer token from the GENERIC_LOGGER_HEADERS environment variable"),
"G1b": Slot("G1b", "Langfuse sink secret key from the LANGFUSE_SECRET_KEY environment variable"),
"F1": Slot("F1", "MCP server static auth_value registered through /v1/mcp/server"),
"F2": Slot("F2", "Per-user MCP OAuth access token from the authorization-code flow"),
"F2E": Slot("F2E", "Per-user MCP env var value stored through /v1/mcp/server/{server_id}/user-env-vars"),
"F3": Slot("F3", "Client x-mcp-<server>-authorization request header"),
"H1": Slot("H1", "Pass-through endpoint credential header resolved from os.environ"),
"H2": Slot("H2", "Vector store api_key declared in the proxy config.yaml vector_store_registry"),
"H2S": Slot("H2S", "Search tool api_key declared in the proxy config.yaml search_tools"),
}
)
def canary(slot_id: str) -> Canary:
slot: Final = SLOTS[slot_id]
core: Final = uuid.uuid4().hex
return Canary(slot_id, core, f"{slot.prefix}lkc-{slot_id}-{core}")
def _decoded_runs(blob: bytes) -> Iterable[tuple[str, bytes]]:
for text in dict.fromkeys(run.group().rstrip(b"=") for run in _BASE64_RUN.finditer(blob)):
for offset in range(4):
aligned = text[offset:]
aligned = aligned[: len(aligned) - len(aligned) % 4] if len(aligned) % 4 == 1 else aligned
padded = aligned + b"=" * (-len(aligned) % 4)
alphabets = (("base64", b"+/"), ("base64url", b"-_"))
for name, extra in alphabets if any(char in aligned for char in b"+/-_") else alphabets[:1]:
try:
yield (
name,
binascii.a2b_base64(
padded.translate(_TO_STANDARD) if extra == b"-_" else padded, strict_mode=False
),
)
except (binascii.Error, ValueError):
continue
def _gunzipped(blob: bytes, budget: _Budget) -> Iterable[bytes]:
"""Inflate every gzip member in ``blob``, wherever it starts, ignoring trailing bytes."""
start = blob.find(_GZIP_MAGIC)
while start != -1:
inflater = zlib.decompressobj(16 + zlib.MAX_WBITS)
try:
inflated = inflater.decompress(blob[start:], budget.remaining + 1)
except zlib.error:
inflated = b""
budget.spend(len(inflated))
if inflated:
yield inflated
start = blob.find(_GZIP_MAGIC, start + 1)
def _matches(blob: bytes, canaries: Sequence[Canary], encoding: str, depth: int, budget: _Budget) -> Iterable[Match]:
lowered: Final = blob.lower()
for candidate in canaries:
if candidate.core.encode() in lowered:
yield Match(candidate.slot, encoding)
if depth >= _MAX_DEPTH:
return
for inflated in _gunzipped(blob, budget):
yield from _matches(inflated, canaries, f"{encoding}>gzip" if encoding != "raw" else "gzip", depth + 1, budget)
for name, decoded in _decoded_runs(blob):
budget.spend(len(decoded))
label = f"{encoding}>{name}" if encoding != "raw" else name
if _worth_descending(decoded):
yield from _matches(decoded, canaries, label, depth + 1, budget)
else:
lowered_decoded = decoded.lower()
yield from (Match(c.slot, label) for c in canaries if c.core.encode() in lowered_decoded)
def _worth_descending(decoded: bytes) -> bool:
"""Recursion can only find something through a gzip member or another base64 run.
Skipping the rest is exact, not a heuristic: the core check has already run on ``decoded``.
"""
return _GZIP_MAGIC in decoded or _BASE64_RUN.search(decoded) is not None
def find_canary(
blob: bytes | str, canaries: Sequence[Canary], *, budget_bytes: int = DECODE_BUDGET_BYTES
) -> tuple[Match, ...]:
"""Every canary found in ``blob``, one ``Match`` per slot with the shallowest encoding seen.
Decoding is bounded: at most ``_MAX_DEPTH`` nested layers and ``DECODE_BUDGET_BYTES`` decoded or
inflated bytes per call (``budget_bytes``). Exceeding the byte budget raises ``DecodeBudgetExceeded`` (an
``AssertionError``) instead of returning a partial, possibly clean, result.
"""
data: Final = blob.encode() if isinstance(blob, str) else blob
found: Final[dict[str, Match]] = {} # mutable-ok: first (shallowest) encoding per slot wins
for match in _matches(data, canaries, "raw", 0, _Budget(budget_bytes)):
found.setdefault(match.slot, match)
return tuple(found.values())