feat(llm): mask API keys in UI responses and prevent masked-key leakage in fetch/test/save payloads
- Add mask_api_key() and is_masked_or_placeholder() to llm_provider service - Return masked keys in all provider CRUD endpoints - Reject masked/placeholder keys in fetch_models and test_provider_config - Show masked key with Change button in ProviderConfig.svelte edit form - Exclude masked keys from fetch-models, test, and submit payloads on frontend - Update semantics-core skill with clarified complexity tier rules - Switch agent modes from subagent to all
This commit is contained in:
@@ -16,7 +16,7 @@ from ...core.logger import logger
|
||||
from ...dependencies import get_current_user as get_current_active_user
|
||||
from ...plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
||||
from ...schemas.auth import User
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ...services.llm_provider import LLMProviderService, is_masked_or_placeholder, mask_api_key
|
||||
|
||||
|
||||
# #region FetchModelsRequest [C:1] [TYPE Class]
|
||||
@@ -36,7 +36,7 @@ router = APIRouter(tags=["LLM"])
|
||||
# #endregion router
|
||||
|
||||
|
||||
# #region _is_valid_runtime_api_key [TYPE Function]
|
||||
# #region _is_valid_runtime_api_key [C:4] [TYPE Function]
|
||||
# @BRIEF Validate decrypted runtime API key presence/shape.
|
||||
# @PRE: value can be None.
|
||||
# @POST: Returns True only for non-placeholder key.
|
||||
@@ -53,7 +53,7 @@ def _is_valid_runtime_api_key(value: str | None) -> bool:
|
||||
# #endregion _is_valid_runtime_api_key
|
||||
|
||||
|
||||
# #region get_providers [TYPE Function]
|
||||
# #region get_providers [C:4] [TYPE Function]
|
||||
# @BRIEF Retrieve all LLM provider configurations.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns list of LLMProviderConfig.
|
||||
@@ -78,7 +78,7 @@ async def get_providers(
|
||||
provider_type=LLMProviderType(p.provider_type),
|
||||
name=p.name,
|
||||
base_url=p.base_url,
|
||||
api_key="********",
|
||||
api_key=mask_api_key(service.get_decrypted_api_key(p.id)) if p.api_key else "",
|
||||
default_model=p.default_model,
|
||||
is_active=p.is_active,
|
||||
)
|
||||
@@ -89,7 +89,7 @@ async def get_providers(
|
||||
# #endregion get_providers
|
||||
|
||||
|
||||
# #region fetch_models [TYPE Function]
|
||||
# #region fetch_models [C:4] [TYPE Function]
|
||||
# @BRIEF Fetch available models from an LLM provider by base_url+provider_type, or by provider_id.
|
||||
# @PRE: User is authenticated. Either provider_id or base_url+provider_type must be provided.
|
||||
# @POST: Returns a list of available model IDs.
|
||||
@@ -109,6 +109,11 @@ async def fetch_models(
|
||||
api_key = payload.api_key or ""
|
||||
provider_id = payload.provider_id or ""
|
||||
|
||||
# Treat masked/placeholder API keys as if no api_key was provided;
|
||||
# they are display-only and cannot be used for real API calls.
|
||||
if is_masked_or_placeholder(api_key):
|
||||
api_key = ""
|
||||
|
||||
# Early validation: at least one of api_key or provider_id is required
|
||||
if not api_key and not provider_id:
|
||||
raise HTTPException(
|
||||
@@ -163,7 +168,7 @@ async def fetch_models(
|
||||
# #endregion fetch_models
|
||||
|
||||
|
||||
# #region get_llm_status [TYPE Function]
|
||||
# #region get_llm_status [C:4] [TYPE Function]
|
||||
# @BRIEF Returns whether LLM runtime is configured for dashboard validation.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: configured=true only when an active provider with valid decrypted key exists.
|
||||
@@ -234,7 +239,7 @@ async def get_llm_status(
|
||||
# #endregion get_llm_status
|
||||
|
||||
|
||||
# #region create_provider [TYPE Function]
|
||||
# #region create_provider [C:4] [TYPE Function]
|
||||
# @BRIEF Create a new LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
# @POST: Returns the created LLMProviderConfig.
|
||||
@@ -258,7 +263,7 @@ async def create_provider(
|
||||
provider_type=LLMProviderType(provider.provider_type),
|
||||
name=provider.name,
|
||||
base_url=provider.base_url,
|
||||
api_key="********",
|
||||
api_key=mask_api_key(config.api_key),
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active,
|
||||
)
|
||||
@@ -267,7 +272,7 @@ async def create_provider(
|
||||
# #endregion create_provider
|
||||
|
||||
|
||||
# #region update_provider [TYPE Function]
|
||||
# #region update_provider [C:4] [TYPE Function]
|
||||
# @BRIEF Update an existing LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
# @POST: Returns the updated LLMProviderConfig.
|
||||
@@ -293,7 +298,7 @@ async def update_provider(
|
||||
provider_type=LLMProviderType(provider.provider_type),
|
||||
name=provider.name,
|
||||
base_url=provider.base_url,
|
||||
api_key="********",
|
||||
api_key=mask_api_key(service.get_decrypted_api_key(provider.id)) if provider.api_key else "",
|
||||
default_model=provider.default_model,
|
||||
is_active=provider.is_active,
|
||||
)
|
||||
@@ -302,7 +307,7 @@ async def update_provider(
|
||||
# #endregion update_provider
|
||||
|
||||
|
||||
# #region delete_provider [TYPE Function]
|
||||
# #region delete_provider [C:4] [TYPE Function]
|
||||
# @BRIEF Delete an LLM provider configuration.
|
||||
# @PRE: User is authenticated and has admin permissions.
|
||||
# @POST: Returns success status.
|
||||
@@ -325,7 +330,7 @@ async def delete_provider(
|
||||
# #endregion delete_provider
|
||||
|
||||
|
||||
# #region test_connection [TYPE Function]
|
||||
# #region test_connection [C:4] [TYPE Function]
|
||||
# @BRIEF Test connection to an LLM provider.
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns success status and message.
|
||||
@@ -380,7 +385,7 @@ async def test_connection(
|
||||
# #endregion test_connection
|
||||
|
||||
|
||||
# #region test_provider_config [TYPE Function]
|
||||
# #region test_provider_config [C:4] [TYPE Function]
|
||||
# @BRIEF Test connection with a provided configuration (not yet saved).
|
||||
# @PRE: User is authenticated.
|
||||
# @POST: Returns success status and message.
|
||||
@@ -400,8 +405,8 @@ async def test_provider_config(
|
||||
extra={"src": "llm_routes.test_provider_config"},
|
||||
)
|
||||
|
||||
# Check if API key is provided
|
||||
if not config.api_key or config.api_key == "********":
|
||||
# Check if API key is provided (reject empty, placeholder, or masked keys)
|
||||
if is_masked_or_placeholder(config.api_key):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="API key is required for testing connection"
|
||||
)
|
||||
|
||||
@@ -13,7 +13,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.llm import LLMProvider
|
||||
from src.plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
||||
from src.services.llm_provider import EncryptionManager, LLMProviderService
|
||||
from src.services.llm_provider import (
|
||||
EncryptionManager,
|
||||
LLMProviderService,
|
||||
is_masked_or_placeholder,
|
||||
mask_api_key,
|
||||
)
|
||||
|
||||
# region _test_encryption_key_fixture [TYPE Global]
|
||||
# @PURPOSE: Ensure encryption-dependent provider tests run with a valid Fernet key.
|
||||
@@ -210,3 +215,46 @@ def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db):
|
||||
|
||||
|
||||
# endregion test_update_provider_ignores_masked_placeholder_api_key
|
||||
|
||||
|
||||
# region test_mask_api_key [TYPE Function]
|
||||
# @RELATION: VERIFIES -> [mask_api_key]
|
||||
# @PURPOSE: Verify mask_api_key produces correct masked strings for various key lengths and edge cases.
|
||||
# @INVARIANT: mask_api_key never reveals more than 4 chars from any position.
|
||||
# @TEST_EDGE: None -> ""
|
||||
# @TEST_EDGE: "" -> ""
|
||||
# @TEST_EDGE: "abcd" -> "****"
|
||||
# @TEST_EDGE: "abcdef" -> "ab...ef"
|
||||
# @TEST_EDGE: "abcdefgh" -> "ab...gh"
|
||||
# @TEST_EDGE: "sk-test-key-1234abcd" -> "sk-t...abcd"
|
||||
def test_mask_api_key():
|
||||
assert mask_api_key(None) == ""
|
||||
assert mask_api_key("") == ""
|
||||
assert mask_api_key("abcd") == "****"
|
||||
assert mask_api_key("abcdef") == "ab...ef"
|
||||
assert mask_api_key("abcdefgh") == "ab...gh"
|
||||
assert mask_api_key("sk-test-key-1234abcd") == "sk-t...abcd"
|
||||
|
||||
|
||||
# endregion test_mask_api_key
|
||||
|
||||
|
||||
# region test_is_masked_or_placeholder [TYPE Function]
|
||||
# @RELATION: VERIFIES -> [is_masked_or_placeholder]
|
||||
# @PURPOSE: Verify predicate correctly identifies all forms of masked/placeholder API keys.
|
||||
# @INVARIANT: Real API keys (no "..." pattern) always return False.
|
||||
# @TEST_EDGE: None -> True
|
||||
# @TEST_EDGE: "" -> True
|
||||
# @TEST_EDGE: "********" -> True
|
||||
# @TEST_EDGE: "sk-...abcd" -> True
|
||||
# @TEST_EDGE: "...xyz" -> True
|
||||
# @TEST_EDGE: "sk-real-key-1234" -> False
|
||||
# @TEST_EDGE: "short" -> False
|
||||
def test_is_masked_or_placeholder():
|
||||
assert is_masked_or_placeholder(None) is True
|
||||
assert is_masked_or_placeholder("") is True
|
||||
assert is_masked_or_placeholder("********") is True
|
||||
assert is_masked_or_placeholder("sk-...abcd") is True
|
||||
assert is_masked_or_placeholder("...xyz") is True
|
||||
assert is_masked_or_placeholder("sk-real-key-1234") is False
|
||||
assert is_masked_or_placeholder("short") is False
|
||||
|
||||
@@ -19,6 +19,37 @@ if TYPE_CHECKING:
|
||||
MASKED_API_KEY_PLACEHOLDER = "********"
|
||||
|
||||
|
||||
# #region mask_api_key [C:2] [TYPE Function]
|
||||
# @BRIEF Mask an API key for safe display, showing first 4 and last 4 characters.
|
||||
# @PRE: api_key is a plaintext string or None.
|
||||
# @POST: Returns "****" for very short keys; "{first 2}...{last 2}" for <=8 chars;
|
||||
# "{first 4}...{last 4}" for longer keys; "" for None/empty.
|
||||
def mask_api_key(api_key: str | None) -> str:
|
||||
if not api_key:
|
||||
return ""
|
||||
if len(api_key) <= 4:
|
||||
return "****"
|
||||
if len(api_key) <= 8:
|
||||
return f"{api_key[:2]}...{api_key[-2:]}"
|
||||
return f"{api_key[:4]}...{api_key[-4:]}"
|
||||
|
||||
|
||||
# #endregion mask_api_key
|
||||
|
||||
|
||||
# #region is_masked_or_placeholder [C:2] [TYPE Function]
|
||||
# @BRIEF Predicate: True when api_key is None, empty, "********", or contains "...".
|
||||
# @PRE: api_key can be None.
|
||||
# @POST: Returns True only for non-real-key values.
|
||||
def is_masked_or_placeholder(api_key: str | None) -> bool:
|
||||
if not api_key:
|
||||
return True
|
||||
return api_key == MASKED_API_KEY_PLACEHOLDER or "..." in api_key
|
||||
|
||||
|
||||
# #endregion is_masked_or_placeholder
|
||||
|
||||
|
||||
# #region _require_fernet_key [C:5] [TYPE Function]
|
||||
# @BRIEF Load and validate the Fernet key used for secret encryption.
|
||||
# @PRE: ENCRYPTION_KEY environment variable must be set to a valid Fernet key.
|
||||
@@ -191,12 +222,8 @@ class LLMProviderService:
|
||||
db_provider.provider_type = config.provider_type.value
|
||||
db_provider.name = config.name
|
||||
db_provider.base_url = config.base_url
|
||||
# Ignore masked placeholder values; they are display-only and must not overwrite secrets.
|
||||
if (
|
||||
config.api_key is not None
|
||||
and config.api_key != ""
|
||||
and config.api_key != MASKED_API_KEY_PLACEHOLDER
|
||||
):
|
||||
# Ignore masked/placeholder values; they are display-only and must not overwrite secrets.
|
||||
if not is_masked_or_placeholder(config.api_key):
|
||||
db_provider.api_key = self.encryption.encrypt(config.api_key)
|
||||
db_provider.default_model = config.default_model
|
||||
db_provider.is_active = config.is_active
|
||||
|
||||
Reference in New Issue
Block a user