Skip to content

Commit b6993da

Browse files
committed
Add possibility to chain detectors with each other
1 parent d5664b9 commit b6993da

8 files changed

Lines changed: 353 additions & 45 deletions

File tree

‎config.yaml‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ pipeline:
7979
module: streamad.model
8080
model_args:
8181
is_global: false
82-
anomaly_threshold: 0.01
82+
anomaly_threshold: 0.1
8383
score_threshold: 0.5
8484
time_type: ms
8585
time_range: 20
@@ -96,7 +96,10 @@ pipeline:
9696
checksum: 5db8bfb617e80361362c33b1d1afc6d762c28e9fa9275fb11514a3bdef76bb88
9797
base_url: https://heibox.uni-heidelberg.de/d/0d5cbcbe16cd46a58021/
9898
threshold: 0.5
99+
consume_from: inspector
99100
inspector_name: dga_inspector
101+
next_detectors: domainator
102+
send_to_alerter: true
100103
produce_topics: ""
101104
- name: "domainator"
102105
detector_module_name: "domainator_detector"
@@ -105,15 +108,21 @@ pipeline:
105108
checksum: 9d86d66b4976c9b325bed0934a9a9eb3a20960b08be9afe491454624cc0aaa6c
106109
base_url: https://heibox.uni-heidelberg.de/d/0d5cbcbe16cd46a58021/
107110
threshold: 0.5
108-
inspector_name: no_inspector
111+
consume_from: detector
112+
inspector_name: ""
113+
next_detectors: ""
114+
send_to_alerter: true
109115
produce_topics: ""
110116

111117
alerting:
112118
log_to_file: true
113119
log_file_path: "/opt/logs/alerts.txt"
114120
log_to_kafka: true
115121
external_kafka_topic: "hamstring_alerts"
116-
122+
plugins:
123+
- name: attributor
124+
alerter_class_name: AttributorAlerter
125+
alerter_module_name: attributor_alerter
117126
monitoring:
118127
clickhouse_connector:
119128
batch_size: 50 # do not set higher
@@ -151,6 +160,7 @@ environment:
151160
prefilter_to_inspector: "pipeline-prefilter_to_inspector"
152161
inspector_to_detector: "pipeline-inspector_to_detector"
153162
detector_to_alerter: "pipeline-detector_to_alerter"
163+
detector_to_detector: "pipeline-detector_to_detector"
154164
monitoring:
155165
clickhouse_server:
156166
hostname: clickhouse-server

‎docs/configuration.rst‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,10 @@ To entirely skip the anomaly detection phase, you can set ``inspector_module_nam
234234
- A unique name amongst the detector configurations top identify the detector instance.
235235
* - inspector_name
236236
-
237-
- The name of the inspector configuration the detector consumes data from. The same inspector name can be referenced in multiple detector configurations.
237+
- The name of the inspector configuration the detector consumes data from. The same inspector name can be referenced in multiple detector configurations. Omit this or set ``consume_from: detector`` when the detector consumes from another detector.
238+
* - consume_from
239+
- ``inspector``
240+
- Set to ``detector`` for detector instances that consume from the detector-to-detector topic instead of an inspector.
238241
* - detector_module_name
239242
-
240243
- Name of the python file in ``"src/detector/plugins/"`` the detector should use.
@@ -255,7 +258,13 @@ To entirely skip the anomaly detection phase, you can set ``inspector_module_nam
255258
- Threshold for the detector's classification.
256259
* - produce_topics
257260
- ``(empty)``
258-
- (Optional) Comma-separated list of topic suffixes to produce alerts to. If left empty, defaults to the ``generic`` topic.
261+
- (Optional) Comma-separated list of alerter topic suffixes to produce alerts to. If left empty, defaults to the ``generic`` alerter topic. Use ``send_to_alerter: false`` or ``produce_topics: []`` for intermediary detectors that should not produce to an alerter.
262+
* - next_detectors
263+
- ``(empty)``
264+
- (Optional) Comma-separated list of detector instance names that should receive this detector's suspicious output on detector-to-detector topics.
265+
* - send_to_alerter
266+
- ``true``
267+
- Set to ``false`` to disable the detector-to-alerter Kafka output while still allowing detector-to-detector forwarding.
259268

260269

261270
``pipeline.alerting``

