Two defense-in-depth fixes from an adversarial review of the Email/WebSocket guided-config surface (#1544). Both harden pre-existing patterns that the new email probe — the first whose target host is caller-controlled while reusing stored credentials — made relevant. Email TLS certificate verification (CERT_NONE -> verified): - The implicit-SSL paths (IMAP4_SSL/SMTP_SSL) in the connection probe and the polling/send adapter used Python's default unverified context (CERT_NONE, no hostname check), so the mailbox password was sent to a server whose certificate was never checked; an active MITM could harvest it. All four call sites now build their context through a shared email_tls_context() that verifies the certificate and hostname against the system CA bundle, matching the STARTTLS path, which already did. - New EmailConfig.verify_tls field (default True) is the documented opt-out for self-signed/internal-CA servers, surfaced in the guided Email setup with a localized label/help in all nine locales. Settings-write CSRF guard: - require_settings_write_auth was missing the cross-site browser guard its siblings (require_auth, require_event_stream_auth) enforce on unsafe methods. POST /channels/email/test merges stored credentials with a caller patch, so a cross-site page could steer the stored mailbox password to an attacker-chosen host. The guard now runs first on every settings-write route; same-origin Web UI calls and non-browser clients (no Origin) are unaffected. Tests: 9 new email TLS regressions (helper + probe/adapter x verify_tls) and 5 new settings-write cross-site guard regressions; the email field-count (29 -> 30) and i18n parity assertions were updated. Channel sweep 639 passed, frontend 656 passed, test_readme_counts 108 passed; new/changed files are black- and ruff-clean (email.py's 9 ruff errors are pre-existing import-block debt, unchanged 9 -> 9). Follow-up to #1519 (extends #1520, hardens #1544). Signed-off-by: shadowinlife <shadowinlife@gmail.com>
632 lines
22 KiB
Python
632 lines
22 KiB
Python
"""Tests for channel config field metadata and fail-safe secret masking.
|
|
|
|
Covers the uiHints registry (hand-written for DingTalk and QQ, derived
|
|
elsewhere) and the security acceptance criterion: no key matching
|
|
:data:`SECRET_KEY_RE` ever survives in the non-secret ``values`` half.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from src.channels import config_meta as _config_meta
|
|
from src.channels.config_meta import (
|
|
SECRET_KEY_RE,
|
|
channel_field_hints,
|
|
is_secret_key,
|
|
split_values_secrets,
|
|
)
|
|
from src.channels.registry import discover_channel_names, load_channel_class
|
|
|
|
# Discovery already excludes the helper modules (``registry._INTERNAL``), so
|
|
# this is the real channel surface. The sweep below locks it at 16.
|
|
ADAPTER_NAMES: list[str] = sorted(discover_channel_names())
|
|
|
|
|
|
def _dummy_value(value: object) -> object:
|
|
"""Return a non-empty stand-in for a ``default_config()`` value."""
|
|
if isinstance(value, bool):
|
|
return True
|
|
if isinstance(value, list):
|
|
return ["dummy-list-item"]
|
|
if isinstance(value, dict):
|
|
return {"dummy": "dummy-value"}
|
|
if isinstance(value, int):
|
|
return 1234
|
|
if isinstance(value, float):
|
|
return 1.5
|
|
return "dummy-secret-1234"
|
|
|
|
|
|
def _section_for(name: str) -> dict[str, object] | None:
|
|
"""Return ``default_config()`` populated with dummies, or None if unloadable."""
|
|
try:
|
|
config = load_channel_class(name).default_config()
|
|
except Exception: # noqa: BLE001 - adapters with missing SDKs must degrade
|
|
return None
|
|
if not isinstance(config, dict):
|
|
return None
|
|
return {key: _dummy_value(value) for key, value in config.items()}
|
|
|
|
|
|
# --- (a) hand-written DingTalk hints ---------------------------------------- #
|
|
|
|
|
|
def test_dingtalk_field_hints_exact_snapshot() -> None:
|
|
"""DingTalk hints match the frozen contract, with ``enabled`` excluded."""
|
|
hints = channel_field_hints("dingtalk")
|
|
assert hints == [
|
|
{
|
|
"key": "client_id",
|
|
"type": "text",
|
|
"secret": False,
|
|
"required": True,
|
|
"help_key": "settings.channels.fields.dingtalk.client_id",
|
|
},
|
|
{
|
|
"key": "client_secret",
|
|
"type": "password",
|
|
"secret": True,
|
|
"required": True,
|
|
"help_key": "settings.channels.fields.dingtalk.client_secret",
|
|
},
|
|
{
|
|
"key": "allow_from",
|
|
"type": "list",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.dingtalk.allow_from",
|
|
},
|
|
{
|
|
"key": "allow_remote_media_redirects",
|
|
"type": "bool",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.dingtalk.allow_remote_media_redirects",
|
|
},
|
|
{
|
|
"key": "remote_media_redirect_allowed_hosts",
|
|
"type": "list",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.dingtalk.remote_media_redirect_allowed_hosts",
|
|
},
|
|
{
|
|
"key": "group_user_isolation",
|
|
"type": "bool",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.dingtalk.group_user_isolation",
|
|
},
|
|
{
|
|
"key": "force_ipv4",
|
|
"type": "bool",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.dingtalk.force_ipv4",
|
|
},
|
|
]
|
|
assert "enabled" not in {hint["key"] for hint in hints}
|
|
|
|
|
|
def test_qq_field_hints_exact_snapshot() -> None:
|
|
"""QQ hints match the frozen contract, with ``enabled`` excluded."""
|
|
hints = channel_field_hints("qq")
|
|
assert hints == [
|
|
{
|
|
"key": "app_id",
|
|
"type": "text",
|
|
"secret": False,
|
|
"required": True,
|
|
"help_key": "settings.channels.fields.qq.app_id",
|
|
},
|
|
{
|
|
"key": "secret",
|
|
"type": "password",
|
|
"secret": True,
|
|
"required": True,
|
|
"help_key": "settings.channels.fields.qq.secret",
|
|
},
|
|
{
|
|
"key": "allow_from",
|
|
"type": "list",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.qq.allow_from",
|
|
},
|
|
{
|
|
"key": "msg_format",
|
|
"type": "text",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.qq.msg_format",
|
|
},
|
|
{
|
|
"key": "ack_message",
|
|
"type": "text",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.qq.ack_message",
|
|
},
|
|
{
|
|
"key": "media_dir",
|
|
"type": "text",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.qq.media_dir",
|
|
},
|
|
{
|
|
"key": "download_chunk_size",
|
|
"type": "text",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.qq.download_chunk_size",
|
|
},
|
|
{
|
|
"key": "download_max_bytes",
|
|
"type": "text",
|
|
"secret": False,
|
|
"required": False,
|
|
"help_key": "settings.channels.fields.qq.download_max_bytes",
|
|
},
|
|
]
|
|
assert "enabled" not in {hint["key"] for hint in hints}
|
|
|
|
|
|
# --- (a2) hand-written Email / WebSocket hints ------------------------------- #
|
|
|
|
_EMAIL_HINT_KEYS = (
|
|
"consent_granted",
|
|
"imap_host",
|
|
"imap_port",
|
|
"imap_username",
|
|
"imap_password",
|
|
"imap_mailbox",
|
|
"imap_use_ssl",
|
|
"smtp_host",
|
|
"smtp_port",
|
|
"smtp_username",
|
|
"smtp_password",
|
|
"smtp_use_tls",
|
|
"smtp_use_ssl",
|
|
"verify_tls",
|
|
"from_address",
|
|
"auto_reply_enabled",
|
|
"poll_interval_seconds",
|
|
"mark_seen",
|
|
"post_action",
|
|
"post_action_move_mailbox",
|
|
"post_action_expunge",
|
|
"post_action_ignore_skipped",
|
|
"max_body_chars",
|
|
"subject_prefix",
|
|
"allow_from",
|
|
"verify_dkim",
|
|
"verify_spf",
|
|
"allowed_attachment_types",
|
|
"max_attachment_size",
|
|
"max_attachments_per_email",
|
|
)
|
|
|
|
_WEBSOCKET_HINT_KEYS = (
|
|
"host",
|
|
"port",
|
|
"unix_socket_path",
|
|
"path",
|
|
"token",
|
|
"token_issue_path",
|
|
"token_issue_secret",
|
|
"token_ttl_s",
|
|
"websocket_requires_token",
|
|
"allow_from",
|
|
"streaming",
|
|
"max_message_bytes",
|
|
"ping_interval_s",
|
|
"ping_timeout_s",
|
|
"ssl_certfile",
|
|
"ssl_keyfile",
|
|
)
|
|
|
|
|
|
def test_email_field_hints_contract() -> None:
|
|
"""Email hints: declaration order, secrets, required set, help_key prefix."""
|
|
hints = channel_field_hints("email")
|
|
keys = tuple(hint["key"] for hint in hints)
|
|
assert keys == _EMAIL_HINT_KEYS
|
|
assert "enabled" not in keys
|
|
assert len(hints) == 30
|
|
|
|
by_key = {hint["key"]: hint for hint in hints}
|
|
assert {key for key, hint in by_key.items() if hint["secret"]} == {
|
|
"imap_password",
|
|
"smtp_password",
|
|
}
|
|
assert by_key["imap_password"]["type"] == "password"
|
|
assert by_key["smtp_password"]["type"] == "password"
|
|
# required mirrors EmailChannel._validate_config: the channel refuses to
|
|
# start without all six credential fields.
|
|
assert {key for key, hint in by_key.items() if hint["required"]} == {
|
|
"imap_host",
|
|
"imap_username",
|
|
"imap_password",
|
|
"smtp_host",
|
|
"smtp_username",
|
|
"smtp_password",
|
|
}
|
|
for hint in hints:
|
|
assert hint["help_key"] == f"settings.channels.fields.email.{hint['key']}"
|
|
|
|
|
|
def test_websocket_field_hints_contract() -> None:
|
|
"""WebSocket hints: declaration order, secrets, help_key prefix."""
|
|
hints = channel_field_hints("websocket")
|
|
keys = tuple(hint["key"] for hint in hints)
|
|
assert keys == _WEBSOCKET_HINT_KEYS
|
|
assert "enabled" not in keys
|
|
assert len(hints) == 16
|
|
|
|
by_key = {hint["key"]: hint for hint in hints}
|
|
assert {key for key, hint in by_key.items() if hint["secret"]} == {
|
|
"token",
|
|
"token_issue_secret",
|
|
}
|
|
assert by_key["token"]["type"] == "password"
|
|
assert by_key["token_issue_secret"]["type"] == "password"
|
|
# ssl_certfile/ssl_keyfile are filesystem paths, not secret material.
|
|
assert by_key["ssl_certfile"]["secret"] is False
|
|
assert by_key["ssl_keyfile"]["secret"] is False
|
|
# Nothing is unconditionally required: defaults work and unix_socket_path
|
|
# is a legitimate alternative to host/port.
|
|
assert {key for key, hint in by_key.items() if hint["required"]} == set()
|
|
for hint in hints:
|
|
assert hint["help_key"] == f"settings.channels.fields.websocket.{hint['key']}"
|
|
|
|
|
|
# --- (a3) hint-authoritative secret resolution ------------------------------- #
|
|
|
|
|
|
def test_is_secret_key_hint_authoritative_with_regex_failsafe() -> None:
|
|
"""A hand-written hint's secret flag wins for the keys it covers only."""
|
|
# Declared non-secret wins over the regex over-match...
|
|
assert is_secret_key("websocket", "token_issue_path") is False
|
|
assert is_secret_key("websocket", "token_ttl_s") is False
|
|
assert is_secret_key("websocket", "websocket_requires_token") is False
|
|
# ...and declared secrets stay secret.
|
|
assert is_secret_key("websocket", "token") is True
|
|
assert is_secret_key("websocket", "token_issue_secret") is True
|
|
assert is_secret_key("email", "imap_password") is True
|
|
assert is_secret_key("email", "smtp_password") is True
|
|
assert is_secret_key("dingtalk", "client_secret") is True
|
|
# An UNHINTED regex-matching key on a hand-written channel keeps the
|
|
# unconditional fail-safe...
|
|
assert is_secret_key("websocket", "webhook_secret") is True
|
|
# ...as do derived channels and unknown channels.
|
|
assert is_secret_key("telegram", "bot_token") is True
|
|
assert is_secret_key("no_such_channel", "api_key") is True
|
|
assert is_secret_key("telegram", "allow_from") is False
|
|
|
|
|
|
def test_websocket_token_shaped_non_secrets_land_in_values() -> None:
|
|
"""The security-flip root cause: requires_token masked → form seeds False."""
|
|
section = {
|
|
"enabled": False,
|
|
"websocket_requires_token": True,
|
|
"token_ttl_s": 300,
|
|
"token_issue_path": "/issue",
|
|
"token": "s3cret-value",
|
|
"token_issue_secret": "issue-secret-value",
|
|
"webhook_secret": "unhinted-but-regex-caught",
|
|
}
|
|
|
|
values, secrets = split_values_secrets("websocket", section)
|
|
|
|
assert values["websocket_requires_token"] is True
|
|
assert values["token_ttl_s"] == 300
|
|
assert values["token_issue_path"] == "/issue"
|
|
assert set(secrets) == {"token", "token_issue_secret", "webhook_secret"}
|
|
assert secrets["token"] == {"set": True, "masked": "****alue"}
|
|
|
|
|
|
def test_email_and_dingtalk_masking_unchanged() -> None:
|
|
values, secrets = split_values_secrets(
|
|
"email",
|
|
{"imap_host": "h", "imap_password": "a", "smtp_password": "b"},
|
|
)
|
|
assert values == {"imap_host": "h"}
|
|
assert set(secrets) == {"imap_password", "smtp_password"}
|
|
|
|
values, secrets = split_values_secrets(
|
|
"dingtalk", {"client_secret": "dummy-secret-1234"}
|
|
)
|
|
assert values == {}
|
|
assert secrets == {"client_secret": {"set": True, "masked": "****1234"}}
|
|
|
|
|
|
def test_derived_channel_regex_masking_unchanged() -> None:
|
|
values, secrets = split_values_secrets(
|
|
"telegram", {"bot_token": "x", "allow_from": ["*"]}
|
|
)
|
|
assert "bot_token" not in values
|
|
assert "bot_token" in secrets
|
|
assert values == {"allow_from": ["*"]}
|
|
|
|
|
|
# --- (b) fallback derivation ------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["discord", "telegram"])
|
|
def test_fallback_derives_types_and_secret_flags(name: str) -> None:
|
|
"""Adapters without hand-written hints derive metadata from default_config()."""
|
|
config = _section_for(name)
|
|
assert config is not None, f"{name} should expose default_config()"
|
|
hints = channel_field_hints(name)
|
|
assert hints, f"{name} fallback should derive at least one field"
|
|
by_key = {hint["key"]: hint for hint in hints}
|
|
assert "enabled" not in by_key
|
|
assert any(SECRET_KEY_RE.search(key) for key in config)
|
|
|
|
for key, value in config.items():
|
|
if key == "enabled":
|
|
continue
|
|
if isinstance(value, dict):
|
|
assert key not in by_key
|
|
continue
|
|
hint = by_key[key]
|
|
assert hint["required"] is False
|
|
assert hint["help_key"] is None
|
|
if SECRET_KEY_RE.search(key):
|
|
assert hint["secret"] is True
|
|
assert hint["type"] == "password"
|
|
else:
|
|
assert hint["secret"] is False
|
|
if isinstance(value, bool):
|
|
assert hint["type"] == "bool"
|
|
elif isinstance(value, list):
|
|
assert hint["type"] == "list"
|
|
else:
|
|
assert hint["type"] == "text"
|
|
|
|
|
|
def test_dict_valued_defaults_are_excluded_from_derived_hints(monkeypatch) -> None:
|
|
"""Dict-valued fields stay out of derived hints (hermetic stub pin).
|
|
|
|
The generic form edits text/password/bool/list widgets only; a dict
|
|
field derived as ``text`` rendered as "[object Object]" once the GET
|
|
defaults backfill reached pristine forms. Excluded from hints, still
|
|
present in ``values``, secret masking untouched.
|
|
"""
|
|
|
|
class _Stub:
|
|
@staticmethod
|
|
def default_config() -> dict:
|
|
return {
|
|
"enabled": False,
|
|
"bot_token": "",
|
|
"dm": {},
|
|
"policies": {"mention": {}},
|
|
"name": "",
|
|
}
|
|
|
|
monkeypatch.setattr(_config_meta, "load_channel_class", lambda name: _Stub)
|
|
|
|
hints = channel_field_hints("stub_dict_channel")
|
|
assert {hint["key"] for hint in hints} == {"bot_token", "name"}
|
|
by_key = {hint["key"]: hint for hint in hints}
|
|
assert by_key["bot_token"]["type"] == "password"
|
|
assert by_key["bot_token"]["secret"] is True
|
|
|
|
values, secrets = split_values_secrets("stub_dict_channel", _Stub.default_config())
|
|
assert {"dm", "policies"} <= set(values)
|
|
assert "bot_token" in secrets
|
|
|
|
|
|
def test_dict_valued_defaults_are_excluded_for_real_adapters() -> None:
|
|
"""Same rule against a real adapter shape (signal's dm/group policy maps).
|
|
|
|
whatsapp ``lid_mappings``, napcat ``group_policy_overrides`` and mochat
|
|
``mention``/``groups`` are the same class; signal is the loadable
|
|
representative here.
|
|
"""
|
|
config = _section_for("signal")
|
|
if config is None:
|
|
pytest.skip("signal adapter unloadable in this environment")
|
|
dict_keys = {key for key, value in config.items() if isinstance(value, dict)}
|
|
assert dict_keys, "signal should expose dict-valued defaults"
|
|
|
|
hint_keys = {hint["key"] for hint in channel_field_hints("signal")}
|
|
assert not (dict_keys & hint_keys)
|
|
|
|
values, _ = split_values_secrets("signal", config)
|
|
assert dict_keys <= set(values)
|
|
|
|
|
|
def test_unloadable_adapter_is_handled_gracefully() -> None:
|
|
"""Adapters whose SDK is missing yield no hints instead of raising."""
|
|
for name in ADAPTER_NAMES:
|
|
try:
|
|
load_channel_class(name)
|
|
except Exception: # noqa: BLE001 - this is the branch under test
|
|
assert channel_field_hints(name) == []
|
|
|
|
|
|
# --- (c)/(d) fail-safe secret heuristic ------------------------------------ #
|
|
|
|
|
|
def test_unknown_channel_masks_secret_keys_and_strips_them_from_values() -> None:
|
|
"""A secret-shaped key is masked even without any hand-written hint."""
|
|
values, secrets = split_values_secrets(
|
|
"no_such_channel",
|
|
{"webhook_secret": "abc12345", "bot_token": "x", "name": "n"},
|
|
)
|
|
assert values == {"name": "n"}
|
|
assert secrets == {
|
|
"webhook_secret": {"set": True, "masked": "****"},
|
|
"bot_token": {"set": True, "masked": "****"},
|
|
}
|
|
|
|
|
|
def test_short_secret_masking_boundary() -> None:
|
|
"""Secrets of 8 characters or fewer disclose nothing; 9 disclose the last 4."""
|
|
for length in range(1, 9):
|
|
_, secrets = split_values_secrets(
|
|
"no_such_channel", {"token": "1234567890"[:length]}
|
|
)
|
|
assert secrets["token"] == {"set": True, "masked": "****"}, length
|
|
_, secrets = split_values_secrets("no_such_channel", {"token": "123456789"})
|
|
assert secrets["token"] == {"set": True, "masked": "****6789"}
|
|
|
|
|
|
def test_empty_secret_is_unset_and_unmasked() -> None:
|
|
"""An unset secret reports ``set: false`` and an empty mask."""
|
|
values, secrets = split_values_secrets("no_such_channel", {"api_key": ""})
|
|
assert values == {}
|
|
assert secrets == {"api_key": {"set": False, "masked": ""}}
|
|
|
|
|
|
def test_non_secret_values_pass_through_verbatim() -> None:
|
|
"""Non-secret keys (including ``enabled``) are copied unchanged."""
|
|
section = {"client_id": "abc", "allow_from": ["u1"], "enabled": True}
|
|
values, secrets = split_values_secrets("dingtalk", section)
|
|
assert values == section
|
|
assert secrets == {}
|
|
|
|
|
|
def test_proxy_url_userinfo_is_stripped_from_values() -> None:
|
|
"""URL userinfo is stripped from non-secret values; other strings stay put."""
|
|
values, secrets = split_values_secrets(
|
|
"telegram",
|
|
{
|
|
"proxy": "http://user:pw@proxy.local:8080",
|
|
"webhook_url": "https://example.com/hook",
|
|
"note": "plain text",
|
|
"bad_proxy": "http://user:pw@host:notaport",
|
|
"token": "abc",
|
|
},
|
|
)
|
|
assert values["proxy"] == "http://proxy.local:8080"
|
|
assert "user" not in values["proxy"]
|
|
assert "pw" not in values["proxy"]
|
|
assert values["webhook_url"] == "https://example.com/hook"
|
|
assert values["note"] == "plain text"
|
|
assert values["bad_proxy"] == "http://user:pw@host:notaport"
|
|
assert secrets["token"] == {"set": True, "masked": "****"}
|
|
|
|
|
|
def test_hint_marked_secret_is_masked_for_known_channel() -> None:
|
|
"""A hint-declared secret is masked and never reaches values."""
|
|
values, secrets = split_values_secrets(
|
|
"dingtalk", {"client_secret": "dummy-secret-1234"}
|
|
)
|
|
assert values == {}
|
|
assert secrets == {"client_secret": {"set": True, "masked": "****1234"}}
|
|
|
|
|
|
def test_secret_key_regex_is_case_insensitive() -> None:
|
|
"""Credential-shaped keys match case-insensitively; identifiers do not."""
|
|
for key in (
|
|
"API_KEY",
|
|
"WebhookSecret",
|
|
"bot_token",
|
|
"db_password",
|
|
"encrypt_key",
|
|
"ENCRYPT_KEY",
|
|
"signing_key",
|
|
"private_key",
|
|
"credential",
|
|
"proxy_username",
|
|
"apikey",
|
|
):
|
|
assert SECRET_KEY_RE.search(key), key
|
|
for key in ("allow_from", "client_id", "imap_username", "domain"):
|
|
assert not SECRET_KEY_RE.search(key), key
|
|
|
|
|
|
# --- (e) security sweep over every discovered adapter ---------------------- #
|
|
|
|
_EXPECTED_SECRET_KEYS: dict[str, set[str]] = {
|
|
"dingtalk": {"client_secret"},
|
|
"discord": {"token", "proxy_password", "proxy_username"},
|
|
"email": {"imap_password", "smtp_password"},
|
|
"feishu": {"app_secret", "verification_token", "encrypt_key"},
|
|
"matrix": {"password", "access_token"},
|
|
"mochat": {"claw_token"},
|
|
"msteams": {"app_password"},
|
|
"napcat": {"access_token"},
|
|
"qq": {"secret"},
|
|
"signal": set(),
|
|
"slack": {"bot_token", "app_token", "user_token_read_only"},
|
|
"telegram": {"token", "webhook_secret_token"},
|
|
"wecom": {"secret"},
|
|
"weixin": {"token"},
|
|
"whatsapp": set(),
|
|
"websocket": {"token", "token_issue_secret"},
|
|
}
|
|
|
|
_IDENTIFIER_KEYS = frozenset(
|
|
{"client_id", "app_id", "bot_id", "phone_number", "imap_username", "smtp_username"}
|
|
)
|
|
|
|
|
|
def test_discovered_adapter_surface_is_locked() -> None:
|
|
"""The sweep below covers the frozen set of 16 built-in adapters."""
|
|
assert len(ADAPTER_NAMES) == 16
|
|
|
|
|
|
@pytest.mark.parametrize("name", ADAPTER_NAMES)
|
|
def test_split_never_leaks_secret_keys_in_values(name: str) -> None:
|
|
"""A regex-matching key reaches ``values`` only via an audited hint.
|
|
|
|
Hand-written hints are authoritative for the keys they cover (websocket's
|
|
``token_ttl_s`` is a declared non-secret); every OTHER regex-matching key
|
|
— derived channels, unhinted extras — stays under the unconditional
|
|
:data:`SECRET_KEY_RE` fail-safe.
|
|
"""
|
|
section = _section_for(name)
|
|
if section is None:
|
|
# Missing SDK (e.g. matrix today): hints degrade to empty and masking
|
|
# must still be fail-safe.
|
|
assert channel_field_hints(name) == []
|
|
section = {"webhook_secret": "dummy-secret-1234", "bot_token": "dummy-token"}
|
|
|
|
values, secrets = split_values_secrets(name, section)
|
|
hand_written = {
|
|
hint["key"]: hint for hint in _config_meta.FIELD_HINTS.get(name, [])
|
|
}
|
|
for key in values:
|
|
if SECRET_KEY_RE.search(key):
|
|
assert key in hand_written, f"{name}.{key}: regex fail-safe bypassed"
|
|
assert hand_written[key]["secret"] is False
|
|
assert set(values) | set(secrets) == set(section)
|
|
assert not (set(values) & set(secrets))
|
|
|
|
|
|
@pytest.mark.parametrize("name", ADAPTER_NAMES)
|
|
def test_every_adapter_credential_key_lands_in_secrets(name: str) -> None:
|
|
"""Every credential-shaped field lands in ``secrets``, never in ``values``."""
|
|
section = _section_for(name)
|
|
if section is None:
|
|
pytest.skip(f"{name} adapter is not loadable in this environment")
|
|
values, secrets = split_values_secrets(name, section)
|
|
expected = _EXPECTED_SECRET_KEYS[name]
|
|
assert expected <= set(secrets)
|
|
assert set(expected) & set(values) == set()
|
|
|
|
|
|
@pytest.mark.parametrize("name", ADAPTER_NAMES)
|
|
def test_identifier_fields_stay_visible(name: str) -> None:
|
|
"""Identifier-shaped fields stay in ``values``, not ``secrets``."""
|
|
section = _section_for(name)
|
|
if section is None:
|
|
pytest.skip(f"{name} adapter is not loadable in this environment")
|
|
values, secrets = split_values_secrets(name, section)
|
|
for key in _IDENTIFIER_KEYS & set(section):
|
|
assert key in values, f"{name}.{key} must stay visible"
|
|
assert key not in secrets, f"{name}.{key} must not be masked"
|
|
|
|
|
|
# --- (f) unknown channel ---------------------------------------------------- #
|
|
|
|
|
|
def test_unknown_channel_has_no_hints() -> None:
|
|
assert channel_field_hints("definitely_not_a_real_channel") == []
|