Skip to content

Commit c10266f

Browse files
authored
Merge pull request #922 from MISP/fix/modat-observation-cardinality
Fix MODAT parsing of multiple PTR and certificate RDN values
2 parents b60b895 + a2a214e commit c10266f

3 files changed

Lines changed: 150 additions & 7 deletions

File tree

‎documentation/expansion/modat.json‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"Preserve array nesting in JSON; repeat scalar leaves in the typed observation. Web files also get individual file objects.",
3131
"Link observations to input and related infrastructure; deduplicate shared objects and set discovered attributes to_ids=false.",
3232
"Bound search pagination; report rate limits and authentication errors without exposing response bodies or credentials.",
33-
"Use fresh PyMISP UUIDs for generated objects and host attachments across enrichments; deduplicate identical evidence only within the current response."
33+
"Use fresh PyMISP UUIDs for generated objects and host attachments across enrichments; deduplicate identical evidence only within the current response.",
34+
"Preserve multiple reverse-DNS names in dns-record objects and repeated certificate RDNs in the full issuer/subject strings, respecting single-valued standard object relations."
3435
],
3536
"input": {
3637
"format": "misp_standard",
@@ -210,7 +211,8 @@
210211
"misp_modules/data/modat/modat-dns-record/definition.json"
211212
],
212213
"no_results": "Return original attribute and empty Object array.",
213-
"partial_failure": "A failed API call returns an error for the enrichment; partial results are not silently returned."
214+
"partial_failure": "A failed API call returns an error for the enrichment; partial results are not silently returned.",
215+
"errors": "Unexpected input or response parsing failures report the processing stage and exception class. Exception messages, response contents and credentials are not logged or returned."
214216
},
215217
"config": {
216218
"apikey": {
@@ -267,7 +269,7 @@
267269
"Restart misp-modules, enable modat enrichment in MISP and set apikey. Configure optional result limits, timeout and DNS behavior as needed."
268270
],
269271
"validation": {
270-
"offline_tests": 80,
272+
"offline_tests": 85,
271273
"command": "python -m pytest tests/test_modat.py -q",
272274
"live_authenticated": false
273275
}

‎misp_modules/modules/expansion/modat.py‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import base64
44
import ipaddress
55
import json
6+
import logging
67
import math
78
import re
89
from pathlib import Path
@@ -13,6 +14,8 @@
1314
from pymisp import MISPAttribute, MISPEvent, MISPObject
1415
from pymisp.exceptions import PyMISPError
1516

17+
LOGGER = logging.getLogger("misp-modules.modat")
18+
1619
BASE_URL = "https://api.magnify.modat.io"
1720
DATA_PATH = Path(__file__).resolve().parents[2] / "data" / "modat"
1821
SERVICE_MAPPING = json.loads((DATA_PATH / "service_mapping.json").read_text())
@@ -383,11 +386,22 @@ def host(self, data):
383386
obj = MISPObject("ip-port", strict=True)
384387
self.add(obj, "ip", ip)
385388
self.add(obj, "domain", data.get("fqdns"))
386-
self.add(obj, "ptr-record", data.get("rdns"))
389+
rdns = data.get("rdns") or []
390+
if not isinstance(rdns, list) or not all(isinstance(name, str) for name in rdns):
391+
raise ModatError("MODAT returned invalid reverse DNS names.")
392+
rdns = list(dict.fromkeys(name for name in rdns if name))
393+
# ip-port permits one PTR value; dns-record can preserve the complete answer set.
394+
if len(rdns) == 1:
395+
self.add(obj, "ptr-record", rdns[0])
387396
if data.get("is_anycast") is not None:
388397
self.add(obj, "text", f"MODAT is_anycast={str(data['is_anycast']).lower()}")
389398
self.add(obj, "text", [f"MODAT classification: {tag}" for tag in data.get("tags", [])])
390399
host = self.put(obj)
400+
if rdns:
401+
reverse_dns = MISPObject("dns-record", strict=True)
402+
self.add(reverse_dns, "queried-domain", ipaddress.ip_address(ip).reverse_pointer)
403+
self.add(reverse_dns, "ptr-record", rdns)
404+
self.put(reverse_dns, host.uuid)
391405
self.infrastructure(data, host.uuid)
392406
self.vulnerabilities(data.get("cves"), host.uuid)
393407
# Preserve extensions and all original nested data as evidence, without inventing attribute semantics.
@@ -455,7 +469,11 @@ def service(self, ip, data, parent):
455469
for side in ("issuer", "subject"):
456470
dn = tls.get(side) or {}
457471
for field, relation in dn_mapping.items():
458-
self.add(cert, f"{side}-{relation}", dn.get(field))
472+
values = list(dict.fromkeys(dn.get(field) or []))
473+
# Specialized x509 DN relations are single-valued. Repeated RDNs
474+
# are retained in the full DN below and the MODAT observation.
475+
if len(values) == 1:
476+
self.add(cert, f"{side}-{relation}", values[0])
459477
if dn:
460478
# Render an X.509 distinguished name, retaining repeated RDN values.
461479
labels = {
@@ -575,6 +593,7 @@ def handler(q=False):
575593
if q is False:
576594
return False
577595
client = None
596+
stage = "module request"
578597
try:
579598
request = json.loads(q)
580599
if not isinstance(request, dict):
@@ -584,6 +603,7 @@ def handler(q=False):
584603
raise ModatError("Provide an attribute with type, value and uuid.")
585604
if not isinstance(attribute["type"], str) or not isinstance(attribute["value"], (str, int)):
586605
raise ModatError("Attribute type and value must be strings (numeric values are also accepted).")
606+
stage = "input attribute"
587607
UUID(str(attribute["uuid"]))
588608
config = request.get("config") or {}
589609
if not isinstance(config, dict) or not isinstance(config.get("apikey"), str) or not config["apikey"].strip():
@@ -597,37 +617,48 @@ def handler(q=False):
597617
if query and len(query) > 2000:
598618
raise ModatError("Query exceeds the API's 2000-character limit.")
599619
parser = ModatParser(attribute)
620+
stage = "MODAT client configuration"
600621
client = ModatClient(config["apikey"].strip(), timeout)
601622
if ip:
623+
stage = "MODAT host lookup"
602624
data = client.request(f"/host/{quote(ip, safe='')}/v1")
603625
if data is not None:
604626
if not isinstance(data.get("data"), dict):
605627
raise ModatError("MODAT returned invalid host details.")
628+
stage = "MODAT host data"
606629
parser.host(data["data"])
607630
else:
631+
stage = "MODAT service search"
608632
for row in client.search("/service/search/v1", query, limit, page_size):
609633
if not isinstance(row.get("service"), dict):
610634
raise ModatError("MODAT returned an invalid service search record.")
635+
stage = "MODAT service data"
611636
parser.host(row)
612637
zones = [zone] if include_dns and zone else []
613638
if include_dns and reverse_dns and ip:
639+
stage = "MODAT reverse DNS search"
614640
rrtype = "a" if ipaddress.ip_address(ip).version == 4 else "aaaa"
615641
for row in client.search("/dns/search/v1", _term((f"{rrtype}.ip",), ip), limit, min(page_size, 100)):
616642
if not isinstance(row.get("fqdn"), str):
617643
raise ModatError("MODAT returned an invalid DNS search record.")
618644
zones.append(_domain(row["fqdn"]))
619645
for fqdn in dict.fromkeys(zones):
646+
stage = "MODAT DNS zone lookup"
620647
data = client.request(f"/dns/zones/{quote(fqdn, safe='')}/v1")
621648
if data is not None:
649+
stage = "MODAT DNS zone data"
622650
parser.dns(data)
651+
stage = "MISP enrichment output"
623652
return parser.result()
624653
except ModatError as exc:
625654
return {"error": str(exc)}
626655
except requests.RequestException:
627656
# Exceptions may contain request headers or API response bodies; do not echo them.
628657
return {"error": "Unable to reach MODAT (network error or timeout)."}
629-
except (ValueError, TypeError, KeyError, AttributeError, PyMISPError):
630-
return {"error": "Invalid request, attribute, or MODAT response data."}
658+
except (ValueError, TypeError, KeyError, AttributeError, PyMISPError) as exc:
659+
# Only identify the stage and exception class: exception messages may contain response data.
660+
LOGGER.warning("MODAT enrichment failed during %s (%s).", stage, type(exc).__name__)
661+
return {"error": f"Unable to process {stage} ({type(exc).__name__})."}
631662
finally:
632663
if client is not None:
633664
client.session.close()

‎tests/test_modat.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,3 +461,113 @@ def test_duplicate_host_evidence_is_reused_within_response():
461461
for obj in second["Object"]:
462462
refs = [(r["referenced_uuid"], r["relationship_type"]) for r in obj.get("ObjectReference", [])]
463463
assert len(refs) == len(set(refs))
464+
465+
466+
def test_multiple_reverse_dns_names_use_dns_record():
467+
host = {"ip": "203.0.113.14", "rdns": ["one.example.com", "two.example.com"], "services": []}
468+
with patch.object(modat.requests.Session, "request", return_value=response({"data": host})):
469+
result = modat.handler(request("ip-src", host["ip"], include_dns=False))
470+
assert "error" not in result
471+
objects = result["results"]["Object"]
472+
dns = next(o for o in objects if o["name"] == "dns-record")
473+
assert {a["value"] for a in dns["Attribute"] if a["object_relation"] == "ptr-record"} == set(host["rdns"])
474+
assert (
475+
next(a["value"] for a in dns["Attribute"] if a["object_relation"] == "queried-domain")
476+
== "14.113.0.203.in-addr.arpa"
477+
)
478+
endpoint = next(o for o in objects if o["name"] == "ip-port")
479+
assert len([a for a in endpoint["Attribute"] if a["object_relation"] == "ptr-record"]) <= 1
480+
481+
482+
def test_certificate_multiple_distinguished_name_values_are_preserved():
483+
observed = service()
484+
observed["tls"]["issuer"] = {"common_name": ["Issuer One", "Issuer Two"], "organization": ["Org One", "Org Two"]}
485+
observed["tls"]["subject"] = {"common_name": ["one.example.com", "two.example.com"], "country": ["LU", "BE"]}
486+
host = {"ip": "203.0.113.14", "services": [observed]}
487+
with patch.object(modat.requests.Session, "request", return_value=response({"data": host})):
488+
result = modat.handler(request("ip-src", host["ip"], include_dns=False))
489+
assert "error" not in result
490+
cert = next(o for o in result["results"]["Object"] if o["name"] == "x509")
491+
values = {a["object_relation"]: a["value"] for a in cert["Attribute"]}
492+
assert "CN=Issuer One,CN=Issuer Two" in values["issuer"]
493+
assert "O=Org One,O=Org Two" in values["issuer"]
494+
assert "CN=one.example.com,CN=two.example.com" in values["subject"]
495+
assert "C=LU,C=BE" in values["subject"]
496+
for rel in ("issuer-common-name", "issuer-organization", "subject-common-name", "subject-country"):
497+
assert len([a for a in cert["Attribute"] if a["object_relation"] == rel]) <= 1
498+
499+
500+
def test_full_misp_request_with_reverse_dns_and_multiple_rdn_values():
501+
source = {
502+
**attribute("ip-src"),
503+
"id": "1",
504+
"event_id": "2",
505+
"category": "Network activity",
506+
"value1": "203.0.113.14",
507+
"value2": "",
508+
"to_ids": False,
509+
"timestamp": "1791885325",
510+
"distribution": "0",
511+
"comment": "",
512+
"sharing_group_id": "0",
513+
"deleted": False,
514+
"disable_correlation": False,
515+
"object_id": "0",
516+
"object_relation": None,
517+
"first_seen": None,
518+
"last_seen": None,
519+
}
520+
observed = service()
521+
observed["tls"]["subject"]["common_name"] = ["one.example.com", "two.example.com"]
522+
host = {"ip": source["value"], "rdns": ["one.example.com", "two.example.com"], "services": [observed]}
523+
zone = {
524+
"fqdn": "example.com",
525+
"records": {"a": [{"ip": source["value"], "ttl": 300, "created_at": "2026-01-01T00:00:00Z"}]},
526+
}
527+
q = json.dumps(
528+
{
529+
"module": "modat",
530+
"attribute": source,
531+
"event_id": "2",
532+
"timeout": 10,
533+
"config": {
534+
"apikey": "test-secret",
535+
"max_results": "1",
536+
"page_size": "10",
537+
"timeout": "10",
538+
"include_dns": "1",
539+
"reverse_dns": "1",
540+
},
541+
}
542+
)
543+
with patch.object(
544+
modat.requests.Session,
545+
"request",
546+
side_effect=[
547+
response({"data": host}),
548+
response({"page": [{"fqdn": "example.com"}], "total_pages": 1}),
549+
response(zone),
550+
],
551+
) as call:
552+
result = modat.handler(q)
553+
assert "error" not in result
554+
assert call.call_count == 3
555+
assert {"modat-service", "x509", "dns-record", "modat-dns-record", "domain-ip"} <= {
556+
o["name"] for o in result["results"]["Object"]
557+
}
558+
assert result["results"]["Attribute"][0]["uuid"] == source["uuid"]
559+
560+
561+
@pytest.mark.parametrize("method,stage", [("host", "MODAT host data"), ("result", "MISP enrichment output")])
562+
def test_parser_errors_identify_stage_without_leaking_data(method, stage, caplog):
563+
with (
564+
patch.object(
565+
modat.requests.Session, "request", return_value=response({"data": {"ip": "203.0.113.14", "services": []}})
566+
),
567+
patch.object(modat.ModatParser, method, side_effect=TypeError("test-secret private response")),
568+
):
569+
result = modat.handler(request(include_dns=False))
570+
assert result["error"] == f"Unable to process {stage} (TypeError)."
571+
assert stage in caplog.text
572+
assert "test-secret" not in result["error"] + caplog.text
573+
assert "private response" not in result["error"] + caplog.text

0 commit comments

Comments
 (0)