Skip to content

Commit 425a5d3

Browse files
Mullvad_DNS (New Analyzer) (#2763)
* created Mullvad_DNS analyzer * resolved the issue * Resolved issues in PR * resolved migration conflict * resolved the classification error in analyzer * fixed the tests error because of invalid sample DNS * updated the requirements and the Mockup Response * Removed unnecessary support for generic observable * Changed the variable hostname and redundant observable variable * Added varibale observable * accepted changes by @fgibertoni Co-authored-by: Federico Gibertoni <[email protected]> * implemented requested changes and cleaned the code * added comment for httpx requirement * add the comment for package requirement * Enhanced logging information --------- Co-authored-by: Federico Gibertoni <[email protected]>
1 parent 2fd6c7e commit 425a5d3

File tree

3 files changed

+291
-0
lines changed

3 files changed

+291
-0
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from django.db import migrations
2+
from django.db.models.fields.related_descriptors import (
3+
ForwardManyToOneDescriptor,
4+
ForwardOneToOneDescriptor,
5+
ManyToManyDescriptor,
6+
ReverseManyToOneDescriptor,
7+
ReverseOneToOneDescriptor,
8+
)
9+
10+
plugin = {
11+
"python_module": {
12+
"health_check_schedule": None,
13+
"update_schedule": None,
14+
"module": "dns.dns_malicious_detectors.mullvad_dns.MullvadDNSAnalyzer",
15+
"base_path": "api_app.analyzers_manager.observable_analyzers",
16+
},
17+
"name": "Mullvad_DNS",
18+
"description": "[Mullvad_DNS](https://github.com/mullvad/dns-blocklists) is an analyzer that queries Mullvad's DNS-over-HTTPS service (using the 'base' endpoint) to check a domain's DNS records. \r\nIt supports two modes:\r\n- 'query': returns raw DNS answer data.\r\n- 'malicious': interprets an NXDOMAIN (rcode==3) as the domain being blocked (i.e., malicious).",
19+
"disabled": False,
20+
"soft_time_limit": 60,
21+
"routing_key": "default",
22+
"health_check_status": True,
23+
"type": "observable",
24+
"docker_based": False,
25+
"maximum_tlp": "RED",
26+
"observable_supported": ["url", "domain"],
27+
"supported_filetypes": [],
28+
"run_hash": False,
29+
"run_hash_type": "",
30+
"not_supported_filetypes": [],
31+
"mapping_data_model": {},
32+
"model": "analyzers_manager.AnalyzerConfig",
33+
}
34+
35+
params = [
36+
{
37+
"python_module": {
38+
"module": "dns.dns_malicious_detectors.mullvad_dns.MullvadDNSAnalyzer",
39+
"base_path": "api_app.analyzers_manager.observable_analyzers",
40+
},
41+
"name": "mode",
42+
"type": "str",
43+
"description": "'query': returns raw DNS answer data.\r\n'malicious': interprets an NXDOMAIN (rcode==3) as the domain being blocked (i.e., malicious).",
44+
"is_secret": False,
45+
"required": False,
46+
}
47+
]
48+
49+
values = [
50+
{
51+
"parameter": {
52+
"python_module": {
53+
"module": "dns.dns_malicious_detectors.mullvad_dns.MullvadDNSAnalyzer",
54+
"base_path": "api_app.analyzers_manager.observable_analyzers",
55+
},
56+
"name": "mode",
57+
"type": "str",
58+
"description": "'query': returns raw DNS answer data.\r\n'malicious': interprets an NXDOMAIN (rcode==3) as the domain being blocked (i.e., malicious).",
59+
"is_secret": False,
60+
"required": False,
61+
},
62+
"analyzer_config": "Mullvad_DNS",
63+
"connector_config": None,
64+
"visualizer_config": None,
65+
"ingestor_config": None,
66+
"pivot_config": None,
67+
"for_organization": False,
68+
"value": "query",
69+
"updated_at": "2025-02-20T16:14:25.192983Z",
70+
"owner": None,
71+
}
72+
]
73+
74+
75+
def _get_real_obj(Model, field, value):
76+
def _get_obj(Model, other_model, value):
77+
if isinstance(value, dict):
78+
real_vals = {}
79+
for key, real_val in value.items():
80+
real_vals[key] = _get_real_obj(other_model, key, real_val)
81+
value = other_model.objects.get_or_create(**real_vals)[0]
82+
# it is just the primary key serialized
83+
else:
84+
if isinstance(value, int):
85+
if Model.__name__ == "PluginConfig":
86+
value = other_model.objects.get(name=plugin["name"])
87+
else:
88+
value = other_model.objects.get(pk=value)
89+
else:
90+
value = other_model.objects.get(name=value)
91+
return value
92+
93+
if (
94+
type(getattr(Model, field))
95+
in [
96+
ForwardManyToOneDescriptor,
97+
ReverseManyToOneDescriptor,
98+
ReverseOneToOneDescriptor,
99+
ForwardOneToOneDescriptor,
100+
]
101+
and value
102+
):
103+
other_model = getattr(Model, field).get_queryset().model
104+
value = _get_obj(Model, other_model, value)
105+
elif type(getattr(Model, field)) in [ManyToManyDescriptor] and value:
106+
other_model = getattr(Model, field).rel.model
107+
value = [_get_obj(Model, other_model, val) for val in value]
108+
return value
109+
110+
111+
def _create_object(Model, data):
112+
mtm, no_mtm = {}, {}
113+
for field, value in data.items():
114+
value = _get_real_obj(Model, field, value)
115+
if type(getattr(Model, field)) is ManyToManyDescriptor:
116+
mtm[field] = value
117+
else:
118+
no_mtm[field] = value
119+
try:
120+
o = Model.objects.get(**no_mtm)
121+
except Model.DoesNotExist:
122+
o = Model(**no_mtm)
123+
o.full_clean()
124+
o.save()
125+
for field, value in mtm.items():
126+
attribute = getattr(o, field)
127+
if value is not None:
128+
attribute.set(value)
129+
return False
130+
return True
131+
132+
133+
def migrate(apps, schema_editor):
134+
Parameter = apps.get_model("api_app", "Parameter")
135+
PluginConfig = apps.get_model("api_app", "PluginConfig")
136+
python_path = plugin.pop("model")
137+
Model = apps.get_model(*python_path.split("."))
138+
if not Model.objects.filter(name=plugin["name"]).exists():
139+
exists = _create_object(Model, plugin)
140+
if not exists:
141+
for param in params:
142+
_create_object(Parameter, param)
143+
for value in values:
144+
_create_object(PluginConfig, value)
145+
146+
147+
def reverse_migrate(apps, schema_editor):
148+
python_path = plugin.pop("model")
149+
Model = apps.get_model(*python_path.split("."))
150+
Model.objects.get(name=plugin["name"]).delete()
151+
152+
153+
class Migration(migrations.Migration):
154+
atomic = False
155+
dependencies = [
156+
("api_app", "0071_delete_last_elastic_report"),
157+
("analyzers_manager", "0151_analyzer_config_ipquery"),
158+
]
159+
160+
operations = [migrations.RunPython(migrate, reverse_migrate)]
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl
2+
# See the file 'LICENSE' for copying permission.
3+
4+
5+
import base64
6+
import logging
7+
from urllib.parse import urlparse
8+
9+
import dns.message
10+
import httpx
11+
12+
from api_app.analyzers_manager.classes import ObservableAnalyzer
13+
from api_app.analyzers_manager.exceptions import AnalyzerConfigurationException
14+
from api_app.analyzers_manager.observable_analyzers.dns.dns_responses import (
15+
malicious_detector_response,
16+
)
17+
from api_app.choices import Classification
18+
from tests.mock_utils import MockUpResponse, if_mock_connections, patch
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
class MullvadDNSAnalyzer(ObservableAnalyzer):
24+
"""
25+
MullvadDNSAnalyzer:
26+
27+
This analyzer queries Mullvad's DNS-over-HTTPS service (using the "base" endpoint)
28+
to check a domain's DNS records. It supports two modes:
29+
- "query": returns raw DNS answer data.
30+
- "malicious": interprets an NXDOMAIN (rcode==3) as the domain being blocked (i.e., malicious).
31+
"""
32+
33+
url = "https://base.dns.mullvad.net/dns-query"
34+
mode: str = "query"
35+
36+
def update(self):
37+
pass
38+
39+
@staticmethod
40+
def encode_query(observable: str) -> str:
41+
"""
42+
Constructs a DNS query for the given observable (domain) for an A record,
43+
converts it to wire format, and encodes it in URL-safe base64.
44+
"""
45+
logger.info(f"Encoding DNS query for {observable}")
46+
query = dns.message.make_query(observable, dns.rdatatype.A)
47+
wire_query = query.to_wire()
48+
encoded_query = (
49+
base64.urlsafe_b64encode(wire_query).rstrip(b"=").decode("ascii")
50+
)
51+
logger.info(f"Mullvad_DNS encoded query for {observable}: {encoded_query}")
52+
return encoded_query
53+
54+
def run(self):
55+
"""
56+
Executes the analyzer:
57+
- Validates the observable type (DOMAIN or URL).
58+
- For URLs, extracts the hostname.
59+
- Encodes a DNS "A" record query.
60+
- Makes an HTTP GET request to the Mullvad DoH endpoint.
61+
- Parses the DNS response.
62+
- Depending on the configured mode ("query" or "malicious"), returns either raw data or a flagged result.
63+
"""
64+
observable = self.observable_name
65+
66+
if self.observable_classification == Classification.URL:
67+
logger.debug(f"Mullvad_DNS extracting hostname from URL {observable}")
68+
hostname = urlparse(observable).hostname
69+
observable = hostname
70+
71+
encoded_query = self.encode_query(observable)
72+
complete_url = f"{self.url}?dns={encoded_query}"
73+
logger.info(f"Requesting Mullvad DNS for {observable} at: {complete_url}")
74+
75+
try:
76+
response = httpx.Client(http2=True).get(
77+
complete_url,
78+
headers={"accept": "application/dns-message"},
79+
timeout=30.0,
80+
)
81+
response.raise_for_status()
82+
except httpx.HTTPError as e:
83+
logger.error(f"HTTP error: {e}")
84+
raise AnalyzerConfigurationException(f"Failed to query Mullvad DNS: {e}")
85+
86+
dns_response = dns.message.from_wire(response.content)
87+
88+
if self.mode == "malicious":
89+
malicious = dns_response.rcode() == 3
90+
return malicious_detector_response(
91+
observable=observable,
92+
malicious=malicious,
93+
note=f"Domain is {'' if malicious else 'not '}blocked by Mullvad DNS content filtering.",
94+
)
95+
96+
elif self.mode == "query":
97+
answers = dns_response.answer
98+
data = [str(rrset) for rrset in answers] if answers else []
99+
return {
100+
"status": "success",
101+
"data": data,
102+
"message": f"DNS query for {observable} completed successfully.",
103+
}
104+
else:
105+
raise AnalyzerConfigurationException(
106+
f"Invalid mode: {self.mode}. Must be 'query' or 'malicious'."
107+
)
108+
109+
@classmethod
110+
def _monkeypatch(cls):
111+
patches = [
112+
if_mock_connections(
113+
patch(
114+
"httpx.Client.get",
115+
return_value=MockUpResponse(
116+
{
117+
"status": "success",
118+
"data": "example.com. 236 IN A 23.215.0.138",
119+
"message": "DNS query for example.com completed successfully.",
120+
},
121+
200,
122+
content=b"pn\x01\x03\x00\x01\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01",
123+
),
124+
)
125+
)
126+
]
127+
return super()._monkeypatch(patches=patches)

requirements/project-requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ pylnk3==0.4.2
8989
androguard==3.4.0a1 # version >=4.x of androguard raises a dependency conflict with quark-engine==25.1.1
9090
wad==0.4.6
9191

92+
# httpx required for HTTP/2 support (Mullvad DNS rejects HTTP/1.1 with protocol errors)
93+
httpx[http2]==0.28.1
94+
95+
9296
# this is required because XLMMacroDeobfuscator does not pin the following packages
9397
pyxlsb2==0.0.8
9498
xlrd2==1.3.4

0 commit comments

Comments
 (0)