Skip to content

Commit cd46926

Browse files
committed
Add support for data filtering in webhook forwarder and introduce BasicFilter:
- Implement `match_filters` method in `webhook.py` for conditional forwarding based on filters. - Extend configuration model with a `filters` field and the `BasicFilter` class for attribute-based filtering. - Refactor filter application logic in `immediate`, `bulk`, and `periodic` modes. - Add and adjust related test cases for filtering and configuration validation.
1 parent 356e9d0 commit cd46926

5 files changed

Lines changed: 83 additions & 36 deletions

File tree

mongoose/core/watchdogs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def on_any_event(self, event: FileSystemEvent):
5353
return
5454
elif event.event_type == "created":
5555
new_configuration = self._load_configuration(src_file)
56-
if new_configuration:
56+
if new_configuration and new_configuration.enable:
5757
created.append(new_configuration)
5858
# Not supported yet
5959
# elif event.event_type == "modified":

mongoose/forward/webhook.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,24 @@ def start(self):
134134
super().start()
135135
logger.info(f"Forwarding to {self.config.url} [mode={self.config.mode}]")
136136

137+
def match_filters(self, data):
138+
"""Check if data matches any configured filters.
139+
Filters are applied using OR logic: if any filter matches, the data passes.
140+
If no filters are configured, all data is allowed through.
141+
142+
Args:
143+
data: The data object to check against configured filters.
144+
145+
Returns:
146+
True if data passes filtering (matches at least one filter or no filters configured),
147+
False otherwise.
148+
"""
149+
matches = len(self.config.filters) == 0
150+
for f in self.config.filters:
151+
if f.matches(data):
152+
matches = True
153+
return matches
154+
137155
def _run(self):
138156
"""Main worker loop to process messages and forward them.
139157
@@ -146,13 +164,15 @@ def _run(self):
146164
if self.config.mode == "immediate":
147165
data = self.queue.get(timeout=1.0)
148166
if data is not None:
149-
self.forward(data)
167+
if self.match_filters(data):
168+
self.forward(data)
150169
self.queue.task_done()
151170
elif self.config.mode == "bulk":
152171
try:
153172
data = self.queue.get(timeout=1.0)
154173
if data is not None:
155-
self._buffer.append(data)
174+
if self.match_filters(data):
175+
self._buffer.append(data)
156176
if len(self._buffer) >= self.config.bulk_size:
157177
self._flush_buffer()
158178
self.queue.task_done()
@@ -166,7 +186,8 @@ def _run(self):
166186
while len(self._buffer) < self.config.periodic_rate * 2: # Limit buffer growth
167187
data = self.queue.get_nowait()
168188
if data is not None:
169-
self._buffer.append(data)
189+
if self.match_filters(data):
190+
self._buffer.append(data)
170191
self.queue.task_done()
171192
except (Exception,): # empty queue
172193
pass
@@ -286,7 +307,3 @@ def _should_retry(self, exception: requests.exceptions.RequestException, attempt
286307
f"Failed to forward data to {self.config.url} after {self.config.retry_count + 1} attempts: {exception}"
287308
)
288309
return False
289-
290-
291-
# Discord-specific forwarder implementation moved to `mongoose.forward.discord`.
292-
# See mongoose/forward/discord.py for the DiscordFormatter and DiscordForwarder

mongoose/models/configuration.py

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,57 @@
11
from pathlib import Path
2-
from typing import Dict, List, Optional, Union
2+
from typing import Dict, List, Optional, Union, Any
33

44
from pydantic import BaseModel, Field, HttpUrl, SecretStr, validator
55

66

7-
class WebhookForwarderConfiguration(BaseModel):
7+
class BasicFilter(BaseModel):
8+
"""A filter that matches objects based on attribute values.
9+
10+
This filter checks if an object has a specific attribute and whether
11+
the attribute's value is in a predefined list of allowed values.
12+
13+
Attributes:
14+
attribute: The name of the attribute to check on the target object.
15+
values: List of acceptable values for the attribute.
16+
"""
17+
18+
attribute: str
19+
values: List[str]
20+
21+
def matches(self, obj: Any) -> bool:
22+
"""Check if the given object matches this filter.
23+
24+
The object matches if it has the specified attribute and the
25+
attribute's value is in the list of allowed values.
26+
27+
Args:
28+
obj: The object to check against this filter.
29+
30+
Returns:
31+
True if the object has the attribute and its value is in the
32+
allowed values list, False otherwise.
33+
"""
34+
if not hasattr(obj, self.attribute):
35+
return False
36+
value = getattr(obj, self.attribute)
37+
return value in self.values
38+
39+
40+
class ForwarderConfiguration(BaseModel):
41+
enable: bool = Field(default=False)
42+
"""Enable the forwarder. Defaults to False."""
43+
44+
topics: List[str] = Field(default_factory=lambda: ["enriched-network-dpi", "enriched-network-alert"])
45+
"""List of topics to forward. Defaults to ["enriched-network-dpi", "enriched-network-alert"]."""
46+
47+
configuration_file: Path = None
48+
"""Configuration file path, only set when loaded from a drop-in configuration."""
49+
50+
filters: List[BasicFilter] = []
51+
"""List of filters. Defaults to []."""
52+
53+
54+
class WebhookForwarderConfiguration(ForwarderConfiguration):
855
"""Configuration for the Webhook Forwarder.
956
1057
This class defines the destination, authentication, and reliability settings
@@ -23,9 +70,6 @@ class WebhookForwarderConfiguration(BaseModel):
2370
url: Union[HttpUrl, str]
2471
"""The destination URL for the webhook (must be a valid HTTP/HTTPS URL)."""
2572

