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:
2026-05-15 23:34:09 +03:00
parent b3572ce443
commit 30ba70933d
203 changed files with 158083 additions and 28396 deletions

View File

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

View File

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