From 471fa2928fb52fd958f973320ff6173643337457 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Mon, 20 Jul 2026 18:13:26 +0800 Subject: [PATCH] fix(device-plugins): repair bundled WAF and EDR integrations Separate dispatcher actions from API payload fields, align script handler signatures with the loader contract, preserve response payloads in ToolResult.output, and add regression coverage. --- .../huaweicloud_waf_v39/hw_waf.handler.py | 32 ++-- .../huaweicloud_waf_v39/hw_waf_event.yaml | 9 +- .../huaweicloud_waf_v39/hw_waf_policy.yaml | 10 +- .../huorong_edr_v1_0/huorong.handler.py | 13 +- ..._huaweicloud_waf_huorong_device_plugins.py | 181 ++++++++++++++++++ 5 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 tests/tool/test_huaweicloud_waf_huorong_device_plugins.py diff --git a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py index fd0e4409b..5f6739eb0 100644 --- a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py +++ b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py @@ -267,10 +267,10 @@ async def _request( if resp.status >= 400: return ToolResult( success=False, - data=resp_json, + output=resp_json, error=f"HTTP {resp.status}: {resp_text[:300]}", ) - return ToolResult(success=True, data=resp_json) + return ToolResult(success=True, output=resp_json) def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: @@ -291,10 +291,9 @@ def _page_query(params: dict[str, Any]) -> dict[str, Any]: # --------------------------------------------------------------------------- -async def host(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def host(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") if action == "host_list": q = _page_query(params) @@ -360,10 +359,9 @@ async def host(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def policy(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") if action == "policy_list": q = _page_query(params) @@ -400,7 +398,9 @@ async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: if action == "cc_rule_create": pol_id = params["policy_id"] - body = _pick(params, "url", "limit_num", "limit_period", "lock_time", "tag_type", "action") + body = _pick(params, "url", "limit_num", "limit_period", "lock_time", "tag_type") + if params.get("rule_action") is not None: + body["action"] = params["rule_action"] return await _request(cfg, "POST", f"/v1/{pid}/waf/policy/{pol_id}/cc", body=body) if action == "cc_rule_delete": @@ -415,7 +415,9 @@ async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: if action == "custom_rule_create": pol_id = params["policy_id"] - body = _pick(params, "name", "conditions", "action", "priority", "description") + body = _pick(params, "name", "conditions", "priority", "description") + if params.get("rule_action") is not None: + body["action"] = params["rule_action"] return await _request(cfg, "POST", f"/v1/{pid}/waf/policy/{pol_id}/custom", body=body) if action == "custom_rule_delete": @@ -446,16 +448,17 @@ async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def event(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def event(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") if action == "event_list": q = _page_query(params) - for k in ("from", "to", "hosts", "attacks", "action"): + for k in ("from", "to", "hosts", "attacks"): if params.get(k) is not None: q[k] = params[k] + if params.get("event_action") is not None: + q["action"] = params["event_action"] return await _request(cfg, "GET", f"/v1/{pid}/waf/event/attack/logs", query=q) if action == "event_show": @@ -471,9 +474,11 @@ async def event(params: dict[str, Any], ctx: ToolContext) -> ToolResult: if action == "event_export_job": body = {} - for k in ("from", "to", "hosts", "attacks", "action"): + for k in ("from", "to", "hosts", "attacks"): if params.get(k) is not None: body[k] = params[k] + if params.get("event_action") is not None: + body["action"] = params["event_action"] return await _request(cfg, "POST", f"/v1/{pid}/waf/event/attack/log/job", body=body) if action == "threat_distribution": @@ -500,10 +505,9 @@ async def event(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def overview(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def overview(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") def _time_query() -> dict[str, Any]: q: dict[str, Any] = {} diff --git a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml index 0c6c99713..183d78778 100644 --- a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml +++ b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml @@ -20,7 +20,7 @@ inputSchema: - event_list 用途: 查询攻击事件列表(分页) 必填: 无 - 常用: `from`、`to`、`hosts`、`attacks`、`action`、`page`、`pagesize` + 常用: `from`、`to`、`hosts`、`attacks`、`event_action`、`page`、`pagesize` 风险提示: 只读查询接口;建议传 from/to 缩小范围 是否任务型: 否 - event_show @@ -38,7 +38,7 @@ inputSchema: - event_export_job 用途: 下发自定义导出攻击事件的异步任务 必填: `from`、`to` - 常用: `from`、`to`、`hosts`、`attacks`、`action` + 常用: `from`、`to`、`hosts`、`attacks`、`event_action` 风险提示: 写操作(下发异步任务);任务完成后可通过 `event_log_download` 获取结果 是否任务型: 是 - threat_distribution @@ -89,9 +89,12 @@ inputSchema: items: type: string description: 攻击类型列表(如 `sqli`、`xss`、`cmdi`、`cc` 等) - action: + event_action: type: string description: 防护动作筛选,`block`(拦截)或 `log`(仅记录) + enum: + - block + - log top: type: integer description: 返回 Top N 条数,默认 5 diff --git a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml index fb04016f7..e37f02ed5 100644 --- a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml +++ b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml @@ -61,8 +61,8 @@ inputSchema: 是否任务型: 否 - cc_rule_create 用途: 创建 CC 防护规则 - 必填: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`action` - 常用: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`action` + 必填: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`rule_action` + 常用: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`rule_action` 风险提示: 写操作;会新增 CC 规则 是否任务型: 否 - cc_rule_delete @@ -79,8 +79,8 @@ inputSchema: 是否任务型: 否 - custom_rule_create 用途: 创建精准防护规则 - 必填: `policy_id`、`name`、`conditions`、`action` - 常用: `policy_id`、`name`、`conditions`、`action`、`priority` + 必填: `policy_id`、`name`、`conditions`、`rule_action` + 常用: `policy_id`、`name`、`conditions`、`rule_action`、`priority` 风险提示: 写操作;会新增精准防护规则 是否任务型: 否 - custom_rule_delete @@ -168,7 +168,7 @@ inputSchema: tag_type: type: string description: CC 规则标签类型,如 `ip`、`cookie`、`header` 等 - action: + rule_action: type: object description: 规则匹配后的动作,含 `category`(`block`/`pass`/`log`)等字段 conditions: diff --git a/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py b/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py index 3168dfdbc..cdb1fdcd7 100644 --- a/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py +++ b/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py @@ -156,10 +156,10 @@ async def _post( if resp.status >= 400: return ToolResult( success=False, - data=resp_json, + output=resp_json, error=f"HTTP {resp.status}: {resp_text[:200]}", ) - return ToolResult(success=True, data=resp_json) + return ToolResult(success=True, output=resp_json) def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: @@ -171,9 +171,8 @@ def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: # --------------------------------------------------------------------------- -async def group(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def group(ctx: ToolContext, action: str, **params: Any) -> ToolResult: base_url, timeout, secret_id, secret_key, verify_ssl = _resolve_runtime_config() - action = params.get("action", "") if action == "group_list": return await _post(base_url, "/api/group/_list", {}, secret_id, secret_key, timeout, verify_ssl) @@ -194,9 +193,8 @@ async def group(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def clnts(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def clnts(ctx: ToolContext, action: str, **params: Any) -> ToolResult: base_url, timeout, secret_id, secret_key, verify_ssl = _resolve_runtime_config() - action = params.get("action", "") if action == "clnts_online": body = _pick(params, "offset") @@ -231,9 +229,8 @@ async def clnts(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def task(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def task(ctx: ToolContext, action: str, **params: Any) -> ToolResult: base_url, timeout, secret_id, secret_key, verify_ssl = _resolve_runtime_config() - action = params.get("action", "") if action == "task_create": body = _pick(params, "offset") diff --git a/tests/tool/test_huaweicloud_waf_huorong_device_plugins.py b/tests/tool/test_huaweicloud_waf_huorong_device_plugins.py new file mode 100644 index 000000000..e3a8cc8ee --- /dev/null +++ b/tests/tool/test_huaweicloud_waf_huorong_device_plugins.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import ast +import inspect +import shutil +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml + +from flocks.tool.registry import ToolContext, ToolResult +from flocks.tool.tool_loader import yaml_to_tool + + +_ROOT = Path(__file__).resolve().parents[2] +_DEVICE_ROOT = _ROOT / ".flocks" / "flockshub" / "plugins" / "tools" / "device" +_HUAWEI_DIR = _DEVICE_ROOT / "huaweicloud_waf_v39" +_HUORONG_DIR = _DEVICE_ROOT / "huorong_edr_v1_0" + + +def _installed_tool( + source_dir: Path, + yaml_name: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + project_root = tmp_path / "project" + install_dir = project_root / ".flocks" / "plugins" / "tools" / "device" / source_dir.name + shutil.copytree(source_dir, install_dir) + monkeypatch.chdir(project_root) + yaml_path = install_dir / yaml_name + raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + return raw, yaml_to_tool(raw, yaml_path) + + +def _script_globals(tool) -> dict[str, Any]: + return inspect.getclosurevars(tool.handler).nonlocals["fn"].__globals__ + + +def _ctx() -> ToolContext: + return ToolContext(session_id="device-plugin-test", message_id="device-plugin-test") + + +def test_huawei_manifests_separate_dispatch_action_from_api_payload(): + event = yaml.safe_load((_HUAWEI_DIR / "hw_waf_event.yaml").read_text(encoding="utf-8")) + event_properties = event["inputSchema"]["properties"] + + assert "event_list" in event_properties["action"]["enum"] + assert event_properties["event_action"]["enum"] == ["block", "log"] + + policy = yaml.safe_load((_HUAWEI_DIR / "hw_waf_policy.yaml").read_text(encoding="utf-8")) + policy_properties = policy["inputSchema"]["properties"] + + assert "policy_list" in policy_properties["action"]["enum"] + assert policy_properties["rule_action"]["type"] == "object" + + +@pytest.mark.parametrize( + "handler_path", + [_HUAWEI_DIR / "hw_waf.handler.py", _HUORONG_DIR / "huorong.handler.py"], +) +def test_tool_result_calls_use_supported_fields(handler_path: Path): + module = ast.parse(handler_path.read_text(encoding="utf-8"), filename=str(handler_path)) + unsupported: list[tuple[int, str]] = [] + for node in ast.walk(module): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name) or node.func.id != "ToolResult": + continue + unsupported.extend( + (node.lineno, keyword.arg) + for keyword in node.keywords + if keyword.arg is not None and keyword.arg not in ToolResult.model_fields + ) + + assert unsupported == [] + + +@pytest.mark.parametrize( + "yaml_name", + [ + "hw_waf_host.yaml", + "hw_waf_policy.yaml", + "hw_waf_event.yaml", + "hw_waf_overview.yaml", + ], +) +@pytest.mark.asyncio +async def test_huawei_handlers_execute_through_script_loader( + yaml_name: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUAWEI_DIR, yaml_name, tmp_path, monkeypatch) + globals_ = _script_globals(tool) + monkeypatch.setitem(globals_, "_load_config", lambda *_: SimpleNamespace(project_id="project-1")) + + result = await tool.handler(_ctx(), action="unsupported") + + assert result.success is False + assert result.error == "Unknown action: unsupported" + + +@pytest.mark.asyncio +async def test_huawei_event_action_maps_to_api_filter( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUAWEI_DIR, "hw_waf_event.yaml", tmp_path, monkeypatch) + globals_ = _script_globals(tool) + calls: list[dict[str, Any]] = [] + + async def fake_request(cfg, method, path, query=None, body=None): + calls.append({"method": method, "path": path, "query": query, "body": body}) + return ToolResult(success=True, output={}) + + monkeypatch.setitem(globals_, "_load_config", lambda *_: SimpleNamespace(project_id="project-1")) + monkeypatch.setitem(globals_, "_request", fake_request) + + result = await tool.handler( + _ctx(), + action="event_list", + event_action="block", + page=1, + ) + + assert result.success is True + assert calls[0]["query"] == {"page": 1, "action": "block"} + + +@pytest.mark.asyncio +async def test_huawei_rule_action_maps_to_api_body( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUAWEI_DIR, "hw_waf_policy.yaml", tmp_path, monkeypatch) + globals_ = _script_globals(tool) + calls: list[dict[str, Any]] = [] + + async def fake_request(cfg, method, path, query=None, body=None): + calls.append({"method": method, "path": path, "query": query, "body": body}) + return ToolResult(success=True, output={}) + + monkeypatch.setitem(globals_, "_load_config", lambda *_: SimpleNamespace(project_id="project-1")) + monkeypatch.setitem(globals_, "_request", fake_request) + rule_action = {"category": "block"} + + result = await tool.handler( + _ctx(), + action="cc_rule_create", + policy_id="policy-1", + url="/login", + rule_action=rule_action, + ) + + assert result.success is True + assert calls[0]["body"] == {"url": "/login", "action": rule_action} + + +@pytest.mark.parametrize( + "yaml_name", + ["huorong_group.yaml", "huorong_clnts.yaml", "huorong_task.yaml"], +) +@pytest.mark.asyncio +async def test_huorong_handlers_execute_through_script_loader( + yaml_name: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUORONG_DIR, yaml_name, tmp_path, monkeypatch) + globals_ = _script_globals(tool) + monkeypatch.setitem( + globals_, + "_resolve_runtime_config", + lambda: ("https://huorong.example.com", 30, "secret-id", "secret-key", False), + ) + + result = await tool.handler(_ctx(), action="unsupported") + + assert result.success is False + assert result.error == "Unknown action: unsupported"