26-
configuration_file: Path = None
27-
"""Configuration file path, only set when loaded from a drop-in configuration."""
28-
2973
headers: Dict[str, str] = Field(default_factory=dict)
3074
"""Optional dictionary of additional HTTP headers to include in requests."""
3175

@@ -50,12 +94,6 @@ class WebhookForwarderConfiguration(BaseModel):
5094
timeout: float = Field(default=10.0, gt=0)
5195
"""Request timeout in seconds. Defaults to 10.0."""
5296

53-
enable: bool = Field(default=False)
54-
"""Enable the forwarder. Defaults to False."""
55-
56-
topics: List[str] = Field(default_factory=lambda: ["enriched-network-dpi", "enriched-network-alert"])
57-
"""List of topics to forward. Defaults to ["enriched-network-dpi", "enriched-network-alert"]."""
58-
5997
# Forwarding modes
6098
mode: str = Field(default="immediate") # immediate, bulk, periodic
6199
"""Forwarding mode ('immediate', 'bulk', 'periodic'). Defaults to 'immediate'."""
@@ -94,7 +132,7 @@ def validate_auth_token(cls, v, values):
94132
return v
95133

96134

97-
class FileForwarderConfiguration(BaseModel):
135+
class FileForwarderConfiguration(ForwarderConfiguration):
98136
"""Configuration for the File Forwarder."""
99137

100138
output_dir: str = "output"

tests/test_processing_unsubscribe.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_unsubscribe():
2222
assert len(pq.queues[topic]) == 1
2323

2424
# Unsubscribe (method not yet implemented)
25-
if hasattr(pq, 'unsubscribe'):
25+
if hasattr(pq, "unsubscribe"):
2626
pq.unsubscribe(subscriber_id)
2727
assert subscriber_id not in pq.subscribers
2828
assert topic not in pq.queues or len(pq.queues[topic]) == 0

tests/test_webhook_forwarder.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,7 @@ def test_webhook_formatter_alert():
100100

101101
def test_webhook_forwarder_basic_flow(webhook_receiver):
102102
config = WebhookForwarderConfiguration(
103-
enable=True,
104-
url="http://example.com/webhook",
105-
retry_count=0,
106-
topics=["network-alert"]
103+
enable=True, url="http://example.com/webhook", retry_count=0, topics=["network-alert"]
107104
)
108105

109106
pq = ProcessingQueue()
@@ -147,7 +144,7 @@ def test_webhook_forwarder_auth_bearer():
147144
url="http://example.com/webhook",
148145
auth_type="bearer",
149146
auth_token=SecretStr("mytoken"),
150-
topics=["network-alert"]
147+
topics=["network-alert"],
151148
)
152149

153150
forwarder = WebhookForwarder(config)
@@ -170,9 +167,7 @@ def test_webhook_forwarder_auth_header():
170167

171168
def test_webhook_forwarder_retries(webhook_receiver):
172169
config = WebhookForwarderConfiguration(
173-
enable=True,
174-
url="http://example.com/webhook", retry_count=1, retry_delay=0.1,
175-
topics=["network-alert"]
170+
enable=True, url="http://example.com/webhook", retry_count=1, retry_delay=0.1, topics=["network-alert"]
176171
)
177172

178173
pq = ProcessingQueue()
@@ -216,17 +211,14 @@ def test_webhook_configuration_validation():
216211
WebhookForwarderConfiguration(enable=True, url="http://example.com", auth_type="bearer")
217212

218213
with pytest.raises(ValueError, match="must be in 'user:pass' format"):
219-
WebhookForwarderConfiguration(enable=True, url="http://example.com", auth_type="basic",
220-
auth_token=SecretStr("not-a-pair"))
214+
WebhookForwarderConfiguration(
215+
enable=True, url="http://example.com", auth_type="basic", auth_token=SecretStr("not-a-pair")
216+
)
221217

222218

223219
def test_webhook_forwarder_bulk_mode(webhook_receiver):
224220
config = WebhookForwarderConfiguration(
225-
enable=True,
226-
url="http://example.com/webhook",
227-
mode="bulk",
228-
bulk_size=2,
229-
topics=["network-alert"]
221+
enable=True, url="http://example.com/webhook", mode="bulk", bulk_size=2, topics=["network-alert"]
230222
)
231223

232224
pq = ProcessingQueue()

0 commit comments

Comments
 (0)