feat: add connector security primitives and doctor posture checks with full S32 test coverage (implemented, adapter wiring deferred)

This commit is contained in:
rookiestar28
2026-02-13 02:02:55 +08:00
parent 38fce78425
commit 72d7cc4bd5
4 changed files with 1446 additions and 1 deletions
+1
View File
@@ -4,6 +4,7 @@ __pycache__/
.planning/
.env
.venv/
.venv-wsl/
venv/
env/
.pytest_cache/
+569
View File
@@ -0,0 +1,569 @@
"""
S32 — Connector Security Profile.
Centralised ingress/auth/scope/allowlist security decisions for
internet-exposed connector deployments.
Provides:
- auth header verification (Bearer / HMAC-SHA256 signature),
- replay/dedupe window checks,
- scope and service-user allowlist enforcement,
- fail-closed error envelope mapping,
- reusable runtime primitives wrapping R75 transport contract.
All defaults are **fail-closed** — missing or invalid auth rejects the
request. Permissive modes require explicit operator opt-in.
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, FrozenSet, List, Optional, Set
from .transport_contract import (
CallbackContract,
CallbackError,
CallbackRecord,
ReconnectPolicy,
TokenContract,
TokenError,
TokenResult,
TokenSource,
TransportError,
)
logger = logging.getLogger("connector.security_profile")
# ---------------------------------------------------------------------------
# Auth verification
# ---------------------------------------------------------------------------
class AuthScheme(str, Enum):
"""Supported ingress auth schemes."""
BEARER = "bearer"
HMAC_SHA256 = "hmac_sha256"
NONE = "none"
@dataclass
class AuthVerifyResult:
"""Result of an ingress auth verification."""
ok: bool = False
scheme: str = AuthScheme.NONE.value
identity: str = ""
error: str = ""
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"ok": self.ok, "scheme": self.scheme}
if self.identity:
d["identity"] = self.identity
if self.error:
d["error"] = self.error
return d
def verify_bearer_token(
header_value: str,
*,
expected_token: str,
) -> AuthVerifyResult:
"""
Verify a ``Bearer <token>`` auth header.
Fail-closed: empty or mismatched token → reject.
"""
if not header_value or not expected_token:
return AuthVerifyResult(
ok=False,
scheme=AuthScheme.BEARER.value,
error="missing_token" if not expected_token else "missing_header",
)
# Accept with or without "Bearer " prefix
raw = header_value
if raw.lower().startswith("bearer "):
raw = raw[7:]
raw = raw.strip()
if not hmac.compare_digest(raw, expected_token):
return AuthVerifyResult(
ok=False,
scheme=AuthScheme.BEARER.value,
error="token_mismatch",
)
return AuthVerifyResult(
ok=True,
scheme=AuthScheme.BEARER.value,
identity="bearer",
)
def verify_hmac_signature(
body: bytes,
*,
signature_header: str,
secret: str,
algorithm: str = "sha256",
) -> AuthVerifyResult:
"""
Verify an HMAC signature over the raw request body.
Used by webhook platforms (WhatsApp, LINE, Kakao) that sign payloads.
Fail-closed: missing secret / header / mismatch → reject.
"""
if not secret:
return AuthVerifyResult(
ok=False,
scheme=AuthScheme.HMAC_SHA256.value,
error="missing_secret",
)
if not signature_header:
return AuthVerifyResult(
ok=False,
scheme=AuthScheme.HMAC_SHA256.value,
error="missing_signature_header",
)
algo_map = {
"sha256": hashlib.sha256,
"sha1": hashlib.sha1,
}
hash_fn = algo_map.get(algorithm)
if not hash_fn:
return AuthVerifyResult(
ok=False,
scheme=AuthScheme.HMAC_SHA256.value,
error=f"unsupported_algorithm:{algorithm}",
)
expected = hmac.new(
secret.encode("utf-8"),
body,
hash_fn,
).hexdigest()
# Strip common prefixes (e.g. "sha256=")
sig = signature_header.strip()
for prefix in (f"{algorithm}=", "sha256=", "sha1="):
if sig.lower().startswith(prefix):
sig = sig[len(prefix) :]
break
if not hmac.compare_digest(sig.lower(), expected.lower()):
return AuthVerifyResult(
ok=False,
scheme=AuthScheme.HMAC_SHA256.value,
error="signature_mismatch",
)
return AuthVerifyResult(
ok=True,
scheme=AuthScheme.HMAC_SHA256.value,
identity="hmac",
)
# ---------------------------------------------------------------------------
# Replay / dedupe window
# ---------------------------------------------------------------------------
# Default window = 5 minutes
DEFAULT_REPLAY_WINDOW_SEC = 300
# Absolute cap on window entries to prevent memory abuse
MAX_REPLAY_ENTRIES = 50_000
@dataclass
class ReplayEntry:
key: str
timestamp: float = field(default_factory=time.time)
class ReplayGuard:
"""
Sliding-window duplicate/replay detector.
Provides O(1) membership check with bounded memory.
Entries older than ``window_sec`` are evicted lazily on insert.
"""
def __init__(
self,
window_sec: int = DEFAULT_REPLAY_WINDOW_SEC,
max_entries: int = MAX_REPLAY_ENTRIES,
):
self._window_sec = window_sec
self._max_entries = max_entries
self._seen: Dict[str, float] = {}
@property
def window_sec(self) -> int:
return self._window_sec
def check_and_record(self, key: str) -> bool:
"""
Returns True if the key is **new** (not a replay).
Returns False if it is a duplicate within the window.
"""
self._evict_expired()
now = time.time()
if key in self._seen:
ts = self._seen[key]
if now - ts <= self._window_sec:
return False # duplicate within window
# Expired entry — treat as new
self._seen[key] = now
# Enforce hard cap after insert
if len(self._seen) > self._max_entries:
self._enforce_cap()
return True
def is_duplicate(self, key: str) -> bool:
"""Inverse of check_and_record — True if replay."""
return not self.check_and_record(key)
def _evict_expired(self) -> None:
now = time.time()
cutoff = now - self._window_sec
# Evict expired keys
expired = [k for k, ts in self._seen.items() if ts < cutoff]
for k in expired:
del self._seen[k]
self._enforce_cap()
def _enforce_cap(self) -> None:
"""Evict oldest entries to respect max_entries."""
if len(self._seen) > self._max_entries:
sorted_items = sorted(self._seen.items(), key=lambda x: x[1])
excess = len(self._seen) - self._max_entries
for k, _ in sorted_items[:excess]:
del self._seen[k]
@property
def size(self) -> int:
return len(self._seen)
# ---------------------------------------------------------------------------
# Scope / allowlist enforcement
# ---------------------------------------------------------------------------
class ScopeDecision(str, Enum):
ALLOW = "allow"
DENY = "deny"
SKIP = "skip" # No allowlist configured — pass-through
@dataclass
class ScopeResult:
decision: str = ScopeDecision.DENY.value
matched_entry: str = ""
reason: str = ""
def to_dict(self) -> Dict[str, Any]:
return {
"decision": self.decision,
"matched_entry": self.matched_entry,
"reason": self.reason,
}
class AllowlistPolicy:
"""
Scope / service-user allowlist enforcement.
When an allowlist is configured, only entries in the list are permitted.
When the allowlist is empty **and** ``strict=True`` (default), all
requests are denied (fail-closed). Set ``strict=False`` to allow-all
when no list is configured.
"""
def __init__(
self,
entries: Optional[List[str]] = None,
*,
strict: bool = True,
normalizer: Optional[Any] = None,
):
raw = entries or []
self._normalizer = normalizer or (lambda x: x.strip().lower())
self._entries: FrozenSet[str] = frozenset(
self._normalizer(e) for e in raw if e.strip()
)
self._strict = strict
@property
def entries(self) -> FrozenSet[str]:
return self._entries
@property
def strict(self) -> bool:
return self._strict
def evaluate(self, identifier: str) -> ScopeResult:
"""Check whether *identifier* is allowed under the current policy."""
normalized = self._normalizer(identifier)
if not self._entries:
if self._strict:
return ScopeResult(
decision=ScopeDecision.DENY.value,
reason="empty_allowlist_strict",
)
return ScopeResult(
decision=ScopeDecision.SKIP.value,
reason="no_allowlist_configured",
)
if normalized in self._entries:
return ScopeResult(
decision=ScopeDecision.ALLOW.value,
matched_entry=normalized,
)
return ScopeResult(
decision=ScopeDecision.DENY.value,
reason="not_in_allowlist",
)
# ---------------------------------------------------------------------------
# Fail-closed error mapping
# ---------------------------------------------------------------------------
def to_transport_error(
code: str,
message: str,
*,
retryable: bool = False,
details: Optional[Dict[str, Any]] = None,
) -> TransportError:
"""Create a deterministic ``TransportError`` from a security decision."""
return TransportError(
code=code,
message=message,
retryable=retryable,
details=details or {},
)
def auth_failure_error(result: AuthVerifyResult) -> TransportError:
"""Map an ``AuthVerifyResult`` failure to a ``TransportError``."""
return to_transport_error(
code=f"auth_{result.error}",
message=f"Auth failed ({result.scheme}): {result.error}",
retryable=False,
)
def scope_denial_error(result: ScopeResult) -> TransportError:
"""Map a ``ScopeResult`` denial to a ``TransportError``."""
return to_transport_error(
code=f"scope_{result.reason}",
message=f"Scope denied: {result.reason}",
retryable=False,
)
def replay_error(key: str) -> TransportError:
"""Map a replay detection to a ``TransportError``."""
return to_transport_error(
code="replay_detected",
message="Duplicate request detected within replay window",
retryable=False,
details={"key_prefix": key[:8] + "..." if len(key) > 8 else key},
)
# ---------------------------------------------------------------------------
# Composite ingress gate
# ---------------------------------------------------------------------------
@dataclass
class IngressDecision:
"""Result of the composite ingress security check."""
allowed: bool = False
auth: Optional[AuthVerifyResult] = None
scope: Optional[ScopeResult] = None
replay_ok: bool = True
error: Optional[TransportError] = None
def to_dict(self) -> Dict[str, Any]:
d: Dict[str, Any] = {"allowed": self.allowed}
if self.auth:
d["auth"] = self.auth.to_dict()
if self.scope:
d["scope"] = self.scope.to_dict()
d["replay_ok"] = self.replay_ok
if self.error:
d["error"] = self.error.to_dict()
return d
class IngressGate:
"""
Composite fail-closed ingress gate.
Evaluates auth → replay → scope in order. First failure rejects.
Skip semantics:
- Auth is skipped only when ``require_auth=False``.
- Replay check is skipped only when no ``replay_guard`` is provided
at construction time. If a guard **is** configured but ``request_id``
is not supplied at evaluation time, the gate **rejects** (fail-closed).
- Scope/allowlist is skipped only when no ``allowlist`` is provided
at construction time. If an allowlist **is** configured but
``user_id`` is not supplied, the gate **rejects** (fail-closed).
"""
def __init__(
self,
*,
expected_token: Optional[str] = None,
hmac_secret: Optional[str] = None,
replay_guard: Optional[ReplayGuard] = None,
allowlist: Optional[AllowlistPolicy] = None,
require_auth: bool = True,
):
self._expected_token = expected_token
self._hmac_secret = hmac_secret
self._replay_guard = replay_guard
self._allowlist = allowlist
self._require_auth = require_auth
def evaluate(
self,
*,
auth_header: str = "",
body: bytes = b"",
signature_header: str = "",
request_id: str = "",
user_id: str = "",
) -> IngressDecision:
"""
Run all ingress checks in fail-closed order.
1. Auth (bearer or HMAC)
2. Replay guard — rejects if guard configured but ``request_id`` missing
3. Scope/allowlist — rejects if allowlist configured but ``user_id`` missing
"""
decision = IngressDecision()
# --- Auth ---
if self._require_auth:
if self._hmac_secret and body:
auth_result = verify_hmac_signature(
body,
signature_header=signature_header,
secret=self._hmac_secret,
)
elif self._expected_token:
auth_result = verify_bearer_token(
auth_header,
expected_token=self._expected_token,
)
else:
# No auth configured but required → fail-closed
auth_result = AuthVerifyResult(
ok=False,
scheme=AuthScheme.NONE.value,
error="no_auth_configured",
)
decision.auth = auth_result
if not auth_result.ok:
decision.error = auth_failure_error(auth_result)
return decision
# --- Replay ---
if self._replay_guard:
if not request_id:
# Guard configured but no request_id supplied → fail-closed
decision.replay_ok = False
decision.error = to_transport_error(
code="replay_missing_request_id",
message="Replay guard active but no request_id supplied",
retryable=False,
)
return decision
if self._replay_guard.is_duplicate(request_id):
decision.replay_ok = False
decision.error = replay_error(request_id)
return decision
# --- Scope ---
if self._allowlist:
if not user_id:
# Allowlist configured but no user_id supplied → fail-closed
decision.scope = ScopeResult(
decision=ScopeDecision.DENY.value,
reason="missing_user_id",
)
decision.error = to_transport_error(
code="scope_missing_user_id",
message="Allowlist active but no user_id supplied",
retryable=False,
)
return decision
scope_result = self._allowlist.evaluate(user_id)
decision.scope = scope_result
if scope_result.decision == ScopeDecision.DENY.value:
decision.error = scope_denial_error(scope_result)
return decision
decision.allowed = True
return decision
# ---------------------------------------------------------------------------
# Convenience: Security Profile (bundles IngressGate + contract references)
# ---------------------------------------------------------------------------
@dataclass
class ConnectorSecurityProfile:
"""
High-level security profile for a connector deployment.
Bundles ingress gate, token contract, callback contract, and
reconnect policy into a single auditable configuration.
"""
name: str = ""
ingress_gate: Optional[IngressGate] = None
token_contract: Optional[TokenContract] = None
callback_contract: Optional[CallbackContract] = None
reconnect_policy: Optional[ReconnectPolicy] = None
# Posture flags for diagnostics
require_auth: bool = True
require_allowlist: bool = True
strict_callbacks: bool = True
def posture_summary(self) -> Dict[str, Any]:
"""Return a diagnostics-safe posture summary."""
return {
"name": self.name,
"require_auth": self.require_auth,
"require_allowlist": self.require_allowlist,
"strict_callbacks": self.strict_callbacks,
"has_ingress_gate": self.ingress_gate is not None,
"has_token_contract": self.token_contract is not None,
"has_callback_contract": self.callback_contract is not None,
"has_reconnect_policy": self.reconnect_policy is not None,
}
+131 -1
View File
@@ -1,5 +1,5 @@
"""
S30 — ComfyUI-Aware Security Doctor.
S30 + S32 — ComfyUI-Aware Security Doctor.
Deploy-time and runtime security diagnostics specific to ComfyUI extension operations.
Read-only checks by default; optional guarded remediation for safe/local actions only.
@@ -12,6 +12,7 @@ Checks:
- Redaction drift: verify redaction patterns cover known sensitive keys
- ComfyUI runtime mode: Desktop/portable/venv compatibility
- Feature flag posture: high-risk features default-off check
- S32 connector security posture: token, allowlist, callback, DM policy
Usage:
from services.security_doctor import run_security_doctor
@@ -698,6 +699,134 @@ def check_api_key_posture(report: SecurityReport) -> None:
)
# ---------------------------------------------------------------------------
# Security checks — S32 Connector security posture
# ---------------------------------------------------------------------------
# Environment variables checked by S32
_S32_CONNECTOR_TOKEN_VARS = [
"OPENCLAW_CONNECTOR_ADMIN_TOKEN",
"OPENCLAW_CONNECTOR_TELEGRAM_TOKEN",
"OPENCLAW_CONNECTOR_DISCORD_TOKEN",
"OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET",
"OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET",
]
_S32_ALLOWLIST_VARS = [
"OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_CHANNELS",
"OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS",
]
def check_connector_security_posture(report: SecurityReport) -> None:
"""S32: Check connector security posture for internet-exposed deployments."""
# --- Token presence ---
active_tokens = []
missing_tokens = []
for var in _S32_CONNECTOR_TOKEN_VARS:
val = os.environ.get(var, "").strip()
if val:
active_tokens.append(var)
else:
missing_tokens.append(var)
if active_tokens:
report.add(
SecurityCheckResult(
name="s32_connector_tokens",
severity=SecuritySeverity.PASS.value,
message=f"{len(active_tokens)} connector token(s) configured",
category="connector",
detail="Active: " + ", ".join(active_tokens),
)
)
else:
report.add(
SecurityCheckResult(
name="s32_connector_tokens",
severity=SecuritySeverity.INFO.value,
message="No connector tokens configured (connectors not enabled)",
category="connector",
)
)
# --- Allowlist coverage ---
configured_allowlists = []
for var in _S32_ALLOWLIST_VARS:
val = os.environ.get(var, "").strip()
if val:
configured_allowlists.append(var)
# Only warn if tokens are configured but allowlists are empty
if active_tokens and not configured_allowlists:
report.add(
SecurityCheckResult(
name="s32_allowlist_coverage",
severity=SecuritySeverity.WARN.value,
message="Connector tokens active but no user/channel allowlists configured",
category="connector",
detail="Without allowlists, connectors may accept messages from any user.",
remediation=("Set at least one of: " + ", ".join(_S32_ALLOWLIST_VARS)),
)
)
elif configured_allowlists:
report.add(
SecurityCheckResult(
name="s32_allowlist_coverage",
severity=SecuritySeverity.PASS.value,
message=f"{len(configured_allowlists)} connector allowlist(s) configured",
category="connector",
)
)
# --- Webhook signature verification posture ---
wa_token = os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN", "").strip()
wa_secret = os.environ.get("OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET", "").strip()
line_secret = os.environ.get("OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET", "").strip()
line_token = os.environ.get(
"OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN", ""
).strip()
if wa_token and not wa_secret:
report.add(
SecurityCheckResult(
name="s32_whatsapp_sig_missing",
severity=SecuritySeverity.WARN.value,
message="WhatsApp access token set but app_secret missing — webhook signature verification disabled",
category="connector",
remediation="Set OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET for production webhook security.",
)
)
if line_token and not line_secret:
report.add(
SecurityCheckResult(
name="s32_line_sig_missing",
severity=SecuritySeverity.WARN.value,
message="LINE access token set but channel_secret missing — webhook signature verification disabled",
category="connector",
remediation="Set OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET for production webhook security.",
)
)
# --- DM policy open warning ---
dev_mode = os.environ.get("MOLTBOT_DEV_MODE", "").strip().lower()
if dev_mode in ("1", "true", "yes", "on") and active_tokens:
report.add(
SecurityCheckResult(
name="s32_dev_mode_with_connectors",
severity=SecuritySeverity.WARN.value,
message="Dev mode enabled with active connectors — auth bypass risk",
category="connector",
remediation="Disable MOLTBOT_DEV_MODE when connectors are internet-exposed.",
)
)
# ---------------------------------------------------------------------------
# Guarded remediation — safe/local-only actions
# ---------------------------------------------------------------------------
@@ -848,6 +977,7 @@ def run_security_doctor(
check_comfyui_runtime(report)
check_feature_flags(report)
check_api_key_posture(report)
check_connector_security_posture(report) # S32
# Optional guarded remediation
if remediate:
+745
View File
@@ -0,0 +1,745 @@
"""S32 Connector Security Profile — Unit Tests.
Tests cover:
- WP1: Auth verification (Bearer + HMAC), replay guard, scope/allowlist
- WP2: Composite ingress gate (fail-closed ordering), error envelope mapping
- WP3: Security Doctor integration (check_connector_security_posture)
- WP4: Regression — existing transport contract unaffected
- Runtime primitives integration (R75 transport contract wiring)
- Serialization safety for result types
"""
from __future__ import annotations
import hashlib
import hmac as hmac_mod
import os
import time
import unittest
from connector.security_profile import (
AllowlistPolicy,
AuthScheme,
AuthVerifyResult,
ConnectorSecurityProfile,
IngressDecision,
IngressGate,
ReplayGuard,
ScopeDecision,
ScopeResult,
auth_failure_error,
replay_error,
scope_denial_error,
to_transport_error,
verify_bearer_token,
verify_hmac_signature,
)
from connector.transport_contract import (
CallbackContract,
CallbackError,
ReconnectPolicy,
TokenContract,
TokenError,
TokenSource,
)
# =========================================================================
# WP1 — Auth Verification Tests
# =========================================================================
class TestBearerTokenVerification(unittest.TestCase):
"""Bearer token auth header verification."""
def test_valid_bearer_with_prefix(self):
result = verify_bearer_token(
"Bearer my-secret-token", expected_token="my-secret-token"
)
self.assertTrue(result.ok)
self.assertEqual(result.scheme, AuthScheme.BEARER.value)
self.assertEqual(result.identity, "bearer")
def test_valid_bearer_without_prefix(self):
result = verify_bearer_token(
"my-secret-token", expected_token="my-secret-token"
)
self.assertTrue(result.ok)
def test_bearer_case_insensitive_prefix(self):
result = verify_bearer_token(
"BEARER my-secret-token", expected_token="my-secret-token"
)
self.assertTrue(result.ok)
def test_bearer_mismatch_rejected(self):
result = verify_bearer_token(
"Bearer wrong-token", expected_token="correct-token"
)
self.assertFalse(result.ok)
self.assertEqual(result.error, "token_mismatch")
def test_empty_header_rejected(self):
result = verify_bearer_token("", expected_token="some-token")
self.assertFalse(result.ok)
self.assertEqual(result.error, "missing_header")
def test_empty_expected_token_rejected(self):
result = verify_bearer_token("Bearer some-token", expected_token="")
self.assertFalse(result.ok)
self.assertEqual(result.error, "missing_token")
def test_both_empty_rejected(self):
result = verify_bearer_token("", expected_token="")
self.assertFalse(result.ok)
def test_whitespace_handling(self):
result = verify_bearer_token(
"Bearer my-secret-token ", expected_token="my-secret-token"
)
self.assertTrue(result.ok)
class TestHmacSignatureVerification(unittest.TestCase):
"""HMAC-SHA256 signature verification for webhook payloads."""
def _make_sig(self, body: bytes, secret: str, algo: str = "sha256") -> str:
hash_fn = hashlib.sha256 if algo == "sha256" else hashlib.sha1
return hmac_mod.new(secret.encode(), body, hash_fn).hexdigest()
def test_valid_signature(self):
body = b'{"event":"test"}'
secret = "webhook-secret-key"
sig = self._make_sig(body, secret)
result = verify_hmac_signature(body, signature_header=sig, secret=secret)
self.assertTrue(result.ok)
self.assertEqual(result.scheme, AuthScheme.HMAC_SHA256.value)
def test_valid_signature_with_prefix(self):
body = b'{"event":"test"}'
secret = "webhook-secret-key"
sig = "sha256=" + self._make_sig(body, secret)
result = verify_hmac_signature(body, signature_header=sig, secret=secret)
self.assertTrue(result.ok)
def test_signature_mismatch_rejected(self):
body = b'{"event":"test"}'
result = verify_hmac_signature(
body, signature_header="bad-sig", secret="my-secret"
)
self.assertFalse(result.ok)
self.assertEqual(result.error, "signature_mismatch")
def test_missing_secret_rejected(self):
result = verify_hmac_signature(b"body", signature_header="sig", secret="")
self.assertFalse(result.ok)
self.assertEqual(result.error, "missing_secret")
def test_missing_signature_header_rejected(self):
result = verify_hmac_signature(b"body", signature_header="", secret="secret")
self.assertFalse(result.ok)
self.assertEqual(result.error, "missing_signature_header")
def test_unsupported_algorithm_rejected(self):
result = verify_hmac_signature(
b"body", signature_header="sig", secret="secret", algorithm="md5"
)
self.assertFalse(result.ok)
self.assertIn("unsupported_algorithm", result.error)
def test_sha1_algorithm_supported(self):
body = b"test-payload"
secret = "line-channel-secret"
sig = self._make_sig(body, secret, algo="sha1")
result = verify_hmac_signature(
body, signature_header=sig, secret=secret, algorithm="sha1"
)
self.assertTrue(result.ok)
# =========================================================================
# WP1 — Replay Guard Tests
# =========================================================================
class TestReplayGuard(unittest.TestCase):
"""Sliding-window replay/duplicate detector."""
def test_first_request_is_new(self):
guard = ReplayGuard(window_sec=60)
self.assertTrue(guard.check_and_record("req-001"))
def test_duplicate_within_window_rejected(self):
guard = ReplayGuard(window_sec=60)
guard.check_and_record("req-001")
self.assertFalse(guard.check_and_record("req-001"))
def test_different_keys_allowed(self):
guard = ReplayGuard(window_sec=60)
self.assertTrue(guard.check_and_record("req-001"))
self.assertTrue(guard.check_and_record("req-002"))
def test_expired_entry_treated_as_new(self):
guard = ReplayGuard(window_sec=1)
guard.check_and_record("req-001")
# Simulate window expiry
guard._seen["req-001"] = time.time() - 2
self.assertTrue(guard.check_and_record("req-001"))
def test_max_entries_enforced(self):
guard = ReplayGuard(window_sec=600, max_entries=5)
for i in range(10):
guard.check_and_record(f"req-{i:03d}")
self.assertLessEqual(guard.size, 5)
def test_is_duplicate_inverse(self):
guard = ReplayGuard(window_sec=60)
self.assertFalse(guard.is_duplicate("req-001"))
self.assertTrue(guard.is_duplicate("req-001"))
def test_window_sec_property(self):
guard = ReplayGuard(window_sec=120)
self.assertEqual(guard.window_sec, 120)
# =========================================================================
# WP1 — Allowlist / Scope Tests
# =========================================================================
class TestAllowlistPolicy(unittest.TestCase):
"""Scope/allowlist evaluation with fail-closed defaults."""
def test_allow_matching_entry(self):
policy = AllowlistPolicy(["user-a", "user-b"])
result = policy.evaluate("user-a")
self.assertEqual(result.decision, ScopeDecision.ALLOW.value)
def test_deny_non_matching_entry(self):
policy = AllowlistPolicy(["user-a", "user-b"])
result = policy.evaluate("user-c")
self.assertEqual(result.decision, ScopeDecision.DENY.value)
self.assertEqual(result.reason, "not_in_allowlist")
def test_empty_allowlist_strict_mode_denies(self):
"""Fail-closed: empty allowlist with strict=True denies all."""
policy = AllowlistPolicy([], strict=True)
result = policy.evaluate("any-user")
self.assertEqual(result.decision, ScopeDecision.DENY.value)
self.assertEqual(result.reason, "empty_allowlist_strict")
def test_empty_allowlist_permissive_mode_skips(self):
policy = AllowlistPolicy([], strict=False)
result = policy.evaluate("any-user")
self.assertEqual(result.decision, ScopeDecision.SKIP.value)
def test_case_insensitive_by_default(self):
policy = AllowlistPolicy(["User-A"])
result = policy.evaluate("USER-A")
self.assertEqual(result.decision, ScopeDecision.ALLOW.value)
def test_whitespace_normalised(self):
policy = AllowlistPolicy([" user-a "])
result = policy.evaluate("user-a")
self.assertEqual(result.decision, ScopeDecision.ALLOW.value)
def test_custom_normalizer(self):
policy = AllowlistPolicy(
["kakao:user123"],
normalizer=lambda x: x.strip().lower().replace("kakao:", ""),
)
result = policy.evaluate("kakao:USER123")
self.assertEqual(result.decision, ScopeDecision.ALLOW.value)
def test_entries_property(self):
policy = AllowlistPolicy(["a", "b"])
self.assertEqual(policy.entries, frozenset({"a", "b"}))
def test_strict_property(self):
self.assertTrue(AllowlistPolicy([], strict=True).strict)
self.assertFalse(AllowlistPolicy([], strict=False).strict)
def test_none_entries_treated_as_empty(self):
policy = AllowlistPolicy(None, strict=True)
result = policy.evaluate("any")
self.assertEqual(result.decision, ScopeDecision.DENY.value)
# =========================================================================
# WP2 — Composite Ingress Gate Tests
# =========================================================================
class TestIngressGate(unittest.TestCase):
"""Composite fail-closed ingress gate evaluation."""
def test_all_pass(self):
gate = IngressGate(
expected_token="my-token",
replay_guard=ReplayGuard(window_sec=60),
allowlist=AllowlistPolicy(["user-a"]),
)
decision = gate.evaluate(
auth_header="Bearer my-token",
request_id="req-001",
user_id="user-a",
)
self.assertTrue(decision.allowed)
self.assertIsNone(decision.error)
def test_auth_failure_short_circuits(self):
gate = IngressGate(expected_token="correct")
decision = gate.evaluate(auth_header="Bearer wrong")
self.assertFalse(decision.allowed)
self.assertIsNotNone(decision.error)
self.assertIn("auth_", decision.error.code)
def test_replay_failure_short_circuits(self):
guard = ReplayGuard(window_sec=60)
gate = IngressGate(
expected_token="tok",
replay_guard=guard,
)
gate.evaluate(auth_header="Bearer tok", request_id="req-dup")
decision = gate.evaluate(auth_header="Bearer tok", request_id="req-dup")
self.assertFalse(decision.allowed)
self.assertEqual(decision.error.code, "replay_detected")
def test_scope_failure_short_circuits(self):
gate = IngressGate(
expected_token="tok",
allowlist=AllowlistPolicy(["user-a"]),
)
decision = gate.evaluate(
auth_header="Bearer tok",
user_id="user-b",
)
self.assertFalse(decision.allowed)
self.assertIn("scope_", decision.error.code)
def test_no_auth_configured_fail_closed(self):
"""require_auth=True but no token/secret configured → reject."""
gate = IngressGate(require_auth=True)
decision = gate.evaluate()
self.assertFalse(decision.allowed)
self.assertEqual(decision.auth.error, "no_auth_configured")
def test_require_auth_false_skips_auth_check(self):
gate = IngressGate(require_auth=False)
decision = gate.evaluate()
self.assertTrue(decision.allowed)
def test_hmac_preferred_over_bearer(self):
"""When both HMAC secret and body are present, HMAC is used."""
body = b'{"test": 1}'
secret = "my-hmac-secret"
sig = hmac_mod.new(secret.encode(), body, hashlib.sha256).hexdigest()
gate = IngressGate(
expected_token="bearer-token",
hmac_secret=secret,
)
decision = gate.evaluate(
body=body,
signature_header=sig,
)
self.assertTrue(decision.allowed)
self.assertEqual(decision.auth.scheme, AuthScheme.HMAC_SHA256.value)
def test_decision_to_dict(self):
gate = IngressGate(expected_token="tok")
decision = gate.evaluate(auth_header="Bearer tok")
d = decision.to_dict()
self.assertTrue(d["allowed"])
self.assertIn("auth", d)
self.assertTrue(d["replay_ok"])
def test_no_replay_guard_skips_check(self):
gate = IngressGate(expected_token="tok")
decision = gate.evaluate(
auth_header="Bearer tok",
request_id="req-001",
)
self.assertTrue(decision.allowed)
def test_no_allowlist_skips_scope_check(self):
gate = IngressGate(expected_token="tok")
decision = gate.evaluate(
auth_header="Bearer tok",
user_id="user-x",
)
self.assertTrue(decision.allowed)
def test_replay_guard_configured_but_request_id_missing_rejects(self):
"""Fail-closed: replay guard active but no request_id → reject."""
guard = ReplayGuard(window_sec=60)
gate = IngressGate(expected_token="tok", replay_guard=guard)
decision = gate.evaluate(auth_header="Bearer tok") # no request_id
self.assertFalse(decision.allowed)
self.assertFalse(decision.replay_ok)
self.assertEqual(decision.error.code, "replay_missing_request_id")
def test_allowlist_configured_but_user_id_missing_rejects(self):
"""Fail-closed: allowlist active but no user_id → reject."""
gate = IngressGate(
expected_token="tok",
allowlist=AllowlistPolicy(["user-a"]),
)
decision = gate.evaluate(auth_header="Bearer tok") # no user_id
self.assertFalse(decision.allowed)
self.assertEqual(decision.error.code, "scope_missing_user_id")
# =========================================================================
# WP3 — Error Envelope Mapping Tests
# =========================================================================
class TestErrorMapping(unittest.TestCase):
"""Fail-closed error envelope mapping."""
def test_transport_error_creation(self):
err = to_transport_error("some_code", "Some message", retryable=True)
self.assertEqual(err.code, "some_code")
self.assertEqual(err.message, "Some message")
self.assertTrue(err.retryable)
def test_auth_failure_error_mapping(self):
result = AuthVerifyResult(ok=False, scheme="bearer", error="token_mismatch")
err = auth_failure_error(result)
self.assertEqual(err.code, "auth_token_mismatch")
self.assertFalse(err.retryable)
def test_scope_denial_error_mapping(self):
result = ScopeResult(
decision=ScopeDecision.DENY.value, reason="not_in_allowlist"
)
err = scope_denial_error(result)
self.assertEqual(err.code, "scope_not_in_allowlist")
def test_replay_error_mapping(self):
err = replay_error("my-long-request-id-12345")
self.assertEqual(err.code, "replay_detected")
self.assertIn("key_prefix", err.details)
# Truncated to 8 chars + "..."
self.assertEqual(err.details["key_prefix"], "my-long-...")
def test_replay_error_short_key(self):
err = replay_error("abc")
self.assertEqual(err.details["key_prefix"], "abc")
# =========================================================================
# WP2 — Runtime Primitives Integration Tests
# =========================================================================
class TestRuntimePrimitivesIntegration(unittest.TestCase):
"""Verify S32 profile correctly wraps R75 transport primitives."""
def test_callback_strict_mode_via_profile(self):
"""Profile uses strict CallbackContract by default."""
profile = ConnectorSecurityProfile(
name="kakao",
callback_contract=CallbackContract(ack_window_sec=5),
strict_callbacks=True,
)
cb = profile.callback_contract.create(idempotency_key="idem-1")
self.assertTrue(cb.require_ack)
# Cannot deliver without ack
with self.assertRaises(CallbackError):
profile.callback_contract.deliver(cb.callback_id)
def test_callback_compat_mode_explicit(self):
"""Explicit compatibility mode allows direct delivery."""
cc = CallbackContract()
cb = cc.create(
idempotency_key="idem-2",
allow_direct_delivery=True,
)
self.assertFalse(cb.require_ack)
delivered = cc.deliver(cb.callback_id)
self.assertEqual(delivered.state, "delivered")
def test_token_contract_fail_closed(self):
"""Token contract rejects when required token is missing."""
tc = TokenContract(
sources=[
TokenSource(
name="kakao_admin",
env_var="KAKAO_ADMIN_TOKEN",
precedence=1,
required=True,
),
]
)
with self.assertRaises(TokenError):
tc.validate_or_reject(env={})
def test_token_contract_resolves_with_precedence(self):
tc = TokenContract(
sources=[
TokenSource(name="primary", env_var="TOK_A", precedence=1),
TokenSource(name="fallback", env_var="TOK_B", precedence=2),
]
)
result = tc.resolve(env={"TOK_A": "val-a", "TOK_B": "val-b"})
self.assertEqual(result.source_name, "primary")
self.assertEqual(result.raw_value, "val-a")
def test_token_to_dict_excludes_raw(self):
tc = TokenContract(sources=[TokenSource(name="x", env_var="X", precedence=1)])
result = tc.resolve(env={"X": "secret-value-12345"})
d = result.to_dict()
self.assertNotIn("_raw_value", d)
self.assertNotIn("raw_value", d)
self.assertIn("masked_value", d)
def test_reconnect_policy_bounded(self):
rp = ReconnectPolicy(max_retries=3, max_delay_ms=10000)
self.assertTrue(rp.should_retry(0))
self.assertTrue(rp.should_retry(2))
self.assertFalse(rp.should_retry(3))
self.assertEqual(rp.compute_delay_ms(3), -1)
def test_posture_summary(self):
profile = ConnectorSecurityProfile(
name="test",
ingress_gate=IngressGate(expected_token="t"),
require_auth=True,
require_allowlist=True,
strict_callbacks=True,
)
summary = profile.posture_summary()
self.assertEqual(summary["name"], "test")
self.assertTrue(summary["require_auth"])
self.assertTrue(summary["has_ingress_gate"])
self.assertFalse(summary["has_callback_contract"])
# =========================================================================
# WP3 — Security Doctor Integration Tests
# =========================================================================
class TestSecurityDoctorConnectorPosture(unittest.TestCase):
"""WP3: Verify check_connector_security_posture() in Security Doctor."""
def setUp(self):
# Lazy import to avoid coupling test collection to aiohttp etc.
from services.security_doctor import (
SecurityReport,
SecuritySeverity,
check_connector_security_posture,
)
self.SecurityReport = SecurityReport
self.SecuritySeverity = SecuritySeverity
self.check = check_connector_security_posture
# Save and clear all S32-relevant env vars
self._saved_env = {}
s32_vars = [
"OPENCLAW_CONNECTOR_ADMIN_TOKEN",
"OPENCLAW_CONNECTOR_TELEGRAM_TOKEN",
"OPENCLAW_CONNECTOR_DISCORD_TOKEN",
"OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET",
"OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN",
"OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET",
"OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_DISCORD_ALLOWED_CHANNELS",
"OPENCLAW_CONNECTOR_LINE_ALLOWED_USERS",
"OPENCLAW_CONNECTOR_WHATSAPP_ALLOWED_USERS",
"MOLTBOT_DEV_MODE",
]
for var in s32_vars:
self._saved_env[var] = os.environ.pop(var, None)
def tearDown(self):
# Restore original env vars
for var, val in self._saved_env.items():
if val is None:
os.environ.pop(var, None)
else:
os.environ[var] = val
def _get_checks(self, report, prefix="s32_"):
return [c for c in report.checks if c.name.startswith(prefix)]
def test_no_tokens_emits_info(self):
"""When no connector tokens are configured, emit INFO (not WARN/FAIL)."""
report = self.SecurityReport()
self.check(report)
checks = self._get_checks(report)
token_check = [c for c in checks if c.name == "s32_connector_tokens"]
self.assertEqual(len(token_check), 1)
self.assertEqual(token_check[0].severity, self.SecuritySeverity.INFO.value)
self.assertIn("not enabled", token_check[0].message.lower())
def test_tokens_with_allowlist_passes(self):
"""Tokens + at least one allowlist → PASS for both."""
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"] = "test-tok"
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_ALLOWED_USERS"] = "123"
report = self.SecurityReport()
self.check(report)
checks = self._get_checks(report)
token_check = [c for c in checks if c.name == "s32_connector_tokens"][0]
allowlist_check = [c for c in checks if c.name == "s32_allowlist_coverage"][0]
self.assertEqual(token_check.severity, self.SecuritySeverity.PASS.value)
self.assertEqual(allowlist_check.severity, self.SecuritySeverity.PASS.value)
def test_tokens_without_allowlist_warns(self):
"""Tokens active but no allowlist → WARN."""
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"] = "test-tok"
report = self.SecurityReport()
self.check(report)
checks = self._get_checks(report)
allowlist_check = [c for c in checks if c.name == "s32_allowlist_coverage"]
self.assertEqual(len(allowlist_check), 1)
self.assertEqual(allowlist_check[0].severity, self.SecuritySeverity.WARN.value)
self.assertTrue(
len(allowlist_check[0].remediation) > 0, "Should have remediation text"
)
def test_whatsapp_token_without_secret_warns(self):
"""WhatsApp access token set without app_secret → WARN."""
os.environ["OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN"] = "wa-tok"
report = self.SecurityReport()
self.check(report)
sig_check = [c for c in report.checks if c.name == "s32_whatsapp_sig_missing"]
self.assertEqual(len(sig_check), 1)
self.assertEqual(sig_check[0].severity, self.SecuritySeverity.WARN.value)
def test_whatsapp_token_with_secret_no_warn(self):
"""Both WhatsApp token and secret → no signature warning."""
os.environ["OPENCLAW_CONNECTOR_WHATSAPP_ACCESS_TOKEN"] = "wa-tok"
os.environ["OPENCLAW_CONNECTOR_WHATSAPP_APP_SECRET"] = "wa-secret"
report = self.SecurityReport()
self.check(report)
sig_check = [c for c in report.checks if c.name == "s32_whatsapp_sig_missing"]
self.assertEqual(len(sig_check), 0)
def test_line_token_without_secret_warns(self):
"""LINE access token set without channel_secret → WARN."""
os.environ["OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN"] = "line-tok"
report = self.SecurityReport()
self.check(report)
sig_check = [c for c in report.checks if c.name == "s32_line_sig_missing"]
self.assertEqual(len(sig_check), 1)
self.assertEqual(sig_check[0].severity, self.SecuritySeverity.WARN.value)
def test_line_both_set_no_warn(self):
"""Both LINE token and secret → no signature warning."""
os.environ["OPENCLAW_CONNECTOR_LINE_CHANNEL_ACCESS_TOKEN"] = "line-tok"
os.environ["OPENCLAW_CONNECTOR_LINE_CHANNEL_SECRET"] = "line-sec"
report = self.SecurityReport()
self.check(report)
sig_check = [c for c in report.checks if c.name == "s32_line_sig_missing"]
self.assertEqual(len(sig_check), 0)
def test_dev_mode_with_tokens_warns(self):
"""Dev mode + active tokens → WARN."""
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"] = "tok"
os.environ["MOLTBOT_DEV_MODE"] = "true"
report = self.SecurityReport()
self.check(report)
dev_check = [
c for c in report.checks if c.name == "s32_dev_mode_with_connectors"
]
self.assertEqual(len(dev_check), 1)
self.assertEqual(dev_check[0].severity, self.SecuritySeverity.WARN.value)
def test_dev_mode_without_tokens_no_warn(self):
"""Dev mode without any tokens → no connector warning."""
os.environ["MOLTBOT_DEV_MODE"] = "1"
report = self.SecurityReport()
self.check(report)
dev_check = [
c for c in report.checks if c.name == "s32_dev_mode_with_connectors"
]
self.assertEqual(len(dev_check), 0)
def test_all_checks_have_connector_category(self):
"""All S32 checks should have category='connector'."""
os.environ["OPENCLAW_CONNECTOR_TELEGRAM_TOKEN"] = "tok"
report = self.SecurityReport()
self.check(report)
s32_checks = self._get_checks(report)
for c in s32_checks:
self.assertEqual(c.category, "connector", f"{c.name} missing category")
# =========================================================================
# WP4 — Regression: Existing transport contract unaffected
# =========================================================================
class TestTransportContractRegression(unittest.TestCase):
"""Verify existing R75 transport contract is not broken by S32."""
def test_callback_default_strict(self):
"""Default callback creation should still be strict."""
cc = CallbackContract()
cb = cc.create(idempotency_key="reg-1")
self.assertTrue(cb.require_ack)
self.assertFalse(cb.allow_direct_delivery)
def test_callback_idempotency_preserved(self):
cc = CallbackContract()
cb1 = cc.create(idempotency_key="same-key")
cb2 = cc.create(idempotency_key="same-key")
self.assertEqual(cb1.callback_id, cb2.callback_id)
def test_callback_ack_then_deliver(self):
cc = CallbackContract(ack_window_sec=9999)
cb = cc.create(idempotency_key="reg-2")
cc.acknowledge(cb.callback_id)
delivered = cc.deliver(cb.callback_id)
self.assertEqual(delivered.state, "delivered")
def test_token_mask_format(self):
tc = TokenContract(sources=[TokenSource(name="a", env_var="A", precedence=1)])
result = tc.resolve(env={"A": "abcdefghijklmnop"})
self.assertIn("***", result.masked_value)
self.assertNotIn("abcdefghijklmnop", result.masked_value)
def test_reconnect_jitter_bounded(self):
rp = ReconnectPolicy(jitter_ms=500)
for _ in range(50):
delay = rp.compute_delay_ms(0)
self.assertGreaterEqual(delay, rp.initial_delay_ms)
self.assertLessEqual(delay, rp.initial_delay_ms + 500)
# =========================================================================
# AuthVerifyResult / ScopeResult serialization
# =========================================================================
class TestResultSerialization(unittest.TestCase):
"""Serialization safety for auth and scope results."""
def test_auth_result_to_dict_success(self):
r = AuthVerifyResult(ok=True, scheme="bearer", identity="bearer")
d = r.to_dict()
self.assertTrue(d["ok"])
self.assertIn("identity", d)
def test_auth_result_to_dict_failure(self):
r = AuthVerifyResult(ok=False, scheme="bearer", error="missing_header")
d = r.to_dict()
self.assertFalse(d["ok"])
self.assertIn("error", d)
self.assertNotIn("identity", d)
def test_scope_result_to_dict(self):
r = ScopeResult(decision="allow", matched_entry="user-a")
d = r.to_dict()
self.assertEqual(d["decision"], "allow")
self.assertEqual(d["matched_entry"], "user-a")
if __name__ == "__main__":
unittest.main()