‎docs/pipeline.rst‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -749,13 +749,22 @@ You may use the provided, pre-trained models or supply your own. To use a custom
749749
- `model`: model name
750750
- `checksum`: SHA256 digest for integrity validation
751751
- `threshold`: probability threshold for classifying a request as malicious
752-
- `inspector_name`: name of the inspector configuration for input
752+
- `inspector_name`: name of the inspector configuration for input. Omit this or set `consume_from: detector` when consuming from another detector.
753+
- `consume_from`: use `detector` to consume from the detector-to-detector topic for this detector instance
753754
- `detector_module_name`: name of the python module the implementation details reside
754755
- `detector_class_name`: name of the class in the python module to load the detector implementation details
755-
- `produce_topics`: (Optional) Comma-separated list of topic suffixes to produce alerts to. If left empty, alerts are sent to the ``generic`` alerter topic.
756+
- `produce_topics`: (Optional) Comma-separated list of topic suffixes to produce alerts to. If left empty, alerts are sent to the ``generic`` alerter topic. Use `send_to_alerter: false` or `produce_topics: []` for intermediary detectors that should not produce to an alerter.
757+
- `next_detectors`: (Optional) Comma-separated list of detector instance names that receive this detector's suspicious output.
756758

757759
These parameters are loaded at startup and used to download, verify, and load the model/scaler if not already cached locally (in temp directory).
758760

761+
Detector Chaining
762+
-----------------
763+
764+
Detector instances can be chained by setting ``next_detectors`` on the upstream detector and ``consume_from: detector`` on the downstream detector. Detector-to-detector topics use the naming pattern ``[detector_to_detector_prefix]-[detector_name]``.
765+
766+
For intermediary detectors that should only forward suspicious output to another detector, set ``send_to_alerter: false`` or ``produce_topics: []``.
767+
759768

760769
Stage 7: Alerter
761770
================

‎src/detector/detector.py‎

Lines changed: 159 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
import asyncio
99
import numpy as np
1010
import requests
11+
import marshmallow_dataclass
1112
from numpy import median
1213
from abc import ABC, abstractmethod
1314
import importlib
1415

