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]