Is litellm safe?
- Python shell/command execution
- Python dynamic code execution
- Node.js shell/command execution
litellm is an AI python_package analyzed by SkillTotal's deterministic static scanner. The scan found no malicious indicators, though 11 risky constructs are reported for review. It can: delegated authentication, dynamic code execution, filesystem read, filesystem write, mcp tools detected, network egress, scoped identity and shell execution — capabilities are what the code can do, not a verdict on intent. Risk score 10/100 (low).
litellm 1.97.0
Automated static-analysis result. It can contain false positives and false negatives, and is not a claim about the intent of litellm's authors. Report a false positive.
Behavioral traits
How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.
Findings (11)
The code turns strings into live code at runtime (eval / new Function / exec).
exec(compiled, exec_globals) # noqa: S102
exec(compiled, exec_globals) # noqa: S102
Why it matters: If those strings aren't fixed and trusted, they become a way to run arbitrary code.
Fix: Avoid evaluating dynamically constructed code; if unavoidable, ensure the input is a trusted constant and never derived from external data.
The component can run operating-system commands or spawn processes.
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,66899,e=>{"use strict";var t=e.i(843476),s=e.i(271645),r=e.i(107233),a=e.i(569074),l=e.i(602869),n=e.i(332102);e.i(707701);var o …main();`}})())},[i,d,p,e,r,a]),(0,t.jsxs)(t.Fragment,{children:[(0,t.jsx)(F.Button,{variant:"secondary",icon:eo.CodeOutlined,onClick:()=>{c(!0)},children:"Get Code"}),(0,t.jsxs)(Z.Modal,{title:"Generated Code",open:i,onCancel:()=>{c(!1)},fo …}`,eb=({visible:e,initialJson:r,onSave:a,onClose:l})=>{let[n,o]=(0,s.useState)(r||ev),[i,c]=(0,s.useState)(null),d=()=>{c(null),l()};return(0,t.jsx)(Z.Modal,{title:(0,t.jsx)("div",{className:"flex items-center justify-between",children:(0,t …`}),(0,t.jsx)(eO,{value:e,onChange:e=>r(e.target.value),placeholder:a,rows:l,className:"font-sans"}),p.length>0&&(0,t.jsxs)("div",{className:"mt-2 flex flex-wrap gap-2 items-center",children:[(0,t.jsx)("span",{className:"text-xs text-gray-5 …${t}`}],339019)},123287,e=>{"use strict";let t=(0,e.i(475254).default)("circle-check",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);e.s(["default",0,t])},595468,e=>{"use strict";var t=e.i(1232 …Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.
Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; prefer execFile with an argument array.
The component can run operating-system commands or spawn processes.
subprocess.check_call([sys.executable, "-m", "pip", "install", "supabase"])
subprocess.check_call([sys.executable, "-m", "pip", "install", "sentry_sdk"])
subprocess.check_call([sys.executable, "-m", "pip", "install", "slack_bolt"])
return subprocess.Popen(
[
sys.executable,
"-m",
"litellm.proxy.proxy_cli",
"--config",
str(config_path),
"--port", …result: Final = subprocess.run(
[
"prisma",
"migrate",
"diff",
"--from-url",
db_url,
"--to-schema-datamodel",
"./sch …subprocess.run(
[
"prisma",
"db",
"push",
"--accept-data-loss",
"--skip-gene …result: Final = subprocess.run(["prisma", "generate"], capture_output=True, text=True)
subprocess.Popen(command, stdout=devnull, stderr=devnull)
subprocess.run(["prisma"], capture_output=True)
subprocess.Popen(command, stdout=devnull, stderr=devnull)
Why it matters: Powerful and often legitimate — confirm the commands aren't built from untrusted input.
Fix: Confirm the command and its arguments are fully controlled and not derived from untrusted input; avoid shell=True.
A server is bound to all network interfaces (0.0.0.0), not just your own machine.
"0.0.0.0",
- if litellm.force_ipv4 is True, it will return AsyncHTTPTransport with local_address="0.0.0.0"
"local_addr": ("0.0.0.0", 0) if litellm.force_ipv4 else None,- If force_ipv4 is True, it will create an AsyncHTTPTransport with local_address set to "0.0.0.0"
return AsyncHTTPTransport(local_address="0.0.0.0")
return HTTPTransport(local_address="0.0.0.0")
@click.option("--host", default="0.0.0.0", help="Host for the server to listen on.", envvar="HOST")Why it matters: Without authentication, other hosts on the network can reach it.
Fix: Bind to 127.0.0.1 for local-only use, or require authentication and restrict access if remote exposure is intended.
The component reads files from disk.
files("litellm").joinpath("anthropic_beta_headers_config.json").read_text(encoding="utf-8")with open("user_cost.json", "r") as json_file:with open(config_path) as f:
content: Final = file_path.read_text(encoding="utf-8")
content: Final = file_path.read_text(encoding="utf-8")
with open(json_path, "r") as f:
with open(file_path, "rb") as f:
with open(str(content), "rb") as f:
with open(str(file_content_obj), "rb") as f:
return open(file_path, "rb")
with open(str(file), "rb") as f:
with open(token_file, "r") as f:
content: Final = json.loads(files("litellm").joinpath("blog_posts.json").read_text(encoding="utf-8"))files("litellm").joinpath("model_prices_and_context_window_backup.json").read_text(encoding="utf-8")with open(file_content, "rb") as f:
with open(web_identity_token_file, "r") as f:
with open(str(file_content), "rb") as f:
with open(str(file_content), "rb") as f:
with open(image, "rb") as f:
with open(self.auth_file, "r") as f:
with open(config_path) as f:
with open(self.access_token_file, "r") as f:
with open(self.api_key_file, "r") as f:
with open(self.api_key_file, "r") as f:
with open(file_path, "r") as file:
Why it matters: Usually legitimate, but worth confirming it can't be steered into reading sensitive files.
Fix: Confirm which files are read and that paths cannot be influenced by untrusted input to reach sensitive locations.
The component writes or deletes files on disk.
with open("user_cost.json", "w") as json_file:with open(self.auth_file, "w") as f:
with open(self.access_token_file, "w") as f:
with open(self.api_key_file, "w") as f:
with open(local_path, "wb") as f:
os.unlink(local_requirements_path)
os.unlink(tmp_path)
os.unlink(tmp_path)
with open(cert_path, "w") as f:
with open(key_path, "w") as f:
os.unlink(tmp_file)
SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n")
os.remove(token_file)
with open(log_path, "w") as log_file:
with open(resolved_path, "w") as f:
with open(filename, "w") as f:
with open(resolved_settings_path, "w") as f:
with open(CLAUDE_SETTINGS_PATH, "w") as f:
with open(local_file_path, "w") as f:
with open(local_file_path, "w") as f:
with open(path, "w", encoding="utf-8") as handle:
os.remove(filepath)
temp_file_path.write_bytes(file_content)
with open(os.devnull, "w") as devnull:
Why it matters: Usually legitimate, but worth confirming the paths can't be controlled by untrusted input.
Fix: Confirm which files are written/deleted and that paths cannot be influenced by untrusted input.
The component makes outbound network requests.
`;t.document.write(a),t.document.close(),t.onload=()=>{t.print()}})(e),a(!1)},children:[(0,t.jsx)(ev.FilePdfOutlined,{className:"mr-3 text-red-500"}),"Export as PDF"]}),(0,t.jsxs)("button",{className:"flex items-center w-full px-4 py-2 text …}'`}),(0,t.jsx)(a.Text,{className:"text-xs text-gray-600 mt-3 mb-2",children:"Look for these headers in the response:"}),(0,t.jsxs)("div",{className:"space-y-1.5",children:[(0,t.jsxs)("div",{className:"flex items-start gap-3",children:[(0,t …`}),(0,t.jsx)(eO,{value:e,onChange:e=>r(e.target.value),placeholder:a,rows:l,className:"font-sans"}),p.length>0&&(0,t.jsxs)("div",{className:"mt-2 flex flex-wrap gap-2 items-center",children:[(0,t.jsx)("span",{className:"text-xs text-gray-5 …(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,233525,(e,t,r)=>{"use strict";Object.defineProperty(r,"__esModule",{value:!0}),Object.defineProperty(r,"warnOnce",{enumerable:!0 …(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,793479,e=>{"use strict";var t=e.i(843476),a=e.i(271645),r=e.i(115504);let s=a.forwardRef(({className:e,type:a,...s},l)=>(0,t.jsx …(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,{otherChunks:["static/chunks/37v5ulr6qjozi.js","static/chunks/3z-oetkpttgun.js","static/chunks/0gme6v-5y3nzk.js","static/chunks/ …Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.
Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.
The component makes outbound network requests.
import httpx
import httpx
self.response = response or httpx.Response(
status_code=self.status_code,
request=httpx.Request(method="POST", url="https://litellm.ai"),
)request=httpx.Request(method="POST", url="https://litellm.ai"),
import httpx
response: Final = httpx.get(url, timeout=timeout)
import httpx
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="delete_assistant", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="delete_assistant", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
response=httpx.Response(
status_code=400,
content="Unsupported provider",
request=httpx.Request(method="create_thread", url="https://github.com/BerriAI/litellm"),
),Why it matters: Usually legitimate, but confirm the destinations are expected and no sensitive data leaves.
Fix: Confirm the destination hosts are expected and that no sensitive data is sent off-host.
An OAuth 2.0 / OpenID Connect delegated-authentication flow was detected (authorization-code / refresh-token / token-exchange grant, an OIDC authorize/discovery endpoint or id_token, or a delegation library). Tools authenticate with the end user's delegated, scoped credentials rather than a long-lived embedded service credential. (25 occurrence(s) shown as evidence).
id_token: Final = auth_data.get("id_token")derived: Final = self._extract_account_id(id_token or access_token)
"grant_type=authorization_code"
if not all(key in data for key in ("access_token", "refresh_token", "id_token")):"id_token": data["id_token"],
"grant_type": "refresh_token",
id_token: Final = data.get("id_token")if not access_token or not id_token:
"id_token": id_token,
id_token: Final = tokens.get("id_token")account_id: Final = self._extract_account_id(id_token or access_token)
"id_token": id_token,
XAI_OAUTH_DISCOVERY_URL: Final = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"authorization_endpoint=discovery["authorization_endpoint"],
"grant_type": "authorization_code",
authorization_endpoint: Final = data.get("authorization_endpoint")if not authorization_endpoint or not token_endpoint:
"authorization_endpoint": self._validate_xai_endpoint(authorization_endpoint),
authorization_endpoint: str,
return f"{authorization_endpoint}?{urlencode(params)}""id_token": token_payload.get("id_token"),"grant_type": "refresh_token",
GET /v1/mcp/oauth/authorize — Shows HTML form to collect the API key
POST /v1/mcp/oauth/authorize — Stores temp auth code and redirects
"authorization_endpoint": f"{base_url}/v1/mcp/oauth/authorize",Fix: Delegated auth is a lower-blast-radius execution context than an embedded static credential. Confirm the requested scopes are minimal and that tokens are never logged or forwarded off-host.
A short-lived, scoped, assumed identity was detected — an STS AssumeRole / session token, a cloud managed or workload identity, an impersonated service account, a projected Kubernetes service-account token, or a dynamic-secret broker. Tools authenticate with a narrowly-scoped credential that expires, rather than a long-lived embedded service credential. (25 occurrence(s) shown as evidence).
DefaultAzureCredential,
ManagedIdentityCredential,
return ManagedIdentityCredential(client_id=_client_id)
return DefaultAzureCredential()
from azure.identity import DefaultAzureCredential
DefaultAzureCredential as AsyncDefaultAzureCredential,
credential=DefaultAzureCredential(),
"""Call STS AssumeRole and return temporary credentials."""
sts_response: Final = sts_client.assume_role(
6. From DefaultAzureCredential
# Try to get token provider from service principal or DefaultAzureCredential
"Using Azure AD token provider based on Service Principal with Secret workflow or DefaultAzureCredential for Azure Auth"
# try to get DefaultAzureCredential provider
Try to get DefaultAzureCredential provider
Token provider callable if DefaultAzureCredential is enabled and available, None otherwise
verbose_logger.debug("Attempting to use DefaultAzureCredential for Azure Auth")azure_credential=AzureCredentialType.DefaultAzureCredential,
verbose_logger.debug("Successfully obtained Azure AD token provider using DefaultAzureCredential")verbose_logger.debug("DefaultAzureCredential failed: %s", e)# ``_get_default_ttl_for_boto3_credentials`` (~59 minutes); AssumeRole STS credentials expire with
``_auth_with_env_vars`` (including when skipping AssumeRole because the runtime identity
non-refreshable ``Credentials`` cached ~59 min, inside the 3600s STS session), and AssumeRole
# iam_cache: static keys, ambient env (including skip-AssumeRole path), web identity, and
# AssumeRole. Do not cache profile / explicit session-token paths here.
we can skip sts:AssumeRole and use ambient credentials directly.
Fix: A scoped, short-lived identity is the smallest-blast-radius execution context. Confirm the assumed role / requested scope grants only the permissions the tool needs, and that the token lifetime is minimal.
An MCP tool surface (manifest or tool definitions) was found.
Why it matters: Just context — review which tools it offers and their permissions.
Fix: Review the declared MCP tools and their permissions.
Check your own component
Run the same evidence-backed scan on any MCP server, agent skill, or package.
Scan your own componentHow we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →