Building a connector¶
Connectors are Suspicious's plugin system for pushing case activity to
external tools — ticketing, threat-intel platforms, chat, SIEM. The
built-in TheHive, MISP, Watcher and SMTP-notify integrations are
themselves connectors (connectors/contrib/), and third-party packages
plug in through the same contract without touching Suspicious code.
Starting your own: Suspicious/Suspicious/connectors/contrib/template/
is a complete, working package skeleton (manifest, both hooks, sync(),
packaging, and tests) — copy it out of the tree and rename rather than
starting from the snippet below. It's a standalone pip package (its own
pyproject.toml), not a builtin — it isn't listed in
connectors.contrib.BUILTIN_CONNECTOR_PATHS and never gets discovered
from in-place.
Overview¶
A connector is a Python class deriving from connectors.base.Connector
with a declarative ConnectorManifest. Connectors come in two tiers:
- Built-in (contrib) — shipped in
connectors/contrib/, always discovered, listed inconnectors.contrib.BUILTIN_CONNECTOR_PATHS. - Third-party — distributed as ordinary pip packages exposing a
suspicious.connectorsentry point, installed at image build time viaSuspicious/requirements-connectors.txt.
A connector can react to three kinds of work:
| Hook | Trigger |
|---|---|
on_case_created(event) |
a new case enters the pipeline (case_created) |
on_case_finalised(event) |
scoring finished and a verdict exists (case_finalised) |
sync() |
a periodic schedule declared in manifest.schedules, registered into Celery beat |
plus a mandatory health_check() — a cheap connectivity/auth probe that
must never raise (return HealthStatus(ok=False, detail=...) instead).
It powers the Test connection button and the health chip in
Settings → Connectors.
Delivery guarantees¶
Every hook invocation runs on a Celery worker, never inline in the case pipeline — a broken connector cannot delay or fail a case:
- Retries — up to 3 attempts total (1 initial + 2 Celery retries) with exponential backoff (30 s · 2^n for events, 60 s · 2^n for syncs).
- Circuit breaker — each connector gets its own breaker
(5 consecutive failures open it; it half-closes after 60 s). While
open, deliveries are recorded as
skippedinstead of hammering a dead endpoint. - Delivery ledger — every attempt writes a
ConnectorDeliveryrow (event, case, status, error, duration, attempt number), visible per connector via the API (/api/connectors/<name>/deliveries/).
Anatomy of a connector¶
from connectors.base import (
ConfigField, Connector, ConnectorManifest, HealthStatus,
EVENT_CASE_FINALISED,
)
import requests
class SlackConnector(Connector):
manifest = ConnectorManifest(
name="slack",
version="1.0.0",
author="you",
description="Post finalised case verdicts to a Slack channel.",
config_schema=(
ConfigField("webhook_url", "secret", required=True),
ConfigField("channel", "str", default="#soc"),
),
events=(EVENT_CASE_FINALISED,),
)
def health_check(self) -> HealthStatus:
if not self.config.get("webhook_url"):
return HealthStatus(ok=False, detail="webhook_url not configured")
return HealthStatus(ok=True)
def on_case_finalised(self, event) -> None:
requests.post(self.config["webhook_url"], json={
"text": f"Case {event.case_id}: {event.results} "
f"(score {event.final_score})",
}, timeout=10)
Manifest rules (enforced by ConnectorManifest.validate() at discovery
time — a violation records the connector in registry.errors and skips
it, it never crashes startup):
nameis a slug:^[a-z][a-z0-9_]{1,63}$. It doubles as the config section name and the breaker/ledger key.eventsmay only containcase_created/case_finalised.config_schemafield types arestr,int,bool,urlorsecret. Keys may be dotted (instances.primary.api_key) to describe nested config.schedulesareSchedule(name, interval_seconds)with a positive interval; each one becomes a Celery beat entry calling yoursync().enabled_by_defaultcontrols the initial toggle state on first discovery; afterwards the admin's choice (stored inConnectorState) wins.
Only implement the hooks you subscribe to. Subscribing to an event without implementing its hook records a failed delivery — it never propagates into the case pipeline.
CaseEvent schema¶
Hooks receive a CaseEvent, a frozen snapshot of the case at emission
time. Connectors needing more context must re-fetch the case by id.
| Field | Type | Meaning |
|---|---|---|
event |
str |
case_created or case_finalised |
case_id |
int |
primary key of the case |
status |
str |
case status at emission time |
results |
str |
verdict (Safe / Inconclusive / Suspicious / Dangerous) |
final_score |
float \| None |
aggregated score, None until finalised |
confidence |
float \| None |
classifier confidence, None until finalised |
reporter_email |
str |
address of the reporting user |
created_at |
str |
case creation time, ISO-8601 |
schema_version |
int |
currently 1 |
Compatibility promise: within a major schema_version, changes are
additive only — existing fields keep their name, type and meaning, new
fields may appear. CaseEvent.from_dict rejects payloads with a
different major version, so a connector built against version 1 either
receives a payload it understands or a recorded failure — never a
silently reinterpreted one.
Packaging a third-party connector¶
Expose the class through the suspicious.connectors entry-point group
in your package's pyproject.toml:
[project]
name = "suspicious-slack-connector"
version = "1.0.0"
dependencies = ["requests"]
[project.entry-points."suspicious.connectors"]
slack = "suspicious_slack:SlackConnector"
Install it into the backend image by adding one line to
Suspicious/requirements-connectors.txt:
suspicious-slack-connector==1.0.0
then rebuild and roll out:
cd deployment
make build && make deploy
On startup the registry loads every entry point in the group. An entry
point that fails to import (missing dependency, bad manifest) is
isolated: it lands in registry.errors, is surfaced as a warning banner
in Settings → Connectors, and does not affect other connectors.
Configuration¶
Each connector owns the integrations.<name> config section; the
fields declared in config_schema are registered with the runtime
config system automatically.
- Non-secret fields are editable from Settings → Connectors (the
form is generated from the schema) or via
PUT /api/connectors/<name>/config/. secretfields are write-rejected and read-masked through the API. Manage them in Vault (production) orsettings.json(dev) — they are overlaid at read time and never stored in the database.
Enable/disable with the toggle in Settings → Connectors (or
PATCH /api/connectors/<name>/ with {"enabled": false}), and verify
connectivity with Test connection, which calls your
health_check().
Operational behavior¶
- Ledger — every attempt is a
ConnectorDeliveryrow.successmeans your hook returned;failedmeans it raised (the message is stored, truncated to 5000 chars);skippedmeans the circuit breaker was open and the call was never made. Skipped deliveries are not retried — the case pipeline has long moved on; re-sync from the external side or wait for the next event. - Retries — a raising hook is retried while
attempt < MAX_ATTEMPTS(3). Each attempt gets its own ledger row. - Idempotency is your job —
case_finalisedcan fire more than once for the same case (re-finalisation after late analyzer results; the same was true of the legacy integrations this framework replaces). Useevent.case_idfor dedup or make the downstream write idempotent (e.g. upsert by case id). - Disabled connectors receive nothing: events are not enqueued for them and scheduled syncs return immediately.