Skip to content

Commit a6a7246

Browse files
authored
fix(misp): eliminate N+1 HTTP requests in MISP connector. Closes #3571 (#3579)
* fix(connectors): eliminate N+1 HTTP requests in MISP connector * test(connectors): add unit tests for MISP bulk add_event
1 parent ed743d7 commit a6a7246

2 files changed

Lines changed: 154 additions & 4 deletions

File tree

api_app/connectors_manager/connectors/misp.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,16 @@ def run(self):
118118
# append attribute name to event info
119119
event.info += f": {self._base_attr_obj.value}"
120120

121-
# add event to MISP Instance
122-
misp_event = misp_instance.add_event(event, pythonify=True)
123-
# add attributes to event on MISP Instance
121+
# bulk: attach all attributes to the event object before sending
124122
for attr in attributes:
125-
misp_instance.add_attribute(misp_event.id, attr)
123+
event.add_attribute(
124+
attr.type,
125+
attr.value,
126+
**{k: v for k, v in attr.to_dict().items() if k not in ("type", "value", "uuid")},
127+
)
128+
129+
# single request — event + all attributes sent together
130+
misp_event = misp_instance.add_event(event, pythonify=True)
126131

127132
return misp_instance.get_event(misp_event.id)
128133

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
from kombu import uuid
4+
5+
from api_app.analyzables_manager.models import Analyzable
6+
from api_app.choices import Classification
7+
from api_app.connectors_manager.connectors.misp import MISP
8+
from api_app.connectors_manager.models import ConnectorConfig, ConnectorReport
9+
from api_app.models import Job, Parameter, PluginConfig
10+
from tests import CustomTestCase
11+
12+
13+
class MISPConnectorTestCase(CustomTestCase):
14+
fixtures = [
15+
"api_app/fixtures/0001_user.json",
16+
]
17+
18+
@staticmethod
19+
def _get_misp_config():
20+
return ConnectorConfig.objects.get(name="MISP")
21+
22+
@staticmethod
23+
def _create_plugin_configs(config):
24+
pcs = []
25+
for name in ("url_key_name", "api_key_name"):
26+
param = Parameter.objects.get(python_module=config.python_module, name=name)
27+
pc = PluginConfig.objects.create(
28+
parameter=param,
29+
value="https://misp.test" if "url" in name else "test-api-key",
30+
for_organization=False,
31+
owner=None,
32+
connector_config=config,
33+
)
34+
pcs.append(pc)
35+
return pcs
36+
37+
def _setup_job(self):
38+
config = self._get_misp_config()
39+
pcs = self._create_plugin_configs(config)
40+
analyzable = Analyzable.objects.create(name="8.8.8.8", classification=Classification.IP)
41+
job = Job.objects.create(
42+
analyzable=analyzable,
43+
user=self.superuser,
44+
status=Job.STATUSES.REPORTED_WITHOUT_FAILS.value,
45+
)
46+
job.connectors_to_execute.set([config])
47+
return job, config, pcs
48+
49+
@staticmethod
50+
def _cleanup(job, config, pcs):
51+
try:
52+
ConnectorReport.objects.get(job=job, config=config).delete()
53+
except ConnectorReport.DoesNotExist:
54+
pass
55+
analyzable = job.analyzable
56+
job.delete()
57+
analyzable.delete()
58+
for pc in pcs:
59+
pc.delete()
60+
61+
@patch("api_app.connectors_manager.connectors.misp.MockPyMISP")
62+
@patch("api_app.connectors_manager.connectors.misp.pymisp.PyMISP")
63+
def test_bulk_add_event_called_once(self, mock_pymisp_cls, mock_misp_cls):
64+
"""
65+
run() must call add_event exactly once with all attributes already
66+
attached to the event object. add_attribute on the MISP instance
67+
must never be called (that was the old N+1 pattern).
68+
"""
69+
mock_instance = MagicMock()
70+
mock_pymisp_cls.return_value = mock_instance
71+
mock_misp_cls.return_value = mock_instance
72+
73+
mock_event = MagicMock()
74+
mock_event.id = 42
75+
mock_instance.add_event.return_value = mock_event
76+
mock_instance.get_event.return_value = {"Event": {"id": 42}}
77+
78+
job, config, pcs = self._setup_job()
79+
try:
80+
connector = MISP(config)
81+
connector.start(job.pk, {}, uuid())
82+
83+
report = ConnectorReport.objects.get(job=job, config=config)
84+
self.assertEqual(report.status, ConnectorReport.STATUSES.SUCCESS)
85+
86+
mock_instance.add_event.assert_called_once()
87+
mock_instance.add_attribute.assert_not_called()
88+
89+
event_arg = mock_instance.add_event.call_args[0][0]
90+
self.assertGreaterEqual(len(event_arg.attributes), 1)
91+
finally:
92+
self._cleanup(job, config, pcs)
93+
94+
@patch("api_app.connectors_manager.connectors.misp.MockPyMISP")
95+
@patch("api_app.connectors_manager.connectors.misp.pymisp.PyMISP")
96+
def test_all_attributes_present_on_event(self, mock_pymisp_cls, mock_misp_cls):
97+
"""
98+
The event sent to MISP must contain the base attribute and the
99+
link attribute — all in one shot.
100+
"""
101+
mock_instance = MagicMock()
102+
mock_pymisp_cls.return_value = mock_instance
103+
mock_misp_cls.return_value = mock_instance
104+
105+
mock_event = MagicMock()
106+
mock_event.id = 99
107+
mock_instance.add_event.return_value = mock_event
108+
mock_instance.get_event.return_value = {"Event": {"id": 99}}
109+
110+
job, config, pcs = self._setup_job()
111+
try:
112+
connector = MISP(config)
113+
connector.start(job.pk, {}, uuid())
114+
115+
event_arg = mock_instance.add_event.call_args[0][0]
116+
attr_types = [a.type for a in event_arg.attributes]
117+
118+
self.assertIn("ip-src", attr_types)
119+
self.assertIn("link", attr_types)
120+
finally:
121+
self._cleanup(job, config, pcs)
122+
123+
@patch("api_app.connectors_manager.connectors.misp.MockPyMISP")
124+
@patch("api_app.connectors_manager.connectors.misp.pymisp.PyMISP")
125+
def test_add_event_failure_marks_report_failed(self, mock_pymisp_cls, mock_misp_cls):
126+
"""
127+
If add_event raises, the connector report status must be FAILED.
128+
"""
129+
mock_instance = MagicMock()
130+
mock_pymisp_cls.return_value = mock_instance
131+
mock_misp_cls.return_value = mock_instance
132+
mock_instance.add_event.side_effect = Exception("MISP unreachable")
133+
134+
job, config, pcs = self._setup_job()
135+
try:
136+
connector = MISP(config)
137+
try:
138+
connector.start(job.pk, {}, uuid())
139+
except Exception:
140+
pass
141+
142+
report = ConnectorReport.objects.get(job=job, config=config)
143+
self.assertEqual(report.status, ConnectorReport.STATUSES.FAILED)
144+
finally:
145+
self._cleanup(job, config, pcs)

0 commit comments

Comments
 (0)