import importlib
import os
import sys
import uuid
from datetime import datetime
from enum import Enum, unique
import asyncio
from abc import ABC, abstractmethod
import marshmallow_dataclass
import numpy as np
from streamad.util import StreamGenerator, CustomDS
sys.path.append(os.getcwd())
from src.base.clickhouse_kafka_sender import ClickHouseKafkaSender
from src.base.data_classes.batch import Batch
from src.base.utils import (
setup_config,
get_zeek_sensor_topic_base_names,
generate_collisions_resistant_uuid,
)
from src.base.acceleration import resolve_acceleration_config
from src.base.kafka import (
ExactlyOnceKafkaConsumeHandler,
ExactlyOnceKafkaProduceHandler,
KafkaMessageFetchException,
)
from src.base.log_config import get_logger
from src.base.execution import (
create_pipeline_executor,
run_thread_worker_pool,
start_pipeline_worker_replicas,
)
module_name = "data_inspection.inspector"
logger = get_logger(module_name)
config = setup_config()
PRODUCE_TOPIC_PREFIX = config["environment"]["kafka_topics_prefix"]["pipeline"][
"inspector_to_detector"
]
CONSUME_TOPIC_PREFIX = config["environment"]["kafka_topics_prefix"]["pipeline"][
"prefilter_to_inspector"
]
SENSOR_PROTOCOLS = get_zeek_sensor_topic_base_names(config)
PREFILTERS = config["pipeline"]["log_filtering"]
INSPECTORS = config["pipeline"]["data_inspection"]
COLLECTORS = config["pipeline"]["log_collection"]["collectors"]
DETECTORS = config["pipeline"]["data_analysis"]
PLUGIN_PATH = "src.inspector.plugins"
[docs]
class InspectorAbstractBase(ABC): # pragma: no cover
@abstractmethod
def __init__(self, consume_topic, produce_topics, config) -> None:
pass
@abstractmethod
def inspect_anomalies(self) -> None:
pass
@abstractmethod
def _get_models(self, models) -> list:
pass
@abstractmethod
def subnet_is_suspicious(self) -> bool:
pass
[docs]
class InspectorBase(InspectorAbstractBase):
"""Finds anomalies in a batch of requests and produces it to the ``Detector``."""
def __init__(self, consume_topic, produce_topics, config) -> None:
"""
Initializes the InspectorBase with necessary configurations and connections.
Sets up Kafka handlers, database connections, and configuration parameters based on
the provided configuration. For non-NoInspector implementations, initializes model
related parameters including mode, model configurations, thresholds, and time parameters.
Args:
consume_topic (str): Kafka topic to consume messages from
produce_topics (list): List of Kafka topics to produce messages to
config (dict): Configuration dictionary containing inspector settings
Note:
The "NoInspector" implementation skips model configuration initialization
as it doesn't perform actual anomaly detection.
"""
if not config["inspector_class_name"] == "NoInspector":
self.mode = config["mode"]
self.model_configurations = (
config["models"] if "models" in config.keys() else None
)
self.anomaly_threshold = config["anomaly_threshold"]
self.score_threshold = config["score_threshold"]
self.time_type = config["time_type"]
self.time_range = config["time_range"]
self.name = config["name"]
self.acceleration = resolve_acceleration_config(
globals()["config"].get("pipeline", {}),
config,
component_name=f"{module_name}.{self.name}",
logger=logger,
)
self.consume_topic = consume_topic
self.produce_topics = produce_topics
self.batch_id = None
self.X = None
self.key = None
self.begin_timestamp = None
self.end_timestamp = None
self.messages = []
self.anomalies = []
self.kafka_consume_handler = ExactlyOnceKafkaConsumeHandler(self.consume_topic)
self.kafka_produce_handler = ExactlyOnceKafkaProduceHandler()
self.monitoring_kafka_producer = ClickHouseKafkaSender.create_shared_producer()
# databases
self.batch_tree = ClickHouseKafkaSender(
"batch_tree", self.monitoring_kafka_producer
)
self.batch_timestamps = ClickHouseKafkaSender(
"batch_timestamps", self.monitoring_kafka_producer
)
self.suspicious_batch_timestamps = ClickHouseKafkaSender(
"suspicious_batch_timestamps", self.monitoring_kafka_producer
)
self.suspicious_batches_to_batch = ClickHouseKafkaSender(
"suspicious_batches_to_batch", self.monitoring_kafka_producer
)
self.logline_timestamps = ClickHouseKafkaSender(
"logline_timestamps", self.monitoring_kafka_producer
)
self.fill_levels = ClickHouseKafkaSender(
"fill_levels", self.monitoring_kafka_producer
)
self.fill_levels.insert(
dict(
timestamp=datetime.now(),
stage=module_name,
entry_type="total_loglines",
entry_count=0,
)
)
def get_and_fill_data(self, source_message=None) -> None:
"""Consumes data from Kafka and stores it for processing.
Fetches batch data from the configured Kafka topic and stores it in internal data structures.
If the Inspector is already busy processing data, the consumption is skipped with a warning.
Logs batch information and updates database entries for monitoring purposes.
"""
if self.messages:
logger.warning(
"Inspector is busy: Not consuming new messages. Wait for the Inspector to finish the "
"current workload."
)
return
key, data = self.kafka_consume_handler.consume_as_object(source_message)
if data:
self.parent_row_id = data.batch_tree_row_id
self.batch_id = data.batch_id
self.begin_timestamp = data.begin_timestamp
self.end_timestamp = data.end_timestamp
self.messages = data.data
self.key = key
self.batch_timestamps.insert(
dict(
batch_id=self.batch_id,
stage=module_name,
status="in_process",
instance_name=self.name,
timestamp=datetime.now(),
is_active=True,
message_count=len(self.messages),
)
)
row_id = generate_collisions_resistant_uuid()
self.batch_tree.insert(
dict(
batch_row_id=row_id,
stage=module_name,
instance_name=self.name,
status="in_process",
timestamp=datetime.now(),
parent_batch_row_id=self.parent_row_id,
batch_id=self.batch_id,
)
)
self.fill_levels.insert(
dict(
timestamp=datetime.now(),
stage=module_name,
entry_type="total_loglines",
entry_count=len(self.messages),
)
)
if not self.messages:
logger.info(
"Received message:\n"
f" ⤷ Empty data field: No unfiltered data available. Belongs to subnet_id {key}."
)
else:
logger.info(
"Received message:\n"
f" ⤷ Contains data field of {len(self.messages)} message(s). Belongs to subnet_id {key}."
)
def clear_data(self) -> None:
"""Clears all data from internal data structures.
Resets messages, anomalies, feature matrix, and timestamps to prepare
the Inspector for processing the next batch of data.
"""
self.messages = []
self.anomalies = []
self.X = []
self.begin_timestamp = None
self.end_timestamp = None
logger.debug("Cleared messages and timestamps. Inspector is now available.")
def send_data(self):
"""Forwards anomalous data to the Detector for further analysis.
Evaluates anomaly scores against the configured thresholds. If the proportion of
anomalous time steps exceeds the threshold, groups messages by client IP and
forwards each group as a suspicious batch to the Detector via Kafka. Otherwise,
logs the batch as filtered out and updates monitoring databases.
"""
row_id = generate_collisions_resistant_uuid()
if self.subnet_is_suspicious():
buckets = {}
for message in self.messages:
if message["src_ip"] in buckets.keys():
buckets[message["src_ip"]].append(message)
else:
buckets[message["src_ip"]] = []
buckets.get(message["src_ip"]).append(message)
for key, value in buckets.items():
suspicious_batch_id = uuid.uuid4() # generate new suspicious_batch_id
self.suspicious_batches_to_batch.insert(
dict(
timestamp=datetime.now(),
suspicious_batch_id=suspicious_batch_id,
batch_id=self.batch_id,
)
)
data_to_send = {
"batch_tree_row_id": row_id,
"batch_id": suspicious_batch_id,
"begin_timestamp": self.begin_timestamp,
"end_timestamp": self.end_timestamp,
"data": value,
}
batch_schema = marshmallow_dataclass.class_schema(Batch)()
# important to finish before sending, otherwise detector can process before finished here!
self.suspicious_batch_timestamps.insert(
dict(
suspicious_batch_id=suspicious_batch_id,
src_ip=key,
stage=module_name,
instance_name=self.name,
status="finished",
timestamp=datetime.now(),
is_active=True,
message_count=len(value),
)
)
self.batch_tree.insert(
dict(
batch_row_id=row_id,
stage=module_name,
instance_name=self.name,
status="finished",
timestamp=datetime.now(),
parent_batch_row_id=self.parent_row_id,
batch_id=suspicious_batch_id,
)
)
for topic in self.produce_topics:
self.kafka_produce_handler.produce(
topic=topic,
data=batch_schema.dumps(data_to_send),
key=key,
)
else: # subnet is not suspicious
self.batch_timestamps.insert(
dict(
batch_id=self.batch_id,
stage=module_name,
instance_name=self.name,
status="filtered_out",
timestamp=datetime.now(),
is_active=False,
message_count=len(self.messages),
)
)
logline_ids = set()
for message in self.messages:
logline_ids.add(message["logline_id"])
for logline_id in logline_ids:
self.logline_timestamps.insert(
dict(
logline_id=logline_id,
stage=module_name,
status="filtered_out",
timestamp=datetime.now(),
is_active=False,
)
)
self.batch_tree.insert(
dict(
batch_row_id=row_id,
stage=module_name,
instance_name=self.name,
status="finished",
timestamp=datetime.now(),
parent_batch_row_id=self.parent_row_id,
batch_id=self.batch_id,
)
)
self.fill_levels.insert(
dict(
timestamp=datetime.now(),
stage=module_name,
entry_type="total_loglines",
entry_count=0,
)
)
def inspect(self):
"""
Executes the anomaly detection process with validation and fallback handling.
This method:
1. Validates that model configurations exist
2. Logs a warning if multiple models are configured (only first is used)
3. Retrieves the models through _get_models()
4. Calls inspect_anomalies() to perform the actual detection
Raises:
NotImplementedError: If no model configurations are provided
"""
if self.model_configurations == None or len(self.model_configurations) == 0:
logger.warning("No model is set!")
raise NotImplementedError(f"No model is set!")
if len(self.model_configurations) > 1:
logger.warning(
f"Model List longer than 1. Only the first one is taken: {self.model_configurations[0]['model']}!"
)
self.models = self._get_models(self.model_configurations)
self.inspect_anomalies()
# TODO: test this!
def bootstrap_inspection_process(self):
"""
Implements the main inspection process loop that continuously:
1. Fetches new data from Kafka
2. Inspects the data for anomalies
3. Sends suspicious data to detectors
"""
logger.info(f"Starting {self.name}")
while True:
try:
source_messages = self.kafka_consume_handler.consume_batch()
if not source_messages:
continue
with self.kafka_produce_handler.transaction_batch(
self.kafka_consume_handler, source_messages
):
for source_message in source_messages:
try:
self.get_and_fill_data(source_message)
self.inspect()
self.send_data()
finally:
self.clear_data()
except KafkaMessageFetchException as e: # pragma: no cover
logger.debug(e)
except IOError as e:
logger.error(e)
raise e
except ValueError as e:
logger.debug(e)
except KeyboardInterrupt:
logger.info(f" {self.consume_topic} Closing down Inspector...")
break
async def start(self): # pragma: no cover
"""
Starts the inspector in an asynchronous context.
This method runs the synchronous bootstrap_inspection_process() in a separate
thread using run_in_executor, allowing the inspector to operate concurrently
with other async components in the pipeline.
"""
loop = asyncio.get_running_loop()
executor = create_pipeline_executor(config, module_name, self.name)
try:
await loop.run_in_executor(executor, self.bootstrap_inspection_process)
finally:
executor.shutdown(wait=False, cancel_futures=True)
def build_inspector_worker(
inspector_config,
consume_topic,
produce_topics,
worker_id=None,
):
class_name = inspector_config["inspector_class_name"]
plugin_module_name = f"{PLUGIN_PATH}.{inspector_config['inspector_module_name']}"
plugin_module = importlib.import_module(plugin_module_name)
inspector_class = getattr(plugin_module, class_name)
worker = inspector_class(
consume_topic=consume_topic,
produce_topics=produce_topics,
config=inspector_config,
)
worker.worker_id = worker_id
return worker
def run_inspector_worker_process(
process_index,
threads_per_process,
inspector_config,
consume_topic,
produce_topics,
):
def worker_factory(worker_id):
return build_inspector_worker(
inspector_config=inspector_config,
consume_topic=consume_topic,
produce_topics=produce_topics,
worker_id=worker_id,
)
run_thread_worker_pool(
worker_factory=worker_factory,
target_name="bootstrap_inspection_process",
module_name=module_name,
instance_name=inspector_config["name"],
process_index=process_index,
threads_per_process=threads_per_process,
)
async def main():
"""
Entry point for the Inspector module.
This function:
1. Iterates through all configured inspectors
2. Creates the appropriate inspector instance based on configuration
3. Starts each inspector as an asynchronous task
4. Gathers all tasks to run them concurrently
The function dynamically loads inspector classes from the plugin system
based on configuration values, allowing for flexible extension of the
inspection capabilities.
"""
tasks = []
for inspector in INSPECTORS:
logger.info(inspector["name"])
consume_topic = f"{CONSUME_TOPIC_PREFIX}-{inspector['name']}"
produce_topics = [
f"{PRODUCE_TOPIC_PREFIX}-{detector['name']}"
for detector in DETECTORS
if detector.get("inspector_name") == inspector["name"]
and str(detector.get("consume_from", "")).strip().lower() != "detector"
]
class_name = inspector["inspector_class_name"]
plugin_module_name = f"{PLUGIN_PATH}.{inspector['inspector_module_name']}"
logger.info(f"using {class_name} and {plugin_module_name}")
def worker_factory(
worker_id,
inspector=inspector,
consume_topic=consume_topic,
produce_topics=produce_topics,
):
return build_inspector_worker(
inspector_config=inspector,
consume_topic=consume_topic,
produce_topics=produce_topics,
worker_id=worker_id,
)
tasks.append(
asyncio.create_task(
start_pipeline_worker_replicas(
config=config,
module_name=module_name,
instance_name=inspector["name"],
worker_factory=worker_factory,
target_name="bootstrap_inspection_process",
process_entrypoint=run_inspector_worker_process,
process_args=(inspector, consume_topic, produce_topics),
)
)
)
await asyncio.gather(*tasks)
if __name__ == "__main__": # pragma: no cover
asyncio.run(main())