Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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":
Expand Down Expand Up @@ -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":
Expand All @@ -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":
Expand All @@ -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] = {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
181 changes: 181 additions & 0 deletions tests/tool/test_huaweicloud_waf_huorong_device_plugins.py
Original file line number Diff line number Diff line change
@@ -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"