From a894cf7e41aa6115d1414324dceb3e6b99e55744 Mon Sep 17 00:00:00 2001
From: Alexandre Dulaunoy
Date: Thu, 29 Oct 2026 18:10:23 +0100
Subject: [PATCH 1/2] feat: [doppel_alert_takedown] add alert and takedown
workflow actions
---
.../action_mod/doppel_alert_takedown.md | 18 +++
.../action_mod/doppel_alert_takedown.py | 140 ++++++++++++++++++
tests/test_doppel_alert_takedown.py | 96 ++++++++++++
3 files changed, 254 insertions(+)
create mode 100644 documentation/action_mod/doppel_alert_takedown.md
create mode 100644 misp_modules/modules/action_mod/doppel_alert_takedown.py
create mode 100644 tests/test_doppel_alert_takedown.py
diff --git a/documentation/action_mod/doppel_alert_takedown.md b/documentation/action_mod/doppel_alert_takedown.md
new file mode 100644
index 000000000..39690dd2b
--- /dev/null
+++ b/documentation/action_mod/doppel_alert_takedown.md
@@ -0,0 +1,18 @@
+# Doppel Alert and Takedown
+
+Create an alert for an explicit URL or request takedown for an existing alert through the Doppel V1 API.
+
+Configure `DOPPEL_API_KEY` on the module server. Optional `DOPPEL_USER_API_KEY` selects a user-specific identity, and `DOPPEL_ORGANIZATION_CODE` selects the organization for users belonging to several organizations. Credentials are not workflow parameters.
+
+Set workflow `action` to `create_alert` and supply `entity`, an HTTP(S) URL. The provider returns an existing alert when the entity is already registered. A successful call can therefore return an existing alert ID.
+
+For `request_takedown`, supply exactly one of `alert_id` and `entity`, with an optional `comment`. The module reads the alert before updating it. Alerts already in `actioned` or `taken_down` return their existing state. Other alerts receive `PUT /v1/alert` with `queue_state=actioned`, the provider's documented Request Takedown mapping. `actioned` means requested; completion is represented by `taken_down` and can only be confirmed by the provider. Concurrent workflow executions can still race between the read and update; provider queue policy rejects duplicate state transitions.
+
+Provider queue policy may reject a request, including exhausted trial allowances, unsupported dark-web takedowns, or a missing rationale for potential false positives. Supply at least 15 non-whitespace characters in the comment for potential false positives. Errors report the HTTP status without copying provider bodies or keys. After a timeout, inspect Doppel before repeating the workflow. Calls verify TLS, reject redirects, use connection/read timeouts of 5/30 seconds, and are never automatically retried.
+
+The result includes the alert ID, link, queue state and `request_performed` flag. An enrichment lookup does not invoke this action.
+
+References:
+
+- https://doppel.readme.io/docs/authentication
+- https://doppel.readme.io/docs/request-takedown
diff --git a/misp_modules/modules/action_mod/doppel_alert_takedown.py b/misp_modules/modules/action_mod/doppel_alert_takedown.py
new file mode 100644
index 000000000..f8d3f4c84
--- /dev/null
+++ b/misp_modules/modules/action_mod/doppel_alert_takedown.py
@@ -0,0 +1,140 @@
+"""Create a Doppel alert or request takedown through an explicit workflow action."""
+
+import json
+import os
+import re
+from urllib.parse import urlsplit
+
+import requests
+
+moduleinfo = {
+ "version": "1.0",
+ "author": "MISP Project",
+ "name": "Doppel Alert and Takedown",
+ "description": "Create Doppel alerts or explicitly request takedown for an existing alert.",
+ "module-type": ["action"],
+ "data-source": "external",
+ "logo": "",
+ "requirements": ["requests", "DOPPEL_API_KEY in the module server environment"],
+ "features": "Actions use the Doppel V1 API. Existing actioned or resolved alerts are not actioned again.",
+ "references": ["https://doppel.readme.io/docs/request-takedown"],
+ "input": "Explicit entity URL or alert ID and action parameters.",
+ "output": "Alert ID, workflow state, link and whether a request was performed.",
+}
+moduleconfig = {
+ "params": {
+ "action": {"type": "string", "value": "create_alert", "description": "create_alert or request_takedown"},
+ "entity": {"type": "string", "description": "HTTP(S) URL; exactly one of entity or alert_id for takedown"},
+ "alert_id": {"type": "string", "description": "Existing Doppel alert ID, such as ACM-1234"},
+ "comment": {"type": "large_string", "description": "Optional rationale, sent with a takedown request"},
+ },
+ "blocking": True,
+ "support_filters": False,
+ "expect_misp_core_format": False,
+}
+returns = "data"
+API = "https://api.doppel.com/v1/alert"
+
+
+class ActionError(ValueError):
+ """A diagnostic that does not expose credentials."""
+
+
+def _request(method, headers, **kwargs):
+ try:
+ response = requests.request(
+ method, API, headers=headers, timeout=(5, 30), verify=True, allow_redirects=False, **kwargs
+ )
+ except requests.RequestException:
+ raise ActionError("Doppel request failed or timed out; check the alert before repeating the action.") from None
+ if response.status_code not in (200, 201):
+ raise ActionError(f"Doppel returned HTTP {response.status_code}; check the alert before retrying.")
+ try:
+ data = response.json()
+ except ValueError:
+ raise ActionError("Doppel returned invalid JSON; check the alert before retrying.") from None
+ if not isinstance(data, dict) or not isinstance(data.get("id"), str) or not data["id"]:
+ raise ActionError("Doppel did not return an alert acknowledgement; check the service before retrying.")
+ return data
+
+
+def handler(q=False):
+ if q is False:
+ return False
+ try:
+ request = json.loads(q)
+ params = request.get("params") if isinstance(request, dict) else None
+ if not isinstance(params, dict):
+ raise ActionError("Workflow params are required.")
+ action = params.get("action", "create_alert")
+ entity, alert_id = params.get("entity") or "", params.get("alert_id") or ""
+ comment = params.get("comment") or ""
+ if action not in ("create_alert", "request_takedown"):
+ raise ActionError("action must be create_alert or request_takedown.")
+ if not all(isinstance(value, str) for value in (entity, alert_id, comment)):
+ raise ActionError("entity, alert_id and comment must be strings.")
+ if entity:
+ parsed = urlsplit(entity)
+ if (
+ parsed.scheme not in ("http", "https")
+ or not parsed.hostname
+ or parsed.username
+ or parsed.password
+ or any(ord(c) <= 32 for c in entity)
+ ):
+ raise ActionError("entity must be an HTTP(S) URL without embedded credentials.")
+ parsed.port
+ if alert_id and not re.fullmatch(r"[A-Za-z0-9]+-[0-9]+", alert_id):
+ raise ActionError("Invalid Doppel alert ID.")
+ if bool(entity) == bool(alert_id) or (action == "create_alert" and not entity):
+ raise ActionError("Supply entity for creation, or exactly one of entity and alert_id for takedown.")
+ if len(comment) > 10000:
+ raise ActionError("comment must be at most 10000 characters.")
+ api_key = os.getenv("DOPPEL_API_KEY", "")
+ if not api_key:
+ raise ActionError("Configure DOPPEL_API_KEY in the module server environment.")
+ headers = {"x-api-key": api_key, "Accept": "application/json"}
+ for environment, header in [
+ ("DOPPEL_USER_API_KEY", "x-user-api-key"),
+ ("DOPPEL_ORGANIZATION_CODE", "x-organization-code"),
+ ]:
+ if os.getenv(environment):
+ headers[header] = os.environ[environment]
+ performed = True
+ if action == "create_alert":
+ data = _request("POST", headers, json={"entity": entity})
+ else:
+ selector = {"id": alert_id} if alert_id else {"entity": entity}
+ data = _request("GET", headers, params=selector)
+ if data.get("queue_state") in ("actioned", "taken_down"):
+ performed = False
+ else:
+ body = {"queue_state": "actioned"}
+ if comment:
+ body["comment"] = comment
+ data = _request("PUT", headers, params=selector, json=body)
+ if data.get("queue_state") != "actioned":
+ raise ActionError(
+ "Doppel did not acknowledge the takedown request; check the alert before retrying."
+ )
+ return {
+ "data": {
+ "action": action,
+ "alert_id": data["id"],
+ "queue_state": data.get("queue_state"),
+ "link": "https://app.doppel.com/alerts/" + data["id"],
+ "request_performed": performed,
+ }
+ }
+ except ActionError as exc:
+ return {"error": str(exc)}
+ except (ValueError, TypeError, KeyError, AttributeError):
+ return {"error": "Invalid workflow parameters or Doppel response."}
+
+
+def introspection():
+ return {"config": moduleconfig}
+
+
+def version():
+ return dict(moduleinfo, config=moduleconfig)
diff --git a/tests/test_doppel_alert_takedown.py b/tests/test_doppel_alert_takedown.py
new file mode 100644
index 000000000..051410dc6
--- /dev/null
+++ b/tests/test_doppel_alert_takedown.py
@@ -0,0 +1,96 @@
+import json
+from unittest.mock import Mock, patch
+
+import pytest
+import requests
+
+from misp_modules.modules.action_mod import doppel_alert_takedown as module
+
+
+def query(**params):
+ return json.dumps({"params": params})
+
+
+def reply(data, status=200):
+ return Mock(status_code=status, json=Mock(return_value=data))
+
+
+def test_creation_uses_one_post_and_environment_authentication():
+ with (
+ patch.dict("os.environ", {"DOPPEL_API_KEY": "test-secret"}),
+ patch.object(
+ module.requests, "request", return_value=reply({"id": "ACM-1", "queue_state": "doppel_review"})
+ ) as request,
+ ):
+ result = module.handler(query(action="create_alert", entity="https://bad.example"))
+ assert result["data"]["alert_id"] == "ACM-1"
+ assert request.call_count == 1
+ assert request.call_args.args[0] == "POST"
+ assert request.call_args.kwargs["json"] == {"entity": "https://bad.example"}
+ assert "test-secret" not in str(result)
+
+
+def test_takedown_checks_existing_state_and_requests_actioned():
+ with (
+ patch.dict("os.environ", {"DOPPEL_API_KEY": "test-secret"}),
+ patch.object(
+ module.requests,
+ "request",
+ side_effect=[
+ reply({"id": "ACM-1", "queue_state": "needs_confirmation"}),
+ reply({"id": "ACM-1", "queue_state": "actioned"}),
+ ],
+ ) as request,
+ ):
+ result = module.handler(
+ query(action="request_takedown", alert_id="ACM-1", comment="Confirmed phishing investigation.")
+ )
+ assert result["data"]["request_performed"] is True
+ assert request.call_args.args[0] == "PUT"
+ assert request.call_args.kwargs["json"]["queue_state"] == "actioned"
+ assert request.call_args.kwargs["params"] == {"id": "ACM-1"}
+
+
+@pytest.mark.parametrize("state", ["actioned", "taken_down"])
+def test_repeated_takedown_does_not_send_another_update(state):
+ with (
+ patch.dict("os.environ", {"DOPPEL_API_KEY": "test-secret"}),
+ patch.object(module.requests, "request", return_value=reply({"id": "ACM-1", "queue_state": state})) as request,
+ ):
+ result = module.handler(query(action="request_takedown", alert_id="ACM-1"))
+ assert result["data"]["request_performed"] is False
+ assert request.call_count == 1
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"action": "bad"},
+ {"action": "create_alert", "entity": "file:///etc/passwd"},
+ {"action": "request_takedown", "alert_id": "ACM-1", "entity": "https://example.com"},
+ ],
+)
+def test_invalid_parameters_do_not_call_service(params):
+ with patch.object(module.requests, "request") as request:
+ assert "error" in module.handler(query(**params))
+ request.assert_not_called()
+
+
+def test_transport_failure_is_not_retried():
+ with (
+ patch.dict("os.environ", {"DOPPEL_API_KEY": "test-secret"}),
+ patch.object(module.requests, "request", side_effect=requests.Timeout("test-secret")) as request,
+ ):
+ result = module.handler(query(action="create_alert", entity="https://bad.example"))
+ assert "error" in result and "test-secret" not in str(result)
+ assert request.call_count == 1
+ assert request.call_args.kwargs["timeout"] == (5, 30)
+ assert request.call_args.kwargs["verify"] is True
+ assert request.call_args.kwargs["allow_redirects"] is False
+
+
+def test_discovery():
+ assert module.handler(False) is False
+ assert module.introspection()["config"]["blocking"] is True
+ assert module.version()["config"] == module.moduleconfig
From 5ddf86fad1479577dbfea3e4bd727f912c74efde Mon Sep 17 00:00:00 2001
From: Alexandre Dulaunoy
Date: Thu, 29 Oct 2026 23:26:21 +0100
Subject: [PATCH 2/2] fix: [doppel_alert_takedown] address review findings
---
.../action_mod/doppel_alert_takedown.py | 3 ++-
tests/test_doppel_alert_takedown.py | 20 ++++++++++++++++---
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/misp_modules/modules/action_mod/doppel_alert_takedown.py b/misp_modules/modules/action_mod/doppel_alert_takedown.py
index f8d3f4c84..96450825d 100644
--- a/misp_modules/modules/action_mod/doppel_alert_takedown.py
+++ b/misp_modules/modules/action_mod/doppel_alert_takedown.py
@@ -103,6 +103,7 @@ def handler(q=False):
performed = True
if action == "create_alert":
data = _request("POST", headers, json={"entity": entity})
+ data = _request("GET", headers, params={"id": data["id"]})
else:
selector = {"id": alert_id} if alert_id else {"entity": entity}
data = _request("GET", headers, params=selector)
@@ -122,7 +123,7 @@ def handler(q=False):
"action": action,
"alert_id": data["id"],
"queue_state": data.get("queue_state"),
- "link": "https://app.doppel.com/alerts/" + data["id"],
+ "link": data.get("doppel_link"),
"request_performed": performed,
}
}
diff --git a/tests/test_doppel_alert_takedown.py b/tests/test_doppel_alert_takedown.py
index 051410dc6..188bf86c7 100644
--- a/tests/test_doppel_alert_takedown.py
+++ b/tests/test_doppel_alert_takedown.py
@@ -24,9 +24,9 @@ def test_creation_uses_one_post_and_environment_authentication():
):
result = module.handler(query(action="create_alert", entity="https://bad.example"))
assert result["data"]["alert_id"] == "ACM-1"
- assert request.call_count == 1
- assert request.call_args.args[0] == "POST"
- assert request.call_args.kwargs["json"] == {"entity": "https://bad.example"}
+ assert request.call_count == 2
+ assert request.call_args_list[0].args[0] == "POST"
+ assert request.call_args_list[0].kwargs["json"] == {"entity": "https://bad.example"}
assert "test-secret" not in str(result)
@@ -94,3 +94,17 @@ def test_discovery():
assert module.handler(False) is False
assert module.introspection()["config"]["blocking"] is True
assert module.version()["config"] == module.moduleconfig
+
+
+def test_creation_fetches_state_and_preserves_provider_link():
+ responses = [
+ reply({"id": "ACM-1"}),
+ reply({"id": "ACM-1", "queue_state": "doppel_review", "doppel_link": "https://vision.doppel.com/drp/ACM-1"}),
+ ]
+ with (
+ patch.dict("os.environ", {"DOPPEL_API_KEY": "test-secret"}),
+ patch.object(module.requests, "request", side_effect=responses),
+ ):
+ result = module.handler(query(action="create_alert", entity="https://bad.example"))
+ assert result["data"]["queue_state"] == "doppel_review"
+ assert result["data"]["link"] == "https://vision.doppel.com/drp/ACM-1"