diff --git a/services/bin/cosmos.js b/services/bin/cosmos.js new file mode 100755 index 00000000..e524df6e --- /dev/null +++ b/services/bin/cosmos.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../chaitin__cosmos/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../chaitin__cosmos/bin/cosmos.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 4124b804..38019e1f 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -9,6 +9,14 @@ const services = { entryFile: "../alibaba-cloud__simple-application-server-firewall/bin/alibaba-cloud-simple-application-server-firewall.js", serviceModule: "../alibaba-cloud__simple-application-server-firewall/src/service.js", }, + "cloudatlas": { + entryFile: "../chaitin__cloudatlas/bin/cloudatlas.js", + serviceModule: "../chaitin__cloudatlas/src/service.js", + }, + "cosmos": { + entryFile: "../chaitin__cosmos/bin/cosmos.js", + serviceModule: "../chaitin__cosmos/src/service.js", + }, "safeline-waf": { entryFile: "../chaitin__safeline-waf/bin/safeline-waf.js", serviceModule: "../chaitin__safeline-waf/src/service.js", @@ -17,10 +25,6 @@ const services = { entryFile: "../chaitin__safeline-waf-eliminate-false-positive/bin/safeline-waf-eliminate-false-positive.js", serviceModule: "../chaitin__safeline-waf-eliminate-false-positive/src/service.js", }, - "cloudatlas": { - entryFile: "../chaitin__cloudatlas/bin/cloudatlas.js", - serviceModule: "../chaitin__cloudatlas/src/service.js", - }, "das-gateway-v3": { entryFile: "../das__gateway_v3/bin/das-gateway-v3.js", serviceModule: "../das__gateway_v3/src/service.js", diff --git a/services/chaitin__cosmos/README.md b/services/chaitin__cosmos/README.md new file mode 100644 index 00000000..348f559b --- /dev/null +++ b/services/chaitin__cosmos/README.md @@ -0,0 +1,72 @@ +# Chaitin Cosmos (万象) OctoBus Service + +OctoBus service package for Chaitin Cosmos (万象) platform log APIs via Pedestal JSON-RPC. + +## Features + +- **SearchLogInfo**: Get log details by IDs +- **SearchLogList**: Search log list with keyword, time range, condition query, filters, and pagination +- **SearchAggregationStatistics**: Get log aggregation statistics with time-series data + +## Configuration + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `endpoint` | string | yes | Cosmos Pedestal RPC base URL, e.g. `https://cosmos.example.com` | +| `headers` | object | no | Extra HTTP headers sent to Cosmos | +| `timeoutMs` | integer | no | HTTP timeout in ms, default 5000 | + +## Secret + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `api_token` | string | yes | Cosmos JWT token for Authorization bearer header | + +> **Note:** `api_token` must be configured via the instance secret. It is **not** accepted in request messages. This follows the OctoBus convention that credentials belong in secret config rather than per-call payloads. + +## TLS Certificate Requirements + +This service validates TLS certificates by default. If your Cosmos deployment uses a self-signed or private CA certificate: + +1. **Recommended:** Add the CA to the system trust store +2. **Alternative:** Set the `NODE_EXTRA_CA_CERTS` environment variable to point to your CA bundle: + ```bash + export NODE_EXTRA_CA_CERTS=/path/to/ca-bundle.pem + ``` + +Per-request TLS bypass (`skipTlsVerify`) is intentionally **not** supported, as it would require process-wide security downgrades (`NODE_TLS_REJECT_UNAUTHORIZED=0`) that affect all other HTTPS connections in the same Node.js process. + +## Quick Start + +```bash +# Import service +octobus service import cosmos ./services/chaitin__cosmos + +# Create instance +octobus instance create cosmos-test --service cosmos --config-json '{"endpoint":"https://cosmos.demo.chaitin.cn"}' --secret-json '{"api_token":"xxx"}' + +# Create capset +octobus capset create cosmos --name cosmos +octobus capset add-instance cosmos cosmos-test + +# Call via gRPC (e.g. grpcurl) +grpcurl -plaintext \ + -H "x-octobus-capset: cosmos" \ + -H "x-octobus-service: cosmos" \ + -H "x-octobus-instance: cosmos-test" \ + -d '{"time_range_start": "1750550400", "time_range_end": "1750723200", "count": "1", "offset": "0"}' \ + octobus_addr:9000 \ + Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList + +# Call via MCP +curl -X POST \ + -H "Content-Type: application/json" \ + "http://octobus_addr:9000/capsets/cosmos/mcp" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' + +# Call via Connect-RPC +curl -s -X POST \ + "http://octobus_addr:9000/capsets/cosmos/connect/cosmos-test/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList" \ + -H "Content-Type: application/json" \ + -d '{"time_range_start": "1750550400", "time_range_end": "1750723200", "count": "1", "offset": "0"}' +``` diff --git a/services/chaitin__cosmos/bin/cosmos.js b/services/chaitin__cosmos/bin/cosmos.js new file mode 100755 index 00000000..508272f0 --- /dev/null +++ b/services/chaitin__cosmos/bin/cosmos.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/chaitin__cosmos/config.schema.json b/services/chaitin__cosmos/config.schema.json new file mode 100644 index 00000000..2c8bbdd9 --- /dev/null +++ b/services/chaitin__cosmos/config.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "Cosmos Pedestal RPC base URL, for example https://cosmos.example.com." + }, + "restBaseUrl": { + "type": "string", + "description": "Legacy alias for endpoint." + }, + "baseUrl": { + "type": "string", + "description": "Legacy alias for endpoint." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional extra HTTP headers sent to Cosmos." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "HTTP timeout in milliseconds." + } + } +} diff --git a/services/chaitin__cosmos/package.json b/services/chaitin__cosmos/package.json new file mode 100644 index 00000000..0e57192f --- /dev/null +++ b/services/chaitin__cosmos/package.json @@ -0,0 +1,13 @@ +{ + "name": "cosmos", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "cosmos": "bin/cosmos.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0", + "undici": "^7.0.0" + } +} diff --git a/services/chaitin__cosmos/proto/cosmos.proto b/services/chaitin__cosmos/proto/cosmos.proto new file mode 100644 index 00000000..0415325e --- /dev/null +++ b/services/chaitin__cosmos/proto/cosmos.proto @@ -0,0 +1,140 @@ +syntax = "proto3"; + +package Chaitin_COSMOS; + +import "google/protobuf/wrappers.proto"; +import "google/protobuf/struct.proto"; + +option go_package = "miner/grpc-service/Chaitin_COSMOS"; + +service Chaitin_COSMOS { + // 获取日志详情,根据日志 ID 列表查询 + rpc SearchLogInfo(SearchLogInfoRequest) returns (SearchLogInfoResponse) {} + + // 获取日志列表,支持关键字搜索、时间范围、条件过滤与分页 + rpc SearchLogList(SearchLogListRequest) returns (SearchLogListResponse) {} + + // 获取日志聚合统计,按指定维度聚合并返回时序数据 + rpc SearchAggregationStatistics(SearchAggregationStatisticsRequest) returns (SearchAggregationStatisticsResponse) {} +} + +// ─── SearchLogInfo ─── + +message SearchLogInfoRequest { + reserved 1; // formerly api_token — removed; token now comes from ctx.secret + repeated string ids = 2; // 日志 ID 列表,必填且非空 +} + +message SearchLogInfoResponse { + google.protobuf.Value err = 1; + google.protobuf.Value msg = 2; + SearchLogInfoData data = 3; +} + +message SearchLogInfoData { + repeated CosmoLogRecord records = 1; +} + +// ─── SearchLogList ─── + +message SearchLogListRequest { + reserved 1; // formerly api_token — removed; token now comes from ctx.secret + repeated string keyword = 2; // 关键字搜索,可选 + google.protobuf.Int64Value time_range_start = 3; // 时间范围起始(Unix 秒),可选 + google.protobuf.Int64Value time_range_end = 4; // 时间范围结束(Unix 秒),可选 + string advanced_query = 5; // 高级查询语句,可选 + CosmoConditionQuery condition_query = 6; // 条件查询,可选 + CosmoFilter filter = 7; // 过滤条件,可选 + google.protobuf.Int64Value count = 8; // 分页大小,可选,默认 100 + google.protobuf.Int64Value offset = 9; // 分页偏移,可选,默认 0 + string attack_chain_phase = 10; // 攻击链阶段筛选,可选 + google.protobuf.BoolValue fall = 11; // 失陷状态筛选,可选 + repeated CosmoOrganization organization = 12; // 组织机构筛选,可选 +} + +message CosmoConditionQuery { + string logical_op = 1; // 逻辑运算符,如 "AND" + repeated CosmoExpression expressions = 2; // 条件表达式列表 +} + +message CosmoExpression { + string column = 1; // 字段名,如 "src_ip", "log_id" + string op = 2; // 运算符,如 "equal", "contains" + string value = 3; // 值 +} + +message CosmoFilter { + repeated string origin_event_name = 1; // 原始事件名过滤 + repeated string src_ip = 2; // 源 IP 过滤 + repeated string dest_ip = 3; // 目的 IP 过滤 + repeated string src_country = 4; // 源国家过滤 + repeated string src_port = 5; // 源端口过滤 + repeated string dest_port = 6; // 目的端口过滤 + repeated int32 attack_result = 7; // 攻击结果过滤 +} + +message CosmoOrganization { + string oper = 1; // 运算符,如 "=" + int64 target = 2; // 目标 ID +} + +message SearchLogListResponse { + google.protobuf.Value err = 1; + google.protobuf.Value msg = 2; + SearchLogListData data = 3; +} + +message SearchLogListData { + repeated CosmoLogRecord records = 1; + int64 start_time = 2; + int64 end_time = 3; +} + +// ─── SearchAggregationStatistics ─── + +message SearchAggregationStatisticsRequest { + reserved 1; // formerly api_token — removed; token now comes from ctx.secret + repeated string keyword = 2; // 关键字搜索,可选 + google.protobuf.Int64Value time_range_start = 3; // 时间范围起始(Unix 秒),可选 + google.protobuf.Int64Value time_range_end = 4; // 时间范围结束(Unix 秒),可选 + string advanced_query = 5; // 高级查询语句,可选 + CosmoConditionQuery condition_query = 6; // 条件查询,可选 + CosmoFilter filter = 7; // 过滤条件,可选 + repeated string key = 8; // 聚合维度,如 ["src_ip","dest_ip","event_type"] + google.protobuf.Int64Value count = 9; // 返回条数,可选,默认 10 + google.protobuf.BoolValue asc = 10; // 是否升序,可选,默认 false + string attack_chain_phase = 11; // 攻击链阶段筛选,可选 + google.protobuf.BoolValue fall = 12; // 失陷状态筛选,可选 + repeated CosmoOrganization organization = 13; // 组织机构筛选,可选 +} + +message SearchAggregationStatisticsResponse { + google.protobuf.Value err = 1; + google.protobuf.Value msg = 2; + SearchAggregationStatisticsData data = 3; +} + +message SearchAggregationStatisticsData { + repeated CosmoAggregationGroup groups = 1; + int64 total = 2; +} + +message CosmoAggregationGroup { + google.protobuf.Struct result = 1; // 聚合键值,如 {"src_ip":"1.2.3.4","event_type":52001} + repeated CosmoAggregationPoint data = 2; // 时序数据点 + int64 count = 3; // 总数 +} + +message CosmoAggregationPoint { + int64 start_time = 1; // 时间点(Unix 秒) + int64 count = 2; // 计数 +} + +// ─── 通用日志记录 ─── + +message CosmoLogRecord { + // Cosmos API 返回字段非常丰富且可能随版本变化, + // 使用 google.protobuf.Struct 保留完整的原始数据, + // 避免 proto 字段名 (snake_case) 与 protobuf-es JS 表示 (camelCase) 的转换问题 + google.protobuf.Struct raw = 1; +} diff --git a/services/chaitin__cosmos/secret.schema.json b/services/chaitin__cosmos/secret.schema.json new file mode 100644 index 00000000..7dc36244 --- /dev/null +++ b/services/chaitin__cosmos/secret.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "api_token": { + "type": "string", + "description": "Cosmos JWT token for Authorization bearer header." + } + } +} diff --git a/services/chaitin__cosmos/service.json b/services/chaitin__cosmos/service.json new file mode 100644 index 00000000..1f3d22eb --- /dev/null +++ b/services/chaitin__cosmos/service.json @@ -0,0 +1,37 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "cosmos", + "displayName": "Chaitin Cosmos", + "description": "OctoBus package for Chaitin Cosmos (万象) log search, log detail, and aggregation statistics APIs via Pedestal JSON-RPC.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/cosmos.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo": { + "name": "search-log-info", + "description": "Get Cosmos log detail by IDs." + }, + "Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList": { + "name": "search-log-list", + "description": "Search Cosmos log list with filters." + }, + "Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics": { + "name": "search-aggregation-statistics", + "description": "Get Cosmos log aggregation statistics." + } + } + } + } +} diff --git a/services/chaitin__cosmos/src/cosmos.js b/services/chaitin__cosmos/src/cosmos.js new file mode 100644 index 00000000..0da3d653 --- /dev/null +++ b/services/chaitin__cosmos/src/cosmos.js @@ -0,0 +1,641 @@ +// Chaitin_COSMOS Cosmos Pedestal JSON-RPC proxy +// Bindings: endpoint (required), headers (optional), timeoutMs (optional) + +import { Agent as UndiciAgent } from 'undici'; +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +const DEFAULT_TIMEOUT_MS = 5000; +const RPC_PATH = '/pedestal/rpc'; + +const METHOD_SEARCH_LOG_INFO = '/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'; +const METHOD_SEARCH_LOG_LIST = '/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'; +const METHOD_SEARCH_AGGREGATION = '/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'; + +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, + INTERNAL: grpcStatus.INTERNAL, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +const firstDefined = (...vals) => vals.find((v) => v !== undefined && v !== null); +const firstNonEmpty = (...vals) => vals.find((v) => v !== undefined && v !== null && v !== ''); + +const unwrapString = (source) => { + if (source === undefined || source === null) return ''; + if (typeof source === 'object' && source !== null && 'value' in source) { + return String(source.value ?? ''); + } + return String(source); +}; + +const toPositiveInt = (val) => { + if (val === undefined || val === null) return null; + if (typeof val === 'bigint') return Number(val); + 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; +}; + +/** Validate and normalize timeoutMs — must be a finite positive integer */ +const validateTimeoutMs = (val) => { + if (val === undefined || val === null) return DEFAULT_TIMEOUT_MS; + const n = Number(val); + if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) { + const display = Number.isNaN(val) ? 'NaN' : !Number.isFinite(val) ? String(val) : JSON.stringify(val); + throw errorWithCode('INVALID_ARGUMENT', `timeoutMs must be a positive integer, got ${display}`); + } + return n; +}; + +const toBoolean = (val) => { + if (typeof val === 'boolean') return val; + if (typeof val === 'object' && val !== null && 'value' in val) { + return toBoolean(val.value); + } + if (val === undefined || val === null) return false; + const str = String(val).trim().toLowerCase(); + if (str === 'true') return true; + if (str === 'false') return false; + return Boolean(val); +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), + ...(ctx?.bindings ?? {}), +}); + +const normalizeBaseUrl = (url) => { + const base = String(url || '').trim(); + if (!/^https?:\/\//i.test(base)) return null; + return base.replace(/\/$/, ''); +}; + +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 toValue = (val) => { + if (val === undefined || val === null) return undefined; + if (typeof val === 'string') return { stringValue: val }; + if (typeof val === 'number') return { numberValue: val }; + if (typeof val === 'boolean') return { boolValue: val }; + if (Array.isArray(val)) { + const values = val.map((item) => toValue(item)).filter((item) => item !== undefined); + return { listValue: { values } }; + } + if (typeof val === 'object') { + const fields = {}; + for (const [k, v] of Object.entries(val)) { + const normalized = toValue(v); + fields[k] = normalized === undefined ? { nullValue: 'NULL_VALUE' } : normalized; + } + return { structValue: { fields } }; + } + return { stringValue: String(val) }; +}; + +// ─── RPC helpers ─── + +const buildAuthHeader = (token) => { + if (!token) return {}; + return { Authorization: `bearer ${token}` }; +}; + +// Required headers for Cosmos Pedestal RPC (from API docs): +// - x-menu-name: identifies the UI menu context (31 = log search) +// - x-request-path: identifies the RPC gateway path +const PEDESTAL_REQUIRED_HEADERS = { + 'x-menu-name': '31', + 'x-request-path': 'pedestal', +}; + +// Per-request undici dispatcher that enforces TLS certificate verification. +// This is a per-request configuration — it does NOT modify process.env or +// affect any other HTTP connections in the same Node.js process. +// If you need to connect to a Cosmos deployment with a self-signed cert, +// configure a custom CA via NODE_EXTRA_CA_CERTS or add the CA to the +// system trust store. +const SECURE_DISPATCHER = new UndiciAgent({ connect: { rejectUnauthorized: true } }); + +const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, timeoutMs) => { + const url = `${endpoint}${RPC_PATH}`; + const headers = { + ...baseHeaders, + 'Content-Type': 'application/json', + ...PEDESTAL_REQUIRED_HEADERS, + ...buildAuthHeader(token), + }; + + const body = { + method: rpcMethod, + params, + jsonrpc: '2.0', + id: '0', + }; + + const fetchOptions = { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + dispatcher: SECURE_DISPATCHER, + }; + + try { + const res = await fetch(url, fetchOptions); + + if (!res.ok) { + const text = await res.text(); + if (res.status === 401 || res.status === 403) { + throw errorWithCode('PERMISSION_DENIED', `upstream http ${res.status}: ${text}`); + } + throw errorWithCode('UNAVAILABLE', `upstream http ${res.status}: ${text}`); + } + + const json = await res.json(); + + if (json.error) { + const rpcErr = json.error; + const msg = rpcErr.message || String(rpcErr.code || 'unknown rpc error'); + if (rpcErr.code === -32600 || rpcErr.code === -32602) { + throw errorWithCode('INVALID_ARGUMENT', msg); + } + if (rpcErr.code === -32601) { + throw errorWithCode('FAILED_PRECONDITION', msg); + } + // -32xxx range: server-side errors (e.g. -32000 "获取当前页面数据失败") + // These are upstream failures, not connectivity issues + if (rpcErr.code >= -32099 && rpcErr.code <= -32000) { + throw errorWithCode('INTERNAL', msg); + } + // Code 1: auth failure + if (rpcErr.code === 1) { + throw errorWithCode('PERMISSION_DENIED', msg || 'authentication failed'); + } + throw errorWithCode('INTERNAL', msg); + } + + return json.result; + } catch (e) { + if (e instanceof GrpcError) throw e; + // AbortSignal.timeout() throws a TimeoutError on expiry + if (e?.name === 'TimeoutError' || e?.name === 'AbortError') { + throw errorWithCode('DEADLINE_EXCEEDED', `request timed out after ${timeoutMs}ms`); + } + const reason = e?.cause?.message || e?.message || 'rpc call failed'; + throw errorWithCode('UNAVAILABLE', reason); + } +}; + +// ─── Map log record ─── + +const mapLogRecord = (item) => { + if (!item || typeof item !== 'object') return {}; + // Cosmos API returns rich, version-variable fields. + // We store the complete original record in `raw` (google.protobuf.Struct) + // to avoid snake_case ↔ camelCase conversion issues with protobuf-es. + return { raw: item }; +}; + +// ─── Method handlers ─── + +export function rpcdef(ctx) { + const bindings = mergedBindings(ctx); + const endpoint = bindings.endpoint || bindings.restBaseUrl || bindings.rest_base_url || bindings.baseUrl || bindings.base_url || ''; + const timeoutMs = validateTimeoutMs(ctx.limits?.timeoutMs); + const baseHeaders = parseHeaders(bindings.headers); + const meta = ctx.meta || {}; + + const getToken = () => { + // Token comes from ctx.secret (via bindings) only — not from request messages. + // This follows the OctoBus convention that credentials belong in secret config, + // not in per-call request payloads. + const token = firstNonEmpty(bindings.api_token, bindings.apiToken); + return String(token || '').trim(); + }; + + 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 = `[Chaitin_COSMOS][${action}]${trace.length ? `[${trace.join(' ')}]` : ''}`; + try { + console.log(prefix, JSON.stringify(details)); + } catch { + console.log(prefix, details); + } + }; + + const normalizedEndpoint = normalizeBaseUrl(endpoint); + + const callSearchLogInfo = async (req) => { + const token = getToken(); + if (!token) { + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required (configure via secret)'); + } + if (!normalizedEndpoint) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + } + + const rawIds = firstDefined(req?.ids, req?.Ids); + let ids; + if (Array.isArray(rawIds)) { + ids = rawIds.map((id) => String(id)); + } else if (typeof rawIds === 'string') { + ids = [rawIds]; + } else { + throw errorWithCode('INVALID_ARGUMENT', 'ids must be a non-empty array of strings'); + } + if (ids.length === 0) { + throw errorWithCode('INVALID_ARGUMENT', 'ids must be non-empty'); + } + + logFlow('SearchLogInfo:start', { endpoint: normalizedEndpoint, ids_count: ids.length }); + + const params = { ids }; + const result = await callPedestalRpc(normalizedEndpoint, 'LogService.SearchLogInfo', params, token, baseHeaders, timeoutMs); + + const dataArr = result?.data; + const records = Array.isArray(dataArr) ? dataArr.map(mapLogRecord) : []; + + logFlow('SearchLogInfo:done', { count: records.length }); + + return { + err: toValue(null), + msg: toValue(null), + data: { records }, + }; + }; + + const callSearchLogList = async (req) => { + const token = getToken(); + if (!token) { + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required (configure via secret)'); + } + if (!normalizedEndpoint) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + } + + const params = {}; + + // keyword — skip empty arrays, Cosmos treats [] as "no match" + const rawKeyword = firstDefined(req?.keyword, req?.Keyword); + if (rawKeyword !== undefined && rawKeyword !== null) { + const kw = Array.isArray(rawKeyword) ? rawKeyword.map(String) : typeof rawKeyword === 'string' ? [rawKeyword] : null; + if (kw === null) { + throw errorWithCode('INVALID_ARGUMENT', 'keyword must be a string array'); + } + if (kw.length > 0) params.keyword = kw; + } + + // time range + const rawStart = firstDefined(req?.time_range_start, req?.timeRangeStart); + const rawEnd = firstDefined(req?.time_range_end, req?.timeRangeEnd); + const start = toPositiveInt(rawStart); + const end = toPositiveInt(rawEnd); + if (start !== null) params.time_range_start = start; + if (end !== null) params.time_range_end = end; + + // advanced_query + const advQuery = unwrapString(firstDefined(req?.advanced_query, req?.advancedQuery, req?.AdvancedQuery)); + if (advQuery) params.advanced_query = advQuery; + + // condition_query + const rawCq = firstDefined(req?.condition_query, req?.conditionQuery, req?.ConditionQuery); + if (rawCq !== undefined && rawCq !== null) { + if (typeof rawCq === 'object' && !Array.isArray(rawCq)) { + params.condition_query = { + logical_op: rawCq.logical_op || 'AND', + expressions: Array.isArray(rawCq.expressions) + ? rawCq.expressions.map((e) => ({ + column: e?.column ?? '', + op: e?.op ?? 'equal', + value: String(e?.value ?? ''), + })) + : [], + }; + } + } + + // filter + const rawFilter = firstDefined(req?.filter, req?.Filter); + if (rawFilter !== undefined && rawFilter !== null && typeof rawFilter === 'object' && !Array.isArray(rawFilter)) { + const f = {}; + for (const key of ['origin_event_name', 'src_ip', 'dest_ip', 'src_country', 'src_port', 'dest_port']) { + const val = rawFilter[key]; + if (val !== undefined && val !== null) f[key] = Array.isArray(val) ? val : null; + } + const attackResult = rawFilter.attack_result || rawFilter.attackResult; + if (attackResult !== undefined && attackResult !== null) { + f.attack_result = Array.isArray(attackResult) ? attackResult : null; + } + params.filter = f; + } + + // pagination + const count = toPositiveInt(firstDefined(req?.count, req?.Count)); + if (count !== null) params.count = count; + const offset = toPositiveInt(firstDefined(req?.offset, req?.Offset)); + if (offset !== null) params.offset = offset; + + // attack_chain_phase + const acp = unwrapString(firstDefined(req?.attack_chain_phase, req?.attackChainPhase)); + if (acp) params.attack_chain_phase = acp; + + // fall (compromise status) + const rawFall = firstDefined(req?.fall, req?.Fall); + if (rawFall !== undefined && rawFall !== null) { + params.fall = toBoolean(rawFall); + } + + // organization — skip if empty, Cosmos treats [] as "filter by nothing" + const rawOrg = firstDefined(req?.organization, req?.Organization); + if (rawOrg !== undefined && rawOrg !== null && Array.isArray(rawOrg) && rawOrg.length > 0) { + const org = rawOrg.filter((o) => o && typeof o === 'object').map((o) => ({ + oper: o.oper || '=', + target: toPositiveInt(o.target) ?? 0, + })); + if (org.length > 0) params.organization = org; + } + + logFlow('SearchLogList:start', { endpoint: normalizedEndpoint, keyword: params.keyword, count: params.count }); + + const result = await callPedestalRpc(normalizedEndpoint, 'LogService.SearchLogList', params, token, baseHeaders, timeoutMs); + + // Cosmos SearchLogList response: result.data is an array of log records + // start_time/end_time may appear at result.data level or at result level + const dataField = result?.data; + // Handle both formats: flat array (result.data = [...records]) and nested (result.data.records) + let records; + let startTime; + let endTime; + if (Array.isArray(dataField)) { + records = dataField.map(mapLogRecord); + startTime = result?.start_time ?? 0; + endTime = result?.end_time ?? 0; + } else if (dataField && typeof dataField === 'object') { + const innerRecords = dataField.records ?? dataField.list ?? dataField.items ?? []; + records = Array.isArray(innerRecords) ? innerRecords.map(mapLogRecord) : []; + startTime = dataField.start_time ?? result?.start_time ?? 0; + endTime = dataField.end_time ?? result?.end_time ?? 0; + } else { + records = []; + startTime = 0; + endTime = 0; + } + + logFlow('SearchLogList:done', { count: records.length }); + + return { + err: toValue(null), + msg: toValue(null), + data: { + records, + start_time: startTime, + end_time: endTime, + }, + }; + }; + + const callSearchAggregationStatistics = async (req) => { + const token = getToken(); + if (!token) { + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required (configure via secret)'); + } + if (!normalizedEndpoint) { + throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + } + + const params = {}; + + // keyword — skip empty arrays, Cosmos treats [] as "no match" + const rawKeyword = firstDefined(req?.keyword, req?.Keyword); + if (rawKeyword !== undefined && rawKeyword !== null) { + const kw = Array.isArray(rawKeyword) ? rawKeyword.map(String) : typeof rawKeyword === 'string' ? [rawKeyword] : null; + if (kw === null) { + throw errorWithCode('INVALID_ARGUMENT', 'keyword must be a string array'); + } + if (kw.length > 0) params.keyword = kw; + } + + // time range + const start = toPositiveInt(firstDefined(req?.time_range_start, req?.timeRangeStart)); + if (start !== null) params.time_range_start = start; + const end = toPositiveInt(firstDefined(req?.time_range_end, req?.timeRangeEnd)); + if (end !== null) params.time_range_end = end; + + // advanced_query + const advQuery = unwrapString(firstDefined(req?.advanced_query, req?.advancedQuery, req?.AdvancedQuery)); + if (advQuery) params.advanced_query = advQuery; + + // condition_query + const rawCq = firstDefined(req?.condition_query, req?.conditionQuery, req?.ConditionQuery); + if (rawCq !== undefined && rawCq !== null) { + if (typeof rawCq === 'object' && !Array.isArray(rawCq)) { + params.condition_query = { + logical_op: rawCq.logical_op || 'AND', + expressions: Array.isArray(rawCq.expressions) + ? rawCq.expressions.map((e) => ({ + column: e?.column ?? '', + op: e?.op ?? 'equal', + value: String(e?.value ?? ''), + })) + : [], + }; + } + } + + // filter + const rawFilter = firstDefined(req?.filter, req?.Filter); + if (rawFilter !== undefined && rawFilter !== null && typeof rawFilter === 'object' && !Array.isArray(rawFilter)) { + const f = {}; + for (const key of ['origin_event_name', 'src_ip', 'dest_ip', 'src_country', 'src_port', 'dest_port']) { + const val = rawFilter[key]; + if (val !== undefined && val !== null) f[key] = Array.isArray(val) ? val : null; + } + const attackResult = rawFilter.attack_result || rawFilter.attackResult; + if (attackResult !== undefined && attackResult !== null) { + f.attack_result = Array.isArray(attackResult) ? attackResult : null; + } + params.filter = f; + } + + // aggregation keys + const rawKey = firstDefined(req?.key, req?.Key); + if (rawKey !== undefined && rawKey !== null) { + if (Array.isArray(rawKey)) { + params.key = rawKey.map(String); + } else { + throw errorWithCode('INVALID_ARGUMENT', 'key must be a string array'); + } + } + + // count & asc + const count = toPositiveInt(firstDefined(req?.count, req?.Count)); + if (count !== null) params.count = count; + const rawAsc = firstDefined(req?.asc, req?.Asc); + if (rawAsc !== undefined && rawAsc !== null) { + params.asc = toBoolean(rawAsc); + } + + // attack_chain_phase + const acp = unwrapString(firstDefined(req?.attack_chain_phase, req?.attackChainPhase)); + if (acp) params.attack_chain_phase = acp; + + // fall (compromise status) + const rawFall = firstDefined(req?.fall, req?.Fall); + if (rawFall !== undefined && rawFall !== null) { + params.fall = toBoolean(rawFall); + } + + // organization — skip if empty, Cosmos treats [] as "filter by nothing" + const rawOrg = firstDefined(req?.organization, req?.Organization); + if (rawOrg !== undefined && rawOrg !== null && Array.isArray(rawOrg) && rawOrg.length > 0) { + const org = rawOrg.filter((o) => o && typeof o === 'object').map((o) => ({ + oper: o.oper || '=', + target: toPositiveInt(o.target) ?? 0, + })); + if (org.length > 0) params.organization = org; + } + + logFlow('SearchAggregation:start', { endpoint: normalizedEndpoint, key: params.key, count: params.count }); + + const result = await callPedestalRpc(normalizedEndpoint, 'LogService.SearchAggregationStatistics', params, token, baseHeaders, timeoutMs); + + // Cosmos SearchAggregationStatistics response: + // result.data is an array of aggregation groups, result.total is the total count + // Handle both flat array and nested object formats + const dataField = result?.data; + const mapGroup = (g) => ({ + result: g?.result ?? {}, + data: Array.isArray(g?.data) ? g.data.map((p) => ({ + start_time: p?.start_time ?? 0, + count: p?.count ?? 0, + })) : [], + count: g?.count ?? 0, + }); + let groups; + let total; + if (Array.isArray(dataField)) { + groups = dataField.map(mapGroup); + total = result?.total ?? 0; + } else if (dataField && typeof dataField === 'object') { + const innerGroups = dataField.groups ?? dataField.list ?? dataField.items ?? []; + groups = Array.isArray(innerGroups) ? innerGroups.map(mapGroup) : []; + total = dataField.total ?? result?.total ?? 0; + } else { + groups = []; + total = 0; + } + + logFlow('SearchAggregation:done', { groups: groups.length, total }); + + return { + err: toValue(null), + msg: toValue(null), + data: { + groups, + total, + }, + }; + }; + + return { + [METHOD_SEARCH_LOG_INFO]: async () => callSearchLogInfo(ctx.req), + [METHOD_SEARCH_LOG_LIST]: async () => callSearchLogList(ctx.req), + [METHOD_SEARCH_AGGREGATION]: async () => callSearchAggregationStatistics(ctx.req), + }; +} + +// ─── OctoBus SDK registration ─── + +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 = {}) => ({ + [METHOD_SEARCH_LOG_INFO]: wrapLegacyHandler(ctx, METHOD_SEARCH_LOG_INFO), + [METHOD_SEARCH_LOG_LIST]: wrapLegacyHandler(ctx, METHOD_SEARCH_LOG_LIST), + [METHOD_SEARCH_AGGREGATION]: wrapLegacyHandler(ctx, METHOD_SEARCH_AGGREGATION), +}); + +const sdkHandlers = registerHandlers({}); + +export const METHOD_SEARCH_LOG_INFO_FULL = 'Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'; +export const METHOD_SEARCH_LOG_LIST_FULL = 'Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'; +export const METHOD_SEARCH_AGGREGATION_FULL = 'Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'; + +export const handlers = { + [METHOD_SEARCH_LOG_INFO_FULL]: (ctx) => sdkHandlers[METHOD_SEARCH_LOG_INFO](ctx), + [METHOD_SEARCH_LOG_LIST_FULL]: (ctx) => sdkHandlers[METHOD_SEARCH_LOG_LIST](ctx), + [METHOD_SEARCH_AGGREGATION_FULL]: (ctx) => sdkHandlers[METHOD_SEARCH_AGGREGATION](ctx), +}; + +export const _test = { + errorWithCode, + firstDefined, + firstNonEmpty, + mergedBindings, + normalizeBaseUrl, + parseHeaders, + registerHandlers, + resolveCallContext, + toBoolean, + toPositiveInt, + toValue, + unwrapString, +}; diff --git a/services/chaitin__cosmos/src/service.js b/services/chaitin__cosmos/src/service.js new file mode 100644 index 00000000..a2da5684 --- /dev/null +++ b/services/chaitin__cosmos/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./cosmos.js"; + +export { handlers } from "./cosmos.js"; + +export const service = defineService({ handlers }); diff --git a/services/chaitin__cosmos/test/cosmos.test.js b/services/chaitin__cosmos/test/cosmos.test.js new file mode 100644 index 00000000..29bcac61 --- /dev/null +++ b/services/chaitin__cosmos/test/cosmos.test.js @@ -0,0 +1,1219 @@ +// Automated tests for Chaitin Cosmos OctoBus service package +// Uses node:test + node:assert with mocked global fetch (no external deps) + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { rpcdef } from '../src/cosmos.js'; +import { GrpcError } from '@chaitin-ai/octobus-sdk'; + +// ─── Helpers ─── + +const MOCK_ENDPOINT = 'https://cosmos.test.example.com'; +const MOCK_TOKEN = 'jwt-mock-token-xxxxx'; + +/** Build a minimal ctx object that rpcdef() expects */ +const makeCtx = (overrides = {}) => ({ + config: { endpoint: MOCK_ENDPOINT, ...overrides.config }, + secret: { api_token: MOCK_TOKEN, ...overrides.secret }, + bindings: { ...overrides.bindings }, + req: overrides.req ?? {}, + limits: overrides.limits ?? {}, + meta: overrides.meta ?? {}, + ...overrides, +}); + +/** Create a mock fetch that records calls and returns configurable responses */ +const createMockFetch = (responseOverrides = {}) => { + const calls = []; + const mockFetch = async (url, options) => { + calls.push({ url, options }); + const key = `${options?.method}:${url}`; + const override = responseOverrides[key] ?? responseOverrides['*']; + if (override) return override; + // Default: successful JSON-RPC response + return { + ok: true, + status: 200, + json: async () => ({ + jsonrpc: '2.0', + id: '0', + result: { data: [] }, + }), + text: async () => '', + }; + }; + return { mockFetch, calls }; +}; + +/** Create a mock fetch that returns a successful RPC response with given result */ +const mockFetchSuccess = (result) => { + const calls = []; + const mockFetch = async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 200, + json: async () => ({ jsonrpc: '2.0', id: '0', result }), + text: async () => '', + }; + }; + return { mockFetch, calls }; +}; + +/** Create a mock fetch that returns an HTTP error */ +const mockFetchHttpError = (status, body = '') => { + const calls = []; + const mockFetch = async (url, options) => { + calls.push({ url, options }); + return { + ok: false, + status, + json: async () => ({ jsonrpc: '2.0', id: '0', result: null }), + text: async () => body, + }; + }; + return { mockFetch, calls }; +}; + +/** Create a mock fetch that returns a JSON-RPC error */ +const mockFetchRpcError = (code, message) => { + const calls = []; + const mockFetch = async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 200, + json: async () => ({ + jsonrpc: '2.0', + id: '0', + error: { code, message }, + }), + text: async () => '', + }; + }; + return { mockFetch, calls }; +}; + +/** Create a mock fetch that throws a network error */ +const mockFetchNetworkError = (message = 'ECONNREFUSED') => { + const calls = []; + const mockFetch = async (url, options) => { + calls.push({ url, options }); + throw new TypeError(`fetch failed: ${message}`); + }; + return { mockFetch, calls }; +}; + +let originalFetch; + +const installMock = (mockFetch) => { + originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch; +}; + +const restoreFetch = () => { + globalThis.fetch = originalFetch; +}; + +/** Parse the JSON body that was sent to fetch */ +const parseSentBody = (call) => JSON.parse(call.options.body); + +// ─── Test suites ─── + +describe('rpcdef — input validation', () => { + it('throws INVALID_ARGUMENT when api_token is missing from secret', async () => { + const ctx = makeCtx({ secret: {}, req: {} }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3, // INVALID_ARGUMENT + ); + }); + + it('throws INVALID_ARGUMENT when endpoint is missing', async () => { + const ctx = makeCtx({ config: { endpoint: '' }, secret: { api_token: MOCK_TOKEN }, req: {} }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3, + ); + }); + + it('throws INVALID_ARGUMENT when endpoint is not http/https', async () => { + const ctx = makeCtx({ config: { endpoint: 'ftp://bad' }, secret: { api_token: MOCK_TOKEN }, req: {} }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3, + ); + }); +}); + +describe('SearchLogInfo', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('constructs correct upstream request with ids array', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['log-001', 'log-002'] } }); + const defs = rpcdef(ctx); + await defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(calls.length, 1); + const call = calls[0]; + assert.equal(call.url, `${MOCK_ENDPOINT}/pedestal/rpc`); + assert.equal(call.options.method, 'POST'); + + const body = parseSentBody(call); + assert.equal(body.method, 'LogService.SearchLogInfo'); + assert.equal(body.jsonrpc, '2.0'); + assert.deepEqual(body.params.ids, ['log-001', 'log-002']); + }); + + it('sends correct headers: Authorization, Content-Type, x-menu-name, x-request-path', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const headers = calls[0].options.headers; + assert.equal(headers['Authorization'], `bearer ${MOCK_TOKEN}`); + assert.equal(headers['Content-Type'], 'application/json'); + assert.equal(headers['x-menu-name'], '31'); + assert.equal(headers['x-request-path'], 'pedestal'); + }); + + it('merges custom headers from config', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT, headers: { 'X-Custom': 'yes' } }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const headers = calls[0].options.headers; + assert.equal(headers['X-Custom'], 'yes'); + assert.equal(headers['Authorization'], `bearer ${MOCK_TOKEN}`); + }); + + it('maps upstream response data array to records', async () => { + const upstreamData = [ + { log_id: 'log-001', src_ip: '1.2.3.4', event: 'scan' }, + { log_id: 'log-002', src_ip: '5.6.7.8', event: 'exploit' }, + ]; + const { mockFetch } = mockFetchSuccess({ data: upstreamData }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['log-001', 'log-002'] } }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(result.data.records.length, 2); + assert.deepEqual(result.data.records[0].raw, upstreamData[0]); + assert.deepEqual(result.data.records[1].raw, upstreamData[1]); + }); + + it('handles empty data array from upstream', async () => { + const { mockFetch } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['nonexistent'] } }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(result.data.records.length, 0); + }); + + it('handles missing data field in upstream response', async () => { + const { mockFetch } = mockFetchSuccess({}); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(result.data.records.length, 0); + }); + + it('throws INVALID_ARGUMENT when ids is missing', async () => { + const ctx = makeCtx({ req: {} }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('ids'), + ); + }); + + it('throws INVALID_ARGUMENT when ids is empty array', async () => { + const ctx = makeCtx({ req: { ids: [] } }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3, + ); + }); + + it('accepts ids as a single string (wrapped in array)', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: 'single-id' } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const body = parseSentBody(calls[0]); + assert.deepEqual(body.params.ids, ['single-id']); + }); + + it('uses api_token from ctx.secret (not from request)', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + // Token comes from secret only — request-level api_token is ignored + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const headers = calls[0].options.headers; + assert.equal(headers['Authorization'], `bearer ${MOCK_TOKEN}`); + }); + + it('uses api_token from secret bindings', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + secret: { api_token: 'secret-token' }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const headers = calls[0].options.headers; + assert.equal(headers['Authorization'], 'bearer secret-token'); + }); + + it('rejects when secret has no api_token', async () => { + const ctx = makeCtx({ secret: {}, req: { ids: ['id1'] } }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('api_token'), + ); + }); +}); + +describe('SearchLogList', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('constructs minimal request with only required fields', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: {} }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.method, 'LogService.SearchLogList'); + // Empty keyword array should not be sent (Cosmos treats [] as no-match) + assert.equal(body.params.keyword, undefined); + }); + + it('maps keyword string to array', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { keyword: 'suspicious' } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.deepEqual(body.params.keyword, ['suspicious']); + }); + + it('passes keyword array directly', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { keyword: ['attack', 'scan'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.deepEqual(body.params.keyword, ['attack', 'scan']); + }); + + it('skips empty keyword array', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { keyword: [] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.keyword, undefined); + }); + + it('throws INVALID_ARGUMENT for invalid keyword type', async () => { + const ctx = makeCtx({ req: { keyword: 12345 } }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('keyword'), + ); + }); + + it('maps time_range_start and time_range_end', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { time_range_start: 1700000000, time_range_end: 1700086400 } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.time_range_start, 1700000000); + assert.equal(body.params.time_range_end, 1700086400); + }); + + it('handles Int64Value wrapper for time_range_start', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { time_range_start: { value: 1700000000 } } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.time_range_start, 1700000000); + }); + + it('maps advanced_query', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { advanced_query: 'src_ip:1.2.3.4 AND dest_port:80' } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.advanced_query, 'src_ip:1.2.3.4 AND dest_port:80'); + }); + + it('maps condition_query with expressions', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + req: { + condition_query: { + logical_op: 'OR', + expressions: [ + { column: 'src_ip', op: 'equal', value: '1.2.3.4' }, + { column: 'dest_port', op: 'contains', value: '443' }, + ], + }, + }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.condition_query.logical_op, 'OR'); + assert.equal(body.params.condition_query.expressions.length, 2); + assert.equal(body.params.condition_query.expressions[0].column, 'src_ip'); + assert.equal(body.params.condition_query.expressions[0].op, 'equal'); + assert.equal(body.params.condition_query.expressions[0].value, '1.2.3.4'); + }); + + it('condition_query defaults logical_op to AND when omitted', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + req: { condition_query: { expressions: [{ column: 'src_ip', op: 'equal', value: '10.0.0.1' }] } }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.condition_query.logical_op, 'AND'); + }); + + it('condition_query defaults op to "equal" and value to "" when omitted', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { condition_query: { expressions: [{ column: 'src_ip' }] } } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.condition_query.expressions[0].op, 'equal'); + assert.equal(body.params.condition_query.expressions[0].value, ''); + }); + + it('maps filter with all array fields', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + req: { + filter: { + origin_event_name: ['WAF'], + src_ip: ['1.2.3.4', '5.6.7.8'], + dest_ip: ['9.10.11.12'], + src_country: ['CN'], + src_port: ['80'], + dest_port: ['443'], + attack_result: [1, 2], + }, + }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + const f = body.params.filter; + assert.deepEqual(f.origin_event_name, ['WAF']); + assert.deepEqual(f.src_ip, ['1.2.3.4', '5.6.7.8']); + assert.deepEqual(f.dest_ip, ['9.10.11.12']); + assert.deepEqual(f.src_country, ['CN']); + assert.deepEqual(f.src_port, ['80']); + assert.deepEqual(f.dest_port, ['443']); + assert.deepEqual(f.attack_result, [1, 2]); + }); + + it('filter sets non-array values to null', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + req: { filter: { src_ip: 'not-an-array' } }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.filter.src_ip, null); + }); + + it('maps pagination: count and offset', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { count: 50, offset: 100 } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.count, 50); + assert.equal(body.params.offset, 100); + }); + + it('maps attack_chain_phase', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { attack_chain_phase: 'recon' } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.attack_chain_phase, 'recon'); + }); + + it('maps fall (BoolValue)', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { fall: true } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.fall, true); + }); + + it('maps fall as BoolValue wrapper { value: true }', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { fall: { value: true } } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.fall, true); + }); + + it('maps organization with oper and target', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + req: { organization: [{ oper: '=', target: 42 }, { target: 99 }] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.organization.length, 2); + assert.equal(body.params.organization[0].oper, '='); + assert.equal(body.params.organization[0].target, 42); + assert.equal(body.params.organization[1].oper, '='); + assert.equal(body.params.organization[1].target, 99); + }); + + it('skips empty organization array', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { organization: [] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.organization, undefined); + }); + + it('maps response with flat array data', async () => { + const upstreamData = [ + { log_id: 'l1', src_ip: '1.1.1.1' }, + { log_id: 'l2', src_ip: '2.2.2.2' }, + ]; + const { mockFetch } = mockFetchSuccess({ data: upstreamData, start_time: 1700000000, end_time: 1700086400 }); + installMock(mockFetch); + + const ctx = makeCtx({ req: {} }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + assert.equal(result.data.records.length, 2); + assert.deepEqual(result.data.records[0].raw, upstreamData[0]); + assert.equal(result.data.start_time, 1700000000); + assert.equal(result.data.end_time, 1700086400); + }); + + it('maps response with nested object data (records key)', async () => { + const upstreamResult = { + data: { + records: [{ log_id: 'l1' }, { log_id: 'l2' }], + start_time: 1700000000, + end_time: 1700086400, + }, + }; + const { mockFetch } = mockFetchSuccess(upstreamResult); + installMock(mockFetch); + + const ctx = makeCtx({ req: {} }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + assert.equal(result.data.records.length, 2); + assert.equal(result.data.start_time, 1700000000); + assert.equal(result.data.end_time, 1700086400); + }); + + it('maps response with nested object data (list key)', async () => { + const upstreamResult = { + data: { + list: [{ log_id: 'l1' }], + start_time: 1700000000, + end_time: 1700086400, + }, + }; + const { mockFetch } = mockFetchSuccess(upstreamResult); + installMock(mockFetch); + + const ctx = makeCtx({ req: {} }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + assert.equal(result.data.records.length, 1); + }); + + it('handles all request parameters together (kitchen sink)', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + req: { + keyword: ['attack'], + time_range_start: 1700000000, + time_range_end: 1700086400, + advanced_query: 'src_ip:1.2.3.4', + condition_query: { + logical_op: 'AND', + expressions: [{ column: 'src_ip', op: 'equal', value: '1.2.3.4' }], + }, + filter: { src_ip: ['1.2.3.4'], attack_result: [1] }, + count: 50, + offset: 0, + attack_chain_phase: 'exploit', + fall: false, + organization: [{ oper: '=', target: 1 }], + }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](); + + const body = parseSentBody(calls[0]); + assert.deepEqual(body.params.keyword, ['attack']); + assert.equal(body.params.time_range_start, 1700000000); + assert.equal(body.params.time_range_end, 1700086400); + assert.equal(body.params.advanced_query, 'src_ip:1.2.3.4'); + assert.ok(body.params.condition_query); + assert.ok(body.params.filter); + assert.equal(body.params.count, 50); + assert.equal(body.params.offset, 0); + assert.equal(body.params.attack_chain_phase, 'exploit'); + assert.equal(body.params.fall, false); + assert.ok(body.params.organization); + }); +}); + +describe('SearchAggregationStatistics', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('constructs correct upstream request', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [], total: 0 }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { key: ['src_ip', 'dest_ip'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.method, 'LogService.SearchAggregationStatistics'); + assert.deepEqual(body.params.key, ['src_ip', 'dest_ip']); + }); + + it('maps key as string array', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [], total: 0 }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { key: ['event_type'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + const body = parseSentBody(calls[0]); + assert.deepEqual(body.params.key, ['event_type']); + }); + + it('throws INVALID_ARGUMENT when key is not an array', async () => { + const ctx = makeCtx({ req: { key: 'not-array' } }); + const defs = rpcdef(ctx); + await assert.rejects( + () => defs['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('key'), + ); + }); + + it('maps count and asc', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [], total: 0 }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { key: ['src_ip'], count: 20, asc: true } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.count, 20); + assert.equal(body.params.asc, true); + }); + + it('maps asc as BoolValue wrapper', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [], total: 0 }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { key: ['src_ip'], asc: { value: true } } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + const body = parseSentBody(calls[0]); + assert.equal(body.params.asc, true); + }); + + it('maps flat array aggregation response', async () => { + const upstreamData = [ + { + result: { src_ip: '1.2.3.4', event_type: 52001 }, + data: [{ start_time: 1700000000, count: 42 }, { start_time: 1700003600, count: 15 }], + count: 57, + }, + ]; + const { mockFetch } = mockFetchSuccess({ data: upstreamData, total: 1 }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { key: ['src_ip', 'event_type'] } }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + assert.equal(result.data.groups.length, 1); + assert.deepEqual(result.data.groups[0].result, { src_ip: '1.2.3.4', event_type: 52001 }); + assert.equal(result.data.groups[0].data.length, 2); + assert.equal(result.data.groups[0].data[0].start_time, 1700000000); + assert.equal(result.data.groups[0].data[0].count, 42); + assert.equal(result.data.groups[0].count, 57); + assert.equal(result.data.total, 1); + }); + + it('maps nested object aggregation response (groups key)', async () => { + const upstreamResult = { + data: { + groups: [{ result: { src_ip: '5.5.5.5' }, data: [], count: 0 }], + total: 1, + }, + }; + const { mockFetch } = mockFetchSuccess(upstreamResult); + installMock(mockFetch); + + const ctx = makeCtx({ req: { key: ['src_ip'] } }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + assert.equal(result.data.groups.length, 1); + assert.deepEqual(result.data.groups[0].result, { src_ip: '5.5.5.5' }); + assert.equal(result.data.total, 1); + }); + + it('handles empty aggregation response', async () => { + const { mockFetch } = mockFetchSuccess({ data: [], total: 0 }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { key: ['src_ip'] } }); + const result = await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + assert.equal(result.data.groups.length, 0); + assert.equal(result.data.total, 0); + }); + + it('shares same parameter mapping as SearchLogList (keyword, time, filter, etc.)', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [], total: 0 }); + installMock(mockFetch); + + const ctx = makeCtx({ + req: { + keyword: ['attack'], + time_range_start: 1700000000, + time_range_end: 1700086400, + advanced_query: 'severity:high', + condition_query: { + logical_op: 'AND', + expressions: [{ column: 'src_ip', op: 'equal', value: '1.2.3.4' }], + }, + filter: { src_ip: ['1.2.3.4'] }, + key: ['src_ip'], + count: 10, + asc: false, + attack_chain_phase: 'recon', + fall: true, + organization: [{ oper: '=', target: 5 }], + }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'](); + + const body = parseSentBody(calls[0]); + assert.deepEqual(body.params.keyword, ['attack']); + assert.equal(body.params.time_range_start, 1700000000); + assert.equal(body.params.time_range_end, 1700086400); + assert.equal(body.params.advanced_query, 'severity:high'); + assert.ok(body.params.condition_query); + assert.ok(body.params.filter); + assert.deepEqual(body.params.key, ['src_ip']); + assert.equal(body.params.count, 10); + assert.equal(body.params.asc, false); + assert.equal(body.params.attack_chain_phase, 'recon'); + assert.equal(body.params.fall, true); + assert.ok(body.params.organization); + }); +}); + +describe('Error handling — upstream HTTP errors', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('401 → PERMISSION_DENIED (gRPC code 7)', async () => { + const { mockFetch } = mockFetchHttpError(401, 'Unauthorized'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 7, + ); + }); + + it('403 → PERMISSION_DENIED (gRPC code 7)', async () => { + const { mockFetch } = mockFetchHttpError(403, 'Forbidden'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 7, + ); + }); + + it('500 → UNAVAILABLE (gRPC code 14)', async () => { + const { mockFetch } = mockFetchHttpError(500, 'Internal Server Error'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 14, + ); + }); + + it('502 → UNAVAILABLE (gRPC code 14)', async () => { + const { mockFetch } = mockFetchHttpError(502, 'Bad Gateway'); + installMock(mockFetch); + + const ctx = makeCtx({ req: {} }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'](), + (err) => err instanceof GrpcError && err.code === 14, + ); + }); +}); + +describe('Error handling — JSON-RPC error codes', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('-32600 (Invalid Request) → INVALID_ARGUMENT (gRPC code 3)', async () => { + const { mockFetch } = mockFetchRpcError(-32600, 'Invalid Request'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3, + ); + }); + + it('-32602 (Invalid Params) → INVALID_ARGUMENT (gRPC code 3)', async () => { + const { mockFetch } = mockFetchRpcError(-32602, 'Invalid params'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 3, + ); + }); + + it('-32601 (Method not found) → FAILED_PRECONDITION (gRPC code 9)', async () => { + const { mockFetch } = mockFetchRpcError(-32601, 'Method not found'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 9, + ); + }); + + it('-32000 (Server error) → INTERNAL (gRPC code 13)', async () => { + const { mockFetch } = mockFetchRpcError(-32000, '获取当前页面数据失败'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 13, + ); + }); + + it('-32050 (Server error range) → INTERNAL (gRPC code 13)', async () => { + const { mockFetch } = mockFetchRpcError(-32050, 'internal timeout'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 13, + ); + }); + + it('code 1 (Cosmos auth failure) → PERMISSION_DENIED (gRPC code 7)', async () => { + const { mockFetch } = mockFetchRpcError(1, 'token expired'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 7, + ); + }); + + it('unknown RPC error code → INTERNAL (gRPC code 13)', async () => { + const { mockFetch } = mockFetchRpcError(-99999, 'something weird'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 13, + ); + }); +}); + +describe('Error handling — network errors', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('network error → UNAVAILABLE (gRPC code 14)', async () => { + const { mockFetch } = mockFetchNetworkError('ECONNREFUSED'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 14, + ); + }); + + it('DNS error → UNAVAILABLE', async () => { + const { mockFetch } = mockFetchNetworkError('getaddrinfo ENOTFOUND cosmos.invalid'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 14, + ); + }); +}); + +describe('TLS — per-request undici dispatcher (no process.env side effects)', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('uses undici Agent dispatcher for per-request TLS — never touches process.env', async () => { + const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + + let capturedDispatcher = null; + const { mockFetch } = mockFetchSuccess({ data: [] }); + installMock(async (url, options) => { + capturedDispatcher = options?.dispatcher; + return mockFetch(url, options); + }); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + // Verify that NODE_TLS_REJECT_UNAUTHORIZED was never touched + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); + + // Verify the dispatcher is set (per-request TLS config, not process-wide) + assert.ok(capturedDispatcher, 'fetch should receive a dispatcher option'); + assert.equal(typeof capturedDispatcher, 'object'); + + if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; + }); + + it('process.env stays untouched even when upstream throws', async () => { + const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + + const { mockFetch } = mockFetchHttpError(500, 'error'); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + try { await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); } catch {} + + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); + + if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; + }); + + it('no skipTlsVerify config is honored — TLS is always enforced via dispatcher', async () => { + const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + + // Even if someone tries to pass skipTlsVerify in config, it should have no effect + const { mockFetch } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: true }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + // process.env should NOT be touched regardless + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); + + if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; + }); +}); + +describe('Endpoint normalization', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('trims trailing slash from endpoint', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ config: { endpoint: 'https://cosmos.test.example.com/' }, req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(calls[0].url, 'https://cosmos.test.example.com/pedestal/rpc'); + }); + + it('accepts http:// endpoint', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ config: { endpoint: 'http://cosmos.local:8080' }, req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(calls[0].url, 'http://cosmos.local:8080/pedestal/rpc'); + }); + + it('uses restBaseUrl alias for endpoint', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ config: { restBaseUrl: 'https://alias.example.com' }, req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(calls[0].url, 'https://alias.example.com/pedestal/rpc'); + }); + + it('uses baseUrl alias for endpoint', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ config: { baseUrl: 'https://base.example.com' }, req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(calls[0].url, 'https://base.example.com/pedestal/rpc'); + }); +}); + +describe('Headers parsing from config', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('parses headers from JSON string', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT, headers: '{"X-Trace-Id":"abc123"}' }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.equal(calls[0].options.headers['X-Trace-Id'], 'abc123'); + }); + + it('ignores invalid JSON headers gracefully', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT, headers: 'not-json' }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + // Should not crash; headers just won't include custom ones + assert.ok(calls[0].options.headers['Authorization']); + }); +}); + +describe('Timeout — AbortSignal.timeout prevents indefinite hangs', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('passes AbortSignal.timeout to fetch with configured timeoutMs', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + limits: { timeoutMs: 10000 }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const signal = calls[0].options.signal; + assert.ok(signal instanceof AbortSignal, 'fetch should receive an AbortSignal'); + // AbortSignal.timeout() creates a signal that is not yet aborted + assert.equal(signal.aborted, false); + }); + + it('uses DEFAULT_TIMEOUT_MS (5000) when limits.timeoutMs is not set', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + assert.ok(calls[0].options.signal instanceof AbortSignal); + }); + + it('timeout expiry throws DEADLINE_EXCEEDED (gRPC code 4)', async () => { + // Simulate a TimeoutError from AbortSignal.timeout + const timeoutErr = new Error('The operation was aborted due to timeout'); + timeoutErr.name = 'TimeoutError'; + installMock(async () => { throw timeoutErr; }); + + const ctx = makeCtx({ limits: { timeoutMs: 1 }, req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 4 && err.message.includes('timed out'), + ); + }); + + it('AbortError (user-initiated abort) also throws DEADLINE_EXCEEDED', async () => { + const abortErr = new Error('The operation was aborted'); + abortErr.name = 'AbortError'; + installMock(async () => { throw abortErr; }); + + const ctx = makeCtx({ req: { ids: ['id1'] } }); + await assert.rejects( + () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), + (err) => err instanceof GrpcError && err.code === 4, + ); + }); + + it('rejects negative timeoutMs with INVALID_ARGUMENT', () => { + const ctx = makeCtx({ limits: { timeoutMs: -1 }, req: { ids: ['id1'] } }); + assert.throws( + () => rpcdef(ctx), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('timeoutMs'), + ); + }); + + it('rejects Infinity timeoutMs with INVALID_ARGUMENT', () => { + const ctx = makeCtx({ limits: { timeoutMs: Infinity }, req: { ids: ['id1'] } }); + assert.throws( + () => rpcdef(ctx), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('timeoutMs'), + ); + }); + + it('rejects non-numeric timeoutMs with INVALID_ARGUMENT', () => { + const ctx = makeCtx({ limits: { timeoutMs: 'fast' }, req: { ids: ['id1'] } }); + assert.throws( + () => rpcdef(ctx), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('timeoutMs'), + ); + }); + + it('rejects zero timeoutMs with INVALID_ARGUMENT', () => { + const ctx = makeCtx({ limits: { timeoutMs: 0 }, req: { ids: ['id1'] } }); + assert.throws( + () => rpcdef(ctx), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('timeoutMs'), + ); + }); + + it('rejects NaN timeoutMs with INVALID_ARGUMENT', () => { + const ctx = makeCtx({ limits: { timeoutMs: NaN }, req: { ids: ['id1'] } }); + assert.throws( + () => rpcdef(ctx), + (err) => err instanceof GrpcError && err.code === 3 && err.message.includes('timeoutMs'), + ); + }); +}); + +describe('Legacy handler wrapping — handlers export', () => { + it('handlers object contains all three method keys', async () => { + const { handlers, METHOD_SEARCH_LOG_INFO_FULL, METHOD_SEARCH_LOG_LIST_FULL, METHOD_SEARCH_AGGREGATION_FULL } = await import('../src/cosmos.js'); + + assert.ok(typeof handlers[METHOD_SEARCH_LOG_INFO_FULL] === 'function'); + assert.ok(typeof handlers[METHOD_SEARCH_LOG_LIST_FULL] === 'function'); + assert.ok(typeof handlers[METHOD_SEARCH_AGGREGATION_FULL] === 'function'); + }); + + it('exported method names match expected gRPC paths', async () => { + const { METHOD_SEARCH_LOG_INFO_FULL, METHOD_SEARCH_LOG_LIST_FULL, METHOD_SEARCH_AGGREGATION_FULL } = await import('../src/cosmos.js'); + + assert.equal(METHOD_SEARCH_LOG_INFO_FULL, 'Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'); + assert.equal(METHOD_SEARCH_LOG_LIST_FULL, 'Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList'); + assert.equal(METHOD_SEARCH_AGGREGATION_FULL, 'Chaitin_COSMOS.Chaitin_COSMOS/SearchAggregationStatistics'); + }); +}); diff --git a/services/package.json b/services/package.json index 2fbea088..5c0e0a3d 100644 --- a/services/package.json +++ b/services/package.json @@ -7,6 +7,7 @@ "octobus-tentacles": "bin/octobus-tentacles.js", "alibaba-cloud-simple-application-server-firewall": "bin/alibaba-cloud-simple-application-server-firewall.js", "cloudatlas": "bin/cloudatlas.js", + "cosmos": "bin/cosmos.js", "das-gateway-v3": "bin/das-gateway-v3.js", "das-tgfw-v6": "bin/das-tgfw-v6.js", "dbaudit": "bin/dbaudit.js", @@ -71,6 +72,7 @@ "files": [ "bin/alibaba-cloud-simple-application-server-firewall.js", "bin/cloudatlas.js", + "bin/cosmos.js", "bin/das-gateway-v3.js", "bin/das-tgfw-v6.js", "bin/dbaudit.js", @@ -167,6 +169,7 @@ "riversafe__waf", "skycloud__inet", "slack__group-robot", + "chaitin__cosmos", "chaitin__safeline-waf-eliminate-false-positive", "chaitin__safeline-waf", "sangfor__fw_v8-0-45", diff --git a/services/scripts/validate-service-package.mjs b/services/scripts/validate-service-package.mjs index f361aebc..de931544 100644 --- a/services/scripts/validate-service-package.mjs +++ b/services/scripts/validate-service-package.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -216,7 +217,22 @@ function validateRootDispatcher(errors, root, pkg, binEntries, serviceDirs) { errors.push(`package.json files must include default dispatcher "${dispatcherTarget}"`); } - const dispatcher = fs.readFileSync(path.join(root, filepathFromPackagePath(dispatcherTarget)), "utf8"); + const dispatcherPath = path.join(root, filepathFromPackagePath(dispatcherTarget)); + const dispatcher = fs.readFileSync(dispatcherPath, "utf8"); + const syntaxCheckEnv = { ...process.env }; + delete syntaxCheckEnv.NODE_TEST_CONTEXT; + const syntaxCheck = spawnSync(process.execPath, ["--check", "--input-type=module"], { + encoding: "utf8", + env: syntaxCheckEnv, + input: dispatcher, + }); + if (syntaxCheck.error != null) { + errors.push(`${dispatcherTarget} syntax check failed: ${syntaxCheck.error.message}`); + } else if (syntaxCheck.status !== 0) { + const detail = (syntaxCheck.stderr || syntaxCheck.stdout).trim(); + errors.push(`${dispatcherTarget} must be valid JavaScript${detail === "" ? "" : `: ${detail}`}`); + } + for (const snippet of [ "import { Command } from \"commander\";", ".allowUnknownOption(true)", diff --git a/services/tests/validate-service-package.test.mjs b/services/tests/validate-service-package.test.mjs index 82c1b825..dda3fa16 100644 --- a/services/tests/validate-service-package.test.mjs +++ b/services/tests/validate-service-package.test.mjs @@ -351,6 +351,14 @@ export const handlers = { assert.match(errors, /forbidden package artifact "node_modules"/); }); +test("reports invalid root dispatcher syntax", () => { + const root = fixture(); + fs.appendFileSync(path.join(root, "bin", "octobus-tentacles.js"), "const =;\n"); + + const errors = validateRepository(root, { serviceDir: "chaitin__safeline-waf" }).errors.join("\n"); + assert.match(errors, /bin\/octobus-tentacles\.js must be valid JavaScript/); +}); + test("reports incomplete root dispatcher implementation", () => { const root = fixture(); fs.writeFileSync(path.join(root, "bin", "octobus-tentacles.js"), "#!/usr/bin/env node\n");