diff --git a/docs/screenshots/01-npm-test-hfish.png b/docs/screenshots/01-npm-test-hfish.png new file mode 100644 index 00000000..284ef499 Binary files /dev/null and b/docs/screenshots/01-npm-test-hfish.png differ diff --git a/docs/screenshots/02-daemon-relay-chain.png b/docs/screenshots/02-daemon-relay-chain.png new file mode 100644 index 00000000..6561c34e Binary files /dev/null and b/docs/screenshots/02-daemon-relay-chain.png differ diff --git a/docs/screenshots/03-e2e-real-device-test.png b/docs/screenshots/03-e2e-real-device-test.png new file mode 100644 index 00000000..9a42ee7c Binary files /dev/null and b/docs/screenshots/03-e2e-real-device-test.png differ diff --git a/docs/screenshots/04a-GetSystemInfo-via-OctoBus.png b/docs/screenshots/04a-GetSystemInfo-via-OctoBus.png new file mode 100644 index 00000000..ab34465f Binary files /dev/null and b/docs/screenshots/04a-GetSystemInfo-via-OctoBus.png differ diff --git a/docs/screenshots/04b-ListAttackIPs-via-OctoBus.png b/docs/screenshots/04b-ListAttackIPs-via-OctoBus.png new file mode 100644 index 00000000..607e8860 Binary files /dev/null and b/docs/screenshots/04b-ListAttackIPs-via-OctoBus.png differ diff --git a/docs/screenshots/04c-ListAttackDetails-via-OctoBus.png b/docs/screenshots/04c-ListAttackDetails-via-OctoBus.png new file mode 100644 index 00000000..3e1e9cf6 Binary files /dev/null and b/docs/screenshots/04c-ListAttackDetails-via-OctoBus.png differ diff --git a/docs/screenshots/04d-ListAttackAccounts-via-OctoBus.png b/docs/screenshots/04d-ListAttackAccounts-via-OctoBus.png new file mode 100644 index 00000000..7485fbb4 Binary files /dev/null and b/docs/screenshots/04d-ListAttackAccounts-via-OctoBus.png differ diff --git a/docs/screenshots/05-access-log-ndjson.png b/docs/screenshots/05-access-log-ndjson.png new file mode 100644 index 00000000..b98a64f2 Binary files /dev/null and b/docs/screenshots/05-access-log-ndjson.png differ diff --git a/docs/screenshots/06-direct-vs-octobus-naming.png b/docs/screenshots/06-direct-vs-octobus-naming.png new file mode 100644 index 00000000..5371991f Binary files /dev/null and b/docs/screenshots/06-direct-vs-octobus-naming.png differ diff --git a/docs/screenshots/07-grpcurl-verbose-octobus-proof.png b/docs/screenshots/07-grpcurl-verbose-octobus-proof.png new file mode 100644 index 00000000..f9bcb96c Binary files /dev/null and b/docs/screenshots/07-grpcurl-verbose-octobus-proof.png differ diff --git a/docs/screenshots/08-4proof-summary-ndjson.png b/docs/screenshots/08-4proof-summary-ndjson.png new file mode 100644 index 00000000..dd24e5ee Binary files /dev/null and b/docs/screenshots/08-4proof-summary-ndjson.png differ diff --git a/services/bin/m01-intelligence.js b/services/bin/m01-intelligence.js old mode 100644 new mode 100755 diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js old mode 100644 new mode 100755 index 412b8988..52463f1d --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -265,6 +265,10 @@ const services = { entryFile: "../wd__k01/bin/wd-k01.js", serviceModule: "../wd__k01/src/service.js", }, + "threatbook-hfish": { + entryFile: "../threatbook__hfish/bin/threatbook-hfish.js", + serviceModule: "../threatbook__hfish/src/service.js", + }, "opencti": { entryFile: "../filigran__opencti/bin/opencti.js", serviceModule: "../filigran__opencti/src/service.js", diff --git a/services/bin/threatbook-hfish.js b/services/bin/threatbook-hfish.js new file mode 100755 index 00000000..06cd43cd --- /dev/null +++ b/services/bin/threatbook-hfish.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../threatbook__hfish/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../threatbook__hfish/bin/threatbook-hfish.js", import.meta.url)), +}); diff --git a/services/m01__intelligence/bin/m01-intelligence.js b/services/m01__intelligence/bin/m01-intelligence.js old mode 100644 new mode 100755 diff --git a/services/package.json b/services/package.json index 91438aeb..a4103707 100644 --- a/services/package.json +++ b/services/package.json @@ -70,6 +70,7 @@ "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", "wd-k01": "bin/wd-k01.js", + "threatbook-hfish": "bin/threatbook-hfish.js", "opencti": "bin/opencti.js" }, "files": [ @@ -137,6 +138,7 @@ "bin/venus-ads-v3-6.js", "bin/volcengine-cloud-firewall.js", "bin/wd-k01.js", + "bin/threatbook-hfish.js", "bin/opencti.js", "bin/wangsu-label-ip.js", "alibaba-cloud__simple-application-server-firewall", @@ -191,6 +193,7 @@ "threatbook__cloudapi_v3", "threatbook__claudsandbox_v3", "threatbook__ngtip_v5", + "threatbook__hfish", "threatbook__onesig", "threatbook__tdp", "threatbook__tip_v4", diff --git a/services/threatbook__hfish/README.md b/services/threatbook__hfish/README.md new file mode 100644 index 00000000..45022da4 --- /dev/null +++ b/services/threatbook__hfish/README.md @@ -0,0 +1,83 @@ +# ThreatBook HFish Honeypot OctoBus Service + +OctoBus service package for [HFish](https://hfish.net/) honeypot by ThreatBook (Chaitin). Provides APIs to query attack source IPs, attack details, captured credentials, and system status. + +## Package Files + +- `service.json`: OctoBus service manifest. +- `proto/hfish.proto`: gRPC API definition. +- `config.schema.json`: non-secret endpoint, headers, timeout, and TLS settings. +- `secret.schema.json`: HFish API key. +- `src/hfish.js`: HFish REST API proxy implementation. +- `src/service.js`: OctoBus SDK `defineService` wrapper. +- `bin/threatbook-hfish.js`: service-local executable entrypoint. +- `test/hfish.test.js`: node:test coverage for request validation, REST mapping, error mapping, and SDK handler invocation. +- `test/mock_upstream.js`: optional local HFish HTTP mock. + +## Configuration + +Use `endpoint` for the HFish server base URL. + +```json +{ + "endpoint": "https://hfish.example.com:4433", + "headers": { + "X-Extra": "demo" + }, + "timeoutMs": 1500, + "skipTlsVerify": true +} +``` + +Use `secret.apiKey` for the HFish API key: + +```json +{ + "apiKey": "replace-with-hfish-api-key" +} +``` + +Requests may still pass `api_key` or `apiKey`; configured secret values take precedence over request values (because gRPC proto3 string fields default to `""` which would shadow the real secret). + +## RPC Methods + +| Method | HTTP Mapping | Description | +|--------|-------------|-------------| +| `ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs` | `POST /api/v1/attack/ip?api_key=...&page=...&limit=...` | List attack source IPs with pagination | +| `ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails` | `POST /api/v1/attack/detail?api_key=...&page=...&limit=...` | List attack details with pagination | +| `ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts` | `POST /api/v1/attack/account?api_key=...&page=...&limit=...` | List captured credentials from attacks | +| `ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo` | `GET /api/v1/hfish/sys_info?api_key=...` | Get honeypot system status | + +## Parameter Notes + +- All POST endpoints send an empty `{}` JSON body; authentication and pagination are via query parameters. +- `page` defaults to 1, `limit` defaults to 20. +- HFish API response codes: `0` = success, `1003` = authentication failure (maps to `PERMISSION_DENIED`). + +## Behavior Notes + +- HTTP 401 maps to `UNAUTHENTICATED`, 403 maps to `PERMISSION_DENIED`. +- Other HTTP 4xx responses map to `FAILED_PRECONDITION`. +- HTTP 5xx, network, and TLS failures map to `UNAVAILABLE`. +- Non-JSON success bodies map to `UNKNOWN`. +- HFish response code `1003` (illegal apikey) maps to `PERMISSION_DENIED`. +- Other non-zero HFish response codes map to `FAILED_PRECONDITION`. + +## Local Checks + +```bash +cd services +npm run validate -- --service-dir threatbook__hfish +npm test -- --service-dir threatbook__hfish --coverage +npm run pack:check +``` + +## Supported Versions + +- HFish v3.x (tested on v3.3.6) + +## Risk & CapSet Notes + +- **Read-only operations**: all four methods are read-only queries. +- **Suggested CapSet**: `threat-intel` with read-only constraints. +- **No side effects**: these APIs do not modify any HFish configuration or data. diff --git a/services/threatbook__hfish/bin/threatbook-hfish.js b/services/threatbook__hfish/bin/threatbook-hfish.js new file mode 100755 index 00000000..508272f0 --- /dev/null +++ b/services/threatbook__hfish/bin/threatbook-hfish.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../src/service.js"; + +runServiceMain(service); diff --git a/services/threatbook__hfish/config.schema.json b/services/threatbook__hfish/config.schema.json new file mode 100644 index 00000000..d0ebac57 --- /dev/null +++ b/services/threatbook__hfish/config.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "HFish server base URL, for example https://hfish.example.com:4433." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional extra HTTP headers sent to HFish." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 1500, + "description": "HTTP timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private HFish deployments." + } + } +} diff --git a/services/threatbook__hfish/package.json b/services/threatbook__hfish/package.json new file mode 100644 index 00000000..1a9728c6 --- /dev/null +++ b/services/threatbook__hfish/package.json @@ -0,0 +1,12 @@ +{ + "name": "threatbook-hfish", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "threatbook-hfish": "bin/threatbook-hfish.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/threatbook__hfish/proto/hfish.proto b/services/threatbook__hfish/proto/hfish.proto new file mode 100644 index 00000000..393da094 --- /dev/null +++ b/services/threatbook__hfish/proto/hfish.proto @@ -0,0 +1,153 @@ +syntax = "proto3"; + +package ThreatBook_HFISH; + +import "google/protobuf/wrappers.proto"; +import "google/protobuf/struct.proto"; + +option go_package = "miner/grpc-service/ThreatBook_HFISH"; + +service ThreatBook_HFISH { + // 获取攻击来源 IP 列表,支持分页 + rpc ListAttackIPs(ListAttackIPsRequest) returns (ListAttackIPsResponse) {} + + // 获取攻击详情列表,支持分页 + rpc ListAttackDetails(ListAttackDetailsRequest) returns (ListAttackDetailsResponse) {} + + // 获取攻击 IP 使用的账号信息列表 + rpc ListAttackAccounts(ListAttackAccountsRequest) returns (ListAttackAccountsResponse) {} + + // 获取蜜罐系统运行状态信息 + rpc GetSystemInfo(GetSystemInfoRequest) returns (GetSystemInfoResponse) {} +} + +// ==================== ListAttackIPs ==================== + +message ListAttackIPsRequest { + string api_key = 1; // API 访问凭证,必填 + google.protobuf.Int64Value page = 2; // 页码,可选,默认 1 + google.protobuf.Int64Value limit = 3; // 每页条数,可选,默认 20 +} + +message ListAttackIPsResponse { + int64 response_code = 1; // 响应码,0 表示成功 + string verbose_msg = 2; // 响应消息 + repeated AttackIPRecord data = 3; // 攻击 IP 列表 +} + +message AttackIPRecord { + string ip = 1; // 攻击者 IP + int64 attack_count = 2; // 攻击次数 + string first_attack_time = 3; // 首次攻击时间 + string last_attack_time = 4; // 最近攻击时间 + repeated string attack_types = 5; // 攻击类型列表 + string country = 6; // 国家 + string province = 7; // 省份 + string city = 8; // 城市 + string group = 9; // 所属分组 + string comment = 10; // 备注 + int64 attack_chain_count = 11; // 攻击链数量 + int64 port_count = 12; // 端口数量 + int64 related_info_count = 13; // 关联信息数量 +} + +// ==================== ListAttackDetails ==================== + +message ListAttackDetailsRequest { + string api_key = 1; // API 访问凭证,必填 + google.protobuf.Int64Value page = 2; // 页码,可选,默认 1 + google.protobuf.Int64Value limit = 3; // 每页条数,可选,默认 20 + string ip = 4; // 按 IP 过滤,可选 + string type = 5; // 按蜜罐类型过滤,可选 +} + +message ListAttackDetailsResponse { + int64 response_code = 1; // 响应码,0 表示成功 + string verbose_msg = 2; // 响应消息 + AttackDetailData data = 3; // 攻击详情数据 +} + +message AttackDetailData { + int64 total_num = 1; // 总记录数 + int64 page_no = 2; // 当前页码 + int64 page_size = 3; // 每页大小 + int64 total_page = 4; // 总页数 + repeated AttackDetailRecord detail_list = 5; // 攻击详情列表 +} + +message AttackDetailRecord { + int64 id = 1; // 记录 ID + string src_ip = 2; // 源 IP + string src_port = 3; // 源端口 + string dest_ip = 4; // 目的 IP + string dest_port = 5; // 目的端口 + string protocol = 6; // 协议 + string type = 7; // 蜜罐类型 + string app_name = 8; // 蜜罐名称 + string client_name = 9; // 节点名称 + string raw_data = 10; // 原始攻击载荷 + string country = 11; // 国家 + string province = 12; // 省份 + string city = 13; // 城市 + string create_time = 14; // 攻击时间 + string attack_chain = 15; // 攻击链信息 + string crawl_info = 16; // 扫描信息 + string user_info = 17; // 账号信息 +} + +// ==================== ListAttackAccounts ==================== + +message ListAttackAccountsRequest { + string api_key = 1; // API 访问凭证,必填 + google.protobuf.Int64Value page = 2; // 页码,可选,默认 1 + google.protobuf.Int64Value limit = 3; // 每页条数,可选,默认 20 +} + +message ListAttackAccountsResponse { + int64 response_code = 1; // 响应码,0 表示成功 + string verbose_msg = 2; // 响应消息 + repeated AttackAccountRecord data = 3; // 账号信息列表 +} + +message AttackAccountRecord { + int64 id = 1; // 记录 ID + string ip = 2; // 攻击者 IP + string account = 3; // 账号 + string password = 4; // 密码 + string type = 5; // 蜜罐类型 + string create_time = 6; // 捕获时间 +} + +// ==================== GetSystemInfo ==================== + +message GetSystemInfoRequest { + string api_key = 1; // API 访问凭证,必填 +} + +message GetSystemInfoResponse { + int64 response_code = 1; // 响应码,0 表示成功 + string verbose_msg = 2; // 响应消息 + SystemInfoData data = 3; // 系统状态数据 +} + +message SystemInfoData { + int64 total_honeypots = 1; // 蜜罐总数 + int64 total_cardinal_honeypots = 2; // 主要蜜罐数 + int64 total_online_honeypots = 3; // 在线蜜罐数 + int64 total_offline_honeypots = 4; // 离线蜜罐数 + map honeypot_self_cnt = 5; // 各类型蜜罐数量 + repeated ClientInfo clients = 6; // 节点列表 +} + +message ClientInfo { + string name = 1; // 节点名称 + string ip = 2; // 节点 IP + int64 create_time = 3; // 创建时间 + repeated HoneypotInfo honeypots = 4; // 蜜罐列表 +} + +message HoneypotInfo { + string type = 1; // 蜜罐类型 + string name = 2; // 蜜罐名称 + int64 state = 3; // 状态:2=在线,3=离线 +} diff --git a/services/threatbook__hfish/secret.schema.json b/services/threatbook__hfish/secret.schema.json new file mode 100644 index 00000000..3f92a299 --- /dev/null +++ b/services/threatbook__hfish/secret.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "apiKey": { + "type": "string", + "description": "HFish API key for authentication." + } + } +} diff --git a/services/threatbook__hfish/service.json b/services/threatbook__hfish/service.json new file mode 100644 index 00000000..3f0ab4ff --- /dev/null +++ b/services/threatbook__hfish/service.json @@ -0,0 +1,41 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "threatbook-hfish", + "displayName": "ThreatBook HFish Honeypot", + "description": "OctoBus package for ThreatBook HFish honeypot attack IPs, attack details, attack accounts, and system info APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/hfish.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs": { + "name": "list-attack-ips", + "description": "List HFish attack source IPs with pagination." + }, + "ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails": { + "name": "list-attack-details", + "description": "List HFish attack details with pagination." + }, + "ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts": { + "name": "list-attack-accounts", + "description": "List HFish attack accounts with pagination." + }, + "ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo": { + "name": "get-system-info", + "description": "Get HFish honeypot system status." + } + } + } + } +} diff --git a/services/threatbook__hfish/src/hfish.js b/services/threatbook__hfish/src/hfish.js new file mode 100644 index 00000000..b51360c1 --- /dev/null +++ b/services/threatbook__hfish/src/hfish.js @@ -0,0 +1,473 @@ +// ThreatBook_HFISH HFish Honeypot Proxy +// Bindings: endpoint (required), headers (optional), timeoutMs (optional) +// API key: secret.apiKey + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +const DEFAULT_TIMEOUT_MS = 1500; +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 20; + +const LIST_ATTACK_IPS_PATH = '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs'; +const LIST_ATTACK_DETAILS_PATH = '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails'; +const LIST_ATTACK_ACCOUNTS_PATH = '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts'; +const GET_SYSTEM_INFO_PATH = '/ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo'; + +const grpcCodeFor = (code) => ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAUTHENTICATED: grpcStatus.UNAUTHENTICATED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const firstDefined = (...vals) => vals.find((v) => v !== undefined && v !== null); + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), + ...(ctx?.bindings ?? {}), +}); + +const parseHeaders = (value) => { + if (value === undefined || value === null || value === '') return {}; + if (typeof value === 'object' && !Array.isArray(value)) return value; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch { + return {}; + } + } + return {}; +}; + +const normalizeBaseUrl = (url) => { + const base = String(url || '').trim(); + if (!/^https?:\/\//i.test(base)) return null; + return base.replace(/\/$/, ''); +}; + +const toPositiveInt = (val) => { + if (val === undefined || val === null) return null; + if (typeof val === 'object') { + if ('value' in val) return toPositiveInt(val.value); + return null; + } + const n = Number(val); + if (!Number.isInteger(n) || Number.isNaN(n)) return null; + return n > 0 ? n : null; +}; + +const pickStringField = (req, keys) => { + for (const key of keys) { + if (hasOwn(req, key)) { + return String(req[key]); + } + } + return undefined; +}; + +const extractApiKey = (req, bindings) => { + // Prefer bindings (secret/config) over request, because gRPC proto + // string fields default to "" which firstDefined treats as defined, + // shadowing the real secret value from bindings. + const key = firstDefined(bindings?.apiKey, bindings?.api_key, req?.api_key, req?.apiKey); + if (!key) return null; + return String(key).trim(); +}; + +export function rpcdef(ctx) { + const bindings = mergedBindings(ctx); + const restBaseUrl = bindings.endpoint || bindings.restBaseUrl || bindings.baseUrl || ''; + const timeoutMs = ctx.limits?.timeoutMs || DEFAULT_TIMEOUT_MS; + const baseHeaders = parseHeaders(bindings.headers); + const meta = ctx.meta || {}; + const skipTlsVerify = Boolean(bindings.tlsInsecureSkipVerify || bindings.skipTlsVerify || bindings.skip_tls_verify || bindings.tls_insecure_skip_verify); + + // TLS skip is handled at the process level by the OctoBus daemon, which + // sets the appropriate environment variable before spawning the subprocess. + // Handler code must NOT mutate process.env — that would be a global, + // irreversible side effect affecting all services and third-party HTTPS + // calls in the process. The insecureSkipVerify/tlsInsecureSkipVerify fetch + // options below are OctoBus runtime conventions; the daemon may intercept + // them or configure TLS externally before process start. + + const logFlow = (action, details) => { + const inst = meta.instance_id || meta.instanceId; + const reqId = meta.request_id || meta.requestId; + const trace = []; + if (inst) trace.push(`inst=${inst}`); + if (reqId) trace.push(`req=${reqId}`); + const prefix = `[ThreatBook_HFISH][${action}]${trace.length ? `[${trace.join(' ')}]` : ''}`; + try { + console.log(prefix, JSON.stringify(details)); + } catch { + console.log(prefix, details); + } + }; + + const throwForHttpError = (status, text) => { + // Log upstream response body server-side only (may contain sensitive data). + try { console.error(`[ThreatBook_HFISH] upstream http ${status}: ${String(text).slice(0, 500)}`); } catch { /* ignore */ } + if (status === 401) throw errorWithCode('UNAUTHENTICATED', `upstream returned ${status}`); + if (status === 403) throw errorWithCode('PERMISSION_DENIED', `upstream returned ${status}`); + if (status >= 400 && status < 500) throw errorWithCode('FAILED_PRECONDITION', `upstream returned ${status}`); + throw errorWithCode('UNAVAILABLE', `upstream returned ${status}`); + }; + + const readJsonResponse = async (res, emptyValue) => { + const text = await res.text(); + if (!res.ok) { + throwForHttpError(res.status, text); + } + if (!text.trim()) { + return emptyValue; + } + try { + return JSON.parse(text); + } catch { + throw errorWithCode('UNKNOWN', 'response is not valid JSON'); + } + }; + + const tlsOptions = () => (skipTlsVerify + ? { + insecureSkipVerify: true, + tlsInsecureSkipVerify: true, + } + : {}); + + const fetchHfish = async (url, init) => { + try { + return await fetch(url, { + ...init, + ...tlsOptions(), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (e) { + const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError'; + if (isTimeout) { + throw errorWithCode('DEADLINE_EXCEEDED', 'timeout after ' + timeoutMs + 'ms'); + } + const reason = e?.cause?.message || e?.message || 'fetch failed'; + throw errorWithCode('UNAVAILABLE', reason); + } + }; + + const buildApiKeyUrl = (baseUrl, path, apiKey, extraParams = {}) => { + // String concatenation to preserve subpath in baseUrl (e.g. http://host/hfish). + // new URL('/api/...', 'http://host/hfish') would discard /hfish because + // absolute paths replace the entire pathname. + const base = baseUrl.replace(/\/+$/, ''); + const normalizedPath = path.replace(/^\/+/, ''); + const url = new URL(`${base}/${normalizedPath}`); + url.searchParams.set('api_key', apiKey); + for (const [k, v] of Object.entries(extraParams)) { + if (v !== undefined && v !== null) { + url.searchParams.set(k, String(v)); + } + } + return url.toString(); + }; + + const callListAttackIPs = async (req) => { + const apiKey = extractApiKey(req, bindings); + if (!apiKey) { + throw errorWithCode('INVALID_ARGUMENT', 'api_key is required'); + } + const baseUrl = normalizeBaseUrl(restBaseUrl); + if (!baseUrl) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + } + + const page = toPositiveInt(firstDefined(req?.page, req?.Page)) || DEFAULT_PAGE; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) || DEFAULT_LIMIT; + + const url = buildApiKeyUrl(baseUrl, '/api/v1/attack/ip', apiKey, { page, limit }); + logFlow('ListAttackIPs:start', { baseUrl, page, limit }); + + const res = await fetchHfish(url, { method: 'POST', headers: { ...baseHeaders, 'content-type': 'application/json' }, body: '{}' }); + const json = await readJsonResponse(res, {}); + + if (json.response_code !== undefined && json.response_code !== 0) { + const msg = json.verbose_msg || `HFish API error: code ${json.response_code}`; + if (json.response_code === 1003) { + throw errorWithCode('PERMISSION_DENIED', msg); + } + throw errorWithCode('FAILED_PRECONDITION', msg); + } + + const data = json.data || {}; + const attackIpList = Array.isArray(data.attack_ip) ? data.attack_ip : []; + + const records = attackIpList.map((item) => ({ + ip: item?.ip ?? '', + attack_count: item?.attack_count ?? 0, + first_attack_time: item?.first_attack_time ?? '', + last_attack_time: item?.last_attack_time ?? '', + attack_types: Array.isArray(item?.attack_types) ? item.attack_types : [], + country: item?.country ?? '', + province: item?.province ?? '', + city: item?.city ?? '', + group: item?.group ?? item?.group_name ?? '', + comment: item?.comment ?? '', + attack_chain_count: item?.attack_chain_count ?? 0, + port_count: item?.port_count ?? 0, + related_info_count: item?.related_info_count ?? 0, + })); + + logFlow('ListAttackIPs:done', { count: records.length }); + return { response_code: json.response_code, verbose_msg: json.verbose_msg || '', data: records }; + }; + + const callListAttackDetails = async (req) => { + const apiKey = extractApiKey(req, bindings); + if (!apiKey) { + throw errorWithCode('INVALID_ARGUMENT', 'api_key is required'); + } + const baseUrl = normalizeBaseUrl(restBaseUrl); + if (!baseUrl) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + } + + const page = toPositiveInt(firstDefined(req?.page, req?.Page)) || DEFAULT_PAGE; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) || DEFAULT_LIMIT; + const ip = pickStringField(req ?? {}, ['ip']); + const type = pickStringField(req ?? {}, ['type']); + + const extraParams = { page, limit }; + if (ip) extraParams.ip = ip; + if (type) extraParams.type = type; + const url = buildApiKeyUrl(baseUrl, '/api/v1/attack/detail', apiKey, extraParams); + logFlow('ListAttackDetails:start', { baseUrl, page, limit, ip: ip || undefined, type: type || undefined }); + + const res = await fetchHfish(url, { method: 'POST', headers: { ...baseHeaders, 'content-type': 'application/json' }, body: '{}' }); + const json = await readJsonResponse(res, {}); + + if (json.response_code !== undefined && json.response_code !== 0) { + const msg = json.verbose_msg || `HFish API error: code ${json.response_code}`; + if (json.response_code === 1003) { + throw errorWithCode('PERMISSION_DENIED', msg); + } + throw errorWithCode('FAILED_PRECONDITION', msg); + } + + const data = json.data || {}; + const detailList = Array.isArray(data.detail_list) ? data.detail_list : []; + + const records = detailList.map((item) => ({ + id: item?.id ?? 0, + src_ip: item?.src_ip ?? item?.source_ip ?? '', + src_port: item?.src_port ?? '', + dest_ip: item?.dest_ip ?? item?.dst_ip ?? '', + dest_port: item?.dest_port ?? item?.dst_port ?? '', + protocol: item?.protocol ?? '', + type: item?.type ?? '', + app_name: item?.app_name ?? item?.name ?? '', + client_name: item?.client_name ?? '', + raw_data: item?.raw_data ?? item?.info ?? '', + country: item?.country ?? '', + province: item?.province ?? '', + city: item?.city ?? '', + create_time: item?.create_time ?? item?.time ?? '', + attack_chain: item?.attack_chain ?? '', + crawl_info: item?.crawl_info ?? '', + user_info: item?.user_info ?? '', + })); + + logFlow('ListAttackDetails:done', { total: data.total_num || 0, count: records.length }); + return { + response_code: json.response_code, + verbose_msg: json.verbose_msg || '', + data: { + total_num: data.total_num || 0, + page_no: data.page_no || page, + page_size: data.page_size || limit, + total_page: data.total_page || 0, + detail_list: records, + }, + }; + }; + + const callListAttackAccounts = async (req) => { + const apiKey = extractApiKey(req, bindings); + if (!apiKey) { + throw errorWithCode('INVALID_ARGUMENT', 'api_key is required'); + } + const baseUrl = normalizeBaseUrl(restBaseUrl); + if (!baseUrl) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + } + + const page = toPositiveInt(firstDefined(req?.page, req?.Page)) || DEFAULT_PAGE; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) || DEFAULT_LIMIT; + + const url = buildApiKeyUrl(baseUrl, '/api/v1/attack/account', apiKey, { page, limit }); + logFlow('ListAttackAccounts:start', { baseUrl, page, limit }); + + const res = await fetchHfish(url, { method: 'POST', headers: { ...baseHeaders, 'content-type': 'application/json' }, body: '{}' }); + const json = await readJsonResponse(res, {}); + + if (json.response_code !== undefined && json.response_code !== 0) { + const msg = json.verbose_msg || `HFish API error: code ${json.response_code}`; + if (json.response_code === 1003) { + throw errorWithCode('PERMISSION_DENIED', msg); + } + throw errorWithCode('FAILED_PRECONDITION', msg); + } + + const data = Array.isArray(json.data) ? json.data : []; + + const records = data.map((item) => ({ + id: item?.id ?? 0, + ip: item?.ip ?? '', + account: item?.account ?? item?.username ?? '', + password: item?.password ?? item?.passwd ?? '', + type: item?.type ?? '', + create_time: item?.create_time ?? '', + })); + + logFlow('ListAttackAccounts:done', { count: records.length }); + return { response_code: json.response_code, verbose_msg: json.verbose_msg || '', data: records }; + }; + + const callGetSystemInfo = async (req) => { + const apiKey = extractApiKey(req, bindings); + if (!apiKey) { + throw errorWithCode('INVALID_ARGUMENT', 'api_key is required'); + } + const baseUrl = normalizeBaseUrl(restBaseUrl); + if (!baseUrl) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + } + + const url = buildApiKeyUrl(baseUrl, '/api/v1/hfish/sys_info', apiKey); + logFlow('GetSystemInfo:start', { baseUrl }); + + const res = await fetchHfish(url, { method: 'GET', headers: baseHeaders }); + const json = await readJsonResponse(res, {}); + + if (json.response_code !== undefined && json.response_code !== 0) { + const msg = json.verbose_msg || `HFish API error: code ${json.response_code}`; + if (json.response_code === 1003) { + throw errorWithCode('PERMISSION_DENIED', msg); + } + throw errorWithCode('FAILED_PRECONDITION', msg); + } + + const data = json.data || {}; + const honeypotSelfCnt = data.honeypot_self_cnt && typeof data.honeypot_self_cnt === 'object' + ? Object.fromEntries( + Object.entries(data.honeypot_self_cnt).map(([k, v]) => [k, Number(v) || 0]) + ) + : {}; + const clients = Array.isArray(data.clients) ? data.clients.map((c) => ({ + name: c?.name ?? '', + ip: c?.ip ?? '', + create_time: c?.create_time ?? 0, + honeypots: Array.isArray(c?.honeypots) ? c.honeypots.map((h) => ({ + type: h?.type ?? '', + name: h?.name ?? '', + state: h?.state ?? 0, + })) : [], + })) : []; + + logFlow('GetSystemInfo:done', { total_honeypots: data.total_honeypots || 0 }); + return { + response_code: json.response_code, + verbose_msg: json.verbose_msg || '', + data: { + total_honeypots: data.total_honeypots || 0, + total_cardinal_honeypots: data.total_cardinal_honeypots || 0, + total_online_honeypots: data.total_online_honeypots || 0, + total_offline_honeypots: data.total_offline_honeypots || 0, + honeypot_self_cnt: honeypotSelfCnt, + clients, + }, + }; + }; + + return { + [LIST_ATTACK_IPS_PATH]: async () => callListAttackIPs({ ...(ctx.req || {}) }), + [LIST_ATTACK_DETAILS_PATH]: async () => callListAttackDetails({ ...(ctx.req || {}) }), + [LIST_ATTACK_ACCOUNTS_PATH]: async () => callListAttackAccounts({ ...(ctx.req || {}) }), + [GET_SYSTEM_INFO_PATH]: async () => callGetSystemInfo({ ...(ctx.req || {}) }), + }; +} + +const mergeCtx = (baseCtx, innerCtx) => ({ + ...(baseCtx ?? {}), + ...(innerCtx ?? {}), + bindings: { ...(baseCtx?.bindings ?? {}), ...(innerCtx?.bindings ?? {}) }, + config: { ...(baseCtx?.config ?? {}), ...(innerCtx?.config ?? {}) }, + secret: { ...(baseCtx?.secret ?? {}), ...(innerCtx?.secret ?? {}) }, + limits: innerCtx?.limits ?? baseCtx?.limits ?? {}, + meta: innerCtx?.meta ?? baseCtx?.meta ?? {}, + metadata: innerCtx?.metadata ?? baseCtx?.metadata ?? {}, + getMetadata: innerCtx?.getMetadata ?? baseCtx?.getMetadata, +}); + +const resolveCallContext = (baseCtx, reqOrCtx, maybeInnerCtx) => { + if (maybeInnerCtx !== undefined) { + return { req: reqOrCtx ?? {}, ctx: mergeCtx(baseCtx, maybeInnerCtx) }; + } + const innerCtx = reqOrCtx ?? {}; + return { + req: innerCtx.request ?? innerCtx.req ?? {}, + ctx: mergeCtx(baseCtx, innerCtx), + }; +}; + +const wrapLegacyHandler = (baseCtx, methodPath) => async (reqOrCtx, maybeInnerCtx) => { + const call = resolveCallContext(baseCtx, reqOrCtx, maybeInnerCtx); + const legacyCtx = { + ...call.ctx, + req: call.req, + }; + return rpcdef(legacyCtx)[methodPath](); +}; + +const registerHandlers = (ctx = {}) => ({ + [LIST_ATTACK_IPS_PATH]: wrapLegacyHandler(ctx, LIST_ATTACK_IPS_PATH), + [LIST_ATTACK_DETAILS_PATH]: wrapLegacyHandler(ctx, LIST_ATTACK_DETAILS_PATH), + [LIST_ATTACK_ACCOUNTS_PATH]: wrapLegacyHandler(ctx, LIST_ATTACK_ACCOUNTS_PATH), + [GET_SYSTEM_INFO_PATH]: wrapLegacyHandler(ctx, GET_SYSTEM_INFO_PATH), +}); + +export const METHOD_LIST_ATTACK_IPS_FULL = 'ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs'; +export const METHOD_LIST_ATTACK_DETAILS_FULL = 'ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails'; +export const METHOD_LIST_ATTACK_ACCOUNTS_FULL = 'ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts'; +export const METHOD_GET_SYSTEM_INFO_FULL = 'ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo'; + +const sdkHandlers = registerHandlers({}); + +export const handlers = { + [METHOD_LIST_ATTACK_IPS_FULL]: (ctx) => sdkHandlers[LIST_ATTACK_IPS_PATH](ctx), + [METHOD_LIST_ATTACK_DETAILS_FULL]: (ctx) => sdkHandlers[LIST_ATTACK_DETAILS_PATH](ctx), + [METHOD_LIST_ATTACK_ACCOUNTS_FULL]: (ctx) => sdkHandlers[LIST_ATTACK_ACCOUNTS_PATH](ctx), + [METHOD_GET_SYSTEM_INFO_FULL]: (ctx) => sdkHandlers[GET_SYSTEM_INFO_PATH](ctx), +}; + +export const _test = { + errorWithCode, + extractApiKey, + firstDefined, + hasOwn, + mergedBindings, + normalizeBaseUrl, + parseHeaders, + registerHandlers, + resolveCallContext, + toPositiveInt, +}; diff --git a/services/threatbook__hfish/src/service.js b/services/threatbook__hfish/src/service.js new file mode 100644 index 00000000..9939fad7 --- /dev/null +++ b/services/threatbook__hfish/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./hfish.js"; + +export { handlers } from "./hfish.js"; + +export const service = defineService({ handlers }); diff --git a/services/threatbook__hfish/test/hfish.test.js b/services/threatbook__hfish/test/hfish.test.js new file mode 100644 index 00000000..e5253953 --- /dev/null +++ b/services/threatbook__hfish/test/hfish.test.js @@ -0,0 +1,373 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { grpcStatus } from '@chaitin-ai/octobus-sdk'; + +const listAttackIpsPath = '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs'; +const listAttackDetailsPath = '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails'; +const listAttackAccountsPath = '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts'; +const getSystemInfoPath = '/ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo'; + +const buildCtx = (req = {}, overrides = {}) => ({ + bindings: { endpoint: 'http://localhost:18080', ...overrides.bindings }, + secret: { apiKey: 'test-api-key', ...overrides.secret }, + limits: { timeoutMs: 10_000, ...overrides.limits }, + meta: { instance_id: 'inst', request_id: 'req', ...overrides.meta }, + req, +}); + +const setFetch = (impl) => { + global.fetch = impl; +}; + +const loadHandler = async (path, req, overrides = {}) => { + const { rpcdef } = await import('../src/hfish.js'); + const ctx = buildCtx(req, overrides); + return rpcdef(ctx)[path]; +}; + +const loadListAttackIpsHandler = async (req, overrides = {}) => + loadHandler(listAttackIpsPath, req, overrides); + +const loadListAttackDetailsHandler = async (req, overrides = {}) => + loadHandler(listAttackDetailsPath, req, overrides); + +const loadListAttackAccountsHandler = async (req, overrides = {}) => + loadHandler(listAttackAccountsPath, req, overrides); + +const loadGetSystemInfoHandler = async (req, overrides = {}) => + loadHandler(getSystemInfoPath, req, overrides); + +const mockFetch = (impl) => { + setFetch(async (...args) => impl(...args)); +}; + +test('internal helpers', async () => { + const { _test } = await import('../src/hfish.js'); + + // normalizeBaseUrl + assert.equal(_test.normalizeBaseUrl('http://example.com'), 'http://example.com'); + assert.equal(_test.normalizeBaseUrl('https://example.com/path/'), 'https://example.com/path'); + assert.equal(_test.normalizeBaseUrl(''), null); + assert.equal(_test.normalizeBaseUrl('ftp://bad'), null); + + // toPositiveInt + assert.equal(_test.toPositiveInt(5), 5); + assert.equal(_test.toPositiveInt(0), null); + assert.equal(_test.toPositiveInt(-1), null); + assert.equal(_test.toPositiveInt({ value: 3 }), 3); + + // extractApiKey: bindings/secret first (authoritative), then req + // Rationale: gRPC proto string fields default to "" which firstDefined + // treats as defined, shadowing the real secret value from bindings. + assert.equal(_test.extractApiKey({ api_key: 'req-key' }, { apiKey: 'secret' }), 'secret'); + assert.equal(_test.extractApiKey({ apiKey: 'req-key' }, { apiKey: 'secret' }), 'secret'); + assert.equal(_test.extractApiKey({}, { apiKey: 'secret-key' }), 'secret-key'); + assert.equal(_test.extractApiKey({}, { api_key: 'secret-key-old' }), 'secret-key-old'); + assert.equal(_test.extractApiKey({}, {}), null); + // when bindings has no apiKey, fall back to req + assert.equal(_test.extractApiKey({ apiKey: 'req-fallback' }, {}), 'req-fallback'); + assert.equal(_test.extractApiKey({ api_key: 'req-fallback' }, {}), 'req-fallback'); + + // firstDefined + assert.equal(_test.firstDefined(undefined, null, 1, 2), 1); + assert.equal(_test.firstDefined(null, undefined), undefined); + + // errorWithCode + const err = _test.errorWithCode('INVALID_ARGUMENT', 'bad input'); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + + // mergedBindings + const bindings = _test.mergedBindings({ + config: { endpoint: 'http://cfg' }, + secret: { apiKey: 'sec' }, + bindings: { extra: 'val' }, + }); + assert.equal(bindings.endpoint, 'http://cfg'); + assert.equal(bindings.apiKey, 'sec'); + assert.equal(bindings.extra, 'val'); + + // parseHeaders + assert.deepEqual(_test.parseHeaders({ 'X-Custom': 'val' }), { 'X-Custom': 'val' }); + assert.deepEqual(_test.parseHeaders('{"X-Json":"parsed"}'), { 'X-Json': 'parsed' }); + assert.deepEqual(_test.parseHeaders(undefined), {}); + assert.deepEqual(_test.parseHeaders('not-json'), {}); +}); + +test('ListAttackIPs success', async () => { + mockFetch(async (url, init) => { + assert.ok(url.includes('/api/v1/attack/ip')); + assert.ok(url.includes('api_key=test-api-key')); + assert.equal(init.method, 'POST'); + return { + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ + response_code: 0, + verbose_msg: '成功', + data: { + attack_ip: [ + { ip: '1.2.3.4', attack_count: 10 }, + { ip: '5.6.7.8', attack_count: 3 }, + ], + }, + }), + }; + }); + + const handler = await loadListAttackIpsHandler({ page: 1, limit: 20 }); + const result = await handler(); + assert.equal(result.response_code, 0); + assert.equal(result.data.length, 2); + assert.equal(result.data[0].ip, '1.2.3.4'); + assert.equal(result.data[0].attack_count, 10); +}); + +test('ListAttackIPs with subpath endpoint preserves path prefix', async () => { + // Regression: buildApiKeyUrl must preserve subpaths in baseUrl + // (e.g. http://host/hfish). new URL('/api/...', base) would discard + // the /hfish prefix because absolute paths replace the entire pathname. + mockFetch(async (url) => { + assert.ok(url.startsWith('http://example.com/hfish/api/v1/attack/ip'), `URL missing subpath prefix: ${url}`); + assert.ok(url.includes('api_key=test-api-key')); + return { + ok: true, status: 200, headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ + response_code: 0, + verbose_msg: '成功', + data: { attack_ip: [{ ip: '1.2.3.4', attack_count: 1 }] }, + }), + }; + }); + + const handler = await loadListAttackIpsHandler( + { page: 1, limit: 20 }, + { bindings: { endpoint: 'http://example.com/hfish' } } + ); + const result = await handler(); + assert.equal(result.response_code, 0); + assert.equal(result.data.length, 1); + assert.equal(result.data[0].ip, '1.2.3.4'); +}); + +test('ListAttackIPs empty', async () => { + mockFetch(async () => ({ + ok: true, status: 200, headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ response_code: 0, verbose_msg: '成功', data: { attack_ip: [] } }), + })); + + const handler = await loadListAttackIpsHandler({}); + const result = await handler(); + assert.equal(result.response_code, 0); + assert.equal(result.data.length, 0); +}); + +test('ListAttackIPs auth failure', async () => { + mockFetch(async () => ({ + ok: true, status: 200, headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ response_code: 1003, verbose_msg: '认证失败, 详情: illegal apikey' }), + })); + + const handler = await loadListAttackIpsHandler({}); + await assert.rejects(handler(), /PERMISSION_DENIED/); +}); + +test('ListAttackIPs missing apiKey', async () => { + const handler = await loadListAttackIpsHandler({}, { secret: { apiKey: null } }); + await assert.rejects(handler(), /INVALID_ARGUMENT/); +}); + +test('ListAttackIPs http error 503', async () => { + mockFetch(async () => ({ + ok: false, status: 503, + headers: { get: () => 'text/plain' }, + text: async () => 'Service Unavailable', + })); + + const handler = await loadListAttackIpsHandler({}); + await assert.rejects(handler(), /UNAVAILABLE/); +}); + +test('ListAttackIPs http error 401 returns UNAUTHENTICATED', async () => { + mockFetch(async () => ({ + ok: false, status: 401, + headers: { get: () => 'text/plain' }, + text: async () => 'Unauthorized', + })); + + const handler = await loadListAttackIpsHandler({}); + try { + await handler(); + assert.fail('should have thrown'); + } catch (e) { + assert.match(e.message, /UNAUTHENTICATED/); + assert.equal(e.code, grpcStatus.UNAUTHENTICATED, `expected gRPC status UNAUTHENTICATED but got ${e.code}`); + } +}); + +test('ListAttackIPs http error 403 returns PERMISSION_DENIED', async () => { + mockFetch(async () => ({ + ok: false, status: 403, + headers: { get: () => 'text/plain' }, + text: async () => 'Forbidden', + })); + + const handler = await loadListAttackIpsHandler({}); + try { + await handler(); + assert.fail('should have thrown'); + } catch (e) { + assert.match(e.message, /PERMISSION_DENIED/); + assert.equal(e.code, grpcStatus.PERMISSION_DENIED, `expected gRPC status PERMISSION_DENIED but got ${e.code}`); + } +}); + +test('throwForHttpError does not leak upstream response body', async () => { + const sensitiveBody = 'internal stack trace with secret=abc123'; + const errors = []; + const origError = console.error; + console.error = (...args) => errors.push(args.join(' ')); + + mockFetch(async () => ({ + ok: false, status: 500, + headers: { get: () => 'text/plain' }, + text: async () => sensitiveBody, + })); + + const handler = await loadListAttackIpsHandler({}); + try { + await handler(); + assert.fail('should have thrown'); + } catch (e) { + // gRPC error message should NOT contain the sensitive body + assert.ok(!e.message.includes(sensitiveBody), `error message leaked upstream body: ${e.message}`); + // But it should have been logged server-side + assert.ok(errors.some(msg => msg.includes(sensitiveBody)), 'upstream body not logged server-side'); + } finally { + console.error = origError; + } +}); + +test('ListAttackIPs network failure', async () => { + mockFetch(async () => { throw new Error('connect ECONNREFUSED'); }); + + const handler = await loadListAttackIpsHandler({}); + await assert.rejects(handler(), /UNAVAILABLE/); +}); + +test('timeout throws DEADLINE_EXCEEDED', async () => { + mockFetch(async () => { + const err = new Error('The operation was aborted'); + err.name = 'TimeoutError'; + throw err; + }); + + const handler = await loadListAttackIpsHandler({}); + await assert.rejects(handler(), /DEADLINE_EXCEEDED/); +}); + +test('ListAttackDetails success', async () => { + mockFetch(async (url, init) => { + assert.ok(url.includes('/api/v1/attack/detail')); + assert.equal(init.method, 'POST'); + return { + ok: true, status: 200, headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ + response_code: 0, + verbose_msg: '成功', + data: { + total_num: 1, + page_no: 1, + page_size: 20, + detail_list: [ + { id: 1, src_ip: '1.2.3.4', dest_port: '22', type: 'SSH', create_time: '2026-06-01 12:00:00' }, + ], + }, + }), + }; + }); + + const handler = await loadListAttackDetailsHandler({ page: 1, limit: 20 }); + const result = await handler(); + assert.equal(result.response_code, 0); + assert.equal(result.data.total_num, 1); + assert.equal(result.data.detail_list[0].src_ip, '1.2.3.4'); + assert.equal(result.data.detail_list[0].type, 'SSH'); +}); + +test('ListAttackAccounts success', async () => { + mockFetch(async () => ({ + ok: true, status: 200, headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ + response_code: 0, + verbose_msg: '成功', + data: [ + { id: 1, ip: '1.2.3.4', account: 'root', password: 'admin123', type: 'SSH' }, + ], + }), + })); + + const handler = await loadListAttackAccountsHandler({}); + const result = await handler(); + assert.equal(result.response_code, 0); + assert.equal(result.data.length, 1); + assert.equal(result.data[0].account, 'root'); +}); + +test('GetSystemInfo success', async () => { + mockFetch(async (url, init) => { + assert.ok(url.includes('/api/v1/hfish/sys_info')); + assert.equal(init.method, 'GET'); + return { + ok: true, status: 200, headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ + response_code: 0, + verbose_msg: '成功', + data: { + total_honeypots: 7, + total_online_honeypots: 5, + total_offline_honeypots: 2, + honeypot_self_cnt: { 'SSH|SSH蜜罐': 2, 'WEB|WEB蜜罐': 3 }, + clients: [ + { name: 'node1', ip: '10.0.0.1', create_time: 1782388598, honeypots: [{ type: 'SSH', name: 'SSH蜜罐', state: 2 }] }, + ], + }, + }), + }; + }); + + const handler = await loadGetSystemInfoHandler({}); + const result = await handler(); + assert.equal(result.response_code, 0); + assert.equal(result.data.total_honeypots, 7); + assert.equal(result.data.total_online_honeypots, 5); + assert.equal(Object.keys(result.data.honeypot_self_cnt).length, 2); + assert.equal(result.data.clients.length, 1); + assert.equal(result.data.clients[0].honeypots[0].type, 'SSH'); +}); + +test('GetSystemInfo missing apiKey', async () => { + const handler = await loadGetSystemInfoHandler({}, { secret: { apiKey: null } }); + await assert.rejects(handler(), /INVALID_ARGUMENT/); +}); + +test('handler exports correct paths', async () => { + const { handlers } = await import('../src/hfish.js'); + + assert.ok(handlers['ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs']); + assert.ok(handlers['ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails']); + assert.ok(handlers['ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts']); + assert.ok(handlers['ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo']); +}); + +test('rpcdef returns correct handler map structure', async () => { + const { rpcdef } = await import('../src/hfish.js'); + + const ctx = buildCtx({}, { secret: { apiKey: 'key' } }); + const defs = rpcdef(ctx); + + assert.equal(typeof defs[listAttackIpsPath], 'function'); + assert.equal(typeof defs[listAttackDetailsPath], 'function'); + assert.equal(typeof defs[listAttackAccountsPath], 'function'); + assert.equal(typeof defs[getSystemInfoPath], 'function'); +}); diff --git a/services/threatbook__hfish/test/mock_upstream.js b/services/threatbook__hfish/test/mock_upstream.js new file mode 100644 index 00000000..8395a7a2 --- /dev/null +++ b/services/threatbook__hfish/test/mock_upstream.js @@ -0,0 +1,183 @@ +// Mock upstream for HFish attack IP, detail, account, and sys_info APIs +import http from 'node:http'; + +const httpPort = Number(process.env.HTTP_PORT || 18080); +const log = (...args) => console.log('[mock-hfish]', ...args); + +const sampleAttackIPs = { + response_code: 0, + verbose_msg: '成功', + data: { + attack_ip: [ + { + ip: '1.2.3.4', + attack_count: 15, + first_attack_time: '2026-01-01 00:00:00', + last_attack_time: '2026-06-01 12:00:00', + attack_types: ['SSH暴力破解', 'WEB扫描'], + country: 'CN', + province: 'Beijing', + city: 'Beijing', + group: 'default', + comment: '', + attack_chain_count: 3, + port_count: 5, + related_info_count: 2, + }, + { + ip: '5.6.7.8', + attack_count: 3, + first_attack_time: '2026-05-15 08:30:00', + last_attack_time: '2026-06-10 14:20:00', + attack_types: ['Redis未授权'], + country: 'US', + province: 'California', + city: 'Los Angeles', + group: 'default', + comment: 'suspicious', + attack_chain_count: 1, + port_count: 2, + related_info_count: 0, + }, + ], + }, +}; + +const sampleAttackDetails = { + response_code: 0, + verbose_msg: '成功', + data: { + total_num: 2, + page_no: 1, + page_size: 20, + total_page: 1, + detail_list: [ + { + id: 1, + src_ip: '1.2.3.4', + src_port: '54321', + dest_ip: '192.168.1.1', + dest_port: '22', + protocol: 'TCP', + type: 'SSH', + app_name: 'SSH蜜罐', + client_name: '内置节点', + raw_data: 'ssh login attempt: root/admin123', + country: 'CN', + province: 'Beijing', + city: 'Beijing', + create_time: '2026-06-01 12:00:00', + attack_chain: '', + crawl_info: '', + user_info: '', + }, + ], + }, +}; + +const sampleAttackAccounts = { + response_code: 0, + verbose_msg: '成功', + data: [ + { + id: 1, + ip: '1.2.3.4', + account: 'root', + password: 'admin123', + type: 'SSH', + create_time: '2026-06-01 12:00:00', + }, + { + id: 2, + ip: '5.6.7.8', + account: 'admin', + password: 'redis123', + type: 'REDIS', + create_time: '2026-06-10 14:20:00', + }, + ], +}; + +const sampleSystemInfo = { + response_code: 0, + verbose_msg: '成功', + data: { + total_honeypots: 7, + total_cardinal_honeypots: 7, + total_online_honeypots: 7, + total_offline_honeypots: 0, + honeypot_self_cnt: { + 'SSH|SSH蜜罐': 2, + 'REDIS|Redis蜜罐': 1, + 'WEB|WEB蜜罐': 3, + 'TCP|TCP端口监听': 1, + }, + clients: [ + { + name: '内置节点', + ip: '172.17.0.2', + create_time: 1782388598, + honeypots: [ + { type: 'SSH', name: 'SSH蜜罐', state: 2 }, + { type: 'REDIS', name: 'Redis蜜罐', state: 2 }, + ], + }, + ], + }, +}; + +const extractApiKey = (url) => { + const parsed = new URL(url, 'http://localhost'); + return parsed.searchParams.get('api_key') || ''; +}; + +const server = http.createServer((req, res) => { + const url = req.url || ''; + const method = req.method || 'GET'; + const apiKey = extractApiKey(url); + + const sendJson = (data, status = 200) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(data)); + }; + + const sendUnauthorized = () => { + sendJson({ response_code: 1003, verbose_msg: '认证失败, 详情: illegal apikey' }); + }; + + if (url.includes('/api/v1/hfish/sys_info') && method === 'GET') { + if (!apiKey) { sendUnauthorized(); return; } + log('sys_info request'); + sendJson(sampleSystemInfo); + return; + } + + if (url.includes('/api/v1/attack/ip') && method === 'POST') { + if (!apiKey) { sendUnauthorized(); return; } + log('attack/ip request'); + sendJson(sampleAttackIPs); + return; + } + + if (url.includes('/api/v1/attack/detail') && method === 'POST') { + if (!apiKey) { sendUnauthorized(); return; } + log('attack/detail request'); + sendJson(sampleAttackDetails); + return; + } + + if (url.includes('/api/v1/attack/account') && method === 'POST') { + if (!apiKey) { sendUnauthorized(); return; } + log('attack/account request'); + sendJson(sampleAttackAccounts); + return; + } + + log('unknown route', method, url); + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); +}); + +server.listen(httpPort, () => + log(`listening on :${httpPort} (GET /api/v1/hfish/sys_info, POST /api/v1/attack/{ip,detail,account})`) +); diff --git a/services/threatbook__hfish/test/verify-real-device.mjs b/services/threatbook__hfish/test/verify-real-device.mjs new file mode 100644 index 00000000..d860c754 --- /dev/null +++ b/services/threatbook__hfish/test/verify-real-device.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * ThreatBook HFish 真实设备联调验证脚本 + * + * 用法: + * HFISH_ENDPOINT=https://your-hfish:4433 HFISH_API_KEY=your-key node verify-real-device.mjs + * + * 可选环境变量: + * SKIP_TLS - 设置为 true 跳过 TLS 验证(HFish 默认使用自签名证书) + */ + +import { rpcdef } from '../src/hfish.js'; + +const endpoint = process.env.HFISH_ENDPOINT; +const apiKey = process.env.HFISH_API_KEY; +const skipTls = process.env.SKIP_TLS === 'true'; // default false; set SKIP_TLS=true to skip for self-signed certs + +if (!endpoint || !apiKey) { + console.error('❌ 请设置环境变量 HFISH_ENDPOINT 和 HFISH_API_KEY'); + console.error(' 例: HFISH_ENDPOINT=https://hfish:4433 HFISH_API_KEY=xxx node verify-real-device.mjs'); + process.exit(1); +} + +const ctx = { + bindings: { endpoint, skipTlsVerify: skipTls }, + secret: { apiKey }, + limits: { timeoutMs: 10000 }, + meta: { instance_id: 'verify', request_id: 'verify-001' }, + req: {}, +}; + +const api = rpcdef(ctx); +const results = []; + +const testMethod = async (name, path, req = {}) => { + try { + const handlerCtx = { ...ctx, req }; + const res = await rpcdef(handlerCtx)[path](req); + console.log(`✅ ${name}: success`, JSON.stringify(res).slice(0, 200)); + results.push({ name, status: 'PASS' }); + return res; + } catch (e) { + console.log(`❌ ${name}: ${e.message}`); + results.push({ name, status: 'FAIL', error: e.message }); + return null; + } +}; + +console.log('=== HFish 真实设备联调验证 ===\n'); +console.log(`Endpoint: ${endpoint}`); +console.log(`TLS skip: ${skipTls}\n`); + +await testMethod('GetSystemInfo', '/ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo', {}); +await testMethod('ListAttackIPs', '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs', { page: 1, limit: 20 }); +await testMethod('ListAttackDetails', '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails', { page: 1, limit: 20 }); +await testMethod('ListAttackAccounts', '/ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts', { page: 1, limit: 20 }); + +console.log('\n=== 验证结果汇总 ==='); +for (const r of results) { + console.log(` ${r.status === 'PASS' ? '✅' : '❌'} ${r.name}: ${r.status}${r.error ? ' - ' + r.error : ''}`); +} +const passCount = results.filter(r => r.status === 'PASS').length; +console.log(`\n通过: ${passCount}/${results.length}`); + +if (passCount !== results.length) { + console.error(`❌ 存在 ${results.length - passCount} 个测试失败`); + process.exit(1); +}