feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X. WordPress media pipeline - Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries. Capability discovery (F.7e) - Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error. Companion plugin overhaul - Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge. - Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach. - wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL. Other - Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork. - Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8). - n8n refactor: structured errors, capability probe, missing tools backfilled. - Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites. Docs - README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it. - Tools/Tests badges removed in favour of "Plugins: 10". Deployment - PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant. CI - Black + Ruff clean on Python 3.12; pytest tests/ green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
325
core/companion_audit.py
Normal file
325
core/companion_audit.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""F.18.7 — Companion audit-hook receiver + per-site secret store.
|
||||
|
||||
The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress
|
||||
action events (post transitions, user events, plugin activations, etc.)
|
||||
to MCPHub as HMAC-SHA256-signed webhooks. This module provides:
|
||||
|
||||
1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``.
|
||||
2. ``verify_companion_signature`` — constant-time HMAC verification.
|
||||
3. ``handle_companion_audit_request`` — Starlette handler that validates
|
||||
the signature, appends the event to the audit log, and returns 200.
|
||||
|
||||
The store is intentionally independent of the SQLite sites DB so the
|
||||
webhook can land before a dashboard UI exists; the UI (future work) can
|
||||
replace ``CompanionAuditSecretStore`` with DB-backed storage without
|
||||
changing the wire format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger("mcphub.companion_audit")
|
||||
|
||||
|
||||
def _parse_timestamp(raw: Any) -> float | None:
|
||||
"""Coerce the envelope ``timestamp`` field to epoch seconds.
|
||||
|
||||
Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the
|
||||
companion plugin's current format via PHP ``gmdate``) or a numeric
|
||||
epoch value (for forward compatibility with other senders).
|
||||
Returns None on any parse failure so callers can emit a uniform
|
||||
401 without leaking which step failed.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, int | float):
|
||||
return float(raw)
|
||||
if isinstance(raw, str):
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return None
|
||||
# Python 3.11+ fromisoformat handles "Z" suffix; on older
|
||||
# versions we translate it to +00:00 explicitly.
|
||||
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
|
||||
try:
|
||||
return datetime.fromisoformat(iso).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
# Numeric-string fallback (e.g. "1712345678").
|
||||
try:
|
||||
return float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# Pre-F.20 security sweep: bound incoming payload so a misconfigured
|
||||
# (or malicious) companion can't queue up unbounded memory with a
|
||||
# single request. Real audit events are well under 4 KB; 64 KB is a
|
||||
# generous ceiling that still fits within Starlette's default limit.
|
||||
_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024)))
|
||||
|
||||
# Replay-protection window (seconds). Events whose ``timestamp`` field
|
||||
# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401.
|
||||
# Set to 0 or negative to disable (not recommended). Default 5 minutes
|
||||
# balances clock-skew tolerance against replay risk.
|
||||
_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300"))
|
||||
|
||||
|
||||
def _normalise_url(url: str) -> str:
|
||||
return url.rstrip("/").strip().lower()
|
||||
|
||||
|
||||
class CompanionAuditSecretStore:
|
||||
"""File-backed ``site_url -> shared_secret`` map.
|
||||
|
||||
The file is JSON; keys are normalised (lowercased, trailing slash
|
||||
stripped). Access is serialised through a lock to keep reads and
|
||||
writes atomic on a single-process server. Multi-process deployments
|
||||
(gunicorn workers) can still race on the write path — that's fine
|
||||
because the dashboard UI will be the only writer and operates in
|
||||
the master process for this MVP.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.Lock()
|
||||
self._cache: dict[str, str] | None = None
|
||||
|
||||
def _load(self) -> dict[str, str]:
|
||||
if self._cache is not None:
|
||||
return self._cache
|
||||
if not self.path.exists():
|
||||
self._cache = {}
|
||||
return self._cache
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
logger.warning(
|
||||
"companion audit secret file %s is not a JSON object; ignoring.",
|
||||
self.path,
|
||||
)
|
||||
self._cache = {}
|
||||
else:
|
||||
self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Could not read companion audit secret file %s: %s", self.path, exc)
|
||||
self._cache = {}
|
||||
return self._cache
|
||||
|
||||
def _save(self, data: dict[str, str]) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(self.path)
|
||||
try:
|
||||
os.chmod(self.path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
self._cache = dict(data)
|
||||
|
||||
def get(self, site_url: str) -> str | None:
|
||||
with self._lock:
|
||||
return self._load().get(_normalise_url(site_url))
|
||||
|
||||
def set(self, site_url: str, secret: str) -> None:
|
||||
if not secret or len(secret) < 16:
|
||||
raise ValueError("companion audit secret must be at least 16 characters")
|
||||
with self._lock:
|
||||
data = dict(self._load())
|
||||
data[_normalise_url(site_url)] = secret
|
||||
self._save(data)
|
||||
|
||||
def delete(self, site_url: str) -> bool:
|
||||
with self._lock:
|
||||
data = dict(self._load())
|
||||
key = _normalise_url(site_url)
|
||||
if key not in data:
|
||||
return False
|
||||
del data[key]
|
||||
self._save(data)
|
||||
return True
|
||||
|
||||
def list_sites(self) -> list[dict[str, Any]]:
|
||||
"""List sites with secret metadata only — never returns plaintext."""
|
||||
with self._lock:
|
||||
return [
|
||||
{
|
||||
"site_url": site_url,
|
||||
"secret_set": True,
|
||||
"secret_last4": secret[-4:] if len(secret) >= 4 else "",
|
||||
}
|
||||
for site_url, secret in self._load().items()
|
||||
]
|
||||
|
||||
|
||||
_DEFAULT_STORE_PATH = Path(
|
||||
os.environ.get(
|
||||
"COMPANION_AUDIT_SECRETS_PATH",
|
||||
"/tmp/mcphub-data/companion-audit-secrets.json",
|
||||
)
|
||||
)
|
||||
_default_store: CompanionAuditSecretStore | None = None
|
||||
|
||||
|
||||
def get_companion_audit_store(
|
||||
path: str | Path | None = None,
|
||||
) -> CompanionAuditSecretStore:
|
||||
global _default_store
|
||||
if path is not None:
|
||||
return CompanionAuditSecretStore(path)
|
||||
if _default_store is None:
|
||||
_default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH)
|
||||
return _default_store
|
||||
|
||||
|
||||
def verify_companion_signature(
|
||||
body_bytes: bytes, signature_header: str | None, secret: str
|
||||
) -> bool:
|
||||
"""Constant-time HMAC-SHA256 verification.
|
||||
|
||||
Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant.
|
||||
Returns False on any shape error so callers can emit 401 uniformly.
|
||||
"""
|
||||
if not signature_header or not secret:
|
||||
return False
|
||||
sig = signature_header.strip()
|
||||
if sig.startswith("sha256="):
|
||||
sig = sig[len("sha256=") :]
|
||||
if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig):
|
||||
return False
|
||||
expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected.lower(), sig.lower())
|
||||
|
||||
|
||||
# Accepted event names — mirror the PHP side. Events outside this list
|
||||
# are still accepted but tagged so operators can spot rogue sources.
|
||||
_KNOWN_EVENTS = frozenset(
|
||||
{
|
||||
"transition_post_status",
|
||||
"deleted_post",
|
||||
"user_register",
|
||||
"profile_update",
|
||||
"deleted_user",
|
||||
"activated_plugin",
|
||||
"deactivated_plugin",
|
||||
"switch_theme",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_companion_audit_request(request: Request) -> JSONResponse:
|
||||
"""Starlette handler for ``POST /api/companion-audit``.
|
||||
|
||||
Validates the HMAC signature using a per-site secret, parses the
|
||||
envelope, applies a replay window on the ``timestamp`` field, and
|
||||
writes an audit-log entry. Returns 200 on success, 400 on shape
|
||||
errors, 401 on signature/replay failure, 413 on oversized body.
|
||||
"""
|
||||
# Pre-F.20 security sweep: bound the incoming body before reading the
|
||||
# whole thing. Prefer the framework's Content-Length hint; fall back
|
||||
# to reading up to one byte past the ceiling so we can cleanly 413.
|
||||
content_length_header = request.headers.get("content-length")
|
||||
if content_length_header:
|
||||
try:
|
||||
content_length = int(content_length_header)
|
||||
except ValueError:
|
||||
return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400)
|
||||
if content_length > _MAX_BODY_BYTES:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
# Read raw body — we need the exact bytes for HMAC verification.
|
||||
body_bytes = await request.body()
|
||||
if len(body_bytes) > _MAX_BODY_BYTES:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
|
||||
status_code=413,
|
||||
)
|
||||
if not body_bytes:
|
||||
return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400)
|
||||
|
||||
site_header = request.headers.get("X-Airano-MCP-Site") or ""
|
||||
signature = request.headers.get("X-Airano-MCP-Signature")
|
||||
|
||||
try:
|
||||
envelope = json.loads(body_bytes.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
|
||||
|
||||
if not isinstance(envelope, dict):
|
||||
return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400)
|
||||
|
||||
site_url = str(envelope.get("site_url") or site_header or "")
|
||||
if not site_url:
|
||||
return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400)
|
||||
|
||||
store = get_companion_audit_store()
|
||||
secret = store.get(site_url)
|
||||
if secret is None:
|
||||
# Don't leak whether the site exists; same response as a bad sig.
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
if not verify_companion_signature(body_bytes, signature, secret):
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
# Pre-F.20 security sweep: enforce a replay window on the signed
|
||||
# timestamp. Captured webhooks replayed outside the window are
|
||||
# rejected with 401 (same opaque response as a bad signature to
|
||||
# avoid giving attackers a distinguishing oracle).
|
||||
if _REPLAY_WINDOW_SECONDS > 0:
|
||||
ts = _parse_timestamp(envelope.get("timestamp"))
|
||||
if ts is None:
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
now = time.time()
|
||||
if abs(now - ts) > _REPLAY_WINDOW_SECONDS:
|
||||
logger.warning(
|
||||
"companion audit replay rejected site=%s skew=%.1fs",
|
||||
site_url,
|
||||
now - ts,
|
||||
)
|
||||
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
|
||||
|
||||
event_name = str(envelope.get("event") or "unknown")
|
||||
details = {
|
||||
"site_url": site_url,
|
||||
"event": event_name,
|
||||
"known_event": event_name in _KNOWN_EVENTS,
|
||||
"timestamp": envelope.get("timestamp"),
|
||||
"wp_user_id": envelope.get("user_id"),
|
||||
"plugin_version": envelope.get("plugin_version"),
|
||||
"data": envelope.get("data"),
|
||||
}
|
||||
|
||||
try:
|
||||
# Deferred import — avoid pulling audit_log into the store for tests.
|
||||
from core.audit_log import get_audit_logger
|
||||
|
||||
audit_logger = get_audit_logger()
|
||||
audit_logger.log_system_event(
|
||||
event=f"companion_audit:{event_name}",
|
||||
details=details,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("failed to append companion audit event: %s", exc)
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "audit_write_failed"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
return JSONResponse({"ok": True, "event": event_name}, status_code=200)
|
||||
Reference in New Issue
Block a user