163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
"""Webhook subscription table + CRUD DTOs.
|
|
|
|
Webhooks push DECNET bus events out to external SIEM / SOAR stacks
|
|
(Wazuh, Shuffle, TheHive, n8n, ...). Each subscription carries a set
|
|
of NATS-style topic patterns; the `decnet webhook` worker subscribes
|
|
to the union of patterns across all enabled subscriptions and POSTs
|
|
matching events to each matching URL with HMAC-SHA256 signing.
|
|
|
|
Simple mode (UI) exposes a friendly enum (`AttackerDetail`,
|
|
`DeckyStatus`, `SystemStatus`) that expands to patterns at save time.
|
|
Advanced mode lets an admin set raw patterns directly. Storage is
|
|
always the expanded list — the enum is sugar at the router layer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Any, List, Literal, Optional
|
|
from uuid import uuid4
|
|
|
|
from pydantic import BaseModel, Field as PydanticField, HttpUrl
|
|
from sqlmodel import Field, SQLModel
|
|
|
|
|
|
SimpleEvent = Literal["AttackerDetail", "DeckyStatus", "SystemStatus"]
|
|
|
|
|
|
class WebhookSubscription(SQLModel, table=True):
|
|
__tablename__ = "webhook_subscriptions"
|
|
|
|
uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
|
name: str = Field(index=True, unique=True)
|
|
url: str
|
|
secret: str # HMAC-SHA256 key; plaintext pre-v1 (see DEBT-037 §7)
|
|
# JSON-encoded list[str] of NATS-style bus topic patterns.
|
|
# Storing as TEXT keeps the schema portable across SQLite and MySQL
|
|
# without pulling in dialect-specific JSON columns.
|
|
topic_patterns: str = Field(default="[]")
|
|
enabled: bool = Field(default=True, index=True)
|
|
consecutive_failures: int = Field(default=0)
|
|
last_success_at: Optional[datetime] = None
|
|
last_failure_at: Optional[datetime] = None
|
|
last_error: Optional[str] = None
|
|
# Set when the circuit breaker auto-disables the subscription after
|
|
# too many consecutive failures. NULL means "not tripped" — the
|
|
# subscription is either active (enabled=True) or admin-paused
|
|
# (enabled=False, auto_disabled_at=NULL). A non-NULL stamp with
|
|
# enabled=False means the worker tripped it; the operator clears
|
|
# the flag by re-enabling via PATCH.
|
|
auto_disabled_at: Optional[datetime] = None
|
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
|
|
def patterns(self) -> list[str]:
|
|
"""Decode `topic_patterns` to a list. Returns [] on bad/empty JSON."""
|
|
try:
|
|
raw = json.loads(self.topic_patterns or "[]")
|
|
except (ValueError, TypeError):
|
|
return []
|
|
return [p for p in raw if isinstance(p, str)]
|
|
|
|
|
|
# --- API Request / Response Models (Pydantic) ---
|
|
|
|
|
|
class WebhookCreateRequest(BaseModel):
|
|
name: str = PydanticField(..., min_length=1, max_length=64)
|
|
url: HttpUrl
|
|
# If secret is omitted, the router generates a secure random one and
|
|
# returns it exactly once on the create response. After that, callers
|
|
# can only rotate via PATCH.
|
|
secret: Optional[str] = PydanticField(None, min_length=16, max_length=256)
|
|
# At least one of simple_events / topic_patterns must be non-empty
|
|
# (validated in the router, not Pydantic, so the 400 carries a clear
|
|
# detail message).
|
|
simple_events: List[SimpleEvent] = PydanticField(default_factory=list)
|
|
topic_patterns: List[str] = PydanticField(default_factory=list)
|
|
enabled: bool = True
|
|
|
|
|
|
class WebhookUpdateRequest(BaseModel):
|
|
# Partial update — every field optional; the router diffs against the
|
|
# current row and only writes what changed.
|
|
name: Optional[str] = PydanticField(None, min_length=1, max_length=64)
|
|
url: Optional[HttpUrl] = None
|
|
secret: Optional[str] = PydanticField(None, min_length=16, max_length=256)
|
|
simple_events: Optional[List[SimpleEvent]] = None
|
|
topic_patterns: Optional[List[str]] = None
|
|
enabled: Optional[bool] = None
|
|
|
|
|
|
class WebhookResponse(BaseModel):
|
|
"""Public shape — deliberately omits `secret`.
|
|
|
|
The `warnings` field carries non-blocking advisories about the
|
|
subscription's configuration — e.g. an `http://` URL is fine but
|
|
surfaces a warning so the operator knows the event body is
|
|
plaintext on the wire. Empty list when nothing is worth flagging.
|
|
"""
|
|
|
|
uuid: str
|
|
name: str
|
|
url: str
|
|
topic_patterns: List[str]
|
|
enabled: bool
|
|
consecutive_failures: int
|
|
last_success_at: Optional[datetime] = None
|
|
last_failure_at: Optional[datetime] = None
|
|
last_error: Optional[str] = None
|
|
auto_disabled_at: Optional[datetime] = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
warnings: List[str] = PydanticField(default_factory=list)
|
|
|
|
|
|
class WebhookCreateResponse(WebhookResponse):
|
|
"""Create-path response — carries the secret exactly once, for copy-out."""
|
|
|
|
secret: str
|
|
|
|
|
|
class WebhookTestResponse(BaseModel):
|
|
delivered: bool
|
|
status_code: Optional[int] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
def _compute_warnings(url: str) -> List[str]:
|
|
"""Non-blocking advisories about a subscription's configuration.
|
|
|
|
The HMAC signature detects tampering regardless of transport, but an
|
|
on-path attacker can still *read* the event body over plaintext HTTP.
|
|
We surface the warning and let the admin decide — matches DECNET's
|
|
operator-trust posture (see THREAT_MODEL WH-03).
|
|
"""
|
|
out: List[str] = []
|
|
lower = (url or "").lower()
|
|
if lower.startswith("http://"):
|
|
out.append(
|
|
"insecure_url: URL uses http://. Event bodies (including "
|
|
"payload fields) traverse the wire in plaintext; HMAC still "
|
|
"detects tampering but anyone on-path can read the event. "
|
|
"Use https:// in production."
|
|
)
|
|
return out
|
|
|
|
|
|
def _row_to_response_dict(row: dict[str, Any]) -> dict[str, Any]:
|
|
"""Normalize a DB row into the WebhookResponse dict shape.
|
|
|
|
Used by the CRUD router to decode `topic_patterns` JSON, drop the
|
|
`secret` column, and compute any configuration warnings.
|
|
"""
|
|
out = dict(row)
|
|
raw = out.pop("topic_patterns", "[]")
|
|
try:
|
|
out["topic_patterns"] = json.loads(raw or "[]")
|
|
except (ValueError, TypeError):
|
|
out["topic_patterns"] = []
|
|
out.pop("secret", None)
|
|
out["warnings"] = _compute_warnings(out.get("url", ""))
|
|
return out
|