1516
sys.path.append(os.getcwd())
1617
from src.base.clickhouse_kafka_sender import ClickHouseKafkaSender
18+
from src.base.data_classes.batch import Batch
1719
from src.base.utils import setup_config, generate_collisions_resistant_uuid
1820
from src.base.kafka_handler import (
1921
ExactlyOnceKafkaConsumeHandler,
@@ -31,17 +33,81 @@
3133
INSPECTORS = config["pipeline"]["data_inspection"]
3234
DETECTORS = config["pipeline"]["data_analysis"]
3335

36+
PIPELINE_TOPIC_PREFIXES = config["environment"]["kafka_topics_prefix"]["pipeline"]
37+
INSPECTOR_TO_DETECTOR_TOPIC_PREFIX = PIPELINE_TOPIC_PREFIXES["inspector_to_detector"]
38+
DETECTOR_TO_ALERTER_TOPIC_PREFIX = PIPELINE_TOPIC_PREFIXES["detector_to_alerter"]
39+
DETECTOR_TO_DETECTOR_TOPIC_PREFIX = PIPELINE_TOPIC_PREFIXES.get(
40+
"detector_to_detector", "pipeline-detector_to_detector"
41+
)
3442

35-
CONSUME_TOPIC_PREFIX = config["environment"]["kafka_topics_prefix"]["pipeline"][
36-
"inspector_to_detector"
37-
]
38-
PRODUCE_TOPIC_PREFIX = config["environment"]["kafka_topics_prefix"]["pipeline"][
39-
"detector_to_alerter"
40-
]
43+
# Backwards-compatible aliases for tests and external imports.
44+
CONSUME_TOPIC_PREFIX = INSPECTOR_TO_DETECTOR_TOPIC_PREFIX
45+
PRODUCE_TOPIC_PREFIX = DETECTOR_TO_ALERTER_TOPIC_PREFIX
4146

4247
PLUGIN_PATH = "src.detector.plugins"
4348

4449

50+
def _normalize_topic_suffixes(topic_suffixes) -> list[str]:
51+
if topic_suffixes is None:
52+
return []
53+
if isinstance(topic_suffixes, str):
54+
return [suffix.strip() for suffix in topic_suffixes.split(",") if suffix.strip()]
55+
if isinstance(topic_suffixes, (list, tuple, set)):
56+
return [str(suffix).strip() for suffix in topic_suffixes if str(suffix).strip()]
57+
return [str(topic_suffixes).strip()]
58+
59+
60+
def build_alerter_topics(detector_config: dict) -> list[str]:
61+
if detector_config.get("send_to_alerter") is False:
62+
return []
63+
64+
topic_suffixes = detector_config.get("produce_topics", "")
65+
if topic_suffixes is None or topic_suffixes == []:
66+
return []
67+
68+
topic_suffixes = _normalize_topic_suffixes(topic_suffixes)
69+
if not topic_suffixes:
70+
topic_suffixes = ["generic"]
71+
72+
return [
73+
f"{DETECTOR_TO_ALERTER_TOPIC_PREFIX}-{topic_suffix}"
74+
for topic_suffix in topic_suffixes
75+
]
76+
77+
78+
def build_downstream_detector_topics(detector_config: dict) -> list[str]:
79+
detector_names = []
80+
for config_key in ("next_detectors", "produce_detector_topics"):
81+
detector_names.extend(_normalize_topic_suffixes(detector_config.get(config_key)))
82+
83+
return [
84+
f"{DETECTOR_TO_DETECTOR_TOPIC_PREFIX}-{detector_name}"
85+
for detector_name in dict.fromkeys(detector_names)
86+
]
87+
88+
89+
def detector_consumes_from_detector(detector_config: dict) -> bool:
90+
consume_from = str(detector_config.get("consume_from", "")).strip().lower()
91+
if consume_from:
92+
return consume_from == "detector"
93+
94+
detector_source_keys = (
95+
"upstream_detector_name",
96+
"source_detector_name",
97+
"input_detector_name",
98+
)
99+
if any(detector_config.get(source_key) for source_key in detector_source_keys):
100+
return True
101+
102+
return not detector_config.get("inspector_name")
103+
104+
105+
def build_detector_consume_topic(detector_config: dict) -> str:
106+
if detector_consumes_from_detector(detector_config):
107+
return f"{DETECTOR_TO_DETECTOR_TOPIC_PREFIX}-{detector_config['name']}"
108+
return f"{INSPECTOR_TO_DETECTOR_TOPIC_PREFIX}-{detector_config['name']}"
109+
110+
45111
class WrongChecksum(Exception): # pragma: no cover
46112
"""Raises when model checksum validation fails."""
47113

@@ -61,7 +127,13 @@ class DetectorAbstractBase(ABC): # pragma: no cover
61127
"""
62128

63129
@abstractmethod
64-
def __init__(self, detector_config, consume_topic, produce_topics=None) -> None:
130+
def __init__(
131+
self,
132+
detector_config,
133+
consume_topic,
134+
produce_topics=None,
135+
downstream_detector_topics=None,
136+
) -> None:
65137
pass
66138

67139
@abstractmethod
@@ -89,7 +161,13 @@ class DetectorBase(DetectorAbstractBase):
89161
that provide model-specific prediction logic.
90162
"""
91163

92-
def __init__(self, detector_config, consume_topic, produce_topics=None) -> None:
164+
def __init__(
165+
self,
166+
detector_config,
167+
consume_topic,
168+
produce_topics=None,
169+
downstream_detector_topics=None,
170+
) -> None:
93171
"""
94172
Initialize the detector with configuration and Kafka topic settings.
95173
@@ -110,11 +188,20 @@ def __init__(self, detector_config, consume_topic, produce_topics=None) -> None:
110188

111189
self.consume_topic = consume_topic
112190
if produce_topics is None:
113-
self.produce_topics = [f"{PRODUCE_TOPIC_PREFIX}-generic"]
191+
self.produce_topics = [f"{DETECTOR_TO_ALERTER_TOPIC_PREFIX}-generic"]
114192
elif isinstance(produce_topics, str):
115-
self.produce_topics = [produce_topics]
193+
self.produce_topics = _normalize_topic_suffixes(produce_topics)
116194
else:
117195
self.produce_topics = produce_topics
196+
197+
if downstream_detector_topics is None:
198+
self.downstream_detector_topics = []
199+
elif isinstance(downstream_detector_topics, str):
200+
self.downstream_detector_topics = _normalize_topic_suffixes(
201+
downstream_detector_topics
202+
)
203+
else:
204+
self.downstream_detector_topics = downstream_detector_topics
118205
self.suspicious_batch_id = None
119206
self.key = None
120207
self.messages = []
@@ -362,10 +449,12 @@ def send_warning(self) -> None:
362449
"""
363450
logger.info("Store alert.")
364451
row_id = generate_collisions_resistant_uuid()
452+
downstream_messages = []
365453
if len(self.warnings) > 0:
366454
overall_score = median(
367455
[warning["probability"] for warning in self.warnings]
368456
)
457+
downstream_messages = self._get_downstream_messages()
369458
alert = {
370459
"overall_score": overall_score,
371460
"result": self.warnings,
@@ -375,27 +464,28 @@ def send_warning(self) -> None:
375464
"detector_name": self.name,
376465
}
377466

378-
logger.info(f"Producing alert to Kafka: {alert}")
467+
if self.produce_topics:
468+
logger.info(f"Producing alert to Kafka: {alert}")
379469

380-
if self.kafka_produce_handler is None:
381-
self.kafka_produce_handler = ExactlyOnceKafkaProduceHandler()
470+
if self.kafka_produce_handler is None:
471+
self.kafka_produce_handler = ExactlyOnceKafkaProduceHandler()
382472

383-
for topic in self.produce_topics:
384-
self.kafka_produce_handler.produce(
385-
topic=topic,
386-
data=json.dumps(alert),
387-
key=self.key,
388-
)
473+
for topic in self.produce_topics:
474+
self.kafka_produce_handler.produce(
475+
topic=topic,
476+
data=json.dumps(alert),
477+
key=self.key,
478+
)
479+
else:
480+
logger.info("No alerter topics configured. Skipping alert output.")
389481

390482
self.alerts.insert(
391483
dict(
392484
src_ip=self.key,
393485
alert_timestamp=datetime.datetime.now(),
394486
suspicious_batch_id=self.suspicious_batch_id,
395487
overall_score=overall_score,
396-
domain_names=json.dumps(
397-
[warning["request"] for warning in self.warnings]
398-
),
488+
domain_names=json.dumps(self._get_warning_requests()),
399489
result=json.dumps(self.warnings),
400490
)
401491
)
@@ -470,6 +560,9 @@ def send_warning(self) -> None:
470560
)
471561
)
472562

