""" MCP Credential Isolation 迴歸測試 ================================== AwoooP Phase 5.5:防止 2026-04-18 Secret Leak 事故再現 2026-05-04 ogt + Claude Sonnet 4.6 覆蓋: 1. credential_resolver 格式驗證(bad ref 拒絕) 2. dev fallback 正確返回 (value, masked, sha256) 3. secret value 不洩漏到 redaction_middleware output 4. _mcp_audit key 在 redact_mcp_input 中被移除(不送 provider) 5. AuditedMCPToolProvider.__provider 不可從外部直接存取(name mangling) """ from __future__ import annotations import hashlib import json import os import pytest class TestCredentialResolverFormat: """credential_resolver 格式驗證""" def test_bad_ref_raises(self): """格式錯誤的 ref 應拋出 CredentialResolutionError""" import asyncio import sys sys.path.insert(0, ".") from src.plugins.mcp.credential_resolver import ( CredentialResolutionError, resolve_k8s_secret, ) bad_refs = [ "no-slash", "namespace/secret", # 缺 #key "NAMESPACE/secret#key", # namespace 大寫不符格式 "ns/secret#", # key 為空 "", ] for ref in bad_refs: with pytest.raises(CredentialResolutionError): asyncio.run(resolve_k8s_secret(ref)) def test_dev_fallback_resolves(self, monkeypatch): """AWOOOP_DEV_SECRETS_JSON 設定後應正確解析""" import asyncio import sys sys.path.insert(0, ".") dev_secrets = {"awoooi/telegram-bot#TELEGRAM_BOT_TOKEN": "test-token-value-1234"} monkeypatch.setenv("AWOOOP_DEV_SECRETS_JSON", json.dumps(dev_secrets)) from src.plugins.mcp.credential_resolver import resolve_k8s_secret value, masked, sha256 = asyncio.run( resolve_k8s_secret("awoooi/telegram-bot#TELEGRAM_BOT_TOKEN") ) assert value == "test-token-value-1234" assert "test" in masked # 前 4 字元 assert "***" in masked assert sha256 == hashlib.sha256("test-token-value-1234".encode()).hexdigest() def test_dev_fallback_missing_key_raises(self, monkeypatch): """dev secrets 中找不到 key 應拋出錯誤""" import asyncio import sys sys.path.insert(0, ".") monkeypatch.setenv("AWOOOP_DEV_SECRETS_JSON", json.dumps({})) from src.plugins.mcp.credential_resolver import ( CredentialResolutionError, resolve_k8s_secret, ) with pytest.raises(CredentialResolutionError): asyncio.run(resolve_k8s_secret("awoooi/some-secret#key")) class TestRedactionMiddlewareSecretLeak: """2026-04-18 Secret Leak 迴歸:secret value 不得進入 output""" def test_pg_dsn_redacted_from_output(self): """PG DSN 在 output redaction 後不可見""" import sys sys.path.insert(0, ".") from src.plugins.mcp.redaction_middleware import redact_mcp_output output = { "connection": "postgresql+asyncpg://admin:supersecret@10.0.1.5/prod", "status": "connected", } cleaned = redact_mcp_output(output) assert "supersecret" not in json.dumps(cleaned), "PG DSN 密碼不得出現在 output" assert "[REDACTED:PG_DSN]" in cleaned["connection"] def test_telegram_token_redacted_from_output(self): """Telegram token 在 output redaction 後不可見""" import sys sys.path.insert(0, ".") from src.plugins.mcp.redaction_middleware import redact_mcp_output output = "Bot token: 1234567890:ABCDEFGHIJKLMNOPabcdefghijklmno12345678" cleaned = redact_mcp_output(output) assert "ABCDEFGHIJKLMNO" not in cleaned, "Telegram token 不得出現在 output" assert "[REDACTED:TELEGRAM_TOKEN]" in cleaned def test_internal_ip_redacted(self): """GCP 內網 IP 在 output redaction 後不可見""" import sys sys.path.insert(0, ".") from src.plugins.mcp.redaction_middleware import redact_mcp_output output = {"host": "10.0.1.5", "port": 5432} cleaned = redact_mcp_output(output) assert "10.0.1.5" not in json.dumps(cleaned), "內網 IP 不得出現在 output" assert "[REDACTED:INTERNAL_IP]" in cleaned["host"] def test_mcp_audit_key_removed_from_input(self): """_mcp_audit key 在 redact_mcp_input 後應被移除""" import sys sys.path.insert(0, ".") from src.plugins.mcp.redaction_middleware import redact_mcp_input params = { "_mcp_audit": {"session_id": "abc123", "run_id": "xyz"}, "namespace": "default", } cleaned = redact_mcp_input(params) assert "_mcp_audit" not in cleaned, "_mcp_audit 應在送 provider 前移除" assert cleaned["namespace"] == "default" def test_k8s_value_credential_isolation(self): """k8s_value 欄位應被 credential isolation 攔截""" import sys sys.path.insert(0, ".") from src.plugins.mcp.redaction_middleware import redact_mcp_input params = { "k8s_value": "actual-secret-credential", "tool": "some-tool", } cleaned = redact_mcp_input(params) assert "actual-secret-credential" not in json.dumps(cleaned) assert cleaned["k8s_value"] == "[REDACTED:CREDENTIAL_ISOLATION]" class TestNameManglingEncapsulation: """AuditedMCPToolProvider.__provider name mangling 封裝驗證""" def test_single_underscore_not_accessible(self): """_provider(單底線)應不存在於 AuditedMCPToolProvider 實例""" import sys sys.path.insert(0, ".") from src.plugins.mcp.interfaces import MCPToolProvider, MCPTool, MCPToolResult from src.plugins.mcp.registry import AuditedMCPToolProvider class DummyProvider(MCPToolProvider): @property def name(self): return "dummy" async def list_tools(self): return [] async def execute(self, tool_name, parameters): return MCPToolResult(success=True, execution_id="t") wrapped = AuditedMCPToolProvider(DummyProvider()) assert not hasattr(wrapped, "_provider"), ( "_provider 不應可直接存取(name mangling 防止直接存取 inner provider)" ) def test_double_underscore_mangled(self): """__provider 應被 Python name mangling 重命名""" import sys sys.path.insert(0, ".") from src.plugins.mcp.interfaces import MCPToolProvider, MCPTool, MCPToolResult from src.plugins.mcp.registry import AuditedMCPToolProvider class DummyProvider(MCPToolProvider): @property def name(self): return "dummy" async def list_tools(self): return [] async def execute(self, tool_name, parameters): return MCPToolResult(success=True, execution_id="t") wrapped = AuditedMCPToolProvider(DummyProvider()) # Python name mangling: __provider → _AuditedMCPToolProvider__provider assert hasattr(wrapped, "_AuditedMCPToolProvider__provider"), ( "name mangling 後的屬性應可被內部存取" ) assert wrapped.name == "dummy"