deer-flow/backend/tests/test_gateway_secret_masking.py
Hyeonsang Cho b4b2a68ee5
fix(memory): mask backend secrets in the memory config responses (#7665)
* fix(memory): mask backend secrets in the memory config responses

GET /api/memory/config and /api/memory/status returned
memory.backend_config as loaded. Config loading has already resolved
$VAR references by then, so a DeerMem extraction model api_key or a
Honcho api_key reached every caller with memory:read in plaintext,
which with authorization disabled (the default) is every signed-in
user.

MemoryConfigResponse now masks credential-named values (api_key,
*_token, password, authorization, ...) at any depth as "***". The
key is matched on its end, so knobs such as token_counting and
guaranteed_token_budget and the api_key_env variable name stay
readable, and unset values stay null or empty. The loaded config the
backend uses is not changed.

* docs(changelog): reference #7665 for the memory config secret masking fix

* refactor(gateway): share config secret masking between MCP and memory

The memory config masking forked the MCP router's key normalization
and recursive masker. Both now use app/gateway/secret_masking.py, and
each router keeps its own key rule: MCP matches credential terms
anywhere in the key, memory matches them at the end and keeps unset
values. MCP masking is unchanged.

The memory rule now also covers plural and cookie keys (api_keys,
secrets, passwords, cookies). Plural tokens stays out because
max_injection_tokens is a DeerMem knob. A secret-named container is
still masked whole, since its keys can be arbitrary names; the helper
docstring and a test now say so.

* docs(changelog): move the #7665 entry to Unreleased
2026-10-30 14:53:33 +08:00

32 lines
1.5 KiB
Python

"""Shared credential masking used by the MCP and memory config routes."""
import pytest
from app.gateway.secret_masking import MASKED_VALUE, mask_secret_values, normalize_config_key
@pytest.mark.parametrize(
("key", "normalized"),
[("api_key", "api_key"), ("apiKey", "api_key"), ("APIKey", "api_key"), ("API-Key", "api_key"), ("accessToken", "access_token"), ("X-Api-Key", "x_api_key")],
)
def test_normalize_config_key_snake_cases_spellings(key: str, normalized: str) -> None:
assert normalize_config_key(key) == normalized
def _is_secret(key: str) -> bool:
return key in {"api_key", "credentials"}
def test_mask_secret_values_masks_at_any_depth_without_mutating() -> None:
value = {"api_key": "sk-top", "nested": {"api_key": "sk-nested", "model": "m"}, "items": [{"api_key": "sk-list"}, "plain"], "credentials": {"client_id": "id", "client_secret": "s"}}
masked = mask_secret_values(value, _is_secret)
assert masked == {"api_key": MASKED_VALUE, "nested": {"api_key": MASKED_VALUE, "model": "m"}, "items": [{"api_key": MASKED_VALUE}, "plain"], "credentials": MASKED_VALUE}
assert value["nested"]["api_key"] == "sk-nested"
@pytest.mark.parametrize(("keep_unset", "expected"), [(False, {"api_key": MASKED_VALUE, "credentials": MASKED_VALUE}), (True, {"api_key": None, "credentials": ""})])
def test_mask_secret_values_keeps_unset_values_only_when_asked(keep_unset: bool, expected: dict) -> None:
assert mask_secret_values({"api_key": None, "credentials": ""}, _is_secret, keep_unset=keep_unset) == expected