feat(fips): read FIPS mode from general_settings.fips_mode instead of LITELLM_FIPS_MODE

This commit is contained in:
yucheng 2026-10-31 00:58:36 -07:00
parent c4728fc95e
commit 6569e2c132
7 changed files with 142 additions and 100 deletions

View file

@ -2507,6 +2507,13 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="local development only: start even when master_key is unset, empty, or a publicly known default",
)
fips_mode: bool | None = Field(
None,
description=(
"refuse to start unless Python's and the cryptography library's OpenSSL enforce FIPS, and only start on "
"a new database or one set up in FIPS mode"
),
)
coordination_redis: CoordinationRedisParams | None = Field(
None,
description=(

View file

@ -1,19 +1,28 @@
import os
from collections.abc import Mapping
from typing import Final
from litellm._logging import verbose_proxy_logger
from litellm.repositories.fips_repository import FIPS_MARKER, FipsRepository
from litellm.secret_managers.main import str_to_bool
FIPS_MODE_ENV_VAR: Final = "LITELLM_FIPS_MODE"
FIPS_MODE_SETTING: Final = "fips_mode"
_SETTING: Final = f"general_settings.{FIPS_MODE_SETTING}"
class FipsModeError(Exception):
pass
def is_fips_mode() -> bool:
return str_to_bool(os.getenv(FIPS_MODE_ENV_VAR)) is True
def _fips_mode_setting(general_settings: Mapping[str, object]) -> bool | None:
"""general_settings.fips_mode as a boolean: False when unset, None when it is not a boolean."""
value: Final = general_settings.get(FIPS_MODE_SETTING)
if value is None or isinstance(value, bool):
return value is True
return str_to_bool(value) if isinstance(value, str) else None
def is_fips_mode(general_settings: Mapping[str, object]) -> bool:
return _fips_mode_setting(general_settings) is True
def openssl_enforces_fips() -> bool:
@ -49,31 +58,34 @@ def cryptography_enforces_fips() -> bool:
return True
def fips_mode_refusal() -> str | None:
"""Why the proxy must not start: LITELLM_FIPS_MODE is not a boolean, or is on but Python's or the
def fips_mode_refusal(general_settings: Mapping[str, object]) -> str | None:
"""Why the proxy must not start: general_settings.fips_mode is not a boolean, or is on but Python's or the
cryptography library's OpenSSL does not enforce FIPS. None when it may start."""
value: Final = os.getenv(FIPS_MODE_ENV_VAR)
if value and str_to_bool(value) is None:
return f"{FIPS_MODE_ENV_VAR}={value!r} is not a boolean. Set it to true or false, or unset it."
if not is_fips_mode():
setting: Final = _fips_mode_setting(general_settings)
if setting is None:
return (
f"{_SETTING}={general_settings.get(FIPS_MODE_SETTING)!r} is not a boolean. Set it to true or false, "
"or remove it."
)
if not setting:
return None
if not openssl_enforces_fips():
return (
f"{FIPS_MODE_ENV_VAR} is on but OpenSSL does not enforce FIPS. Run the proxy on an OpenSSL with "
f"its FIPS provider active and default_properties = fips=yes, or unset {FIPS_MODE_ENV_VAR}."
f"{_SETTING} is on but OpenSSL does not enforce FIPS. Run the proxy on an OpenSSL with its FIPS "
f"provider active and default_properties = fips=yes, or turn {_SETTING} off."
)
if not cryptography_enforces_fips():
return (
f"{FIPS_MODE_ENV_VAR} is on but the cryptography library's OpenSSL has no working FIPS provider. Build it "
"against the system OpenSSL (pip install --no-binary cryptography cryptography), or unset "
f"{FIPS_MODE_ENV_VAR}."
f"{_SETTING} is on but the cryptography library's OpenSSL has no working FIPS provider. Build it "
"against the system OpenSSL (pip install --no-binary cryptography cryptography), or turn "
f"{_SETTING} off."
)
return None
def enforce_fips_mode() -> None:
def enforce_fips_mode(general_settings: Mapping[str, object]) -> None:
"""Raise FipsModeError with the fips_mode_refusal reason, if there is one."""
refusal: Final = fips_mode_refusal()
refusal: Final = fips_mode_refusal(general_settings)
if refusal is not None:
raise FipsModeError(refusal)
@ -86,40 +98,42 @@ async def _is_marked_fips(database: FipsRepository) -> bool:
return False
async def fips_database_refusal(database: FipsRepository | None, *, database_is_configured: bool) -> str | None:
"""Why the proxy must not start on its database: LITELLM_FIPS_MODE is off on a database set up in FIPS mode, or
on over a database that can't be checked or already holds rows written without it. None when it may start."""
if not is_fips_mode():
async def fips_database_refusal(
database: FipsRepository | None, *, fips_mode: bool, database_is_configured: bool
) -> str | None:
"""Why the proxy must not start on its database: FIPS mode is off on a database set up in FIPS mode, or on over
a database that can't be checked or already holds rows written without it. None when it may start."""
if not fips_mode:
if database is not None and await _is_marked_fips(database):
return (
f"This database was set up with {FIPS_MODE_ENV_VAR}=true, so the proxy only starts on it in FIPS mode. "
f"This database was set up with {_SETTING}: true, so the proxy only starts on it in FIPS mode. "
f"To stop using FIPS mode on it for good, run "
f"DELETE FROM \"LiteLLM_Config\" WHERE param_name = '{FIPS_MARKER}'."
)
return None
if database is None:
return (
f"{FIPS_MODE_ENV_VAR} is on but the database can't be reached to check it."
if database_is_configured
else None
)
return f"{_SETTING} is on but the database can't be reached to check it." if database_is_configured else None
try:
state: Final = await database.state()
except Exception as error: # noqa: BLE001 # a value, so FIPS mode refuses a database it can't check
return f"{FIPS_MODE_ENV_VAR} is on but the database could not be checked: {error}"
return f"{_SETTING} is on but the database could not be checked: {error}"
if state.holds_rows and not state.marked_fips:
return (
f"{FIPS_MODE_ENV_VAR} is on but this database already holds data written without it. "
f"{_SETTING} is on but this database already holds data written without it. "
"FIPS mode only starts on a new, empty database."
)
return None
async def enforce_fips_database(database: FipsRepository | None, *, database_is_configured: bool) -> None:
async def enforce_fips_database(
database: FipsRepository | None, *, fips_mode: bool, database_is_configured: bool
) -> None:
"""Raise FipsModeError with the fips_database_refusal reason, if there is one, then mark the database as set up
in FIPS mode when LITELLM_FIPS_MODE is on."""
refusal: Final = await fips_database_refusal(database, database_is_configured=database_is_configured)
in FIPS mode when FIPS mode is on."""
refusal: Final = await fips_database_refusal(
database, fips_mode=fips_mode, database_is_configured=database_is_configured
)
if refusal is not None:
raise FipsModeError(refusal)
if database is not None and is_fips_mode():
if database is not None and fips_mode:
await database.mark_fips()

View file

@ -499,7 +499,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
encrypt_value_helper,
)
from litellm.proxy.common_utils.error_body_call_id import JSON_OBJECT, error_body_call_id, with_call_id
from litellm.proxy.common_utils.fips import enforce_fips_database, enforce_fips_mode
from litellm.proxy.common_utils.fips import enforce_fips_database, enforce_fips_mode, is_fips_mode
from litellm.proxy.common_utils.healthy_model_filter import (
get_hidden_unhealthy_model_names,
is_healthy_only_listing_default,
@ -1607,7 +1607,7 @@ async def _proxy_lifespan(app: FastAPI, resources: AsyncExitStack) -> AsyncGener
if decoded_worker_config is not None:
await initialize_from_worker_config(decoded_worker_config)
enforce_fips_mode()
enforce_fips_mode(general_settings_view())
async with AsyncExitStack() as boot_database:
enforce_master_key_boot_verdict(
@ -1643,6 +1643,7 @@ async def _proxy_lifespan(app: FastAPI, resources: AsyncExitStack) -> AsyncGener
await enforce_fips_database(
None if prisma_client is None else FipsRepository(prisma_client),
fips_mode=is_fips_mode(general_settings_view()),
database_is_configured=get_secret("DATABASE_URL", None) is not None,
)

View file

@ -10,7 +10,7 @@ from tests.integration._support.database import scratch_database, write_rows
from tests.integration._support.process import refused_boot_log
def _config(directory: Path, upstream_url: str) -> Path:
def _config(directory: Path, upstream_url: str, fips_mode: object) -> Path:
config: Final = directory / "fips_boot.yaml"
config.write_text(
json.dumps(
@ -21,6 +21,7 @@ def _config(directory: Path, upstream_url: str) -> Path:
"litellm_params": {"model": "openai/fips-model", "api_base": upstream_url, "api_key": "sk-x"},
}
],
"general_settings": {"fips_mode": fips_mode},
}
)
)
@ -30,17 +31,13 @@ def _config(directory: Path, upstream_url: str) -> Path:
def test_fips_mode_refuses_to_start_when_openssl_does_not_enforce_fips(gateway: Gateway, tmp_path: Path) -> None:
if _hashlib.get_fips_mode() == 1:
pytest.skip("this runner's OpenSSL is in FIPS mode")
log: Final = refused_boot_log(
gateway, tmp_path, {"LITELLM_FIPS_MODE": "true"}, config=_config(tmp_path, gateway.upstream_url)
)
assert "LITELLM_FIPS_MODE is on but OpenSSL does not enforce FIPS" in log, log
log: Final = refused_boot_log(gateway, tmp_path, {}, config=_config(tmp_path, gateway.upstream_url, True))
assert "general_settings.fips_mode is on but OpenSSL does not enforce FIPS" in log, log
def test_fips_mode_refuses_to_start_on_a_value_that_is_not_a_boolean(gateway: Gateway, tmp_path: Path) -> None:
log: Final = refused_boot_log(
gateway, tmp_path, {"LITELLM_FIPS_MODE": "enforced"}, config=_config(tmp_path, gateway.upstream_url)
)
assert "LITELLM_FIPS_MODE='enforced' is not a boolean" in log, log
log: Final = refused_boot_log(gateway, tmp_path, {}, config=_config(tmp_path, gateway.upstream_url, "enforced"))
assert "general_settings.fips_mode='enforced' is not a boolean" in log, log
def test_proxy_refuses_to_start_without_fips_mode_on_a_database_set_up_in_fips_mode(
@ -61,7 +58,7 @@ def test_proxy_refuses_to_start_without_fips_mode_on_a_database_set_up_in_fips_m
log: Final = refused_boot_log(
gateway,
tmp_path,
{"LITELLM_FIPS_MODE": "false", "DATABASE_URL": database_url},
config=_config(tmp_path, gateway.upstream_url),
{"DATABASE_URL": database_url},
config=_config(tmp_path, gateway.upstream_url, False),
)
assert "This database was set up with LITELLM_FIPS_MODE=true" in log, log
assert "This database was set up with general_settings.fips_mode: true" in log, log

View file

@ -30,14 +30,14 @@ def _marker_rows(database_url: str) -> int:
@pytest.mark.parametrize(
"fips_mode, setup, refusal, marked_after",
[
("true", (_CONFIG_TABLE, _KEY_TABLE), None, 1),
("true", (_CONFIG_TABLE, _KEY_TABLE, _MARKER, _KEY), None, 1),
("true", (_CONFIG_TABLE, _KEY_TABLE, _KEY), "already holds data written without it", 0),
("true", (_CONFIG_TABLE, _KEY_TABLE, _SETTING), "already holds data written without it", 0),
("true", (), "could not be checked", None),
("false", (_CONFIG_TABLE, _KEY_TABLE, _MARKER), "set up with LITELLM_FIPS_MODE=true", 1),
("false", (_CONFIG_TABLE, _KEY_TABLE, _KEY), None, 0),
("false", (), None, None),
(True, (_CONFIG_TABLE, _KEY_TABLE), None, 1),
(True, (_CONFIG_TABLE, _KEY_TABLE, _MARKER, _KEY), None, 1),
(True, (_CONFIG_TABLE, _KEY_TABLE, _KEY), "already holds data written without it", 0),
(True, (_CONFIG_TABLE, _KEY_TABLE, _SETTING), "already holds data written without it", 0),
(True, (), "could not be checked", None),
(False, (_CONFIG_TABLE, _KEY_TABLE, _MARKER), "set up with general_settings.fips_mode: true", 1),
(False, (_CONFIG_TABLE, _KEY_TABLE, _KEY), None, 0),
(False, (), None, None),
],
ids=[
"fips-on-new-database",
@ -52,12 +52,11 @@ def _marker_rows(database_url: str) -> int:
)
async def test_fips_database_check(
monkeypatch: pytest.MonkeyPatch,
fips_mode: str,
fips_mode: bool,
setup: tuple[LiteralString, ...],
refusal: str | None,
marked_after: int | None,
) -> None:
monkeypatch.setenv("LITELLM_FIPS_MODE", fips_mode)
with scratch_database() as database_url:
for statement in setup:
write_rows(statement, (), database_url=database_url)
@ -66,7 +65,7 @@ async def test_fips_database_check(
await client.connect()
try:
with pytest.raises(FipsModeError, match=refusal) if refusal else nullcontext():
await enforce_fips_database(FipsRepository(client), database_is_configured=True)
await enforce_fips_database(FipsRepository(client), fips_mode=fips_mode, database_is_configured=True)
finally:
await client.aclose()
if marked_after is not None:
@ -77,7 +76,6 @@ async def test_fips_database_check(
async def test_pods_starting_together_in_fips_mode_on_a_new_database_both_start(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("LITELLM_FIPS_MODE", "true")
with scratch_database() as database_url:
for statement in (_CONFIG_TABLE, _KEY_TABLE):
write_rows(statement, (), database_url=database_url)
@ -86,7 +84,10 @@ async def test_pods_starting_together_in_fips_mode_on_a_new_database_both_start(
await asyncio.gather(*(pod.connect() for pod in pods))
try:
await asyncio.gather(
*(enforce_fips_database(FipsRepository(pod), database_is_configured=True) for pod in pods)
*(
enforce_fips_database(FipsRepository(pod), fips_mode=True, database_is_configured=True)
for pod in pods
)
)
finally:
await asyncio.gather(*(pod.aclose() for pod in pods))

View file

@ -32,13 +32,19 @@ def _fake_openssl(monkeypatch: pytest.MonkeyPatch, *, fips_flag: int, md5_refuse
monkeypatch.setattr(_hashlib, "new", new)
@pytest.mark.parametrize("value, expected", [("true", True), (" TRUE ", True), ("false", False), (None, False)])
def test_is_fips_mode(monkeypatch: pytest.MonkeyPatch, value: str | None, expected: bool) -> None:
if value is None:
monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False)
else:
monkeypatch.setenv("LITELLM_FIPS_MODE", value)
assert is_fips_mode() is expected
@pytest.mark.parametrize(
"general_settings, expected",
[
({"fips_mode": True}, True),
({"fips_mode": " TRUE "}, True),
({"fips_mode": False}, False),
({"fips_mode": "false"}, False),
({"fips_mode": None}, False),
({}, False),
],
)
def test_is_fips_mode(general_settings: Mapping[str, object], expected: bool) -> None:
assert is_fips_mode(general_settings) is expected
@pytest.mark.parametrize(
@ -90,34 +96,40 @@ def test_this_runners_cryptography_openssl_does_not_enforce_fips() -> None:
@pytest.mark.parametrize(
"fips_mode, openssl_fips, cryptography_fips, refusal",
"general_settings, openssl_fips, cryptography_fips, refusal",
[
(None, False, False, None),
("", False, False, None),
("false", False, False, None),
("true", True, True, None),
("true", False, True, "is on but OpenSSL does not enforce FIPS"),
("true", True, False, "cryptography library's OpenSSL has no working FIPS provider"),
("enforced", True, True, "LITELLM_FIPS_MODE='enforced' is not a boolean"),
({}, False, False, None),
({"fips_mode": False}, False, False, None),
({"fips_mode": True}, True, True, None),
({"fips_mode": "true"}, True, True, None),
({"fips_mode": True}, False, True, "general_settings.fips_mode is on but OpenSSL does not enforce FIPS"),
({"fips_mode": True}, True, False, "cryptography library's OpenSSL has no working FIPS provider"),
({"fips_mode": "enforced"}, True, True, "general_settings.fips_mode='enforced' is not a boolean"),
({"fips_mode": 1}, True, True, "general_settings.fips_mode=1 is not a boolean"),
],
ids=[
"unset",
"false",
"fips",
"fips-from-a-string",
"openssl-not-fips",
"cryptography-not-fips",
"not-a-boolean",
"a-number",
],
ids=["unset", "empty", "false", "fips", "openssl-not-fips", "cryptography-not-fips", "not-a-boolean"],
)
def test_enforce_fips_mode(
monkeypatch: pytest.MonkeyPatch,
fips_mode: str | None,
general_settings: Mapping[str, object],
openssl_fips: bool,
cryptography_fips: bool,
refusal: str | None,
) -> None:
if fips_mode is None:
monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False)
else:
monkeypatch.setenv("LITELLM_FIPS_MODE", fips_mode)
_fake_openssl(monkeypatch, fips_flag=int(openssl_fips), md5_refused=openssl_fips)
monkeypatch.setattr(rust_openssl, "is_fips_enabled", lambda: cryptography_fips)
with pytest.raises(FipsModeError, match=refusal) if refusal else nullcontext():
enforce_fips_mode()
enforce_fips_mode(general_settings)
class _Database:
@ -151,13 +163,13 @@ class _Prisma:
@pytest.mark.parametrize(
"fips_mode, database, refusal, marked_after",
[
("true", _Database(marked_fips=False, holds_rows=False), None, True),
("true", _Database(marked_fips=True, holds_rows=True), None, True),
("true", _Database(marked_fips=False, holds_rows=True), "already holds data written without it", False),
("true", _Database(marked_fips=False, holds_rows=False, readable=False), "could not be checked", False),
("false", _Database(marked_fips=True, holds_rows=True), "set up with LITELLM_FIPS_MODE=true", True),
("false", _Database(marked_fips=False, holds_rows=True), None, False),
("false", _Database(marked_fips=True, holds_rows=True, readable=False), None, True),
(True, _Database(marked_fips=False, holds_rows=False), None, True),
(True, _Database(marked_fips=True, holds_rows=True), None, True),
(True, _Database(marked_fips=False, holds_rows=True), "already holds data written without it", False),
(True, _Database(marked_fips=False, holds_rows=False, readable=False), "could not be checked", False),
(False, _Database(marked_fips=True, holds_rows=True), "set up with general_settings.fips_mode: true", True),
(False, _Database(marked_fips=False, holds_rows=True), None, False),
(False, _Database(marked_fips=True, holds_rows=True, readable=False), None, True),
],
ids=[
"fips-on-new-database",
@ -170,12 +182,10 @@ class _Prisma:
],
)
async def test_enforce_fips_database(
monkeypatch: pytest.MonkeyPatch, fips_mode: str, database: _Database, refusal: str | None, marked_after: bool
fips_mode: bool, database: _Database, refusal: str | None, marked_after: bool
) -> None:
monkeypatch.setenv("LITELLM_FIPS_MODE", fips_mode)
with pytest.raises(FipsModeError, match=refusal) if refusal else nullcontext():
await enforce_fips_database(FipsRepository(_Prisma(database)), database_is_configured=True)
await enforce_fips_database(FipsRepository(_Prisma(database)), fips_mode=fips_mode, database_is_configured=True)
assert database.marked_fips is marked_after
@ -183,13 +193,11 @@ async def test_enforce_fips_database(
@pytest.mark.asyncio
@pytest.mark.parametrize(
"fips_mode, database_is_configured, refusal",
[("true", True, "can't be reached"), ("true", False, None), ("false", True, None)],
[(True, True, "can't be reached"), (True, False, None), (False, True, None)],
ids=["fips-on-unreachable", "fips-on-no-database", "fips-off-unreachable"],
)
async def test_enforce_fips_database_without_a_connected_database(
monkeypatch: pytest.MonkeyPatch, fips_mode: str, database_is_configured: bool, refusal: str | None
fips_mode: bool, database_is_configured: bool, refusal: str | None
) -> None:
monkeypatch.setenv("LITELLM_FIPS_MODE", fips_mode)
with pytest.raises(FipsModeError, match=refusal) if refusal else nullcontext():
await enforce_fips_database(None, database_is_configured=database_is_configured)
await enforce_fips_database(None, fips_mode=fips_mode, database_is_configured=database_is_configured)

View file

@ -1814,10 +1814,24 @@ async def test_proxy_startup_refuses_a_database_set_up_in_fips_mode_when_fips_mo
from litellm.proxy.proxy_server import proxy_startup_event
_boot_with_general_settings(monkeypatch, tmp_path, {"master_key": "sk-a-safe-master-key"})
monkeypatch.delenv("LITELLM_FIPS_MODE", raising=False)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", _PrismaClientOnAFipsDatabase())
with pytest.raises(FipsModeError, match="set up with LITELLM_FIPS_MODE=true"):
with pytest.raises(FipsModeError, match=r"set up with general_settings\.fips_mode: true"):
async with proxy_startup_event(FastAPI()):
pass
@pytest.mark.asyncio
async def test_proxy_startup_reads_fips_mode_from_the_config_file(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
from litellm.proxy.common_utils.fips import FipsModeError
from litellm.proxy.proxy_server import proxy_startup_event
_boot_with_general_settings(monkeypatch, tmp_path, {"master_key": "sk-a-safe-master-key", "fips_mode": True})
monkeypatch.setattr("litellm.proxy.common_utils.fips.openssl_enforces_fips", lambda: False)
with pytest.raises(FipsModeError, match=r"general_settings\.fips_mode is on but OpenSSL does not enforce FIPS"):
async with proxy_startup_event(FastAPI()):
pass