diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index ea8155f4..c457d2cd 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -601,6 +601,10 @@ const services = { entryFile: "../openinfra__openstack-yoga_2022-1/bin/openstack-yoga-2022-1.js", serviceModule: "../openinfra__openstack-yoga_2022-1/src/service.js", }, + "wd-k01-v9-0-2": { + entryFile: "../wd__k01_v9-0-2/bin/wd-k01-v9-0-2.js", + serviceModule: "../wd__k01_v9-0-2/src/service.js", + }, }; const serviceNames = Object.keys(services); diff --git a/services/bin/wd-k01-v9-0-2.js b/services/bin/wd-k01-v9-0-2.js new file mode 100755 index 00000000..15a8c7c5 --- /dev/null +++ b/services/bin/wd-k01-v9-0-2.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../wd__k01_v9-0-2/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../wd__k01_v9-0-2/bin/wd-k01-v9-0-2.js", import.meta.url)), +}); diff --git a/services/package.json b/services/package.json index 6764a88d..642320be 100644 --- a/services/package.json +++ b/services/package.json @@ -150,7 +150,8 @@ "siem": "bin/siem.js", "security-engine": "bin/security-engine.js", "kubernetes-api": "bin/kubernetes-api.js", - "openstack-yoga-2022-1": "bin/openstack-yoga-2022-1.js" + "openstack-yoga-2022-1": "bin/openstack-yoga-2022-1.js", + "wd-k01-v9-0-2": "bin/wd-k01-v9-0-2.js" }, "files": [ "bin/huawei-ccm.js", @@ -296,6 +297,7 @@ "bin/opencti.js", "bin/qianxin-caasm.js", "bin/api7-enterprise-v3-10-2.js", + "bin/wd-k01-v9-0-2.js", "bin/wangsu-label-ip.js", "bin/reportedip.js", "bin/anyi-cloud-native-security.js", @@ -414,6 +416,7 @@ "qianxin__caasm_v1", "api7__enterprise_v3-10-2", "fofa__network-space-mapper", + "wd__k01_v9-0-2", "wangsu__label-ip", "reportedip__reportedip", "anyi__cloud-native-security", diff --git a/services/wd__k01_v9-0-2/README.md b/services/wd__k01_v9-0-2/README.md new file mode 100644 index 00000000..a51b166c --- /dev/null +++ b/services/wd__k01_v9-0-2/README.md @@ -0,0 +1,96 @@ +# WD K01 V9.0.2 + +网盾 K01 威胁情报联防阻断系统 **V9.0.2** RESTful API(V9 新增接口)的 OctoBus service package。 +覆盖只读告警/名单查询与私有情报(攻击类)的增删查。 + +> 与已有 `wd__k01`(BlockIP / UnblockIP,基于 `/api/v1/security/iplist/save`)互补:本包聚焦 +> **查询与情报管理**,不与其封禁写操作重叠。 + +## 支持版本 + +网盾 K01 V9.0.2(北京中盾网空防务技术有限公司)。文档中标注「V9 新增接口」的接口。 + +## 认证方式 + +仅支持 api 组用户(默认 `apiuser`)。每次方法调用是一段无状态会话: + +1. `POST /api/cms/user/login`,body `{username, password}`,成功返回 `token.access_token`。 +2. 业务请求带 `Authorization: Bearer `。 +3. `POST /api/cms/user/logout` 注销。 + +会话不跨调用缓存;登出在 `finally` 中执行,登出失败只记录脱敏的运行日志,不会覆盖业务结果。 + +## 配置 + +`config.schema.json`(非敏感): + +```json +{ "host": "https://192.168.10.10", "timeoutMs": 1500, "skipTlsVerify": true } +``` + +`secret.schema.json`(敏感): + +```json +{ "user": "apiuser", "password": "" } +``` + +## 方法 + +| RPC | 上游接口 | 说明 | 写操作 | +| --- | --- | --- | --- | +| `QueryAttackLog` | `POST /api/v1/logsystem/atkmntlog/query` | 攻击监测日志查询 | 否 | +| `QueryIPList` | `POST /api/v1/security/iplist/query` | IP 黑/白名单查询 | 否 | +| `QueryThreatIntel` | `POST /api/v1/threatintelligence/attack/query` | 私有情报(攻击类)查询 | 否 | +| `AddThreatIntel` | `POST /api/v1/threatintelligence/attack/save` | 添加私有情报(攻击类) | 是 | +| `DeleteThreatIntel` | `POST /api/v1/threatintelligence/attack/delete` | 按 id 删除私有情报 | 是 | + +### 关键输入 + +- 所有查询的 `page` 必须为正整数且不超过 10000,`count` 取值为 `10`、`20`、`50` 或 `100`(默认 10)。 +- `QueryAttackLog`:`page`/`count`,以及 `type_mask[]`(情报类型,如 256=IP黑名单)、`severity_mask[]`(0低/1中/2高)、`party_3rd_mask[]`(0公有/1私有)、`action_mask[]`(1监控/2阻断)、`r_sip`/`r_dip`/`r_s_time`/`r_e_time` 等过滤项;未提供的可选项不下发,负数列表项会被忽略。 +- `QueryIPList`:`color`(0黑/1白,默认0)、`dir`(0源/1目的/2两者,默认2)、`page`/`count`、`ip_search`/`comment_search`/时间过滤。 +- `AddThreatIntel`:`ip`(IPv4,必填)、`type`(攻击类型,>0)、`severity`(优先级,>0)。`method` 固定 `add`。 +- `DeleteThreatIntel`:`id`(>0)。`method` 固定 `delete`。 + +### 输出 + +- 查询类返回 `success/msg_type/msg/total/page/count/raw_json`。 +- 写类返回 `success/msg_type/msg/id/raw_json`。 + +`raw_json` 会按 SDK 的敏感字段规则脱敏;登录和登出响应、Bearer token、密码和请求头不会进入 RPC 响应或日志。 + +## 风险边界 + +- **写操作**:`AddThreatIntel`、`DeleteThreatIntel`。私有情报增删会即时影响联防阻断决策——误加会导致正常 IP 被研判为威胁,误删会降低检出。无自动回滚:`Add` 用 `Delete`(凭返回 id)还原,`Delete` 需重新 `Add`。 +- 设备 `success/msgType` 非成功一律映射为 `FAILED_PRECONDITION` 并带原始 `msg`,不静默吞错。 +- 默认校验 TLS;私有自签部署需显式 `skipTlsVerify: true`。 + +## 错误映射 + +| 场景 | gRPC code | +| --- | --- | +| 缺 host/账号/密码、ip 非法、type/severity/id ≤0、color/dir 越界 | `INVALID_ARGUMENT` | +| 登录失败 / 业务 msgType 非 success | `FAILED_PRECONDITION` | +| 上游 401 | `UNAUTHENTICATED` | +| 上游 403 | `PERMISSION_DENIED` | +| 网络错误 / 5xx | `UNAVAILABLE` | +| 超时 / 重定向 | `UNAVAILABLE` | +| 响应空体 / 非 JSON | `UNKNOWN` | + +## 建议 capset + +只读核查:`query-attack-log` + `query-ip-list` + `query-threat-intel`。 +情报维护(写,需授权与审计):追加 `add-threat-intel` + `delete-threat-intel`。 + +## 验证方式 + +```bash +cd services +npm run validate -- --service-dir wd__k01_v9-0-2 +npm test -- --service-dir wd__k01_v9-0-2 --coverage +npm run pack:check +``` + +真机验证:`query-attack-log`/`query-ip-list` 直接读;情报写操作用测试 IP(如 `198.51.100.x`) +执行 `add-threat-intel` → `query-threat-intel` 确认 → `delete-threat-intel` 清理。 +PR 附设备版本、认证方式、调用截图与已知限制。**截图/代码里不得出现真实账号、密码、token 或生产地址。** diff --git a/services/wd__k01_v9-0-2/bin/wd-k01-v9-0-2.js b/services/wd__k01_v9-0-2/bin/wd-k01-v9-0-2.js new file mode 100755 index 00000000..8e0066ef --- /dev/null +++ b/services/wd__k01_v9-0-2/bin/wd-k01-v9-0-2.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; + +import { service } from '../src/service.js'; + +runServiceMain(service); diff --git a/services/wd__k01_v9-0-2/config.schema.json b/services/wd__k01_v9-0-2/config.schema.json new file mode 100644 index 00000000..939efa43 --- /dev/null +++ b/services/wd__k01_v9-0-2/config.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "host": { + "type": "string", + "description": "WD K01 Web API base URL, including scheme and optional port." + }, + "restBaseUrl": { + "type": "string", + "description": "Alias for host." + }, + "baseUrl": { + "type": "string", + "description": "Alias for host." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 60000, + "default": 1500, + "description": "HTTP timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private deployments." + }, + "tlsInsecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Legacy alias for skipTlsVerify." + }, + "insecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Legacy alias for skipTlsVerify." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional additional HTTP headers." + } + } +} diff --git a/services/wd__k01_v9-0-2/package.json b/services/wd__k01_v9-0-2/package.json new file mode 100644 index 00000000..dc4256d9 --- /dev/null +++ b/services/wd__k01_v9-0-2/package.json @@ -0,0 +1,12 @@ +{ + "name": "wd-k01-v9-0-2", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "wd-k01-v9-0-2": "bin/wd-k01-v9-0-2.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0" + } +} diff --git a/services/wd__k01_v9-0-2/proto/wd_k01_v9_0_2.proto b/services/wd__k01_v9-0-2/proto/wd_k01_v9_0_2.proto new file mode 100644 index 00000000..620ca59d --- /dev/null +++ b/services/wd__k01_v9-0-2/proto/wd_k01_v9_0_2.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package WD_K01_V9_0_2; + +option go_package = "miner/grpc-service/WD_K01_V9_0_2"; + +// 网盾 K01 威胁情报联防阻断系统 V9.0.2 RESTful API(V9 新增接口)。 +// 认证: POST /api/cms/user/login 取 token.access_token,业务请求带 Authorization: Bearer , +// 完成后 POST /api/cms/user/logout。每次方法调用为一次 login→操作→logout 的无状态会话。 +// 本 service 覆盖只读告警/黑白名单查询与私有情报(攻击类)的增删查, +// 与已有 wd__k01(BlockIP/UnblockIP)互补。 +service WD_K01_V9_0_2 { + // 攻击监测日志查询: /api/v1/logsystem/atkmntlog/query + rpc QueryAttackLog(QueryAttackLogRequest) returns (QueryResponse) {} + // IP 黑白名单查询: /api/v1/security/iplist/query + rpc QueryIPList(QueryIPListRequest) returns (QueryResponse) {} + // 私有情报(攻击类)查询: /api/v1/threatintelligence/attack/query + rpc QueryThreatIntel(QueryThreatIntelRequest) returns (QueryResponse) {} + // 私有情报(攻击类)添加: /api/v1/threatintelligence/attack/save + rpc AddThreatIntel(AddThreatIntelRequest) returns (MutationResponse) {} + // 私有情报(攻击类)删除: /api/v1/threatintelligence/attack/delete + rpc DeleteThreatIntel(DeleteThreatIntelRequest) returns (MutationResponse) {} +} + +message QueryAttackLogRequest { + optional int32 page = 1; // 页码,默认 1;显式 0 非法 + optional int32 count = 2; // 每页数量,取值 [10,20,50,100],默认 10;显式 0 非法 + repeated int32 type_mask = 3; // 情报类型编码(见文档表1,如 256=IP黑名单) + repeated int32 severity_mask = 4; // 威胁等级(0 低 / 1 中危 / 2 高危) + repeated int32 party_3rd_mask = 5;// 命中情报源(0 公有 / 1 私有) + repeated int32 action_mask = 6; // 处理动作(1 监控 / 2 阻断) + string r_sip = 7; // 攻击 IP + string r_dip = 8; // 被攻击 IP + int32 country = 9; // 国家编码 + int32 province = 10; // 省份编码 + string r_s_time = 11; // 开始时间,如 2025-07-01 00:00:00 + string r_e_time = 12; // 结束时间 +} + +message QueryIPListRequest { + optional int32 page = 1; // 页码,默认 1;显式 0 非法 + optional int32 count = 2; // 每页数量,取值 [10,20,50,100],默认 10;显式 0 非法 + int32 color = 3; // 名单类型(0 IP 黑名单 / 1 IP 白名单),默认 0 + int32 dir = 4; // 方向(0 源 / 1 目的 / 2 源和目的),默认 2 + string ip_search = 5; // 过滤 IP/网段,可选 + string comment_search = 6;// 过滤备注,可选 + string r_s_time = 7; // 过滤开始时间,可选 + string r_e_time = 8; // 过滤结束时间,可选 +} + +message QueryThreatIntelRequest { + optional int32 page = 1; // 页码,默认 1;显式 0 非法 + optional int32 count = 2; // 每页数量,取值 [10,20,50,100],默认 10;显式 0 非法 + int32 source_id = 3; // 情报源 id,可选(接口新增的情报源查询时传 63) +} + +message AddThreatIntelRequest { + string ip = 1; // 私有情报 IP,必填,IPv4 + int32 type = 2; // 攻击类型,必填 + int32 severity = 3;// 攻击优先级,必填 +} + +message DeleteThreatIntelRequest { + int64 id = 1; // 情报 id,必填,>0 +} + +// 查询类统一响应:data 透传为 raw_json,并抽取分页字段。 +message QueryResponse { + bool success = 1; // 设备 success 字段或 msgType=success + string msg_type = 2; // 设备 msgType + string msg = 3; // 设备 msg + int64 total = 4; // data.total + int64 page = 5; // data.page + int64 count = 6; // data.count + string raw_json = 7; // 经敏感字段脱敏后的业务 JSON +} + +// 写类统一响应。 +message MutationResponse { + bool success = 1; // 设备 success 字段或 msgType=success + string msg_type = 2; // 设备 msgType + string msg = 3; // 设备 msg + int64 id = 4; // 设备返回的 id(如有) + string raw_json = 5; // 经敏感字段脱敏后的业务 JSON +} diff --git a/services/wd__k01_v9-0-2/secret.schema.json b/services/wd__k01_v9-0-2/secret.schema.json new file mode 100644 index 00000000..435d4871 --- /dev/null +++ b/services/wd__k01_v9-0-2/secret.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "user": { + "type": "string", + "description": "WD K01 API-group login username (e.g. apiuser)." + }, + "username": { + "type": "string", + "description": "Alias for user." + }, + "password": { + "type": "string", + "description": "WD K01 login password." + } + } +} diff --git a/services/wd__k01_v9-0-2/service.json b/services/wd__k01_v9-0-2/service.json new file mode 100644 index 00000000..376d7ea2 --- /dev/null +++ b/services/wd__k01_v9-0-2/service.json @@ -0,0 +1,45 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "wd-k01-v9-0-2", + "displayName": "WD K01 V9.0.2", + "description": "OctoBus package for WD K01 threat-intelligence joint-defense system V9.0.2: attack-monitor log query, IP blacklist/whitelist query, and private (attack) threat-intel add/delete/query.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/wd_k01_v9_0_2.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "WD_K01_V9_0_2.WD_K01_V9_0_2/QueryAttackLog": { + "name": "query-attack-log", + "description": "Query WD K01 attack-monitor logs." + }, + "WD_K01_V9_0_2.WD_K01_V9_0_2/QueryIPList": { + "name": "query-ip-list", + "description": "Query WD K01 IP blacklist/whitelist entries." + }, + "WD_K01_V9_0_2.WD_K01_V9_0_2/QueryThreatIntel": { + "name": "query-threat-intel", + "description": "Query WD K01 private (attack) threat intelligence." + }, + "WD_K01_V9_0_2.WD_K01_V9_0_2/AddThreatIntel": { + "name": "add-threat-intel", + "description": "Add a WD K01 private (attack) threat-intel IP." + }, + "WD_K01_V9_0_2.WD_K01_V9_0_2/DeleteThreatIntel": { + "name": "delete-threat-intel", + "description": "Delete a WD K01 private (attack) threat-intel entry by id." + } + } + } + } +} diff --git a/services/wd__k01_v9-0-2/src/service.js b/services/wd__k01_v9-0-2/src/service.js new file mode 100644 index 00000000..7e97d905 --- /dev/null +++ b/services/wd__k01_v9-0-2/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './wd-k01-v9-0-2.js'; + +export { handlers } from './wd-k01-v9-0-2.js'; + +export const service = defineService({ handlers }); diff --git a/services/wd__k01_v9-0-2/src/wd-k01-v9-0-2.js b/services/wd__k01_v9-0-2/src/wd-k01-v9-0-2.js new file mode 100644 index 00000000..d94eeb9f --- /dev/null +++ b/services/wd__k01_v9-0-2/src/wd-k01-v9-0-2.js @@ -0,0 +1,515 @@ +// 网盾 K01 威胁情报联防阻断系统 V9.0.2 RESTful API 适配(V9 新增接口)。 +// 覆盖:攻击监测日志查询、IP 黑白名单查询、私有情报(攻击类)增/删/查。 +// 认证沿用 K01 既有方式:login 取 token.access_token → Bearer → logout,每次调用一会话。 +import { GrpcError, createTlsDispatcher, fetchWithTimeout, redactSensitive, serviceError } from '@chaitin-ai/octobus-sdk'; + +const SVC = 'WD_K01_V9_0_2.WD_K01_V9_0_2'; +export const QUERY_ATTACK_LOG_PATH = `/${SVC}/QueryAttackLog`; +export const QUERY_IPLIST_PATH = `/${SVC}/QueryIPList`; +export const QUERY_INTEL_PATH = `/${SVC}/QueryThreatIntel`; +export const ADD_INTEL_PATH = `/${SVC}/AddThreatIntel`; +export const DELETE_INTEL_PATH = `/${SVC}/DeleteThreatIntel`; + +export const METHOD_QUERY_ATTACK_LOG_FULL = `${SVC}/QueryAttackLog`; +export const METHOD_QUERY_IPLIST_FULL = `${SVC}/QueryIPList`; +export const METHOD_QUERY_INTEL_FULL = `${SVC}/QueryThreatIntel`; +export const METHOD_ADD_INTEL_FULL = `${SVC}/AddThreatIntel`; +export const METHOD_DELETE_INTEL_FULL = `${SVC}/DeleteThreatIntel`; + +export const LOGIN_PATH = '/api/cms/user/login'; +export const LOGOUT_PATH = '/api/cms/user/logout'; +export const ATKMNTLOG_QUERY_PATH = '/api/v1/logsystem/atkmntlog/query'; +export const IPLIST_QUERY_PATH = '/api/v1/security/iplist/query'; +export const INTEL_QUERY_PATH = '/api/v1/threatintelligence/attack/query'; +export const INTEL_SAVE_PATH = '/api/v1/threatintelligence/attack/save'; +export const INTEL_DELETE_PATH = '/api/v1/threatintelligence/attack/delete'; +export const DEFAULT_TIMEOUT_MS = 1500; + +const errorWithCode = (code, message, details) => serviceError(code, String(redactSensitive(message)), details); + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const unwrapScalar = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'object' && hasOwn(value, 'value')) return unwrapScalar(value.value); + return value; +}; + +const pickStringFrom = (source = {}, keys = []) => { + for (const key of keys) { + if (!hasOwn(source, key)) continue; + const raw = unwrapScalar(source[key]); + if (raw === undefined || raw === null) continue; + const value = String(raw).trim(); + if (value) return value; + } + return ''; +}; + +const pickFirstString = (values = []) => { + for (const value of values) { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) continue; + const str = String(raw).trim(); + if (str) return str; + } + return ''; +}; + +const pickInt = (source = {}, keys = [], fallback = 0) => { + for (const key of keys) { + if (!hasOwn(source, key)) continue; + const raw = unwrapScalar(source[key]); + if (raw === undefined || raw === null || raw === '') continue; + const num = Number(raw); + if (Number.isFinite(num)) return Math.trunc(num); + } + return fallback; +}; + +const pickBoolean = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return undefined; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return Number.isNaN(raw) ? undefined : raw !== 0; + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off', ''].includes(normalized)) return false; + } + return undefined; +}; + +const pickFirstBoolean = (values = []) => { + for (const value of values) { + const bool = pickBoolean(value); + if (bool !== undefined) return bool; + } + return undefined; +}; + +// 仅保留请求里出现且为非负整数的列表项;用于 type_mask 等 list 参数。 +const pickIntList = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return undefined; + const arr = Array.isArray(raw) ? raw : hasOwn(raw, 'values') && Array.isArray(raw.values) ? raw.values : null; + if (arr === null) return undefined; + const out = []; + for (const item of arr) { + const num = Number(unwrapScalar(item)); + if (Number.isFinite(num) && num >= 0) out.push(Math.trunc(num)); + } + return out.length ? out : undefined; +}; + +const normalizeBaseUrl = (value) => { + const raw = String(unwrapScalar(value) || '').trim(); + if (!/^https?:\/\//i.test(raw)) return ''; + return raw.replace(/\/+$/, ''); +}; + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: { + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), + }, + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.req ?? ctx.request ?? {}, +}); + +const resolveHost = (bindings = {}) => { + for (const value of [bindings.host, bindings.restBaseUrl, bindings.baseUrl]) { + const host = normalizeBaseUrl(value); + if (host) return host; + } + return ''; +}; +const resolveUser = (bindings = {}) => pickStringFrom(bindings, ['user', 'username']); +const resolvePassword = (bindings = {}) => pickStringFrom(bindings, ['password']); + +const resolveTimeoutMs = (ctx = {}) => { + const raw = Number(unwrapScalar(ctx.limits?.timeoutMs ?? ctx.bindings?.timeoutMs ?? DEFAULT_TIMEOUT_MS)); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS; +}; + +const buildTlsOptions = (bindings = {}) => { + const enabled = pickFirstBoolean([bindings.skipTlsVerify, bindings.tlsInsecureSkipVerify, bindings.insecureSkipVerify]) || false; + const dispatcher = createTlsDispatcher(enabled); + return dispatcher ? { dispatcher } : {}; +}; + +const sanitizeHeaders = (headers) => { + const raw = unwrapScalar(headers); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + return Object.fromEntries(Object.entries(raw).filter(([key]) => key).map(([key, value]) => [key, String(unwrapScalar(value) ?? '')])); +}; + +const buildHeaders = (bindings = {}, meta = {}, extra = {}) => ({ + ...sanitizeHeaders(bindings.headers), + 'x-engine-instance': pickFirstString([meta.instance_id, meta.instanceId, 'unknown']), + 'x-request-id': pickFirstString([meta.request_id, meta.requestId, 'unknown']), + ...extra, +}); + +const parseJsonBody = (text) => { + try { + return JSON.parse(text); + } catch { + throw errorWithCode('UNKNOWN', 'response is not valid JSON'); + } +}; + +const throwForHttpStatus = (status) => { + if (status === 401) throw errorWithCode('UNAUTHENTICATED', 'upstream authentication failed'); + if (status === 403) throw errorWithCode('PERMISSION_DENIED', 'upstream authorization failed'); + if (status >= 400 && status < 500) throw errorWithCode('FAILED_PRECONDITION', `upstream rejected request (HTTP ${status})`); + throw errorWithCode('UNAVAILABLE', `upstream request failed (HTTP ${status})`); +}; + +const fetchRaw = async (ctx, url, init = {}) => { + const callCtx = resolveCallContext(ctx); + let response; + try { + response = await fetchWithTimeout(url, { + redirect: 'error', + ...init, + headers: buildHeaders(callCtx.bindings, callCtx.meta, init.headers || {}), + }, { + timeoutMs: resolveTimeoutMs(callCtx), + ...buildTlsOptions(callCtx.bindings), + }); + } catch (err) { + if (err instanceof GrpcError) throw err; + throw errorWithCode('UNAVAILABLE', err?.cause?.message || err?.message || 'fetch failed'); + } + const text = await response.text(); + if (!response.ok) throwForHttpStatus(response.status); + return { text }; +}; + +const fetchJson = async (ctx, url, init = {}) => { + const { text } = await fetchRaw(ctx, url, init); + if (!String(text || '').trim()) throw errorWithCode('UNKNOWN', 'response body is empty'); + return { json: parseJsonBody(text), text }; +}; + +const requireBindings = (ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const bindings = callCtx.bindings || {}; + const host = resolveHost(bindings); + if (!host) throw errorWithCode('INVALID_ARGUMENT', 'bindings.host is required'); + const user = resolveUser(bindings); + if (!user) throw errorWithCode('INVALID_ARGUMENT', 'bindings.user/username is required'); + const password = resolvePassword(bindings); + if (!password) throw errorWithCode('INVALID_ARGUMENT', 'bindings.password is required'); + return { ...callCtx, bindings, host, user, password }; +}; + +const isIPv4 = (value) => { + const raw = String(value || '').trim(); + const parts = raw.split('.'); + if (parts.length !== 4) return false; + return parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255 && part.length <= 3); +}; + +const requireIpv4 = (value) => { + const ip = String(unwrapScalar(value) || '').trim(); + if (!ip) throw errorWithCode('INVALID_ARGUMENT', 'ip is required'); + if (!isIPv4(ip)) throw errorWithCode('INVALID_ARGUMENT', 'ip must be a valid IPv4 address'); + return ip; +}; + +const requirePositiveInt = (value, field) => { + const num = Number(unwrapScalar(value)); + if (!Number.isInteger(num) || num <= 0) throw errorWithCode('INVALID_ARGUMENT', `${field} must be a positive integer`); + return num; +}; + +const requirePage = (value) => { + const page = requirePositiveInt(value, 'page'); + if (page > 10000) throw errorWithCode('OUT_OF_RANGE', 'page must not exceed 10000'); + return page; +}; + +const requirePageSize = (value) => { + const count = requirePositiveInt(value, 'count'); + if (![10, 20, 50, 100].includes(count)) throw errorWithCode('INVALID_ARGUMENT', 'count must be one of 10, 20, 50, or 100'); + return count; +}; + +const logFlow = (ctx = {}, action, details = {}) => { + const meta = ctx.meta || {}; + const trace = []; + if (meta.instance_id || meta.instanceId) trace.push(`inst=${meta.instance_id || meta.instanceId}`); + if (meta.request_id || meta.requestId) trace.push(`req=${meta.request_id || meta.requestId}`); + const prefix = `[WD_K01_V9_0_2][${action}]${trace.length ? `[${trace.join(' ')}]` : ''}`; + // Never log credentials, bearer tokens, headers, URLs, or upstream bodies. + console.log(prefix, JSON.stringify(redactSensitive(details))); +}; + +const isSemanticSuccess = (json) => { + if (json === null || json === undefined) return false; + if (typeof json.success === 'boolean') return json.success; + return json.ok === true + || Number(json.code) === 0 + || String(json.msgType || '').trim().toLowerCase() === 'success'; +}; + +// ---- session ---- + +const handleLogin = async (ctx = {}) => { + const callCtx = requireBindings(ctx); + const started = Date.now(); + const { json } = await fetchJson(callCtx, `${callCtx.host}${LOGIN_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ username: callCtx.user, password: callCtx.password }), + }); + const token = pickFirstString([ + pickStringFrom(json?.token || {}, ['access_token', 'accessToken']), + json?.token, + json?.access_token, + json?.accessToken, + ]); + if (hasOwn(json, 'error') || !isSemanticSuccess(json) || !token) { + logFlow(callCtx, 'Login', { elapsed_ms: Date.now() - started, success: false }); + throw errorWithCode('FAILED_PRECONDITION', '用户登录失败'); + } + logFlow(callCtx, 'Login', { elapsed_ms: Date.now() - started, success: true }); + return { token }; +}; + +const handleLogout = async (ctx = {}, token) => { + const callCtx = requireBindings(ctx); + await fetchRaw(callCtx, `${callCtx.host}${LOGOUT_PATH}`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8', authorization: `Bearer ${token}` }, + }); + logFlow(callCtx, 'Logout', { success: true }); +}; + +const withSession = async (ctx = {}, actionFn) => { + const login = await handleLogin(ctx); + let result; + try { + result = await actionFn(login.token); + return result; + } finally { + try { + await handleLogout(ctx, login.token); + } catch (logoutErr) { + logFlow(resolveCallContext(ctx), 'Logout', { success: false, error: 'logout request failed' }); + } + } +}; + +// ---- upstream callers ---- + +const callBusiness = async (ctx, token, path, payload) => { + const { json, text } = await fetchJson(ctx, `${ctx.host}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json; charset=utf-8', authorization: `Bearer ${token}` }, + body: JSON.stringify(payload), + }); + return { json, text }; +}; + +const toQueryResult = (json, text) => { + const data = json?.data && typeof json.data === 'object' ? json.data : {}; + return { + success: isSemanticSuccess(json), + msg_type: String(json?.msgType || ''), + msg: String(json?.msg || ''), + total: pickInt(data, ['total'], 0), + page: pickInt(data, ['page'], 0), + count: pickInt(data, ['count'], 0), + raw_json: JSON.stringify(redactSensitive(json)), + }; +}; + +const toMutationResult = (json, text) => ({ + success: isSemanticSuccess(json), + msg_type: String(json?.msgType || ''), + msg: String(json?.msg || ''), + id: pickInt(json, ['id'], 0), + raw_json: JSON.stringify(redactSensitive(json)), +}); + +// ---- request builders ---- + +const buildAttackLogPayload = (req = {}) => { + const payload = { + method: 'query', + page: requirePage(pickInt(req, ['page'], 1)), + count: requirePageSize(pickInt(req, ['count'], 10)), + }; + const typeMask = pickIntList(req.type_mask ?? req.typeMask); + if (typeMask) payload.type_mask = typeMask; + const severityMask = pickIntList(req.severity_mask ?? req.severityMask); + if (severityMask) payload.severity_mask = severityMask; + const partyMask = pickIntList(req.party_3rd_mask ?? req.party3rdMask); + if (partyMask) payload.party_3rd_mask = partyMask; + const actionMask = pickIntList(req.action_mask ?? req.actionMask); + if (actionMask) payload.action_mask = actionMask; + const rSip = pickStringFrom(req, ['r_sip', 'rSip']); + if (rSip) payload.r_sip = rSip; + const rDip = pickStringFrom(req, ['r_dip', 'rDip']); + if (rDip) payload.r_dip = rDip; + const country = pickInt(req, ['country'], 0); + if (country) payload.country = country; + const province = pickInt(req, ['province'], 0); + if (province) payload.province = province; + const sTime = pickStringFrom(req, ['r_s_time', 'rSTime']); + if (sTime) payload.r_s_time = sTime; + const eTime = pickStringFrom(req, ['r_e_time', 'rETime']); + if (eTime) payload.r_e_time = eTime; + return payload; +}; + +const buildIPListPayload = (req = {}) => { + const color = pickInt(req, ['color'], 0); + if (color !== 0 && color !== 1) throw errorWithCode('INVALID_ARGUMENT', 'color must be 0 (black) or 1 (white)'); + const dir = pickInt(req, ['dir'], 2); + if (![0, 1, 2].includes(dir)) throw errorWithCode('INVALID_ARGUMENT', 'dir must be 0, 1, or 2'); + const payload = { + page: requirePage(pickInt(req, ['page'], 1)), + count: requirePageSize(pickInt(req, ['count'], 10)), + color, + dir, + }; + const ipSearch = pickStringFrom(req, ['ip_search', 'ipSearch', 'Ip_Search']); + if (ipSearch) payload.Ip_Search = ipSearch; + const commentSearch = pickStringFrom(req, ['comment_search', 'commentSearch', 'Comment_Search']); + if (commentSearch) payload.Comment_Search = commentSearch; + const sTime = pickStringFrom(req, ['r_s_time', 'rSTime']); + if (sTime) payload.r_s_time = sTime; + const eTime = pickStringFrom(req, ['r_e_time', 'rETime']); + if (eTime) payload.r_e_time = eTime; + return payload; +}; + +const buildIntelQueryPayload = (req = {}) => { + const payload = { + page: requirePage(pickInt(req, ['page'], 1)), + count: requirePageSize(pickInt(req, ['count'], 10)), + }; + const sourceId = pickInt(req, ['source_id', 'sourceId'], 0); + if (sourceId) payload.source_id = sourceId; + return payload; +}; + +const buildIntelAddPayload = (req = {}) => ({ + ip: requireIpv4(req.ip ?? req.IP), + type: requirePositiveInt(req.type, 'type'), + severity: requirePositiveInt(req.severity, 'severity'), + method: 'add', +}); + +const buildIntelDeletePayload = (req = {}) => ({ + id: requirePositiveInt(req.id, 'id'), + method: 'delete', +}); + +// ---- runners ---- + +const runQuery = async (req, ctx, action, path, buildPayload) => { + const callCtx = resolveCallContext({ ...ctx, req: { ...(ctx.req || {}), ...(req || {}) } }); + const bound = requireBindings(callCtx); + const payload = buildPayload(bound.req || {}); + return withSession(bound, (token) => + callBusiness(bound, token, path, payload).then(({ json, text }) => { + const out = toQueryResult(json, text); + logFlow(bound, action, { success: out.success, total: out.total }); + if (!out.success) throw errorWithCode('FAILED_PRECONDITION', String(out.msg || `${action} failed`)); + return out; + })); +}; + +const runMutation = async (req, ctx, action, path, buildPayload) => { + const callCtx = resolveCallContext({ ...ctx, req: { ...(ctx.req || {}), ...(req || {}) } }); + const bound = requireBindings(callCtx); + const payload = buildPayload(bound.req || {}); + return withSession(bound, (token) => + callBusiness(bound, token, path, payload).then(({ json, text }) => { + const out = toMutationResult(json, text); + logFlow(bound, action, { success: out.success, id: out.id }); + if (!out.success) throw errorWithCode('FAILED_PRECONDITION', String(out.msg || `${action} failed`)); + return out; + })); +}; + +const runQueryAttackLog = (req = {}, ctx = {}) => runQuery(req, ctx, 'QueryAttackLog', ATKMNTLOG_QUERY_PATH, buildAttackLogPayload); +const runQueryIPList = (req = {}, ctx = {}) => runQuery(req, ctx, 'QueryIPList', IPLIST_QUERY_PATH, buildIPListPayload); +const runQueryThreatIntel = (req = {}, ctx = {}) => runQuery(req, ctx, 'QueryThreatIntel', INTEL_QUERY_PATH, buildIntelQueryPayload); +const runAddThreatIntel = (req = {}, ctx = {}) => runMutation(req, ctx, 'AddThreatIntel', INTEL_SAVE_PATH, buildIntelAddPayload); +const runDeleteThreatIntel = (req = {}, ctx = {}) => runMutation(req, ctx, 'DeleteThreatIntel', INTEL_DELETE_PATH, buildIntelDeletePayload); + +const requestFromContext = (ctx = {}) => ctx.request ?? ctx.req ?? {}; +const handlerFor = (runner) => (ctx = {}) => runner(requestFromContext(ctx), ctx); + +export function rpcdef(ctx = {}) { + const callCtx = resolveCallContext(ctx); + const pick = (req) => req ?? callCtx.req; + return { + [QUERY_ATTACK_LOG_PATH]: async (req) => runQueryAttackLog(pick(req), callCtx), + [QUERY_IPLIST_PATH]: async (req) => runQueryIPList(pick(req), callCtx), + [QUERY_INTEL_PATH]: async (req) => runQueryThreatIntel(pick(req), callCtx), + [ADD_INTEL_PATH]: async (req) => runAddThreatIntel(pick(req), callCtx), + [DELETE_INTEL_PATH]: async (req) => runDeleteThreatIntel(pick(req), callCtx), + }; +} + +export const handlers = { + [METHOD_QUERY_ATTACK_LOG_FULL]: handlerFor(runQueryAttackLog), + [METHOD_QUERY_IPLIST_FULL]: handlerFor(runQueryIPList), + [METHOD_QUERY_INTEL_FULL]: handlerFor(runQueryThreatIntel), + [METHOD_ADD_INTEL_FULL]: handlerFor(runAddThreatIntel), + [METHOD_DELETE_INTEL_FULL]: handlerFor(runDeleteThreatIntel), +}; + +export const _test = { + buildAttackLogPayload, + buildHeaders, + buildIPListPayload, + buildIntelAddPayload, + buildIntelDeletePayload, + buildIntelQueryPayload, + buildTlsOptions, + callBusiness, + errorWithCode, + fetchJson, + fetchRaw, + handleLogin, + handleLogout, + hasOwn, + isIPv4, + isSemanticSuccess, + normalizeBaseUrl, + parseJsonBody, + pickBoolean, + pickFirstBoolean, + pickFirstString, + pickInt, + pickIntList, + pickStringFrom, + requireBindings, + requireIpv4, + requirePositiveInt, + requirePage, + requirePageSize, + requestFromContext, + resolveCallContext, + resolveHost, + resolvePassword, + resolveTimeoutMs, + resolveUser, + sanitizeHeaders, + throwForHttpStatus, + toMutationResult, + toQueryResult, + unwrapScalar, + withSession, +}; diff --git a/services/wd__k01_v9-0-2/test/mock_upstream.js b/services/wd__k01_v9-0-2/test/mock_upstream.js new file mode 100644 index 00000000..ed030790 --- /dev/null +++ b/services/wd__k01_v9-0-2/test/mock_upstream.js @@ -0,0 +1,84 @@ +/* node:coverage disable */ +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; + +// 模拟网盾 K01 V9.0.2:登录/登出 + 攻击日志/IP名单/私有情报 查询与增删。 +export const createMockServer = async ({ user = 'apiuser', password = 'ApiUser!2025' } = {}) => { + const state = { tokens: new Set(), intel: new Map(), requests: [] }; + + const sendJson = (res, payload, status = 200) => { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(payload)); + }; + + const readJsonBody = (req) => + new Promise((resolve) => { + let raw = ''; + req.on('data', (c) => { raw += c; }); + req.on('end', () => { try { resolve(raw.trim() ? JSON.parse(raw) : {}); } catch { resolve({}); } }); + }); + + const bearer = (req) => { + const h = String(req.headers?.authorization || ''); + return h.startsWith('Bearer ') ? h.slice(7) : ''; + }; + + const queryOk = (list) => ({ + data: { count: 10, page: 1, total: list.length, list }, + msg: '', msgType: 'success', showMsg: true, success: true, + }); + + const server = http.createServer((req, res) => { + (async () => { + const body = await readJsonBody(req); + state.requests.push({ url: req.url, body, auth: bearer(req) }); + + if (req.url === '/api/cms/user/login') { + if (body.username !== user || body.password !== password) { sendJson(res, { error: 'bad creds', success: false }); return; } + const token = randomUUID(); + state.tokens.add(token); + sendJson(res, { success: true, status: true, msg: 'ok', token: { access_token: token, refresh_token: randomUUID() } }); + return; + } + if (req.url === '/api/cms/user/logout') { state.tokens.delete(bearer(req)); res.writeHead(200); res.end('logout ok'); return; } + + // 业务接口需带有效 token + if (!state.tokens.has(bearer(req))) { sendJson(res, { msg: 'unauthorized' }, 401); return; } + + if (req.url === '/api/v1/logsystem/atkmntlog/query') { + sendJson(res, queryOk([{ id: 1, r_sip: '8.8.8.8', r_dip: '10.0.0.1', info_type: 256 }])); + return; + } + if (req.url === '/api/v1/security/iplist/query') { + sendJson(res, queryOk([{ id: 7, ip: '1.1.11.9/32', comment: '', type: body.dir ?? 0 }])); + return; + } + if (req.url === '/api/v1/threatintelligence/attack/query') { + sendJson(res, queryOk(Array.from(state.intel.values()))); + return; + } + if (req.url === '/api/v1/threatintelligence/attack/save') { + const id = state.intel.size + 1; + state.intel.set(id, { id, ip: body.ip, info_type: body.type, score: body.severity }); + sendJson(res, { error_code: 0, id, msg: '添加成功', msgType: 'success', showMsg: true, success: true }); + return; + } + if (req.url === '/api/v1/threatintelligence/attack/delete') { + state.intel.delete(Number(body.id)); + sendJson(res, { error_code: 0, id: Number(body.id), msg: '删除成功', msgType: 'success', showMsg: true, success: true }); + return; + } + sendJson(res, { msg: 'not found', success: false }, 404); + })().catch((err) => sendJson(res, { msg: String(err?.message || err) }, 500)); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + return { + state, + host: `http://127.0.0.1:${port}`, + user, + password, + async close() { await new Promise((resolve) => server.close(resolve)); }, + }; +}; diff --git a/services/wd__k01_v9-0-2/test/smoke.json b/services/wd__k01_v9-0-2/test/smoke.json new file mode 100644 index 00000000..1578ac41 --- /dev/null +++ b/services/wd__k01_v9-0-2/test/smoke.json @@ -0,0 +1,7 @@ +{ + "method": "WD_K01_V9_0_2.WD_K01_V9_0_2/QueryThreatIntel", + "request": {}, + "protocols": ["connect", "grpc", "mcp"], + "requireBusinessSuccess": true, + "requireUpstreamPerProtocol": true +} diff --git a/services/wd__k01_v9-0-2/test/wd-k01-v9-0-2.test.js b/services/wd__k01_v9-0-2/test/wd-k01-v9-0-2.test.js new file mode 100644 index 00000000..456f28cb --- /dev/null +++ b/services/wd__k01_v9-0-2/test/wd-k01-v9-0-2.test.js @@ -0,0 +1,316 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { + QUERY_ATTACK_LOG_PATH, + QUERY_IPLIST_PATH, + QUERY_INTEL_PATH, + ADD_INTEL_PATH, + DELETE_INTEL_PATH, + METHOD_QUERY_ATTACK_LOG_FULL, + METHOD_QUERY_IPLIST_FULL, + METHOD_QUERY_INTEL_FULL, + METHOD_ADD_INTEL_FULL, + METHOD_DELETE_INTEL_FULL, + _test, + handlers, + rpcdef, +} from '../src/wd-k01-v9-0-2.js'; +import { service } from '../src/service.js'; +import { createMockServer } from './mock_upstream.js'; + +const originalFetch = globalThis.fetch; +let seq = 0; +const nextId = () => `inst-${++seq}`; + +const buildCtx = (mock, overrides = {}) => ({ + bindings: { host: mock?.host, user: mock?.user, password: mock?.password, ...(overrides.bindings || {}) }, + config: overrides.config || {}, + secret: overrides.secret || {}, + limits: { timeoutMs: 10_000, ...(overrides.limits || {}) }, + meta: { instance_id: nextId(), request_id: 'req', ...(overrides.meta || {}) }, + req: overrides.req || {}, +}); + +const invoke = (method, request = {}, ctx = {}) => handlers[method]({ ...ctx, request }); + +const createHeaders = (entries = {}) => { + const map = new Map(); + for (const [k, v] of Object.entries(entries)) map.set(String(k).toLowerCase(), Array.isArray(v) ? v.map(String) : [String(v)]); + return { get(n) { const x = map.get(String(n).toLowerCase()); return x?.length ? x.join(', ') : null; } }; +}; +const fakeResponse = (status, body, ok = status >= 200 && status < 300) => ({ status, ok, headers: createHeaders(), text: async () => body }); +const withFetch = (impl) => { globalThis.fetch = impl; }; + +test.afterEach(() => { globalThis.fetch = originalFetch; }); + +// ---------- end-to-end against mock ---------- + +test('query attack log / ip list / threat intel against mock', async () => { + const mock = await createMockServer(); + try { + const ctx = buildCtx(mock); + const atk = await rpcdef(ctx)[QUERY_ATTACK_LOG_PATH]({ page: 1, count: 10, type_mask: [256, 257], severity_mask: [2], r_sip: '8.8.8.8', r_s_time: '2025-07-01 00:00:00' }); + assert.equal(atk.success, true); + assert.equal(atk.total, 1); + assert.equal('login_raw_json' in atk, false); + assert.equal('logout_raw_text' in atk, false); + assert.equal(mock.state.tokens.size, 0); // logged out + + const ipl = await invoke(METHOD_QUERY_IPLIST_FULL, { color: 0, dir: 2, ip_search: '1.1.11.0/24' }, ctx); + assert.equal(ipl.success, true); + assert.equal(ipl.total, 1); + + const empty = await rpcdef(ctx)[QUERY_INTEL_PATH](); + assert.equal(empty.total, 0); + } finally { + await mock.close(); + } +}); + +test('add then query then delete threat intel', async () => { + const mock = await createMockServer(); + try { + const ctx = buildCtx(mock); + const add = await invoke(METHOD_ADD_INTEL_FULL, { ip: '192.168.68.69', type: 214, severity: 1 }, ctx); + assert.equal(add.success, true); + assert.equal(add.id, 1); + assert.equal(mock.state.intel.size, 1); + + const q = await invoke(METHOD_QUERY_INTEL_FULL, { source_id: 63 }, ctx); + assert.equal(q.total, 1); + + const del = await invoke(METHOD_DELETE_INTEL_FULL, { id: 1 }, ctx); + assert.equal(del.success, true); + assert.equal(mock.state.intel.size, 0); + } finally { + await mock.close(); + } +}); + +// ---------- validation ---------- + +test('binding and argument validation', async () => { + const mock = await createMockServer(); + try { + await assert.rejects(() => invoke(METHOD_QUERY_ATTACK_LOG_FULL, {}, buildCtx(mock, { bindings: { host: '' } })), + (e) => e.legacyCode === 'INVALID_ARGUMENT'); + await assert.rejects(() => invoke(METHOD_QUERY_ATTACK_LOG_FULL, {}, buildCtx(mock, { bindings: { user: '', username: '' } })), + (e) => e.legacyCode === 'INVALID_ARGUMENT'); + await assert.rejects(() => invoke(METHOD_QUERY_ATTACK_LOG_FULL, {}, buildCtx(mock, { bindings: { password: '' } })), + (e) => e.legacyCode === 'INVALID_ARGUMENT'); + + const ctx = buildCtx(mock); + await assert.rejects(() => invoke(METHOD_ADD_INTEL_FULL, { ip: 'not-ip', type: 1, severity: 1 }, ctx), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + await assert.rejects(() => invoke(METHOD_ADD_INTEL_FULL, { ip: '1.2.3.4', type: 0, severity: 1 }, ctx), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + await assert.rejects(() => invoke(METHOD_DELETE_INTEL_FULL, { id: 0 }, ctx), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + } finally { + await mock.close(); + } +}); + +test('iplist color/dir validation', () => { + assert.throws(() => _test.buildIPListPayload({ color: 2 }), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + assert.throws(() => _test.buildIPListPayload({ color: 0, dir: 5 }), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + assert.deepEqual(_test.buildIPListPayload({}), { page: 1, count: 10, color: 0, dir: 2 }); + assert.throws(() => _test.buildAttackLogPayload({ page: -1 }), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + assert.throws(() => _test.buildAttackLogPayload({ page: 0 }), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + assert.throws(() => _test.buildIPListPayload({ count: 0 }), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + assert.throws(() => _test.buildIntelQueryPayload({ page: 10001 }), (e) => e.legacyCode === 'OUT_OF_RANGE'); + assert.throws(() => _test.buildIPListPayload({ count: 11 }), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + assert.throws(() => _test.buildIntelQueryPayload({ count: 15 }), (e) => e.legacyCode === 'INVALID_ARGUMENT'); +}); + +test('SDK transport rejects redirects and response values redact credentials', async () => { + let request; + withFetch(async (_url, init) => { + request = init; + return fakeResponse(200, JSON.stringify({ success: true, token: 'must-not-leak', data: { total: 0 } })); + }); + const out = await _test.fetchJson(buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' }), 'https://k01:443/test'); + assert.equal(request.redirect, 'error'); + assert.equal(out.json.token, 'must-not-leak'); + assert.doesNotMatch(_test.toQueryResult(out.json, out.text).raw_json, /must-not-leak/); +}); + +// ---------- error mapping (injected fetch) ---------- + +test('login failure → FAILED_PRECONDITION', async () => { + withFetch(async (url) => { + if (String(url).endsWith('/api/cms/user/login')) return fakeResponse(200, JSON.stringify({ success: false, error: 'bad' })); + return fakeResponse(200, 'ok'); + }); + await assert.rejects(() => invoke(METHOD_QUERY_INTEL_FULL, {}, buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' })), + (e) => e.legacyCode === 'FAILED_PRECONDITION'); +}); + +test('business 401 → UNAUTHENTICATED, and logout still attempted', async () => { + let logoutCalled = false; + withFetch(async (url) => { + const u = String(url); + if (u.endsWith('/login')) return fakeResponse(200, JSON.stringify({ success: true, token: { access_token: 't' } })); + if (u.endsWith('/logout')) { logoutCalled = true; return fakeResponse(200, 'bye'); } + return fakeResponse(401, 'nope', false); + }); + await assert.rejects(() => invoke(METHOD_QUERY_ATTACK_LOG_FULL, {}, buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' })), + (e) => e.legacyCode === 'UNAUTHENTICATED'); + assert.equal(logoutCalled, true); +}); + +test('business semantic failure → FAILED_PRECONDITION', async () => { + withFetch(async (url) => { + const u = String(url); + if (u.endsWith('/login')) return fakeResponse(200, JSON.stringify({ success: true, token: { access_token: 't' } })); + if (u.endsWith('/logout')) return fakeResponse(200, 'bye'); + return fakeResponse(200, JSON.stringify({ success: false, msgType: 'error', msg: '参数错误' })); + }); + await assert.rejects(() => invoke(METHOD_ADD_INTEL_FULL, { ip: '1.2.3.4', type: 1, severity: 1 }, buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' })), + (e) => e.legacyCode === 'FAILED_PRECONDITION' && /参数错误/.test(e.message)); +}); + +test('explicit mutation success false wins over conflicting success envelope fields', async () => { + withFetch(async (url) => { + const u = String(url); + if (u.endsWith('/login')) return fakeResponse(200, JSON.stringify({ success: true, token: { access_token: 't' } })); + if (u.endsWith('/logout')) return fakeResponse(200, 'bye'); + return fakeResponse(200, JSON.stringify({ success: false, msgType: 'success', code: 0, msg: '写入未生效' })); + }); + await assert.rejects(() => invoke(METHOD_ADD_INTEL_FULL, { ip: '1.2.3.4', type: 1, severity: 1 }, buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' })), + (e) => e.legacyCode === 'FAILED_PRECONDITION' && /写入未生效/.test(e.message)); +}); + +test('network error → UNAVAILABLE; empty/invalid body → UNKNOWN', async () => { + const ctx = buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' }); + withFetch(async () => { throw new Error('ECONNREFUSED'); }); + await assert.rejects(() => invoke(METHOD_QUERY_INTEL_FULL, {}, ctx), (e) => e.legacyCode === 'UNAVAILABLE'); + withFetch(async () => fakeResponse(200, ' ')); + await assert.rejects(() => invoke(METHOD_QUERY_INTEL_FULL, {}, ctx), (e) => e.legacyCode === 'UNKNOWN'); + withFetch(async () => fakeResponse(200, 'not-json')); + await assert.rejects(() => invoke(METHOD_QUERY_INTEL_FULL, {}, ctx), (e) => e.legacyCode === 'UNKNOWN'); +}); + +test('logout failure is swallowed (success path still returns)', async () => { + withFetch(async (url) => { + const u = String(url); + if (u.endsWith('/login')) return fakeResponse(200, JSON.stringify({ success: true, token: { access_token: 't' } })); + if (u.endsWith('/logout')) return fakeResponse(500, 'boom', false); + return fakeResponse(200, JSON.stringify({ success: true, msgType: 'success', data: { total: 0, page: 1, count: 10, list: [] } })); + }); + const out = await invoke(METHOD_QUERY_IPLIST_FULL, { color: 1, dir: 0 }, buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' })); + assert.equal(out.success, true); + assert.equal('logout_raw_text' in out, false); +}); + +// ---------- service surface + helpers ---------- + +test('service exposes all five handlers', () => { + for (const k of [METHOD_QUERY_ATTACK_LOG_FULL, METHOD_QUERY_IPLIST_FULL, METHOD_QUERY_INTEL_FULL, METHOD_ADD_INTEL_FULL, METHOD_DELETE_INTEL_FULL]) { + assert.equal(typeof service.handlers[k], 'function'); + } +}); + +test('helper coverage', () => { + const h = _test; + assert.equal(h.normalizeBaseUrl('https://k01:443/'), 'https://k01:443'); + assert.equal(h.normalizeBaseUrl('ftp://x'), ''); + assert.equal(h.resolveHost({ host: 'k01.local', restBaseUrl: 'https://k01:443/' }), 'https://k01:443'); + assert.equal(h.isIPv4('1.2.3.4'), true); + assert.equal(h.isIPv4('999.1.1.1'), false); + assert.equal(h.isIPv4('1.2.3'), false); + assert.equal(h.requireIpv4(' 10.0.0.1 '), '10.0.0.1'); + assert.throws(() => h.requireIpv4(''), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + assert.equal(h.requirePositiveInt(5, 'x'), 5); + assert.throws(() => h.requirePositiveInt(-1, 'x'), (e) => e.legacyCode === 'INVALID_ARGUMENT'); + + assert.deepEqual(h.pickIntList([1, 2, 'bad', -1, 3]), [1, 2, 3]); + assert.equal(h.pickIntList([-1]), undefined); + assert.deepEqual(h.pickIntList({ values: [4, 5] }), [4, 5]); + assert.equal(h.pickIntList([]), undefined); + assert.equal(h.pickIntList('x'), undefined); + assert.equal(h.pickIntList(undefined), undefined); + + assert.equal(h.pickInt({ a: '7' }, ['a'], 0), 7); + assert.equal(h.pickInt({}, ['a'], 9), 9); + assert.equal(h.pickStringFrom({ a: ' x ' }, ['a']), 'x'); + assert.equal(h.pickFirstString([null, '', 'y']), 'y'); + assert.equal(h.pickBoolean('yes'), true); + assert.equal(h.pickBoolean('off'), false); + assert.equal(h.pickBoolean('maybe'), undefined); + assert.equal(h.pickFirstBoolean(['x', 'true']), true); + assert.equal(h.unwrapScalar({ value: { value: 3 } }), 3); + assert.deepEqual(h.sanitizeHeaders({ A: 1, '': 2 }), { A: '1' }); + assert.deepEqual(h.sanitizeHeaders('x'), {}); + assert.ok(h.buildTlsOptions({ skipTlsVerify: true }).dispatcher); + assert.deepEqual(h.buildTlsOptions({}), {}); + assert.equal(h.resolveTimeoutMs({ limits: { timeoutMs: 0 } }), 1500); + assert.equal(h.resolveTimeoutMs({ limits: { timeoutMs: 222 } }), 222); + assert.ok(h.errorWithCode('UNAVAILABLE', 'x') instanceof GrpcError); + assert.equal(h.isSemanticSuccess({ msgType: 'SUCCESS' }), true); + assert.equal(h.isSemanticSuccess({ success: false, msgType: 'err' }), false); + assert.equal(h.isSemanticSuccess({ success: false, msgType: 'success', code: 0 }), false); + assert.equal(h.throwForHttpStatus ? true : false, true); + assert.throws(() => h.throwForHttpStatus(404), (e) => e.legacyCode === 'FAILED_PRECONDITION'); + assert.throws(() => h.throwForHttpStatus(503), (e) => e.legacyCode === 'UNAVAILABLE'); + + assert.equal(h.buildAttackLogPayload({}).method, 'query'); + const full = h.buildAttackLogPayload({ party_3rd_mask: [1], action_mask: [2], r_dip: '9.9.9.9', country: 1, province: 2, r_e_time: 't' }); + assert.deepEqual(full.party_3rd_mask, [1]); + assert.equal(full.country, 1); + assert.deepEqual(h.buildIntelQueryPayload({ page: 2, count: 50, source_id: 63 }), { page: 2, count: 50, source_id: 63 }); + assert.deepEqual(h.buildIntelDeletePayload({ id: 4 }), { id: 4, method: 'delete' }); + assert.deepEqual(h.toQueryResult({ msgType: 'success', data: { total: 3 } }, '{}').total, 3); + assert.equal(h.toMutationResult({ success: true, id: 8 }, '{}').id, 8); + assert.deepEqual(h.resolveCallContext({ request: { a: 1 } }).req, { a: 1 }); + assert.deepEqual(h.resolveCallContext({}).req, {}); +}); + +test('fetch error fallback message', async () => { + withFetch(async () => { throw {}; }); + await assert.rejects(() => _test.fetchRaw({ bindings: {}, limits: {} }, 'http://x', {}), + (e) => e.legacyCode === 'UNAVAILABLE'); + withFetch(async () => { const e = new Error('m'); e.cause = { message: 'deep' }; throw e; }); + await assert.rejects(() => _test.fetchRaw({ bindings: {}, limits: {} }, 'http://x', {}), + (e) => e.legacyCode === 'UNAVAILABLE' && e.message === 'm'); +}); + +test('defensive helper edge branches', () => { + const h = _test; + assert.equal(h.pickStringFrom(null, ['a']), ''); + assert.equal(h.pickStringFrom({ a: null, b: 'y' }, ['a', 'b']), 'y'); + assert.equal(h.pickInt({ a: '', b: '5' }, ['a', 'b'], 0), 5); + assert.equal(h.pickBoolean(NaN), undefined); + assert.deepEqual(h.sanitizeHeaders({ A: null }), { A: '' }); + assert.equal(h.isIPv4(undefined), false); + assert.equal(h.isSemanticSuccess(null), false); + assert.deepEqual(h.toQueryResult({}, '{}'), { success: false, msg_type: '', msg: '', total: 0, page: 0, count: 0, raw_json: '{}' }); + assert.equal(h.buildIntelAddPayload({ IP: '1.2.3.4', type: 2, severity: 3 }).ip, '1.2.3.4'); + const ipl = h.buildIPListPayload({ color: 1, dir: 1, comment_search: 'note', r_s_time: 's', r_e_time: 'e' }); + assert.equal(ipl.Comment_Search, 'note'); + assert.equal(ipl.r_s_time, 's'); + assert.equal(ipl.r_e_time, 'e'); +}); + +test('camelCase meta is logged without error', async () => { + const mock = await createMockServer(); + try { + const ctx = buildCtx(mock, { meta: { instanceId: 'camelInst', requestId: 'camelReq' } }); + delete ctx.meta.instance_id; + const out = await invoke(METHOD_QUERY_INTEL_FULL, {}, ctx); + assert.equal(out.success, true); + } finally { + await mock.close(); + } +}); + +test('business failure with logout also failing still rejects with business error', async () => { + withFetch(async (url) => { + const u = String(url); + if (u.endsWith('/login')) return fakeResponse(200, JSON.stringify({ success: true, token: { access_token: 't' } })); + if (u.endsWith('/logout')) return fakeResponse(500, 'logout boom', false); + return fakeResponse(200, JSON.stringify({ success: false, msgType: 'error', msg: '业务失败' })); + }); + await assert.rejects(() => invoke(METHOD_DELETE_INTEL_FULL, { id: 9 }, buildCtx({ host: 'https://k01:443', user: 'u', password: 'p' })), + (e) => e.legacyCode === 'FAILED_PRECONDITION' && /业务失败/.test(e.message)); +});