From 4f970be783eb1757510e679af5ebd5216c83202f Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Mon, 26 Oct 2026 08:30:13 +0100 Subject: [PATCH 1/2] feat: add Signal and IRC workflow action modules --- README.md | 2 + documentation/README.md | 48 ++++ documentation/action_mod/irc.md | 41 ++++ documentation/action_mod/signal.md | 36 +++ documentation/mkdocs/action_mod.md | 48 ++++ documentation/mkdocs/index.md | 2 + misp_modules/modules/action_mod/irc.py | 240 ++++++++++++++++++++ misp_modules/modules/action_mod/signal.py | 141 ++++++++++++ tests/test_irc.py | 259 ++++++++++++++++++++++ tests/test_messaging_actions.py | 53 +++++ tests/test_signal.py | 137 ++++++++++++ 11 files changed, 1007 insertions(+) create mode 100644 documentation/action_mod/irc.md create mode 100644 documentation/action_mod/signal.md create mode 100644 misp_modules/modules/action_mod/irc.py create mode 100644 misp_modules/modules/action_mod/signal.py create mode 100644 tests/test_irc.py create mode 100644 tests/test_messaging_actions.py create mode 100644 tests/test_signal.py diff --git a/README.md b/README.md index 60fd98d4..8de8c19e 100644 --- a/README.md +++ b/README.md @@ -263,12 +263,14 @@ For further Information see the [license file](https://misp.github.io/misp-modul ### Action Modules * [CrowdStrike IOC Publish](https://misp.github.io/misp-modules/action_mod/#crowdstrike-ioc-publish) - Create and renew module-owned Falcon custom IOCs from MISP event and object attributes. * [Export to Sentinel or Defender](https://misp.github.io/misp-modules/action_mod/#export-to-sentinel-or-defender) - Export indicators to Microsoft Sentinel or Microsoft Defender. Requires an existing installation of MISP2Sentinel or MISP2Defender. +* [IRC](https://misp.github.io/misp-modules/action_mod/#irc) - Send a message to an IRC channel. * [Matrix](https://misp.github.io/misp-modules/action_mod/#matrix) - Send a message to an unencrypted Matrix room. * [Mattermost](https://misp.github.io/misp-modules/action_mod/#mattermost) - Simplistic module to send message to a Mattermost channel. * [Nextcloud talk](https://misp.github.io/misp-modules/action_mod/#nextcloud-talk) - Simplistic module to send a message to a Nextcloud talk conversation. * [OSQuery Pack Publish](https://misp.github.io/misp-modules/action_mod/#osquery-pack-publish) - Publish a MISP query pack to a configured local osquery pack directory. * [QRadar Reference Set Publish](https://misp.github.io/misp-modules/action_mod/#qradar-reference-set-publish) - Publish typed MISP indicators to an existing QRadar reference set and explicit domain. * [SentinelOne IOC Publish](https://misp.github.io/misp-modules/action_mod/#sentinelone-ioc-publish) - Publish MISP event and object indicators to SentinelOne Threat Intelligence with explicit scope. +* [Signal](https://misp.github.io/misp-modules/action_mod/#signal) - Send a message to Signal recipients or a group through signal-cli-rest-api. * [Slack](https://misp.github.io/misp-modules/action_mod/#slack) - Simplistic module to send messages to a Slack channel. * [Test action](https://misp.github.io/misp-modules/action_mod/#test-action) - This module is merely a test, always returning true. Triggers on event publishing. * [Wazuh CDB Publish](https://misp.github.io/misp-modules/action_mod/#wazuh-cdb-publish) - Upload MISP event and object indicators to an explicitly named Wazuh CDB source list. diff --git a/documentation/README.md b/documentation/README.md index 40b0eefd..393ec6dd 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -4642,6 +4642,28 @@ Create and renew module-owned Falcon custom IOCs from MISP event and object attr ----- +#### [IRC](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/irc.py) + +Send a message to an IRC channel. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/irc.py)] + +- **features**: +>Renders Jinja2 templates using matchingData when present, otherwise data, with a JSON fallback. Supports verified TLS, server passwords and channel keys. Splits multiline and long UTF-8 messages into IRC PRIVMSG lines. See the [usage guide](https://github.com/MISP/misp-modules/blob/main/documentation/action_mod/irc.md). + +- **config**: +>{'params': {'irc_server': {'type': 'string', 'description': 'IRC server hostname', 'value': 'irc.example.org'}, 'irc_port': {'type': 'string', 'description': 'IRC server port', 'value': '6697'}, 'use_tls': {'type': 'string', 'description': 'true for verified TLS, false for a plaintext connection', 'value': 'true'}, 'nickname': {'type': 'string', 'description': 'Nickname for this connection', 'value': 'misp'}, 'channel': {'type': 'string', 'description': 'IRC channel to join', 'value': '#misp'}, 'server_password': {'type': 'string', 'description': 'Optional server PASS password', 'value': ''}, 'channel_key': {'type': 'string', 'description': 'Optional channel JOIN key', 'value': ''}, 'message_template': {'type': 'large_string', 'description': 'Jinja2 message template; leave empty to send the workflow data as JSON', 'value': 'MISP event: {{ Event.info }}', 'jinja_supported': True}}, 'blocking': False, 'support_filters': True, 'expect_misp_core_format': False} + +- **input**: +>Workflow data or matchingData, and an optional Jinja2 message template. + +- **output**: +>Boolean success after server processing; IRC provides no recipient delivery receipt. + +- **references**: +>https://modern.ircdocs.horse/ + +----- + #### [Matrix](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/matrix.py) Send a message to an unencrypted Matrix room. @@ -4773,6 +4795,32 @@ Publish MISP event and object indicators to SentinelOne Threat Intelligence with ----- +#### [Signal](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/signal.py) + +Send a message to Signal recipients or a group through signal-cli-rest-api. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/signal.py)] + +- **features**: +>Renders Jinja2 templates using matchingData when present, otherwise data, with a JSON fallback. Sends plain-text messages through POST /v2/send. See the [usage guide](https://github.com/MISP/misp-modules/blob/main/documentation/action_mod/signal.md). + +- **config**: +>{'params': {'signal_api_url': {'type': 'string', 'description': 'Base URL of your signal-cli-rest-api service, including any proxy path prefix', 'value': 'http://localhost:8080'}, 'sender_number': {'type': 'string', 'description': 'Registered or linked Signal account number in international format, e.g. +33123456789'}, 'recipients': {'type': 'string', 'description': 'Comma-separated international phone numbers or signal-cli-rest-api group IDs (group.…)'}, 'message_template': {'type': 'large_string', 'description': 'Jinja2 message template; leave empty to send the workflow data as JSON', 'value': 'MISP event: {{ Event.info }}', 'jinja_supported': True}}, 'blocking': False, 'support_filters': True, 'expect_misp_core_format': False} + +- **input**: +>Workflow data or matchingData, and an optional Jinja2 message template. + +- **output**: +>Boolean success; errors indicate an unsuccessful or unacknowledged send. + +- **references**: +>https://github.com/bbernhard/signal-cli-rest-api + +- **requirements**: +> - requests +> - A configured signal-cli-rest-api service with a registered or linked account + +----- + #### [Slack](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/slack.py) Simplistic module to send messages to a Slack channel. diff --git a/documentation/action_mod/irc.md b/documentation/action_mod/irc.md new file mode 100644 index 00000000..f361ae68 --- /dev/null +++ b/documentation/action_mod/irc.md @@ -0,0 +1,41 @@ +# IRC action module + +Send a plain-text message from a MISP workflow to an IRC channel. The action +follows the Mattermost module's template and workflow data conventions. It uses +Python's standard library, so no extra IRC client dependency is needed. + +| Parameter | Description | +| --- | --- | +| `irc_server` | Server hostname, without a URL scheme. | +| `irc_port` | Server port; defaults to `6697`. Set `6667` explicitly for a typical plaintext server. | +| `use_tls` | `true` (default) uses TLS with certificate and hostname verification. `false` selects plaintext. | +| `nickname` | Nickname for the connection; must be available on the server. | +| `channel` | One channel starting with `#` or `&`, such as `#misp`. | +| `server_password` | Optional server password, sent using `PASS`. | +| `channel_key` | Optional channel key, sent with `JOIN`. | +| `message_template` | Optional Jinja2 template. Empty or omitted sends JSON inside triple backticks. | + +For example, use `MISP event {{ Event.id }}: {{ Event.info }}` when the workflow +supplies an `Event` object. Templates use `matchingData` whenever that key is +present, including an empty value; otherwise they use `data`. Workflow filters +are supported. + +Each invocation connects, registers, joins the channel, sends `PRIVMSG` lines, +and disconnects. The connection answers server `PING` messages. It waits for +registration and its own channel join before sending. Newlines become separate +messages, tabs become spaces, and long lines split at UTF-8 character boundaries +to fit IRC's 512-byte command limit. Empty lines are omitted. Other control +characters, including CTCP framing, are rejected. Markdown markers are literal. + +After sending, the action waits for a server `PONG` to a unique token to observe +errors from preceding commands before reporting success. IRC provides no +recipient delivery receipt; a successful result means the server processed the +commands without returning an error. Connection failures, nickname collisions, +channel access errors and send rejections return `data: false` without server +error text or credentials. The operation has a 30-second I/O budget, with no +automatic retries. Like Mattermost, this action is nonblocking. + +Choose a channel the account can join and write to. SASL, NickServ authentication +and client certificates are unsupported. Each invocation needs an available +nickname; concurrent runs using the same nickname can collide. Keep templates +concise and account for your IRC network's flood limits when scheduling workflows. diff --git a/documentation/action_mod/signal.md b/documentation/action_mod/signal.md new file mode 100644 index 00000000..2322cf8d --- /dev/null +++ b/documentation/action_mod/signal.md @@ -0,0 +1,36 @@ +# Signal action module + +Send a plain-text message from a MISP workflow to Signal phone numbers or a group. +The action follows the Mattermost module's template and workflow data conventions. +It uses the existing `requests` dependency with +[signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api). + +Run a signal-cli-rest-api service reachable from the module server, and register +or link the sending account there. The account must already belong to any target +group. Use the service's group listing endpoint to obtain the group's `id`, with +the `group.` prefix. Account registration, linking and group management are done +in that service, outside this action. + +| Parameter | Description | +| --- | --- | +| `signal_api_url` | Service base URL, such as `http://localhost:8080`. Include any reverse proxy path prefix; omit `/v2/send`. HTTPS URLs are also supported. | +| `sender_number` | Registered or linked account number in international format, such as `+33123456789`. | +| `recipients` | Comma-separated international phone numbers or `group.…` IDs returned by signal-cli-rest-api. | +| `message_template` | Optional Jinja2 template. Empty or omitted sends JSON inside triple backticks. | + +For example, use `MISP event {{ Event.id }}: {{ Event.info }}` when the workflow +supplies an `Event` object. Templates use `matchingData` whenever that key is +present, including an empty value; otherwise they use `data`. Workflow filters +are supported. Messages are plain text, so Markdown markers remain literal text. + +The action sends `POST /v2/send` with `number`, `recipients` and `message`, using +a 30-second timeout. Success requires HTTP 201 and a positive `timestamp` in the +response. This acknowledges the service's send operation, not recipient delivery. +Errors return `data: false` without exposing phone numbers or the response body. +Requests are not automatically retried; a timeout can leave delivery uncertain. +Like Mattermost, this action is nonblocking. + +The API service handles Signal account keys and sees the workflow message in +plain text before Signal encrypts it. Keep access to that service restricted to +trusted clients. This action targets the service's native API and does not +configure authentication for a reverse proxy. diff --git a/documentation/mkdocs/action_mod.md b/documentation/mkdocs/action_mod.md index b9f1d038..dae96adf 100644 --- a/documentation/mkdocs/action_mod.md +++ b/documentation/mkdocs/action_mod.md @@ -27,6 +27,28 @@ Create and renew module-owned Falcon custom IOCs from MISP event and object attr ----- +#### [IRC](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/irc.py) + +Send a message to an IRC channel. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/irc.py)] + +- **features**: +>Renders Jinja2 templates using matchingData when present, otherwise data, with a JSON fallback. Supports verified TLS, server passwords and channel keys. Splits multiline and long UTF-8 messages into IRC PRIVMSG lines. See the [usage guide](https://github.com/MISP/misp-modules/blob/main/documentation/action_mod/irc.md). + +- **config**: +>{'params': {'irc_server': {'type': 'string', 'description': 'IRC server hostname', 'value': 'irc.example.org'}, 'irc_port': {'type': 'string', 'description': 'IRC server port', 'value': '6697'}, 'use_tls': {'type': 'string', 'description': 'true for verified TLS, false for a plaintext connection', 'value': 'true'}, 'nickname': {'type': 'string', 'description': 'Nickname for this connection', 'value': 'misp'}, 'channel': {'type': 'string', 'description': 'IRC channel to join', 'value': '#misp'}, 'server_password': {'type': 'string', 'description': 'Optional server PASS password', 'value': ''}, 'channel_key': {'type': 'string', 'description': 'Optional channel JOIN key', 'value': ''}, 'message_template': {'type': 'large_string', 'description': 'Jinja2 message template; leave empty to send the workflow data as JSON', 'value': 'MISP event: {{ Event.info }}', 'jinja_supported': True}}, 'blocking': False, 'support_filters': True, 'expect_misp_core_format': False} + +- **input**: +>Workflow data or matchingData, and an optional Jinja2 message template. + +- **output**: +>Boolean success after server processing; IRC provides no recipient delivery receipt. + +- **references**: +>https://modern.ircdocs.horse/ + +----- + #### [Matrix](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/matrix.py) Send a message to an unencrypted Matrix room. @@ -158,6 +180,32 @@ Publish MISP event and object indicators to SentinelOne Threat Intelligence with ----- +#### [Signal](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/signal.py) + +Send a message to Signal recipients or a group through signal-cli-rest-api. +[[source code](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/signal.py)] + +- **features**: +>Renders Jinja2 templates using matchingData when present, otherwise data, with a JSON fallback. Sends plain-text messages through POST /v2/send. See the [usage guide](https://github.com/MISP/misp-modules/blob/main/documentation/action_mod/signal.md). + +- **config**: +>{'params': {'signal_api_url': {'type': 'string', 'description': 'Base URL of your signal-cli-rest-api service, including any proxy path prefix', 'value': 'http://localhost:8080'}, 'sender_number': {'type': 'string', 'description': 'Registered or linked Signal account number in international format, e.g. +33123456789'}, 'recipients': {'type': 'string', 'description': 'Comma-separated international phone numbers or signal-cli-rest-api group IDs (group.…)'}, 'message_template': {'type': 'large_string', 'description': 'Jinja2 message template; leave empty to send the workflow data as JSON', 'value': 'MISP event: {{ Event.info }}', 'jinja_supported': True}}, 'blocking': False, 'support_filters': True, 'expect_misp_core_format': False} + +- **input**: +>Workflow data or matchingData, and an optional Jinja2 message template. + +- **output**: +>Boolean success; errors indicate an unsuccessful or unacknowledged send. + +- **references**: +>https://github.com/bbernhard/signal-cli-rest-api + +- **requirements**: +> - requests +> - A configured signal-cli-rest-api service with a registered or linked account + +----- + #### [Slack](https://github.com/MISP/misp-modules/tree/main/misp_modules/modules/action_mod/slack.py) Simplistic module to send messages to a Slack channel. diff --git a/documentation/mkdocs/index.md b/documentation/mkdocs/index.md index d1003922..f0b85c05 100644 --- a/documentation/mkdocs/index.md +++ b/documentation/mkdocs/index.md @@ -198,12 +198,14 @@ For more information: [Extending MISP with Python modules](https://www.misp-proj ### Action Modules * [CrowdStrike IOC Publish](https://misp.github.io/misp-modules/action_mod/#crowdstrike-ioc-publish) - Create and renew module-owned Falcon custom IOCs from MISP event and object attributes. +* [IRC](https://misp.github.io/misp-modules/action_mod/#irc) - Send a message to an IRC channel. * [Matrix](https://misp.github.io/misp-modules/action_mod/#matrix) - Send a message to an unencrypted Matrix room. * [Mattermost](https://misp.github.io/misp-modules/action_mod/#mattermost) - Simplistic module to send message to a Mattermost channel. * [Nextcloud talk](https://misp.github.io/misp-modules/action_mod/#nextcloud-talk) - Simplistic module to send a message to a Nextcloud talk conversation. * [OSQuery Pack Publish](https://misp.github.io/misp-modules/action_mod/#osquery-pack-publish) - Publish a MISP query pack to a configured local osquery pack directory. * [QRadar Reference Set Publish](https://misp.github.io/misp-modules/action_mod/#qradar-reference-set-publish) - Publish typed MISP indicators to an existing QRadar reference set and explicit domain. * [SentinelOne IOC Publish](https://misp.github.io/misp-modules/action_mod/#sentinelone-ioc-publish) - Publish MISP event and object indicators to SentinelOne Threat Intelligence with explicit scope. +* [Signal](https://misp.github.io/misp-modules/action_mod/#signal) - Send a message to Signal recipients or a group through signal-cli-rest-api. * [Slack](https://misp.github.io/misp-modules/action_mod/#slack) - Simplistic module to send messages to a Slack channel. * [Test action](https://misp.github.io/misp-modules/action_mod/#test-action) - This module is merely a test, always returning true. Triggers on event publishing. * [Wazuh CDB Publish](https://misp.github.io/misp-modules/action_mod/#wazuh-cdb-publish) - Upload MISP event and object indicators to an explicitly named Wazuh CDB source list. diff --git a/misp_modules/modules/action_mod/irc.py b/misp_modules/modules/action_mod/irc.py new file mode 100644 index 00000000..2014aedd --- /dev/null +++ b/misp_modules/modules/action_mod/irc.py @@ -0,0 +1,240 @@ +"""Send workflow messages to an IRC channel using a short-lived connection.""" + +import json +import re +import socket +import ssl +from time import monotonic +from uuid import uuid4 + +from jinja2 import TemplateError + +from ._utils import utils + +misperrors = {"error": "Error"} + +moduleconfig = { + "params": { + "irc_server": {"type": "string", "description": "IRC server hostname", "value": "irc.example.org"}, + "irc_port": {"type": "string", "description": "IRC server port", "value": "6697"}, + "use_tls": { + "type": "string", + "description": "true for verified TLS, false for a plaintext connection", + "value": "true", + }, + "nickname": {"type": "string", "description": "Nickname for this connection", "value": "misp"}, + "channel": {"type": "string", "description": "IRC channel to join", "value": "#misp"}, + "server_password": {"type": "string", "description": "Optional server PASS password", "value": ""}, + "channel_key": {"type": "string", "description": "Optional channel JOIN key", "value": ""}, + "message_template": { + "type": "large_string", + "description": "Jinja2 message template; leave empty to send the workflow data as JSON", + "value": "MISP event: {{ Event.info }}", + "jinja_supported": True, + }, + }, + "blocking": False, + "support_filters": True, + "expect_misp_core_format": False, +} + +returns = "boolean" + +moduleinfo = { + "version": "0.1", + "author": "MISP Project", + "description": "Send a message to an IRC channel.", + "module-type": ["action"], + "name": "IRC", + "logo": "", + "requirements": [], + "features": ( + "Renders Jinja2 templates using matchingData when present, otherwise data, with a JSON fallback." + " Supports verified TLS, server passwords and channel keys. Splits multiline and long UTF-8 messages" + " into IRC PRIVMSG lines. See the" + " [usage guide](https://github.com/MISP/misp-modules/blob/main/documentation/action_mod/irc.md)." + ), + "references": ["https://modern.ircdocs.horse/"], + "input": "Workflow data or matchingData, and an optional Jinja2 message template.", + "output": "Boolean success after server processing; IRC provides no recipient delivery receipt.", +} + + +def _casefold(value): + # RFC 1459 channel and nickname casemapping. + return value.lower().translate(str.maketrans("[]\\^", "{}|~")) + + +def _message_lines(message, channel): + if any(ord(char) < 32 and char not in "\r\n\t" or ord(char) == 127 for char in message): + raise ValueError("IRC message contains control characters") + prefix = f"PRIVMSG {channel} :" + budget = 510 - len(prefix.encode("utf-8")) + if budget < 4: + raise ValueError("IRC channel is too long") + lines = [] + for line in message.replace("\t", " ").splitlines(): + encoded = line.encode("utf-8") + while encoded: + chunk = encoded[:budget].decode("utf-8", errors="ignore") + lines.append(prefix + chunk) + encoded = encoded[len(chunk.encode("utf-8")) :] + if not lines: + raise ValueError("Empty IRC message") + return lines + + +def _send_message(sock, params, lines, deadline): + def remaining(): + timeout = deadline - monotonic() + if timeout <= 0: + raise TimeoutError("IRC operation timed out") + return timeout + + def send(line): + encoded = (line + "\r\n").encode("utf-8") + if len(encoded) > 512 or "\r" in line or "\n" in line: + raise ValueError("Invalid IRC command") + sock.settimeout(remaining()) + sock.sendall(encoded) + + def receive(stream): + while True: + sock.settimeout(remaining()) + raw = stream.readline(8193) + if not raw or len(raw) > 8192 or not raw.endswith(b"\n"): + raise OSError("IRC connection closed or invalid response") + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") + if line.startswith("@"): + line = line.split(" ", 1)[1] + prefix = "" + if line.startswith(":"): + prefix, line = line[1:].split(" ", 1) + head, separator, tail = line.partition(" :") + parts = head.split() + if separator: + parts.append(tail) + if not parts: + continue + command, args = parts[0].upper(), parts[1:] + if command == "PING" and args: + send(f"PONG :{args[-1]}") + continue + if command == "ERROR" or (command.isdigit() and 400 <= int(command) <= 599 and command != "422"): + raise OSError("IRC server rejected the operation") + return prefix, command, args + + with sock.makefile("rb") as stream: + if params.get("server_password"): + send(f"PASS :{params['server_password']}") + send(f"NICK {params['nickname']}") + send(f"USER {params['nickname']} 0 * :MISP workflow") + while True: + _, command, args = receive(stream) + if command == "001" and args: + nickname = args[0] + break + + channel = params["channel"] + key = params.get("channel_key", "") + send(f"JOIN {channel}" + (f" {key}" if key else "")) + while True: + prefix, command, args = receive(stream) + if ( + command == "JOIN" + and args + and _casefold(prefix.split("!", 1)[0]) == _casefold(nickname) + and _casefold(args[0]) == _casefold(channel) + ): + break + + for line in lines: + send(line) + # A PONG after the messages lets us observe errors for preceding commands. + token = uuid4().hex + send(f"PING :{token}") + while True: + _, command, args = receive(stream) + if command == "PONG" and args and args[-1] == token: + break + send("QUIT :MISP workflow complete") + + +def create_post(request): + params = request["params"] + for name in ("irc_server", "nickname", "channel", "channel_key"): + value = params.get(name, "") + if not isinstance(value, str) or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError("Invalid IRC configuration") + if not params["irc_server"] or not re.fullmatch( + r"[A-Za-z\[\]\\`_^{|}][A-Za-z0-9\[\]\\`_^{|}-]*", params["nickname"] + ): + raise ValueError("Invalid IRC server or nickname") + if not re.fullmatch(r"[#&][^,:\s\x00-\x1f\x7f]+", params["channel"]): + raise ValueError("Invalid IRC channel") + if any(char in params.get("channel_key", "") for char in ",:"): + raise ValueError("Invalid IRC channel key") + password = params.get("server_password", "") + if not isinstance(password, str) or any(ord(char) < 32 or ord(char) == 127 for char in password): + raise ValueError("Invalid IRC server password") + commands = [ + f"NICK {params['nickname']}", + f"USER {params['nickname']} 0 * :MISP workflow", + f"JOIN {params['channel']} {params.get('channel_key', '')}", + f"PASS :{password}", + ] + if any(len(command.encode("utf-8")) > 510 for command in commands): + raise ValueError("IRC configuration exceeds command length limits") + port = params.get("irc_port", "6697") + if isinstance(port, bool) or not re.fullmatch(r"[0-9]{1,5}", str(port)) or not 1 <= int(port) <= 65535: + raise ValueError("Invalid IRC port") + tls = params.get("use_tls", "true") + if isinstance(tls, str): + tls = {"true": True, "false": False}.get(tls.lower()) + if not isinstance(tls, bool): + raise ValueError("Invalid IRC TLS setting") + + data = request["matchingData"] if "matchingData" in request else request["data"] + template = params.get("message_template", "") + if not isinstance(template, str): + raise ValueError("Invalid message template") + message = utils.renderTemplate(data, template) if template else "```\n{}\n```".format(json.dumps(data)) + lines = _message_lines(message, params["channel"]) + deadline = monotonic() + 30 + with socket.create_connection((params["irc_server"], int(port)), timeout=30) as sock: + if tls: + timeout = deadline - monotonic() + if timeout <= 0: + raise TimeoutError("IRC operation timed out") + sock.settimeout(timeout) + with ssl.create_default_context().wrap_socket(sock, server_hostname=params["irc_server"]) as secured: + _send_message(secured, params, lines, deadline) + else: + _send_message(sock, params, lines, deadline) + return True + + +def handler(q=False): + if q is False: + return False + try: + request = json.loads(q) + if not isinstance(request, dict) or not isinstance(request.get("params"), dict): + raise ValueError("Invalid request") + create_post(request) + except OSError: + return { + "data": False, + "error": "IRC connection or send failed; check TLS, nickname, credentials and channel access.", + } + except (KeyError, IndexError, TypeError, ValueError, TemplateError): + return {"data": False, "error": "Invalid IRC configuration, workflow data, template or server response."} + return {"data": True} + + +def introspection(): + return {"config": moduleconfig} + + +def version(): + return {**moduleinfo, "config": moduleconfig} diff --git a/misp_modules/modules/action_mod/signal.py b/misp_modules/modules/action_mod/signal.py new file mode 100644 index 00000000..7c44c6a6 --- /dev/null +++ b/misp_modules/modules/action_mod/signal.py @@ -0,0 +1,141 @@ +"""Send workflow messages through signal-cli-rest-api.""" + +import json +import re +from urllib.parse import urlsplit + +import requests +from jinja2 import TemplateError + +from ._utils import utils + +misperrors = {"error": "Error"} + +moduleconfig = { + "params": { + "signal_api_url": { + "type": "string", + "description": "Base URL of your signal-cli-rest-api service, including any proxy path prefix", + "value": "http://localhost:8080", + }, + "sender_number": { + "type": "string", + "description": "Registered or linked Signal account number in international format, e.g. +33123456789", + }, + "recipients": { + "type": "string", + "description": "Comma-separated international phone numbers or signal-cli-rest-api group IDs (group.…)", + }, + "message_template": { + "type": "large_string", + "description": "Jinja2 message template; leave empty to send the workflow data as JSON", + "value": "MISP event: {{ Event.info }}", + "jinja_supported": True, + }, + }, + "blocking": False, + "support_filters": True, + "expect_misp_core_format": False, +} + +returns = "boolean" + +moduleinfo = { + "version": "0.1", + "author": "MISP Project", + "description": "Send a message to Signal recipients or a group through signal-cli-rest-api.", + "module-type": ["action"], + "name": "Signal", + "logo": "", + "requirements": ["requests", "A configured signal-cli-rest-api service with a registered or linked account"], + "features": ( + "Renders Jinja2 templates using matchingData when present, otherwise data, with a JSON fallback." + " Sends plain-text messages through POST /v2/send. See the" + " [usage guide](https://github.com/MISP/misp-modules/blob/main/documentation/action_mod/signal.md)." + ), + "references": ["https://github.com/bbernhard/signal-cli-rest-api"], + "input": "Workflow data or matchingData, and an optional Jinja2 message template.", + "output": "Boolean success; errors indicate an unsuccessful or unacknowledged send.", +} + + +def create_post(request): + params = request["params"] + base_url = params["signal_api_url"] + if not isinstance(base_url, str) or any(char.isspace() or ord(char) < 32 for char in base_url): + raise ValueError("Invalid Signal API URL") + parsed = urlsplit(base_url) + if ( + parsed.scheme not in ("http", "https") + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.port == 0 + ): + raise ValueError("Invalid Signal API URL") + + number = params["sender_number"] + if not isinstance(number, str) or not re.fullmatch(r"\+[1-9][0-9]{1,14}", number): + raise ValueError("Invalid Signal sender number") + recipients = params["recipients"] + if not isinstance(recipients, str): + raise ValueError("Invalid Signal recipients") + recipients = [recipient.strip() for recipient in recipients.split(",")] + if not recipients or any( + not re.fullmatch(r"\+[1-9][0-9]{1,14}|group\.[A-Za-z0-9+/=_-]+", recipient) for recipient in recipients + ): + raise ValueError("Invalid Signal recipients") + + data = request["matchingData"] if "matchingData" in request else request["data"] + template = params.get("message_template", "") + if not isinstance(template, str): + raise ValueError("Invalid message template") + message = utils.renderTemplate(data, template) if template else "```\n{}\n```".format(json.dumps(data)) + if not message.strip(): + raise ValueError("Empty Signal message") + + response = requests.post( + f"{base_url.rstrip('/')}/v2/send", + json={"number": number, "recipients": recipients, "message": message}, + timeout=30, + allow_redirects=False, + ) + response.raise_for_status() + if response.status_code != 201: + return False + acknowledgement = response.json() + timestamp = acknowledgement.get("timestamp") if isinstance(acknowledgement, dict) else None + return ( + not isinstance(timestamp, bool) + and isinstance(timestamp, (int, str)) + and str(timestamp).isascii() + and str(timestamp).isdigit() + and int(timestamp) > 0 + ) + + +def handler(q=False): + if q is False: + return False + try: + request = json.loads(q) + if not isinstance(request, dict) or not isinstance(request.get("params"), dict): + raise ValueError("Invalid request") + success = create_post(request) + except requests.RequestException: + return {"data": False, "error": "Signal request failed; check the API service, sender account and recipients."} + except (KeyError, TypeError, ValueError, TemplateError): + return {"data": False, "error": "Invalid Signal configuration, workflow data, template or API response."} + if not success: + return {"data": False, "error": "Signal message was not acknowledged by the API service."} + return {"data": True} + + +def introspection(): + return {"config": moduleconfig} + + +def version(): + return {**moduleinfo, "config": moduleconfig} diff --git a/tests/test_irc.py b/tests/test_irc.py new file mode 100644 index 00000000..d1d4595d --- /dev/null +++ b/tests/test_irc.py @@ -0,0 +1,259 @@ +import json +import socket +import ssl +from collections import deque +from unittest.mock import Mock + +import pytest + +from misp_modules.modules.action_mod import irc + + +def workflow(**params): + return { + "params": { + "irc_server": "irc.example.org", + "irc_port": "6697", + "use_tls": "true", + "nickname": "misp", + "channel": "#misp", + "message_template": "Event: {{ Event.info }}", + **params, + }, + "data": {"Event": {"info": "Example event"}}, + } + + +class IRCConnection: + def __init__(self): + self.commands = [] + self.responses = deque() + self.registration = b":server 001 misp :Welcome\r\n" + self.join = b":misp!user@host JOIN :#misp\r\n" + self.message_error = None + self.disconnected = False + self.closed = False + self.stream_closed = False + self.timeouts = [] + + def __enter__(self): + return self + + def __exit__(self, *args): + self.closed = True + + def settimeout(self, timeout): + self.timeouts.append(timeout) + + def makefile(self, mode): + assert mode == "rb" + connection = self + + class Stream: + def __enter__(self): + return self + + def __exit__(self, *args): + connection.stream_closed = True + + def readline(self, limit): + if connection.responses: + return connection.responses.popleft()[:limit] + if connection.disconnected: + return b"" + raise socket.timeout("private server details") + + return Stream() + + def sendall(self, data): + assert len(data) <= 512 + assert data.endswith(b"\r\n") + line = data[:-2].decode("utf-8") + assert "\r" not in line and "\n" not in line + self.commands.append(line) + if line.startswith("USER "): + self.responses.append(self.registration) + elif line.startswith("JOIN "): + self.responses.append(self.join) + elif line.startswith("PRIVMSG ") and self.message_error: + self.responses.append(self.message_error) + elif line.startswith("PING "): + self.responses.append(f":server PONG server :{line[6:]}\r\n".encode()) + + +@pytest.fixture +def transport(monkeypatch): + connection = IRCConnection() + connection.connect = Mock(return_value=connection) + connection.tls = Mock() + connection.tls.wrap_socket.return_value = connection + connection.context = Mock(return_value=connection.tls) + monkeypatch.setattr(irc.socket, "create_connection", connection.connect) + monkeypatch.setattr(irc.ssl, "create_default_context", connection.context) + return connection + + +def test_tls_registration_join_send_and_quit(transport): + assert irc.handler(json.dumps(workflow(server_password="secret", channel_key="key"))) == {"data": True} + transport.connect.assert_called_once_with(("irc.example.org", 6697), timeout=30) + transport.context.assert_called_once_with() + transport.tls.wrap_socket.assert_called_once_with(transport, server_hostname="irc.example.org") + assert transport.commands[:5] == [ + "PASS :secret", + "NICK misp", + "USER misp 0 * :MISP workflow", + "JOIN #misp key", + "PRIVMSG #misp :Event: Example event", + ] + assert transport.commands[5].startswith("PING :") + assert transport.commands[-1] == "QUIT :MISP workflow complete" + assert transport.closed and transport.stream_closed + assert all(0 < timeout <= 30 for timeout in transport.timeouts) + + +def test_plaintext_custom_port_and_server_ping(transport): + transport.responses.append(b"PING :server-token\r\n") + assert irc.handler(json.dumps(workflow(use_tls="false", irc_port="6667"))) == {"data": True} + transport.connect.assert_called_once_with(("irc.example.org", 6667), timeout=30) + transport.context.assert_not_called() + assert "PONG :server-token" in transport.commands + assert not any(line.startswith("PASS") for line in transport.commands) + + +def test_matching_data_and_json_fallback(transport): + request = workflow() + request["matchingData"] = {"Event": {"info": "Matched"}} + del request["data"] + assert irc.handler(json.dumps(request)) == {"data": True} + assert "PRIVMSG #misp :Event: Matched" in transport.commands + transport.commands.clear() + request["matchingData"] = {} + request["params"].pop("message_template") + assert irc.handler(json.dumps(request)) == {"data": True} + assert [line for line in transport.commands if line.startswith("PRIVMSG")] == [ + "PRIVMSG #misp :```", + "PRIVMSG #misp :{}", + "PRIVMSG #misp :```", + ] + + +def test_multiline_utf8_messages_stay_within_wire_limit(transport): + message = "é😀" * 300 + "\r\nsecond\n/QUIT\rthird\tline" + assert irc.handler(json.dumps(workflow(message_template=message))) == {"data": True} + lines = [line for line in transport.commands if line.startswith("PRIVMSG")] + text = [line.removeprefix("PRIVMSG #misp :") for line in lines] + assert "".join(text[:-3]) == "é😀" * 300 + assert text[-3:] == ["second", "/QUIT", "third line"] + assert all(len((line + "\r\n").encode()) <= 512 for line in lines) + + +@pytest.mark.parametrize( + "reply", + [b":server 433 * misp :Nickname taken\r\n", b"ERROR :private server details\r\n"], +) +def test_registration_rejection_prevents_join_and_send(transport, reply): + transport.registration = reply + result = irc.handler(json.dumps(workflow())) + assert result["data"] is False and "error" in result + assert not any(line.startswith(("JOIN", "PRIVMSG")) for line in transport.commands) + assert "private server details" not in json.dumps(result) + assert transport.closed and transport.stream_closed + + +@pytest.mark.parametrize( + "reply", + [ + b":server 475 misp #misp :Bad key\r\n", + b":server 473 misp #misp :Invite only\r\n", + b":other!user@host JOIN :#misp\r\n", + b":misp!user@host JOIN :#other\r\n", + ], +) +def test_failed_or_unconfirmed_join_prevents_send(transport, reply): + transport.join = reply + assert irc.handler(json.dumps(workflow()))["data"] is False + assert not any(line.startswith("PRIVMSG") for line in transport.commands) + assert transport.closed and transport.stream_closed + + +def test_casemapping_and_ircv3_tagged_join(transport): + transport.join = b"@time=now :MISP!user@host JOIN :#MISP\r\n" + assert irc.handler(json.dumps(workflow())) == {"data": True} + + +def test_send_rejection_is_failure(transport): + transport.message_error = b":server 404 misp #misp :Cannot send private details\r\n" + result = irc.handler(json.dumps(workflow())) + assert result["data"] is False + assert "private details" not in json.dumps(result) + assert transport.closed and transport.stream_closed + + +@pytest.mark.parametrize("error", [socket.timeout("private"), OSError("private"), ssl.SSLError("private")]) +def test_connection_failure_is_sanitized_without_retry(transport, error): + transport.connect.side_effect = error + result = irc.handler(json.dumps(workflow())) + assert result["data"] is False + assert "private" not in json.dumps(result) + transport.connect.assert_called_once() + + +def test_tls_failure_closes_socket(transport): + transport.tls.wrap_socket.side_effect = ssl.SSLCertVerificationError("private certificate details") + assert irc.handler(json.dumps(workflow()))["data"] is False + assert transport.closed + assert transport.commands == [] + + +def test_disconnect_before_registration(transport): + transport.registration = b"" + transport.disconnected = True + assert irc.handler(json.dumps(workflow()))["data"] is False + assert transport.closed and transport.stream_closed + + +def test_timeout_is_bounded_across_server_messages(transport, monkeypatch): + times = iter([0, 0, 0, 0, 31]) + monkeypatch.setattr(irc, "monotonic", lambda: next(times)) + assert irc.handler(json.dumps(workflow()))["data"] is False + assert not any(line.startswith("PRIVMSG") for line in transport.commands) + + +@pytest.mark.parametrize( + "params", + [ + {"irc_server": ""}, + {"irc_server": None}, + {"irc_server": "irc.example.org\r\nQUIT"}, + {"irc_port": "invalid"}, + {"irc_port": 0}, + {"irc_port": 65536}, + {"irc_port": True}, + {"use_tls": "maybe"}, + {"use_tls": 1}, + {"nickname": "bad name"}, + {"nickname": ":bad"}, + {"nickname": ""}, + {"channel": "misp"}, + {"channel": "#one,#two"}, + {"channel": "#misp\r\nQUIT"}, + {"server_password": "secret\r\nQUIT"}, + {"channel_key": "key other"}, + {"channel_key": "one,two"}, + {"message_template": []}, + {"message_template": "{% invalid %}"}, + {"message_template": "{{ missing }}"}, + {"message_template": "message\x00"}, + {"message_template": "\x01ACTION do something\x01"}, + ], +) +def test_invalid_configuration_fails_before_connection(transport, params): + assert irc.handler(json.dumps(workflow(**params)))["data"] is False + transport.connect.assert_not_called() + + +@pytest.mark.parametrize("payload", ["not JSON", "null", "[]", "{}", '{"params": []}', '{"params": {}}']) +def test_malformed_request(transport, payload): + assert irc.handler(payload)["data"] is False + transport.connect.assert_not_called() + assert irc.handler() is False diff --git a/tests/test_messaging_actions.py b/tests/test_messaging_actions.py new file mode 100644 index 00000000..c7682323 --- /dev/null +++ b/tests/test_messaging_actions.py @@ -0,0 +1,53 @@ +import json +from pathlib import Path +from unittest.mock import patch + +from tornado.testing import AsyncHTTPTestCase +from tornado.web import Application + +import misp_modules +from misp_modules import __main__ as service +from misp_modules.modules.action_mod import irc, signal + + +class TestMessagingActionsHTTP(AsyncHTTPTestCase): + def get_app(self): + self.previous = service.MODULES_HANDLERS.copy() + self.cache = service.ListModules.CACHE + service.MODULES_HANDLERS.clear() + for name, module in (("irc", irc), ("signal", signal)): + service.MODULES_HANDLERS.update({name: module, f"type:{name}": "action_mod"}) + service.ListModules.CACHE = None + return Application([(r"/modules", service.ListModules), (r"/query", service.QueryModule)]) + + def tearDown(self): + service.MODULES_HANDLERS.clear() + service.MODULES_HANDLERS.update(self.previous) + service.ListModules.CACHE = self.cache + super().tearDown() + + def test_discovery_and_dispatch(self): + discovered = { + module.stem + for kind, module in misp_modules.iterate_modules(Path(misp_modules.__file__).parent / "modules") + if kind.name == "action_mod" + } + assert {"irc", "signal"} <= discovered + descriptors = {entry["name"]: entry for entry in json.loads(self.fetch("/modules").body)} + for name, module in (("irc", irc), ("signal", signal)): + descriptor = descriptors[name] + assert descriptor["type"] == "action_mod" + assert descriptor["mispattributes"] == module.introspection() + assert descriptor["meta"]["config"]["support_filters"] is True + assert descriptor["meta"]["config"]["blocking"] is False + assert descriptor["meta"]["config"]["expect_misp_core_format"] is False + assert module.returns == "boolean" + with patch.object(module, "create_post", return_value=True) as send: + response = self.fetch( + "/query", + method="POST", + headers={"Content-Type": "application/json"}, + body=json.dumps({"module": name, "params": {}, "data": {}}), + ) + assert response.code == 200 and json.loads(response.body) == {"data": True} + send.assert_called_once() diff --git a/tests/test_signal.py b/tests/test_signal.py new file mode 100644 index 00000000..b78a8c18 --- /dev/null +++ b/tests/test_signal.py @@ -0,0 +1,137 @@ +import json +from types import SimpleNamespace + +import pytest +import requests + +from misp_modules.modules.action_mod import signal + + +def workflow(**params): + return { + "params": { + "signal_api_url": "http://localhost:8080/proxy/", + "sender_number": "+33123456789", + "recipients": "+33123456780, group.YWJjZA==", + "message_template": "Event: {{ Event.info }}", + **params, + }, + "data": {"Event": {"info": "Example event"}}, + } + + +@pytest.fixture +def transport(monkeypatch): + state = SimpleNamespace(calls=[], status=201, body={"timestamp": "1690000000000"}, error=None) + + def send(session, request, **kwargs): + state.calls.append((request, kwargs)) + if state.error: + raise state.error + response = requests.Response() + response.status_code = state.status + response._content = (state.body if isinstance(state.body, str) else json.dumps(state.body)).encode() + response.url = request.url + return response + + monkeypatch.setattr(requests.Session, "send", send) + return state + + +def test_signal_http_contract(transport): + assert signal.handler(json.dumps(workflow())) == {"data": True} + request, options = transport.calls[0] + assert request.method == "POST" + assert request.url == "http://localhost:8080/proxy/v2/send" + assert request.headers["Content-Type"] == "application/json" + assert json.loads(request.body) == { + "number": "+33123456789", + "recipients": ["+33123456780", "group.YWJjZA=="], + "message": "Event: Example event", + } + assert options["timeout"] == 30 + assert options["allow_redirects"] is False + + +def test_matching_data_and_json_fallback(transport): + request = workflow() + request["matchingData"] = {"Event": {"info": "Matched"}} + del request["data"] + assert signal.handler(json.dumps(request)) == {"data": True} + assert json.loads(transport.calls[-1][0].body)["message"] == "Event: Matched" + for template in ("", None): + request["matchingData"] = {} + if template is None: + request["params"].pop("message_template", None) + else: + request["params"]["message_template"] = template + assert signal.handler(json.dumps(request)) == {"data": True} + assert json.loads(transport.calls[-1][0].body)["message"] == "```\n{}\n```" + + +@pytest.mark.parametrize("status", [200, 202, 204, 301, 302, 400, 401, 429, 500]) +def test_unconfirmed_send_is_failure(transport, status): + transport.status = status + transport.body = {"timestamp": "1690000000000", "error": "+33123456789"} + result = signal.handler(json.dumps(workflow())) + assert result["data"] is False and "error" in result + assert "+33123456789" not in json.dumps(result) + assert len(transport.calls) == 1 + + +@pytest.mark.parametrize("body", [{}, [], None, {"timestamp": ""}, {"timestamp": True}, {"timestamp": 0}, "not JSON"]) +def test_invalid_acknowledgement(transport, body): + transport.body = body + assert signal.handler(json.dumps(workflow()))["data"] is False + + +@pytest.mark.parametrize("timestamp", [1690000000000, "1690000000000"]) +def test_timestamp_formats(transport, timestamp): + transport.body = {"timestamp": timestamp} + assert signal.handler(json.dumps(workflow())) == {"data": True} + + +@pytest.mark.parametrize("error", [requests.Timeout("private response"), requests.ConnectionError("private response")]) +def test_transport_errors_are_sanitized_without_retry(transport, error): + transport.error = error + result = signal.handler(json.dumps(workflow())) + assert result["data"] is False + assert "private response" not in json.dumps(result) + assert len(transport.calls) == 1 + + +@pytest.mark.parametrize( + "params", + [ + {"signal_api_url": "localhost:8080"}, + {"signal_api_url": "ftp://localhost"}, + {"signal_api_url": "https://"}, + {"signal_api_url": "http://user:password@localhost"}, + {"signal_api_url": "http://localhost?token=private"}, + {"signal_api_url": "http://localhost#fragment"}, + {"signal_api_url": "http://localhost:invalid"}, + {"signal_api_url": "http://localhost:0"}, + {"signal_api_url": "http://localhost/\n"}, + {"signal_api_url": None}, + {"sender_number": "33123456789"}, + {"sender_number": None}, + {"recipients": ""}, + {"recipients": "+33123456780,"}, + {"recipients": "group."}, + {"recipients": "invalid"}, + {"recipients": []}, + {"message_template": []}, + {"message_template": "{% invalid %}"}, + {"message_template": "{{ missing }}"}, + ], +) +def test_invalid_configuration_fails_before_network(transport, params): + assert signal.handler(json.dumps(workflow(**params)))["data"] is False + assert transport.calls == [] + + +@pytest.mark.parametrize("payload", ["not JSON", "null", "[]", "{}", '{"params": []}', '{"params": {}}']) +def test_malformed_request(transport, payload): + assert signal.handler(payload)["data"] is False + assert transport.calls == [] + assert signal.handler() is False From 89880c5cc5b3d700a6fa5b68a1012ee5bd66ee23 Mon Sep 17 00:00:00 2001 From: Alexandre Dulaunoy Date: Mon, 26 Oct 2026 08:38:28 +0100 Subject: [PATCH 2/2] fix: reject Signal acknowledgements with recipient errors Treat HTTP 201 responses with nonempty errors as failed sends even when a valid timestamp is present. Add regression coverage for recipient and group errors, preserve empty-error success, and document partial-failure behavior. Validation: 150 focused tests passed; Black, isort, flake8, Python 3.10 syntax and diff checks passed. --- documentation/action_mod/signal.md | 8 +++++--- misp_modules/modules/action_mod/signal.py | 7 +++++-- tests/test_signal.py | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/documentation/action_mod/signal.md b/documentation/action_mod/signal.md index 2322cf8d..7a984d8f 100644 --- a/documentation/action_mod/signal.md +++ b/documentation/action_mod/signal.md @@ -24,9 +24,11 @@ present, including an empty value; otherwise they use `data`. Workflow filters are supported. Messages are plain text, so Markdown markers remain literal text. The action sends `POST /v2/send` with `number`, `recipients` and `message`, using -a 30-second timeout. Success requires HTTP 201 and a positive `timestamp` in the -response. This acknowledges the service's send operation, not recipient delivery. -Errors return `data: false` without exposing phone numbers or the response body. +a 30-second timeout. Success requires HTTP 201, a positive `timestamp`, and an +absent or empty `errors` field in the response. Recipient errors, including +partial failures with a valid timestamp, return `data: false`. A successful +response acknowledges the service's send operation, not recipient delivery. +Errors are reported without exposing phone numbers or the response body. Requests are not automatically retried; a timeout can leave delivery uncertain. Like Mattermost, this action is nonblocking. diff --git a/misp_modules/modules/action_mod/signal.py b/misp_modules/modules/action_mod/signal.py index 7c44c6a6..a36708af 100644 --- a/misp_modules/modules/action_mod/signal.py +++ b/misp_modules/modules/action_mod/signal.py @@ -106,7 +106,10 @@ def create_post(request): if response.status_code != 201: return False acknowledgement = response.json() - timestamp = acknowledgement.get("timestamp") if isinstance(acknowledgement, dict) else None + # The API can return 201 with a timestamp even when some recipients failed. + if not isinstance(acknowledgement, dict) or acknowledgement.get("errors"): + return False + timestamp = acknowledgement.get("timestamp") return ( not isinstance(timestamp, bool) and isinstance(timestamp, (int, str)) @@ -129,7 +132,7 @@ def handler(q=False): except (KeyError, TypeError, ValueError, TemplateError): return {"data": False, "error": "Invalid Signal configuration, workflow data, template or API response."} if not success: - return {"data": False, "error": "Signal message was not acknowledged by the API service."} + return {"data": False, "error": "Signal send was not fully acknowledged by the API service."} return {"data": True} diff --git a/tests/test_signal.py b/tests/test_signal.py index b78a8c18..5e92feee 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -91,6 +91,29 @@ def test_timestamp_formats(transport, timestamp): assert signal.handler(json.dumps(workflow())) == {"data": True} +@pytest.mark.parametrize( + "errors", + [ + {"+33123456780": "private recipient failure"}, + {"group.YWJjZA==": {"+33123456780": "private recipient failure"}}, + ["private recipient failure"], + ], +) +def test_recipient_errors_fail_even_with_a_valid_timestamp(transport, errors): + transport.body = {"timestamp": "1690000000000", "errors": errors} + result = signal.handler(json.dumps(workflow())) + assert result["data"] is False and "error" in result + assert "+33123456780" not in json.dumps(result) + assert "private recipient failure" not in json.dumps(result) + assert len(transport.calls) == 1 + + +@pytest.mark.parametrize("errors", [{}, [], None]) +def test_empty_recipient_errors_allow_success(transport, errors): + transport.body = {"timestamp": "1690000000000", "errors": errors} + assert signal.handler(json.dumps(workflow())) == {"data": True} + + @pytest.mark.parametrize("error", [requests.Timeout("private response"), requests.ConnectionError("private response")]) def test_transport_errors_are_sanitized_without_retry(transport, error): transport.error = error