From 45d6308111fcae007837dc59b7423168b8529ccf Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Thu, 29 Oct 2026 18:10:02 +0100 Subject: [PATCH 1/3] feat: [eset_report] retrieve threat intelligence PDF reports --- documentation/expansion/eset_report.md | 13 ++ misp_modules/modules/expansion/eset_report.py | 187 ++++++++++++++++++ tests/test_eset_report.py | 137 +++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 documentation/expansion/eset_report.md create mode 100644 misp_modules/modules/expansion/eset_report.py create mode 100644 tests/test_eset_report.py diff --git a/documentation/expansion/eset_report.md b/documentation/expansion/eset_report.md new file mode 100644 index 00000000..48c2910b --- /dev/null +++ b/documentation/expansion/eset_report.md @@ -0,0 +1,13 @@ +# ESET Threat Intelligence Report + +Download an ESET ETI PDF from a supported report download link and return a file object with its content and SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. Portal report links are translated to the fixed API endpoint `https://eti.eset.com/api/v2/apt-reports/{uuid}/download/pdf`; credentials are sent only to that API host. API key and secret form the provider Bearer credential. Redirects are rejected to keep credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash supports duplicate detection when reviewing enrichment results. + +Input: link. + +Configuration: api_key, api_secret, max_size_mb, max_results. Required: api_key, api_secret. + +max_results defaults to 25 and accepts 1–100. New attributes use `to_ids=false`; the source UUID, detection flag and metadata are preserved. Objects reference the source attribute. Requests verify TLS, reject redirects and use connection/read timeouts of 5/30 seconds. API errors do not echo credentials. No automatic submission retries or background polling occur. + +Reference: https://help.eset.com/eti_portal/en-US/ + +API specification: https://eti.eset.com/docs/api/public/v2 diff --git a/misp_modules/modules/expansion/eset_report.py b/misp_modules/modules/expansion/eset_report.py new file mode 100644 index 00000000..3f6f1d3c --- /dev/null +++ b/misp_modules/modules/expansion/eset_report.py @@ -0,0 +1,187 @@ +import hashlib +import io +import ipaddress +import json +import re +import zipfile +from urllib.parse import urlsplit +from uuid import UUID +import requests +from pymisp import MISPEvent, MISPObject +from pymisp.exceptions import PyMISPError + + +class ModuleError(ValueError): + """A diagnostic safe to show to the analyst.""" + + +def _integer(config, key, default, maximum=100): + value = config.get(key) + if value is None or value == "": + return default + if isinstance(value, bool) or not str(value).isdigit() or (not 1 <= int(value) <= maximum): + raise ModuleError(f"{key} must be an integer between 1 and {maximum}.") + return int(value) + + +def _url(value): + if not isinstance(value, str) or any(ord(c) <= 32 or ord(c) == 127 for c in value): + raise ModuleError("Invalid HTTPS URL.") + parsed = urlsplit(value) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment: + raise ModuleError("An HTTPS URL without credentials or fragment is required.") + parsed.port + return value.rstrip("/") + + +def _normalise(kind, value): + if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 for c in value): + raise ModuleError("A nonempty string value is required.") + value = value.strip() + if kind in ("md5", "sha1", "sha256", "x509-fingerprint-sha256"): + length = {"md5": 32, "sha1": 40}.get(kind, 64) + if not re.fullmatch("[a-fA-F0-9]{%d}" % length, value): + raise ModuleError("Invalid hash.") + return value.lower() + if kind in ("ip-src", "ip-dst"): + return str(ipaddress.ip_address(value)) + if kind in ("domain", "hostname"): + value = value.rstrip(".").encode("idna").decode("ascii").lower() + if len(value) > 253 or not all( + re.fullmatch("[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", p) for p in value.split(".") + ): + raise ModuleError("Invalid DNS name.") + if kind == "vulnerability": + value = value.upper() + if not re.fullmatch("CVE-[0-9]{4}-[0-9]{4,}", value): + raise ModuleError("A CVE identifier is required.") + return value + + +def _object(event, attribute, name): + obj = MISPObject(name) + obj.add_reference(attribute["uuid"], "describes") + event.add_object(obj) + return obj + + +def handler(q=False): + if q is False: + return False + try: + request = json.loads(q) + if not isinstance(request, dict) or not isinstance(request.get("attribute"), dict): + raise ModuleError("An attribute containing type, value and uuid is required.") + attribute = request["attribute"] + UUID(attribute["uuid"]) + if attribute["type"] not in mispattributes["input"]: + raise ModuleError("Unsupported attribute type.") + value = _normalise(attribute["type"], attribute["value"]) + config = request.get("config") or {} + if not isinstance(config, dict): + raise ModuleError("The config must be an object.") + for key in REQUIRED: + if not isinstance(config.get(key), str) or not config[key].strip(): + raise ModuleError(f"Missing {key} configuration.") + limit = _integer(config, "max_results", 25) + event = MISPEvent() + event.add_attribute(**attribute) + _expand(event, attribute, value, config, limit) + result = json.loads(event.to_json()) + return {"results": {key: result.get(key, []) for key in ("Attribute", "Object")}} + except ModuleError as exc: + return {"error": str(exc)} + except ( + ValueError, + TypeError, + KeyError, + AttributeError, + IndexError, + OSError, + RuntimeError, + zipfile.BadZipFile, + PyMISPError, + ): + return {"error": "Invalid request or service response."} + + +def introspection(): + return mispattributes + + +def version(): + return dict(moduleinfo, config=moduleconfig) + + +moduleinfo = { + "version": "1.0", + "author": "MISP Project", + "name": "ESET Threat Intelligence Report", + "description": ( + "Download an ESET ETI PDF from a supported report download link and return a file object with its content and" + " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. API key and secret" + " form the provider Bearer credential. Redirects are rejected to keep credentials on the selected host." + " max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash" + " supports duplicate detection when reviewing enrichment results." + ), + "module-type": ["expansion"], + "data-source": "external", + "logo": "", + "requirements": ["requests", "pymisp"], + "features": ( + "Download an ESET ETI PDF from a supported report download link and return a file object with its content and" + " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. API key and secret" + " form the provider Bearer credential. Redirects are rejected to keep credentials on the selected host." + " max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash" + " supports duplicate detection when reviewing enrichment results." + ), + "references": ["https://help.eset.com/eti_portal/en-US/"], + "input": "link", + "output": "MISP objects linked to the original attribute.", +} +mispattributes = {"input": ["link"], "format": "misp_standard"} +moduleconfig = ["api_key", "api_secret", "max_size_mb", "max_results"] +REQUIRED = ["api_key", "api_secret"] + + +def _expand(event, attribute, value, config, limit): + parsed = urlsplit(_url(value)) + if ( + parsed.hostname not in ("eti.eset.com", "www.eset.com", "eset.com") + or parsed.port not in (None, 443) + or parsed.query + ): + raise ModuleError("A supported ESET ETI report link is required.") + match = re.fullmatch("/reports/apt/([a-zA-Z0-9-]+)(?:/download)?", parsed.path) + if parsed.hostname == "eti.eset.com" and match is None: + match = re.fullmatch("/api/v2/apt-reports/([a-zA-Z0-9-]+)/download/pdf", parsed.path) + if match is None: + raise ModuleError("A supported ESET ETI APT report link is required.") + report_id = str(UUID(match.group(1))) + download = "https://eti.eset.com/api/v2/apt-reports/" + report_id + "/download/pdf" + max_bytes = _integer(config, "max_size_mb", 25, 100) * 1024 * 1024 + try: + with requests.get( + download, + headers={"Authorization": "Bearer " + config["api_key"] + "|" + config["api_secret"]}, + timeout=(5, 30), + verify=True, + allow_redirects=False, + stream=True, + ) as response: + if response.status_code != 200: + raise ModuleError(f"ESET returned HTTP {response.status_code}.") + content = bytearray() + for chunk in response.iter_content(chunk_size=65536): + content.extend(chunk) + if len(content) > max_bytes: + raise ModuleError("The report exceeds max_size_mb.") + except requests.RequestException: + raise ModuleError("ESET report retrieval failed or timed out.") from None + if not content.startswith(b"%PDF-"): + raise ModuleError("ESET did not return a PDF report.") + digest = hashlib.sha256(content).hexdigest() + obj = _object(event, attribute, "file") + obj.add_attribute("filename", value=report_id + ".pdf", to_ids=False) + obj.add_attribute("sha256", value=digest, to_ids=False) + obj.add_attribute("attachment", value=report_id + ".pdf", data=io.BytesIO(content), to_ids=False) diff --git a/tests/test_eset_report.py b/tests/test_eset_report.py new file mode 100644 index 00000000..a9759352 --- /dev/null +++ b/tests/test_eset_report.py @@ -0,0 +1,137 @@ +import json +from unittest.mock import Mock, patch + +import pytest +import requests + +from misp_modules.modules.expansion import eset_report as module + +UUID = "32876e4a-916d-4935-9e51-7b69a76718f8" +SECRET = "test-service-secret" + + +def query(kind=None, value="example.com", config=None, **attribute_fields): + attribute = { + "type": kind or module.mispattributes["input"][0], + "value": value, + "uuid": UUID, + "to_ids": True, + "comment": "source context", + **attribute_fields, + } + return json.dumps( + {"attribute": attribute, "config": config if config is not None else {k: SECRET for k in module.REQUIRED}} + ) + + +def reply(payload=None, status=200): + return Mock(status_code=status, headers={}, json=Mock(return_value=payload)) + + +def assert_source(result): + assert "results" in result, result + source = next(a for a in result["results"]["Attribute"] if a["uuid"] == UUID) + assert source["to_ids"] is True + assert source["comment"] == "source context" + for obj in result["results"]["Object"]: + assert any(r["referenced_uuid"] == UUID for r in obj["ObjectReference"]) + assert all(a["to_ids"] is False for a in obj["Attribute"]) + + +def objects(result, name): + return [obj for obj in result["results"]["Object"] if obj["name"] == name] + + +def values(obj, relation): + return [a["value"] for a in obj["Attribute"] if a["object_relation"] == relation] + + +def test_discovery(): + assert module.handler(False) is False + assert module.introspection()["format"] == "misp_standard" + assert module.version()["config"] == module.moduleconfig + + +@pytest.mark.parametrize("raw", ["{", "[]", "null", "{}", '{"attribute": []}']) +def test_invalid_envelope(raw): + with patch.object(module.requests, "request") as request: + assert "error" in module.handler(raw) + request.assert_not_called() + + +@pytest.mark.parametrize("raw", [0, -1, True, "bad", 101]) +def test_invalid_result_limit(raw): + with pytest.raises(module.ModuleError): + module._integer({"max_results": raw}, "max_results", 25) + + +def test_pdf_is_streamed_and_becomes_a_file_attachment(): + response = Mock(status_code=200) + response.iter_content.return_value = [b"%PDF-1.7\nreport"] + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + with patch.object(module.requests, "get", return_value=response) as request: + result = module.handler( + query(kind="link", value="https://eti.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download") + ) + assert_source(result) + obj = objects(result, "file")[0] + assert values(obj, "filename") == ["123e4567-e89b-12d3-a456-426614174000.pdf"] + assert any(a.get("data") for a in obj["Attribute"]) + assert request.call_args.kwargs["allow_redirects"] is False + + +@pytest.mark.parametrize( + "url", + [ + "https://other.example/reports/apt/123e4567-e89b-12d3-a456-426614174000/download", + "https://eti.eset.com@other.example/reports/apt/x/download", + "https://eti.eset.com:8443/reports/apt/x/download", + ], +) +def test_foreign_urls_are_rejected_without_authentication(url): + with patch.object(module.requests, "get") as request: + assert "error" in module.handler(query(kind="link", value=url)) + request.assert_not_called() + + +def test_portal_reference_uses_fixed_api_download_endpoint(): + response = Mock(status_code=200) + response.iter_content.return_value = [b"%PDF-1.7\nreport"] + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + with patch.object(module.requests, "get", return_value=response) as request: + result = module.handler( + query(kind="link", value="https://www.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download") + ) + assert_source(result) + assert ( + request.call_args.args[0] + == "https://eti.eset.com/api/v2/apt-reports/123e4567-e89b-12d3-a456-426614174000/download/pdf" + ) + assert request.call_args.kwargs["headers"]["Authorization"] == "Bearer " + SECRET + "|" + SECRET + + +@pytest.mark.parametrize("chunks", [[b"login"], [b"%PDF-" + b"x" * 1048576]]) +def test_non_pdf_and_oversized_reports_are_rejected(chunks): + response = Mock(status_code=200) + response.iter_content.return_value = chunks + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + with patch.object(module.requests, "get", return_value=response): + result = module.handler( + query( + kind="link", + value="https://eti.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download", + config={"api_key": SECRET, "api_secret": SECRET, "max_size_mb": 1}, + ) + ) + assert "error" in result + + +def test_download_failure_does_not_expose_credentials(): + with patch.object(module.requests, "get", side_effect=requests.Timeout(SECRET)): + result = module.handler( + query(kind="link", value="https://eti.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download") + ) + assert "error" in result and SECRET not in str(result) From 19ae7a57201a407f2caf5d8b0df543864d9ae073 Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Thu, 29 Oct 2026 23:25:54 +0100 Subject: [PATCH 2/3] fix: [eset_report] address review findings --- documentation/expansion/eset_report.md | 6 +++-- misp_modules/modules/expansion/eset_report.py | 24 ++++++++++--------- tests/test_eset_report.py | 21 ++++++++++++++-- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/documentation/expansion/eset_report.md b/documentation/expansion/eset_report.md index 48c2910b..01150e2f 100644 --- a/documentation/expansion/eset_report.md +++ b/documentation/expansion/eset_report.md @@ -1,13 +1,15 @@ # ESET Threat Intelligence Report -Download an ESET ETI PDF from a supported report download link and return a file object with its content and SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. Portal report links are translated to the fixed API endpoint `https://eti.eset.com/api/v2/apt-reports/{uuid}/download/pdf`; credentials are sent only to that API host. API key and secret form the provider Bearer credential. Redirects are rejected to keep credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash supports duplicate detection when reviewing enrichment results. +Download an ESET ETI PDF from a supported report download link and return a file object with its content and SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. Portal report links are translated to the fixed API endpoint `https://eti.eset.com/api/v2/apt-reports/{uuid}/download/pdf`; credentials are sent only to that API host. Set api_token to the complete generated REST API token, without the Bearer prefix; it is forwarded unchanged. Redirects are rejected to keep credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash supports duplicate detection when reviewing enrichment results. Input: link. -Configuration: api_key, api_secret, max_size_mb, max_results. Required: api_key, api_secret. +Configuration: api_token, max_size_mb, max_results. Required: api_token. max_results defaults to 25 and accepts 1–100. New attributes use `to_ids=false`; the source UUID, detection flag and metadata are preserved. Objects reference the source attribute. Requests verify TLS, reject redirects and use connection/read timeouts of 5/30 seconds. API errors do not echo credentials. No automatic submission retries or background polling occur. Reference: https://help.eset.com/eti_portal/en-US/ API specification: https://eti.eset.com/docs/api/public/v2 + +Portal links of the form `/reports/apt/download/{uuid}` are supported. diff --git a/misp_modules/modules/expansion/eset_report.py b/misp_modules/modules/expansion/eset_report.py index 3f6f1d3c..b35b9c48 100644 --- a/misp_modules/modules/expansion/eset_report.py +++ b/misp_modules/modules/expansion/eset_report.py @@ -119,10 +119,10 @@ def version(): "name": "ESET Threat Intelligence Report", "description": ( "Download an ESET ETI PDF from a supported report download link and return a file object with its content and" - " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. API key and secret" - " form the provider Bearer credential. Redirects are rejected to keep credentials on the selected host." - " max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash" - " supports duplicate detection when reviewing enrichment results." + " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. The complete" + " generated api_token is forwarded unchanged as the provider Bearer credential. Redirects are rejected to keep" + " credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before" + " importing. The report content hash supports duplicate detection when reviewing enrichment results." ), "module-type": ["expansion"], "data-source": "external", @@ -130,18 +130,18 @@ def version(): "requirements": ["requests", "pymisp"], "features": ( "Download an ESET ETI PDF from a supported report download link and return a file object with its content and" - " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. API key and secret" - " form the provider Bearer credential. Redirects are rejected to keep credentials on the selected host." - " max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash" - " supports duplicate detection when reviewing enrichment results." + " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. The complete" + " generated api_token is forwarded unchanged as the provider Bearer credential. Redirects are rejected to keep" + " credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before" + " importing. The report content hash supports duplicate detection when reviewing enrichment results." ), "references": ["https://help.eset.com/eti_portal/en-US/"], "input": "link", "output": "MISP objects linked to the original attribute.", } mispattributes = {"input": ["link"], "format": "misp_standard"} -moduleconfig = ["api_key", "api_secret", "max_size_mb", "max_results"] -REQUIRED = ["api_key", "api_secret"] +moduleconfig = ["api_token", "max_size_mb", "max_results"] +REQUIRED = ["api_token"] def _expand(event, attribute, value, config, limit): @@ -153,6 +153,8 @@ def _expand(event, attribute, value, config, limit): ): raise ModuleError("A supported ESET ETI report link is required.") match = re.fullmatch("/reports/apt/([a-zA-Z0-9-]+)(?:/download)?", parsed.path) + if match is None: + match = re.fullmatch(r"/reports/apt/download/([a-zA-Z0-9-]+)", parsed.path) if parsed.hostname == "eti.eset.com" and match is None: match = re.fullmatch("/api/v2/apt-reports/([a-zA-Z0-9-]+)/download/pdf", parsed.path) if match is None: @@ -163,7 +165,7 @@ def _expand(event, attribute, value, config, limit): try: with requests.get( download, - headers={"Authorization": "Bearer " + config["api_key"] + "|" + config["api_secret"]}, + headers={"Authorization": "Bearer " + config["api_token"]}, timeout=(5, 30), verify=True, allow_redirects=False, diff --git a/tests/test_eset_report.py b/tests/test_eset_report.py index a9759352..0f7fdb6b 100644 --- a/tests/test_eset_report.py +++ b/tests/test_eset_report.py @@ -109,7 +109,7 @@ def test_portal_reference_uses_fixed_api_download_endpoint(): request.call_args.args[0] == "https://eti.eset.com/api/v2/apt-reports/123e4567-e89b-12d3-a456-426614174000/download/pdf" ) - assert request.call_args.kwargs["headers"]["Authorization"] == "Bearer " + SECRET + "|" + SECRET + assert request.call_args.kwargs["headers"]["Authorization"] == "Bearer " + SECRET @pytest.mark.parametrize("chunks", [[b"login"], [b"%PDF-" + b"x" * 1048576]]) @@ -123,7 +123,7 @@ def test_non_pdf_and_oversized_reports_are_rejected(chunks): query( kind="link", value="https://eti.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download", - config={"api_key": SECRET, "api_secret": SECRET, "max_size_mb": 1}, + config={"api_token": SECRET, "max_size_mb": 1}, ) ) assert "error" in result @@ -135,3 +135,20 @@ def test_download_failure_does_not_expose_credentials(): query(kind="link", value="https://eti.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download") ) assert "error" in result and SECRET not in str(result) + + +def test_documented_portal_link_and_complete_token(): + response = Mock(status_code=200) + response.iter_content.return_value = [b"%PDF-1.7"] + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + with patch.object(module.requests, "get", return_value=response) as request: + result = module.handler( + query( + kind="link", + value="https://eti.eset.com/reports/apt/download/123e4567-e89b-12d3-a456-426614174000", + config={"api_token": "whole|token"}, + ) + ) + assert_source(result) + assert request.call_args.kwargs["headers"]["Authorization"] == "Bearer whole|token" From e60fb845cb5ff23e096f6d1d06abf94f2089feee Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Fri, 30 Oct 2026 06:50:43 +0100 Subject: [PATCH 3/3] fix: [eset_report] represent downloaded PDFs as report objects --- documentation/expansion/eset_report.md | 4 +- misp_modules/modules/expansion/eset_report.py | 38 +++++++++++-------- tests/test_eset_report.py | 38 +++++++++++++++++-- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/documentation/expansion/eset_report.md b/documentation/expansion/eset_report.md index 01150e2f..ea2f6047 100644 --- a/documentation/expansion/eset_report.md +++ b/documentation/expansion/eset_report.md @@ -1,6 +1,6 @@ # ESET Threat Intelligence Report -Download an ESET ETI PDF from a supported report download link and return a file object with its content and SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. Portal report links are translated to the fixed API endpoint `https://eti.eset.com/api/v2/apt-reports/{uuid}/download/pdf`; credentials are sent only to that API host. Set api_token to the complete generated REST API token, without the Bearer prefix; it is forwarded unchanged. Redirects are rejected to keep credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report content hash supports duplicate detection when reviewing enrichment results. +Download an ESET ETI PDF from a supported report download link and return a report object with its PDF attachment and source links. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. Portal report links are translated to the fixed API endpoint `https://eti.eset.com/api/v2/apt-reports/{uuid}/download/pdf`; credentials are sent only to that API host. Set api_token to the complete generated REST API token, without the Bearer prefix; it is forwarded unchanged. Redirects are rejected to keep credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before importing. The report title includes the ESET report UUID, type is Report, and report-file holds the PDF. The attachment comment records its SHA-256 for integrity checks. Input: link. @@ -13,3 +13,5 @@ Reference: https://help.eset.com/eti_portal/en-US/ API specification: https://eti.eset.com/docs/api/public/v2 Portal links of the form `/reports/apt/download/{uuid}` are supported. + +Output uses the standard MISP `report` object. Its `link` relations retain the supplied report reference and the canonical PDF API URL, deduplicated when identical. The object references the enriched source attribute. No template changes are required. diff --git a/misp_modules/modules/expansion/eset_report.py b/misp_modules/modules/expansion/eset_report.py index b35b9c48..3e50c743 100644 --- a/misp_modules/modules/expansion/eset_report.py +++ b/misp_modules/modules/expansion/eset_report.py @@ -118,26 +118,26 @@ def version(): "author": "MISP Project", "name": "ESET Threat Intelligence Report", "description": ( - "Download an ESET ETI PDF from a supported report download link and return a file object with its content and" - " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. The complete" - " generated api_token is forwarded unchanged as the provider Bearer credential. Redirects are rejected to keep" - " credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before" - " importing. The report content hash supports duplicate detection when reviewing enrichment results." + "Download an ESET ETI PDF from a supported report download link and return a report object with its PDF" + " attachment and SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. The" + " complete generated api_token is forwarded unchanged as the provider Bearer credential. Redirects are rejected" + " to keep credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped" + " before importing. The report content hash supports duplicate detection when reviewing enrichment results." ), "module-type": ["expansion"], "data-source": "external", "logo": "", "requirements": ["requests", "pymisp"], "features": ( - "Download an ESET ETI PDF from a supported report download link and return a file object with its content and" - " SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. The complete" - " generated api_token is forwarded unchanged as the provider Bearer credential. Redirects are rejected to keep" - " credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped before" - " importing. The report content hash supports duplicate detection when reviewing enrichment results." + "Download an ESET ETI PDF from a supported report download link and return a report object with its PDF" + " attachment and SHA-256. Only HTTPS report paths on eti.eset.com, www.eset.com or eset.com are accepted. The" + " complete generated api_token is forwarded unchanged as the provider Bearer credential. Redirects are rejected" + " to keep credentials on the selected host. max_size_mb defaults to 25 (1–100); streamed responses are capped" + " before importing. The report content hash supports duplicate detection when reviewing enrichment results." ), "references": ["https://help.eset.com/eti_portal/en-US/"], "input": "link", - "output": "MISP objects linked to the original attribute.", + "output": "A report object with title, type, source links and PDF attachment, linked to the original attribute.", } mispattributes = {"input": ["link"], "format": "misp_standard"} moduleconfig = ["api_token", "max_size_mb", "max_results"] @@ -183,7 +183,15 @@ def _expand(event, attribute, value, config, limit): if not content.startswith(b"%PDF-"): raise ModuleError("ESET did not return a PDF report.") digest = hashlib.sha256(content).hexdigest() - obj = _object(event, attribute, "file") - obj.add_attribute("filename", value=report_id + ".pdf", to_ids=False) - obj.add_attribute("sha256", value=digest, to_ids=False) - obj.add_attribute("attachment", value=report_id + ".pdf", data=io.BytesIO(content), to_ids=False) + obj = _object(event, attribute, "report") + obj.add_attribute("title", value="ESET APT report " + report_id, to_ids=False) + obj.add_attribute("type", value="Report", to_ids=False) + for reference in dict.fromkeys((value, download)): + obj.add_attribute("link", value=reference, to_ids=False) + obj.add_attribute( + "report-file", + value=report_id + ".pdf", + data=io.BytesIO(content), + comment="SHA-256: " + digest, + to_ids=False, + ) diff --git a/tests/test_eset_report.py b/tests/test_eset_report.py index 0f7fdb6b..5b33762f 100644 --- a/tests/test_eset_report.py +++ b/tests/test_eset_report.py @@ -65,7 +65,7 @@ def test_invalid_result_limit(raw): module._integer({"max_results": raw}, "max_results", 25) -def test_pdf_is_streamed_and_becomes_a_file_attachment(): +def test_pdf_is_streamed_and_becomes_a_report_attachment(): response = Mock(status_code=200) response.iter_content.return_value = [b"%PDF-1.7\nreport"] response.__enter__ = Mock(return_value=response) @@ -75,8 +75,21 @@ def test_pdf_is_streamed_and_becomes_a_file_attachment(): query(kind="link", value="https://eti.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download") ) assert_source(result) - obj = objects(result, "file")[0] - assert values(obj, "filename") == ["123e4567-e89b-12d3-a456-426614174000.pdf"] + obj = objects(result, "report")[0] + assert values(obj, "report-file") == ["123e4567-e89b-12d3-a456-426614174000.pdf"] + assert values(obj, "title") == ["ESET APT report 123e4567-e89b-12d3-a456-426614174000"] + assert values(obj, "type") == ["Report"] + assert not objects(result, "file") + assert set(values(obj, "link")) == { + "https://eti.eset.com/reports/apt/123e4567-e89b-12d3-a456-426614174000/download", + "https://eti.eset.com/api/v2/apt-reports/123e4567-e89b-12d3-a456-426614174000/download/pdf", + } + attachment = next(a for a in obj["Attribute"] if a["object_relation"] == "report-file") + import base64 + import hashlib + + assert base64.b64decode(attachment["data"]) == b"%PDF-1.7\nreport" + assert attachment["comment"] == "SHA-256: " + hashlib.sha256(b"%PDF-1.7\nreport").hexdigest() assert any(a.get("data") for a in obj["Attribute"]) assert request.call_args.kwargs["allow_redirects"] is False @@ -152,3 +165,22 @@ def test_documented_portal_link_and_complete_token(): ) assert_source(result) assert request.call_args.kwargs["headers"]["Authorization"] == "Bearer whole|token" + + +def test_report_relations_match_the_object_template(): + from pymisp import MISPObject + + response = Mock(status_code=200) + response.iter_content.return_value = [b"%PDF-1.7"] + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + link = "https://eti.eset.com/api/v2/apt-reports/123e4567-e89b-12d3-a456-426614174000/download/pdf" + with patch.object(module.requests, "get", return_value=response): + result = module.handler(query(kind="link", value=link)) + assert_source(result) + report = objects(result, "report")[0] + definition = MISPObject("report")._definition + relations = {a["object_relation"] for a in report["Attribute"]} + assert relations <= set(definition["attributes"]) + assert relations & set(definition["requiredOneOf"]) + assert values(report, "link") == [link]