mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
* fix(agents): redact secret litellm_params fields from all /v1/agents responses Secret-bearing litellm_params fields (aws_secret_access_key, api_key, and similar) are now write-only: list, get, create, update, and patch responses always replace them with a fixed marker, regardless of caller role. Editing an agent no longer requires resending a real credential -- an update that omits a sensitive field, or echoes the marker back, preserves the stored value; a real value still rotates it. * fix(agents): redact secrets nested inside dicts/lists in litellm_params too Greptile found that a secret nested one level down under a non-sensitively-named key, or inside a list of per-provider configs, was neither redacted on read nor restored symmetrically on write (the marker string could get persisted as the real value). Recurse into lists on the read side, and mirror that recursion on the write side so restoration isn't limited to top-level keys. Also fixes a regression the redact rewrite introduced (a plain string leaf like a model name was being misinterpreted as a JSON blob and redacted), and suppresses 3 new test-quality-gate findings on an established repo-wide mocking pattern this PR's new tests also use. * fix(agents): guard list-position credential restore against misassignment Two more real gaps Greptile/veria found in the recursive redact/restore mechanism, verified directly against the exact reported shape (litellm_params.model_list, each entry carrying its own nested litellm_params.api_key/aws_secret_access_key) before fixing: - Positional restoration inside a list could attach one entry's stored credential to a different entry if the list were reordered or resized between GET and PUT/PATCH. Restoration by index now only fires when the incoming and existing entries match on every non-secret field; otherwise the caller's own value is used (never a guessed cross-entry secret). - A subtree collapsed to the flat REDACTED_BY_LITELM marker by the read-side recursion depth cap couldn't be recovered on write (the marker string itself would get persisted). Restore now recognizes that shape and recovers the whole existing subtree. Both covered by regression tests mirroring the exact model_list shape reported, mutation-verified. * fix(agents): simplify list-entry credential restore to positional matching The content-match guard from the previous commit fixed one Greptile finding (cross-entry misassignment on reorder) but introduced a worse one: it also rejected restoration whenever an entry's own non-secret fields changed, which is the common case (rename a model_list entry while leaving its own secret masked) -- silently dropping the stored credential on an ordinary edit. There is no stable per-element identity in a plain dict[str, object] schema, so no rule can satisfy both 'restore whenever the entry itself only had its secret masked' and 'never restore across a reorder' at once. Positional correspondence is what every other part of this restore (and the endpoints' full-replace-on-PUT semantics) already assumes, so drop the content-match gate and rely on it here too: this fixes the common case correctly and accepts cross-entry misassignment on a simultaneous reorder-plus-masked-echo as a known, narrow, documented limitation (not a leak between different agents or tenants, since it only reshuffles one agent's own stored values). Tests updated to pin the accepted trade-off explicitly rather than asserting it away, and to cover the previously broken ordinary-edit case.
152 lines
8.1 KiB
Python
152 lines
8.1 KiB
Python
import ast
|
|
import os
|
|
|
|
IGNORE_FUNCTIONS = [
|
|
"_format_type",
|
|
"_remove_additional_properties",
|
|
"_remove_strict_from_schema",
|
|
"filter_schema_fields",
|
|
"text_completion",
|
|
"_check_for_os_environ_vars",
|
|
"clean_message",
|
|
"unpack_defs",
|
|
"convert_anyof_null_to_nullable", # has a set max depth
|
|
"add_object_type",
|
|
"strip_field",
|
|
"_transform_prompt",
|
|
"mask_dict",
|
|
"_serialize", # we now set a max depth for this
|
|
"_sanitize_request_body_for_spend_logs_payload", # testing added for circular reference
|
|
"_sanitize_value", # testing added for circular reference
|
|
"set_schema_property_ordering", # testing added for infinite recursion
|
|
"process_items", # testing added for infinite recursion + max depth set.
|
|
"_can_object_call_model", # max depth set.
|
|
"encode_unserializable_types", # max depth set.
|
|
"filter_value_from_dict", # max depth set.
|
|
"normalize_json_schema_types", # max depth set.
|
|
"_extract_fields_recursive", # max depth set.
|
|
"_remove_json_schema_refs", # max depth set.,
|
|
"_convert_schema_types", # max depth set.,
|
|
"_fix_enum_empty_strings", # max depth set.,
|
|
"get_access_token", # max depth set.,
|
|
"_redact_base64", # max depth set.
|
|
"_contains_vision_content", # max depth set.
|
|
"_read_all_bytes", # max depth set.
|
|
"_fix_enum_types", # max depth set.
|
|
"_collect_argument_paths", # max depth set.
|
|
"_split_text", # max depth set.
|
|
"_mask_sequence", # max depth set.
|
|
"_walk_payload", # max depth set (DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER).
|
|
"_delete_nested_value_custom", # max depth set (bounded by number of path segments).
|
|
"filter_exceptions_from_params", # max depth set (default 20) to prevent infinite recursion.
|
|
"__getattr__", # lazy loading pattern in litellm/__init__.py with proper caching to prevent infinite recursion.
|
|
"_validate_inheritance_chain", # max depth set (default 100) to prevent infinite recursion in policy inheritance validation.
|
|
"_basic_json_schema_validate", # max depth set.
|
|
"extract_text_from_a2a_message", # max depth set (default 10) to prevent infinite recursion in A2A message parsing.
|
|
"_convert_to_json_serializable_dict", # max depth set (default 20) and circular reference protection to prevent infinite recursion.
|
|
"dict", # max depth set. _LiteLLMParamsDictView.dict() calls builtin dict(), not itself.
|
|
"_read_image_bytes", # max depth set.
|
|
"_get_masked_values", # max depth set (default 20) to prevent infinite recursion while masking nested sensitive config dicts.
|
|
"_redact_sensitive_litellm_params", # max depth set (default 10).
|
|
"_redact_secret_values_in_obj", # max depth set (default 10, _REDACT_SECRET_MAX_DEPTH); fails closed by returning "REDACTED" at the cap.
|
|
"_resolve", # OCI: $ref resolver bounded by `resolving_stack` cycle guard.
|
|
"resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input).
|
|
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
|
|
"_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap.
|
|
"apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap.
|
|
"_filter_argument_value", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by blocking the tool call at the cap.
|
|
"_redact_scanned_content", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by returning "[REDACTED]" at the cap.
|
|
"_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap.
|
|
"_mergeable_branch", # max depth set (_MAX_SCHEMA_FLATTEN_DEPTH=32) plus a seen_refs cycle guard; passes the schema through untouched at the cap.
|
|
"json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
|
|
"with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
|
|
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
|
|
"_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
|
|
"_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
|
|
"_json_safe", # max depth set (_MAX_DEPTH) plus a seen-ids cycle guard for self-referential input.
|
|
"_redact_agent_params_tree", # max depth set (default 10), same shape as _redact_sensitive_litellm_params.
|
|
"_restore_redacted_nested_value", # max depth set (default 10), mirrors _redact_agent_params_tree on the write side.
|
|
]
|
|
|
|
|
|
class RecursiveFunctionFinder(ast.NodeVisitor):
|
|
def __init__(self):
|
|
self.recursive_functions = []
|
|
self.ignored_recursive_functions = []
|
|
|
|
def visit_FunctionDef(self, node):
|
|
# Check if the function calls itself
|
|
if any(self._is_recursive_call(node, call) for call in ast.walk(node)):
|
|
if node.name in IGNORE_FUNCTIONS:
|
|
self.ignored_recursive_functions.append(node.name)
|
|
else:
|
|
self.recursive_functions.append(node.name)
|
|
self.generic_visit(node)
|
|
|
|
def _is_recursive_call(self, func_node, call_node):
|
|
# Check if the call node is a function call
|
|
if not isinstance(call_node, ast.Call):
|
|
return False
|
|
|
|
# Case 1: Direct function call (e.g., my_func())
|
|
if isinstance(call_node.func, ast.Name) and call_node.func.id == func_node.name:
|
|
return True
|
|
|
|
# Case 2: Method call with self (e.g., self.my_func())
|
|
if isinstance(call_node.func, ast.Attribute) and isinstance(
|
|
call_node.func.value, ast.Name
|
|
):
|
|
return (
|
|
call_node.func.value.id == "self"
|
|
and call_node.func.attr == func_node.name
|
|
)
|
|
|
|
return False
|
|
|
|
|
|
def find_recursive_functions_in_file(file_path):
|
|
with open(file_path, "r") as file:
|
|
tree = ast.parse(file.read(), filename=file_path)
|
|
finder = RecursiveFunctionFinder()
|
|
finder.visit(tree)
|
|
return finder.recursive_functions, finder.ignored_recursive_functions
|
|
|
|
|
|
def find_recursive_functions_in_directory(directory):
|
|
recursive_functions = {}
|
|
ignored_recursive_functions = {}
|
|
for root, _, files in os.walk(directory):
|
|
for file in files:
|
|
print("file: ", file)
|
|
if file.endswith(".py"):
|
|
file_path = os.path.join(root, file)
|
|
functions, ignored = find_recursive_functions_in_file(file_path)
|
|
if functions:
|
|
recursive_functions[file_path] = functions
|
|
if ignored:
|
|
ignored_recursive_functions[file_path] = ignored
|
|
return recursive_functions, ignored_recursive_functions
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Example usage
|
|
# raise exception if any recursive functions are found, except for the ignored ones
|
|
# this is used in the CI/CD pipeline to prevent recursive functions from being merged
|
|
|
|
directory_path = "./litellm"
|
|
recursive_functions, ignored_recursive_functions = (
|
|
find_recursive_functions_in_directory(directory_path)
|
|
)
|
|
print("UNIGNORED RECURSIVE FUNCTIONS: ", recursive_functions)
|
|
print("IGNORED RECURSIVE FUNCTIONS: ", ignored_recursive_functions)
|
|
|
|
if len(recursive_functions) > 0:
|
|
# raise exception if any recursive functions are found
|
|
for file, functions in recursive_functions.items():
|
|
print(
|
|
f"🚨 Unignored recursive functions found in {file}: {functions}. THIS IS REALLY BAD, it has caused CPU Usage spikes in the past. Only keep this if it's ABSOLUTELY necessary."
|
|
)
|
|
file, functions = list(recursive_functions.items())[0]
|
|
raise Exception(
|
|
f"🚨 Unignored recursive functions found include {file}: {functions}. THIS IS REALLY BAD, it has caused CPU Usage spikes in the past. Only keep this if it's ABSOLUTELY necessary."
|
|
)
|