Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions documentation/expansion/eset_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# ESET Threat Intelligence Report

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.

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.

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.
197 changes: 197 additions & 0 deletions misp_modules/modules/expansion/eset_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
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 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 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": "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"]
REQUIRED = ["api_token"]


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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept the documented portal-link ordering

When this module receives the portal links emitted by ESET MISP, this matcher rejects them before making a request: ESET documents the path as /reports/apt/download/{uuid}, while this pattern only accepts /reports/apt/{uuid}/download or /reports/apt/{uuid}. Consequently the module cannot process its primary advertised input; match the documented portal URL format instead.

Useful? React with 👍 / 👎.

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:
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_token"]},
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, "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,
)
Loading