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..96450825d --- /dev/null +++ b/misp_modules/modules/action_mod/doppel_alert_takedown.py @@ -0,0 +1,141 @@ +"""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}) + 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) + 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": data.get("doppel_link"), + "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..188bf86c7 --- /dev/null +++ b/tests/test_doppel_alert_takedown.py @@ -0,0 +1,110 @@ +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 == 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) + + +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 + + +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"