ADR-022: MISP-driven Suricata rules via a tag-graduated sync service¶
Status¶
accepted
Date¶
2026-05-03
Context¶
Issue #250 closes the gap between two existing SOC components that did not previously talk to each other: MISP holds threat intelligence (IPs, domains, URLs, file hashes), and Suricata is the network IDS in the data path, but Suricata's ruleset was entirely operator-authored—IOCs added to MISP by blue (or by future threat-intel feeds) never reached the wire. Real OSS-SOC stacks always have this loop: SOC analyst (or upstream feed) adds an indicator to the canonical intel platform, automation pushes it to enforcement points, traffic matching it gets actioned within minutes. The lab needs the same loop so blue's threat-intel work composes with detection on the wire.
Two adjacent decisions constrain the design:
- ADR-019: Suricata stays IDS-only. Packet-level prevention is delivered by Wazuh
active-response. Anything this design produces must therefore be
alertrules;drop/rejectrules from this pipeline would have no effect under the lab's current Docker network model and would mislead blue about what the lab actually enforces. - ADR-008: MISP is the canonical IOC store and the SOC stack's intel hub. The new sync component does not fork or duplicate that role; it consumes MISP and emits Suricata rules, nothing more.
Decision¶
Ship a small, single-purpose Python service aptl-misp-suricata-sync
under the soc compose profile. Architecture and invariants:
- Tag-graduated enforcement. The service polls MISP's
POST /attributes/restSearchfiltered by a configurable tag (IOC_TAG_FILTER, defaultaptl:enforce). MISP attributes without the tag are intel only; tagging them is the explicit graduation step that promotes an indicator to detection. Default lab posture is the service running with zero tagged IOCs—blue's job per iteration is to populate intel and graduate it. - Alert-only rules per ADR-019. The translator hard-codes
alertas the rule action across every IOC type. Any future "drop" semantics would require a follow-up ADR overriding ADR-019; the translator'stest_action_is_always_alert_never_dropis the regression guard. - Dedicated rule file. Generated rules go to
/var/lib/suricata/rules/misp/misp-iocs.rules—under Suricata'sdefault-rule-pathso the file'sfilemd5/filesha1/filesha256directives can reference their hash-list sidecars via documented relative paths. The host bind mount is./config/suricata/rules/misp/ → /var/lib/suricata/rules/mispshared between the Suricata container and the sync service. The repo ships a seedmisp-iocs.rulesplus emptymisp-{md5,sha1,sha256}.listfiles so Suricata can load the configured rule paths cleanly on the very firstaptl lab start, before the sync service has had a chance to write. The bind mount also avoids the named-volume initialization race where Suricata starts ahead of the sync service and finds the rule path missing. The operator-authoredlocal.rulesis never touched. The two files are concatenated by Suricata at runtime via therule-files:list inconfig/suricata/suricata.yaml. Mixing them was rejected: it would conflate hand-written and intel-driven rules ingit blame, in run archives, and in any operator inspection. - Deterministic SID allocation. SID is computed as
SID_BASE + (zlib.crc32(f"{type}|{value}") & 0x7FFFFFF). CRC32 over a stable composite key gives a deterministic, content-addressable SID that does not shift when other IOCs are added or removed (the failure mode of "list-position" allocation). On the rare collision the translator drops the second occurrence and logs a warning; emitting two rules with the same SID would be a Suricata startup error.SID_BASEdefaults to2_000_000, well clear of the operator's hand-written range (current max1000091inlocal.rules) and well clear of ET Open's reserved 2.x ranges. - Idempotent file writes.
rule_writer.write_if_changedreads the existing file (if any), compares to the would-be content, and only writes—atomically via<path>.tmp+Path.replace—on change. The rule-file header carries the MISP URL, tag filter,sid_base, and IOC count but no timestamp—adding a fresh timestamp every render would always invalidate the equality check and trigger a Suricata reload every interval even when the IOC set is unchanged. The same idempotent writer also produces the per-type hash list sidecars; reload only triggers if any of the rule file or any list file actually changed. - Live reload via the unix-command socket.
suricata.yamlenables the unix-command interface at/var/run/suricata/suricata-command.socket. The sync service mounts the samesuricata_command_socketnamed volume and speaks the handshake-then-reload-rulesJSON protocol directly (~30 lines insuricata_reloader.py). This avoids dragging thesuricataapt package onto the sync container just forsuricatasc. The container restart that would otherwise be needed to load new rules is avoided— important for an interval-driven service. - MISP-down preservation. If MISP is unreachable or returns malformed data, the loop logs and exits the tick without writing to disk. The previous rule file stays in place, so a transient MISP outage does not collapse Suricata's intel-driven detection.
-
Hash-content escaping. Bytes outside
[A-Za-z0-9._-/?=&]are hex-escaped via Suricata's|XX|notation in anycontent:value. The threat model is a poisoned upstream MISP feed (or a hostile contributor to a public feed), not a hostile lab operator—but the escape cost is zero and the testtest_rejects_quote_or_semicolon_in_value_via_escapeis the regression guard. -
Validation at the IOC boundary. IPs are validated via :mod:
ipaddressbefore being spliced into rule headers (a malformed value would render an unparseable rule and break Suricata reload); hashes are validated for hex format and per-type length (md5=32, sha1=40, sha256=64) before being written to a list file (bad data would make the entire hash list unloadable). Invalid IOCs are skipped with a warning rather than poisoning the output. -
URL parsing via stdlib.
urllib.parse.urlparsehandles credentials, ports, fragments, query strings, IPv6 hosts, and schemeless inputs correctly—none of which a hand-rolled splitter gets right. Hosts are lowercased and stripped of userinfo / port; path includes the query string when present so URL IOCs that vary by query parameter still match. -
Anchored domain / host matching.
dns.queryandhttp.hostmatches use thedotprefixmodifier so an IOC forbad.commatchesbad.comandsub.bad.combut notnotbad.com. Withoutdotprefixthecontent:directive is a substring match and produces avoidable false positives on every domain sharing a suffix with the IOC. -
Reload retry on failure.
SyncRunnercarries areload_pendingflag across ticks. If a Suricata reload fails (socket not yet ready on first start, transient signal failure), the next tick retries the reload even when the rule file is unchanged, so transient reload failures cannot leave generated rules permanently inactive. -
Transactional file ordering. Per-type hash list files (
misp-<type>.list) are written beforemisp-iocs.ruleson each tick. Suricata's hash rules reference the list files; writing them first means the rule file's references are always resolved against fresh content, never stale. -
MISP envelope drift preservation.
MispClientreturnsNonefor both transport failures and malformed envelope structures (missingresponsekey, non-listAttribute, etc.), and only returns[]for a structurally valid empty IOC set. Treating drift as[]would wipe the rule file on API changes; treating it asNonepreserves the last-known-good file. -
MISP API key sourced from .env.
MISP_API_KEYis required (no hardcoded default) for both the MISP server (asADMIN_KEY) and the sync service. Both compose entries resolve the same${MISP_API_KEY:?...}reference, satisfying SEC-005 for the IOC pipeline. The previous hardcoded default was flagged by codex security review of issue #250 and removed in the same PR. -
TLS verification posture and the
MISP_CA_CERT_PATHhook.MispClienthonors three TLS modes:MISP_VERIFY_SSL=false→curl -k(skip verification).MISP_VERIFY_SSL=true+MISP_CA_CERT_PATHset →curl --cacert <path>(verify against the supplied CA bundle).MISP_VERIFY_SSL=true+ no path → curl's system trust store.
The lab default is the first mode because MISP self-signs its certificate at first boot and there is no shared lab CA today that consumers could pin against. The full fix (extending
INF-005's automated cert generation to issue a lab CA whose chain covers MISP, TheHive, Cortex, and Shuffle, and switching every SOC stack client (this service, the threatintel/casemgmt/ soar MCPs,aptl.core.collectors) to verify-on-by-default) is out of scope for this issue and tracked under SEC-006 (DRAFT) and issue #258. TheMISP_CA_CERT_PATHhook in this PR is the consumer-side plumbing SEC-006 will fill in; flipping the lab to verify-on once #258 ships is a single env-var change.
Translator IOC matrix¶
| MISP type | Generated rule shape |
|---|---|
ip-src |
alert ip <ioc> any -> any any (...)—matches source IP |
ip-dst |
alert ip any any -> <ioc> any (...)—matches destination IP |
domain/hostname |
alert dns ... dns.query; content:"<escaped>"; nocase |
url |
alert http ... http.host; content:"<host>"; nocase[; http.uri; content:"<path>"; nocase] |
sha256/sha1/md5 |
one rule per type, referencing a sidecar list file via Suricata's documented relative-path lookup: ... file.data; filesha256:misp/misp-sha256.list; ... |
| anything else | skipped with a warning log |
Hash IOCs are aggregated rather than rendered one-rule-per-IOC because
Suricata's filemd5 / filesha1 / filesha256 keywords take a file
of hashes as their argument, not an inline digest. The translator
therefore emits one rule per non-empty hash type and writes the digests
themselves to misp-<type>.list files alongside the rule file. This is
both syntactically correct (the inline-content form Suricata would
reject) and operationally efficient (one reload regardless of how many
hashes ship).
URLs match http.host for the host component and http.uri only when
the path is non-trivial; host-only URLs do not emit a content:"/" URI
match (which would broadly false-positive against ordinary traffic).
Service shape¶
The service is the lab's first long-running Python daemon. Layout under
src/aptl/services/misp_suricata_sync/ is intentionally narrow so the
package can serve as a template for future services:
config.py: Pydantic v2ServiceConfig.from_env()mirroringaptl.api.deps's env-then-validate pattern (nopydantic-settings).models.py:MispAttributeDTO +RenderedRule.misp_client.py: curl-subprocess client matchingaptl.core.collectors._curl_jsonsemantics: never raises, returnsNoneon failure, never logs the API key.translator.py: pure:IocTranslator.translate(...) -> list[RenderedRule], plusrender_rules_file(...)that adds the file header.rule_writer.py: atomic, idempotentwrite_if_changed.suricata_reloader.py: unix-command socket client.main.py:run_once,run_loop,main. SIGTERM/SIGINT-aware.
The container is python:3.11-slim + curl + the aptl wheel installed
via pip install . (no extras, no PyMISP, no suricatasc). Console
script aptl-misp-suricata-sync is the entrypoint.
Consequences¶
Positive¶
- Closes the MISP→detection loop without touching ADR-019's IDS-only
posture. Blue's threat-intel work via
aptl-threatintelMCP now has a visible effect on the wire within one sync interval. - Stable SIDs mean Suricata's reload is a no-op when MISP is quiet.
- Tag-graduated enforcement gives blue a deliberate "promote to detect" step instead of every MISP indicator silently becoming a rule.
- Sync service is stateless across restarts (CRC32 SID is a function of IOC content, not insertion order), so container restarts don't resequence rules and don't trigger spurious Suricata reloads.
- The service container is small (
python:3.11-slim+ curl + the aptl wheel) and short—the entire service is < 400 LOC of Python.
Negative¶
- The lab now has a unix-command socket on the Suricata container. Modest attack surface: it's bound to a unix socket on a private Docker volume, not exposed on any network. Only the sync service container mounts the same volume.
- Every IOC matches Suricata's IDS-only posture—
alert, notdrop. Blue cannot author MISP IOCs that block traffic. Real prevention remains the Wazuh AR path (#248/#249). This ADR makes that boundary explicit so blue doesn't expect MISP-driven blocking. - The service is the lab's first Python daemon, so the supervisord / in-process pattern that exists for Wazuh agents does not apply. New service is a single foreground process under restart=unless-stopped.
Risks¶
- Rule churn from a noisy upstream MISP feed. If a feed graduates
thousands of IOCs in a short window, Suricata will reload often.
Mitigation: the
aptl:enforcetag is opt-in per indicator. Lab default is empty. Future work could batch reloads. - Tag misuse. If the wrong tag is applied to a high-traffic IOC
(
tlp:whiteinstead ofaptl:enforce, etc.), a flood of false positives is possible. Mitigation: the tag is documented in this ADR and in the README; the default config isaptl:enforce, nottlp:white, specifically so generic TLP tags don't graduate IOCs by accident. - Suricata socket protocol drift. The handshake currently uses
version: 0.2. If upstream Suricata bumps the protocol, the reloader's_send_commandwill reportreturn: NOKand we fall back to "skip reload, log warning"—the rule file still updates on disk, and a Suricata container restart still picks it up. Detection degrades to "rules apply on next restart," which is acceptable until the protocol bump is addressed.
Verification¶
- 52 pytest unit tests cover translator (per-type rendering, ADR-019 alert-only invariant, deterministic SID, collision behavior, content escaping), rule writer (atomicity, idempotency, no-truncate-on-error), reloader (handshake, reload command, missing-socket failure mode), MISP client (auth header, tag filter, malformed-response tolerance, no-API-key-in-logs invariant), config (validators), and the sync loop (skip-on-fetch-error, skip-on-no-change).
- E2E lab recipe:
aptl lab stop -v && aptl lab start --profiles soc, submit an IOC via theaptl-threatintelMCP with theaptl:enforcetag, wait one sync interval, thendocker exec aptl-suricata cat /var/lib/suricata/rules/misp/misp-iocs.rulesto confirm the rule landed and checksuricata_logs/eve.jsonfor alerts on matching traffic.