From 6569e2c132c64a11026a75075889a3ecb6edf398 Mon Sep 17 00:00:00 2001 From: yucheng Date: Sat, 31 Oct 2026 00:58:36 -0700 Subject: [PATCH] feat(fips): read FIPS mode from general_settings.fips_mode instead of LITELLM_FIPS_MODE --- litellm/proxy/_types.py | 7 ++ litellm/proxy/common_utils/fips.py | 80 ++++++++++-------- litellm/proxy/proxy_server.py | 5 +- .../configuration/test_fips_mode_boot.py | 21 ++--- .../database/test_fips_database.py | 27 +++--- tests/unit/proxy/common_utils/test_fips.py | 84 ++++++++++--------- ...test_proxy_server_endpoints_and_startup.py | 18 +++- 7 files changed, 142 insertions(+), 100 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 07588053959..191209f03a0 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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=( diff --git a/litellm/proxy/common_utils/fips.py b/litellm/proxy/common_utils/fips.py index 8c70b7d7825..9f895bf1a1d 100644 --- a/litellm/proxy/common_utils/fips.py +++ b/litellm/proxy/common_utils/fips.py @@ -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() diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index d02707cb55d..7bf5e07e4e6 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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, ) diff --git a/tests/integration/configuration/test_fips_mode_boot.py b/tests/integration/configuration/test_fips_mode_boot.py index 11e1f8fc088..b4637043800 100644 --- a/tests/integration/configuration/test_fips_mode_boot.py +++ b/tests/integration/configuration/test_fips_mode_boot.py @@ -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 diff --git a/tests/integration/database/test_fips_database.py b/tests/integration/database/test_fips_database.py index 54238bc6876..36074e48719 100644 --- a/tests/integration/database/test_fips_database.py +++ b/tests/integration/database/test_fips_database.py @@ -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)) diff --git a/tests/unit/proxy/common_utils/test_fips.py b/tests/unit/proxy/common_utils/test_fips.py index a48ba3138e2..cccf261fd5c 100644 --- a/tests/unit/proxy/common_utils/test_fips.py +++ b/tests/unit/proxy/common_utils/test_fips.py @@ -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) diff --git a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py index 99e78ae6385..98658dbf731 100644 --- a/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py +++ b/tests/unit/proxy/test_proxy_server_endpoints_and_startup.py @@ -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