From 213a790ad3d86a79f26d17773e73a71e42a65bb1 Mon Sep 17 00:00:00 2001 From: k1r10n Date: Sat, 27 Jun 2026 14:49:41 +1000 Subject: [PATCH 1/3] Add RST Cloud modules and update dependencies - Added multiple RST Cloud expansion modules for Cobalt Strike beacon scanning, favicon fetching, HTML fetching, IoC lookups, Noise Control, screenshots, SSL certificate retrieval, and WHOIS information. - Updated `pyproject.toml` to include `rstapi` dependency version constraints for compatibility. --- documentation/logos/rstcloud.png | Bin 0 -> 28326 bytes documentation/mkdocs/expansion.md | 254 ++++++++++ documentation/mkdocs/index.md | 8 + .../modules/expansion/_rstcloud/__init__.py | 19 + .../modules/expansion/_rstcloud/client.py | 474 ++++++++++++++++++ .../modules/expansion/rst_cs_beacon.py | 128 +++++ misp_modules/modules/expansion/rst_favicon.py | 132 +++++ misp_modules/modules/expansion/rst_html.py | 119 +++++ misp_modules/modules/expansion/rst_ioc.py | 380 ++++++++++++++ .../modules/expansion/rst_noise_control.py | 204 ++++++++ .../modules/expansion/rst_screenshot.py | 106 ++++ misp_modules/modules/expansion/rst_ssl.py | 106 ++++ misp_modules/modules/expansion/rst_whois.py | 125 +++++ pyproject.toml | 2 + tests/test_rst_ioc.py | 58 +++ tests/test_rst_noise_control.py | 92 ++++ tests/test_rst_ssl.py | 48 ++ 17 files changed, 2255 insertions(+) create mode 100644 documentation/logos/rstcloud.png create mode 100644 misp_modules/modules/expansion/_rstcloud/__init__.py create mode 100644 misp_modules/modules/expansion/_rstcloud/client.py create mode 100644 misp_modules/modules/expansion/rst_cs_beacon.py create mode 100644 misp_modules/modules/expansion/rst_favicon.py create mode 100644 misp_modules/modules/expansion/rst_html.py create mode 100644 misp_modules/modules/expansion/rst_ioc.py create mode 100644 misp_modules/modules/expansion/rst_noise_control.py create mode 100644 misp_modules/modules/expansion/rst_screenshot.py create mode 100644 misp_modules/modules/expansion/rst_ssl.py create mode 100644 misp_modules/modules/expansion/rst_whois.py create mode 100644 tests/test_rst_ioc.py create mode 100644 tests/test_rst_noise_control.py create mode 100644 tests/test_rst_ssl.py diff --git a/documentation/logos/rstcloud.png b/documentation/logos/rstcloud.png new file mode 100644 index 0000000000000000000000000000000000000000..af2f9c3b26654ebfcc69bddf5cdadc9f4bda112a GIT binary patch literal 28326 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 Ik~IGOe-8Ctm;e9( literal 0 HcmV?d00001 diff --git a/documentation/mkdocs/expansion.md b/documentation/mkdocs/expansion.md index e7f966f3..7f1d78a8 100644 --- a/documentation/mkdocs/expansion.md +++ b/documentation/mkdocs/expansion.md @@ -2118,6 +2118,260 @@ Module to check an IPv4 address against known RBLs. ----- +#### [RST Cloud Cobalt Strike Beacon](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_cs_beacon.py) + + + +Scan a target IP[:port] for a Cobalt Strike beacon configuration via RST Scan API. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_cs_beacon.py)] + +- **features**: +>Probes the target for Cobalt Strike beacon configurations via RST Scan GET /scan/cs-beacon. On a hit, returns file MISP object(s) with pivotable SHA-256 hashes tagged to the Cobalt Strike galaxy. + +- **config**: +> - api_key +> - base_url +> - port +> - timeout + +- **input**: +>IP, URL, domain, or hostname attribute (optional port via config). + +- **output**: +>file MISP object(s) with beacon hashes and Cobalt Strike galaxy tag. + +- **references**: +>https://api.rstcloud.net/ + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key + +----- + +#### [RST Cloud Favicon](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_favicon.py) + + + +Fetch a target's favicon (image + all hashes for Shodan/Netlas/Censys pivoting) via RST Scan API. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_favicon.py)] + +- **features**: +>Retrieves the favicon image and cryptographic hashes via RST Scan GET /scan/favicon. Returns a file MISP object with MD5/SHA-1/SHA-256 and a standalone Murmur3 favicon-hash attribute for Shodan/FOFA-style pivoting. + +- **config**: +> - api_key +> - base_url +> - timeout + +- **input**: +>URL, domain, hostname, or IP attribute. + +- **output**: +>file MISP object, favicon-hash attribute, and resolved favicon URL. + +- **references**: +>https://api.rstcloud.net/ + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key + +----- + +#### [RST Cloud HTML Fetcher](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_html.py) + + + +Fetch rendered HTML body or extracted JavaScript for a URL/IP target via RST Scan API. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_html.py)] + +- **features**: +>Fetches the rendered HTML body or extracted JavaScript from the target via RST Scan. Returns a file MISP object with the page attached and pivotable content hashes. Configurable mode: body (default) or js. + +- **config**: +> - api_key +> - base_url +> - mode +> - port +> - timeout + +- **input**: +>URL, domain, hostname, or IP attribute (optional port via config). + +- **output**: +>file MISP object (page.html or page.js) with hashes and HTTP metadata. + +- **references**: +>https://api.rstcloud.net/ + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key + +----- + +#### [RST Cloud IoC Lookup](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_ioc.py) + + + +Enrich indicators with RST Cloud threat intelligence. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_ioc.py)] + +- **features**: +>Queries RST Cloud GET /ioc for threat scores, attribution, geo/ASN, DNS, WHOIS, TTPs, CVEs, and related indicators. Returns a structured rst-ioc MISP object with galaxy tags and optional pivotable related hashes/IPs. When misp_url and misp_key are configured, also writes score/threat tags onto the enriched attribute via the MISP API. + +- **config**: +> - api_key +> - base_url +> - misp_url +> - misp_key +> - misp_verifycert + +- **input**: +>IP, domain, hostname, URL, or hash attribute (incl. host|port composites). + +- **output**: +>rst-ioc MISP object, galaxy/score tags, and optional related attributes. + +- **references**: +>https://api.rstcloud.net/ +>https://github.com/MISP/misp-objects/pull/526 + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key +> - rst-ioc object template installed on MISP ([misp-objects #526](https://github.com/MISP/misp-objects/pull/526)) + +----- + +#### [RST Cloud Noise Control](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_noise_control.py) + + + +Check whether a value is known-good / noise via RST Noise Control. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_noise_control.py)] + +- **features**: +>Queries RST Cloud GET /benign/lookup for benign/noisy verdicts. Returns an rst-noise MISP object with false-positive risk tags. When misp_url and misp_key are configured, also annotates the source attribute in place (tags, comment, to_ids, false-positive sightings). + +- **config**: +> - api_key +> - base_url +> - misp_url +> - misp_key +> - misp_verifycert + +- **input**: +>IP, domain, hostname, URL, or hash attribute (incl. host|port composites). + +- **output**: +>rst-noise MISP object with verdict, category, and risk/noise tags. + +- **references**: +>https://api.rstcloud.net/ +>https://github.com/MISP/misp-taxonomies/pull/335 + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key +> - rst-noise object template on MISP ([misp-objects #526](https://github.com/MISP/misp-objects/pull/526)) +> - rstcloud taxonomy on MISP ([misp-taxonomies #335](https://github.com/MISP/misp-taxonomies/pull/335)) + +----- + +#### [RST Cloud Screenshot](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_screenshot.py) + + + +Capture a page screenshot of a URL/IP target via RST Scan API. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_screenshot.py)] + +- **features**: +>Renders the target page and returns a PNG screenshot as an image MISP object (inline in MISP). Configurable frame: first, full (default), or last. + +- **config**: +> - api_key +> - base_url +> - frame +> - port +> - timeout + +- **input**: +>URL, domain, hostname, or IP attribute (optional port via config). + +- **output**: +>image MISP object with PNG attachment linked to the enriched attribute. + +- **references**: +>https://api.rstcloud.net/ + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key + +----- + +#### [RST Cloud SSL Certificate](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_ssl.py) + + + +Fetch the SSL certificate for an IP[:port] as an x509 object via RST Scan API. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_ssl.py)] + +- **features**: +>Connects to the target service and retrieves the TLS certificate via RST Scan GET /scan/ssl/certificate. Returns an x509 MISP object with pivotable fingerprints (SHA-1/256/MD5), subject, issuer, and validity dates. + +- **config**: +> - api_key +> - base_url +> - port +> - timeout + +- **input**: +>IP, hostname, or domain attribute (optional port via config or composite). + +- **output**: +>x509 MISP object referencing the enriched attribute. + +- **references**: +>https://api.rstcloud.net/ + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key + +----- + +#### [RST Cloud Whois](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_whois.py) + + + +Retrieve parsed WHOIS information for a domain via RST Cloud. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/rst_whois.py)] + +- **features**: +>Queries RST Cloud GET /whois for parsed domain registration data. Returns a standard whois MISP object (registrar, registrant, dates, nameservers) linked back to the enriched attribute. + +- **config**: +> - api_key +> - base_url + +- **input**: +>Domain or hostname attribute. + +- **output**: +>whois MISP object with registration and nameserver fields. + +- **references**: +>https://api.rstcloud.net/ + +- **requirements**: +> - rstapi>=1.2.0 (PyPI) +> - An RST Cloud API key + +----- + #### [Recorded Future Enrich](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/expansion/recordedfuture.py) diff --git a/documentation/mkdocs/index.md b/documentation/mkdocs/index.md index e0f5e888..27549435 100644 --- a/documentation/mkdocs/index.md +++ b/documentation/mkdocs/index.md @@ -91,6 +91,14 @@ For more information: [Extending MISP with Python modules](https://www.misp-proj * [RandomcoinDB Lookup](https://misp.github.io/misp-modules/expansion/#randomcoindb-lookup) - Module to access the ransomcoinDB (see https://ransomcoindb.concinnity-risks.com) * [r7_akb](https://misp.github.io/misp-modules/expansion/#r7_akb) - Enrich CVEs via AttackerKB and return structured MISP events. Handles rate limits, regex CVE detection, and markdown cleanup. * [Real-time Blackhost Lists Lookup](https://misp.github.io/misp-modules/expansion/#real-time-blackhost-lists-lookup) - Module to check an IPv4 address against known RBLs. +* [RST Cloud Cobalt Strike Beacon](https://misp.github.io/misp-modules/expansion/#rst-cloud-cobalt-strike-beacon) - Scan a target for Cobalt Strike beacon configurations via RST Scan API. +* [RST Cloud Favicon](https://misp.github.io/misp-modules/expansion/#rst-cloud-favicon) - Fetch favicon image and hashes for Shodan/Netlas/Censys/FOFA pivoting via RST Scan API. +* [RST Cloud HTML Fetcher](https://misp.github.io/misp-modules/expansion/#rst-cloud-html-fetcher) - Fetch rendered HTML body or extracted JavaScript via RST Scan API. +* [RST Cloud IoC Lookup](https://misp.github.io/misp-modules/expansion/#rst-cloud-ioc-lookup) - Enrich indicators with RST Cloud threat intelligence. +* [RST Cloud Noise Control](https://misp.github.io/misp-modules/expansion/#rst-cloud-noise-control) - Check whether an indicator is known-good or noisy via RST Noise Control. +* [RST Cloud Screenshot](https://misp.github.io/misp-modules/expansion/#rst-cloud-screenshot) - Capture a page screenshot via RST Scan API. +* [RST Cloud SSL Certificate](https://misp.github.io/misp-modules/expansion/#rst-cloud-ssl-certificate) - Fetch TLS certificate as an x509 MISP object via RST Scan API. +* [RST Cloud Whois](https://misp.github.io/misp-modules/expansion/#rst-cloud-whois) - Retrieve parsed WHOIS for a domain via RST Cloud. * [Recorded Future Enrich](https://misp.github.io/misp-modules/expansion/#recorded-future-enrich) - Module to enrich attributes with threat intelligence from Recorded Future. * [ReversingLabs Enrichment](https://misp.github.io/misp-modules/expansion/#reversinglabs-enrichment) - Module to enrich file hashes, domains, IPs and URLs with ReversingLabs Spectra Analyze threat intelligence. * [Reverse DNS](https://misp.github.io/misp-modules/expansion/#reverse-dns) - Simple Reverse DNS expansion service to resolve reverse DNS from MISP attributes. diff --git a/misp_modules/modules/expansion/_rstcloud/__init__.py b/misp_modules/modules/expansion/_rstcloud/__init__.py new file mode 100644 index 00000000..2ead34b3 --- /dev/null +++ b/misp_modules/modules/expansion/_rstcloud/__init__.py @@ -0,0 +1,19 @@ +"""Shared RST Cloud helpers for the expansion modules (not a registered module).""" + +from .client import ( # noqa: F401 + apply_to_source_attribute, + error, + host_only, + misp_event_with_source, + new_enrichment_object, + rst_kwargs, + rst_resolver_from_config, + scan_group, + scan_kwargs, + scan_target, + standard_results, + text_result, + threat_tags, + unwrap, + value_from_request, +) diff --git a/misp_modules/modules/expansion/_rstcloud/client.py b/misp_modules/modules/expansion/_rstcloud/client.py new file mode 100644 index 00000000..3c0801a4 --- /dev/null +++ b/misp_modules/modules/expansion/_rstcloud/client.py @@ -0,0 +1,474 @@ +"""Shared helpers for the RST Cloud expansion modules. + +The modules ride the official ``rstapi`` library (PyPI) for transport, so a +misp-modules deployment just needs ``pip install rstapi``. These helpers cover +config parsing, error unwrapping, and the misp-modules result shape. +""" + +from __future__ import annotations + +import json +import re + +DEFAULT_BASE_URL = "https://api.rstcloud.net/v1" + + +def api_key_from_config(config: dict | None) -> str: + """misp-modules passes user config as a dict; accept common key names.""" + config = config or {} + return ( + config.get("api_key") + or config.get("apikey") + or config.get("rst_api_key") + or "" + ) + + +def base_url_from_config(config: dict | None) -> str: + return (config or {}).get("base_url") or DEFAULT_BASE_URL + + +def rst_kwargs(config: dict | None) -> dict: + """Constructor kwargs shared by every rstapi client.""" + return {"APIKEY": api_key_from_config(config), "APIURL": base_url_from_config(config)} + + +def scan_kwargs(config: dict | None) -> dict: + """Constructor kwargs for rstapi.scan, extending rst_kwargs with an optional read timeout. + + Scan endpoints (ssl/html/favicon/screenshot/cs-beacon) are synchronous: the RST + Cloud server connects to the target during your request, so they can take much + longer than a database lookup. The default rstapi READ timeout is 20 s, which + is sometimes not enough. Set ``timeout`` in the module config (seconds, default + 60) to override it. + """ + kw = rst_kwargs(config) + try: + kw["READ"] = max(1, int((config or {}).get("timeout") or 60)) + except (ValueError, TypeError): + kw["READ"] = 60 + return kw + + +def value_from_request(request: dict, keys) -> str | None: + """Pull the indicator value from a misp-modules request (attribute or typed). + + Handles all three shapes MISP sends: a full ``attribute`` object, a typed + top-level key (incl. composites like ``ip-dst|port``), and object-level + enrichment where the value lives in ``object["Attribute"]``. + """ + if request.get("attribute"): + return request["attribute"].get("value") + for key in keys: + if request.get(key): + return request[key] + obj = request.get("object") + if isinstance(obj, dict): + wanted = set(keys) + for a in obj.get("Attribute") or []: + if a.get("type") in wanted and a.get("value"): + return a["value"] + return None + + +def host_only(value): + """Strip a MISP composite ``|port`` suffix, returning the bare host/indicator. + + Used by the lookup modules (ioc / noise-control / whois) where the API keys + on the value itself and the port is irrelevant. + """ + if not value: + return value + return str(value).split("|", 1)[0].strip() + + +_PORT_SUFFIX = re.compile(r":\d{1,5}$") +_PORT_RELATIONS = ("dst-port", "src-port", "port") + + +def _join_host_port(host: str, port) -> str: + """host:port, bracketing IPv6 literals so the colons aren't ambiguous.""" + if host.count(":") >= 2 and not host.startswith("["): + return f"[{host}]:{port}" + return f"{host}:{port}" + + +def _has_explicit_port(host: str) -> bool: + # "1.2.3.4:443" / "host:443" — but not a bare IPv4 or IPv6 literal. + return bool(_PORT_SUFFIX.search(host)) and host.count(":") == 1 + + +def _sibling_port(request) -> str | None: + """Port taken from a sibling attribute when MISP passes the whole object. + + An ``ip-port`` object stores the port as its own attribute (object_relation + ``dst-port`` / ``src-port`` / ``port``); when MISP includes ``object`` in the + request, pick it up so the user doesn't have to set one. + """ + obj = request.get("object") + if not isinstance(obj, dict): + return None + for a in obj.get("Attribute") or []: + rel = (a.get("object_relation") or "").lower() + typ = (a.get("type") or "").lower() + if (rel in _PORT_RELATIONS or typ == "port") and a.get("value"): + return str(a["value"]).strip() + return None + + +def scan_target(request, inputs, config, *, as_url=False, default_port=None, default_scheme="https"): + """Build a Scan-API target from a MISP attribute, honouring an optional port. + + IP/host attributes carry no port, but the Scan API addresses a *service*: + ``host:port`` for ssl / cs-beacon / favicon, or a URL for html / screenshot. + Port resolution, most specific first: + + 1. an explicit port already in the value (``1.2.3.4:8443`` or a URL), + 2. a MISP ``host|port`` composite value (e.g. an ``ip-dst|port`` attribute), + 3. a sibling port attribute in the same MISP object (``ip-port`` object), + 4. the optional ``port`` set in the module config, + 5. ``default_port`` (module-specific fallback, may be ``None``). + + For URL endpoints (``as_url=True``) a bare host becomes + ``://host[:port]`` where scheme is the config ``scheme`` or + ``default_scheme``. Returns ``None`` when no value is present. + """ + raw = value_from_request(request, inputs) + if not raw: + return None + raw = str(raw).strip() + cfg = config or {} + + if raw.startswith(("http://", "https://")): + return raw # already a full URL — it encodes its own port + + # Port, most specific source first. + port = None + if "|" in raw: # MISP composite "host|port" + host, _, p = raw.partition("|") + raw, port = host.strip(), (p.strip() or None) + port = port or _sibling_port(request) or (cfg.get("port") or None) + + has_port = _has_explicit_port(raw) # value was already "host:port" + if as_url: + scheme = (cfg.get("scheme") or default_scheme).strip().lower() + host = raw if has_port or not port else _join_host_port(raw, port) + return f"{scheme}://{host}" + + # host:port endpoints (ssl / cs-beacon / favicon) + if has_port: + return raw + p = port or default_port + return _join_host_port(raw, p) if p else raw + + +def unwrap(resp): + """Return (data, None) or (None, error_message) for an rstapi response.""" + if isinstance(resp, dict) and resp.get("status") == "error": + return None, str(resp.get("message", "RST Cloud API error")) + return resp, None + + +def error(message: str) -> dict: + return {"error": message} + + +# Threat-suffix → (built-in MISP galaxy predicate, RST library galaxy stix_type). +# Kept in sync with rstmisp.misp.tagging; duplicated here so the modules stay +# droppable into misp-modules standalone. The 2nd element selects which RST custom +# galaxy (rst-) a name belongs to when resolving the real cluster tag. +_THREAT_SUFFIX = { + "_group": ("misp-galaxy:threat-actor", "intrusion-set"), + "_actor": ("misp-galaxy:threat-actor", "intrusion-set"), + "_tool": ("misp-galaxy:tool", "tool"), + "_stealer": ("misp-galaxy:stealer", "malware"), + "_backdoor": ("misp-galaxy:backdoor", "malware"), + "_ransomware": ("misp-galaxy:ransomware", "malware"), + "_miner": ("misp-galaxy:cryptominers", "malware"), + "_exploit": ("misp-galaxy:exploit-kit", "malware"), + "_botnet": ("misp-galaxy:botnet", "malware"), + "_rat": ("misp-galaxy:rat", "malware"), + "_campaign": ("misp-galaxy:campaign", "campaign"), +} +# Names with no recognised suffix are malware families. +_THREAT_DEFAULT = ("misp-galaxy:malware", "malware") + +# RST custom galaxy types in MISP (namespace rstcloud); galaxy stix_type = type[4:]. +_RST_GALAXY_TYPES = ("rst-malware", "rst-tool", "rst-intrusion-set", "rst-campaign") + +# Per-process caches: a misp-modules worker is long-lived, so reuse the PyMISP +# client + resolved galaxy ids across calls instead of reconnecting every hover. +_RESOLVER_CACHE: dict = {} + + +def _truthy(v) -> bool: + if isinstance(v, bool): + return v + return str(v).strip().lower() in ("1", "true", "yes", "on") + + +class _RstClusterResolver: + """Resolve an RST threat ``(stix_type, name)`` to its MISP cluster's real + ``tag_name`` (``misp-galaxy:rst-*=""``), so an enrichment tag + attaches the RST Threat Library galaxy — the same node the library/reports/ + feed connectors use. MISP stores a CUSTOM cluster's tag keyed on the UUID, not + the name, so the value-form ``rstcloud:rst-*="name"`` would not link. + + Targeted ``search_galaxy_clusters`` per name (a handful per enrichment call), + not a full galaxy pull; per-name results are memoised on the instance. + """ + + def __init__(self, misp, galaxy_ids: dict): + self._misp = misp + self._ids = galaxy_ids + self._cache: dict = {} + + def __call__(self, stix_type: str, name_lower: str): + gid = self._ids.get("rst-" + stix_type) + if not gid: + return None + key = (stix_type, name_lower) + if key not in self._cache: + self._cache[key] = self._lookup(gid, name_lower) + return self._cache[key] + + def _lookup(self, gid, name_lower): + try: + clusters = self._misp.search_galaxy_clusters( + gid, context="all", searchall=name_lower, pythonify=False + ) + except Exception: + return None + for c in clusters or []: + gc = c.get("GalaxyCluster", c) + tname = gc.get("tag_name") + if not tname: + continue + if (gc.get("value") or "").lower() == name_lower: + return tname + for el in gc.get("GalaxyElement") or []: + if el.get("key") == "synonyms" and (el.get("value") or "").lower() == name_lower: + return tname + return None + + +def rst_resolver_from_config(config: dict | None): + """Build an RST cluster resolver from optional MISP config, or None. + + Needs ``misp_url`` + ``misp_key`` in the module config; without them (the + default standalone deployment) returns None and ``threat_tags`` falls back to + built-in galaxy tags. PyMISP is imported lazily so the modules still install + with just ``rstapi`` when MISP resolution isn't configured. + """ + config = config or {} + url = config.get("misp_url") + key = config.get("misp_key") + if not url or not key: + return None + if url in _RESOLVER_CACHE: + return _RESOLVER_CACHE[url] + try: + from pymisp import PyMISP + except Exception: + return None + try: + misp = PyMISP(url, key, ssl=_truthy(config.get("misp_verifycert", False))) + ids = {} + for g in misp.galaxies(pythonify=False) or []: + gd = g.get("Galaxy", g) + if gd.get("type") in _RST_GALAXY_TYPES and gd.get("id"): + ids.setdefault(gd["type"], gd["id"]) + except Exception: + return None + resolver = _RstClusterResolver(misp, ids) + _RESOLVER_CACHE[url] = resolver + return resolver + + +def threat_tags(threats, rst_resolver=None) -> list: + """Map RST threat names to MISP galaxy tags (best-effort, suffix-driven). + + When ``rst_resolver`` is supplied (built from MISP config), each name first + resolves to its RST Threat Library cluster's real ``tag_name`` so the tag + attaches that galaxy; on a miss (or when no resolver) it falls back to the + built-in ``misp-galaxy:*`` value-form tag. ``rst_resolver`` is any callable + ``(stix_type, name_lower) -> tag_name | None``. + """ + tags = [] + for threat in threats or []: + if threat.endswith(("_technique", "_vuln")): + continue + predicate, stix_type = _THREAT_DEFAULT + name = threat + for suffix, (pred, st) in _THREAT_SUFFIX.items(): + if threat.endswith(suffix): + predicate, stix_type, name = pred, st, threat[: -len(suffix)] + break + clean = name.replace("_", " ") + tag = None + if rst_resolver: + try: + tag = rst_resolver(stix_type, clean.lower()) + except Exception: + tag = None + tags.append(tag or f'{predicate}="{clean}"') + return tags + + +def scan_group(request, source): + """uuid that scan-result objects should reference, so each result stays tied + to exactly what was enriched — without spawning extra container objects. + + 1. the parent object, when MISP includes it in the request (``object``); + 2. otherwise the enriched source attribute itself. + + A screenshot / certificate / fetched body cannot be an *attribute* of a + ``url`` / ``ip-port`` / ``domain-ip`` object — MISP object templates are fixed + and have no such relation — so each is returned as its own object that + references this anchor (``identifies`` / ``screenshot-of`` / …). Returns the + anchor uuid, or ``None`` (typed-key request with no attribute to point at). + """ + obj = request.get("object") + if isinstance(obj, dict) and obj.get("uuid"): + return obj["uuid"] + return source.uuid if source is not None else None + + +def misp_event_with_source(request): + """Start a ``MISPEvent`` seeded with the triggering attribute. + + Returns ``(event, source_attribute_or_None)``. Enrichment objects/attributes + added to the event can ``add_reference(source.uuid, ...)`` so MISP links them + to the attribute the analyst enriched. Requires pymisp, which is always + present in a misp-modules deployment (it's a core dependency). + """ + from pymisp import MISPAttribute, MISPEvent + + event = MISPEvent() + source = None + attr = request.get("attribute") + if attr: + source = MISPAttribute() + source.from_dict(**attr) + event.add_attribute(**source) + return event, source + + +def new_enrichment_object(name): + """Build a ``MISPObject`` for an RST enrichment template. + + Returns ``(object, dedicated)``. Uses the ``rst-*`` template from the MISP + object library (install via [MISP/misp-objects](https://github.com/MISP/misp-objects), + e.g. [PR #526](https://github.com/MISP/misp-objects/pull/526)). Falls back to + a generic ``annotation`` object if the template is not installed yet, so output + stays valid misp_standard on any MISP. + """ + from pymisp import MISPObject + + try: + obj = MISPObject(name) + if getattr(obj, "_known_template", False): + return obj, True + except Exception: + pass + return MISPObject("annotation"), False + + +def standard_results(event) -> dict: + """Serialise a ``MISPEvent`` into the misp_standard expansion result envelope.""" + parsed = json.loads(event.to_json()) + return {"results": {k: parsed[k] for k in ("Attribute", "Object") if parsed.get(k)}} + + +def text_result(value: str, comment: str = "") -> dict: + """A misp_standard 'nothing structured to return' fallback (one text attribute).""" + attr = {"type": "text", "value": value} + if comment: + attr["comment"] = comment + return {"results": {"Attribute": [attr]}} + + +_PYMISP_CACHE: dict = {} + + +def _pymisp(cfg): + """Cached PyMISP client from module config (misp_url/misp_key), or None. + + Reused across calls (a misp-modules worker is long-lived). Returns None when + creds are absent or PyMISP can't connect, so callers degrade gracefully. + """ + url, key = cfg.get("misp_url"), cfg.get("misp_key") + if not (url and key): + return None + ck = (url, key, bool(_truthy(cfg.get("misp_verifycert", False)))) + if ck in _PYMISP_CACHE: + return _PYMISP_CACHE[ck] + try: + from pymisp import PyMISP + client = PyMISP(url, key, ssl=ck[2]) + except Exception: + return None + _PYMISP_CACHE[ck] = client + return client + + +def apply_to_source_attribute(config, request, *, tags=None, comment_note=None, + comment_prefix=None, replace_tag_prefixes=(), + set_to_ids=None, fp_sightings=0): + """Write enrichment back ONTO the enriched attribute via the MISP API. + + MISP enrichment itself can only ADD new attributes/objects — it can't modify + the attribute you ran the module on. So, *only when* ``misp_url``/``misp_key`` + are set in the module config, this updates the source attribute in place: + + * removes the module's own prior tags (``replace_tag_prefixes``) then adds + ``tags`` — so re-running replaces rather than stacks verdicts; + * appends ``comment_note`` to the existing comment (dropping any previous + note that started with ``comment_prefix``, so re-runs stay tidy); + * sets ``to_ids`` when ``set_to_ids`` is not None; + * adds ``fp_sightings`` false-positive sightings (type 1) — a benign signal + that feeds MISP's decay/scoring. + + Returns True if it wrote back (caller should then return an empty result so no + duplicate attribute is created); False otherwise (caller returns normally). + """ + cfg = config or {} + attr = request.get("attribute") or {} + uuid = attr.get("uuid") + misp = _pymisp(cfg) + if not (uuid and misp): + return False + try: + full = misp.get_attribute(uuid, pythonify=True) + except Exception: + return False + try: + changed = False + if comment_note is not None: + existing = (getattr(full, "comment", None) or attr.get("comment") or "") + segments = [s for s in existing.split(" | ") + if s and not (comment_prefix and s.startswith(comment_prefix))] + segments.append(comment_note) + full.comment = " | ".join(segments) + changed = True + if set_to_ids is not None: + full.to_ids = bool(set_to_ids) + changed = True + if changed: + misp.update_attribute(full) + if replace_tag_prefixes: + for t in getattr(full, "tags", []) or []: + name = getattr(t, "name", "") or "" + if any(name.startswith(p) for p in replace_tag_prefixes): + misp.untag(uuid, name) + for tag in tags or []: + misp.tag(uuid, tag) + if fp_sightings: + from pymisp import MISPSighting + for _ in range(int(fp_sightings)): + sighting = MISPSighting() + sighting.from_dict(type="1", source="RST Noise Control") # 1 = false-positive + misp.add_sighting(sighting, attribute=uuid) + return True + except Exception: + return False diff --git a/misp_modules/modules/expansion/rst_cs_beacon.py b/misp_modules/modules/expansion/rst_cs_beacon.py new file mode 100644 index 00000000..11fd1606 --- /dev/null +++ b/misp_modules/modules/expansion/rst_cs_beacon.py @@ -0,0 +1,128 @@ +"""rst_cs_beacon — scan a target for a Cobalt Strike beacon (GET /scan/cs-beacon).""" + +from __future__ import annotations + +import json + +import rstapi + +from ._rstcloud.client import ( + error, + misp_event_with_source, + rst_kwargs, + scan_group, + scan_kwargs, + scan_target, + standard_results, + text_result, + unwrap, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["ip-dst", "ip-src", "url", "domain", "hostname", + "ip-dst|port", "ip-src|port", "hostname|port", "domain|port"] +# misp_standard: on a hit, return the beacon blob sha256(s) as pivotable +# attributes tagged to the Cobalt Strike galaxy. +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.2", + "author": "RST Cloud", + "description": "Scan a target IP[:port] for a Cobalt Strike beacon configuration via RST Scan API.", + "module-type": ["expansion"], + "name": "RST Cloud Cobalt Strike Beacon", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Probes the target for Cobalt Strike beacon configurations via RST Scan " + "GET /scan/cs-beacon. On a hit, returns file MISP object(s) with pivotable " + "SHA-256 hashes tagged to the Cobalt Strike galaxy." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "IP, URL, domain, or hostname attribute (optional port via config).", + "output": "file MISP object(s) with beacon hashes and Cobalt Strike galaxy tag.", +} +# 'port' (optional): port to probe when the attribute carries none (default 443). +moduleconfig = ["api_key", "base_url", "port", "timeout"] + +_CS_TAG = 'misp-galaxy:tool="Cobalt Strike"' + + +def _arch(node): + return node if isinstance(node, dict) else {} + + +def _to_int(v): + try: + return int(v) + except (TypeError, ValueError): + return 0 + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + target = scan_target(request, _INPUTS, config, default_port=443) + if not target: + return error("No target found in the request.") + + data, err = unwrap(rstapi.scan(**scan_kwargs(config)).GetCsBeacon(target)) + if err: + return error(f"RST CS beacon scan failed: {err}") + if not isinstance(data, dict) or not data: + return text_result(f"{target}: no Cobalt Strike beacon found", "RST CS Beacon") + + # The scanner ALWAYS returns x86/x64 probe blocks; an actual beacon is only + # present when a block carries a parsed `config` (or a non-zero `size`). An + # empty config / size 0 means "probed, nothing found" — NOT a detection. + blocks = {"x86": _arch(data.get("x86")), "x64": _arch(data.get("x64"))} + hits = {arch: b for arch, b in blocks.items() + if b.get("config") or _to_int(b.get("size")) > 0} + if not hits: + return text_result(f"{target}: no Cobalt Strike beacon detected", "RST CS Beacon") + + from pymisp import MISPObject + + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + + seen = set() + for arch, block in hits.items(): + sha = block.get("sha256") + if not sha or sha in seen: + continue + seen.add(sha) + # The beacon payload is a file; group its hash + config as a file object + # so the detection is tied to the scanned host, not a loose sha256. + fobj = MISPObject("file") + sha_attr = fobj.add_attribute("sha256", value=sha) + sha_attr.add_tag(_CS_TAG) # tags attach to attributes, not objects + if block.get("md5"): + fobj.add_attribute("md5", value=block["md5"]) + if block.get("size"): + fobj.add_attribute("size-in-bytes", value=block["size"]) + cfg = block.get("config") or {} + fobj.add_attribute("text", value=f"Cobalt Strike beacon ({arch}) on {target}; " + f"config: {json.dumps(cfg)[:400]}") + fobj.comment = "RST CS Beacon" + if anchor: + fobj.add_reference(anchor, "characterizes") + event.add_object(fobj) + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/misp_modules/modules/expansion/rst_favicon.py b/misp_modules/modules/expansion/rst_favicon.py new file mode 100644 index 00000000..baf48792 --- /dev/null +++ b/misp_modules/modules/expansion/rst_favicon.py @@ -0,0 +1,132 @@ +"""rst_favicon — favicon hashes + image as a file object (GET /scan/favicon).""" + +from __future__ import annotations + +import base64 +import json +from io import BytesIO + +import rstapi + +from ._rstcloud.client import ( + error, + host_only, + misp_event_with_source, + rst_kwargs, + scan_group, + scan_kwargs, + standard_results, + text_result, + unwrap, + value_from_request, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["url", "domain", "hostname", "ip-src", "ip-dst", + "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +# misp_standard: file object (md5/sha1/sha256 pivotable in Netlas/Censys) plus a +# standalone favicon_hash attribute (Murmur3/MMH3, pivotable in Shodan/FOFA). +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.3", + "author": "RST Cloud", + "description": "Fetch a target's favicon (image + all hashes for Shodan/Netlas/Censys pivoting) via RST Scan API.", + "module-type": ["expansion"], + "name": "RST Cloud Favicon", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Retrieves the favicon image and cryptographic hashes via RST Scan GET " + "/scan/favicon. Returns a file MISP object with MD5/SHA-1/SHA-256 for Censys/Netlas pivoting and a " + "standalone Murmur3 favicon-hash attribute for Shodan/FOFA-style pivoting." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "URL, domain, hostname, or IP attribute.", + "output": "file MISP object, favicon-hash attribute, and resolved favicon URL.", +} +moduleconfig = ["api_key", "base_url", "timeout"] + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + # Favicon endpoint expects a bare host or a full URL — never host:port. + # The API fetches the page over HTTP/HTTPS itself; adding ":443" breaks it. + raw = value_from_request(request, _INPUTS) + if not raw: + return error("No target found in the request.") + target = raw if raw.startswith(("http://", "https://")) else host_only(raw) + + data, err = unwrap(rstapi.scan(**scan_kwargs(config)).GetFavicon(target, include_base64=True)) + if err: + return error(f"RST favicon scan failed: {err}") + if not isinstance(data, dict) or not data.get("favicon_hash"): + return text_result(f"{target}: no favicon returned", "RST Favicon") + + from pymisp import MISPObject + + fhash = str(data["favicon_hash"]) + req_loc = data.get("req_location") or "" + content_type = data.get("req_content_type") or "image/x-icon" + + # Real filename from the resolved favicon URL (e.g. "drive_2026_32dp.ico") + raw_fname = req_loc.rstrip("/").split("/")[-1].split("?")[0] if req_loc else "" + fname = raw_fname if (raw_fname and "." in raw_fname) else "favicon.ico" + + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + + # file object: standard hashes (md5/sha1/sha256) are auto-correlated and + # indexed for pivoting in Netlas, Censys, and other threat-intel platforms. + fobj = MISPObject("file") + fobj.add_attribute("filename", value=fname) + fobj.add_attribute("mimetype", value=content_type) + for htype in ("md5", "sha1", "sha256"): + if data.get(htype): + fobj.add_attribute(htype, value=data[htype]) + + # Attach the raw image when the API returned base64 + try: + raw = base64.b64decode(data["base64_image"]) if data.get("base64_image") else None + except Exception: + raw = None + if raw: + fobj.add_attribute("attachment", value=fname, data=BytesIO(raw)) + + fobj.comment = "RST Favicon" + if anchor: + fobj.add_reference(anchor, "identifies") + event.add_object(fobj) + + # Resolved favicon URL — where the image actually lives after redirects + if req_loc: + event.add_attribute("link", req_loc, comment="RST Favicon resolved URL", to_ids=False) + + # favicon_hash (Murmur3/MMH3): standalone attribute so it correlates independently + # across events and is searchable in Shodan/FOFA-style hunting workflows. + fav_attr = event.add_attribute( + "other", fhash, + comment=f"Murmur3 favicon hash for {target} (Shodan/FOFA pivot)", + to_ids=False, + ) + fav_attr.add_tag(f'rstcloud:favicon:hash="{fhash}"') + + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/misp_modules/modules/expansion/rst_html.py b/misp_modules/modules/expansion/rst_html.py new file mode 100644 index 00000000..a850fd73 --- /dev/null +++ b/misp_modules/modules/expansion/rst_html.py @@ -0,0 +1,119 @@ +"""rst_html — fetch rendered HTML body / extracted JS as an attachment (GET /scan/html/body[/js]). + +Target format: host:port (e.g. drive.google.com:443). Full URLs pass through unchanged. +""" + +from __future__ import annotations + +import json +from io import BytesIO + +import rstapi + +from ._rstcloud.client import ( + error, + misp_event_with_source, + rst_kwargs, + scan_group, + scan_kwargs, + scan_target, + standard_results, + text_result, + unwrap, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["url", "domain", "hostname", "ip-src", "ip-dst", + "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +# misp_standard: return the fetched body as a downloadable attachment. +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.2", + "author": "RST Cloud", + "description": "Fetch rendered HTML body or extracted JavaScript for a URL/IP target via RST Scan API.", + "module-type": ["expansion"], + "name": "RST Cloud HTML Fetcher", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Fetches the rendered HTML body or extracted JavaScript from the target " + "via RST Scan. Returns a file MISP object with the page attached and " + "pivotable content hashes. Configurable mode: body (default) or js." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "URL, domain, hostname, or IP attribute (optional port via config).", + "output": "file MISP object (page.html or page.js) with hashes and HTTP metadata.", +} +# 'mode' = body | js (default body). 'port' (optional): override default port 443. +moduleconfig = ["api_key", "base_url", "mode", "port", "timeout"] + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") or {} + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + target = scan_target(request, _INPUTS, config, default_port=443) + if not target: + return error("No target found in the request.") + + is_js = (config.get("mode") or "body").lower() == "js" + client = rstapi.scan(**scan_kwargs(config)) + method = client.GetHtmlBodyJs if is_js else client.GetHtmlBody + data, err = unwrap(method(target)) + if err: + return error(f"RST HTML fetch failed: {err}") + + body = data.get("body") if isinstance(data, dict) else (data if isinstance(data, str) else "") + if not body: + return text_result(f"{target}: empty response", "RST HTML Fetcher") + + from pymisp import MISPObject + + meta = data if isinstance(data, dict) else {} + hashes = meta.get("hashes") or {} + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + + # The fetched body IS a file: group it (attachment + pivotable body hashes + + # response metadata) in a `file` object rather than a lone size string. + filename = "page.js" if is_js else "page.html" + label = "extracted JavaScript" if is_js else "HTML body" + fobj = MISPObject("file") + fobj.add_attribute("attachment", value=filename, + data=BytesIO(body.encode("utf-8", "replace")), to_ids=False) + fobj.add_attribute("filename", value=filename) + fobj.add_attribute("mimetype", value="application/javascript" if is_js else "text/html") + fobj.add_attribute("size-in-bytes", value=meta.get("content_length") or len(body)) + for htype in ("md5", "sha1", "sha256"): + if hashes.get(htype): + fobj.add_attribute(htype, value=hashes[htype]) + info = [f"RST {label} for {target}"] + if meta.get("http_status"): + info.append(f"HTTP {meta['http_status']}") + if meta.get("title"): + info.append(f"title: {meta['title']}") + if meta.get("truncated"): + info.append("(body truncated)") + fobj.add_attribute("text", value="; ".join(info)) + fobj.comment = "RST HTML Fetcher" + if anchor: + fobj.add_reference(anchor, "derived-from") + event.add_object(fobj) + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/misp_modules/modules/expansion/rst_ioc.py b/misp_modules/modules/expansion/rst_ioc.py new file mode 100644 index 00000000..62db8ae3 --- /dev/null +++ b/misp_modules/modules/expansion/rst_ioc.py @@ -0,0 +1,380 @@ +"""rst_ioc — enrich an indicator with RST Cloud threat intelligence (GET /ioc).""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import rstapi + +from ._rstcloud.client import ( + apply_to_source_attribute, + error, + host_only, + misp_event_with_source, + new_enrichment_object, + rst_kwargs, + rst_resolver_from_config, + scan_group, + standard_results, + text_result, + threat_tags, + unwrap, + value_from_request, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["ip-src", "ip-dst", "domain", "hostname", "url", "md5", "sha1", "sha256", + "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.4", + "author": "RST Cloud", + "description": ( + "Enrich indicators with RST Cloud threat intelligence. " + "Returns an rst-ioc object (score, attribution, geo/ASN for IPs, " + "DNS/WHOIS for domains, parsed components for URLs, related hashes for " + "file hashes) linked back to the enriched attribute." + ), + "module-type": ["expansion", "hover"], + "name": "RST Cloud IoC Lookup", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Queries RST Cloud GET /ioc for threat scores, attribution, geo/ASN, DNS, " + "WHOIS, TTPs, CVEs, and related indicators. Returns a structured rst-ioc " + "MISP object with galaxy tags and optional pivotable related hashes/IPs. " + "When misp_url and misp_key are configured, also writes score/threat tags " + "onto the enriched attribute via the MISP API." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "IP, domain, hostname, URL, or hash attribute (incl. host|port composites).", + "output": "rst-ioc MISP object, galaxy/score tags, and optional related attributes.", +} +# misp_url/misp_key (optional): when set, tags + score note are also written +# directly onto the enriched attribute via the MISP API (like rst_noise_control). +moduleconfig = ["api_key", "base_url", "misp_url", "misp_key", "misp_verifycert"] + +_HASH_TYPES = {"md5", "sha1", "sha256"} + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def _ts(val) -> str: + """Unix timestamp string/int -> YYYY-MM-DD (UTC), or empty string.""" + try: + return datetime.fromtimestamp(int(val), tz=timezone.utc).strftime("%Y-%m-%d") + except (TypeError, ValueError, OSError): + return "" + + +def _f(val, precision=1) -> str: + try: + return f"{float(val):.{precision}f}" + except (TypeError, ValueError): + return "" + + +def _known(v) -> bool: + return bool(v) and str(v).strip().lower() not in ("", "none", "null", "n/a") + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + value = host_only(value_from_request(request, _INPUTS)) + if not value: + return error("No supported indicator value found in the request.") + + # /ioc always returns HTTP 200; a miss carries an "error" key and no "id". + data, err = unwrap(rstapi.ioclookup(**rst_kwargs(config)).GetIndicator(value)) + if err: + return error(f"RST Cloud lookup failed: {err}") + if not isinstance(data, dict) or data.get("error") or not data.get("id"): + return text_result(f"{value}: not found in RST Cloud", "RST IoC Lookup") + + ioc_type = (data.get("ioc_type") or "").lower() + is_ip = ioc_type in ("ipv4", "ipv6") + is_domain = ioc_type == "domain" + is_url = ioc_type == "url" + is_hash = ioc_type in _HASH_TYPES + + score_block = data.get("score") or {} + total = score_block.get("total") + try: + total_int = int(float(str(total))) + except (TypeError, ValueError): + total_int = None + conf_sub = _f(score_block.get("tags"), 2) # context sub-score + relev_sub = _f(score_block.get("frequency"), 2) # relevance sub-score + + threats = data.get("threat") or [] + tags_str = (data.get("tags") or {}).get("str") or [] + ttp = data.get("ttp") or [] + cve = data.get("cve") or [] + industry = data.get("industry") or [] + fp = data.get("fp") or {} + fp_alarm = str(fp.get("alarm") or "").strip().lower() + fp_flagged = fp_alarm in ("true", "possible") + geo = data.get("geo") or {} + asn_blk = data.get("asn") or {} + src_blk = data.get("src") or {} + resolved = data.get("resolved") or {} + parsed = data.get("parsed") or {} + fseen = _ts(data.get("fseen")) + lseen = _ts(data.get("lseen")) + + # ------------------------------------------------------------------------- + # Derive the type-specific context strings once; reused for both the typed + # rst-ioc object and the annotation fallback text. + # ------------------------------------------------------------------------- + geo_str = asn_str = whois_str = http_status = "" + dns_records: list[str] = [] # ["A: 1.2.3.4", "CNAME: ..."] + resolved_ips: list[str] = [] # pivotable A-record IPs (domains) + url_parts: list[str] = [] + filenames = [f for f in (data.get("filename") or []) if _known(f)] + + if is_ip: + if geo.get("country"): + parts = [geo["country"]] + if geo.get("region") and geo["region"] != geo["country"]: + parts.append(geo["region"]) + if geo.get("city") and geo["city"] not in parts: + parts.append(geo["city"]) + geo_str = ", ".join(parts) + if asn_blk.get("num"): + asn_str = f"AS{asn_blk['num']}" + if asn_blk.get("isp"): + asn_str += f" {asn_blk['isp']}" + if asn_blk.get("org") and asn_blk["org"] != asn_blk.get("isp"): + asn_str += f" / {asn_blk['org']}" + + if is_domain: + res_ip = resolved.get("ip") or {} + a_records = [r for r in (res_ip.get("a") or []) if _known(r)] + cnames = [r for r in (res_ip.get("cname") or []) if _known(r)] + aliases = [r for r in (res_ip.get("alias") or []) if _known(r)] + resolved_ips = a_records + if a_records: + dns_records.append("A: " + ", ".join(a_records)) + if cnames: + dns_records.append("CNAME: " + ", ".join(cnames)) + if aliases: + dns_records.append("alias: " + ", ".join(aliases)) + + whois = resolved.get("whois") or {} + if whois.get("havedata") == "true" or whois.get("registrar"): + w_parts = [] + if _known(whois.get("registrar")): + w_parts.append(f"registrar: {whois['registrar']}") + if _known(whois.get("registrant")): + w_parts.append(f"registrant: {whois['registrant']}") + if _known(whois.get("created")): + w_parts.append(f"created: {whois['created'][:10]}") + if _known(whois.get("expires")): + w_parts.append(f"expires: {whois['expires'][:10]}") + if _known(whois.get("updated")): + w_parts.append(f"updated: {whois['updated'][:10]}") + if _known(whois.get("age")): + w_parts.append(f"age: {whois['age']} days") + whois_str = ", ".join(w_parts) + + if is_url: + if _known(parsed.get("domain")): + url_parts.append(f"domain: {parsed['domain']}") + if _known(parsed.get("path")) and parsed.get("path") not in ("/", "None", "none"): + url_parts.append(f"path: {parsed['path']}") + if _known(parsed.get("port")) and parsed.get("port") != "None": + url_parts.append(f"port: {parsed['port']}") + if _known(resolved.get("status")): + http_status = str(resolved["status"]) + + # Source report URLs (deduped, order preserved). + ref_urls: list[str] = [] + seen_refs: set[str] = set() + for report_url in (src_blk.get("report") or "").split(","): + report_url = report_url.strip() + if report_url and report_url not in seen_refs: + ref_urls.append(report_url) + seen_refs.add(report_url) + src_names = src_blk.get("name") or [] + + # ------------------------------------------------------------------------- + # Annotation fallback text (also a useful human summary). + # ------------------------------------------------------------------------- + lines: list[str] = [] + score_parts = [] + if total_int is not None: + score_parts.append(f"total: {total_int}/100") + if _f(score_block.get("src")): + score_parts.append(f"src: {_f(score_block['src'])}") + if conf_sub: + score_parts.append(f"context: {conf_sub}") + if relev_sub: + score_parts.append(f"relevance: {relev_sub}") + if score_parts: + lines.append("Score: " + ", ".join(score_parts)) + if fseen or lseen: + lines.append(f"Seen: {fseen or '?'} to {lseen or '?'}") + if geo_str: + lines.append("Geo: " + geo_str) + if asn_str: + lines.append("ASN: " + asn_str) + if dns_records: + lines.append("DNS: " + " | ".join(dns_records)) + if whois_str: + lines.append("WHOIS: " + whois_str) + if url_parts: + lines.append("URL: " + ", ".join(url_parts)) + if http_status: + lines.append("HTTP status: " + http_status) + if is_hash: + hash_parts = [f"{h.upper()}: {data[h]}" for h in ("md5", "sha1", "sha256") + if _known(data.get(h))] + if hash_parts: + lines.append("Hashes: " + ", ".join(hash_parts)) + if filenames: + lines.append("Filenames: " + ", ".join(filenames)) + if industry: + lines.append("Industry: " + ", ".join(industry)) + if threats: + lines.append("Threats: " + ", ".join(threats)) + if tags_str: + lines.append("Tags: " + ", ".join(tags_str)) + if ttp: + lines.append("TTPs: " + ", ".join(ttp)) + if cve: + lines.append("CVEs: " + ", ".join(cve)) + if fp_flagged: + note = f"FP alarm: {fp_alarm}" + if fp.get("descr"): + note += f" - {fp['descr']}" + lines.append(note) + if data.get("description"): + lines.append("Description: " + data["description"]) + if src_names: + lines.append("Sources: " + ", ".join(src_names)) + + # ------------------------------------------------------------------------- + # Galaxy + score / FP tags + # ------------------------------------------------------------------------- + rst_resolver = rst_resolver_from_config(config) + galaxy_tags = threat_tags(threats, rst_resolver) + if total_int is not None: + galaxy_tags.append(f'rstcloud:score-total="{total_int}"') + if fp_flagged: + risk = "high" if fp_alarm == "true" else "medium" + galaxy_tags.append(f'false-positive:risk="{risk}"') + + # ------------------------------------------------------------------------- + # Build MISP result + # ------------------------------------------------------------------------- + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + + obj, dedicated = new_enrichment_object("rst-ioc") + obj.comment = "RST IoC Lookup" + + if dedicated: + tag_target = None + if total_int is not None: + tag_target = obj.add_attribute("score-total", value=str(total_int), to_ids=False) + if conf_sub: + obj.add_attribute("score-confidence", value=conf_sub, to_ids=False) + if relev_sub: + obj.add_attribute("score-relevance", value=relev_sub, to_ids=False) + if fseen: + obj.add_attribute("first-seen", value=fseen, to_ids=False) + if lseen: + obj.add_attribute("last-seen", value=lseen, to_ids=False) + for t in threats: + a = obj.add_attribute("threat", value=t, to_ids=False) + tag_target = tag_target or a + for t in ttp: + obj.add_attribute("ttp", value=t, to_ids=False) + for c in cve: + obj.add_attribute("cve", value=c, to_ids=False) + for ind in industry: + obj.add_attribute("industry", value=ind, to_ids=False) + for t in tags_str: + obj.add_attribute("tag", value=t, to_ids=False) + if fp_flagged: + fp_val = fp_alarm + (f" - {fp['descr']}" if fp.get("descr") else "") + obj.add_attribute("false-positive", value=fp_val, to_ids=False) + if geo_str: + obj.add_attribute("geo", value=geo_str, to_ids=False) + if asn_str: + obj.add_attribute("asn", value=asn_str, to_ids=False) + for rec in dns_records: + obj.add_attribute("dns", value=rec, to_ids=False) + if whois_str: + obj.add_attribute("whois", value=whois_str, to_ids=False) + if http_status: + obj.add_attribute("http-status", value=http_status, to_ids=False) + for fn in filenames: + obj.add_attribute("filename", value=fn, to_ids=False) + if data.get("description"): + obj.add_attribute("description", value=data["description"], to_ids=False) + for ref in ref_urls: + obj.add_attribute("ref", value=ref, to_ids=False) + # Fall back to a text attribute as the tag anchor if nothing else exists. + tag_target = tag_target or obj.add_attribute("description", value="\n".join(lines), to_ids=False) + else: + obj.add_attribute("type", value="RST IoC Lookup", to_ids=False) + tag_target = obj.add_attribute("text", value="\n".join(lines), to_ids=False) + if fseen: + obj.add_attribute("creation-date", value=fseen, to_ids=False) + for ref in ref_urls: + obj.add_attribute("ref", value=ref, to_ids=False) + + for tag in galaxy_tags: + tag_target.add_tag(tag) + if anchor: + obj.add_reference(anchor, "characterizes") + event.add_object(obj) + + # Pivotable hashes: expose the related hash values as their own searchable + # IOC attributes (separate from the object) so they correlate across events. + if is_hash: + for htype in ("md5", "sha1", "sha256"): + hval = data.get(htype) + if _known(hval) and hval != value: + a = event.add_attribute(htype, value=hval, to_ids=True, + comment="RST IoC Lookup - related hash") + for tag in galaxy_tags: + a.add_tag(tag) + + # Pivotable resolved IPs for domains (context, not detection-worthy). + for rip in resolved_ips: + event.add_attribute("ip-dst", value=rip, to_ids=False, + comment="RST IoC Lookup - resolved IP") + + # Optional write-back: apply tags + brief note onto the enriched attribute + # directly via the MISP API when misp_url/misp_key are configured. + apply_to_source_attribute( + config, request, + tags=galaxy_tags, + comment_note=( + f"RST score {total_int}/100" + + (f"; threats: {', '.join(threats)}" if threats else "") + ), + comment_prefix="RST score", + ) + + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/misp_modules/modules/expansion/rst_noise_control.py b/misp_modules/modules/expansion/rst_noise_control.py new file mode 100644 index 00000000..0bd903e3 --- /dev/null +++ b/misp_modules/modules/expansion/rst_noise_control.py @@ -0,0 +1,204 @@ +"""rst_noise_control — check if an indicator is benign/noise (GET /benign/lookup).""" + +from __future__ import annotations + +import json + +import rstapi + +from ._rstcloud.client import ( + apply_to_source_attribute, + error, + host_only, + misp_event_with_source, + new_enrichment_object, + rst_kwargs, + scan_group, + standard_results, + text_result, + unwrap, + value_from_request, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["ip-src", "ip-dst", "domain", "hostname", "url", "md5", "sha1", "sha256", + "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.4", + "author": "RST Cloud", + "description": ( + "Check whether a value (IP, domain, URL or hash) is known-good / noise " + "via RST Noise Control. Returns an rst-noise object (verdict, category) " + "linked back to the enriched attribute." + ), + "module-type": ["expansion", "hover"], + "name": "RST Cloud Noise Control", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Queries RST Cloud GET /benign/lookup for benign/noisy verdicts. Returns " + "an rst-noise MISP object with false-positive risk tags. When misp_url and " + "misp_key are configured, also annotates the source attribute in place " + "(tags, comment, to_ids, false-positive sightings)." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "IP, domain, hostname, URL, or hash attribute (incl. host|port composites).", + "output": "rst-noise MISP object with verdict, category, and risk/noise tags.", +} +# misp_url/misp_key/misp_verifycert (optional): when set the verdict is ALSO +# written directly onto the enriched attribute (tags, comment, to_ids, FP +# sightings) via the MISP API — the annotation object is always returned +# regardless. +moduleconfig = ["api_key", "base_url", "misp_url", "misp_key", "misp_verifycert"] + +# Tag families we own — stripped before re-adding so re-runs replace not stack. +_TAG_PREFIXES = ("false-positive:risk=", "rstcloud:noise-control=", "rstcloud:noise-category=") + + +def _category(reason: str) -> str: + """'Change Score Shodan/Scanners/Shodan' -> 'Shodan/Scanners/Shodan'.""" + for action in ("Change Score ", "Drop "): + if reason.startswith(action): + return reason[len(action):].strip() + return reason.strip() + + +def _category_tag(category: str) -> str: + """First ``/``-delimited segment for ``rstcloud:noise-category`` (lower cardinality). + + Full category path stays in the object/comment text; the tag uses only the + top-level bucket before the first ``/``. + + Example (md5, ``Drop Ubuntu Server 26.04 LTS/pam_sepermit.so/``):: + + Verdict: BENIGN - known-good + Category: Ubuntu Server 26.04 LTS/pam_sepermit.so/ + Type: md5 + rstcloud:noise-category="Ubuntu Server 26.04 LTS" + """ + category = (category or "").strip() + if not category: + return category + return category.split("/", 1)[0].strip() + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + value = host_only(value_from_request(request, _INPUTS)) + if not value: + return error("No supported value found in the request.") + + # /benign/lookup always returns HTTP 200 with {value, type, benign, reason}. + # `benign` is the STRING "true"/"false". The `reason` prefix encodes action: + # "Drop ..." -> known-good, safe to suppress (FP risk high) + # "Change Score ..." -> noisy infra (scanners/CDN…), reduce score only + # (FP risk medium — do NOT treat as clean) + # benign=="false" -> unknown / not in database. + # + # Example benign md5 (reason "Drop Ubuntu Server 26.04 LTS/pam_sepermit.so/"): + # object/comment Category: Ubuntu Server 26.04 LTS/pam_sepermit.so/ + # tag rstcloud:noise-category="Ubuntu Server 26.04 LTS" + # (+ false-positive:risk="high", rstcloud:noise-control="drop") + data, err = unwrap(rstapi.noisecontrol(**rst_kwargs(config)).ValueLookup(value)) + if err: + return error(f"RST Noise Control lookup failed: {err}") + if not isinstance(data, dict): + return text_result(f"{value}: unexpected response from RST Noise Control", "RST Noise Control") + + benign = str(data.get("benign", "")).strip().lower() == "true" + reason = (data.get("reason") or "").strip() + ioc_type = (data.get("type") or "").strip() + category = _category(reason) + tag_category = _category_tag(category) + + # --- Determine verdict, tags, and write-back actions --- + if not benign: + verdict = "Not flagged" + detail = "" # "Not Found in our database" is the API's constant for unknown — not a category + tags = [] + fp_sightings = 0 + set_to_ids = None + elif reason.lower().startswith("change score"): + verdict = "NOISY - reduce score" + detail = category + tags = [ + 'false-positive:risk="medium"', + 'rstcloud:noise-control="change-score"', + f'rstcloud:noise-category="{tag_category}"', + ] + fp_sightings = 1 + set_to_ids = None + else: + verdict = "BENIGN - known-good" + detail = category + tags = [ + 'false-positive:risk="high"', + 'rstcloud:noise-control="drop"', + f'rstcloud:noise-category="{tag_category}"', + ] + fp_sightings = 2 + set_to_ids = False + + # --- Build annotation fallback text --- + lines = [f"Verdict: {verdict}"] + if detail: + lines.append(f"Category: {detail}") + if ioc_type: + lines.append(f"Type: {ioc_type}") + + # --- Build MISP result --- + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + + obj, dedicated = new_enrichment_object("rst-noise") + obj.comment = "RST Noise Control" + if dedicated: + tag_target = obj.add_attribute("verdict", value=verdict, to_ids=False) + if detail: + obj.add_attribute("category", value=detail, to_ids=False) + if ioc_type: + obj.add_attribute("ioc-type", value=ioc_type, to_ids=False) + obj.add_attribute("benign", value=str(benign).lower(), to_ids=False) + else: + obj.add_attribute("type", value="RST Noise Control", to_ids=False) + tag_target = obj.add_attribute("text", value="\n".join(lines), to_ids=False) + for tag in tags: + tag_target.add_tag(tag) + if anchor: + obj.add_reference(anchor, "related-to") + event.add_object(obj) + + # Optional write-back: when MISP creds are configured, ALSO annotate the + # source attribute in place (tags, comment, to_ids flip, FP sightings). + # The annotation object is returned regardless. + apply_to_source_attribute( + config, request, + tags=tags, + comment_note=f"RST Noise Control: {verdict}" + (f" - {detail}" if detail else ""), + comment_prefix="RST Noise Control:", + replace_tag_prefixes=_TAG_PREFIXES, + set_to_ids=set_to_ids, + fp_sightings=fp_sightings, + ) + + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/misp_modules/modules/expansion/rst_screenshot.py b/misp_modules/modules/expansion/rst_screenshot.py new file mode 100644 index 00000000..56c41991 --- /dev/null +++ b/misp_modules/modules/expansion/rst_screenshot.py @@ -0,0 +1,106 @@ +"""rst_screenshot — capture a page screenshot as an image object (GET /scan/html/screenshot/*).""" + +from __future__ import annotations + +import base64 +import json +from io import BytesIO + +import rstapi + +from ._rstcloud.client import ( + error, + misp_event_with_source, + rst_kwargs, + scan_group, + scan_kwargs, + scan_target, + standard_results, + text_result, + unwrap, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["url", "domain", "hostname", "ip-src", "ip-dst", + "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +# misp_standard: return an image MISPObject with the PNG attached (rendered inline +# in MISP) instead of a text description. +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.2", + "author": "RST Cloud", + "description": "Capture a page screenshot (first/full/last frame) of a URL/IP target via RST Scan API.", + "module-type": ["expansion"], + "name": "RST Cloud Screenshot", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Renders the target page and returns a PNG screenshot as an image MISP " + "object (inline in MISP). Configurable frame: first, full (default), or last." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "URL, domain, hostname, or IP attribute (optional port via config).", + "output": "image MISP object with PNG attachment linked to the enriched attribute.", +} +# 'frame' selects which screenshot endpoint to call (first/full/last, default full). +# 'port' (optional): override default port 443. +moduleconfig = ["api_key", "base_url", "frame", "port", "timeout"] + +_FRAMES = { + "first": "GetHtmlScreenshotFirst", + "full": "GetHtmlScreenshotFull", + "last": "GetHtmlScreenshotLast", +} + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") or {} + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + target = scan_target(request, _INPUTS, config, default_port=443) + if not target: + return error("No target found in the request.") + + method = _FRAMES.get((config.get("frame") or "full").lower(), "GetHtmlScreenshotFull") + data, err = unwrap(getattr(rstapi.scan(**scan_kwargs(config)), method)(target)) + if err: + return error(f"RST screenshot failed: {err}") + + b64 = data.get("image_base64") if isinstance(data, dict) else None + try: + raw = base64.b64decode(b64) if b64 else None + except Exception: + raw = None + if not raw: + return text_result(f"{target}: no screenshot returned ({method})", "RST Screenshot") + + from pymisp import MISPObject + + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + image = MISPObject("image") + image.add_attribute("attachment", value="screenshot.png", data=BytesIO(raw)) + image.comment = f"RST Screenshot ({method})" + event.add_attribute("link", f"https://{target}" if "://" not in target else target, + comment=f"RST Screenshot source ({method})", to_ids=False) + if anchor: + image.add_reference(anchor, "screenshot-of") + event.add_object(image) + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/misp_modules/modules/expansion/rst_ssl.py b/misp_modules/modules/expansion/rst_ssl.py new file mode 100644 index 00000000..57aab80c --- /dev/null +++ b/misp_modules/modules/expansion/rst_ssl.py @@ -0,0 +1,106 @@ +"""rst_ssl — SSL certificate as a pivotable x509 object (GET /scan/ssl/certificate).""" + +from __future__ import annotations + +import json + +import rstapi + +from ._rstcloud.client import ( + error, + misp_event_with_source, + rst_kwargs, + scan_group, + scan_kwargs, + scan_target, + standard_results, + text_result, + unwrap, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["ip-dst", "ip-src", "hostname", "domain", + "ip-dst|port", "ip-src|port", "hostname|port", "domain|port"] +# misp_standard: return a real x509 MISPObject (searchable subject/issuer, +# pivotable fingerprints) instead of a text blob. +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.2", + "author": "RST Cloud", + "description": "Fetch the SSL certificate for an IP[:port] as an x509 object via RST Scan API.", + "module-type": ["expansion"], + "name": "RST Cloud SSL Certificate", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Connects to the target service and retrieves the TLS certificate via " + "RST Scan GET /scan/ssl/certificate. Returns an x509 MISP object with " + "pivotable fingerprints (SHA-1/256/MD5), subject, issuer, and validity dates." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "IP, hostname, or domain attribute (optional port via config or composite).", + "output": "x509 MISP object referencing the enriched attribute.", +} +# 'port' (optional): TLS port to scan when the attribute carries none (API +# defaults to 443 if omitted). +moduleconfig = ["api_key", "base_url", "port", "timeout"] + +# RST certificate field -> x509 object_relation (pymisp infers the attribute type +# from the template, so fingerprints become pivotable x509-fingerprint-* types). +_X509_MAP = { + "subject_dn": "subject", + "issuer_dn": "issuer", + "serial_number": "serial-number", + "version": "version", + "not_before": "validity-not-before", + "not_after": "validity-not-after", + "fingerprint_sha1": "x509-fingerprint-sha1", + "fingerprint_sha256": "x509-fingerprint-sha256", + "fingerprint_md5": "x509-fingerprint-md5", +} + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + target = scan_target(request, _INPUTS, config, default_port=443) + if not target: + return error("No target found in the request (expects an IP/hostname).") + + data, err = unwrap(rstapi.scan(**scan_kwargs(config)).GetSslCertificate(target)) + if err: + return error(f"RST SSL scan failed: {err}") + if not isinstance(data, dict) or not data.get("subject_dn"): + return text_result(f"{target}: no certificate returned", "RST SSL Certificate") + + from pymisp import MISPObject + + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + x509 = MISPObject("x509") + for field, relation in _X509_MAP.items(): + if data.get(field): + x509.add_attribute(relation, value=data[field]) + x509.comment = f"RST SSL Certificate for {target}" + if anchor: + x509.add_reference(anchor, "identifies") + event.add_object(x509) + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/misp_modules/modules/expansion/rst_whois.py b/misp_modules/modules/expansion/rst_whois.py new file mode 100644 index 00000000..451d2443 --- /dev/null +++ b/misp_modules/modules/expansion/rst_whois.py @@ -0,0 +1,125 @@ +"""rst_whois — parsed WHOIS as a misp_standard whois object (GET /whois/{domain}).""" + +from __future__ import annotations + +import json + +import rstapi + +from ._rstcloud.client import ( + error, + misp_event_with_source, + rst_kwargs, + scan_group, + standard_results, + text_result, + unwrap, + value_from_request, +) + +misperrors = {"error": "Error"} + +_INPUTS = ["domain", "hostname"] +mispattributes = {"input": _INPUTS, "format": "misp_standard"} + +moduleinfo = { + "version": "0.2", + "author": "RST Cloud", + "description": "Retrieve parsed WHOIS information for a domain via RST Cloud.", + "module-type": ["expansion", "hover"], + "name": "RST Cloud Whois", + "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], + "features": ( + "Queries RST Cloud GET /whois for parsed domain registration data. " + "Returns a standard whois MISP object (registrar, registrant, dates, " + "nameservers) linked back to the enriched attribute." + ), + "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "input": "Domain or hostname attribute.", + "output": "whois MISP object with registration and nameserver fields.", +} +moduleconfig = ["api_key", "base_url"] + + +def introspection(): + return mispattributes + + +def version(): + moduleinfo["config"] = moduleconfig + return moduleinfo + + +def _known(v) -> bool: + """True when a value is present and not a placeholder like 'unknown'.""" + return bool(v) and str(v).strip().lower() not in ("unknown", "none", "", "null", "n/a") + + +def handler(q=False): + if q is False: + return False + request = json.loads(q) + config = request.get("config") + if not rst_kwargs(config)["APIKEY"]: + return error("An RST Cloud API key is required (set api_key in the module config).") + domain = value_from_request(request, _INPUTS) + if not domain: + return error("No domain found in the request.") + + data, err = unwrap(rstapi.whoisapi(**rst_kwargs(config)).GetDomainInfo(domain)) + if err: + return error(f"RST Whois API lookup failed: {err}") + if not isinstance(data, dict): + return text_result(f"{domain}: no WHOIS data found", "RST Whois API") + + from pymisp import MISPObject + + event, source = misp_event_with_source(request) + anchor = scan_group(request, source) + + obj = MISPObject("whois") + obj.comment = f"RST Whois API lookup for {domain}" + + # Identity + if _known(data.get("domain")): + obj.add_attribute("domain", value=data["domain"], to_ids=False) + if _known(data.get("registrar")): + obj.add_attribute("registrar", value=data["registrar"], to_ids=False) + if _known(data.get("registrant")): + obj.add_attribute("registrant-name", value=data["registrant"], to_ids=False) + if _known(data.get("registrant_org")): + obj.add_attribute("registrant-org", value=data["registrant_org"], to_ids=False) + if _known(data.get("registrant_email")): + obj.add_attribute("registrant-email", value=data["registrant_email"], to_ids=False) + + # Dates (API returns "created_on" / "updated_on" / "expires_on") + if _known(data.get("created_on")): + obj.add_attribute("creation-date", value=data["created_on"], to_ids=False) + if _known(data.get("updated_on")): + obj.add_attribute("modification-date", value=data["updated_on"], to_ids=False) + if _known(data.get("expires_on")): + obj.add_attribute("expiration-date", value=data["expires_on"], to_ids=False) + + # Nameservers — one attribute per NS + for ns in (data.get("nameservers") or "").split(","): + ns = ns.strip() + if ns: + obj.add_attribute("nameserver", value=ns, to_ids=False) + + # Domain age + status as a free-text note (no dedicated whois relation) + notes = [] + if data.get("age") is not None: + notes.append(f"age: {data['age']} days") + if _known(data.get("status")): + notes.append(f"status: {data['status']}") + if notes: + obj.add_attribute("text", value="; ".join(notes), to_ids=False) + + if anchor: + obj.add_reference(anchor, "related-to") + event.add_object(obj) + return standard_results(event) + + +if __name__ == "__main__": + print(json.dumps(version(), indent=2)) diff --git a/pyproject.toml b/pyproject.toml index d94a8467..2cc122cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ minimal = [ "pyipasnhistory", "pysafebrowsing", "requests[security]", + "rstapi>=1.2.0,<2", "slack-sdk", "urlarchiver", "vt-graph-api", @@ -120,6 +121,7 @@ all = [ "python-pptx", "pyzbar", "requests[security]", ## in minimal + "rstapi>=1.2.0,<2", ## in minimal "setuptools", "shodan", "sigmatools", diff --git a/tests/test_rst_ioc.py b/tests/test_rst_ioc.py new file mode 100644 index 00000000..f5878dd6 --- /dev/null +++ b/tests/test_rst_ioc.py @@ -0,0 +1,58 @@ +"""Unit tests for rst_ioc (mocked rstapi, no network).""" + +import json +from unittest.mock import patch + +from misp_modules.modules.expansion import rst_ioc + + +class _FakeClient: + def __init__(self, payload): + self._payload = payload + + def GetIndicator(self, value): + return self._payload + + +def _query(attribute, config=None): + return json.dumps({ + "module": "rst_ioc", + "attribute": attribute, + "config": config if config is not None else {"api_key": "test-key"}, + }) + + +def test_rst_ioc_not_found_returns_text(): + attribute = {"type": "ip-dst", "value": "8.8.8.8", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + with patch.object(rst_ioc.rstapi, "ioclookup", return_value=_FakeClient({"error": "Not Found"})): + result = rst_ioc.handler(_query(attribute)) + assert "Attribute" in result["results"] + assert any("not found" in a["value"].lower() for a in result["results"]["Attribute"]) + + +def test_rst_ioc_hit_returns_rst_ioc_object_with_score_tag(): + attribute = {"type": "domain", "value": "evil.example", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + payload = { + "id": "rst-123", + "ioc_type": "domain", + "score": {"total": "82", "tags": "0.5", "frequency": "0.3"}, + "threat": ["akira_ransomware"], + "fp": {"alarm": "false"}, + } + with patch.object(rst_ioc.rstapi, "ioclookup", return_value=_FakeClient(payload)): + result = rst_ioc.handler(_query(attribute)) + obj = result["results"]["Object"][0] + assert obj["name"] in ("rst-ioc", "annotation") + relations = {a.get("object_relation"): a for a in obj["Attribute"]} + tag_target = relations.get("score-total") or relations.get("text") or obj["Attribute"][0] + tag_names = [t["name"] for t in tag_target.get("Tag", [])] + assert 'rstcloud:score-total="82"' in tag_names + assert any("akira" in t.lower() for t in tag_names) + + +def test_rst_ioc_missing_api_key(): + attribute = {"type": "domain", "value": "evil.example", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + with patch.object(rst_ioc.rstapi, "ioclookup") as mock_lookup: + result = rst_ioc.handler(_query(attribute, config={})) + mock_lookup.assert_not_called() + assert result == {"error": "An RST Cloud API key is required (set api_key in the module config)."} diff --git a/tests/test_rst_noise_control.py b/tests/test_rst_noise_control.py new file mode 100644 index 00000000..60a92698 --- /dev/null +++ b/tests/test_rst_noise_control.py @@ -0,0 +1,92 @@ +"""Unit tests for rst_noise_control (mocked rstapi, no network).""" + +import json +from unittest.mock import patch + +from misp_modules.modules.expansion import rst_noise_control + + +class _FakeClient: + def __init__(self, payload): + self._payload = payload + + def ValueLookup(self, value): + return self._payload + + +def _query(attribute, config=None): + return json.dumps({ + "module": "rst_noise_control", + "attribute": attribute, + "config": config if config is not None else {"api_key": "test-key"}, + }) + + +def _verdict_attr(result): + obj = result["results"]["Object"][0] + for a in obj["Attribute"]: + if a.get("object_relation") == "verdict": + return a + for a in obj["Attribute"]: + val = a.get("value") or "" + if a.get("object_relation") == "text" or (a.get("type") == "text" and "Verdict:" in val): + return a + return obj["Attribute"][-1] + + +def _verdict_tags(result): + obj = result["results"]["Object"][0] + for a in obj["Attribute"]: + if a.get("Tag"): + return [t["name"] for t in a["Tag"]] + return [] + + +def test_rst_noise_control_drop_verdict(): + attribute = {"type": "ip-dst", "value": "8.8.8.8", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + payload = {"benign": "true", "reason": "Drop Public DNS/Services/Google", "type": "ipv4"} + with patch.object(rst_noise_control.rstapi, "noisecontrol", return_value=_FakeClient(payload)): + result = rst_noise_control.handler(_query(attribute)) + verdict = _verdict_attr(result) + assert "BENIGN" in verdict["value"] + tags = _verdict_tags(result) + assert 'false-positive:risk="high"' in tags + assert 'rstcloud:noise-control="drop"' in tags + assert 'rstcloud:noise-category="Public DNS"' in tags + + +_UBUNTU_CATEGORY = "Ubuntu Server 26.04 LTS/pam_sepermit.so/" +_UBUNTU_TAG = "Ubuntu Server 26.04 LTS" + + +def test_rst_noise_control_ubuntu_benign_hash(): + attribute = {"type": "md5", "value": "abc", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + payload = {"benign": "true", "reason": f"Drop {_UBUNTU_CATEGORY}", "type": "md5"} + with patch.object(rst_noise_control.rstapi, "noisecontrol", return_value=_FakeClient(payload)): + result = rst_noise_control.handler(_query(attribute)) + verdict = _verdict_attr(result) + assert "BENIGN" in verdict["value"] + tags = _verdict_tags(result) + assert 'false-positive:risk="high"' in tags + assert 'rstcloud:noise-control="drop"' in tags + assert f'rstcloud:noise-category="{_UBUNTU_TAG}"' in tags + + +def test_rst_noise_control_deep_category_tag(): + full = "NSRL 2025.03.1_modern/726.LibOVRPlatform64_1.dll/Meta - Oculus Platform SDK" + attribute = {"type": "md5", "value": "abc", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + payload = {"benign": "true", "reason": f"Drop {full}", "type": "md5"} + with patch.object(rst_noise_control.rstapi, "noisecontrol", return_value=_FakeClient(payload)): + result = rst_noise_control.handler(_query(attribute)) + tags = _verdict_tags(result) + assert 'rstcloud:noise-category="NSRL 2025.03.1_modern"' in tags + + +def test_rst_noise_control_not_in_database(): + attribute = {"type": "ip-dst", "value": "1.2.3.4", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + payload = {"benign": "false", "reason": "Not Found in our database", "type": "ipv4"} + with patch.object(rst_noise_control.rstapi, "noisecontrol", return_value=_FakeClient(payload)): + result = rst_noise_control.handler(_query(attribute)) + verdict = _verdict_attr(result) + assert "not flagged" in verdict["value"].lower() + assert _verdict_tags(result) == [] diff --git a/tests/test_rst_ssl.py b/tests/test_rst_ssl.py new file mode 100644 index 00000000..6ce7832a --- /dev/null +++ b/tests/test_rst_ssl.py @@ -0,0 +1,48 @@ +"""Unit tests for rst_ssl (mocked rstapi, no network).""" + +import json +from unittest.mock import patch + +from misp_modules.modules.expansion import rst_ssl + + +class _FakeClient: + def __init__(self, payload): + self._payload = payload + + def GetSslCertificate(self, target): + return self._payload + + +def _query(attribute, config=None): + return json.dumps({ + "module": "rst_ssl", + "attribute": attribute, + "config": config if config is not None else {"api_key": "test-key", "port": "443"}, + }) + + +def test_rst_ssl_returns_x509_object(): + attribute = {"type": "ip-dst", "value": "93.184.216.34", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + payload = { + "subject_dn": "CN=example.com", + "issuer_dn": "CN=DigiCert", + "fingerprint_sha1": "a" * 40, + "fingerprint_sha256": "b" * 64, + "not_after": "2026-12-21T19:20:01Z", + "serial_number": "01", + } + with patch.object(rst_ssl.rstapi, "scan", return_value=_FakeClient(payload)): + result = rst_ssl.handler(_query(attribute)) + obj = result["results"]["Object"][0] + assert obj["name"] == "x509" + relations = {a["object_relation"]: a for a in obj["Attribute"]} + assert relations["subject"]["value"] == "CN=example.com" + assert relations["x509-fingerprint-sha256"]["value"] == "b" * 64 + + +def test_rst_ssl_no_certificate(): + attribute = {"type": "ip-dst", "value": "1.2.3.4", "uuid": "5b582d80-7a7e-4b6a-9f22-77656e72bb3b"} + with patch.object(rst_ssl.rstapi, "scan", return_value=_FakeClient({})): + result = rst_ssl.handler(_query(attribute)) + assert any("no certificate" in a["value"].lower() for a in result["results"]["Attribute"]) From c770cc1d5e3fd6e2ec428af5ba1f59863fae4d34 Mon Sep 17 00:00:00 2001 From: k1r10n Date: Sat, 27 Jun 2026 19:31:51 +1000 Subject: [PATCH 2/3] Add RSTAPI dependency and update README with RST Cloud modules - Introduced `rstapi` version 1.2.0 as an optional dependency for RST Cloud API access. - Updated README to include new RST Cloud modules for various functionalities such as Cobalt Strike beacon scanning, favicon fetching, HTML fetching, IoC lookups, Noise Control, screenshots, SSL certificate retrieval, and WHOIS information. --- README.md | 8 ++++++++ poetry.lock | 25 ++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d401c378..964c09db 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,14 @@ For further Information see the [license file](https://misp.github.io/misp-modul * [Real-time Blackhost Lists Lookup](https://misp.github.io/misp-modules/expansion/#real-time-blackhost-lists-lookup) - Module to check an IPv4 address against known RBLs. * [Recorded Future Enrich](https://misp.github.io/misp-modules/expansion/#recorded-future-enrich) - Module to enrich attributes with threat intelligence from Recorded Future. * [Reverse DNS](https://misp.github.io/misp-modules/expansion/#reverse-dns) - Simple Reverse DNS expansion service to resolve reverse DNS from MISP attributes. +* [RST Cloud Cobalt Strike Beacon](https://misp.github.io/misp-modules/expansion/#rst-cloud-cobalt-strike-beacon) - Scan a target for Cobalt Strike beacon configurations via RST Scan API. +* [RST Cloud Favicon](https://misp.github.io/misp-modules/expansion/#rst-cloud-favicon) - Fetch favicon image and hashes for Shodan/Netlas/Censys/FOFA pivoting via RST Scan API. +* [RST Cloud HTML Fetcher](https://misp.github.io/misp-modules/expansion/#rst-cloud-html-fetcher) - Fetch rendered HTML body or extracted JavaScript via RST Scan API. +* [RST Cloud IoC Lookup](https://misp.github.io/misp-modules/expansion/#rst-cloud-ioc-lookup) - Enrich indicators with RST Cloud threat intelligence. +* [RST Cloud Noise Control](https://misp.github.io/misp-modules/expansion/#rst-cloud-noise-control) - Check whether an indicator is known-good or noisy via RST Noise Control. +* [RST Cloud Screenshot](https://misp.github.io/misp-modules/expansion/#rst-cloud-screenshot) - Capture a page screenshot via RST Scan API. +* [RST Cloud SSL Certificate](https://misp.github.io/misp-modules/expansion/#rst-cloud-ssl-certificate) - Fetch TLS certificate as an x509 MISP object via RST Scan API. +* [RST Cloud Whois](https://misp.github.io/misp-modules/expansion/#rst-cloud-whois) - Retrieve parsed WHOIS for a domain via RST Cloud. * [ReversingLabs Spectra Analyze](https://misp.github.io/misp-modules/expansion/#reversinglabs-spectra-analyze) - Threat intelligence enrichment module * [SecurityTrails Lookup](https://misp.github.io/misp-modules/expansion/#securitytrails-lookup) - An expansion modules for SecurityTrails. * [Shodan Lookup](https://misp.github.io/misp-modules/expansion/#shodan-lookup) - Module to query on Shodan. diff --git a/poetry.lock b/poetry.lock index 00c6c771..482021eb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -6367,6 +6367,25 @@ files = [ {file = "rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256"}, ] +[[package]] +name = "rstapi" +version = "1.2.0" +description = "Python library to access the RST Cloud API." +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"minimal\" or extra == \"all\"" +files = [ + {file = "rstapi-1.2.0-py3-none-any.whl", hash = "sha256:aab1fbb4e520135a3b280bebadb6511ad805f5aee46a31ed18a28aa3b6ae529c"}, + {file = "rstapi-1.2.0.tar.gz", hash = "sha256:19c5d98f522b9dbf03f1960aa76c137ce03be6d8c205b0cf9336ec7a8c9f25ff"}, +] + +[package.dependencies] +requests = "*" + +[package.extras] +test = ["pytest (>=7)"] + [[package]] name = "rtfde" version = "0.1.2.2" @@ -7890,10 +7909,10 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] [extras] -all = ["anyrun-sdk", "apiosintds", "assemblyline_client", "backscatter", "blockchain", "censys", "clamd", "crowdstrike-falconpy", "dnsdb2", "domaintools_api", "geoip2", "greynoise", "jbxapi", "lief", "lxml", "maclookup", "matplotlib", "mattermostdriver", "misp-stix", "mwdblib", "ndjson", "numpy", "oauth2", "opencv-python", "openpyxl", "pandas", "pandas_ods_reader", "pandoc", "passivetotal", "pdftotext", "pycountry", "pyeti-python3", "pyeupi", "pygeoip", "pyintel471", "pyipasnhistory", "pymisp", "pypdns", "pypssl", "pysafebrowsing", "pytesseract", "python-docx", "python-pptx", "pyzbar", "requests", "setuptools", "shodan", "sigmatools", "sigmf", "slack-sdk", "socialscan", "sparqlwrapper", "tau-clients", "taxii2-client", "trustar", "urlarchiver", "vt-graph-api", "vt-py", "vulners", "vysion", "wand", "xlrd", "yara-python"] -minimal = ["backscatter", "blockchain", "censys", "clamd", "crowdstrike-falconpy", "dnsdb2", "domaintools_api", "geoip2", "greynoise", "jbxapi", "maclookup", "matplotlib", "mattermostdriver", "ndjson", "oauth2", "passivetotal", "pycountry", "pyeti-python3", "pyeupi", "pygeoip", "pyintel471", "pyipasnhistory", "pypdns", "pypssl", "pysafebrowsing", "requests", "slack-sdk", "socialscan", "urlarchiver", "vt-graph-api", "vt-py", "vulners", "yara-python"] +all = ["anyrun-sdk", "apiosintds", "assemblyline_client", "backscatter", "blockchain", "censys", "clamd", "crowdstrike-falconpy", "dnsdb2", "domaintools_api", "geoip2", "greynoise", "jbxapi", "lief", "lxml", "maclookup", "matplotlib", "mattermostdriver", "misp-stix", "mwdblib", "ndjson", "numpy", "oauth2", "opencv-python", "openpyxl", "pandas", "pandas_ods_reader", "pandoc", "passivetotal", "pdftotext", "pycountry", "pyeti-python3", "pyeupi", "pygeoip", "pyintel471", "pyipasnhistory", "pymisp", "pypdns", "pypssl", "pysafebrowsing", "pytesseract", "python-docx", "python-pptx", "pyzbar", "requests", "rstapi", "setuptools", "shodan", "sigmatools", "sigmf", "slack-sdk", "socialscan", "sparqlwrapper", "tau-clients", "taxii2-client", "trustar", "urlarchiver", "vt-graph-api", "vt-py", "vulners", "vysion", "wand", "xlrd", "yara-python"] +minimal = ["backscatter", "blockchain", "censys", "clamd", "crowdstrike-falconpy", "dnsdb2", "domaintools_api", "geoip2", "greynoise", "jbxapi", "maclookup", "matplotlib", "mattermostdriver", "ndjson", "oauth2", "passivetotal", "pycountry", "pyeti-python3", "pyeupi", "pygeoip", "pyintel471", "pyipasnhistory", "pypdns", "pypssl", "pysafebrowsing", "requests", "rstapi", "slack-sdk", "socialscan", "urlarchiver", "vt-graph-api", "vt-py", "vulners", "yara-python"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "380a4bb70a1fcbc16a4f8092bb827d9bd8f828c7bd249a391e9eb03626b5938c" +content-hash = "4ccfa27333f21a63bb882b6c1feeebf3deca81a602a910954d72922e98ac65e8" From cde2da8be2b2e1209e02f0393cb7853d5e794ad4 Mon Sep 17 00:00:00 2001 From: k1r10n Date: Sun, 28 Jun 2026 00:34:25 +1000 Subject: [PATCH 3/3] black 79 symbols --- .../modules/expansion/_rstcloud/__init__.py | 2 +- .../modules/expansion/_rstcloud/client.py | 200 +++++++++++------- .../modules/expansion/rst_cs_beacon.py | 77 +++++-- misp_modules/modules/expansion/rst_favicon.py | 77 +++++-- misp_modules/modules/expansion/rst_html.py | 77 +++++-- misp_modules/modules/expansion/rst_ioc.py | 195 +++++++++++------ .../modules/expansion/rst_noise_control.py | 133 ++++++++---- .../modules/expansion/rst_screenshot.py | 76 +++++-- misp_modules/modules/expansion/rst_ssl.py | 58 +++-- misp_modules/modules/expansion/rst_whois.py | 60 ++++-- 10 files changed, 668 insertions(+), 287 deletions(-) diff --git a/misp_modules/modules/expansion/_rstcloud/__init__.py b/misp_modules/modules/expansion/_rstcloud/__init__.py index 2ead34b3..7b6d8a06 100644 --- a/misp_modules/modules/expansion/_rstcloud/__init__.py +++ b/misp_modules/modules/expansion/_rstcloud/__init__.py @@ -1,4 +1,4 @@ -"""Shared RST Cloud helpers for the expansion modules (not a registered module).""" +"""Shared RST Cloud helpers for expansion modules (not registered).""" from .client import ( # noqa: F401 apply_to_source_attribute, diff --git a/misp_modules/modules/expansion/_rstcloud/client.py b/misp_modules/modules/expansion/_rstcloud/client.py index 3c0801a4..0884a801 100644 --- a/misp_modules/modules/expansion/_rstcloud/client.py +++ b/misp_modules/modules/expansion/_rstcloud/client.py @@ -30,17 +30,20 @@ def base_url_from_config(config: dict | None) -> str: def rst_kwargs(config: dict | None) -> dict: """Constructor kwargs shared by every rstapi client.""" - return {"APIKEY": api_key_from_config(config), "APIURL": base_url_from_config(config)} + return { + "APIKEY": api_key_from_config(config), + "APIURL": base_url_from_config(config), + } def scan_kwargs(config: dict | None) -> dict: - """Constructor kwargs for rstapi.scan, extending rst_kwargs with an optional read timeout. + """Constructor kwargs for rstapi.scan with optional read timeout. - Scan endpoints (ssl/html/favicon/screenshot/cs-beacon) are synchronous: the RST - Cloud server connects to the target during your request, so they can take much - longer than a database lookup. The default rstapi READ timeout is 20 s, which - is sometimes not enough. Set ``timeout`` in the module config (seconds, default - 60) to override it. + Extends ``rst_kwargs``. Scan endpoints (ssl/html/favicon/screenshot/ + cs-beacon) are synchronous: the RST Cloud server connects to the target + during your request, so they can take much longer than a database lookup. + The default rstapi READ timeout is 20 s, which is sometimes not enough. + Set ``timeout`` in the module config (seconds, default 60) to override it. """ kw = rst_kwargs(config) try: @@ -51,7 +54,7 @@ def scan_kwargs(config: dict | None) -> dict: def value_from_request(request: dict, keys) -> str | None: - """Pull the indicator value from a misp-modules request (attribute or typed). + """Pull the indicator from a misp-modules request (attribute or typed). Handles all three shapes MISP sends: a full ``attribute`` object, a typed top-level key (incl. composites like ``ip-dst|port``), and object-level @@ -72,7 +75,7 @@ def value_from_request(request: dict, keys) -> str | None: def host_only(value): - """Strip a MISP composite ``|port`` suffix, returning the bare host/indicator. + """Strip a MISP composite ``|port`` suffix; return bare host/indicator. Used by the lookup modules (ioc / noise-control / whois) where the API keys on the value itself and the port is irrelevant. @@ -101,9 +104,10 @@ def _has_explicit_port(host: str) -> bool: def _sibling_port(request) -> str | None: """Port taken from a sibling attribute when MISP passes the whole object. - An ``ip-port`` object stores the port as its own attribute (object_relation - ``dst-port`` / ``src-port`` / ``port``); when MISP includes ``object`` in the - request, pick it up so the user doesn't have to set one. + An ``ip-port`` object stores the port as its own attribute + (object_relation ``dst-port`` / ``src-port`` / ``port``); when MISP + includes ``object`` in the request, pick it up so the user doesn't have + to set one. """ obj = request.get("object") if not isinstance(obj, dict): @@ -116,16 +120,24 @@ def _sibling_port(request) -> str | None: return None -def scan_target(request, inputs, config, *, as_url=False, default_port=None, default_scheme="https"): - """Build a Scan-API target from a MISP attribute, honouring an optional port. +def scan_target( + request, + inputs, + config, + *, + as_url=False, + default_port=None, + default_scheme="https", +): + """Build a Scan-API target from a MISP attribute with optional port. IP/host attributes carry no port, but the Scan API addresses a *service*: - ``host:port`` for ssl / cs-beacon / favicon, or a URL for html / screenshot. - Port resolution, most specific first: + ``host:port`` for ssl / cs-beacon / favicon, or a URL for html / + screenshot. Port resolution, most specific first: 1. an explicit port already in the value (``1.2.3.4:8443`` or a URL), - 2. a MISP ``host|port`` composite value (e.g. an ``ip-dst|port`` attribute), - 3. a sibling port attribute in the same MISP object (``ip-port`` object), + 2. a MISP ``host|port`` composite (e.g. an ``ip-dst|port`` attribute), + 3. a sibling port attribute in the same MISP object (``ip-port``), 4. the optional ``port`` set in the module config, 5. ``default_port`` (module-specific fallback, may be ``None``). @@ -173,10 +185,11 @@ def error(message: str) -> dict: return {"error": message} -# Threat-suffix → (built-in MISP galaxy predicate, RST library galaxy stix_type). -# Kept in sync with rstmisp.misp.tagging; duplicated here so the modules stay -# droppable into misp-modules standalone. The 2nd element selects which RST custom -# galaxy (rst-) a name belongs to when resolving the real cluster tag. +# Threat-suffix -> (built-in MISP galaxy predicate, RST galaxy stix_type). +# Kept in sync with rstmisp.misp.tagging; duplicated here so the modules +# stay droppable into misp-modules standalone. The 2nd element selects which +# RST custom galaxy (rst-) a name belongs to when resolving the +# real cluster tag. _THREAT_SUFFIX = { "_group": ("misp-galaxy:threat-actor", "intrusion-set"), "_actor": ("misp-galaxy:threat-actor", "intrusion-set"), @@ -193,11 +206,17 @@ def error(message: str) -> dict: # Names with no recognised suffix are malware families. _THREAT_DEFAULT = ("misp-galaxy:malware", "malware") -# RST custom galaxy types in MISP (namespace rstcloud); galaxy stix_type = type[4:]. -_RST_GALAXY_TYPES = ("rst-malware", "rst-tool", "rst-intrusion-set", "rst-campaign") +# RST custom galaxy types in MISP (namespace rstcloud); stix_type = type[4:]. +_RST_GALAXY_TYPES = ( + "rst-malware", + "rst-tool", + "rst-intrusion-set", + "rst-campaign", +) # Per-process caches: a misp-modules worker is long-lived, so reuse the PyMISP -# client + resolved galaxy ids across calls instead of reconnecting every hover. +# client + resolved galaxy ids across calls instead of reconnecting on every +# hover. _RESOLVER_CACHE: dict = {} @@ -210,12 +229,14 @@ def _truthy(v) -> bool: class _RstClusterResolver: """Resolve an RST threat ``(stix_type, name)`` to its MISP cluster's real ``tag_name`` (``misp-galaxy:rst-*=""``), so an enrichment tag - attaches the RST Threat Library galaxy — the same node the library/reports/ - feed connectors use. MISP stores a CUSTOM cluster's tag keyed on the UUID, not - the name, so the value-form ``rstcloud:rst-*="name"`` would not link. - - Targeted ``search_galaxy_clusters`` per name (a handful per enrichment call), - not a full galaxy pull; per-name results are memoised on the instance. + attaches the RST Threat Library galaxy — the same node the library/ + reports/ feed connectors use. MISP stores a CUSTOM cluster's tag keyed on + the UUID, not the name, so the value-form ``rstcloud:rst-*="name"`` + would not link. + + Targeted ``search_galaxy_clusters`` per name (a handful per enrichment + call), not a full galaxy pull; per-name results are memoised on the + instance. """ def __init__(self, misp, galaxy_ids: dict): @@ -247,7 +268,10 @@ def _lookup(self, gid, name_lower): if (gc.get("value") or "").lower() == name_lower: return tname for el in gc.get("GalaxyElement") or []: - if el.get("key") == "synonyms" and (el.get("value") or "").lower() == name_lower: + if ( + el.get("key") == "synonyms" + and (el.get("value") or "").lower() == name_lower + ): return tname return None @@ -255,10 +279,10 @@ def _lookup(self, gid, name_lower): def rst_resolver_from_config(config: dict | None): """Build an RST cluster resolver from optional MISP config, or None. - Needs ``misp_url`` + ``misp_key`` in the module config; without them (the - default standalone deployment) returns None and ``threat_tags`` falls back to - built-in galaxy tags. PyMISP is imported lazily so the modules still install - with just ``rstapi`` when MISP resolution isn't configured. + Needs ``misp_url`` + ``misp_key`` in the module config; without them + (the default standalone deployment) returns None and ``threat_tags`` falls + back to built-in galaxy tags. PyMISP is imported lazily so the modules + still install with just ``rstapi`` when MISP resolution isn't configured. """ config = config or {} url = config.get("misp_url") @@ -272,7 +296,9 @@ def rst_resolver_from_config(config: dict | None): except Exception: return None try: - misp = PyMISP(url, key, ssl=_truthy(config.get("misp_verifycert", False))) + misp = PyMISP( + url, key, ssl=_truthy(config.get("misp_verifycert", False)) + ) ids = {} for g in misp.galaxies(pythonify=False) or []: gd = g.get("Galaxy", g) @@ -302,7 +328,8 @@ def threat_tags(threats, rst_resolver=None) -> list: name = threat for suffix, (pred, st) in _THREAT_SUFFIX.items(): if threat.endswith(suffix): - predicate, stix_type, name = pred, st, threat[: -len(suffix)] + n = len(suffix) + predicate, stix_type, name = pred, st, threat[:-n] break clean = name.replace("_", " ") tag = None @@ -316,17 +343,18 @@ def threat_tags(threats, rst_resolver=None) -> list: def scan_group(request, source): - """uuid that scan-result objects should reference, so each result stays tied + """uuid scan-result objects should reference so each result stays tied to exactly what was enriched — without spawning extra container objects. 1. the parent object, when MISP includes it in the request (``object``); 2. otherwise the enriched source attribute itself. A screenshot / certificate / fetched body cannot be an *attribute* of a - ``url`` / ``ip-port`` / ``domain-ip`` object — MISP object templates are fixed - and have no such relation — so each is returned as its own object that - references this anchor (``identifies`` / ``screenshot-of`` / …). Returns the - anchor uuid, or ``None`` (typed-key request with no attribute to point at). + ``url`` / ``ip-port`` / ``domain-ip`` object — MISP object templates are + fixed and have no such relation — so each is returned as its own object + that references this anchor (``identifies`` / ``screenshot-of`` / …). + Returns the anchor uuid, or ``None`` (typed-key request with no attribute + to point at). """ obj = request.get("object") if isinstance(obj, dict) and obj.get("uuid"): @@ -337,9 +365,9 @@ def scan_group(request, source): def misp_event_with_source(request): """Start a ``MISPEvent`` seeded with the triggering attribute. - Returns ``(event, source_attribute_or_None)``. Enrichment objects/attributes - added to the event can ``add_reference(source.uuid, ...)`` so MISP links them - to the attribute the analyst enriched. Requires pymisp, which is always + Returns ``(event, source_attribute_or_None)``. Enrichment objects/ + attributes added to the event can ``add_reference(source.uuid, ...)`` so + MISP links them to the attribute the analyst enriched. Requires pymisp, present in a misp-modules deployment (it's a core dependency). """ from pymisp import MISPAttribute, MISPEvent @@ -358,10 +386,11 @@ def new_enrichment_object(name): """Build a ``MISPObject`` for an RST enrichment template. Returns ``(object, dedicated)``. Uses the ``rst-*`` template from the MISP - object library (install via [MISP/misp-objects](https://github.com/MISP/misp-objects), - e.g. [PR #526](https://github.com/MISP/misp-objects/pull/526)). Falls back to - a generic ``annotation`` object if the template is not installed yet, so output - stays valid misp_standard on any MISP. + object library (install via + [MISP/misp-objects](https://github.com/MISP/misp-objects), e.g. + [PR #526](https://github.com/MISP/misp-objects/pull/526)). Falls back to + a generic ``annotation`` object if the template is not installed yet, so + output stays valid misp_standard on any MISP. """ from pymisp import MISPObject @@ -375,13 +404,17 @@ def new_enrichment_object(name): def standard_results(event) -> dict: - """Serialise a ``MISPEvent`` into the misp_standard expansion result envelope.""" + """Serialise a ``MISPEvent`` into the misp_standard result envelope.""" parsed = json.loads(event.to_json()) - return {"results": {k: parsed[k] for k in ("Attribute", "Object") if parsed.get(k)}} + return { + "results": { + k: parsed[k] for k in ("Attribute", "Object") if parsed.get(k) + } + } def text_result(value: str, comment: str = "") -> dict: - """A misp_standard 'nothing structured to return' fallback (one text attribute).""" + """misp_standard fallback when nothing structured to return (one text).""" attr = {"type": "text", "value": value} if comment: attr["comment"] = comment @@ -394,8 +427,9 @@ def text_result(value: str, comment: str = "") -> dict: def _pymisp(cfg): """Cached PyMISP client from module config (misp_url/misp_key), or None. - Reused across calls (a misp-modules worker is long-lived). Returns None when - creds are absent or PyMISP can't connect, so callers degrade gracefully. + Reused across calls (a misp-modules worker is long-lived). Returns None + when creds are absent or PyMISP can't connect, so callers degrade + gracefully. """ url, key = cfg.get("misp_url"), cfg.get("misp_key") if not (url and key): @@ -405,6 +439,7 @@ def _pymisp(cfg): return _PYMISP_CACHE[ck] try: from pymisp import PyMISP + client = PyMISP(url, key, ssl=ck[2]) except Exception: return None @@ -412,25 +447,36 @@ def _pymisp(cfg): return client -def apply_to_source_attribute(config, request, *, tags=None, comment_note=None, - comment_prefix=None, replace_tag_prefixes=(), - set_to_ids=None, fp_sightings=0): +def apply_to_source_attribute( + config, + request, + *, + tags=None, + comment_note=None, + comment_prefix=None, + replace_tag_prefixes=(), + set_to_ids=None, + fp_sightings=0, +): """Write enrichment back ONTO the enriched attribute via the MISP API. - MISP enrichment itself can only ADD new attributes/objects — it can't modify - the attribute you ran the module on. So, *only when* ``misp_url``/``misp_key`` - are set in the module config, this updates the source attribute in place: + MISP enrichment itself can only ADD new attributes/objects — it can't + modify the attribute you ran the module on. So, *only when* + ``misp_url``/``misp_key`` are set in the module config, this updates the + source attribute in place: - * removes the module's own prior tags (``replace_tag_prefixes``) then adds - ``tags`` — so re-running replaces rather than stacks verdicts; - * appends ``comment_note`` to the existing comment (dropping any previous - note that started with ``comment_prefix``, so re-runs stay tidy); + * removes the module's own prior tags (``replace_tag_prefixes``) then + adds ``tags`` — so re-running replaces rather than stacks verdicts; + * appends ``comment_note`` to the existing comment (dropping any + previous note that started with ``comment_prefix``, so re-runs stay + tidy); * sets ``to_ids`` when ``set_to_ids`` is not None; - * adds ``fp_sightings`` false-positive sightings (type 1) — a benign signal - that feeds MISP's decay/scoring. + * adds ``fp_sightings`` false-positive sightings (type 1) — a benign + signal that feeds MISP's decay/scoring. - Returns True if it wrote back (caller should then return an empty result so no - duplicate attribute is created); False otherwise (caller returns normally). + Returns True if it wrote back (caller should then return an empty result + so no duplicate attribute is created); False otherwise (caller returns + normally). """ cfg = config or {} attr = request.get("attribute") or {} @@ -445,9 +491,14 @@ def apply_to_source_attribute(config, request, *, tags=None, comment_note=None, try: changed = False if comment_note is not None: - existing = (getattr(full, "comment", None) or attr.get("comment") or "") - segments = [s for s in existing.split(" | ") - if s and not (comment_prefix and s.startswith(comment_prefix))] + existing = ( + getattr(full, "comment", None) or attr.get("comment") or "" + ) + segments = [ + s + for s in existing.split(" | ") + if s and not (comment_prefix and s.startswith(comment_prefix)) + ] segments.append(comment_note) full.comment = " | ".join(segments) changed = True @@ -465,9 +516,12 @@ def apply_to_source_attribute(config, request, *, tags=None, comment_note=None, misp.tag(uuid, tag) if fp_sightings: from pymisp import MISPSighting + for _ in range(int(fp_sightings)): sighting = MISPSighting() - sighting.from_dict(type="1", source="RST Noise Control") # 1 = false-positive + sighting.from_dict( + type="1", source="RST Noise Control" + ) # 1 = false-positive misp.add_sighting(sighting, attribute=uuid) return True except Exception: diff --git a/misp_modules/modules/expansion/rst_cs_beacon.py b/misp_modules/modules/expansion/rst_cs_beacon.py index 11fd1606..c2e7ccfa 100644 --- a/misp_modules/modules/expansion/rst_cs_beacon.py +++ b/misp_modules/modules/expansion/rst_cs_beacon.py @@ -1,4 +1,4 @@ -"""rst_cs_beacon — scan a target for a Cobalt Strike beacon (GET /scan/cs-beacon).""" +"""rst_cs_beacon — scan for Cobalt Strike beacon (GET /scan/cs-beacon).""" from __future__ import annotations @@ -20,8 +20,17 @@ misperrors = {"error": "Error"} -_INPUTS = ["ip-dst", "ip-src", "url", "domain", "hostname", - "ip-dst|port", "ip-src|port", "hostname|port", "domain|port"] +_INPUTS = [ + "ip-dst", + "ip-src", + "url", + "domain", + "hostname", + "ip-dst|port", + "ip-src|port", + "hostname|port", + "domain|port", +] # misp_standard: on a hit, return the beacon blob sha256(s) as pivotable # attributes tagged to the Cobalt Strike galaxy. mispattributes = {"input": _INPUTS, "format": "misp_standard"} @@ -29,20 +38,32 @@ moduleinfo = { "version": "0.2", "author": "RST Cloud", - "description": "Scan a target IP[:port] for a Cobalt Strike beacon configuration via RST Scan API.", + "description": ( + "Scan a target IP[:port] for a Cobalt Strike beacon configuration" + " via RST Scan API." + ), "module-type": ["expansion"], "name": "RST Cloud Cobalt Strike Beacon", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Probes the target for Cobalt Strike beacon configurations via RST Scan " - "GET /scan/cs-beacon. On a hit, returns file MISP object(s) with pivotable " - "SHA-256 hashes tagged to the Cobalt Strike galaxy." + "Probes the target for Cobalt Strike beacon configurations via RST" + " Scan GET /scan/cs-beacon. On a hit, returns file MISP object(s)" + " with pivotable SHA-256 hashes tagged to the Cobalt Strike" + " galaxy." + ), + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], + "input": ( + "IP, URL, domain, or hostname attribute (optional port via config)." + ), + "output": ( + "file MISP object(s) with beacon hashes and Cobalt Strike galaxy tag." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], - "input": "IP, URL, domain, or hostname attribute (optional port via config).", - "output": "file MISP object(s) with beacon hashes and Cobalt Strike galaxy tag.", } -# 'port' (optional): port to probe when the attribute carries none (default 443). +# 'port' (optional): port to probe when the attribute carries none +# (default 443). moduleconfig = ["api_key", "base_url", "port", "timeout"] _CS_TAG = 'misp-galaxy:tool="Cobalt Strike"' @@ -74,7 +95,10 @@ def handler(q=False): request = json.loads(q) config = request.get("config") if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) target = scan_target(request, _INPUTS, config, default_port=443) if not target: return error("No target found in the request.") @@ -83,16 +107,23 @@ def handler(q=False): if err: return error(f"RST CS beacon scan failed: {err}") if not isinstance(data, dict) or not data: - return text_result(f"{target}: no Cobalt Strike beacon found", "RST CS Beacon") + return text_result( + f"{target}: no Cobalt Strike beacon found", "RST CS Beacon" + ) # The scanner ALWAYS returns x86/x64 probe blocks; an actual beacon is only # present when a block carries a parsed `config` (or a non-zero `size`). An # empty config / size 0 means "probed, nothing found" — NOT a detection. blocks = {"x86": _arch(data.get("x86")), "x64": _arch(data.get("x64"))} - hits = {arch: b for arch, b in blocks.items() - if b.get("config") or _to_int(b.get("size")) > 0} + hits = { + arch: b + for arch, b in blocks.items() + if b.get("config") or _to_int(b.get("size")) > 0 + } if not hits: - return text_result(f"{target}: no Cobalt Strike beacon detected", "RST CS Beacon") + return text_result( + f"{target}: no Cobalt Strike beacon detected", "RST CS Beacon" + ) from pymisp import MISPObject @@ -105,8 +136,9 @@ def handler(q=False): if not sha or sha in seen: continue seen.add(sha) - # The beacon payload is a file; group its hash + config as a file object - # so the detection is tied to the scanned host, not a loose sha256. + # The beacon payload is a file; group its hash + config as a file + # object so the detection is tied to the scanned host, not a loose + # sha256. fobj = MISPObject("file") sha_attr = fobj.add_attribute("sha256", value=sha) sha_attr.add_tag(_CS_TAG) # tags attach to attributes, not objects @@ -115,8 +147,13 @@ def handler(q=False): if block.get("size"): fobj.add_attribute("size-in-bytes", value=block["size"]) cfg = block.get("config") or {} - fobj.add_attribute("text", value=f"Cobalt Strike beacon ({arch}) on {target}; " - f"config: {json.dumps(cfg)[:400]}") + fobj.add_attribute( + "text", + value=( + f"Cobalt Strike beacon ({arch}) on {target}; " + f"config: {json.dumps(cfg)[:400]}" + ), + ) fobj.comment = "RST CS Beacon" if anchor: fobj.add_reference(anchor, "characterizes") diff --git a/misp_modules/modules/expansion/rst_favicon.py b/misp_modules/modules/expansion/rst_favicon.py index baf48792..cfcd8b80 100644 --- a/misp_modules/modules/expansion/rst_favicon.py +++ b/misp_modules/modules/expansion/rst_favicon.py @@ -1,4 +1,4 @@ -"""rst_favicon — favicon hashes + image as a file object (GET /scan/favicon).""" +"""rst_favicon — favicon hashes as a file object (GET /scan/favicon).""" from __future__ import annotations @@ -23,27 +23,45 @@ misperrors = {"error": "Error"} -_INPUTS = ["url", "domain", "hostname", "ip-src", "ip-dst", - "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] -# misp_standard: file object (md5/sha1/sha256 pivotable in Netlas/Censys) plus a -# standalone favicon_hash attribute (Murmur3/MMH3, pivotable in Shodan/FOFA). +_INPUTS = [ + "url", + "domain", + "hostname", + "ip-src", + "ip-dst", + "ip-src|port", + "ip-dst|port", + "hostname|port", + "domain|port", +] +# misp_standard: file object (md5/sha1/sha256 pivotable in Netlas/Censys) +# plus a standalone favicon_hash attribute (Murmur3/MMH3, Shodan/FOFA). mispattributes = {"input": _INPUTS, "format": "misp_standard"} moduleinfo = { "version": "0.3", "author": "RST Cloud", - "description": "Fetch a target's favicon (image + all hashes for Shodan/Netlas/Censys pivoting) via RST Scan API.", + "description": ( + "Fetch a target's favicon (image + all hashes for" + " Shodan/Netlas/Censys pivoting) via RST Scan API." + ), "module-type": ["expansion"], "name": "RST Cloud Favicon", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Retrieves the favicon image and cryptographic hashes via RST Scan GET " - "/scan/favicon. Returns a file MISP object with MD5/SHA-1/SHA-256 for Censys/Netlas pivoting and a " - "standalone Murmur3 favicon-hash attribute for Shodan/FOFA-style pivoting." + "Retrieves the favicon image and cryptographic hashes via RST Scan" + " GET /scan/favicon. Returns a file MISP object with" + " MD5/SHA-1/SHA-256 for Censys/Netlas pivoting and a standalone" + " Murmur3 favicon-hash attribute for Shodan/FOFA-style pivoting." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], "input": "URL, domain, hostname, or IP attribute.", - "output": "file MISP object, favicon-hash attribute, and resolved favicon URL.", + "output": ( + "file MISP object, favicon-hash attribute, and resolved favicon URL." + ), } moduleconfig = ["api_key", "base_url", "timeout"] @@ -63,7 +81,10 @@ def handler(q=False): request = json.loads(q) config = request.get("config") if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) # Favicon endpoint expects a bare host or a full URL — never host:port. # The API fetches the page over HTTP/HTTPS itself; adding ":443" breaks it. raw = value_from_request(request, _INPUTS) @@ -71,7 +92,11 @@ def handler(q=False): return error("No target found in the request.") target = raw if raw.startswith(("http://", "https://")) else host_only(raw) - data, err = unwrap(rstapi.scan(**scan_kwargs(config)).GetFavicon(target, include_base64=True)) + data, err = unwrap( + rstapi.scan(**scan_kwargs(config)).GetFavicon( + target, include_base64=True + ) + ) if err: return error(f"RST favicon scan failed: {err}") if not isinstance(data, dict) or not data.get("favicon_hash"): @@ -84,7 +109,9 @@ def handler(q=False): content_type = data.get("req_content_type") or "image/x-icon" # Real filename from the resolved favicon URL (e.g. "drive_2026_32dp.ico") - raw_fname = req_loc.rstrip("/").split("/")[-1].split("?")[0] if req_loc else "" + raw_fname = ( + req_loc.rstrip("/").split("/")[-1].split("?")[0] if req_loc else "" + ) fname = raw_fname if (raw_fname and "." in raw_fname) else "favicon.ico" event, source = misp_event_with_source(request) @@ -101,7 +128,11 @@ def handler(q=False): # Attach the raw image when the API returned base64 try: - raw = base64.b64decode(data["base64_image"]) if data.get("base64_image") else None + raw = ( + base64.b64decode(data["base64_image"]) + if data.get("base64_image") + else None + ) except Exception: raw = None if raw: @@ -114,12 +145,18 @@ def handler(q=False): # Resolved favicon URL — where the image actually lives after redirects if req_loc: - event.add_attribute("link", req_loc, comment="RST Favicon resolved URL", to_ids=False) - - # favicon_hash (Murmur3/MMH3): standalone attribute so it correlates independently - # across events and is searchable in Shodan/FOFA-style hunting workflows. + event.add_attribute( + "link", + req_loc, + comment="RST Favicon resolved URL", + to_ids=False, + ) + + # favicon_hash (Murmur3/MMH3): standalone attribute for independent + # correlation across events and Shodan/FOFA-style hunting workflows. fav_attr = event.add_attribute( - "other", fhash, + "other", + fhash, comment=f"Murmur3 favicon hash for {target} (Shodan/FOFA pivot)", to_ids=False, ) diff --git a/misp_modules/modules/expansion/rst_html.py b/misp_modules/modules/expansion/rst_html.py index a850fd73..ab7471d8 100644 --- a/misp_modules/modules/expansion/rst_html.py +++ b/misp_modules/modules/expansion/rst_html.py @@ -1,6 +1,7 @@ -"""rst_html — fetch rendered HTML body / extracted JS as an attachment (GET /scan/html/body[/js]). +"""rst_html — fetch HTML/JS as attachment (GET /scan/html/body[/js]). -Target format: host:port (e.g. drive.google.com:443). Full URLs pass through unchanged. +Target format: host:port (e.g. drive.google.com:443). Full URLs pass through +unchanged. """ from __future__ import annotations @@ -24,28 +25,50 @@ misperrors = {"error": "Error"} -_INPUTS = ["url", "domain", "hostname", "ip-src", "ip-dst", - "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +_INPUTS = [ + "url", + "domain", + "hostname", + "ip-src", + "ip-dst", + "ip-src|port", + "ip-dst|port", + "hostname|port", + "domain|port", +] # misp_standard: return the fetched body as a downloadable attachment. mispattributes = {"input": _INPUTS, "format": "misp_standard"} moduleinfo = { "version": "0.2", "author": "RST Cloud", - "description": "Fetch rendered HTML body or extracted JavaScript for a URL/IP target via RST Scan API.", + "description": ( + "Fetch rendered HTML body or extracted JavaScript for a URL/IP" + " target via RST Scan API." + ), "module-type": ["expansion"], "name": "RST Cloud HTML Fetcher", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Fetches the rendered HTML body or extracted JavaScript from the target " - "via RST Scan. Returns a file MISP object with the page attached and " - "pivotable content hashes. Configurable mode: body (default) or js." + "Fetches the rendered HTML body or extracted JavaScript from the" + " target via RST Scan. Returns a file MISP object with the page" + " attached and pivotable content hashes. Configurable mode: body" + " (default) or js." + ), + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], + "input": ( + "URL, domain, hostname, or IP attribute (optional port via config)." + ), + "output": ( + "file MISP object (page.html or page.js) with hashes and HTTP" + " metadata." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], - "input": "URL, domain, hostname, or IP attribute (optional port via config).", - "output": "file MISP object (page.html or page.js) with hashes and HTTP metadata.", } -# 'mode' = body | js (default body). 'port' (optional): override default port 443. +# 'mode' = body | js (default body). 'port' (optional): override default +# port 443. moduleconfig = ["api_key", "base_url", "mode", "port", "timeout"] @@ -64,7 +87,10 @@ def handler(q=False): request = json.loads(q) config = request.get("config") or {} if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) target = scan_target(request, _INPUTS, config, default_port=443) if not target: return error("No target found in the request.") @@ -76,7 +102,11 @@ def handler(q=False): if err: return error(f"RST HTML fetch failed: {err}") - body = data.get("body") if isinstance(data, dict) else (data if isinstance(data, str) else "") + body = ( + data.get("body") + if isinstance(data, dict) + else (data if isinstance(data, str) else "") + ) if not body: return text_result(f"{target}: empty response", "RST HTML Fetcher") @@ -87,16 +117,25 @@ def handler(q=False): event, source = misp_event_with_source(request) anchor = scan_group(request, source) - # The fetched body IS a file: group it (attachment + pivotable body hashes + + # The fetched body IS a file: group it (attachment + pivotable hashes + # response metadata) in a `file` object rather than a lone size string. filename = "page.js" if is_js else "page.html" label = "extracted JavaScript" if is_js else "HTML body" fobj = MISPObject("file") - fobj.add_attribute("attachment", value=filename, - data=BytesIO(body.encode("utf-8", "replace")), to_ids=False) + fobj.add_attribute( + "attachment", + value=filename, + data=BytesIO(body.encode("utf-8", "replace")), + to_ids=False, + ) fobj.add_attribute("filename", value=filename) - fobj.add_attribute("mimetype", value="application/javascript" if is_js else "text/html") - fobj.add_attribute("size-in-bytes", value=meta.get("content_length") or len(body)) + fobj.add_attribute( + "mimetype", + value="application/javascript" if is_js else "text/html", + ) + fobj.add_attribute( + "size-in-bytes", value=meta.get("content_length") or len(body) + ) for htype in ("md5", "sha1", "sha256"): if hashes.get(htype): fobj.add_attribute(htype, value=hashes[htype]) diff --git a/misp_modules/modules/expansion/rst_ioc.py b/misp_modules/modules/expansion/rst_ioc.py index 62db8ae3..73e452c2 100644 --- a/misp_modules/modules/expansion/rst_ioc.py +++ b/misp_modules/modules/expansion/rst_ioc.py @@ -1,4 +1,4 @@ -"""rst_ioc — enrich an indicator with RST Cloud threat intelligence (GET /ioc).""" +"""rst_ioc — enrich an indicator with RST threat intel (GET /ioc).""" from __future__ import annotations @@ -25,36 +25,64 @@ misperrors = {"error": "Error"} -_INPUTS = ["ip-src", "ip-dst", "domain", "hostname", "url", "md5", "sha1", "sha256", - "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +_INPUTS = [ + "ip-src", + "ip-dst", + "domain", + "hostname", + "url", + "md5", + "sha1", + "sha256", + "ip-src|port", + "ip-dst|port", + "hostname|port", + "domain|port", +] mispattributes = {"input": _INPUTS, "format": "misp_standard"} moduleinfo = { "version": "0.4", "author": "RST Cloud", "description": ( - "Enrich indicators with RST Cloud threat intelligence. " - "Returns an rst-ioc object (score, attribution, geo/ASN for IPs, " - "DNS/WHOIS for domains, parsed components for URLs, related hashes for " - "file hashes) linked back to the enriched attribute." + "Enrich indicators with RST Cloud threat intelligence. Returns an" + " rst-ioc object (score, attribution, geo/ASN for IPs, DNS/WHOIS" + " for domains, parsed components for URLs, related hashes for file" + " hashes) linked back to the enriched attribute." ), "module-type": ["expansion", "hover"], "name": "RST Cloud IoC Lookup", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Queries RST Cloud GET /ioc for threat scores, attribution, geo/ASN, DNS, " - "WHOIS, TTPs, CVEs, and related indicators. Returns a structured rst-ioc " - "MISP object with galaxy tags and optional pivotable related hashes/IPs. " - "When misp_url and misp_key are configured, also writes score/threat tags " - "onto the enriched attribute via the MISP API." + "Queries RST Cloud GET /ioc for threat scores, attribution," + " geo/ASN, DNS, WHOIS, TTPs, CVEs, and related indicators. Returns" + " a structured rst-ioc MISP object with galaxy tags and optional" + " pivotable related hashes/IPs. When misp_url and misp_key are" + " configured, also writes score/threat tags onto the enriched" + " attribute via the MISP API." + ), + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], + "input": ( + "IP, domain, hostname, URL, or hash attribute (incl. host|port" + " composites)." + ), + "output": ( + "rst-ioc MISP object, galaxy/score tags, and optional related" + " attributes." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], - "input": "IP, domain, hostname, URL, or hash attribute (incl. host|port composites).", - "output": "rst-ioc MISP object, galaxy/score tags, and optional related attributes.", } # misp_url/misp_key (optional): when set, tags + score note are also written -# directly onto the enriched attribute via the MISP API (like rst_noise_control). -moduleconfig = ["api_key", "base_url", "misp_url", "misp_key", "misp_verifycert"] +# directly onto the enriched attribute via the MISP API (like rst_noise). +moduleconfig = [ + "api_key", + "base_url", + "misp_url", + "misp_key", + "misp_verifycert", +] _HASH_TYPES = {"md5", "sha1", "sha256"} @@ -71,7 +99,9 @@ def version(): def _ts(val) -> str: """Unix timestamp string/int -> YYYY-MM-DD (UTC), or empty string.""" try: - return datetime.fromtimestamp(int(val), tz=timezone.utc).strftime("%Y-%m-%d") + return datetime.fromtimestamp(int(val), tz=timezone.utc).strftime( + "%Y-%m-%d" + ) except (TypeError, ValueError, OSError): return "" @@ -84,7 +114,12 @@ def _f(val, precision=1) -> str: def _known(v) -> bool: - return bool(v) and str(v).strip().lower() not in ("", "none", "null", "n/a") + return bool(v) and str(v).strip().lower() not in ( + "", + "none", + "null", + "n/a", + ) def handler(q=False): @@ -93,23 +128,30 @@ def handler(q=False): request = json.loads(q) config = request.get("config") if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) value = host_only(value_from_request(request, _INPUTS)) if not value: return error("No supported indicator value found in the request.") # /ioc always returns HTTP 200; a miss carries an "error" key and no "id". - data, err = unwrap(rstapi.ioclookup(**rst_kwargs(config)).GetIndicator(value)) + data, err = unwrap( + rstapi.ioclookup(**rst_kwargs(config)).GetIndicator(value) + ) if err: return error(f"RST Cloud lookup failed: {err}") if not isinstance(data, dict) or data.get("error") or not data.get("id"): - return text_result(f"{value}: not found in RST Cloud", "RST IoC Lookup") + return text_result( + f"{value}: not found in RST Cloud", "RST IoC Lookup" + ) - ioc_type = (data.get("ioc_type") or "").lower() - is_ip = ioc_type in ("ipv4", "ipv6") + ioc_type = (data.get("ioc_type") or "").lower() + is_ip = ioc_type in ("ipv4", "ipv6") is_domain = ioc_type == "domain" - is_url = ioc_type == "url" - is_hash = ioc_type in _HASH_TYPES + is_url = ioc_type == "url" + is_hash = ioc_type in _HASH_TYPES score_block = data.get("score") or {} total = score_block.get("total") @@ -117,34 +159,34 @@ def handler(q=False): total_int = int(float(str(total))) except (TypeError, ValueError): total_int = None - conf_sub = _f(score_block.get("tags"), 2) # context sub-score + conf_sub = _f(score_block.get("tags"), 2) # context sub-score relev_sub = _f(score_block.get("frequency"), 2) # relevance sub-score - threats = data.get("threat") or [] - tags_str = (data.get("tags") or {}).get("str") or [] - ttp = data.get("ttp") or [] - cve = data.get("cve") or [] - industry = data.get("industry") or [] - fp = data.get("fp") or {} - fp_alarm = str(fp.get("alarm") or "").strip().lower() + threats = data.get("threat") or [] + tags_str = (data.get("tags") or {}).get("str") or [] + ttp = data.get("ttp") or [] + cve = data.get("cve") or [] + industry = data.get("industry") or [] + fp = data.get("fp") or {} + fp_alarm = str(fp.get("alarm") or "").strip().lower() fp_flagged = fp_alarm in ("true", "possible") - geo = data.get("geo") or {} - asn_blk = data.get("asn") or {} - src_blk = data.get("src") or {} - resolved = data.get("resolved") or {} - parsed = data.get("parsed") or {} - fseen = _ts(data.get("fseen")) - lseen = _ts(data.get("lseen")) + geo = data.get("geo") or {} + asn_blk = data.get("asn") or {} + src_blk = data.get("src") or {} + resolved = data.get("resolved") or {} + parsed = data.get("parsed") or {} + fseen = _ts(data.get("fseen")) + lseen = _ts(data.get("lseen")) # ------------------------------------------------------------------------- # Derive the type-specific context strings once; reused for both the typed # rst-ioc object and the annotation fallback text. # ------------------------------------------------------------------------- geo_str = asn_str = whois_str = http_status = "" - dns_records: list[str] = [] # ["A: 1.2.3.4", "CNAME: ..."] - resolved_ips: list[str] = [] # pivotable A-record IPs (domains) + dns_records: list[str] = [] # ["A: 1.2.3.4", "CNAME: ..."] + resolved_ips: list[str] = [] # pivotable A-record IPs (domains) url_parts: list[str] = [] - filenames = [f for f in (data.get("filename") or []) if _known(f)] + filenames = [f for f in data.get("filename") or [] if _known(f)] if is_ip: if geo.get("country"): @@ -163,9 +205,9 @@ def handler(q=False): if is_domain: res_ip = resolved.get("ip") or {} - a_records = [r for r in (res_ip.get("a") or []) if _known(r)] - cnames = [r for r in (res_ip.get("cname") or []) if _known(r)] - aliases = [r for r in (res_ip.get("alias") or []) if _known(r)] + a_records = [r for r in res_ip.get("a") or [] if _known(r)] + cnames = [r for r in res_ip.get("cname") or [] if _known(r)] + aliases = [r for r in res_ip.get("alias") or [] if _known(r)] resolved_ips = a_records if a_records: dns_records.append("A: " + ", ".join(a_records)) @@ -194,7 +236,11 @@ def handler(q=False): if is_url: if _known(parsed.get("domain")): url_parts.append(f"domain: {parsed['domain']}") - if _known(parsed.get("path")) and parsed.get("path") not in ("/", "None", "none"): + if _known(parsed.get("path")) and parsed.get("path") not in ( + "/", + "None", + "none", + ): url_parts.append(f"path: {parsed['path']}") if _known(parsed.get("port")) and parsed.get("port") != "None": url_parts.append(f"port: {parsed['port']}") @@ -241,8 +287,11 @@ def handler(q=False): if http_status: lines.append("HTTP status: " + http_status) if is_hash: - hash_parts = [f"{h.upper()}: {data[h]}" for h in ("md5", "sha1", "sha256") - if _known(data.get(h))] + hash_parts = [ + f"{h.upper()}: {data[h]}" + for h in ("md5", "sha1", "sha256") + if _known(data.get(h)) + ] if hash_parts: lines.append("Hashes: " + ", ".join(hash_parts)) if filenames: @@ -290,7 +339,9 @@ def handler(q=False): if dedicated: tag_target = None if total_int is not None: - tag_target = obj.add_attribute("score-total", value=str(total_int), to_ids=False) + tag_target = obj.add_attribute( + "score-total", value=str(total_int), to_ids=False + ) if conf_sub: obj.add_attribute("score-confidence", value=conf_sub, to_ids=False) if relev_sub: @@ -311,7 +362,9 @@ def handler(q=False): for t in tags_str: obj.add_attribute("tag", value=t, to_ids=False) if fp_flagged: - fp_val = fp_alarm + (f" - {fp['descr']}" if fp.get("descr") else "") + fp_val = fp_alarm + ( + f" - {fp['descr']}" if fp.get("descr") else "" + ) obj.add_attribute("false-positive", value=fp_val, to_ids=False) if geo_str: obj.add_attribute("geo", value=geo_str, to_ids=False) @@ -326,14 +379,21 @@ def handler(q=False): for fn in filenames: obj.add_attribute("filename", value=fn, to_ids=False) if data.get("description"): - obj.add_attribute("description", value=data["description"], to_ids=False) + obj.add_attribute( + "description", value=data["description"], to_ids=False + ) for ref in ref_urls: obj.add_attribute("ref", value=ref, to_ids=False) - # Fall back to a text attribute as the tag anchor if nothing else exists. - tag_target = tag_target or obj.add_attribute("description", value="\n".join(lines), to_ids=False) + # Fall back to a text attribute as the tag anchor if nothing else + # exists. + tag_target = tag_target or obj.add_attribute( + "description", value="\n".join(lines), to_ids=False + ) else: obj.add_attribute("type", value="RST IoC Lookup", to_ids=False) - tag_target = obj.add_attribute("text", value="\n".join(lines), to_ids=False) + tag_target = obj.add_attribute( + "text", value="\n".join(lines), to_ids=False + ) if fseen: obj.add_attribute("creation-date", value=fseen, to_ids=False) for ref in ref_urls: @@ -345,26 +405,35 @@ def handler(q=False): obj.add_reference(anchor, "characterizes") event.add_object(obj) - # Pivotable hashes: expose the related hash values as their own searchable - # IOC attributes (separate from the object) so they correlate across events. + # Pivotable hashes: expose related hash values as searchable IOC + # attributes (separate from the object) so they correlate across events. if is_hash: for htype in ("md5", "sha1", "sha256"): hval = data.get(htype) if _known(hval) and hval != value: - a = event.add_attribute(htype, value=hval, to_ids=True, - comment="RST IoC Lookup - related hash") + a = event.add_attribute( + htype, + value=hval, + to_ids=True, + comment="RST IoC Lookup - related hash", + ) for tag in galaxy_tags: a.add_tag(tag) # Pivotable resolved IPs for domains (context, not detection-worthy). for rip in resolved_ips: - event.add_attribute("ip-dst", value=rip, to_ids=False, - comment="RST IoC Lookup - resolved IP") + event.add_attribute( + "ip-dst", + value=rip, + to_ids=False, + comment="RST IoC Lookup - resolved IP", + ) # Optional write-back: apply tags + brief note onto the enriched attribute # directly via the MISP API when misp_url/misp_key are configured. apply_to_source_attribute( - config, request, + config, + request, tags=galaxy_tags, comment_note=( f"RST score {total_int}/100" diff --git a/misp_modules/modules/expansion/rst_noise_control.py b/misp_modules/modules/expansion/rst_noise_control.py index 0bd903e3..1668f800 100644 --- a/misp_modules/modules/expansion/rst_noise_control.py +++ b/misp_modules/modules/expansion/rst_noise_control.py @@ -1,4 +1,4 @@ -"""rst_noise_control — check if an indicator is benign/noise (GET /benign/lookup).""" +"""rst_noise_control — benign/noise check (GET /benign/lookup).""" from __future__ import annotations @@ -22,54 +22,87 @@ misperrors = {"error": "Error"} -_INPUTS = ["ip-src", "ip-dst", "domain", "hostname", "url", "md5", "sha1", "sha256", - "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] +_INPUTS = [ + "ip-src", + "ip-dst", + "domain", + "hostname", + "url", + "md5", + "sha1", + "sha256", + "ip-src|port", + "ip-dst|port", + "hostname|port", + "domain|port", +] mispattributes = {"input": _INPUTS, "format": "misp_standard"} moduleinfo = { "version": "0.4", "author": "RST Cloud", "description": ( - "Check whether a value (IP, domain, URL or hash) is known-good / noise " - "via RST Noise Control. Returns an rst-noise object (verdict, category) " - "linked back to the enriched attribute." + "Check whether a value (IP, domain, URL or hash) is known-good /" + " noise via RST Noise Control. Returns an rst-noise object" + " (verdict, category) linked back to the enriched attribute." ), "module-type": ["expansion", "hover"], "name": "RST Cloud Noise Control", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Queries RST Cloud GET /benign/lookup for benign/noisy verdicts. Returns " - "an rst-noise MISP object with false-positive risk tags. When misp_url and " - "misp_key are configured, also annotates the source attribute in place " - "(tags, comment, to_ids, false-positive sightings)." + "Queries RST Cloud GET /benign/lookup for benign/noisy verdicts." + " Returns an rst-noise MISP object with false-positive risk tags." + " When misp_url and misp_key are configured, also annotates the" + " source attribute in place (tags, comment, to_ids, false-positive" + " sightings)." + ), + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], + "input": ( + "IP, domain, hostname, URL, or hash attribute (incl. host|port" + " composites)." + ), + "output": ( + "rst-noise MISP object with verdict, category, and risk/noise tags." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], - "input": "IP, domain, hostname, URL, or hash attribute (incl. host|port composites).", - "output": "rst-noise MISP object with verdict, category, and risk/noise tags.", } # misp_url/misp_key/misp_verifycert (optional): when set the verdict is ALSO # written directly onto the enriched attribute (tags, comment, to_ids, FP # sightings) via the MISP API — the annotation object is always returned # regardless. -moduleconfig = ["api_key", "base_url", "misp_url", "misp_key", "misp_verifycert"] +moduleconfig = [ + "api_key", + "base_url", + "misp_url", + "misp_key", + "misp_verifycert", +] # Tag families we own — stripped before re-adding so re-runs replace not stack. -_TAG_PREFIXES = ("false-positive:risk=", "rstcloud:noise-control=", "rstcloud:noise-category=") +_TAG_PREFIXES = ( + "false-positive:risk=", + "rstcloud:noise-control=", + "rstcloud:noise-category=", +) def _category(reason: str) -> str: """'Change Score Shodan/Scanners/Shodan' -> 'Shodan/Scanners/Shodan'.""" for action in ("Change Score ", "Drop "): if reason.startswith(action): - return reason[len(action):].strip() + n = len(action) + return reason[n:].strip() return reason.strip() def _category_tag(category: str) -> str: - """First ``/``-delimited segment for ``rstcloud:noise-category`` (lower cardinality). + """First ``/``-delimited segment for ``rstcloud:noise-category``. - Full category path stays in the object/comment text; the tag uses only the - top-level bucket before the first ``/``. + Lower cardinality than the full category path. Full category path stays in + the object/comment text; the tag uses only the top-level bucket before + the first ``/``. Example (md5, ``Drop Ubuntu Server 26.04 LTS/pam_sepermit.so/``):: @@ -99,61 +132,73 @@ def handler(q=False): request = json.loads(q) config = request.get("config") if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) value = host_only(value_from_request(request, _INPUTS)) if not value: return error("No supported value found in the request.") - # /benign/lookup always returns HTTP 200 with {value, type, benign, reason}. - # `benign` is the STRING "true"/"false". The `reason` prefix encodes action: + # /benign/lookup always returns HTTP 200 with + # {value, type, benign, reason}. + # `benign` is the STRING "true"/"false". The `reason` prefix encodes + # action: # "Drop ..." -> known-good, safe to suppress (FP risk high) # "Change Score ..." -> noisy infra (scanners/CDN…), reduce score only # (FP risk medium — do NOT treat as clean) # benign=="false" -> unknown / not in database. # - # Example benign md5 (reason "Drop Ubuntu Server 26.04 LTS/pam_sepermit.so/"): + # Example benign md5 (reason "Drop Ubuntu Server 26.04 LTS/.../"): # object/comment Category: Ubuntu Server 26.04 LTS/pam_sepermit.so/ # tag rstcloud:noise-category="Ubuntu Server 26.04 LTS" # (+ false-positive:risk="high", rstcloud:noise-control="drop") - data, err = unwrap(rstapi.noisecontrol(**rst_kwargs(config)).ValueLookup(value)) + data, err = unwrap( + rstapi.noisecontrol(**rst_kwargs(config)).ValueLookup(value) + ) if err: return error(f"RST Noise Control lookup failed: {err}") if not isinstance(data, dict): - return text_result(f"{value}: unexpected response from RST Noise Control", "RST Noise Control") + return text_result( + f"{value}: unexpected response from RST Noise Control", + "RST Noise Control", + ) - benign = str(data.get("benign", "")).strip().lower() == "true" - reason = (data.get("reason") or "").strip() + benign = str(data.get("benign", "")).strip().lower() == "true" + reason = (data.get("reason") or "").strip() ioc_type = (data.get("type") or "").strip() category = _category(reason) tag_category = _category_tag(category) # --- Determine verdict, tags, and write-back actions --- if not benign: - verdict = "Not flagged" - detail = "" # "Not Found in our database" is the API's constant for unknown — not a category - tags = [] + verdict = "Not flagged" + # "Not Found in our database" is the API constant for unknown — + # not a category. + detail = "" + tags = [] fp_sightings = 0 - set_to_ids = None + set_to_ids = None elif reason.lower().startswith("change score"): - verdict = "NOISY - reduce score" - detail = category - tags = [ + verdict = "NOISY - reduce score" + detail = category + tags = [ 'false-positive:risk="medium"', 'rstcloud:noise-control="change-score"', f'rstcloud:noise-category="{tag_category}"', ] fp_sightings = 1 - set_to_ids = None + set_to_ids = None else: - verdict = "BENIGN - known-good" - detail = category - tags = [ + verdict = "BENIGN - known-good" + detail = category + tags = [ 'false-positive:risk="high"', 'rstcloud:noise-control="drop"', f'rstcloud:noise-category="{tag_category}"', ] fp_sightings = 2 - set_to_ids = False + set_to_ids = False # --- Build annotation fallback text --- lines = [f"Verdict: {verdict}"] @@ -177,7 +222,9 @@ def handler(q=False): obj.add_attribute("benign", value=str(benign).lower(), to_ids=False) else: obj.add_attribute("type", value="RST Noise Control", to_ids=False) - tag_target = obj.add_attribute("text", value="\n".join(lines), to_ids=False) + tag_target = obj.add_attribute( + "text", value="\n".join(lines), to_ids=False + ) for tag in tags: tag_target.add_tag(tag) if anchor: @@ -188,9 +235,11 @@ def handler(q=False): # source attribute in place (tags, comment, to_ids flip, FP sightings). # The annotation object is returned regardless. apply_to_source_attribute( - config, request, + config, + request, tags=tags, - comment_note=f"RST Noise Control: {verdict}" + (f" - {detail}" if detail else ""), + comment_note=f"RST Noise Control: {verdict}" + + (f" - {detail}" if detail else ""), comment_prefix="RST Noise Control:", replace_tag_prefixes=_TAG_PREFIXES, set_to_ids=set_to_ids, diff --git a/misp_modules/modules/expansion/rst_screenshot.py b/misp_modules/modules/expansion/rst_screenshot.py index 56c41991..9aea9c19 100644 --- a/misp_modules/modules/expansion/rst_screenshot.py +++ b/misp_modules/modules/expansion/rst_screenshot.py @@ -1,4 +1,4 @@ -"""rst_screenshot — capture a page screenshot as an image object (GET /scan/html/screenshot/*).""" +"""rst_screenshot — page screenshot as image (GET /scan/html/screenshot/*).""" from __future__ import annotations @@ -22,28 +22,50 @@ misperrors = {"error": "Error"} -_INPUTS = ["url", "domain", "hostname", "ip-src", "ip-dst", - "ip-src|port", "ip-dst|port", "hostname|port", "domain|port"] -# misp_standard: return an image MISPObject with the PNG attached (rendered inline -# in MISP) instead of a text description. +_INPUTS = [ + "url", + "domain", + "hostname", + "ip-src", + "ip-dst", + "ip-src|port", + "ip-dst|port", + "hostname|port", + "domain|port", +] +# misp_standard: return an image MISPObject with the PNG attached (rendered +# inline in MISP) instead of a text description. mispattributes = {"input": _INPUTS, "format": "misp_standard"} moduleinfo = { "version": "0.2", "author": "RST Cloud", - "description": "Capture a page screenshot (first/full/last frame) of a URL/IP target via RST Scan API.", + "description": ( + "Capture a page screenshot (first/full/last frame) of a URL/IP" + " target via RST Scan API." + ), "module-type": ["expansion"], "name": "RST Cloud Screenshot", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Renders the target page and returns a PNG screenshot as an image MISP " - "object (inline in MISP). Configurable frame: first, full (default), or last." + "Renders the target page and returns a PNG screenshot as an image" + " MISP object (inline in MISP). Configurable frame: first, full" + " (default), or last." + ), + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], + "input": ( + "URL, domain, hostname, or IP attribute (optional port via config)." + ), + "output": ( + "image MISP object with PNG attachment linked to the enriched" + " attribute." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], - "input": "URL, domain, hostname, or IP attribute (optional port via config).", - "output": "image MISP object with PNG attachment linked to the enriched attribute.", } -# 'frame' selects which screenshot endpoint to call (first/full/last, default full). +# 'frame' selects which screenshot endpoint to call (first/full/last, +# default full). # 'port' (optional): override default port 443. moduleconfig = ["api_key", "base_url", "frame", "port", "timeout"] @@ -69,13 +91,20 @@ def handler(q=False): request = json.loads(q) config = request.get("config") or {} if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) target = scan_target(request, _INPUTS, config, default_port=443) if not target: return error("No target found in the request.") - method = _FRAMES.get((config.get("frame") or "full").lower(), "GetHtmlScreenshotFull") - data, err = unwrap(getattr(rstapi.scan(**scan_kwargs(config)), method)(target)) + method = _FRAMES.get( + (config.get("frame") or "full").lower(), "GetHtmlScreenshotFull" + ) + data, err = unwrap( + getattr(rstapi.scan(**scan_kwargs(config)), method)(target) + ) if err: return error(f"RST screenshot failed: {err}") @@ -85,17 +114,26 @@ def handler(q=False): except Exception: raw = None if not raw: - return text_result(f"{target}: no screenshot returned ({method})", "RST Screenshot") + return text_result( + f"{target}: no screenshot returned ({method})", + "RST Screenshot", + ) from pymisp import MISPObject event, source = misp_event_with_source(request) anchor = scan_group(request, source) image = MISPObject("image") - image.add_attribute("attachment", value="screenshot.png", data=BytesIO(raw)) + image.add_attribute( + "attachment", value="screenshot.png", data=BytesIO(raw) + ) image.comment = f"RST Screenshot ({method})" - event.add_attribute("link", f"https://{target}" if "://" not in target else target, - comment=f"RST Screenshot source ({method})", to_ids=False) + event.add_attribute( + "link", + f"https://{target}" if "://" not in target else target, + comment=f"RST Screenshot source ({method})", + to_ids=False, + ) if anchor: image.add_reference(anchor, "screenshot-of") event.add_object(image) diff --git a/misp_modules/modules/expansion/rst_ssl.py b/misp_modules/modules/expansion/rst_ssl.py index 57aab80c..51c944ad 100644 --- a/misp_modules/modules/expansion/rst_ssl.py +++ b/misp_modules/modules/expansion/rst_ssl.py @@ -1,4 +1,4 @@ -"""rst_ssl — SSL certificate as a pivotable x509 object (GET /scan/ssl/certificate).""" +"""rst_ssl — SSL certificate as x509 object (GET /scan/ssl/certificate).""" from __future__ import annotations @@ -20,8 +20,16 @@ misperrors = {"error": "Error"} -_INPUTS = ["ip-dst", "ip-src", "hostname", "domain", - "ip-dst|port", "ip-src|port", "hostname|port", "domain|port"] +_INPUTS = [ + "ip-dst", + "ip-src", + "hostname", + "domain", + "ip-dst|port", + "ip-src|port", + "hostname|port", + "domain|port", +] # misp_standard: return a real x509 MISPObject (searchable subject/issuer, # pivotable fingerprints) instead of a text blob. mispattributes = {"input": _INPUTS, "format": "misp_standard"} @@ -29,25 +37,36 @@ moduleinfo = { "version": "0.2", "author": "RST Cloud", - "description": "Fetch the SSL certificate for an IP[:port] as an x509 object via RST Scan API.", + "description": ( + "Fetch the SSL certificate for an IP[:port] as an x509 object via" + " RST Scan API." + ), "module-type": ["expansion"], "name": "RST Cloud SSL Certificate", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Connects to the target service and retrieves the TLS certificate via " - "RST Scan GET /scan/ssl/certificate. Returns an x509 MISP object with " - "pivotable fingerprints (SHA-1/256/MD5), subject, issuer, and validity dates." + "Connects to the target service and retrieves the TLS certificate" + " via RST Scan GET /scan/ssl/certificate. Returns an x509 MISP" + " object with pivotable fingerprints (SHA-1/256/MD5), subject," + " issuer, and validity dates." + ), + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], + "input": ( + "IP, hostname, or domain attribute (optional port via config or" + " composite)." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], - "input": "IP, hostname, or domain attribute (optional port via config or composite).", "output": "x509 MISP object referencing the enriched attribute.", } # 'port' (optional): TLS port to scan when the attribute carries none (API # defaults to 443 if omitted). moduleconfig = ["api_key", "base_url", "port", "timeout"] -# RST certificate field -> x509 object_relation (pymisp infers the attribute type -# from the template, so fingerprints become pivotable x509-fingerprint-* types). +# RST certificate field -> x509 object_relation (pymisp infers attribute +# type from the template, so fingerprints become pivotable x509-fingerprint-* +# types). _X509_MAP = { "subject_dn": "subject", "issuer_dn": "issuer", @@ -76,16 +95,25 @@ def handler(q=False): request = json.loads(q) config = request.get("config") if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) target = scan_target(request, _INPUTS, config, default_port=443) if not target: - return error("No target found in the request (expects an IP/hostname).") + return error( + "No target found in the request (expects an IP/hostname)." + ) - data, err = unwrap(rstapi.scan(**scan_kwargs(config)).GetSslCertificate(target)) + data, err = unwrap( + rstapi.scan(**scan_kwargs(config)).GetSslCertificate(target) + ) if err: return error(f"RST SSL scan failed: {err}") if not isinstance(data, dict) or not data.get("subject_dn"): - return text_result(f"{target}: no certificate returned", "RST SSL Certificate") + return text_result( + f"{target}: no certificate returned", "RST SSL Certificate" + ) from pymisp import MISPObject diff --git a/misp_modules/modules/expansion/rst_whois.py b/misp_modules/modules/expansion/rst_whois.py index 451d2443..bac6f396 100644 --- a/misp_modules/modules/expansion/rst_whois.py +++ b/misp_modules/modules/expansion/rst_whois.py @@ -1,4 +1,4 @@ -"""rst_whois — parsed WHOIS as a misp_standard whois object (GET /whois/{domain}).""" +"""rst_whois — parsed WHOIS as whois object (GET /whois/{domain}).""" from __future__ import annotations @@ -25,16 +25,21 @@ moduleinfo = { "version": "0.2", "author": "RST Cloud", - "description": "Retrieve parsed WHOIS information for a domain via RST Cloud.", + "description": ( + "Retrieve parsed WHOIS information for a domain via RST Cloud." + ), "module-type": ["expansion", "hover"], "name": "RST Cloud Whois", "requirements": ["An RST Cloud API key.", "rstapi>=1.2.0 (PyPI)."], "features": ( - "Queries RST Cloud GET /whois for parsed domain registration data. " - "Returns a standard whois MISP object (registrar, registrant, dates, " - "nameservers) linked back to the enriched attribute." + "Queries RST Cloud GET /whois for parsed domain registration data." + " Returns a standard whois MISP object (registrar, registrant," + " dates, nameservers) linked back to the enriched attribute." ), - "references": ["https://api.rstcloud.net/", "https://pypi.org/project/rstapi/"], + "references": [ + "https://api.rstcloud.net/", + "https://pypi.org/project/rstapi/", + ], "input": "Domain or hostname attribute.", "output": "whois MISP object with registration and nameserver fields.", } @@ -52,7 +57,13 @@ def version(): def _known(v) -> bool: """True when a value is present and not a placeholder like 'unknown'.""" - return bool(v) and str(v).strip().lower() not in ("unknown", "none", "", "null", "n/a") + return bool(v) and str(v).strip().lower() not in ( + "unknown", + "none", + "", + "null", + "n/a", + ) def handler(q=False): @@ -61,12 +72,17 @@ def handler(q=False): request = json.loads(q) config = request.get("config") if not rst_kwargs(config)["APIKEY"]: - return error("An RST Cloud API key is required (set api_key in the module config).") + return error( + "An RST Cloud API key is required (set api_key in the module" + " config)." + ) domain = value_from_request(request, _INPUTS) if not domain: return error("No domain found in the request.") - data, err = unwrap(rstapi.whoisapi(**rst_kwargs(config)).GetDomainInfo(domain)) + data, err = unwrap( + rstapi.whoisapi(**rst_kwargs(config)).GetDomainInfo(domain) + ) if err: return error(f"RST Whois API lookup failed: {err}") if not isinstance(data, dict): @@ -86,19 +102,33 @@ def handler(q=False): if _known(data.get("registrar")): obj.add_attribute("registrar", value=data["registrar"], to_ids=False) if _known(data.get("registrant")): - obj.add_attribute("registrant-name", value=data["registrant"], to_ids=False) + obj.add_attribute( + "registrant-name", value=data["registrant"], to_ids=False + ) if _known(data.get("registrant_org")): - obj.add_attribute("registrant-org", value=data["registrant_org"], to_ids=False) + obj.add_attribute( + "registrant-org", value=data["registrant_org"], to_ids=False + ) if _known(data.get("registrant_email")): - obj.add_attribute("registrant-email", value=data["registrant_email"], to_ids=False) + obj.add_attribute( + "registrant-email", + value=data["registrant_email"], + to_ids=False, + ) # Dates (API returns "created_on" / "updated_on" / "expires_on") if _known(data.get("created_on")): - obj.add_attribute("creation-date", value=data["created_on"], to_ids=False) + obj.add_attribute( + "creation-date", value=data["created_on"], to_ids=False + ) if _known(data.get("updated_on")): - obj.add_attribute("modification-date", value=data["updated_on"], to_ids=False) + obj.add_attribute( + "modification-date", value=data["updated_on"], to_ids=False + ) if _known(data.get("expires_on")): - obj.add_attribute("expiration-date", value=data["expires_on"], to_ids=False) + obj.add_attribute( + "expiration-date", value=data["expires_on"], to_ids=False + ) # Nameservers — one attribute per NS for ns in (data.get("nameservers") or "").split(","):