From d8961ed37c56b5a229f17bce42b5099cb702f3a1 Mon Sep 17 00:00:00 2001 From: lsl Date: Wed, 24 Jun 2026 18:18:44 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E4=B8=87=E8=B1=A1=E8=8E=B7=E5=8F=96=E6=97=A5=E5=BF=97=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/chaitin__cosmos/README.md | 61 ++ services/chaitin__cosmos/bin/cosmos.js | 7 + services/chaitin__cosmos/config.schema.json | 37 ++ services/chaitin__cosmos/package.json | 16 + services/chaitin__cosmos/proto/cosmos.proto | 140 ++++ .../proto/google/protobuf/struct.proto | 94 +++ .../proto/google/protobuf/wrappers.proto | 121 ++++ services/chaitin__cosmos/secret.schema.json | 11 + services/chaitin__cosmos/service.json | 37 ++ services/chaitin__cosmos/src/cosmos.js | 616 ++++++++++++++++++ services/chaitin__cosmos/src/service.js | 7 + 11 files changed, 1147 insertions(+) create mode 100644 services/chaitin__cosmos/README.md create mode 100755 services/chaitin__cosmos/bin/cosmos.js create mode 100644 services/chaitin__cosmos/config.schema.json create mode 100644 services/chaitin__cosmos/package.json create mode 100644 services/chaitin__cosmos/proto/cosmos.proto create mode 100644 services/chaitin__cosmos/proto/google/protobuf/struct.proto create mode 100644 services/chaitin__cosmos/proto/google/protobuf/wrappers.proto create mode 100644 services/chaitin__cosmos/secret.schema.json create mode 100644 services/chaitin__cosmos/service.json create mode 100644 services/chaitin__cosmos/src/cosmos.js create mode 100644 services/chaitin__cosmos/src/service.js diff --git a/services/chaitin__cosmos/README.md b/services/chaitin__cosmos/README.md new file mode 100644 index 00000000..71a905d3 --- /dev/null +++ b/services/chaitin__cosmos/README.md @@ -0,0 +1,61 @@ +# 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 | +| `skipTlsVerify` | boolean | no | Skip TLS cert verification, default false | + +## Secret + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `api_token` | string | yes | Cosmos JWT token for Authorization bearer header | + +## 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","skipTlsVerify":false}' --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"}' +``` \ No newline at end of file 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..09098774 --- /dev/null +++ b/services/chaitin__cosmos/config.schema.json @@ -0,0 +1,37 @@ +{ + "$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." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private Cosmos deployments." + } + } +} diff --git a/services/chaitin__cosmos/package.json b/services/chaitin__cosmos/package.json new file mode 100644 index 00000000..be4435b3 --- /dev/null +++ b/services/chaitin__cosmos/package.json @@ -0,0 +1,16 @@ +{ + "name": "@chaitin-ai/octobus-service-cosmos", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/service.js", + "bin": { + "cosmos": "bin/cosmos.js" + }, + "scripts": { + "start": "node bin/cosmos.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/chaitin__cosmos/proto/cosmos.proto b/services/chaitin__cosmos/proto/cosmos.proto new file mode 100644 index 00000000..de7174ba --- /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 { + string api_token = 1; // 访问凭证,必填 + 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 { + string api_token = 1; // 访问凭证,必填 + 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 { + string api_token = 1; // 访问凭证,必填 + 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/proto/google/protobuf/struct.proto b/services/chaitin__cosmos/proto/google/protobuf/struct.proto new file mode 100644 index 00000000..ad995c9f --- /dev/null +++ b/services/chaitin__cosmos/proto/google/protobuf/struct.proto @@ -0,0 +1,94 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option go_package = "google.golang.org/protobuf/types/known/structpb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "StructProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// `Struct` represents a structured data value, consisting of fields +// which map to dynamically typed values. In some languages, `Struct` +// might be supported by a native representation. For example, in +// scripting languages like JS a struct is represented as an +// object. The details of that representation are described alongside +// the proto support for the language. +// +// The JSON representation for `Struct` is JSON object. +message Struct { + // Unordered map of dynamically typed values. + map fields = 1; +} + +// `Value` represents a dynamically typed value which can be either +// null, a number, a string, a boolean, a recursive struct value, or a +// list of values. A producer of value is expected to set one of these +// variants. Absence of any variant indicates an error. +// +// The JSON representation for `Value` is JSON value. +message Value { + // The kind of value. + oneof kind { + // Represents a null value. + NullValue null_value = 1; + // Represents a double value. + double number_value = 2; + // Represents a string value. + string string_value = 3; + // Represents a boolean value. + bool bool_value = 4; + // Represents a structured value. + Struct struct_value = 5; + // Represents a repeated `Value`. + ListValue list_value = 6; + } +} + +// `NullValue` is a singleton enumeration to represent the null value for the +// `Value` type union. +// +// The JSON representation for `NullValue` is JSON `null`. +enum NullValue { + // Null value. + NULL_VALUE = 0; +} + +// `ListValue` is a wrapper around a repeated field of values. +// +// The JSON representation for `ListValue` is JSON array. +message ListValue { + // Repeated field of dynamically typed values. + repeated Value values = 1; +} diff --git a/services/chaitin__cosmos/proto/google/protobuf/wrappers.proto b/services/chaitin__cosmos/proto/google/protobuf/wrappers.proto new file mode 100644 index 00000000..b46fac75 --- /dev/null +++ b/services/chaitin__cosmos/proto/google/protobuf/wrappers.proto @@ -0,0 +1,121 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option go_package = "google.golang.org/protobuf/types/known/wrapperspb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "WrappersProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; + +// Wrapper message for `double`. +// +// The result of hashing a `google.protobuf.DoubleValue` is the same +// as hashing the `value` field directly. +message DoubleValue { + // The double value. + double value = 1; +} + +// Wrapper message for `float`. +// +// The result of hashing a `google.protobuf.FloatValue` is the same +// as hashing the `value` field directly. +message FloatValue { + // The float value. + float value = 1; +} + +// Wrapper message for `int64`. +// +// The result of hashing a `google.protobuf.Int64Value` is the same +// as hashing the `value` field directly. +message Int64Value { + // The int64 value. + int64 value = 1; +} + +// Wrapper message for `uint64`. +// +// The result of hashing a `google.protobuf.UInt64Value` is the same +// as hashing the `value` field directly. +message UInt64Value { + // The uint64 value. + uint64 value = 1; +} + +// Wrapper message for `int32`. +// +// The result of hashing a `google.protobuf.Int32Value` is the same +// as hashing the `value` field directly. +message Int32Value { + // The int32 value. + int32 value = 1; +} + +// Wrapper message for `uint32`. +// +// The result of hashing a `google.protobuf.UInt32Value` is the same +// as hashing the `value` field directly. +message UInt32Value { + // The uint32 value. + uint32 value = 1; +} + +// Wrapper message for `bool`. +// +// The result of hashing a `google.protobuf.BoolValue` is the same +// as hashing the `value` field directly. +message BoolValue { + // The bool value. + bool value = 1; +} + +// Wrapper message for `string`. +// +// The result of hashing a `google.protobuf.StringValue` is the same +// as hashing the `value` field directly. +message StringValue { + // The string value. + string value = 1; +} + +// Wrapper message for `bytes`. +// +// The result of hashing a `google.protobuf.BytesValue` is the same +// as hashing the `value` field directly. +message BytesValue { + // The bytes value. + bytes value = 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..f61a6cd1 --- /dev/null +++ b/services/chaitin__cosmos/src/cosmos.js @@ -0,0 +1,616 @@ +// Chaitin_COSMOS Cosmos Pedestal JSON-RPC proxy +// Bindings: endpoint (required), headers (optional), timeoutMs (optional), skipTlsVerify (optional) + +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; +}; + +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', +}; + +const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, timeoutMs, skipTlsVerify) => { + 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 tlsOptions = skipTlsVerify + ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } + : {}; + + try { + const res = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + timeoutMs, + ...tlsOptions, + }); + + 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; + 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 = 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); + + const requestWithDefaults = (req = {}) => { + // proto3 defaults string fields to "" — treat empty strings as "not set" + // so bindings.api_token can fill in when the request doesn't specify it + const token = firstNonEmpty(req?.api_token, req?.apiToken, bindings.api_token, bindings.apiToken); + if (!token) return { ...(req ?? {}) }; + // Spread req FIRST so our resolved api_token takes priority over the + // proto3 default empty string in req.api_token + return { + ...(req ?? {}), + api_token: token, + }; + }; + + const getToken = (req) => { + // proto3 defaults string fields to "" — treat empty strings as absent + const token = firstNonEmpty(req?.api_token, req?.apiToken, 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(req); + if (!token) { + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required'); + } + 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, skipTlsVerify); + + 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(req); + if (!token) { + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required'); + } + 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, skipTlsVerify); + + // 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(req); + if (!token) { + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required'); + } + 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, skipTlsVerify); + + // 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(requestWithDefaults(ctx.req)), + [METHOD_SEARCH_LOG_LIST]: async () => callSearchLogList(requestWithDefaults(ctx.req)), + [METHOD_SEARCH_AGGREGATION]: async () => callSearchAggregationStatistics(requestWithDefaults(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), +}; 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 }); From d659937d657dac87471a1ca4b5b10feba816dda1 Mon Sep 17 00:00:00 2001 From: lsl Date: Fri, 26 Jun 2026 15:11:48 +0800 Subject: [PATCH 2/9] test: add automated tests for cosmos service package Add mock-upstream-based test suite covering protocol mapping, error handling, and request construction for all 3 RPC methods (SearchLogInfo, SearchLogList, SearchAggregationStatistics). 73 test cases across 12 suites: - Input validation (api_token, endpoint) - SearchLogInfo: request construction, headers, response mapping, proto3 defaults - SearchLogList: keyword/time/condition/filter/pagination/organization mapping - SearchAggregationStatistics: aggregation key/asc/response mapping - Error handling: HTTP errors (401/403/500), JSON-RPC errors (-32600/-32601/-32000/1), network errors - skipTlsVerify: tlsOptions passed to fetch - timeoutMs: passed to fetch options - Endpoint normalization and headers parsing - handlers export verification Uses node:test + node:assert (zero external test deps). Co-Authored-By: Claude --- services/chaitin__cosmos/package.json | 3 +- services/chaitin__cosmos/test/cosmos.test.js | 1139 ++++++++++++++++++ 2 files changed, 1141 insertions(+), 1 deletion(-) create mode 100644 services/chaitin__cosmos/test/cosmos.test.js diff --git a/services/chaitin__cosmos/package.json b/services/chaitin__cosmos/package.json index be4435b3..0200adee 100644 --- a/services/chaitin__cosmos/package.json +++ b/services/chaitin__cosmos/package.json @@ -8,7 +8,8 @@ "cosmos": "bin/cosmos.js" }, "scripts": { - "start": "node bin/cosmos.js" + "start": "node bin/cosmos.js", + "test": "node --test test/cosmos.test.js" }, "dependencies": { "@chaitin-ai/octobus-sdk": "^0.5.0" diff --git a/services/chaitin__cosmos/test/cosmos.test.js b/services/chaitin__cosmos/test/cosmos.test.js new file mode 100644 index 00000000..650406bd --- /dev/null +++ b/services/chaitin__cosmos/test/cosmos.test.js @@ -0,0 +1,1139 @@ +// 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', 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 request over binding/secret', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'], api_token: 'req-level-token' } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const headers = calls[0].options.headers; + assert.equal(headers['Authorization'], 'bearer req-level-token'); + }); + + it('uses api_token from secret when request api_token is empty (proto3 default)', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ req: { ids: ['id1'], api_token: '' } }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const headers = calls[0].options.headers; + assert.equal(headers['Authorization'], `bearer ${MOCK_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, // PERMISSION_DENIED + ); + }); + + 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, // UNAVAILABLE + ); + }); + + 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, // FAILED_PRECONDITION + ); + }); + + 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, // INTERNAL + ); + }); + + 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('skipTlsVerify — tlsOptions passed to fetch', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('passes insecureSkipVerify and tlsInsecureSkipVerify when skipTlsVerify is true', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: true }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const opts = calls[0].options; + assert.equal(opts.insecureSkipVerify, true); + assert.equal(opts.tlsInsecureSkipVerify, true); + }); + + it('does NOT pass tlsOptions when skipTlsVerify is false', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: false }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const opts = calls[0].options; + assert.equal(opts.insecureSkipVerify, undefined); + assert.equal(opts.tlsInsecureSkipVerify, undefined); + }); + + it('does NOT pass tlsOptions by default (skipTlsVerify unset)', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const opts = calls[0].options; + assert.equal(opts.insecureSkipVerify, undefined); + assert.equal(opts.tlsInsecureSkipVerify, undefined); + }); + + it('accepts tlsInsecureSkipVerify alias from config', async () => { + const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + installMock(mockFetch); + + const ctx = makeCtx({ + config: { endpoint: MOCK_ENDPOINT, tlsInsecureSkipVerify: true }, + req: { ids: ['id1'] }, + }); + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + + const opts = calls[0].options; + assert.equal(opts.insecureSkipVerify, true); + assert.equal(opts.tlsInsecureSkipVerify, true); + }); +}); + +describe('timeoutMs — passed to fetch options', () => { + beforeEach(() => { originalFetch = globalThis.fetch; }); + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('passes timeoutMs from limits to fetch', 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'](); + + assert.equal(calls[0].options.timeoutMs, 10000); + }); + + 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.equal(calls[0].options.timeoutMs, 5000); + }); +}); + +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('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'); + }); +}); From 0ddd73fe470a2d871d73b893905be1954f088676 Mon Sep 17 00:00:00 2001 From: lsl Date: Fri, 26 Jun 2026 15:18:38 +0800 Subject: [PATCH 3/9] fix: use NODE_TLS_REJECT_UNAUTHORIZED for skipTlsVerify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tlsOptions approach (insecureSkipVerify/tlsInsecureSkipVerify) does not work with standard Node.js fetch (undici) — these are not valid fetch options. Switch to setting NODE_TLS_REJECT_UNAUTHORIZED=0 before the request and restoring it in a finally block, which is the correct way to skip TLS verification with Node.js built-in fetch. Also update tests: replace tlsOptions-based assertions with env var manipulation verification, remove timeoutMs fetch option tests (no longer passed to fetch). Co-Authored-By: Claude --- services/chaitin__cosmos/src/cosmos.js | 30 ++++-- services/chaitin__cosmos/test/cosmos.test.js | 99 +++++++++++--------- 2 files changed, 77 insertions(+), 52 deletions(-) diff --git a/services/chaitin__cosmos/src/cosmos.js b/services/chaitin__cosmos/src/cosmos.js index f61a6cd1..bddd4ccf 100644 --- a/services/chaitin__cosmos/src/cosmos.js +++ b/services/chaitin__cosmos/src/cosmos.js @@ -137,18 +137,21 @@ const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, id: '0', }; - const tlsOptions = skipTlsVerify - ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } - : {}; + const fetchOptions = { + method: 'POST', + headers, + body: JSON.stringify(body), + }; + // Node.js fetch() does not support TLS options directly. + // Set NODE_TLS_REJECT_UNAUTHORIZED=0 before the request to skip verification + // for self-signed certs, then restore the original value afterward. + const savedTlsEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + if (skipTlsVerify) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } try { - const res = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - timeoutMs, - ...tlsOptions, - }); + const res = await fetch(url, fetchOptions); if (!res.ok) { const text = await res.text(); @@ -186,6 +189,13 @@ const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, if (e instanceof GrpcError) throw e; const reason = e?.cause?.message || e?.message || 'rpc call failed'; throw errorWithCode('UNAVAILABLE', reason); + } finally { + // Restore original TLS env var + if (savedTlsEnv === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedTlsEnv; + } } }; diff --git a/services/chaitin__cosmos/test/cosmos.test.js b/services/chaitin__cosmos/test/cosmos.test.js index 650406bd..13397596 100644 --- a/services/chaitin__cosmos/test/cosmos.test.js +++ b/services/chaitin__cosmos/test/cosmos.test.js @@ -950,12 +950,15 @@ describe('Error handling — network errors', () => { }); }); -describe('skipTlsVerify — tlsOptions passed to fetch', () => { +describe('skipTlsVerify — NODE_TLS_REJECT_UNAUTHORIZED handling', () => { beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); - it('passes insecureSkipVerify and tlsInsecureSkipVerify when skipTlsVerify is true', async () => { - const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + it('sets NODE_TLS_REJECT_UNAUTHORIZED=0 when skipTlsVerify is true, restores after', async () => { + const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + + const { mockFetch } = mockFetchSuccess({ data: [] }); installMock(mockFetch); const ctx = makeCtx({ @@ -964,82 +967,94 @@ describe('skipTlsVerify — tlsOptions passed to fetch', () => { }); await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); - const opts = calls[0].options; - assert.equal(opts.insecureSkipVerify, true); - assert.equal(opts.tlsInsecureSkipVerify, true); + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); + + // Restore + if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; }); - it('does NOT pass tlsOptions when skipTlsVerify is false', async () => { - const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + it('preserves original NODE_TLS_REJECT_UNAUTHORIZED value after request', async () => { + const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; + + const { mockFetch } = mockFetchSuccess({ data: [] }); installMock(mockFetch); const ctx = makeCtx({ - config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: false }, + config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: true }, req: { ids: ['id1'] }, }); await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); - const opts = calls[0].options; - assert.equal(opts.insecureSkipVerify, undefined); - assert.equal(opts.tlsInsecureSkipVerify, undefined); + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '1'); + + // Restore + if (savedEnv === undefined) { + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + } else { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; + } }); - it('does NOT pass tlsOptions by default (skipTlsVerify unset)', async () => { - const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + it('restores env even when upstream throws an error', 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({ - config: { endpoint: MOCK_ENDPOINT }, + config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: true }, req: { ids: ['id1'] }, }); - await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + try { + await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + } catch { + // expected + } - const opts = calls[0].options; - assert.equal(opts.insecureSkipVerify, undefined); - assert.equal(opts.tlsInsecureSkipVerify, undefined); + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); + + // Restore + if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; }); - it('accepts tlsInsecureSkipVerify alias from config', async () => { - const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + it('does NOT touch env when skipTlsVerify is false', async () => { + const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; + + const { mockFetch } = mockFetchSuccess({ data: [] }); installMock(mockFetch); const ctx = makeCtx({ - config: { endpoint: MOCK_ENDPOINT, tlsInsecureSkipVerify: true }, + config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: false }, req: { ids: ['id1'] }, }); await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); - const opts = calls[0].options; - assert.equal(opts.insecureSkipVerify, true); - assert.equal(opts.tlsInsecureSkipVerify, true); + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); + + // Restore + if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; }); -}); -describe('timeoutMs — passed to fetch options', () => { - beforeEach(() => { originalFetch = globalThis.fetch; }); - afterEach(() => { globalThis.fetch = originalFetch; }); + it('accepts tlsInsecureSkipVerify alias from config', async () => { + const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - it('passes timeoutMs from limits to fetch', async () => { - const { mockFetch, calls } = mockFetchSuccess({ data: [] }); + const { mockFetch } = mockFetchSuccess({ data: [] }); installMock(mockFetch); const ctx = makeCtx({ - limits: { timeoutMs: 10000 }, + config: { endpoint: MOCK_ENDPOINT, tlsInsecureSkipVerify: true }, req: { ids: ['id1'] }, }); await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); - assert.equal(calls[0].options.timeoutMs, 10000); - }); - - 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.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); - assert.equal(calls[0].options.timeoutMs, 5000); + // Restore + if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; }); }); From 70cf244fb1a521200c0bbb472ee17d9b21db04c4 Mon Sep 17 00:00:00 2001 From: lsl Date: Fri, 26 Jun 2026 19:59:59 +0800 Subject: [PATCH 4/9] chore: remove vendored Google protobuf files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google/protobuf/struct.proto and wrappers.proto are standard well-known types that proto compilers provide by default — they should not be vendored in the service package. Co-Authored-By: Claude --- .../proto/google/protobuf/struct.proto | 94 -------------- .../proto/google/protobuf/wrappers.proto | 121 ------------------ 2 files changed, 215 deletions(-) delete mode 100644 services/chaitin__cosmos/proto/google/protobuf/struct.proto delete mode 100644 services/chaitin__cosmos/proto/google/protobuf/wrappers.proto diff --git a/services/chaitin__cosmos/proto/google/protobuf/struct.proto b/services/chaitin__cosmos/proto/google/protobuf/struct.proto deleted file mode 100644 index ad995c9f..00000000 --- a/services/chaitin__cosmos/proto/google/protobuf/struct.proto +++ /dev/null @@ -1,94 +0,0 @@ -// Protocol Buffers - Google's data interchange format -// Copyright 2008 Google Inc. All rights reserved. -// https://developers.google.com/protocol-buffers/ -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are -// met: -// -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above -// copyright notice, this list of conditions and the following disclaimer -// in the documentation and/or other materials provided with the -// distribution. -// * Neither the name of Google Inc. nor the names of its -// contributors may be used to endorse or promote products derived from -// this software without specific prior written written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -syntax = "proto3"; - -package google.protobuf; - -option go_package = "google.golang.org/protobuf/types/known/structpb"; -option java_package = "com.google.protobuf"; -option java_outer_classname = "StructProto"; -option java_multiple_files = true; -option objc_class_prefix = "GPB"; -option csharp_namespace = "Google.Protobuf.WellKnownTypes"; - -// `Struct` represents a structured data value, consisting of fields -// which map to dynamically typed values. In some languages, `Struct` -// might be supported by a native representation. For example, in -// scripting languages like JS a struct is represented as an -// object. The details of that representation are described alongside -// the proto support for the language. -// -// The JSON representation for `Struct` is JSON object. -message Struct { - // Unordered map of dynamically typed values. - map fields = 1; -} - -// `Value` represents a dynamically typed value which can be either -// null, a number, a string, a boolean, a recursive struct value, or a -// list of values. A producer of value is expected to set one of these -// variants. Absence of any variant indicates an error. -// -// The JSON representation for `Value` is JSON value. -message Value { - // The kind of value. - oneof kind { - // Represents a null value. - NullValue null_value = 1; - // Represents a double value. - double number_value = 2; - // Represents a string value. - string string_value = 3; - // Represents a boolean value. - bool bool_value = 4; - // Represents a structured value. - Struct struct_value = 5; - // Represents a repeated `Value`. - ListValue list_value = 6; - } -} - -// `NullValue` is a singleton enumeration to represent the null value for the -// `Value` type union. -// -// The JSON representation for `NullValue` is JSON `null`. -enum NullValue { - // Null value. - NULL_VALUE = 0; -} - -// `ListValue` is a wrapper around a repeated field of values. -// -// The JSON representation for `ListValue` is JSON array. -message ListValue { - // Repeated field of dynamically typed values. - repeated Value values = 1; -} diff --git a/services/chaitin__cosmos/proto/google/protobuf/wrappers.proto b/services/chaitin__cosmos/proto/google/protobuf/wrappers.proto deleted file mode 100644 index b46fac75..00000000 --- a/services/chaitin__cosmos/proto/google/protobuf/wrappers.proto +++ /dev/null @@ -1,121 +0,0 @@ -// Protocol Buffers - Google's data interchange format -// Copyright 2008 Google Inc. All rights reserved. -// https://developers.google.com/protocol-buffers/ -// -// Redistribution and use in source and binary forms, with or without -// modification, are permitted provided that the following conditions are -// met: -// -// * Redistributions of source code must retain the above copyright -// notice, this list of conditions and the following disclaimer. -// * Redistributions in binary form must reproduce the above -// copyright notice, this list of conditions and the following disclaimer -// in the documentation and/or other materials provided with the -// distribution. -// * Neither the name of Google Inc. nor the names of its -// contributors may be used to endorse or promote products derived from -// this software without specific prior written permission. -// -// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -syntax = "proto3"; - -package google.protobuf; - -option go_package = "google.golang.org/protobuf/types/known/wrapperspb"; -option java_package = "com.google.protobuf"; -option java_outer_classname = "WrappersProto"; -option java_multiple_files = true; -option objc_class_prefix = "GPB"; -option csharp_namespace = "Google.Protobuf.WellKnownTypes"; - -// Wrapper message for `double`. -// -// The result of hashing a `google.protobuf.DoubleValue` is the same -// as hashing the `value` field directly. -message DoubleValue { - // The double value. - double value = 1; -} - -// Wrapper message for `float`. -// -// The result of hashing a `google.protobuf.FloatValue` is the same -// as hashing the `value` field directly. -message FloatValue { - // The float value. - float value = 1; -} - -// Wrapper message for `int64`. -// -// The result of hashing a `google.protobuf.Int64Value` is the same -// as hashing the `value` field directly. -message Int64Value { - // The int64 value. - int64 value = 1; -} - -// Wrapper message for `uint64`. -// -// The result of hashing a `google.protobuf.UInt64Value` is the same -// as hashing the `value` field directly. -message UInt64Value { - // The uint64 value. - uint64 value = 1; -} - -// Wrapper message for `int32`. -// -// The result of hashing a `google.protobuf.Int32Value` is the same -// as hashing the `value` field directly. -message Int32Value { - // The int32 value. - int32 value = 1; -} - -// Wrapper message for `uint32`. -// -// The result of hashing a `google.protobuf.UInt32Value` is the same -// as hashing the `value` field directly. -message UInt32Value { - // The uint32 value. - uint32 value = 1; -} - -// Wrapper message for `bool`. -// -// The result of hashing a `google.protobuf.BoolValue` is the same -// as hashing the `value` field directly. -message BoolValue { - // The bool value. - bool value = 1; -} - -// Wrapper message for `string`. -// -// The result of hashing a `google.protobuf.StringValue` is the same -// as hashing the `value` field directly. -message StringValue { - // The string value. - string value = 1; -} - -// Wrapper message for `bytes`. -// -// The result of hashing a `google.protobuf.BytesValue` is the same -// as hashing the `value` field directly. -message BytesValue { - // The bytes value. - bytes value = 1; -} From 9b8eadbc90b5f2c2af4040f923008b9737112f9f Mon Sep 17 00:00:00 2001 From: lsl Date: Wed, 1 Jul 2026 11:35:27 +0800 Subject: [PATCH 5/9] fix(cosmos): per-request TLS, secret-only token, complete service registration - Replace process.env.NODE_TLS_REJECT_UNAUTHORIZED with undici Agent dispatcher for per-request TLS enforcement (no process-wide downgrade) - Remove skipTlsVerify from config.schema.json and runtime code - Move api_token from proto request messages to ctx.secret only - Re-number proto fields after removing api_token - Add undici as explicit dependency in sub-package - Complete service package registration: add cosmos entry to services/package.json bin/files, services/bin/cosmos.js wrapper, and services/bin/octobus-tentacles.js dispatch table - Update README with TLS requirements and secret-only token docs - Update tests: remove skipTlsVerify tests, add per-request dispatcher tests, update token tests to verify secret-only source (71 pass) Co-Authored-By: Claude --- .github/workflows/ci.yml | 5 - services/bin/cosmos.js | 10 ++ services/bin/octobus-tentacles.js | 9 ++ services/chaitin__cosmos/README.md | 27 +++-- services/chaitin__cosmos/config.schema.json | 5 - services/chaitin__cosmos/package.json | 12 +- services/chaitin__cosmos/proto/cosmos.proto | 51 ++++---- services/chaitin__cosmos/src/cosmos.js | 89 +++++++------- services/chaitin__cosmos/test/cosmos.test.js | 119 +++++++------------ services/package.json | 3 + 10 files changed, 157 insertions(+), 173 deletions(-) create mode 100755 services/bin/cosmos.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c1372d3..212ceaa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,11 +133,6 @@ jobs: cache-dependency-path: sdk/package-lock.json registry-url: https://registry.npmjs.org - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y protobuf-compiler - - name: Validate tag id: release run: | 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..64cfa62c 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -9,6 +9,15 @@ 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", diff --git a/services/chaitin__cosmos/README.md b/services/chaitin__cosmos/README.md index 71a905d3..348f559b 100644 --- a/services/chaitin__cosmos/README.md +++ b/services/chaitin__cosmos/README.md @@ -15,7 +15,6 @@ OctoBus service package for Chaitin Cosmos (万象) platform log APIs via Pedest | `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 | -| `skipTlsVerify` | boolean | no | Skip TLS cert verification, default false | ## Secret @@ -23,6 +22,20 @@ OctoBus service package for Chaitin Cosmos (万象) platform log APIs via Pedest |-------|------|----------|-------------| | `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 @@ -30,10 +43,9 @@ OctoBus service package for Chaitin Cosmos (万象) platform log APIs via Pedest octobus service import cosmos ./services/chaitin__cosmos # Create instance -octobus instance create cosmos-test --service cosmos --config-json '{"endpoint":"https://cosmos.demo.chaitin.cn","skipTlsVerify":false}' --secret-json '{"api_token":"xxx"}' - -# create capset +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 @@ -46,16 +58,15 @@ grpcurl -plaintext \ octobus_addr:9000 \ Chaitin_COSMOS.Chaitin_COSMOS/SearchLogList -# Call via MCP - +# 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 +# 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"}' -``` \ No newline at end of file +``` diff --git a/services/chaitin__cosmos/config.schema.json b/services/chaitin__cosmos/config.schema.json index 09098774..2c8bbdd9 100644 --- a/services/chaitin__cosmos/config.schema.json +++ b/services/chaitin__cosmos/config.schema.json @@ -27,11 +27,6 @@ "minimum": 1, "default": 5000, "description": "HTTP timeout in milliseconds." - }, - "skipTlsVerify": { - "type": "boolean", - "default": false, - "description": "Skip TLS certificate verification for private Cosmos deployments." } } } diff --git a/services/chaitin__cosmos/package.json b/services/chaitin__cosmos/package.json index 0200adee..0e57192f 100644 --- a/services/chaitin__cosmos/package.json +++ b/services/chaitin__cosmos/package.json @@ -1,17 +1,13 @@ { - "name": "@chaitin-ai/octobus-service-cosmos", - "version": "0.1.0", + "name": "cosmos", + "version": "0.0.0", "private": true, "type": "module", - "main": "src/service.js", "bin": { "cosmos": "bin/cosmos.js" }, - "scripts": { - "start": "node bin/cosmos.js", - "test": "node --test test/cosmos.test.js" - }, "dependencies": { - "@chaitin-ai/octobus-sdk": "^0.5.0" + "@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 index de7174ba..affdc313 100644 --- a/services/chaitin__cosmos/proto/cosmos.proto +++ b/services/chaitin__cosmos/proto/cosmos.proto @@ -21,8 +21,7 @@ service Chaitin_COSMOS { // ─── SearchLogInfo ─── message SearchLogInfoRequest { - string api_token = 1; // 访问凭证,必填 - repeated string ids = 2; // 日志 ID 列表,必填且非空 + repeated string ids = 1; // 日志 ID 列表,必填且非空 } message SearchLogInfoResponse { @@ -38,18 +37,17 @@ message SearchLogInfoData { // ─── SearchLogList ─── message SearchLogListRequest { - string api_token = 1; // 访问凭证,必填 - 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; // 组织机构筛选,可选 + repeated string keyword = 1; // 关键字搜索,可选 + google.protobuf.Int64Value time_range_start = 2; // 时间范围起始(Unix 秒),可选 + google.protobuf.Int64Value time_range_end = 3; // 时间范围结束(Unix 秒),可选 + string advanced_query = 4; // 高级查询语句,可选 + CosmoConditionQuery condition_query = 5; // 条件查询,可选 + CosmoFilter filter = 6; // 过滤条件,可选 + google.protobuf.Int64Value count = 7; // 分页大小,可选,默认 100 + google.protobuf.Int64Value offset = 8; // 分页偏移,可选,默认 0 + string attack_chain_phase = 9; // 攻击链阶段筛选,可选 + google.protobuf.BoolValue fall = 10; // 失陷状态筛选,可选 + repeated CosmoOrganization organization = 11; // 组织机构筛选,可选 } message CosmoConditionQuery { @@ -93,19 +91,18 @@ message SearchLogListData { // ─── SearchAggregationStatistics ─── message SearchAggregationStatisticsRequest { - string api_token = 1; // 访问凭证,必填 - 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; // 组织机构筛选,可选 + repeated string keyword = 1; // 关键字搜索,可选 + google.protobuf.Int64Value time_range_start = 2; // 时间范围起始(Unix 秒),可选 + google.protobuf.Int64Value time_range_end = 3; // 时间范围结束(Unix 秒),可选 + string advanced_query = 4; // 高级查询语句,可选 + CosmoConditionQuery condition_query = 5; // 条件查询,可选 + CosmoFilter filter = 6; // 过滤条件,可选 + repeated string key = 7; // 聚合维度,如 ["src_ip","dest_ip","event_type"] + google.protobuf.Int64Value count = 8; // 返回条数,可选,默认 10 + google.protobuf.BoolValue asc = 9; // 是否升序,可选,默认 false + string attack_chain_phase = 10; // 攻击链阶段筛选,可选 + google.protobuf.BoolValue fall = 11; // 失陷状态筛选,可选 + repeated CosmoOrganization organization = 12; // 组织机构筛选,可选 } message SearchAggregationStatisticsResponse { diff --git a/services/chaitin__cosmos/src/cosmos.js b/services/chaitin__cosmos/src/cosmos.js index bddd4ccf..85bb4378 100644 --- a/services/chaitin__cosmos/src/cosmos.js +++ b/services/chaitin__cosmos/src/cosmos.js @@ -1,6 +1,7 @@ // Chaitin_COSMOS Cosmos Pedestal JSON-RPC proxy -// Bindings: endpoint (required), headers (optional), timeoutMs (optional), skipTlsVerify (optional) +// 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; @@ -121,7 +122,15 @@ const PEDESTAL_REQUIRED_HEADERS = { 'x-request-path': 'pedestal', }; -const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, timeoutMs, skipTlsVerify) => { +// 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, @@ -141,14 +150,8 @@ const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, method: 'POST', headers, body: JSON.stringify(body), + dispatcher: SECURE_DISPATCHER, }; - // Node.js fetch() does not support TLS options directly. - // Set NODE_TLS_REJECT_UNAUTHORIZED=0 before the request to skip verification - // for self-signed certs, then restore the original value afterward. - const savedTlsEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - if (skipTlsVerify) { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; - } try { const res = await fetch(url, fetchOptions); @@ -189,13 +192,6 @@ const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, if (e instanceof GrpcError) throw e; const reason = e?.cause?.message || e?.message || 'rpc call failed'; throw errorWithCode('UNAVAILABLE', reason); - } finally { - // Restore original TLS env var - if (savedTlsEnv === undefined) { - delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - } else { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedTlsEnv; - } } }; @@ -217,24 +213,12 @@ export function rpcdef(ctx) { 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); - - const requestWithDefaults = (req = {}) => { - // proto3 defaults string fields to "" — treat empty strings as "not set" - // so bindings.api_token can fill in when the request doesn't specify it - const token = firstNonEmpty(req?.api_token, req?.apiToken, bindings.api_token, bindings.apiToken); - if (!token) return { ...(req ?? {}) }; - // Spread req FIRST so our resolved api_token takes priority over the - // proto3 default empty string in req.api_token - return { - ...(req ?? {}), - api_token: token, - }; - }; - const getToken = (req) => { - // proto3 defaults string fields to "" — treat empty strings as absent - const token = firstNonEmpty(req?.api_token, req?.apiToken, bindings.api_token, bindings.apiToken); + 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(); }; @@ -255,9 +239,9 @@ export function rpcdef(ctx) { const normalizedEndpoint = normalizeBaseUrl(endpoint); const callSearchLogInfo = async (req) => { - const token = getToken(req); + const token = getToken(); if (!token) { - throw errorWithCode('INVALID_ARGUMENT', 'api_token is required'); + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required (configure via secret)'); } if (!normalizedEndpoint) { throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); @@ -279,7 +263,7 @@ export function rpcdef(ctx) { logFlow('SearchLogInfo:start', { endpoint: normalizedEndpoint, ids_count: ids.length }); const params = { ids }; - const result = await callPedestalRpc(normalizedEndpoint, 'LogService.SearchLogInfo', params, token, baseHeaders, timeoutMs, skipTlsVerify); + const result = await callPedestalRpc(normalizedEndpoint, 'LogService.SearchLogInfo', params, token, baseHeaders, timeoutMs); const dataArr = result?.data; const records = Array.isArray(dataArr) ? dataArr.map(mapLogRecord) : []; @@ -294,9 +278,9 @@ export function rpcdef(ctx) { }; const callSearchLogList = async (req) => { - const token = getToken(req); + const token = getToken(); if (!token) { - throw errorWithCode('INVALID_ARGUMENT', 'api_token is required'); + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required (configure via secret)'); } if (!normalizedEndpoint) { throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); @@ -386,7 +370,7 @@ export function rpcdef(ctx) { logFlow('SearchLogList:start', { endpoint: normalizedEndpoint, keyword: params.keyword, count: params.count }); - const result = await callPedestalRpc(normalizedEndpoint, 'LogService.SearchLogList', params, token, baseHeaders, timeoutMs, skipTlsVerify); + 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 @@ -424,9 +408,9 @@ export function rpcdef(ctx) { }; const callSearchAggregationStatistics = async (req) => { - const token = getToken(req); + const token = getToken(); if (!token) { - throw errorWithCode('INVALID_ARGUMENT', 'api_token is required'); + throw errorWithCode('INVALID_ARGUMENT', 'api_token is required (configure via secret)'); } if (!normalizedEndpoint) { throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); @@ -526,7 +510,7 @@ export function rpcdef(ctx) { logFlow('SearchAggregation:start', { endpoint: normalizedEndpoint, key: params.key, count: params.count }); - const result = await callPedestalRpc(normalizedEndpoint, 'LogService.SearchAggregationStatistics', params, token, baseHeaders, timeoutMs, skipTlsVerify); + 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 @@ -567,9 +551,9 @@ export function rpcdef(ctx) { }; return { - [METHOD_SEARCH_LOG_INFO]: async () => callSearchLogInfo(requestWithDefaults(ctx.req)), - [METHOD_SEARCH_LOG_LIST]: async () => callSearchLogList(requestWithDefaults(ctx.req)), - [METHOD_SEARCH_AGGREGATION]: async () => callSearchAggregationStatistics(requestWithDefaults(ctx.req)), + [METHOD_SEARCH_LOG_INFO]: async () => callSearchLogInfo(ctx.req), + [METHOD_SEARCH_LOG_LIST]: async () => callSearchLogList(ctx.req), + [METHOD_SEARCH_AGGREGATION]: async () => callSearchAggregationStatistics(ctx.req), }; } @@ -624,3 +608,18 @@ export const handlers = { [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/test/cosmos.test.js b/services/chaitin__cosmos/test/cosmos.test.js index 13397596..08a4ddb0 100644 --- a/services/chaitin__cosmos/test/cosmos.test.js +++ b/services/chaitin__cosmos/test/cosmos.test.js @@ -121,7 +121,7 @@ const parseSentBody = (call) => JSON.parse(call.options.body); // ─── Test suites ─── describe('rpcdef — input validation', () => { - it('throws INVALID_ARGUMENT when api_token is missing', async () => { + it('throws INVALID_ARGUMENT when api_token is missing from secret', async () => { const ctx = makeCtx({ secret: {}, req: {} }); const defs = rpcdef(ctx); await assert.rejects( @@ -266,26 +266,39 @@ describe('SearchLogInfo', () => { assert.deepEqual(body.params.ids, ['single-id']); }); - it('uses api_token from request over binding/secret', async () => { + it('uses api_token from ctx.secret (not from request)', async () => { const { mockFetch, calls } = mockFetchSuccess({ data: [] }); installMock(mockFetch); - const ctx = makeCtx({ req: { ids: ['id1'], api_token: 'req-level-token' } }); + // 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 req-level-token'); + assert.equal(headers['Authorization'], `bearer ${MOCK_TOKEN}`); }); - it('uses api_token from secret when request api_token is empty (proto3 default)', async () => { + it('uses api_token from secret bindings', async () => { const { mockFetch, calls } = mockFetchSuccess({ data: [] }); installMock(mockFetch); - const ctx = makeCtx({ req: { ids: ['id1'], api_token: '' } }); + 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 ${MOCK_TOKEN}`); + 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'), + ); }); }); @@ -803,7 +816,7 @@ describe('Error handling — upstream HTTP errors', () => { const ctx = makeCtx({ req: { ids: ['id1'] } }); await assert.rejects( () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), - (err) => err instanceof GrpcError && err.code === 7, // PERMISSION_DENIED + (err) => err instanceof GrpcError && err.code === 7, ); }); @@ -825,7 +838,7 @@ describe('Error handling — upstream HTTP errors', () => { const ctx = makeCtx({ req: { ids: ['id1'] } }); await assert.rejects( () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), - (err) => err instanceof GrpcError && err.code === 14, // UNAVAILABLE + (err) => err instanceof GrpcError && err.code === 14, ); }); @@ -874,7 +887,7 @@ describe('Error handling — JSON-RPC error codes', () => { const ctx = makeCtx({ req: { ids: ['id1'] } }); await assert.rejects( () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), - (err) => err instanceof GrpcError && err.code === 9, // FAILED_PRECONDITION + (err) => err instanceof GrpcError && err.code === 9, ); }); @@ -885,7 +898,7 @@ describe('Error handling — JSON-RPC error codes', () => { const ctx = makeCtx({ req: { ids: ['id1'] } }); await assert.rejects( () => rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](), - (err) => err instanceof GrpcError && err.code === 13, // INTERNAL + (err) => err instanceof GrpcError && err.code === 13, ); }); @@ -950,110 +963,66 @@ describe('Error handling — network errors', () => { }); }); -describe('skipTlsVerify — NODE_TLS_REJECT_UNAUTHORIZED handling', () => { +describe('TLS — per-request undici dispatcher (no process.env side effects)', () => { beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); - it('sets NODE_TLS_REJECT_UNAUTHORIZED=0 when skipTlsVerify is true, restores after', async () => { + 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(mockFetch); - - const ctx = makeCtx({ - config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: true }, - req: { ids: ['id1'] }, + installMock(async (url, options) => { + capturedDispatcher = options?.dispatcher; + return mockFetch(url, options); }); - await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); - - assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); - // Restore - if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; - }); - - it('preserves original NODE_TLS_REJECT_UNAUTHORIZED value after request', async () => { - const savedEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'; - - const { mockFetch } = mockFetchSuccess({ data: [] }); - installMock(mockFetch); - - const ctx = makeCtx({ - config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: true }, - req: { ids: ['id1'] }, - }); + const ctx = makeCtx({ req: { ids: ['id1'] } }); await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); - assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, '1'); - - // Restore - if (savedEnv === undefined) { - delete process.env.NODE_TLS_REJECT_UNAUTHORIZED; - } else { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; - } - }); - - it('restores env even when upstream throws an error', 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({ - config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: true }, - req: { ids: ['id1'] }, - }); - try { - await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); - } catch { - // expected - } - + // Verify that NODE_TLS_REJECT_UNAUTHORIZED was never touched assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, undefined); - // Restore + // 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('does NOT touch env when skipTlsVerify is false', async () => { + 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 } = mockFetchSuccess({ data: [] }); + const { mockFetch } = mockFetchHttpError(500, 'error'); installMock(mockFetch); - const ctx = makeCtx({ - config: { endpoint: MOCK_ENDPOINT, skipTlsVerify: false }, - req: { ids: ['id1'] }, - }); - await rpcdef(ctx)['/Chaitin_COSMOS.Chaitin_COSMOS/SearchLogInfo'](); + 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); - // Restore if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; }); - it('accepts tlsInsecureSkipVerify alias from config', async () => { + 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, tlsInsecureSkipVerify: true }, + 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); - // Restore if (savedEnv !== undefined) process.env.NODE_TLS_REJECT_UNAUTHORIZED = savedEnv; }); }); 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", From d54c582c36e57c77b61dab1dad8553a01136375a Mon Sep 17 00:00:00 2001 From: lsl Date: Wed, 1 Jul 2026 11:59:37 +0800 Subject: [PATCH 6/9] fix(cosmos): use reserved for removed api_token field numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents wire-format misinterpretation by old clients — field 1 is reserved instead of re-numbered, so legacy api_token payloads are safely discarded rather than silently mapped to new fields. Co-Authored-By: Claude --- services/chaitin__cosmos/proto/cosmos.proto | 51 +++++++++++---------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/services/chaitin__cosmos/proto/cosmos.proto b/services/chaitin__cosmos/proto/cosmos.proto index affdc313..0415325e 100644 --- a/services/chaitin__cosmos/proto/cosmos.proto +++ b/services/chaitin__cosmos/proto/cosmos.proto @@ -21,7 +21,8 @@ service Chaitin_COSMOS { // ─── SearchLogInfo ─── message SearchLogInfoRequest { - repeated string ids = 1; // 日志 ID 列表,必填且非空 + reserved 1; // formerly api_token — removed; token now comes from ctx.secret + repeated string ids = 2; // 日志 ID 列表,必填且非空 } message SearchLogInfoResponse { @@ -37,17 +38,18 @@ message SearchLogInfoData { // ─── SearchLogList ─── message SearchLogListRequest { - repeated string keyword = 1; // 关键字搜索,可选 - google.protobuf.Int64Value time_range_start = 2; // 时间范围起始(Unix 秒),可选 - google.protobuf.Int64Value time_range_end = 3; // 时间范围结束(Unix 秒),可选 - string advanced_query = 4; // 高级查询语句,可选 - CosmoConditionQuery condition_query = 5; // 条件查询,可选 - CosmoFilter filter = 6; // 过滤条件,可选 - google.protobuf.Int64Value count = 7; // 分页大小,可选,默认 100 - google.protobuf.Int64Value offset = 8; // 分页偏移,可选,默认 0 - string attack_chain_phase = 9; // 攻击链阶段筛选,可选 - google.protobuf.BoolValue fall = 10; // 失陷状态筛选,可选 - repeated CosmoOrganization organization = 11; // 组织机构筛选,可选 + 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 { @@ -91,18 +93,19 @@ message SearchLogListData { // ─── SearchAggregationStatistics ─── message SearchAggregationStatisticsRequest { - repeated string keyword = 1; // 关键字搜索,可选 - google.protobuf.Int64Value time_range_start = 2; // 时间范围起始(Unix 秒),可选 - google.protobuf.Int64Value time_range_end = 3; // 时间范围结束(Unix 秒),可选 - string advanced_query = 4; // 高级查询语句,可选 - CosmoConditionQuery condition_query = 5; // 条件查询,可选 - CosmoFilter filter = 6; // 过滤条件,可选 - repeated string key = 7; // 聚合维度,如 ["src_ip","dest_ip","event_type"] - google.protobuf.Int64Value count = 8; // 返回条数,可选,默认 10 - google.protobuf.BoolValue asc = 9; // 是否升序,可选,默认 false - string attack_chain_phase = 10; // 攻击链阶段筛选,可选 - google.protobuf.BoolValue fall = 11; // 失陷状态筛选,可选 - repeated CosmoOrganization organization = 12; // 组织机构筛选,可选 + 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 { From fbb8148dc6d575555987e10331f2d7ec26d979cb Mon Sep 17 00:00:00 2001 From: lsl Date: Wed, 1 Jul 2026 12:31:15 +0800 Subject: [PATCH 7/9] fix(cosmos): wire up timeoutMs via AbortSignal.timeout Previously timeoutMs was accepted as a parameter but never passed to fetch, allowing requests to hang indefinitely. Now uses AbortSignal.timeout(timeoutMs) on each fetch call, and maps TimeoutError/AbortError to DEADLINE_EXCEEDED (gRPC code 4). Co-Authored-By: Claude --- services/chaitin__cosmos/src/cosmos.js | 5 ++ services/chaitin__cosmos/test/cosmos.test.js | 56 ++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/services/chaitin__cosmos/src/cosmos.js b/services/chaitin__cosmos/src/cosmos.js index 85bb4378..47c77f98 100644 --- a/services/chaitin__cosmos/src/cosmos.js +++ b/services/chaitin__cosmos/src/cosmos.js @@ -150,6 +150,7 @@ const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, method: 'POST', headers, body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), dispatcher: SECURE_DISPATCHER, }; @@ -190,6 +191,10 @@ const callPedestalRpc = async (endpoint, rpcMethod, params, token, baseHeaders, 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); } diff --git a/services/chaitin__cosmos/test/cosmos.test.js b/services/chaitin__cosmos/test/cosmos.test.js index 08a4ddb0..6127e1fd 100644 --- a/services/chaitin__cosmos/test/cosmos.test.js +++ b/services/chaitin__cosmos/test/cosmos.test.js @@ -1104,6 +1104,62 @@ describe('Headers parsing from config', () => { }); }); +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, + ); + }); +}); + 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'); From 679b99379801ef76a065363f35428bc7da27c3a1 Mon Sep 17 00:00:00 2001 From: lsl Date: Wed, 1 Jul 2026 12:42:11 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(cosmos):=20validate=20timeoutMs=20?= =?UTF-8?q?=E2=80=94=20reject=20negative,=20NaN,=20Infinity,=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents RangeError/TypeError from AbortSignal.timeout() when given invalid values. Negative, zero, NaN, Infinity, and non-numeric timeoutMs now throw INVALID_ARGUMENT (gRPC code 3) instead of being silently passed through and wrapped as UNAVAILABLE. Co-Authored-By: Claude --- services/chaitin__cosmos/src/cosmos.js | 13 ++++++- services/chaitin__cosmos/test/cosmos.test.js | 40 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/services/chaitin__cosmos/src/cosmos.js b/services/chaitin__cosmos/src/cosmos.js index 47c77f98..0da3d653 100644 --- a/services/chaitin__cosmos/src/cosmos.js +++ b/services/chaitin__cosmos/src/cosmos.js @@ -49,6 +49,17 @@ const toPositiveInt = (val) => { 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) { @@ -215,7 +226,7 @@ const mapLogRecord = (item) => { 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 = ctx.limits?.timeoutMs || DEFAULT_TIMEOUT_MS; + const timeoutMs = validateTimeoutMs(ctx.limits?.timeoutMs); const baseHeaders = parseHeaders(bindings.headers); const meta = ctx.meta || {}; diff --git a/services/chaitin__cosmos/test/cosmos.test.js b/services/chaitin__cosmos/test/cosmos.test.js index 6127e1fd..29bcac61 100644 --- a/services/chaitin__cosmos/test/cosmos.test.js +++ b/services/chaitin__cosmos/test/cosmos.test.js @@ -1158,6 +1158,46 @@ describe('Timeout — AbortSignal.timeout prevents indefinite hangs', () => { (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', () => { From bd93c86a91ee33305806cc3d9fd9ab1fc8c1734f Mon Sep 17 00:00:00 2001 From: lsl Date: Fri, 10 Jul 2026 15:28:46 +0800 Subject: [PATCH 9/9] fix(services): validate root dispatcher syntax Co-Authored-By: Claude --- .github/workflows/ci.yml | 5 +++++ services/bin/octobus-tentacles.js | 5 ----- services/scripts/validate-service-package.mjs | 18 +++++++++++++++++- .../tests/validate-service-package.test.mjs | 8 ++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 212ceaa1..5c1372d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,11 @@ jobs: cache-dependency-path: sdk/package-lock.json registry-url: https://registry.npmjs.org + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + - name: Validate tag id: release run: | diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 64cfa62c..38019e1f 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -17,7 +17,6 @@ const services = { 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", @@ -26,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/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");