563+
if downstream_messages:
564+
self._send_detector_batch(row_id, downstream_messages)
565+
473566
self.fill_levels.insert(
474567
dict(
475568
timestamp=datetime.datetime.now(),
@@ -479,6 +572,46 @@ def send_warning(self) -> None:
479572
)
480573
)
481574

575+
def _send_detector_batch(self, parent_row_id, messages) -> None:
576+
if not self.downstream_detector_topics:
577+
return
578+
579+
logger.info(
580+
f"Producing detector output to Kafka topics: {self.downstream_detector_topics}"
581+
)
582+
data_to_send = {
583+
"batch_tree_row_id": parent_row_id,
584+
"batch_id": self.suspicious_batch_id,
585+
"begin_timestamp": self.begin_timestamp,
586+
"end_timestamp": self.end_timestamp,
587+
"data": messages,
588+
}
589+
batch_schema = marshmallow_dataclass.class_schema(Batch)()
590+
591+
if self.kafka_produce_handler is None:
592+
self.kafka_produce_handler = ExactlyOnceKafkaProduceHandler()
593+
594+
for topic in self.downstream_detector_topics:
595+
self.kafka_produce_handler.produce(
596+
topic=topic,
597+
data=batch_schema.dumps(data_to_send),
598+
key=self.key,
599+
)
600+
601+
def _get_warning_requests(self) -> list:
602+
return [
603+
warning.get("request", warning.get("request_domain", warning))
604+
for warning in self.warnings
605+
]
606+
607+
def _get_downstream_messages(self) -> list:
608+
messages = [
609+
warning["request"] for warning in self.warnings if "request" in warning
610+
]
611+
if messages:
612+
return messages
613+
return self.messages
614+
482615
# TODO: test bootstrap!
483616
def bootstrap_detector_instance(self):
484617
"""
@@ -543,15 +676,9 @@ async def main(): # pragma: no cover
543676

544677
tasks = []
545678
for detector_config in DETECTORS:
546-
consume_topic = f"{CONSUME_TOPIC_PREFIX}-{detector_config['name']}"
547-
produce_topics_str = detector_config.get("produce_topics", "")
548-
if produce_topics_str:
549-
produce_topics = [
550-
f"{PRODUCE_TOPIC_PREFIX}-{t.strip()}"
551-
for t in produce_topics_str.split(",")
552-
]
553-
else:
554-
produce_topics = [f"{PRODUCE_TOPIC_PREFIX}-generic"]
679+
consume_topic = build_detector_consume_topic(detector_config)
680+
produce_topics = build_alerter_topics(detector_config)
681+
downstream_detector_topics = build_downstream_detector_topics(detector_config)
555682

556683
class_name = detector_config["detector_class_name"]
557684
module_name = f"{PLUGIN_PATH}.{detector_config['detector_module_name']}"
@@ -561,6 +688,7 @@ async def main(): # pragma: no cover
561688
detector_config=detector_config,
562689
consume_topic=consume_topic,
563690
produce_topics=produce_topics,
691+
downstream_detector_topics=downstream_detector_topics,
564692
)
565693
tasks.append(asyncio.create_task(detector.start()))
566694
await asyncio.gather(*tasks)

0 commit comments

Comments
 (0)