Skip to content

Commit d24e8c6

Browse files
Add RDAP analyzer (#3760)
* Add RDAP analyzer * Add Rdap to the FREE_TO_USE_ANALYZERS playbook
1 parent c64af39 commit d24e8c6

4 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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": "rdap.Rdap",
15+
"base_path": "api_app.analyzers_manager.observable_analyzers",
16+
},
17+
"name": "Rdap",
18+
"description": "Query the public [RDAP](https://about.rdap.org/) bootstrap "
19+
"(rdap.org) for registration data (RFC 9082); the free, unauthenticated WHOIS "
20+
"successor. Supports IPs, domains and URLs.",
21+
"disabled": False,
22+
"soft_time_limit": 30,
23+
"routing_key": "default",
24+
"health_check_status": True,
25+
"type": "observable",
26+
"docker_based": False,
27+
"maximum_tlp": "AMBER",
28+
"observable_supported": ["ip", "domain", "url"],
29+
"supported_filetypes": [],
30+
"run_hash": False,
31+
"run_hash_type": "",
32+
"not_supported_filetypes": [],
33+
"mapping_data_model": {},
34+
"model": "analyzers_manager.AnalyzerConfig",
35+
}
36+
37+
params = []
38+
39+
values = []
40+
41+
42+
def _get_real_obj(Model, field, value):
43+
def _get_obj(Model, other_model, value):
44+
if isinstance(value, dict):
45+
real_vals = {}
46+
for key, real_val in value.items():
47+
real_vals[key] = _get_real_obj(other_model, key, real_val)
48+
value = other_model.objects.get_or_create(**real_vals)[0]
49+
# it is just the primary key serialized
50+
else:
51+
if isinstance(value, int):
52+
if Model.__name__ == "PluginConfig":
53+
value = other_model.objects.get(name=plugin["name"])
54+
else:
55+
value = other_model.objects.get(pk=value)
56+
else:
57+
value = other_model.objects.get(name=value)
58+
return value
59+
60+
if (
61+
type(getattr(Model, field))
62+
in [
63+
ForwardManyToOneDescriptor,
64+
ReverseManyToOneDescriptor,
65+
ReverseOneToOneDescriptor,
66+
ForwardOneToOneDescriptor,
67+
]
68+
and value
69+
):
70+
other_model = getattr(Model, field).get_queryset().model
71+
value = _get_obj(Model, other_model, value)
72+
elif type(getattr(Model, field)) in [ManyToManyDescriptor] and value:
73+
other_model = getattr(Model, field).rel.model
74+
value = [_get_obj(Model, other_model, val) for val in value]
75+
return value
76+
77+
78+
def _create_object(Model, data):
79+
mtm, no_mtm = {}, {}
80+
for field, value in data.items():
81+
value = _get_real_obj(Model, field, value)
82+
if type(getattr(Model, field)) is ManyToManyDescriptor:
83+
mtm[field] = value
84+
else:
85+
no_mtm[field] = value
86+
try:
87+
o = Model.objects.get(**no_mtm)
88+
except Model.DoesNotExist:
89+
o = Model(**no_mtm)
90+
o.full_clean()
91+
o.save()
92+
for field, value in mtm.items():
93+
attribute = getattr(o, field)
94+
if value is not None:
95+
attribute.set(value)
96+
return False
97+
return True
98+
99+
100+
def migrate(apps, schema_editor):
101+
Parameter = apps.get_model("api_app", "Parameter")
102+
PluginConfig = apps.get_model("api_app", "PluginConfig")
103+
plugin_data = dict(plugin)
104+
python_path = plugin_data.pop("model")
105+
Model = apps.get_model(*python_path.split("."))
106+
if not Model.objects.filter(name=plugin_data["name"]).exists():
107+
exists = _create_object(Model, plugin_data)
108+
if not exists:
109+
for param in params:
110+
_create_object(Parameter, param)
111+
for value in values:
112+
_create_object(PluginConfig, value)
113+
114+
115+
def reverse_migrate(apps, schema_editor):
116+
plugin_data = dict(plugin)
117+
python_path = plugin_data.pop("model")
118+
Model = apps.get_model(*python_path.split("."))
119+
Model.objects.get(name=plugin_data["name"]).delete()
120+
121+
122+
class Migration(migrations.Migration):
123+
atomic = False
124+
dependencies = [
125+
("api_app", "0073_alter_updatecheckstatus_last_checked_at_and_more"),
126+
("analyzers_manager", "0193_analyzer_config_cve_exploitability"),
127+
]
128+
129+
operations = [migrations.RunPython(migrate, reverse_migrate)]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl
2+
# See the file 'LICENSE' for copying permission.
3+
4+
import ipaddress
5+
from urllib.parse import urlparse
6+
7+
import requests
8+
9+
from api_app.analyzers_manager import classes
10+
from api_app.analyzers_manager.exceptions import AnalyzerRunException
11+
from api_app.choices import Classification
12+
13+
14+
class Rdap(classes.ObservableAnalyzer):
15+
"""Query the public RDAP bootstrap (https://rdap.org) for an observable's
16+
registration data.
17+
18+
RDAP (Registration Data Access Protocol, RFC 9082/9083) is the IETF-standard,
19+
free and unauthenticated successor to WHOIS. It returns structured JSON
20+
describing the registration of IP addresses, domains, and URLs (resolved to
21+
their host). The rdap.org bootstrap redirects each query to the authoritative
22+
RDAP server for the object.
23+
"""
24+
25+
url: str = "https://rdap.org"
26+
27+
def update(self) -> bool:
28+
# No local data to refresh; rdap.org is queried live on every run.
29+
return False
30+
31+
@staticmethod
32+
def _path_for_host(host: str) -> str:
33+
"""Pick the RDAP endpoint for a host: ``ip/`` for an IP literal
34+
(the host of a URL can be one), ``domain/`` otherwise."""
35+
try:
36+
ipaddress.ip_address(host)
37+
except ValueError:
38+
return f"domain/{host}"
39+
return f"ip/{host}"
40+
41+
def run(self):
42+
if self.observable_classification == Classification.IP:
43+
path = f"ip/{self.observable_name}"
44+
elif self.observable_classification == Classification.DOMAIN:
45+
path = f"domain/{self.observable_name}"
46+
elif self.observable_classification == Classification.URL:
47+
hostname = urlparse(self.observable_name).hostname
48+
if not hostname:
49+
raise AnalyzerRunException(f"unable to extract a hostname from URL {self.observable_name}")
50+
path = self._path_for_host(hostname)
51+
else:
52+
raise AnalyzerRunException(
53+
f"{self.observable_classification} is not a supported observable type "
54+
"for RDAP (supported: ip, domain, url)"
55+
)
56+
57+
try:
58+
response = requests.get(
59+
f"{self.url}/{path}",
60+
headers={"Accept": "application/rdap+json"},
61+
timeout=10,
62+
)
63+
# RDAP returns 404 when the registry holds no record for the object;
64+
# treat that as a clean negative result rather than an error.
65+
if response.status_code == 404:
66+
return {"found": False}
67+
response.raise_for_status()
68+
except requests.RequestException as e:
69+
raise AnalyzerRunException(str(e)) from e
70+
71+
try:
72+
result = response.json()
73+
except ValueError as e:
74+
raise AnalyzerRunException(f"RDAP server returned a non-JSON response: {e}") from e
75+
result["found"] = True
76+
return result
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl
2+
# See the file 'LICENSE' for copying permission.
3+
4+
5+
from django.db import migrations
6+
7+
8+
def migrate(apps, schema_editor):
9+
playbook_config = apps.get_model("playbooks_manager", "PlaybookConfig")
10+
AnalyzerConfig = apps.get_model("analyzers_manager", "AnalyzerConfig")
11+
pc = playbook_config.objects.get(name="FREE_TO_USE_ANALYZERS")
12+
pc.analyzers.add(AnalyzerConfig.objects.get(name="Rdap").id)
13+
pc.full_clean()
14+
pc.save()
15+
16+
17+
def reverse_migrate(apps, schema_editor):
18+
playbook_config = apps.get_model("playbooks_manager", "PlaybookConfig")
19+
AnalyzerConfig = apps.get_model("analyzers_manager", "AnalyzerConfig")
20+
pc = playbook_config.objects.get(name="FREE_TO_USE_ANALYZERS")
21+
pc.analyzers.remove(AnalyzerConfig.objects.get(name="Rdap").id)
22+
pc.full_clean()
23+
pc.save()
24+
25+
26+
class Migration(migrations.Migration):
27+
dependencies = [
28+
("playbooks_manager", "0067_add_cve_exploitability_to_free_to_use"),
29+
("analyzers_manager", "0194_analyzer_config_rdap"),
30+
]
31+
32+
operations = [
33+
migrations.RunPython(migrate, reverse_migrate),
34+
]
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# This file is a part of IntelOwl https://github.com/intelowlproject/IntelOwl
2+
# See the file 'LICENSE' for copying permission.
3+
4+
from unittest.mock import MagicMock, patch
5+
6+
from api_app.analyzers_manager.exceptions import AnalyzerRunException
7+
from api_app.analyzers_manager.observable_analyzers.rdap import Rdap
8+
from api_app.choices import Classification
9+
from tests import CustomTestCase
10+
11+
12+
class RdapTestCase(CustomTestCase):
13+
"""Unit tests for the RDAP analyzer (mocked HTTP, no live calls)."""
14+
15+
@staticmethod
16+
def _analyzer(observable_name, classification):
17+
analyzer = Rdap(config={})
18+
analyzer.observable_name = observable_name
19+
analyzer.observable_classification = classification
20+
return analyzer
21+
22+
@staticmethod
23+
def _ok(json_data):
24+
response = MagicMock(status_code=200)
25+
response.raise_for_status.return_value = None
26+
response.json.return_value = json_data
27+
return response
28+
29+
@patch("api_app.analyzers_manager.observable_analyzers.rdap.requests.get")
30+
def test_domain_found(self, mock_get):
31+
mock_get.return_value = self._ok({"objectClassName": "domain", "ldhName": "example.com"})
32+
result = self._analyzer("example.com", Classification.DOMAIN).run()
33+
self.assertTrue(result["found"])
34+
self.assertEqual(result["ldhName"], "example.com")
35+
self.assertIn("rdap.org/domain/example.com", mock_get.call_args.args[0])
36+
37+
@patch("api_app.analyzers_manager.observable_analyzers.rdap.requests.get")
38+
def test_ip_uses_ip_endpoint(self, mock_get):
39+
mock_get.return_value = self._ok({"objectClassName": "ip network"})
40+
self._analyzer("1.1.1.1", Classification.IP).run()
41+
self.assertIn("rdap.org/ip/1.1.1.1", mock_get.call_args.args[0])
42+
43+
@patch("api_app.analyzers_manager.observable_analyzers.rdap.requests.get")
44+
def test_url_resolves_to_host_domain(self, mock_get):
45+
mock_get.return_value = self._ok({"objectClassName": "domain"})
46+
self._analyzer("https://sub.example.com/path?q=1", Classification.URL).run()
47+
self.assertIn("rdap.org/domain/sub.example.com", mock_get.call_args.args[0])
48+
49+
@patch("api_app.analyzers_manager.observable_analyzers.rdap.requests.get")
50+
def test_url_with_ip_host_uses_ip_endpoint(self, mock_get):
51+
mock_get.return_value = self._ok({"objectClassName": "ip network"})
52+
self._analyzer("http://1.1.1.1/path", Classification.URL).run()
53+
self.assertIn("rdap.org/ip/1.1.1.1", mock_get.call_args.args[0])
54+
55+
@patch("api_app.analyzers_manager.observable_analyzers.rdap.requests.get")
56+
def test_non_json_response_raises(self, mock_get):
57+
response = MagicMock(status_code=200)
58+
response.raise_for_status.return_value = None
59+
response.json.side_effect = ValueError("Expecting value")
60+
mock_get.return_value = response
61+
with self.assertRaises(AnalyzerRunException):
62+
self._analyzer("example.com", Classification.DOMAIN).run()
63+
64+
@patch("api_app.analyzers_manager.observable_analyzers.rdap.requests.get")
65+
def test_not_found_returns_clean_negative(self, mock_get):
66+
mock_get.return_value = MagicMock(status_code=404)
67+
result = self._analyzer("does-not-exist.invalid", Classification.DOMAIN).run()
68+
self.assertEqual(result, {"found": False})
69+
70+
def test_unsupported_classification_raises(self):
71+
with self.assertRaises(AnalyzerRunException):
72+
self._analyzer("deadbeefdeadbeef", Classification.HASH).run()
73+
74+
def test_url_without_hostname_raises(self):
75+
with self.assertRaises(AnalyzerRunException):
76+
self._analyzer("not-a-url", Classification.URL).run()

0 commit comments

Comments
 (0)