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