SkillTotal

Is litellm safe?

No malicious indicators - review capabilities before installing
Notable — review in context (capabilities are not malware):
  • 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

python_package · pypi:litellm
LOW
10
/ 100 risk score
Snapshot · scanned Aug 19, 2026 · litellm@1.97.0 · engine 0.41.0 / ruleset 45

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.

Capabilities — what this component can do (not a risk score):
delegated authenticationdynamic code executionfilesystem readfilesystem writemcp tools detectednetwork egressscoped identityshell execution

Behavioral traits

How this component maps to the CSA agentic threat model. Descriptive — it never affects the risk score.

Tool surface
Tool Usage
Execution authority
Tool Access Control / Direct Tool Access
Filesystem reach
Tool Execution Context
Network egress
Interaction & Communication / Direct Communication
Network exposure
Interaction & Communication
Delegated authentication
Tool Execution Context / User Delegated Credentials
Scoped / least-privilege identity
Tool Execution Context / Least-Privilege Service Identity

Findings (11)

HIGHPython dynamic code executionST-DYN-PY

The code turns strings into live code at runtime (eval / new Function / exec).

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.

HIGHNode.js shell/command executionST-SHELL-NODE

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.

HIGHPython shell/command executionST-SHELL-PY

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.

MEDIUMServer bound to all network interfacesST-EXPOSE-BIND

A server is bound to all network interfaces (0.0.0.0), not just your own machine.

- 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.

MEDIUMPython filesystem readST-FS-PY-READ

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:
content: Final = file_path.read_text(encoding="utf-8")
content: Final = file_path.read_text(encoding="utf-8")
with open(str(file_content_obj), "rb") 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(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(self.auth_file, "r") 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:

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.

MEDIUMPython filesystem write/deleteST-FS-PY-WRITE

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(cert_path, "w") as f:
with open(key_path, "w") as f:
SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n")
with open(resolved_settings_path, "w") as f:
with open(CLAUDE_SETTINGS_PATH, "w") as f:
with open(path, "w", encoding="utf-8") as handle:
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.

MEDIUMNode.js network egressST-NET-NODE

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.

MEDIUMPython network egressST-NET-PY

The component makes outbound network requests.

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"),
response: Final = httpx.get(url, timeout=timeout)
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.

LOWDelegated authentication (OAuth 2.0 / OIDC)ST-AUTH-DELEGATED

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)
if not all(key in data for key in ("access_token", "refresh_token", "id_token")):
id_token: Final = data.get("id_token")
if not access_token or not id_token:
id_token: Final = tokens.get("id_token")
account_id: Final = self._extract_account_id(id_token or access_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.

LOWScoped / least-privilege identityST-AUTH-SCOPED

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(
# 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.

LOWMCP tool surface detectedST-MCP-DETECTED

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 component

How we determine this: deterministic static analysis (regex + AST), evidence-anchored, no code execution. Methodology →