diff --git a/services/anyi__cloud-native-security/README.md b/services/anyi__cloud-native-security/README.md new file mode 100644 index 00000000..d5e16f16 --- /dev/null +++ b/services/anyi__cloud-native-security/README.md @@ -0,0 +1,105 @@ +# Anyi Cloud Native Security (DISS) OctoBus Service + +OctoBus service package for [Anyi Technology](https://www.anyi.com.cn/) Cloud Native Security Platform (DISS). + +Import it into OctoBus with: + +```bash +octobus service import --id anyi-cloud-native-security ./services//anyi__cloud-native-security +``` + +## Supported Version + +Tested against DISS API v1.0.0. The API uses apiKey authentication: the JWT token is sent directly in the `Authorization` header without a `Bearer` prefix (i.e., `Authorization: `). + +## Package Files + +- `service.json`: OctoBus service manifest. +- `proto/anyi_cloud_native_security.proto`: gRPC API definition. +- `config.schema.json`: non-secret endpoint, timeout, TLS, and user settings. +- `secret.schema.json`: DISS API token (sent as `Authorization: `, no Bearer prefix). +- `src/anyi-cloud-native-security.js`: DISS API implementation. +- `src/service.js`: OctoBus SDK `defineService` wrapper. +- `bin/anyi-cloud-native-security.js`: service-local executable entrypoint. +- `test/anyi-cloud-native-security.test.js`: node:test coverage for validation, request/response mapping, error handling, and SDK handler invocation. +- `test/mock_upstream.js`: local DISS API mock server. + +## Configuration + +Use `endpoint` for the DISS REST API base URL. `baseUrl` is accepted as a legacy alias. + +```json +{ + "endpoint": "https://diss.example.com:8543", + "timeoutMs": 15000, + "skipTlsVerify": false, + "defaultUser": "admin" +} +``` + +Use `secret.token` for the DISS API token (placed directly in the `Authorization` header, no `Bearer` prefix): + +```json +{ + "token": "eyJhbGciOiJQQkVTMi1IUzI1NitBMTI4S1ciLCJlbmMiOiJBMTI4Q0JDLUhTMjU2In0..." +} +``` + +## RPC Methods + +| Method | Description | +|---|---| +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListWarnings` | List security warnings with pagination and filters | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListWarningGroups` | List aggregated warning groups | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/DisposeWarnings` | Dispose warnings (isolation/pause/stop/kill) | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/DisposeWarningGroups` | Dispose aggregated warning groups | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListVulnerabilities` | List image vulnerabilities | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListHosts` | List host assets | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListContainers` | List container assets | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListClusters` | List K8s clusters | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ContainerControl` | Control container (resume/start/activate/deactivate) | +| `Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/UnblockNetwork` | Unblock container network isolation | + +## Behavior Notes + +- All list methods support `from`/`limit` pagination parameters (default: 0/20). +- `ListHosts` uses `defaultUser` from config (default: `admin`); per-request `user` overrides it. +- Filter fields use `google.protobuf.Struct` to pass through DISS API body parameters without hardcoding the full DISS schema. +- `DisposeWarnings` and `DisposeWarningGroups` require `action` (one of: isolation, pause, stop, kill). These are write operations. +- `ContainerControl` requires `action` (one of: resume, start, activate, deactivate) and `container_id`. +- `UnblockNetwork` requires `container_id`. +- HTTP 401 maps to gRPC UNAUTHENTICATED, 403 to PERMISSION_DENIED, 4xx to FAILED_PRECONDITION, 5xx/network to UNAVAILABLE. +- When `skipTlsVerify` is true, TLS certificate verification is skipped (useful for self-signed certificates in private deployments). + +## Risk Notes + +- **Write operations**: `DisposeWarnings`, `DisposeWarningGroups`, `ContainerControl`, and `UnblockNetwork` modify system state. + - `isolation` action isolates a container network; use `UnblockNetwork` to reverse. + - `stop`/`kill` actions stop or kill containers; verify target before invocation. + - `deactivate` deactivates a container; use `activate` or `start` to reverse. +- **Idempotency**: DISS disposal and control operations are idempotent for repeated identical requests. +- **Rollback**: Container isolation can be reversed via `UnblockNetwork`. Stopped containers may require manual restart. +- **Audit**: All write operations are logged by DISS with user context and timestamp. + +## Suggested Capset + +For a security monitoring agent: + +``` +ListWarnings, ListWarningGroups, ListVulnerabilities, ListHosts, ListContainers, ListClusters +``` + +For a security response agent (adds write operations): + +``` +ListWarnings, ListWarningGroups, DisposeWarnings, DisposeWarningGroups, ListVulnerabilities, ListHosts, ListContainers, ListClusters, ContainerControl, UnblockNetwork +``` + +## Local Checks + +```bash +cd services +npm run validate -- --service-dir anyi__cloud-native-security +npm test -- --service-dir anyi__cloud-native-security --coverage +npm run pack:check +``` diff --git a/services/anyi__cloud-native-security/bin/anyi-cloud-native-security.js b/services/anyi__cloud-native-security/bin/anyi-cloud-native-security.js new file mode 100644 index 00000000..508272f0 --- /dev/null +++ b/services/anyi__cloud-native-security/bin/anyi-cloud-native-security.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/anyi__cloud-native-security/config.schema.json b/services/anyi__cloud-native-security/config.schema.json new file mode 100644 index 00000000..46543074 --- /dev/null +++ b/services/anyi__cloud-native-security/config.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "Anyi DISS REST API base URL, for example https://diss.example.com:8543." + }, + "baseUrl": { + "type": "string", + "description": "Legacy alias for endpoint." + }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Optional extra HTTP headers sent to DISS." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 15000, + "description": "HTTP timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private DISS deployments." + }, + "defaultUser": { + "type": "string", + "default": "admin", + "description": "Default user parameter for DISS API queries that require a user context." + } + } +} diff --git a/services/anyi__cloud-native-security/package.json b/services/anyi__cloud-native-security/package.json new file mode 100644 index 00000000..d6195608 --- /dev/null +++ b/services/anyi__cloud-native-security/package.json @@ -0,0 +1,13 @@ +{ + "name": "anyi-cloud-native-security", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "anyi-cloud-native-security": "bin/anyi-cloud-native-security.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0", + "undici": "^7.16.0" + } +} diff --git a/services/anyi__cloud-native-security/proto/anyi_cloud_native_security.proto b/services/anyi__cloud-native-security/proto/anyi_cloud_native_security.proto new file mode 100644 index 00000000..50740928 --- /dev/null +++ b/services/anyi__cloud-native-security/proto/anyi_cloud_native_security.proto @@ -0,0 +1,186 @@ +syntax = "proto3"; + +package Anyi_CloudNativeSecurity; + +import "google/protobuf/struct.proto"; + +option go_package = "miner/grpc-service/Anyi_CloudNativeSecurity"; + +service Anyi_CloudNativeSecurity { + // 获取告警列表,支持分页和过滤 + rpc ListWarnings(ListWarningsRequest) returns (ListWarningsResponse) {} + + // 获取聚合告警列表,支持分页和过滤 + rpc ListWarningGroups(ListWarningGroupsRequest) returns (ListWarningGroupsResponse) {} + + // 告警处置:isolation 隔离、pause 暂停、stop 停止、kill 终止 + rpc DisposeWarnings(DisposeWarningsRequest) returns (DisposeWarningsResponse) {} + + // 聚合告警处置:isolation 隔离、pause 暂停、stop 停止、kill 终止 + rpc DisposeWarningGroups(DisposeWarningGroupsRequest) returns (DisposeWarningGroupsResponse) {} + + // 获取镜像漏洞列表 + rpc ListVulnerabilities(ListVulnerabilitiesRequest) returns (ListVulnerabilitiesResponse) {} + + // 获取主机列表 + rpc ListHosts(ListHostsRequest) returns (ListHostsResponse) {} + + // 获取容器列表 + rpc ListContainers(ListContainersRequest) returns (ListContainersResponse) {} + + // 获取集群列表 + rpc ListClusters(ListClustersRequest) returns (ListClustersResponse) {} + + // 容器控制操作:resume 恢复、start 启动、activate 激活、deactivate 停用 + rpc ContainerControl(ContainerControlRequest) returns (ContainerControlResponse) {} + + // 解除容器网络隔离 + rpc UnblockNetwork(UnblockNetworkRequest) returns (UnblockNetworkResponse) {} +} + +// --- ListWarnings --- + +message ListWarningsRequest { + google.protobuf.Struct filter = 1; // 过滤条件,透传至 DISS API body + int64 from = 2; // 分页起始偏移,默认 0 + int64 limit = 3; // 分页大小,默认 20 +} + +message ListWarningsResponse { + int64 code = 1; // DISS 返回码,200 表示成功 + string message = 2; // DISS 返回消息 + google.protobuf.Struct data = 3; // DISS 返回数据,含 items/total 等 +} + +// --- ListWarningGroups --- + +message ListWarningGroupsRequest { + google.protobuf.Struct filter = 1; + int64 from = 2; + int64 limit = 3; +} + +message ListWarningGroupsResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- DisposeWarnings --- + +message DisposeWarningsRequest { + string action = 1; // 处置方式:isolation / pause / stop / kill + string account = 2; // 租户,可选 + repeated google.protobuf.Struct warnings = 3; // 告警信息列表 + repeated google.protobuf.Struct whitelist = 4; // 白名单条目,可选 +} + +message DisposeWarningsResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- DisposeWarningGroups --- + +message DisposeWarningGroupsRequest { + string action = 1; + string account = 2; + repeated google.protobuf.Struct warning_groups = 3; + repeated google.protobuf.Struct whitelist = 4; + bool ns_networkpolicy = 5; // 是否应用网络策略 +} + +message DisposeWarningGroupsResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- ListVulnerabilities --- + +message ListVulnerabilitiesRequest { + google.protobuf.Struct filter = 1; + int64 from = 2; + int64 limit = 3; +} + +message ListVulnerabilitiesResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- ListHosts --- + +message ListHostsRequest { + google.protobuf.Struct filter = 1; + string user = 2; // DISS API 登录用户,默认 "admin" + int64 from = 3; + int64 limit = 4; +} + +message ListHostsResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- ListContainers --- + +message ListContainersRequest { + google.protobuf.Struct filter = 1; + int64 from = 2; + int64 limit = 3; +} + +message ListContainersResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- ListClusters --- + +message ListClustersRequest { + google.protobuf.Struct filter = 1; + int64 from = 2; + int64 limit = 3; +} + +message ListClustersResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- ContainerControl --- + +message ContainerControlRequest { + string action = 1; // 操作:resume / start / activate / deactivate + string container_id = 2; // 容器 ID + string container_name = 3; // 容器名称,可选 + string host_id = 4; // 主机 ID,可选 + string cluster_id = 5; // 集群 ID,可选 + string analysis = 6; // 处置说明,可选 +} + +message ContainerControlResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} + +// --- UnblockNetwork --- + +message UnblockNetworkRequest { + string container_id = 1; // 容器 ID + string host_id = 2; // 主机 ID,可选 + string cluster_id = 3; // 集群 ID,可选 +} + +message UnblockNetworkResponse { + int64 code = 1; + string message = 2; + google.protobuf.Struct data = 3; +} diff --git a/services/anyi__cloud-native-security/screenshots/list-containers-success.png b/services/anyi__cloud-native-security/screenshots/list-containers-success.png new file mode 100644 index 00000000..3ecd16bb Binary files /dev/null and b/services/anyi__cloud-native-security/screenshots/list-containers-success.png differ diff --git a/services/anyi__cloud-native-security/screenshots/list-hosts-success.png b/services/anyi__cloud-native-security/screenshots/list-hosts-success.png new file mode 100644 index 00000000..dece5ea2 Binary files /dev/null and b/services/anyi__cloud-native-security/screenshots/list-hosts-success.png differ diff --git a/services/anyi__cloud-native-security/screenshots/list-warnings-success.png b/services/anyi__cloud-native-security/screenshots/list-warnings-success.png new file mode 100644 index 00000000..2fb4a69f Binary files /dev/null and b/services/anyi__cloud-native-security/screenshots/list-warnings-success.png differ diff --git a/services/anyi__cloud-native-security/secret.schema.json b/services/anyi__cloud-native-security/secret.schema.json new file mode 100644 index 00000000..f31c8086 --- /dev/null +++ b/services/anyi__cloud-native-security/secret.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "token": { + "type": "string", + "description": "Anyi DISS API JWT token for Authorization: Bearer ." + } + } +} diff --git a/services/anyi__cloud-native-security/service.json b/services/anyi__cloud-native-security/service.json new file mode 100644 index 00000000..57ae190f --- /dev/null +++ b/services/anyi__cloud-native-security/service.json @@ -0,0 +1,31 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "anyi-cloud-native-security", + "displayName": "Anyi Cloud Native Security", + "description": "OctoBus package for Anyi Cloud Native Security Platform (DISS) alert, vulnerability, asset, and container control APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": ["proto"], + "files": ["proto/anyi_cloud_native_security.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListWarnings": { "name": "list-warnings", "description": "List security warnings from Anyi DISS." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListWarningGroups": { "name": "list-warning-groups", "description": "List aggregated warning groups from Anyi DISS." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/DisposeWarnings": { "name": "dispose-warnings", "description": "Dispose warnings (isolation/pause/stop/kill)." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/DisposeWarningGroups": { "name": "dispose-warning-groups", "description": "Dispose aggregated warning groups." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListVulnerabilities": { "name": "list-vulnerabilities", "description": "List image vulnerabilities from Anyi DISS." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListHosts": { "name": "list-hosts", "description": "List host assets from Anyi DISS." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListContainers": { "name": "list-containers", "description": "List container assets from Anyi DISS." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ListClusters": { "name": "list-clusters", "description": "List K8s clusters from Anyi DISS." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/ContainerControl": { "name": "container-control", "description": "Control container (resume/start/activate/deactivate)." }, + "Anyi_CloudNativeSecurity.Anyi_CloudNativeSecurity/UnblockNetwork": { "name": "unblock-network", "description": "Unblock container network isolation." } + } + } + } +} diff --git a/services/anyi__cloud-native-security/src/anyi-cloud-native-security.js b/services/anyi__cloud-native-security/src/anyi-cloud-native-security.js new file mode 100644 index 00000000..4b90a696 --- /dev/null +++ b/services/anyi__cloud-native-security/src/anyi-cloud-native-security.js @@ -0,0 +1,453 @@ +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_USER = 'admin'; +const DEFAULT_FROM = 0; +const DEFAULT_LIMIT = 20; + +const DISPOSAL_ACTIONS = ['isolation', 'pause', 'stop', 'kill']; +const CONTROL_ACTIONS = ['resume', 'start', 'activate', 'deactivate']; + +const PKG = 'Anyi_CloudNativeSecurity'; +const SVC = `${PKG}.${PKG}`; + +const METHOD_PATHS = { + ListWarnings: `/${SVC}/ListWarnings`, + ListWarningGroups: `/${SVC}/ListWarningGroups`, + DisposeWarnings: `/${SVC}/DisposeWarnings`, + DisposeWarningGroups: `/${SVC}/DisposeWarningGroups`, + ListVulnerabilities: `/${SVC}/ListVulnerabilities`, + ListHosts: `/${SVC}/ListHosts`, + ListContainers: `/${SVC}/ListContainers`, + ListClusters: `/${SVC}/ListClusters`, + ContainerControl: `/${SVC}/ContainerControl`, + UnblockNetwork: `/${SVC}/UnblockNetwork`, +}; + +const FULL_METHODS = {}; +for (const [name, path] of Object.entries(METHOD_PATHS)) { + FULL_METHODS[name] = `${SVC}/${name}`; +} + +export { METHOD_PATHS, FULL_METHODS }; + +// --- Helpers --- + +const grpcCodeFor = (code) => ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAUTHENTICATED: grpcStatus.UNAUTHENTICATED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const firstDefined = (...vals) => vals.find((v) => v !== undefined && v !== null); + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), + ...(ctx?.bindings ?? {}), +}); + +const parseHeaders = (value) => { + if (value === undefined || value === null || value === '') return {}; + if (typeof value === 'object' && !Array.isArray(value)) return value; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch { return {}; } + } + return {}; +}; + +const normalizeBaseUrl = (url) => { + const base = String(url || '').trim(); + if (!/^https?:\/\//i.test(base)) return null; + return base.replace(/\/$/, ''); +}; + +const toPositiveInt = (val) => { + if (val === undefined || val === null) return null; + if (typeof val === 'object' && 'value' in val) return toPositiveInt(val.value); + const n = Number(val); + if (!Number.isInteger(n) || Number.isNaN(n)) return null; + return n; +}; + +const toStructValue = (val) => { + if (val === undefined || val === null) return { nullValue: 'NULL_VALUE' }; + 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)) { + return { listValue: { values: val.map(toStructValue) } }; + } + if (typeof val === 'object') { + const fields = {}; + for (const [k, v] of Object.entries(val)) { + fields[k] = toStructValue(v); + } + return { structValue: { fields } }; + } + return { stringValue: String(val) }; +}; + +const toStruct = (obj) => { + if (obj === undefined || obj === null) return undefined; + const fields = {}; + for (const [k, v] of Object.entries(obj)) { + fields[k] = toStructValue(v); + } + return { fields }; +}; + +const fromStruct = (struct) => { + if (!struct || !struct.fields) return {}; + const obj = {}; + for (const [k, v] of Object.entries(struct.fields)) { + obj[k] = fromStructValue(v); + } + return obj; +}; + +const fromStructValue = (sv) => { + if (!sv) return null; + if (hasOwn(sv, 'nullValue')) return null; + if (hasOwn(sv, 'stringValue')) return sv.stringValue; + if (hasOwn(sv, 'numberValue')) return sv.numberValue; + if (hasOwn(sv, 'boolValue')) return sv.boolValue; + if (hasOwn(sv, 'structValue')) return fromStruct(sv.structValue); + if (hasOwn(sv, 'listValue')) { + return (sv.listValue?.values ?? []).map(fromStructValue); + } + return null; +}; + +const resolveCallContext = (baseCtx, reqOrCtx, maybeInnerCtx) => { + if (maybeInnerCtx !== undefined) { + return { req: reqOrCtx ?? {}, ctx: { ...baseCtx, ...maybeInnerCtx, bindings: mergedBindings({ ...baseCtx, ...maybeInnerCtx }) } }; + } + const innerCtx = reqOrCtx ?? {}; + return { + req: innerCtx.request ?? innerCtx.req ?? {}, + ctx: { ...baseCtx, ...innerCtx, bindings: mergedBindings({ ...baseCtx, ...innerCtx }) }, + }; +}; + +// --- DISS HTTP Client --- + +// DISS API uses apiKey auth: Authorization header with raw token value (no "Bearer " prefix). +// Verified against real DISS platform — "Bearer " prefix causes TokenInvalided. +const buildHeaders = (token, extraHeaders = {}) => ({ + ...extraHeaders, + 'Content-Type': 'application/json', + ...(token ? { Authorization: token } : {}), +}); + +let insecureDispatcherPromise; + +const fetchDiss = async (url, init, timeoutMs, skipTlsVerify) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const opts = { ...init, signal: controller.signal }; + if (skipTlsVerify) { + const { Agent } = await import('undici'); + insecureDispatcherPromise ??= Promise.resolve(new Agent({ + connect: { rejectUnauthorized: false }, + })); + opts.dispatcher = await insecureDispatcherPromise; + } + const res = await fetch(url, opts); + clearTimeout(timeoutId); + return res; + } catch (e) { + clearTimeout(timeoutId); + if (e.name === 'AbortError') throw errorWithCode('DEADLINE_EXCEEDED', `request timeout after ${timeoutMs}ms`); + const reason = e?.cause?.message || e?.message || 'fetch failed'; + throw errorWithCode('UNAVAILABLE', reason); + } +}; + +const throwForHttpError = (status, text) => { + if (status === 401) throw errorWithCode('UNAUTHENTICATED', `upstream http 401: ${text}`); + if (status === 403) throw errorWithCode('PERMISSION_DENIED', `upstream http 403: ${text}`); + if (status >= 400 && status < 500) throw errorWithCode('FAILED_PRECONDITION', `upstream http ${status}: ${text}`); + throw errorWithCode('UNAVAILABLE', `upstream http ${status}: ${text}`); +}; + +const readJsonResponse = async (res) => { + const text = await res.text(); + if (!res.ok) throwForHttpError(res.status, text); + if (!text.trim()) return { code: res.status, message: '', data: null }; + try { + return JSON.parse(text); + } catch { + throw errorWithCode('UNKNOWN', 'response is not valid JSON'); + } +}; + +const dissPost = async (baseUrl, path, token, body, query, timeoutMs, skipTlsVerify, extraHeaders) => { + const qs = query ? '?' + query : ''; + const url = `${baseUrl}${path}${qs}`; + const headers = buildHeaders(token, extraHeaders); + const init = { method: 'POST', headers, body: body !== undefined && body !== null ? JSON.stringify(body) : undefined }; + const res = await fetchDiss(url, init, timeoutMs, skipTlsVerify); + return readJsonResponse(res); +}; + +// --- Method Implementations --- + +const callListWarnings = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const from = toPositiveInt(firstDefined(req?.from, req?.From)) ?? DEFAULT_FROM; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) ?? DEFAULT_LIMIT; + const filter = req?.filter ? fromStruct(req.filter) : {}; + const query = `from=${from}&limit=${limit}`; + const json = await dissPost(baseUrl, '/api/v1/securitylog/warninginfo', token, filter, query, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callListWarningGroups = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const from = toPositiveInt(firstDefined(req?.from, req?.From)) ?? DEFAULT_FROM; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) ?? DEFAULT_LIMIT; + const filter = req?.filter ? fromStruct(req.filter) : {}; + const query = `from=${from}&limit=${limit}`; + const json = await dissPost(baseUrl, '/api/v1/securitylog/warninginfogroup', token, filter, query, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callDisposeWarnings = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const action = String(firstDefined(req?.action, req?.Action) || '').trim().toLowerCase(); + if (!action) throw errorWithCode('INVALID_ARGUMENT', 'action is required'); + if (!DISPOSAL_ACTIONS.includes(action)) { + throw errorWithCode('INVALID_ARGUMENT', `action must be one of: ${DISPOSAL_ACTIONS.join(', ')}`); + } + const payload = { Action: action }; + if (req?.account) payload.Account = String(req.account); + if (Array.isArray(req?.warnings)) payload.WarningInfo = req.warnings.map((w) => fromStructValue(w) ?? w); + if (Array.isArray(req?.whitelist)) payload.WarningWhiteList = req.whitelist.map((w) => fromStructValue(w) ?? w); + const json = await dissPost(baseUrl, '/api/v1/securitylog/warninginfo/disposal', token, payload, null, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callDisposeWarningGroups = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const action = String(firstDefined(req?.action, req?.Action) || '').trim().toLowerCase(); + if (!action) throw errorWithCode('INVALID_ARGUMENT', 'action is required'); + if (!DISPOSAL_ACTIONS.includes(action)) { + throw errorWithCode('INVALID_ARGUMENT', `action must be one of: ${DISPOSAL_ACTIONS.join(', ')}`); + } + const payload = { Action: action }; + if (req?.account) payload.Account = String(req.account); + if (Array.isArray(req?.warning_groups)) payload.WarningInfo = req.warning_groups.map((w) => fromStructValue(w) ?? w); + if (Array.isArray(req?.whitelist)) payload.WarningWhiteList = req.whitelist.map((w) => fromStructValue(w) ?? w); + if (hasOwn(req, 'ns_networkpolicy') || hasOwn(req, 'NsNetworkpolicy')) { + payload.NsNetworkpolicy = Boolean(firstDefined(req?.ns_networkpolicy, req?.NsNetworkpolicy)); + } + const json = await dissPost(baseUrl, '/api/v1/securitylog/warninginfogroup/disposal', token, payload, null, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callListVulnerabilities = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const from = toPositiveInt(firstDefined(req?.from, req?.From)) ?? DEFAULT_FROM; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) ?? DEFAULT_LIMIT; + const filter = req?.filter ? fromStruct(req.filter) : {}; + const query = `from=${from}&limit=${limit}`; + const json = await dissPost(baseUrl, '/api/v1/securitylog/vulnerabilitiesscan', token, filter, query, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callListHosts = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const from = toPositiveInt(firstDefined(req?.from, req?.From)) ?? DEFAULT_FROM; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) ?? DEFAULT_LIMIT; + const user = String(firstDefined(req?.user, req?.User, bindings.defaultUser, bindings.DefaultUser, DEFAULT_USER)); + const filter = req?.filter ? fromStruct(req.filter) : {}; + const query = `user=${encodeURIComponent(user)}&from=${from}&limit=${limit}`; + const json = await dissPost(baseUrl, '/api/v1/asset/hosts/', token, filter, query, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callListContainers = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const from = toPositiveInt(firstDefined(req?.from, req?.From)) ?? DEFAULT_FROM; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) ?? DEFAULT_LIMIT; + const filter = req?.filter ? fromStruct(req.filter) : {}; + const query = `from=${from}&limit=${limit}`; + const json = await dissPost(baseUrl, '/api/v1/containers/', token, filter, query, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callListClusters = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const from = toPositiveInt(firstDefined(req?.from, req?.From)) ?? DEFAULT_FROM; + const limit = toPositiveInt(firstDefined(req?.limit, req?.Limit)) ?? DEFAULT_LIMIT; + const filter = req?.filter ? fromStruct(req.filter) : {}; + const query = `from=${from}&limit=${limit}`; + const json = await dissPost(baseUrl, '/api/v1/k8s/clusters', token, filter, query, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callContainerControl = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const action = String(firstDefined(req?.action, req?.Action) || '').trim().toLowerCase(); + if (!action) throw errorWithCode('INVALID_ARGUMENT', 'action is required'); + if (!CONTROL_ACTIONS.includes(action)) { + throw errorWithCode('INVALID_ARGUMENT', `action must be one of: ${CONTROL_ACTIONS.join(', ')}`); + } + const containerId = String(firstDefined(req?.container_id, req?.containerId, req?.ContainerId) || '').trim(); + if (!containerId) throw errorWithCode('INVALID_ARGUMENT', 'container_id is required'); + + const payload = { Action: action, ContainerId: containerId }; + if (req?.container_name) payload.ContainerName = String(req.container_name); + if (req?.host_id) payload.HostId = String(req.host_id); + if (req?.cluster_id) payload.ClusterId = String(req.cluster_id); + if (req?.cluster_id) payload.Cluster = String(req.cluster_id); + if (req?.analysis) payload.Analysis = String(req.analysis); + if (req?.analysis) payload.ProcessNote = String(req.analysis); + + const json = await dissPost(baseUrl, '/api/v1/system/respcenter/operation', token, payload, null, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +const callUnblockNetwork = async (req, bindings, baseUrl, token, timeoutMs, skipTlsVerify, extraHeaders) => { + const containerId = String(firstDefined(req?.container_id, req?.containerId, req?.ContainerId) || '').trim(); + if (!containerId) throw errorWithCode('INVALID_ARGUMENT', 'container_id is required'); + + const payload = { ContainerId: containerId }; + if (req?.host_id) payload.HostId = String(req.host_id); + if (req?.cluster_id) payload.ClusterId = String(req.cluster_id); + + const json = await dissPost(baseUrl, '/api/v1/system/respcenter/unblock-network', token, payload, null, timeoutMs, skipTlsVerify, extraHeaders); + return { code: json?.code ?? 200, message: json?.message ?? '', data: toStruct(json?.data) }; +}; + +// --- rpcdef & handlers --- + +export function rpcdef(ctx) { + const bindings = mergedBindings(ctx); + const baseUrl = normalizeBaseUrl(bindings.endpoint || bindings.restBaseUrl || bindings.baseUrl || bindings.base_url || ''); + if (!baseUrl) throw errorWithCode('INVALID_ARGUMENT', 'endpoint is required (http/https)'); + + const token = String(bindings.token || '').trim(); + const timeoutMs = Number(firstDefined(bindings.timeoutMs, bindings.timeout_ms, ctx?.limits?.timeoutMs, DEFAULT_TIMEOUT_MS)); + const effectiveTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS; + const skipTlsVerify = Boolean(bindings.skipTlsVerify || bindings.tlsInsecureSkipVerify || bindings.skip_tls_verify); + const baseHeaders = parseHeaders(bindings.headers); + const meta = ctx.meta || {}; + + const logFlow = (action, details) => { + const inst = meta.instance_id || meta.instanceId; + const reqId = meta.request_id || meta.requestId; + const prefix = `[Anyi_CloudNativeSecurity][${action}]${inst ? `[inst=${inst}]` : ''}${reqId ? `[req=${reqId}]` : ''}`; + try { console.log(prefix, JSON.stringify(details)); } catch { console.log(prefix, details); } + }; + + const requestWithDefaults = (req = {}) => req; + + return { + [METHOD_PATHS.ListWarnings]: async () => callListWarnings(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders), + [METHOD_PATHS.ListWarningGroups]: async () => callListWarningGroups(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders), + [METHOD_PATHS.DisposeWarnings]: async () => { + logFlow('DisposeWarnings:start', { action: ctx.req?.action }); + const result = await callDisposeWarnings(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders); + logFlow('DisposeWarnings:done', { action: ctx.req?.action }); + return result; + }, + [METHOD_PATHS.DisposeWarningGroups]: async () => { + logFlow('DisposeWarningGroups:start', { action: ctx.req?.action }); + const result = await callDisposeWarningGroups(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders); + logFlow('DisposeWarningGroups:done', { action: ctx.req?.action }); + return result; + }, + [METHOD_PATHS.ListVulnerabilities]: async () => callListVulnerabilities(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders), + [METHOD_PATHS.ListHosts]: async () => callListHosts(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders), + [METHOD_PATHS.ListContainers]: async () => callListContainers(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders), + [METHOD_PATHS.ListClusters]: async () => callListClusters(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders), + [METHOD_PATHS.ContainerControl]: async () => { + logFlow('ContainerControl:start', { action: ctx.req?.action, container_id: ctx.req?.container_id }); + const result = await callContainerControl(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders); + logFlow('ContainerControl:done', { action: ctx.req?.action }); + return result; + }, + [METHOD_PATHS.UnblockNetwork]: async () => { + logFlow('UnblockNetwork:start', { container_id: ctx.req?.container_id }); + const result = await callUnblockNetwork(requestWithDefaults(ctx.req), bindings, baseUrl, token, effectiveTimeout, skipTlsVerify, baseHeaders); + logFlow('UnblockNetwork:done', { container_id: ctx.req?.container_id }); + return result; + }, + }; +} + +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 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_PATHS.ListWarnings]: wrapLegacyHandler(ctx, METHOD_PATHS.ListWarnings), + [METHOD_PATHS.ListWarningGroups]: wrapLegacyHandler(ctx, METHOD_PATHS.ListWarningGroups), + [METHOD_PATHS.DisposeWarnings]: wrapLegacyHandler(ctx, METHOD_PATHS.DisposeWarnings), + [METHOD_PATHS.DisposeWarningGroups]: wrapLegacyHandler(ctx, METHOD_PATHS.DisposeWarningGroups), + [METHOD_PATHS.ListVulnerabilities]: wrapLegacyHandler(ctx, METHOD_PATHS.ListVulnerabilities), + [METHOD_PATHS.ListHosts]: wrapLegacyHandler(ctx, METHOD_PATHS.ListHosts), + [METHOD_PATHS.ListContainers]: wrapLegacyHandler(ctx, METHOD_PATHS.ListContainers), + [METHOD_PATHS.ListClusters]: wrapLegacyHandler(ctx, METHOD_PATHS.ListClusters), + [METHOD_PATHS.ContainerControl]: wrapLegacyHandler(ctx, METHOD_PATHS.ContainerControl), + [METHOD_PATHS.UnblockNetwork]: wrapLegacyHandler(ctx, METHOD_PATHS.UnblockNetwork), +}); + +const callSdkHandler = (ctx, path) => { + const handler = registerHandlers({})(path); + return handler(ctx); +}; + +const sdkHandlers = registerHandlers({}); + +export const handlers = { + [FULL_METHODS.ListWarnings]: (ctx) => sdkHandlers[METHOD_PATHS.ListWarnings](ctx), + [FULL_METHODS.ListWarningGroups]: (ctx) => sdkHandlers[METHOD_PATHS.ListWarningGroups](ctx), + [FULL_METHODS.DisposeWarnings]: (ctx) => sdkHandlers[METHOD_PATHS.DisposeWarnings](ctx), + [FULL_METHODS.DisposeWarningGroups]: (ctx) => sdkHandlers[METHOD_PATHS.DisposeWarningGroups](ctx), + [FULL_METHODS.ListVulnerabilities]: (ctx) => sdkHandlers[METHOD_PATHS.ListVulnerabilities](ctx), + [FULL_METHODS.ListHosts]: (ctx) => sdkHandlers[METHOD_PATHS.ListHosts](ctx), + [FULL_METHODS.ListContainers]: (ctx) => sdkHandlers[METHOD_PATHS.ListContainers](ctx), + [FULL_METHODS.ListClusters]: (ctx) => sdkHandlers[METHOD_PATHS.ListClusters](ctx), + [FULL_METHODS.ContainerControl]: (ctx) => sdkHandlers[METHOD_PATHS.ContainerControl](ctx), + [FULL_METHODS.UnblockNetwork]: (ctx) => sdkHandlers[METHOD_PATHS.UnblockNetwork](ctx), +}; + +export const _test = { + CONTROL_ACTIONS, + DISPOSAL_ACTIONS, + errorWithCode, + firstDefined, + fromStruct, + fromStructValue, + hasOwn, + mergedBindings, + normalizeBaseUrl, + parseHeaders, + toPositiveInt, + toStruct, + toStructValue, +}; diff --git a/services/anyi__cloud-native-security/src/service.js b/services/anyi__cloud-native-security/src/service.js new file mode 100644 index 00000000..8f1c18ce --- /dev/null +++ b/services/anyi__cloud-native-security/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./anyi-cloud-native-security.js"; + +export { handlers } from "./anyi-cloud-native-security.js"; + +export const service = defineService({ handlers }); diff --git a/services/anyi__cloud-native-security/test/anyi-cloud-native-security.test.js b/services/anyi__cloud-native-security/test/anyi-cloud-native-security.test.js new file mode 100644 index 00000000..5eaf06fd --- /dev/null +++ b/services/anyi__cloud-native-security/test/anyi-cloud-native-security.test.js @@ -0,0 +1,767 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { + METHOD_PATHS, + FULL_METHODS, + _test, + handlers, + rpcdef, +} from '../src/anyi-cloud-native-security.js'; +import { service } from '../src/service.js'; + +const { + errorWithCode, + firstDefined, + fromStruct, + fromStructValue, + hasOwn, + mergedBindings, + normalizeBaseUrl, + parseHeaders, + toPositiveInt, + toStruct, + toStructValue, + DISPOSAL_ACTIONS, + CONTROL_ACTIONS, +} = _test; + +const originalFetch = globalThis.fetch; +const originalConsoleLog = console.log; + +const buildCtx = (overrides = {}) => ({ + bindings: { + endpoint: 'https://diss.example.com:8543', + token: 'test-token', + ...(overrides.bindings || {}), + }, + config: overrides.config || {}, + secret: overrides.secret || {}, + limits: { timeoutMs: 2000, ...(overrides.limits || {}) }, + meta: { instance_id: 'inst', request_id: 'req', ...(overrides.meta || {}) }, + req: overrides.req || {}, +}); + +const mockResponse = (status, body) => ({ + status, + ok: status >= 200 && status < 300, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + headers: new Map([['content-type', 'application/json']]), +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalConsoleLog; +}); + +// --- Helper unit tests --- + +test('normalizeBaseUrl rejects non-http URLs', () => { + assert.equal(normalizeBaseUrl(''), null); + assert.equal(normalizeBaseUrl('ftp://example.com'), null); + assert.equal(normalizeBaseUrl('https://diss.example.com:8543'), 'https://diss.example.com:8543'); + assert.equal(normalizeBaseUrl('https://diss.example.com:8543/'), 'https://diss.example.com:8543'); + assert.equal(normalizeBaseUrl('http://diss.example.com'), 'http://diss.example.com'); +}); + +test('toPositiveInt handles various inputs', () => { + assert.equal(toPositiveInt(undefined), null); + assert.equal(toPositiveInt(null), null); + assert.equal(toPositiveInt(10), 10); + assert.equal(toPositiveInt(0), 0); + assert.equal(toPositiveInt(-1), -1); + assert.equal(toPositiveInt(1.5), null); + assert.equal(toPositiveInt({ value: 5 }), 5); + assert.equal(toPositiveInt('abc'), null); + assert.equal(toPositiveInt(NaN), null); + assert.equal(toPositiveInt({ value: 'x' }), null); + assert.equal(toPositiveInt({}), null); +}); + +test('firstDefined returns first non-null/undefined', () => { + assert.equal(firstDefined(undefined, null, 'hello'), 'hello'); + assert.equal(firstDefined('first', 'second'), 'first'); + assert.equal(firstDefined(undefined, undefined, 0), 0); + assert.equal(firstDefined(), undefined); +}); + +test('mergedBindings merges config, secret, and bindings', () => { + const ctx = { config: { endpoint: 'a' }, secret: { token: 'b' }, bindings: { extra: 'c' } }; + const result = mergedBindings(ctx); + assert.equal(result.endpoint, 'a'); + assert.equal(result.token, 'b'); + assert.equal(result.extra, 'c'); +}); + +test('mergedBindings handles empty ctx', () => { + const result = mergedBindings(); + assert.deepEqual(result, {}); + const result2 = mergedBindings({}); + assert.deepEqual(result2, {}); +}); + +test('parseHeaders handles various inputs', () => { + assert.deepEqual(parseHeaders(undefined), {}); + assert.deepEqual(parseHeaders(null), {}); + assert.deepEqual(parseHeaders(''), {}); + assert.deepEqual(parseHeaders({ 'X-Custom': 'val' }), { 'X-Custom': 'val' }); + assert.deepEqual(parseHeaders('{"X-Custom":"val"}'), { 'X-Custom': 'val' }); + assert.deepEqual(parseHeaders('invalid'), {}); + assert.deepEqual(parseHeaders([]), {}); + assert.deepEqual(parseHeaders(42), {}); +}); + +test('hasOwn checks property existence', () => { + assert.equal(hasOwn({ a: 1 }, 'a'), true); + assert.equal(hasOwn({ a: 1 }, 'b'), false); + assert.equal(hasOwn(null, 'a'), false); + assert.equal(hasOwn(undefined, 'a'), false); +}); + +test('errorWithCode creates GrpcError with legacyCode', () => { + const err = errorWithCode('INVALID_ARGUMENT', 'test error'); + assert.ok(err instanceof GrpcError); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /INVALID_ARGUMENT: test error/); +}); + +test('toStruct handles null/undefined', () => { + assert.equal(toStruct(undefined), undefined); + assert.equal(toStruct(null), undefined); +}); + +test('toStruct / fromStruct round-trip', () => { + const obj = { name: 'test', count: 42, active: true, items: [1, 2], nested: { key: 'val' } }; + const struct = toStruct(obj); + assert.ok(struct.fields); + const roundTripped = fromStruct(struct); + assert.equal(roundTripped.name, 'test'); + assert.equal(roundTripped.count, 42); + assert.equal(roundTripped.active, true); + assert.deepEqual(roundTripped.items, [1, 2]); + assert.equal(roundTripped.nested.key, 'val'); +}); + +test('fromStructValue handles all types', () => { + assert.equal(fromStructValue({ nullValue: 'NULL_VALUE' }), null); + assert.equal(fromStructValue({ stringValue: 'hello' }), 'hello'); + assert.equal(fromStructValue({ numberValue: 42 }), 42); + assert.equal(fromStructValue({ boolValue: true }), true); + assert.deepEqual(fromStructValue({ listValue: { values: [{ stringValue: 'a' }] } }), ['a']); + assert.equal(fromStructValue(null), null); + assert.equal(fromStructValue(undefined), null); + assert.equal(fromStructValue({}), null); +}); + +test('toStructValue handles all types', () => { + assert.deepEqual(toStructValue(null), { nullValue: 'NULL_VALUE' }); + assert.deepEqual(toStructValue(undefined), { nullValue: 'NULL_VALUE' }); + assert.deepEqual(toStructValue('hello'), { stringValue: 'hello' }); + assert.deepEqual(toStructValue(42), { numberValue: 42 }); + assert.deepEqual(toStructValue(true), { boolValue: true }); + assert.deepEqual(toStructValue([1, 'a']), { listValue: { values: [{ numberValue: 1 }, { stringValue: 'a' }] } }); + assert.ok(toStructValue({ a: 1 }).structValue); +}); + +test('DISPOSAL_ACTIONS and CONTROL_ACTIONS are correct', () => { + assert.deepEqual(DISPOSAL_ACTIONS, ['isolation', 'pause', 'stop', 'kill']); + assert.deepEqual(CONTROL_ACTIONS, ['resume', 'start', 'activate', 'deactivate']); +}); + +test('rpcdef rejects missing endpoint', () => { + assert.throws( + () => rpcdef(buildCtx({ bindings: { endpoint: '', token: 't' } })), + (err) => err instanceof GrpcError && err.legacyCode === 'INVALID_ARGUMENT', + ); +}); + +test('service defines handlers', () => { + assert.ok(service.handlers); + for (const name of Object.keys(FULL_METHODS)) { + assert.ok(typeof service.handlers[FULL_METHODS[name]] === 'function', `handler ${name} exists`); + } +}); + +// --- ListWarnings --- + +test('ListWarnings sends request and returns response', async () => { + globalThis.fetch = async (url, init) => { + assert.ok(url.includes('/api/v1/securitylog/warninginfo')); + assert.ok(url.includes('from=0')); + assert.ok(url.includes('limit=20')); + assert.equal(init.method, 'POST'); + assert.ok(init.headers['Authorization']); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 1, items: [{ Id: 1 }] } }); + }; + + const result = await rpcdef(buildCtx({ req: { from: 0, limit: 20 } }))[METHOD_PATHS.ListWarnings](); + assert.equal(result.code, 200); + assert.equal(result.message, 'ok'); + assert.ok(result.data.fields); +}); + +test('ListWarnings uses default from/limit when not specified', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('from=0')); + assert.ok(url.includes('limit=20')); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](); + assert.equal(result.code, 200); +}); + +test('ListWarnings passes filter as body', async () => { + globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + assert.equal(body.Severity, 'high'); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const filter = toStruct({ Severity: 'high' }); + const result = await rpcdef(buildCtx({ req: { filter } }))[METHOD_PATHS.ListWarnings](); + assert.equal(result.code, 200); +}); + +test('ListWarnings handles empty response body', async () => { + globalThis.fetch = async () => mockResponse(200, ''); + const result = await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](); + assert.equal(result.code, 200); +}); + +test('ListWarnings handles non-JSON response', async () => { + globalThis.fetch = async () => ({ status: 200, ok: true, text: async () => 'not json', headers: new Map([['content-type', 'text/plain']]) }); + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'UNKNOWN', + ); +}); + +// --- DisposeWarnings --- + +test('DisposeWarnings rejects missing action', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: { action: '' } }))[METHOD_PATHS.DisposeWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'INVALID_ARGUMENT', + ); +}); + +test('DisposeWarnings rejects invalid action', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: { action: 'explode' } }))[METHOD_PATHS.DisposeWarnings](), + /action must be one of/, + ); +}); + +test('DisposeWarnings succeeds with valid action', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + assert.ok(url.includes('/api/v1/securitylog/warninginfo/disposal')); + const body = JSON.parse(init.body); + assert.equal(body.Action, 'isolation'); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: { action: 'isolation' } }))[METHOD_PATHS.DisposeWarnings](); + assert.equal(result.code, 200); +}); + +test('DisposeWarnings passes account and whitelist', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + assert.equal(body.Account, 'tenant-1'); + assert.ok(Array.isArray(body.WarningInfo)); + assert.ok(Array.isArray(body.WarningWhiteList)); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ + req: { + action: 'stop', + account: 'tenant-1', + warnings: [toStructValue({ Id: 1 })], + whitelist: [toStructValue({ Id: 2 })], + }, + }))[METHOD_PATHS.DisposeWarnings](); + assert.equal(result.code, 200); +}); + +test('DisposeWarnings with all valid actions', async () => { + console.log = () => {}; + for (const action of DISPOSAL_ACTIONS) { + globalThis.fetch = async () => mockResponse(200, { code: 200, message: 'ok', data: null }); + const result = await rpcdef(buildCtx({ req: { action } }))[METHOD_PATHS.DisposeWarnings](); + assert.equal(result.code, 200, `${action} succeeds`); + } +}); + +// --- DisposeWarningGroups --- + +test('DisposeWarningGroups succeeds', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + assert.ok(url.includes('/api/v1/securitylog/warninginfogroup/disposal')); + const body = JSON.parse(init.body); + assert.equal(body.Action, 'kill'); + assert.equal(body.NsNetworkpolicy, true); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: { action: 'kill', ns_networkpolicy: true } }))[METHOD_PATHS.DisposeWarningGroups](); + assert.equal(result.code, 200); +}); + +test('DisposeWarningGroups rejects missing action', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.DisposeWarningGroups](), + (err) => err instanceof GrpcError && err.legacyCode === 'INVALID_ARGUMENT', + ); +}); + +test('DisposeWarningGroups rejects invalid action', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: { action: 'invalid' } }))[METHOD_PATHS.DisposeWarningGroups](), + /action must be one of/, + ); +}); + +test('DisposeWarningGroups passes warning_groups and whitelist', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + assert.ok(Array.isArray(body.WarningInfo)); + assert.ok(Array.isArray(body.WarningWhiteList)); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ + req: { + action: 'pause', + warning_groups: [toStructValue({ Id: 10 })], + whitelist: [toStructValue({ Id: 20 })], + }, + }))[METHOD_PATHS.DisposeWarningGroups](); + assert.equal(result.code, 200); +}); + +// --- ContainerControl --- + +test('ContainerControl rejects missing action', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: { container_id: 'ctr-1' } }))[METHOD_PATHS.ContainerControl](), + (err) => err instanceof GrpcError && err.legacyCode === 'INVALID_ARGUMENT', + ); +}); + +test('ContainerControl rejects missing container_id', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: { action: 'start' } }))[METHOD_PATHS.ContainerControl](), + /container_id is required/, + ); +}); + +test('ContainerControl rejects invalid action', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: { action: 'explode', container_id: 'ctr-1' } }))[METHOD_PATHS.ContainerControl](), + /action must be one of/, + ); +}); + +test('ContainerControl succeeds with valid action', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + assert.ok(url.includes('/api/v1/system/respcenter/operation')); + const body = JSON.parse(init.body); + assert.equal(body.Action, 'deactivate'); + assert.equal(body.ContainerId, 'ctr-1'); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: { action: 'deactivate', container_id: 'ctr-1' } }))[METHOD_PATHS.ContainerControl](); + assert.equal(result.code, 200); +}); + +test('ContainerControl passes optional fields', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + assert.equal(body.ContainerName, 'my-container'); + assert.equal(body.HostId, 'host-1'); + assert.equal(body.ClusterId, 'cluster-1'); + assert.equal(body.Analysis, 'suspicious activity'); + assert.equal(body.ProcessNote, 'suspicious activity'); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ + req: { action: 'start', container_id: 'ctr-1', container_name: 'my-container', host_id: 'host-1', cluster_id: 'cluster-1', analysis: 'suspicious activity' }, + }))[METHOD_PATHS.ContainerControl](); + assert.equal(result.code, 200); +}); + +test('ContainerControl with all valid actions', async () => { + console.log = () => {}; + for (const action of CONTROL_ACTIONS) { + globalThis.fetch = async () => mockResponse(200, { code: 200, message: 'ok', data: null }); + const result = await rpcdef(buildCtx({ req: { action, container_id: 'ctr-1' } }))[METHOD_PATHS.ContainerControl](); + assert.equal(result.code, 200, `${action} succeeds`); + } +}); + +// --- UnblockNetwork --- + +test('UnblockNetwork rejects missing container_id', async () => { + globalThis.fetch = async () => { throw new Error('should not fetch'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.UnblockNetwork](), + /container_id is required/, + ); +}); + +test('UnblockNetwork succeeds', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + assert.ok(url.includes('/api/v1/system/respcenter/unblock-network')); + const body = JSON.parse(init.body); + assert.equal(body.ContainerId, 'ctr-1'); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: { container_id: 'ctr-1' } }))[METHOD_PATHS.UnblockNetwork](); + assert.equal(result.code, 200); +}); + +test('UnblockNetwork passes optional fields', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + assert.equal(body.HostId, 'host-1'); + assert.equal(body.ClusterId, 'cluster-1'); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: { container_id: 'ctr-1', host_id: 'host-1', cluster_id: 'cluster-1' } }))[METHOD_PATHS.UnblockNetwork](); + assert.equal(result.code, 200); +}); + +// --- ListHosts --- + +test('ListHosts uses defaultUser when not specified', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('user=admin')); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 0, items: [] } }); + }; + + const result = await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListHosts](); + assert.equal(result.code, 200); +}); + +test('ListHosts uses user from request', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('user=custom-user')); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 0, items: [] } }); + }; + + const result = await rpcdef(buildCtx({ req: { user: 'custom-user' } }))[METHOD_PATHS.ListHosts](); + assert.equal(result.code, 200); +}); + +test('ListHosts uses user from config defaultUser', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('user=config-user')); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 0, items: [] } }); + }; + + const result = await rpcdef(buildCtx({ bindings: { defaultUser: 'config-user' }, req: {} }))[METHOD_PATHS.ListHosts](); + assert.equal(result.code, 200); +}); + +// --- ListContainers --- + +test('ListContainers returns response', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('/api/v1/containers/')); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 1, items: [{ Id: 'ctr-1' }] } }); + }; + + const result = await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListContainers](); + assert.equal(result.code, 200); +}); + +// --- ListClusters --- + +test('ListClusters returns response', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('/api/v1/k8s/clusters')); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 0, items: [] } }); + }; + + const result = await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListClusters](); + assert.equal(result.code, 200); +}); + +// --- ListVulnerabilities --- + +test('ListVulnerabilities returns response', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('/api/v1/securitylog/vulnerabilitiesscan')); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 0, items: [] } }); + }; + + const result = await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListVulnerabilities](); + assert.equal(result.code, 200); +}); + +// --- ListWarningGroups --- + +test('ListWarningGroups returns response', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('/api/v1/securitylog/warninginfogroup')); + return mockResponse(200, { code: 200, message: 'ok', data: { total: 0, items: [] } }); + }; + + const result = await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarningGroups](); + assert.equal(result.code, 200); +}); + +// --- HTTP Error mapping --- + +test('401 maps to UNAUTHENTICATED', async () => { + globalThis.fetch = async () => mockResponse(401, 'unauthorized'); + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'UNAUTHENTICATED', + ); +}); + +test('403 maps to PERMISSION_DENIED', async () => { + globalThis.fetch = async () => mockResponse(403, 'forbidden'); + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'PERMISSION_DENIED', + ); +}); + +test('500 maps to UNAVAILABLE', async () => { + globalThis.fetch = async () => mockResponse(500, 'internal error'); + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'UNAVAILABLE', + ); +}); + +test('network error maps to UNAVAILABLE', async () => { + globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); }; + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'UNAVAILABLE', + ); +}); + +test('400 maps to FAILED_PRECONDITION', async () => { + globalThis.fetch = async () => mockResponse(400, 'bad request'); + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'FAILED_PRECONDITION', + ); +}); + +test('422 maps to FAILED_PRECONDITION', async () => { + globalThis.fetch = async () => mockResponse(422, 'unprocessable'); + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'FAILED_PRECONDITION', + ); +}); + +test('502 maps to UNAVAILABLE', async () => { + globalThis.fetch = async () => mockResponse(502, 'bad gateway'); + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'UNAVAILABLE', + ); +}); + +test('network error with cause maps to UNAVAILABLE', async () => { + const err = new Error('fetch failed'); + err.cause = { message: 'connection refused' }; + globalThis.fetch = async () => { throw err; }; + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'UNAVAILABLE', + ); +}); + +test('AbortError maps to DEADLINE_EXCEEDED', async () => { + const err = new DOMException('The operation was aborted', 'AbortError'); + globalThis.fetch = async () => { throw err; }; + await assert.rejects( + () => rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](), + (err) => err instanceof GrpcError && err.legacyCode === 'DEADLINE_EXCEEDED', + ); +}); + +// --- handlers export --- + +test('handlers object has all 10 methods', () => { + const handlerKeys = Object.keys(handlers); + assert.equal(handlerKeys.length, 10); + for (const name of Object.keys(FULL_METHODS)) { + assert.ok(handlers[FULL_METHODS[name]], `handlers has ${name}`); + } +}); + +// --- handlers invocation via SDK-style context --- + +test('handlers.ListWarnings works with SDK context', async () => { + globalThis.fetch = async () => mockResponse(200, { code: 200, message: 'ok', data: { total: 0, items: [] } }); + const ctx = { + config: { endpoint: 'https://diss.example.com:8543' }, + secret: { token: 'test-token' }, + req: {}, + bindings: {}, + limits: {}, + meta: {}, + }; + const result = await handlers[FULL_METHODS.ListWarnings](ctx); + assert.equal(result.code, 200); +}); + +test('handlers.ContainerControl validates via SDK context', async () => { + const ctx = { + config: { endpoint: 'https://diss.example.com:8543' }, + secret: { token: 'test-token' }, + req: { action: 'start' }, + bindings: {}, + limits: {}, + meta: {}, + }; + await assert.rejects( + () => handlers[FULL_METHODS.ContainerControl](ctx), + /container_id is required/, + ); +}); + +// --- skipTlsVerify binding --- + +test('skipTlsVerify is read from bindings and uses undici dispatcher', async () => { + let capturedDispatcher; + const origImport = globalThis[Symbol.for('undici')]; + // When skipTlsVerify is true, fetchDiss imports undici and uses a dispatcher + // We verify the code path by mocking globalThis.fetch to capture the dispatcher + let capturedOpts; + globalThis.fetch = async (url, opts) => { + capturedOpts = opts; + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + // skipTlsVerify=true path will dynamically import undici Agent + // For unit test we just verify it doesn't crash and still returns data + const result = await rpcdef(buildCtx({ bindings: { skipTlsVerify: true }, req: {} }))[METHOD_PATHS.ListWarnings](); + assert.equal(result.code, 200); +}); + +test('skipTlsVerify false by default uses native fetch', async () => { + let capturedOpts; + globalThis.fetch = async (url, opts) => { + capturedOpts = opts; + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + await rpcdef(buildCtx({ req: {} }))[METHOD_PATHS.ListWarnings](); + assert.equal(capturedOpts.dispatcher, undefined); +}); + +// --- timeout from bindings --- + +test('timeoutMs from bindings sets up AbortController signal', async () => { + let capturedOpts; + globalThis.fetch = async (url, opts) => { + capturedOpts = opts; + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + await rpcdef(buildCtx({ bindings: { timeoutMs: 5000 }, req: {} }))[METHOD_PATHS.ListWarnings](); + assert.ok(capturedOpts.signal instanceof AbortSignal); +}); + +// --- token from secret --- + +test('token from secret is used when bindings has no token', async () => { + let capturedHeaders; + globalThis.fetch = async (url, opts) => { + capturedHeaders = opts.headers; + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + // Pass explicit empty token in bindings so secret.token takes precedence via mergedBindings + const ctx = { + bindings: { endpoint: 'https://diss.example.com:8543' }, + config: {}, + secret: { token: 'secret-token' }, + limits: { timeoutMs: 2000 }, + meta: {}, + req: {}, + }; + await rpcdef(ctx)[METHOD_PATHS.ListWarnings](); + assert.ok(capturedHeaders['Authorization'].includes('secret-token')); +}); + +// --- endpoint aliases --- + +test('baseUrl alias works for endpoint', async () => { + globalThis.fetch = async () => mockResponse(200, { code: 200, message: 'ok', data: null }); + const result = await rpcdef(buildCtx({ bindings: { endpoint: '', baseUrl: 'https://alias.example.com:8543' }, req: {} }))[METHOD_PATHS.ListWarnings](); + assert.equal(result.code, 200); +}); + +// --- no token scenario --- + +test('requests without token omit Authorization header', async () => { + let capturedHeaders; + globalThis.fetch = async (url, opts) => { + capturedHeaders = opts.headers; + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + await rpcdef(buildCtx({ bindings: { endpoint: 'https://diss.example.com:8543', token: '' }, req: {} }))[METHOD_PATHS.ListWarnings](); + assert.equal(capturedHeaders['Authorization'], undefined); +}); + +// --- from/limit with value wrappers --- + +test('from/limit handle value wrapper objects', async () => { + globalThis.fetch = async (url) => { + assert.ok(url.includes('from=5')); + assert.ok(url.includes('limit=50')); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: { from: { value: 5 }, limit: { value: 50 } } }))[METHOD_PATHS.ListWarnings](); + assert.equal(result.code, 200); +}); + +// --- action case-insensitive --- + +test('DisposeWarnings action is case-insensitive', async () => { + console.log = () => {}; + globalThis.fetch = async (url, init) => { + const body = JSON.parse(init.body); + assert.equal(body.Action, 'isolation'); + return mockResponse(200, { code: 200, message: 'ok', data: null }); + }; + + const result = await rpcdef(buildCtx({ req: { action: 'ISOLATION' } }))[METHOD_PATHS.DisposeWarnings](); + assert.equal(result.code, 200); +}); diff --git a/services/anyi__cloud-native-security/test/mock_upstream.js b/services/anyi__cloud-native-security/test/mock_upstream.js new file mode 100644 index 00000000..c77be1db --- /dev/null +++ b/services/anyi__cloud-native-security/test/mock_upstream.js @@ -0,0 +1,139 @@ +import http from 'node:http'; +import { URL } from 'node:url'; + +const HTTP_PORT = Number(process.env.HTTP_PORT || 19092); +const FAIL_RATE = Number(process.env.FAIL_RATE || 0); +const VERBOSE = process.env.LOG_VERBOSE === '1'; + +const log = (...args) => console.log('[diss-mock]', ...args); + +const sendJson = (res, status, payload) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(payload)); +}; + +const readBody = (req) => + new Promise((resolve, reject) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; if (raw.length > 1_000_000) reject(new Error('payload too large')); }); + req.on('end', () => { if (!raw.trim()) { resolve({}); return; } try { resolve(JSON.parse(raw)); } catch (err) { reject(err); } }); + req.on('error', reject); + }); + +const shouldInjectError = () => FAIL_RATE > 0 && Math.random() * 100 < FAIL_RATE; + +const mockData = { + warnings: { + code: 200, message: 'ok', + data: { total: 2, items: [ + { Id: 1, Description: 'Suspicious process', Severity: 'high', ContainerName: 'web-app', HostName: 'node-1', CreateTime: 1700000000 }, + { Id: 2, Description: 'Privilege escalation', Severity: 'critical', ContainerName: 'db-app', HostName: 'node-2', CreateTime: 1700000001 }, + ] }, + }, + warningGroups: { + code: 200, message: 'ok', + data: { total: 1, items: [ + { Id: 10, Description: 'Grouped alerts', Amount: 5, EvtType: 'process', Severity: 'high' }, + ] }, + }, + vulnerabilities: { + code: 200, message: 'ok', + data: { total: 1, items: [ + { Id: 1, ImageName: 'nginx:latest', Severity: 'high', Target: 'nginx', Vulnerabilities: [{ CVEId: 'CVE-2024-0001', Severity: 'high' }] }, + ] }, + }, + hosts: { + code: 200, message: 'ok', + data: { total: 1, items: [ + { Id: 'host-1', HostName: 'node-1', IPv4: '10.0.0.1', AgentStatus: true }, + ] }, + }, + containers: { + code: 200, message: 'ok', + data: { total: 1, items: [ + { Id: 'ctr-1', Name: 'web-app', ImageName: 'nginx:latest', HostName: 'node-1' }, + ] }, + }, + clusters: { + code: 200, message: 'ok', + data: { total: 1, items: [ + { Id: 'cluster-1', Name: 'prod-cluster', Version: 'v1.28.0' }, + ] }, + }, + disposal: { code: 200, message: 'ok', data: null }, + containerControl: { code: 200, message: 'ok', data: null }, + unblockNetwork: { code: 200, message: 'ok', data: null }, +}; + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://localhost:${HTTP_PORT}`); + const path = url.pathname; + + if (shouldInjectError()) { + sendJson(res, 500, { code: 500, message: 'simulated server error', data: null }); + return; + } + + let body; + try { body = await readBody(req); } catch { sendJson(res, 400, { code: 400, message: 'invalid json', data: null }); return; } + + const authHeader = (req.headers['authorization'] || '').trim(); + if (!authHeader) { + sendJson(res, 401, { code: 401, message: 'unauthorized', data: null }); + return; + } + const token = authHeader; + if (token === 'invalid-token') { + sendJson(res, 403, { code: 403, message: 'forbidden', data: null }); + return; + } + + if (VERBOSE) log('request', { method: req.method, path, body }); + + if (path === '/api/v1/securitylog/warninginfo' && req.method === 'POST') { + sendJson(res, 200, mockData.warnings); + } else if (path === '/api/v1/securitylog/warninginfogroup' && req.method === 'POST') { + sendJson(res, 200, mockData.warningGroups); + } else if (path === '/api/v1/securitylog/warninginfo/disposal' && req.method === 'POST') { + if (!body.Action || !['isolation', 'pause', 'stop', 'kill'].includes(body.Action)) { + sendJson(res, 400, { code: 400, message: 'invalid action', data: null }); + } else { + sendJson(res, 200, mockData.disposal); + } + } else if (path === '/api/v1/securitylog/warninginfogroup/disposal' && req.method === 'POST') { + if (!body.Action) { + sendJson(res, 400, { code: 400, message: 'action required', data: null }); + } else { + sendJson(res, 200, mockData.disposal); + } + } else if (path === '/api/v1/securitylog/vulnerabilitiesscan' && req.method === 'POST') { + sendJson(res, 200, mockData.vulnerabilities); + } else if (path === '/api/v1/asset/hosts/' && req.method === 'POST') { + sendJson(res, 200, mockData.hosts); + } else if (path === '/api/v1/containers/' && req.method === 'POST') { + sendJson(res, 200, mockData.containers); + } else if (path === '/api/v1/k8s/clusters' && req.method === 'POST') { + sendJson(res, 200, mockData.clusters); + } else if (path === '/api/v1/system/respcenter/operation' && req.method === 'POST') { + if (!body.Action || !['resume', 'start', 'activate', 'deactivate'].includes(body.Action)) { + sendJson(res, 400, { code: 400, message: 'invalid action', data: null }); + } else if (!body.ContainerId) { + sendJson(res, 400, { code: 400, message: 'container_id required', data: null }); + } else { + sendJson(res, 200, mockData.containerControl); + } + } else if (path === '/api/v1/system/respcenter/unblock-network' && req.method === 'POST') { + if (!body.ContainerId) { + sendJson(res, 400, { code: 400, message: 'container_id required', data: null }); + } else { + sendJson(res, 200, mockData.unblockNetwork); + } + } else { + sendJson(res, 404, { code: 404, message: 'not found', data: null }); + } +}); + +server.listen(HTTP_PORT, () => { + log(`listening on http://127.0.0.1:${HTTP_PORT}`); + log('fail rate:', FAIL_RATE); +}); diff --git a/services/bin/anyi-cloud-native-security.js b/services/bin/anyi-cloud-native-security.js new file mode 100644 index 00000000..7766ceb7 --- /dev/null +++ b/services/bin/anyi-cloud-native-security.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../anyi__cloud-native-security/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../anyi__cloud-native-security/bin/anyi-cloud-native-security.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 77449572..05540f34 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -265,6 +265,10 @@ const services = { entryFile: "../filigran__opencti/bin/opencti.js", serviceModule: "../filigran__opencti/src/service.js", }, + "anyi-cloud-native-security": { + entryFile: "../anyi__cloud-native-security/bin/anyi-cloud-native-security.js", + serviceModule: "../anyi__cloud-native-security/src/service.js", + }, }; const serviceNames = Object.keys(services); diff --git a/services/package.json b/services/package.json index d6243de4..66725f70 100644 --- a/services/package.json +++ b/services/package.json @@ -69,7 +69,11 @@ "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", "wd-k01": "bin/wd-k01.js", - "opencti": "bin/opencti.js" + "tencent-bh": "bin/tencent-bh.js", + "alibaba-sas": "bin/alibaba-sas.js", + "misp": "bin/misp.js", + "opencti": "bin/opencti.js", + "anyi-cloud-native-security": "bin/anyi-cloud-native-security.js" }, "files": [ "bin/aliyun-waf3.js", @@ -137,6 +141,7 @@ "bin/wd-k01.js", "bin/opencti.js", "bin/wangsu-label-ip.js", + "bin/anyi-cloud-native-security.js", "alibaba-cloud__simple-application-server-firewall", "chaitin__cloudatlas", "chaitin__dsensor_ds-s_h_40-25.07.001", @@ -201,6 +206,7 @@ "filigran__opencti", "fofa__network-space-mapper", "wangsu__label-ip", + "anyi__cloud-native-security", "scripts", "aliyun__waf3", "bin/octobus-tentacles.js"