diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 62d5d389..cbcf4236 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -253,6 +253,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/package.json b/services/package.json index 6b181372..31ad3f31 100644 --- a/services/package.json +++ b/services/package.json @@ -67,6 +67,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": [ @@ -131,6 +132,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", @@ -193,6 +195,7 @@ "venus__ads_v3-6", "volcengine__cloud-firewall", "wd__k01", + "threatbook__hfish", "filigran__opencti", "fofa__network-space-mapper", "wangsu__label-ip", diff --git a/services/screenshot-hfish-verify.sh b/services/screenshot-hfish-verify.sh new file mode 100755 index 00000000..14c01efa --- /dev/null +++ b/services/screenshot-hfish-verify.sh @@ -0,0 +1,284 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="${REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" +SERVICES_ROOT="${SERVICES_ROOT:-$REPO_ROOT/services}" +SERVICE_DIR="${SERVICE_DIR:-$SERVICES_ROOT/threatbook__hfish}" +OCTOBUS_BIN="${OCTOBUS_BIN:-$(command -v octobus || true)}" +OUTPUT_DIR="${OUTPUT_DIR:-$REPO_ROOT/.octobus-proof/hfish}" +OCTOBUS_ADDR="${OCTOBUS_ADDR:-127.0.0.1:19102}" +OCTOBUS_DATA_DIR="${OCTOBUS_DATA_DIR:-/tmp/obh2}" +AUTO_DAEMON="${AUTO_DAEMON:-1}" +AUTO_DAEMON_STOP="${AUTO_DAEMON_STOP:-0}" +SERVICE_ID="${SERVICE_ID:-threatbook-hfish}" +INSTANCE_ID="${INSTANCE_ID:-hfish-local}" +CAPSET_ID="${CAPSET_ID:-threat-intel}" +SETUP="${SETUP:-1}" +HFISH_SKIP_TLS="${HFISH_SKIP_TLS:-false}" + +HFISH_ENDPOINT="${HFISH_ENDPOINT:-}" +HFISH_API_KEY="${HFISH_API_KEY:-}" +HFISH_CAPSET_TOKEN="${HFISH_CAPSET_TOKEN:-}" + +export INSTANCE_ID CAPSET_ID SERVICE_ID + +G='\033[0;32m' +Y='\033[1;33m' +C='\033[0;36m' +N='\033[0m' + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { echo "缺少命令: $1" >&2; exit 1; } +} + +search_lines() { + local pattern="$1" + shift + if command -v rg >/dev/null 2>&1; then + rg "$pattern" "$@" + else + grep -E "$pattern" "$@" + fi +} + +get_window_id() { + osascript -e 'tell application "Terminal" to get id of front window' 2>/dev/null || true +} + +take_screenshot() { + local name="$1" + local filepath="$OUTPUT_DIR/$name" + local wid + wid="$(get_window_id)" + if [[ -n "$wid" ]]; then + sleep 0.5 + screencapture -l "$wid" -o "$filepath" 2>/dev/null \ + && echo -e "${G}📸 已保存: $filepath${N}" \ + || echo -e "${Y}⚠️ 自动截图失败,请手动截图${N}" + else + echo -e "${Y}⚠️ 无法获取 Terminal 窗口 ID,请手动截图${N}" + fi +} + +octobus() { + "$OCTOBUS_BIN" --addr "$OCTOBUS_ADDR" "$@" +} + +admin_octobus() { + if [[ -n "${OCTOBUS_ADMIN_TOKEN:-}" ]]; then + OCTOBUS_ADMIN_TOKEN="$OCTOBUS_ADMIN_TOKEN" octobus "$@" + else + octobus "$@" + fi +} + +json_instance_filter='.. | objects | select((.ID? // .Id? // .id? // "") == env.INSTANCE_ID)' +json_capset_filter='.. | objects | select((.ID? // .Id? // .id? // "") == env.CAPSET_ID)' + +need_cmd jq +need_cmd grpcurl +need_cmd curl +need_cmd npm +need_cmd osascript +need_cmd screencapture +need_cmd octobus + +mkdir -p "$OUTPUT_DIR" + +wait_for_daemon() { + local retries="${1:-40}" + local i + for ((i=0; i/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +cleanup_daemon() { + if [[ -n "${AUTO_DAEMON_PID:-}" ]] && kill -0 "$AUTO_DAEMON_PID" >/dev/null 2>&1; then + kill "$AUTO_DAEMON_PID" >/dev/null 2>&1 || true + wait "$AUTO_DAEMON_PID" 2>/dev/null || true + fi +} + +ensure_daemon() { + if octobus --addr "$OCTOBUS_ADDR" status >/dev/null 2>&1; then + return 0 + fi + if [[ "$AUTO_DAEMON" != "1" ]]; then + echo "octobus daemon is not running at $OCTOBUS_ADDR; run octobus serve first" >&2 + exit 1 + fi + mkdir -p "$OCTOBUS_DATA_DIR" + echo -e "${G}自动启动临时 OctoBus daemon: ${OCTOBUS_ADDR}${N}" + if [[ "$HFISH_SKIP_TLS" == "true" ]]; then + NODE_TLS_REJECT_UNAUTHORIZED=0 octobus serve --data-dir "$OCTOBUS_DATA_DIR" --addr "$OCTOBUS_ADDR" >"$OCTOBUS_DATA_DIR/daemon.stdout.log" 2>"$OCTOBUS_DATA_DIR/daemon.stderr.log" & + else + octobus serve --data-dir "$OCTOBUS_DATA_DIR" --addr "$OCTOBUS_ADDR" >"$OCTOBUS_DATA_DIR/daemon.stdout.log" 2>"$OCTOBUS_DATA_DIR/daemon.stderr.log" & + fi + AUTO_DAEMON_PID=$! + if [[ "$AUTO_DAEMON_STOP" == "1" ]]; then + trap cleanup_daemon EXIT + fi + if ! wait_for_daemon; then + echo "自动启动 daemon 失败: $OCTOBUS_ADDR" >&2 + echo "stdout:" >&2 + tail -50 "$OCTOBUS_DATA_DIR/daemon.stdout.log" >&2 || true + echo "stderr:" >&2 + tail -50 "$OCTOBUS_DATA_DIR/daemon.stderr.log" >&2 || true + exit 1 + fi +} + +ensure_service() { + if octobus --addr "$OCTOBUS_ADDR" service get "$SERVICE_ID" >/dev/null 2>&1; then + admin_octobus service import "$SERVICE_ID" "$SERVICE_DIR" >/dev/null 2>&1 || true + return 0 + fi + if ! admin_octobus service import "$SERVICE_ID" "$SERVICE_DIR"; then + admin_octobus service get "$SERVICE_ID" >/dev/null 2>&1 || exit 1 + fi +} + +ensure_instance() { + if admin_octobus instance get "$INSTANCE_ID" >/dev/null 2>&1; then + admin_octobus instance update-config "$INSTANCE_ID" --config-json "$CONFIG_JSON" --restart + admin_octobus instance update-secret "$INSTANCE_ID" --secret-json "$SECRET_JSON" --restart + return 0 + fi + admin_octobus instance create "$INSTANCE_ID" --service "$SERVICE_ID" --config-json "$CONFIG_JSON" --secret-json "$SECRET_JSON" +} + +ensure_capset() { + if ! admin_octobus capset get "$CAPSET_ID" >/dev/null 2>&1; then + admin_octobus capset create "$CAPSET_ID" --name "Threat Intel" + fi + if ! admin_octobus capset list-instances "$CAPSET_ID" | jq -e "$json_instance_filter" >/dev/null 2>&1; then + local add_output + if ! add_output="$(admin_octobus capset add-instance "$CAPSET_ID" "$INSTANCE_ID" 2>&1)"; then + if printf '%s' "$add_output" | grep -q 'UNIQUE constraint failed: capset_instances.id'; then + echo -e "${Y}capset 已绑定实例,跳过 add-instance: $CAPSET_ID -> $INSTANCE_ID${N}" + else + printf '%s\n' "$add_output" >&2 + exit 1 + fi + else + printf '%s\n' "$add_output" + fi + fi +} + +if [[ -z "$HFISH_ENDPOINT" || -z "$HFISH_API_KEY" ]]; then + echo "请设置 HFISH_ENDPOINT 和 HFISH_API_KEY" >&2 + echo "例: HFISH_ENDPOINT=https://127.0.0.1:4433 HFISH_API_KEY=xxx SETUP=1 bash services/screenshot-hfish-verify.sh" >&2 + exit 1 +fi + +if [[ ! -x "$OCTOBUS_BIN" ]]; then + echo "未找到可执行的 octobus CLI: $OCTOBUS_BIN" >&2 + exit 1 +fi + +if [[ ! -d "$SERVICE_DIR" ]]; then + echo "未找到 service package 目录: $SERVICE_DIR" >&2 + echo "请先 checkout 含 HFish service package 的分支,或手动设置 SERVICE_DIR" >&2 + exit 1 +fi + +GRPC_HEADERS=(-H "x-octobus-capset: $CAPSET_ID" -H "x-octobus-instance: $INSTANCE_ID") +REFLECTION_HEADERS=(-H "x-octobus-capset: $CAPSET_ID") +if [[ -n "$HFISH_CAPSET_TOKEN" ]]; then + GRPC_HEADERS+=(-H "authorization: Bearer $HFISH_CAPSET_TOKEN") + REFLECTION_HEADERS+=(-H "authorization: Bearer $HFISH_CAPSET_TOKEN") +fi + +DIRECT_CURL_TLS_ARGS=() +if [[ "$HFISH_SKIP_TLS" == "true" ]]; then + DIRECT_CURL_TLS_ARGS+=(-k) +fi + +CONFIG_JSON="$(jq -cn --arg endpoint "$HFISH_ENDPOINT" --argjson skipTlsVerify "$([[ "$HFISH_SKIP_TLS" == "true" ]] && echo true || echo false)" '{endpoint:$endpoint, timeoutMs:1500, skipTlsVerify:$skipTlsVerify}')" +SECRET_JSON="$(jq -cn --arg apiKey "$HFISH_API_KEY" '{apiKey:$apiKey}')" + +echo -e "${C}=== ThreatBook HFish OctoBus 联调取证截图 ===${N}" +echo -e "${C}输出目录: $OUTPUT_DIR${N}" +echo -e "${C}Service dir: $SERVICE_DIR${N}" +echo -e "${C}OctoBus addr: $OCTOBUS_ADDR${N}" +echo -e "${C}OctoBus data dir: $OCTOBUS_DATA_DIR${N}" +echo -e "${C}所有证据均来自当前环境的真实命令输出${N}\n" + +ensure_daemon + +if [[ "$SETUP" == "1" ]]; then + echo -e "${G}0) 导入 service + 创建/复用 instance/capset${N}" + ensure_service + ensure_instance + ensure_capset + echo -e "${C}当前实例记录${N}" + admin_octobus instance get "$INSTANCE_ID" | jq -c '.' + take_screenshot "00-setup-and-instance.png" +fi + +echo -e "\n${G}1) service package 单测通过${N}" +(cd "$SERVICES_ROOT" && npm test -- --service-dir threatbook__hfish) +take_screenshot "01-npm-test-hfish.png" + +echo -e "\n${G}2) OctoBus 特有标识:reflection + capset + instance${N}" +echo -e "${C}[gRPC reflection list]${N}" +grpcurl -plaintext "${REFLECTION_HEADERS[@]}" "$OCTOBUS_ADDR" list | search_lines 'ThreatBook_HFISH|grpc.reflection' || true +echo +echo -e "${C}[gRPC reflection describe]${N}" +grpcurl -plaintext "${REFLECTION_HEADERS[@]}" "$OCTOBUS_ADDR" describe ThreatBook_HFISH.ThreatBook_HFISH +echo +echo -e "${C}[capset list-methods]${N}" +admin_octobus capset list-methods "$CAPSET_ID" | jq '.methods[] | {MethodFullName, MCPToolName, Enabled, CapsetInstanceID}' +echo +echo -e "${C}[instance list]${N}" +admin_octobus instance list | jq -c "$json_instance_filter" +take_screenshot "02-reflection-capset-instance.png" + +echo -e "\n${G}3) 四个 RPC 经本地 OctoBus 中转调用${N}" +echo -e "${C}GetSystemInfo${N}" +grpcurl -plaintext "${GRPC_HEADERS[@]}" -d '{}' "$OCTOBUS_ADDR" ThreatBook_HFISH.ThreatBook_HFISH/GetSystemInfo | jq '{responseCode, verboseMsg, data: {totalHoneypots: .data.totalHoneypots, totalOnlineHoneypots: .data.totalOnlineHoneypots, clients: (.data.clients | length)}}' +echo +echo -e "${C}ListAttackIPs${N}" +grpcurl -plaintext "${GRPC_HEADERS[@]}" -d '{"page":1,"limit":1}' "$OCTOBUS_ADDR" ThreatBook_HFISH.ThreatBook_HFISH/ListAttackIPs | jq '{responseCode, verboseMsg, count: (.data | length), first: .data[0]}' +echo +echo -e "${C}ListAttackDetails${N}" +grpcurl -plaintext "${GRPC_HEADERS[@]}" -d '{"page":1,"limit":1}' "$OCTOBUS_ADDR" ThreatBook_HFISH.ThreatBook_HFISH/ListAttackDetails | jq '{responseCode, verboseMsg, data: {totalNum: .data.totalNum, pageNo: .data.pageNo, pageSize: .data.pageSize, first: .data.detailList[0]}}' +echo +echo -e "${C}ListAttackAccounts${N}" +grpcurl -plaintext "${GRPC_HEADERS[@]}" -d '{"page":1,"limit":1}' "$OCTOBUS_ADDR" ThreatBook_HFISH.ThreatBook_HFISH/ListAttackAccounts | jq '{responseCode, verboseMsg, count: (.data | length), first: .data[0]}' +take_screenshot "03-four-rpcs-via-octobus.png" + +echo -e "\n${G}4) 直连 HFish vs 经 OctoBus:字段命名差异${N}" +echo -e "${C}[直连 HFish REST,snake_case]${N}" +cat </dev/null || true diff --git a/services/threatbook__hfish/README.md b/services/threatbook__hfish/README.md new file mode 100644 index 00000000..8b36f6b2 --- /dev/null +++ b/services/threatbook__hfish/README.md @@ -0,0 +1,121 @@ +# 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`). + +## Verification Screenshots + +### HFish Setup & Instance + +![HFish setup and instance dashboard](https://raw.githubusercontent.com/boy331/OctoBus/docs/hfish-screenshots-hosting/docs/hfish-screenshots/00-setup-and-instance.png) + +HFish honeypot management console showing deployed instance and service status. + +### npm test Passing + +![npm test hfish results](https://raw.githubusercontent.com/boy331/OctoBus/docs/hfish-screenshots-hosting/docs/hfish-screenshots/01-npm-test-hfish.png) + +All unit tests passing for the `threatbook__hfish` service package. + +### Reflection & CapSet Instance + +![Reflection capset instance verification](https://raw.githubusercontent.com/boy331/OctoBus/docs/hfish-screenshots-hosting/docs/hfish-screenshots/02-reflection-capset-instance.png) + +OctoBus reflection API returning the CapSet instance for HFish service validation. + +### Four RPCs via OctoBus + +![Four RPCs invoked through OctoBus](https://raw.githubusercontent.com/boy331/OctoBus/docs/hfish-screenshots-hosting/docs/hfish-screenshots/03-four-rpcs-via-octobus.png) + +All four RPC methods (`ListAttackIPs`, `ListAttackDetails`, `ListAttackAccounts`, `GetSystemInfo`) successfully invoked through OctoBus. + +### Direct API vs OctoBus jsonName Mapping + +![Direct API call vs OctoBus jsonName mapping](https://raw.githubusercontent.com/boy331/OctoBus/docs/hfish-screenshots-hosting/docs/hfish-screenshots/04-direct-vs-octobus-jsonname.png) + +Side-by-side comparison: direct HFish API call vs. OctoBus RPC with `jsonName` field mapping, confirming consistent response structure. + +### Access Log (NDJSON) + +![Access log NDJSON output](https://raw.githubusercontent.com/boy331/OctoBus/docs/hfish-screenshots-hosting/docs/hfish-screenshots/05-access-log-ndjson.png) + +OctoBus access log showing NDJSON-formatted request/response entries for HFish RPC calls. + +## Behavior Notes + +- HTTP 401/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..7e8ff6c4 --- /dev/null +++ b/services/threatbook__hfish/src/hfish.js @@ -0,0 +1,466 @@ +// 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, + 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); + + if (skipTlsVerify) { + const inst = meta.instance_id || meta.instanceId || 'unknown'; + console.warn( + `[ThreatBook_HFISH][TLS] skipTlsVerify=true; certificate verification behavior depends on the runtime fetch implementation. [inst=${inst}]`, + ); + } + + 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 = {}) => { + const url = new URL(path, baseUrl); + 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: { attack_ip: 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..ce3dad08 --- /dev/null +++ b/services/threatbook__hfish/test/hfish.test.js @@ -0,0 +1,333 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +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.attack_ip.length, 2); + assert.equal(result.data.attack_ip[0].ip, '1.2.3.4'); + assert.equal(result.data.attack_ip[0].attack_count, 10); +}); + +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.attack_ip.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({}); + await assert.rejects(handler(), /UNAUTHENTICATED/); +}); + +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({}); + await assert.rejects(handler(), /PERMISSION_DENIED/); +}); + +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); +}