diff --git a/backend/workflows/actions.py b/backend/workflows/actions.py index 14be92c..8edd09c 100644 --- a/backend/workflows/actions.py +++ b/backend/workflows/actions.py @@ -15,6 +15,7 @@ LogAction, ReleaseIPAction, SendEmailAction, + SendNotificationAction, UpdateTicketAction, ) @@ -33,5 +34,6 @@ "LogAction", "ReleaseIPAction", "SendEmailAction", + "SendNotificationAction", "UpdateTicketAction", ] diff --git a/backend/workflows/prefect/actions/__init__.py b/backend/workflows/prefect/actions/__init__.py index 6f1fef6..034370d 100644 --- a/backend/workflows/prefect/actions/__init__.py +++ b/backend/workflows/prefect/actions/__init__.py @@ -11,6 +11,7 @@ from .registry import ActionRegistry from .release_ip import ReleaseIPAction from .send_email import SendEmailAction +from .send_notification import SendNotificationAction from .update_ticket import UpdateTicketAction __all__ = [ @@ -28,5 +29,6 @@ "LogAction", "ReleaseIPAction", "SendEmailAction", + "SendNotificationAction", "UpdateTicketAction", ] diff --git a/backend/workflows/prefect/actions/registry.py b/backend/workflows/prefect/actions/registry.py index fec0ad9..7e8457a 100644 --- a/backend/workflows/prefect/actions/registry.py +++ b/backend/workflows/prefect/actions/registry.py @@ -14,6 +14,7 @@ from .log import LogAction from .release_ip import ReleaseIPAction from .send_email import SendEmailAction +from .send_notification import SendNotificationAction from .update_ticket import UpdateTicketAction @@ -22,6 +23,7 @@ class ActionRegistry: "log": LogAction, "delay": DelayAction, "send_email": SendEmailAction, + "send_notification": SendNotificationAction, "api_call": ApiCallAction, "create_ticket": CreateTicketAction, "update_ticket": UpdateTicketAction, diff --git a/backend/workflows/prefect/actions/send_notification.py b/backend/workflows/prefect/actions/send_notification.py new file mode 100644 index 0000000..93f85ef --- /dev/null +++ b/backend/workflows/prefect/actions/send_notification.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import json +from typing import Any, Dict +from urllib.parse import urlsplit + +import requests + +from .base import ActionResult, BaseAction + + +MAX_RESPONSE_BYTES = 16 * 1024 +PROVIDERS = {"slack", "feishu", "wecom"} + + +class SendNotificationAction(BaseAction): + name = "Send Notification" + description = "Send a workflow notification to Slack, Feishu, or Wecom using an incoming webhook" + category = "notification" + config_schema = { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": ["slack", "feishu", "wecom"], + "x-enum-labels": { + "slack": "Slack", + "feishu": "Feishu", + "wecom": "Wecom", + }, + "default": "feishu", + "description": "Notification provider", + }, + "webhook_url": { + "type": "string", + "description": "Incoming bot webhook URL for the selected provider", + "writeOnly": True, + "x-sensitive": True, + "x-secret-bindings": ["provider"], + }, + "title": { + "type": "string", + "description": "Notification title. Supports {{variable.path}} placeholders.", + }, + "message": { + "type": "string", + "description": "Notification message. Supports {{variable.path}} placeholders.", + }, + "format": { + "type": "string", + "enum": ["text", "markdown"], + "x-enum-labels": { + "text": "Text", + "markdown": "Markdown", + }, + "default": "markdown", + "description": "Message format", + }, + "mention_all": { + "type": "boolean", + "default": False, + "description": "Mention everyone when supported by the provider", + }, + "include_context_payload": { + "type": "boolean", + "default": False, + "description": "Append selected workflow context data to the message", + }, + "context_source": { + "type": "string", + "enum": ["previous_step", "trigger_data", "variables"], + "x-enum-labels": { + "previous_step": "Previous Step Output", + "trigger_data": "Trigger Data", + "variables": "Workflow Variables", + }, + "default": "previous_step", + "description": "Workflow context data appended when include_context_payload is enabled", + }, + "timeout": { + "type": "integer", + "minimum": 1, + "maximum": 60, + "default": 15, + }, + }, + "required": ["provider", "webhook_url", "message"], + } + + @staticmethod + def _bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + @staticmethod + def _provider(value: Any) -> str: + provider = str(value or "feishu").strip().lower() + aliases = { + "wechat_work": "wecom", + "wechat": "wecom", + "enterprise_wechat": "wecom", + "lark": "feishu", + } + return aliases.get(provider, provider) + + @staticmethod + def _compose(title: str, message: str) -> str: + title = title.strip() + message = message.strip() + if title and message: + return f"{title}\n{message}" + return title or message + + @staticmethod + def _validated_webhook_url(raw_url: str) -> str: + parsed = urlsplit(raw_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.hostname: + raise ValueError("Webhook URL must be an absolute HTTP or HTTPS URL.") + if parsed.username is not None or parsed.password is not None: + raise ValueError("Webhook URL cannot contain embedded credentials.") + if parsed.fragment: + raise ValueError("Webhook URL cannot contain a URL fragment.") + return raw_url + + @staticmethod + def _truncate_response(response: requests.Response) -> str: + raw = response.content or b"" + try: + return raw[:MAX_RESPONSE_BYTES].decode(response.encoding or "utf-8", errors="replace") + except LookupError: + return raw[:MAX_RESPONSE_BYTES].decode("utf-8", errors="replace") + + def _context_payload(self, context: Dict[str, Any], source: str) -> Any: + if source == "trigger_data": + return context.get("trigger_data") or {} + if source == "variables": + return context.get("variables") or {} + previous = context.get("previous_step") or {} + return previous.get("output") or {} + + def _format_context_payload(self, payload: Any, fmt: str) -> str: + if payload in (None, "", {}, []): + return "" + if isinstance(payload, dict): + compact = { + key: value + for key, value in payload.items() + if key not in {"raw_response", "response_body", "response_json"} + } + payload = compact or payload + rendered = json.dumps(payload, ensure_ascii=False, indent=2, default=str) + if fmt == "markdown": + return f"\n\nContext Payload:\n```json\n{rendered}\n```" + return f"\n\nContext Payload:\n{rendered}" + + def _message(self, config: Dict[str, Any], context: Dict[str, Any], fmt: str) -> tuple[str, str]: + title = str(self.resolve_variables(config.get("title", ""), context) or "") + message = str(self.resolve_variables(config.get("message", ""), context) or "") + content = self._compose(title, message) + if self._bool(config.get("include_context_payload")): + source = str(config.get("context_source") or "previous_step") + content += self._format_context_payload(self._context_payload(context, source), fmt) + return title, content + + def _slack_payload(self, title: str, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]: + prefix = "\n" if mention_all else "" + if fmt == "markdown" and title.strip(): + body = content + if body.startswith(title.strip()): + body = body[len(title.strip()):].lstrip("\n") + text = f"{prefix}*{title.strip()}*" + if body: + text += f"\n{body}" + else: + text = prefix + content + return {"text": text, "mrkdwn": fmt == "markdown"} + + def _feishu_payload(self, title: str, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]: + mention = 'All\n' if mention_all else "" + if fmt == "markdown": + return { + "msg_type": "interactive", + "card": { + "config": {"wide_screen_mode": True}, + "header": { + "template": "blue", + "title": { + "tag": "plain_text", + "content": title.strip() or "Workflow Notification", + }, + }, + "elements": [ + { + "tag": "div", + "text": {"tag": "lark_md", "content": mention + content}, + } + ], + }, + } + return {"msg_type": "text", "content": {"text": mention + content}} + + def _wecom_payload(self, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]: + if fmt == "markdown": + mention = "<@all>\n" if mention_all else "" + return {"msgtype": "markdown", "markdown": {"content": mention + content}} + payload: Dict[str, Any] = {"msgtype": "text", "text": {"content": content}} + if mention_all: + payload["text"]["mentioned_list"] = ["@all"] + return payload + + def _payload(self, provider: str, title: str, content: str, fmt: str, mention_all: bool) -> Dict[str, Any]: + if provider == "slack": + return self._slack_payload(title, content, fmt, mention_all) + if provider == "feishu": + return self._feishu_payload(title, content, fmt, mention_all) + return self._wecom_payload(content, fmt, mention_all) + + def _provider_error(self, provider: str, response: requests.Response) -> str: + if not response.content: + return "" + try: + payload = response.json() + except ValueError: + return "" + if not isinstance(payload, dict): + return "" + if provider == "feishu" and int(payload.get("code", 0) or 0) != 0: + return str(payload.get("msg") or payload.get("message") or "Feishu webhook returned an error.") + if provider == "wecom" and int(payload.get("errcode", 0) or 0) != 0: + return str(payload.get("errmsg") or payload.get("message") or "Wecom webhook returned an error.") + return "" + + def execute(self, config: Dict[str, Any], context: Dict[str, Any]) -> ActionResult: + provider = self._provider(config.get("provider")) + if provider not in PROVIDERS: + return ActionResult(False, error=f"Unsupported notification provider: {provider}", logs="Notification validation failed.") + fmt = str(config.get("format") or "markdown").strip().lower() + if fmt not in {"text", "markdown"}: + fmt = "markdown" + try: + timeout = int(config.get("timeout") or 15) + if not 1 <= timeout <= 60: + raise ValueError("Notification timeout must be between 1 and 60 seconds.") + webhook_url = self._validated_webhook_url(str(config.get("webhook_url") or "").strip()) + title, content = self._message(config, context, fmt) + if not content.strip(): + raise ValueError("Notification message is empty after variable resolution.") + payload = self._payload(provider, title, content, fmt, self._bool(config.get("mention_all"))) + response = requests.post( + webhook_url, + headers={"Content-Type": "application/json"}, + json=payload, + timeout=timeout, + allow_redirects=False, + ) + provider_error = self._provider_error(provider, response) + success = response.ok and not provider_error + return ActionResult( + success, + { + "provider": provider, + "status_code": response.status_code, + "response_body": self._truncate_response(response), + "format": fmt, + }, + provider_error or ("" if response.ok else f"Notification webhook returned HTTP {response.status_code}."), + f"Notification request to {provider} returned HTTP {response.status_code}.", + ) + except requests.RequestException as exc: + return ActionResult(False, error=f"Notification request failed ({type(exc).__name__}).", logs=f"Notification to {provider} failed.") + except (TypeError, ValueError) as exc: + return ActionResult(False, error=str(exc), logs="Notification validation failed.") diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 13b1f10..a175f34 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -110,6 +110,12 @@ def bootstrap_presets(self, request): 'description': 'Send a security notification email', 'category': 'notification', }, + { + 'action_type': 'send_notification', + 'name': 'Slack / Feishu / Wecom Notification', + 'description': 'Send a security notification to Slack, Feishu, or Wecom', + 'category': 'notification', + }, { 'action_type': 'api_call', 'name': 'API Call', diff --git a/frontend/src/api.ts b/frontend/src/api.ts index cc7b848..f9590f3 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1286,7 +1286,7 @@ export async function getWorkflow(id: string): Promise { } // Create workflow -type WorkflowWritePayload = Omit, 'execution_engine'> & { +export type WorkflowWritePayload = Omit, 'execution_engine'> & { execution_engine?: 'prefect'; }; diff --git a/frontend/src/modules/workflows/WorkflowEditor.tsx b/frontend/src/modules/workflows/WorkflowEditor.tsx index dbdc4a8..1607081 100644 --- a/frontend/src/modules/workflows/WorkflowEditor.tsx +++ b/frontend/src/modules/workflows/WorkflowEditor.tsx @@ -35,6 +35,7 @@ import { Workflow, WorkflowStep, ActionInfo, + WorkflowWritePayload, } from 'services/workflows'; import { listInterfaceEndpoints } from 'services/interfaces'; import type { InterfaceEndpoint } from 'services/interfaces'; @@ -187,7 +188,7 @@ const WorkflowEditor: React.FC = ({ workflowId, onBack, onS const tags = normalizeTags(values.tags); - const payload: Partial = { + const payload: WorkflowWritePayload = { name: values.name, description: values.description || '', trigger_type: values.trigger_type, @@ -714,4 +715,3 @@ const WorkflowEditor: React.FC = ({ workflowId, onBack, onS }; export default WorkflowEditor; - diff --git a/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx b/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx index 2c37f8d..7ae7b9c 100644 --- a/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx +++ b/frontend/src/modules/workflows/components/ActionConfigBuilder.tsx @@ -45,6 +45,7 @@ type JsonSchemaProperty = { writeOnly?: boolean; items?: JsonSchemaProperty; properties?: Record; + 'x-enum-labels'?: Record | string[]; 'x-sensitive'?: boolean; }; @@ -97,10 +98,51 @@ const formatLabel = (name: string): string => ( const formatOptionLabel = (value: unknown): string => { const raw = String(value); + if (raw === 'wechat_work' || raw === 'wechat' || raw === 'enterprise_wechat' || raw === 'wecom') { + return 'Wecom'; + } if (raw && raw === raw.toUpperCase()) return raw; return formatLabel(raw.replace(/-/g, '_')); }; +const optionLabelFromSchema = ( + value: unknown, + labels?: Record | string[], + values?: unknown[], +): string => { + const raw = String(value); + if (raw === 'wechat_work' || raw === 'wechat' || raw === 'enterprise_wechat' || raw === 'wecom') { + return 'Wecom'; + } + if (Array.isArray(labels)) { + return labels[values?.indexOf(value) || 0] || formatOptionLabel(value); + } + return labels?.[raw] || formatOptionLabel(value); +}; + +const normalizeProviderForAction = (actionType: string, provider: unknown): string => { + const value = String(provider || 'generic').toLowerCase(); + if ( + actionType === 'send_notification' + && ['wechat_work', 'wechat', 'enterprise_wechat', 'wecom'].includes(value) + ) { + return 'wecom'; + } + return value; +}; + +const normalizeProviderOptionValue = (fieldName: string, value: unknown): string | number => { + if (typeof value === 'number') return value; + const raw = String(value); + if ( + fieldName === 'provider' + && ['wechat_work', 'wechat', 'enterprise_wechat', 'wecom'].includes(raw.toLowerCase()) + ) { + return 'wecom'; + } + return raw; +}; + const isMultilineField = (name: string): boolean => ( /(^|_)(body|comment|description|message|notes?|records?|template)$/.test(name) ); @@ -129,15 +171,24 @@ const fieldsFromSchema = (schema?: ActionConfigSchema): FieldDef[] => { const required = new Set(schema?.required || []); return Object.entries(schema?.properties || {}).map(([name, property]) => { const sensitive = Boolean(property.writeOnly || property['x-sensitive']); + const seenOptions = new Set(); + const options = property.enum + ?.map((value) => ({ + value: normalizeProviderOptionValue(name, value), + label: optionLabelFromSchema(value, property['x-enum-labels'], property.enum), + })) + .filter((option) => { + const key = String(option.value); + if (seenOptions.has(key)) return false; + seenOptions.add(key); + return true; + }); return { name, label: property.title || formatLabel(name), type: resolveFieldType(name, property, sensitive), required: required.has(name), - options: property.enum?.map((value) => ({ - value: typeof value === 'number' ? value : String(value), - label: formatOptionLabel(value), - })), + options, default: property.default, description: property.description, sensitive, @@ -278,7 +329,8 @@ const ActionConfigBuilder: React.FC = ({ const [form] = Form.useForm(); const loadedConfigKey = useRef(null); const initialTargets = useRef>({}); - const provider = Form.useWatch('provider', form) || config?.provider || 'generic'; + const watchedProvider = Form.useWatch('provider', form) || config?.provider || 'generic'; + const provider = normalizeProviderForAction(actionType, watchedProvider); const schema = actionInfo?.config_schema as ActionConfigSchema | undefined; const fields = fieldsFromSchema(schema); @@ -298,7 +350,7 @@ const ActionConfigBuilder: React.FC = ({ if (loadedConfigKey.current === nextConfigKey) return; loadedConfigKey.current = nextConfigKey; initialTargets.current = { - provider: String(config?.provider || 'generic').toLowerCase(), + provider: normalizeProviderForAction(actionType, config?.provider), api_url: normalizeTargetValue(config?.api_url), url: normalizeTargetValue(config?.url), alias_name: String(config?.alias_name || 'ARGUS_BLOCKLIST').trim(), @@ -312,6 +364,9 @@ const ActionConfigBuilder: React.FC = ({ && !(typeof value === 'string' && value.startsWith('enc:v1:')) )), ); + if (actionType === 'send_notification' && safeConfig.provider) { + safeConfig.provider = normalizeProviderForAction(actionType, safeConfig.provider); + } form.resetFields(); form.setFieldsValue(safeConfig); setJsonValue(JSON.stringify(safeConfig, null, 2)); @@ -321,12 +376,16 @@ const ActionConfigBuilder: React.FC = ({ const values = { ...inputValues }; const initial = initialTargets.current; const apiUrlChanged = normalizeTargetValue(values.api_url) !== initial.api_url; + if (actionType === 'send_notification' && values.provider) { + values.provider = normalizeProviderForAction(actionType, values.provider); + } const providerChanged = ( - String(values.provider || 'generic').toLowerCase() !== initial.provider + normalizeProviderForAction(actionType, values.provider) !== initial.provider ); const isOPNsenseAction = actionType === 'block_ip' || actionType === 'release_ip'; + const providerBoundSecretAction = actionType === 'send_notification'; const protectedChanged = configuredSecretFields.length > 0 - && (apiUrlChanged || (isOPNsenseAction && providerChanged)); + && (apiUrlChanged || ((isOPNsenseAction || providerBoundSecretAction) && providerChanged)); const nextAliasChanged = ( isOPNsenseAction && configuredSecretFields.some((field) => field === 'api_key' || field === 'api_secret') @@ -465,7 +524,7 @@ const ActionConfigBuilder: React.FC = ({ showIcon style={{ marginBottom: 16 }} message="Credential re-entry required" - description="The Provider or API URL changed. Re-enter the API Key and, for OPNsense, the API Secret before saving." + description="A credential binding field changed. Re-enter the protected credential before saving." /> )} diff --git a/frontend/src/modules/workflows/components/ActionPalette.tsx b/frontend/src/modules/workflows/components/ActionPalette.tsx index 178665c..094e7d5 100644 --- a/frontend/src/modules/workflows/components/ActionPalette.tsx +++ b/frontend/src/modules/workflows/components/ActionPalette.tsx @@ -15,6 +15,7 @@ import { BranchesOutlined, PlayCircleOutlined, StopOutlined, + NotificationOutlined, } from '@ant-design/icons'; import type { ActionInfo, SavedWorkflowNode } from 'services/workflows'; @@ -46,6 +47,7 @@ const toCategoryLabel = (value: string): string => { const actionIcons: Record = { send_email: , + send_notification: , api_call: , ip_lookup: , hash_lookup: , diff --git a/frontend/src/modules/workflows/components/CustomNodes.tsx b/frontend/src/modules/workflows/components/CustomNodes.tsx index a758a19..6da8cdb 100644 --- a/frontend/src/modules/workflows/components/CustomNodes.tsx +++ b/frontend/src/modules/workflows/components/CustomNodes.tsx @@ -21,6 +21,7 @@ import { SearchOutlined, LockOutlined, UnlockOutlined, + NotificationOutlined, } from '@ant-design/icons'; const { Text } = Typography; @@ -45,6 +46,7 @@ const nodeColors: Record = // Icon mapping for known action types const actionIcons: Record = { send_email: , + send_notification: , api_call: , ip_lookup: , hash_lookup: , diff --git a/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx b/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx index a75e3c3..832378b 100644 --- a/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx +++ b/frontend/src/modules/workflows/components/VisualWorkflowEditor.tsx @@ -83,6 +83,7 @@ import type { WorkflowEdge, SavedWorkflowNode, TicketWorkflowBinding, + WorkflowWritePayload, } from 'services/workflows'; import type { InterfaceEndpoint } from 'services/interfaces'; import { nodeTypes } from './CustomNodes'; @@ -1093,7 +1094,7 @@ const VisualWorkflowEditor: React.FC = ({ const steps = nodesToSteps(nodes, sanitizedEdges); const workflowEdges = flowEdgesToWorkflowEdges(sanitizedEdges); - const payload: Partial = { + const payload: WorkflowWritePayload = { name: values.name, description: values.description || '', trigger_type: values.trigger_type, diff --git a/frontend/src/services/workflows/index.ts b/frontend/src/services/workflows/index.ts index 08ab176..819865a 100644 --- a/frontend/src/services/workflows/index.ts +++ b/frontend/src/services/workflows/index.ts @@ -7,6 +7,7 @@ export { type StepExecution, type ActionInfo, type WorkflowStats, + type WorkflowWritePayload, type SavedWorkflowNode, type TicketWorkflowBinding,