diff --git a/services/bin/cloudwalker.js b/services/bin/cloudwalker.js new file mode 100755 index 00000000..f6120cab --- /dev/null +++ b/services/bin/cloudwalker.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__cloudwalker/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../chaitin__cloudwalker/bin/cloudwalker.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 77449572..f0b2906c 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -25,6 +25,10 @@ const services = { entryFile: "../chaitin__cloudatlas/bin/cloudatlas.js", serviceModule: "../chaitin__cloudatlas/src/service.js", }, + "cloudwalker": { + entryFile: "../chaitin__cloudwalker/bin/cloudwalker.js", + serviceModule: "../chaitin__cloudwalker/src/service.js", + }, "das-gateway-v3": { entryFile: "../das__gateway_v3/bin/das-gateway-v3.js", serviceModule: "../das__gateway_v3/src/service.js", diff --git a/services/chaitin__cloudwalker/README.md b/services/chaitin__cloudwalker/README.md new file mode 100644 index 00000000..789a864e --- /dev/null +++ b/services/chaitin__cloudwalker/README.md @@ -0,0 +1,158 @@ +# CloudWalker OctoBus Service + +长亭科技牧云(CloudWalker)集群与漏洞只读查询适配器。 + +```bash +octobus service import cloudwalker ./services/chaitin__cloudwalker +``` + +## Package Files + +- `service.json` — OctoBus 服务清单,声明运行模式与 proto 入口。 +- `proto/cloudwalker.proto` — gRPC API 定义,6 个 unary 方法。 +- `config.schema.json` — 非密配置:`baseUrl`、`referer`。 +- `secret.schema.json` — 密钥配置:`token`(必填)、`cookie`(可选)。 +- `src/cloudwalker.js` — 上游 REST API 请求映射、响应归一化、错误分类。 +- `src/service.js` — OctoBus SDK `defineService` 封装。 +- `bin/cloudwalker.js` — 服务本地可执行入口。 +- `test/cloudwalker.test.js` — node:test 覆盖:请求映射、响应归一化、错误分类、SDK handler。 +- `test/cloudwalker-client.test.js` — 扩展客户端测试:HTML 响应检测、fallback 逻辑、零值保留。 +- `test/mock_upstream.js` — 本地 mock 上游,覆盖成功 / 认证失败 / 5xx / 超时。 + +## 支持版本 + +- **目标产品**: 长亭科技牧云 CloudWalker +- **适配版本**: VM-S10-26.06.002 + +## 认证方式 + +Token + Browser Session Cookie 组合认证: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `token` | 是 | API Token,通过牧云控制台「个人中心 → API Token」生成 | +| `cookie` | 视环境 | 浏览器 Session Cookie;Demo 环境需要,正式环境可能仅需 token | + +认证头同时发送三种格式以兼容不同牧云版本:`Authorization: Bearer `、`token: `、`x-auth-token: `。 + +## Configuration + +```json +{ + "baseUrl": "https://cnapp.demo.chaitin.cn", + "referer": "https://cnapp.demo.chaitin.cn/profile/apitoken" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `baseUrl` | string | 是 | 牧云 API 基础 URL | +| `referer` | string | 否 | 浏览器 Referer 头,部分环境需要 | + +Secret: + +```json +{ + "token": "TMCpan#2VB44wwF...", + "cookie": "_c_WBKFRo=...; veinmind=..." +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `token` | string | 是 | API Token | +| `cookie` | string | 否 | 浏览器 Session Cookie(含 httpOnly 的 veinmind) | + +Handler 优先使用 `ctx.config` / `ctx.secret`,其次回退到环境变量 `CLOUDWALKER_BASE_URL`、`CLOUDWALKER_TOKEN`、`CLOUDWALKER_COOKIE`、`CLOUDWALKER_REFERER`。 + +## RPC Methods + +| 方法 | 上游 API | 说明 | +|------|----------|------| +| `Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusters` | `GET /cluster/cluster_list` | 查询集群列表,支持 name / status 过滤与分页 | +| `Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterInfo` | `GET /cluster/cluster_info` | 按集群 ID 获取详情 | +| `Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusterVulnEvents` | `GET /cluster_vuln/vuln_event_list` | 查询集群漏洞事件列表,支持 CVE / CNVD / risk / state 等过滤 | +| `Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterVulnEvent` | `GET /cluster_vuln/vuln_event_info` | 按事件 ID 获取集群漏洞详情 | +| `Chaitin_CloudWalker.Chaitin_CloudWalker/ListMicroserviceVulnEvents` | `GET /cluster_microservice/vuln_event_list` | 查询微服务漏洞事件列表,支持 serviceName / clusterName 等过滤 | +| `Chaitin_CloudWalker.Chaitin_CloudWalker/GetMicroserviceVulnEvent` | `GET /cluster_microservice/vuln_event_info` | 按事件 ID 获取微服务漏洞详情 | + +## Behavior Notes + +- **Proto3 零值参数跳过**:gRPC 反序列化后 int32 字段(如 `status`、`order`)默认值为 0,`appendScalarQuery` 主动跳过值为 0 的数字参数,避免发送 `status=0` 导致上游 "Validation Failed"。 +- **Fallback 过滤**:`clusterName`、`cnvd`、`cnnvd` 等参数在 Demo 上游不稳定,客户端先尝试直接查询,失败后自动降级为全量扫描 + 客户端过滤。 +- **HTML 响应检测**:Session 过期时上游返回 200 + HTML 页面而非 JSON,客户端识别 `` 并触发 fallback 或抛出 `UNAVAILABLE` 错误。 +- **snake_case → camelCase**:上游 API 返回 snake_case 字段,客户端自动转换为 camelCase 以匹配 proto JSON 格式。 +- **非 JSON 内容守卫**:响应 Content-Type 非 JSON 时直接拒绝,防止将 HTML 误解析为业务数据。 + +### 错误映射 + +| 上游 HTTP 状态 | gRPC 状态码 | +|----------------|-------------| +| 400 | `INVALID_ARGUMENT` | +| 401 | `UNAUTHENTICATED` | +| 403 | `PERMISSION_DENIED` | +| 404 | `NOT_FOUND` | +| 409 | `ALREADY_EXISTS` | +| 412 | `FAILED_PRECONDITION` | +| 429 | `RESOURCE_EXHAUSTED` | +| 504 | `DEADLINE_EXCEEDED` | +| 其他 5xx / 网络错误 | `UNAVAILABLE` | + +## Risk Boundary + +- **风险等级**: `read-only` +- **写操作**: 本版本无写操作,所有 6 个方法均为只读查询。 +- 本适配器不修改牧云平台任何数据。 + +## Suggested Capset + +- `prod` — 生产环境只读查询 + +```bash +octobus capset create prod +octobus capset add-instance prod --service cloudwalker --instance cloudwalker-demo +``` + +## Known Limitations + +- 第一版仅覆盖 6 个只读查询能力,不支持漏洞处置、集群纳管等写操作。 +- Demo 环境的 `clusterName`、`cnvd`、`cnnvd` 过滤参数不稳定,需通过 fallback 机制兜底。 +- Cookie 中的 `veinmind` 为 httpOnly,需通过浏览器 CDP 获取,无法从 `document.cookie` 读取。 +- `ListClustersRequest.status` 为 int32 类型,proto3 默认值为 0,不支持通过 0 值筛选集群状态;需传正整数(如 1=运行中、2=异常)。 +- Fallback 模式下(`clusterName`/`cnvd`/`cnnvd` 触发客户端过滤),`collectFilteredItems` 在命中 `pageSize` 数量后提前中断当前页扫描,返回的 `nextPageToken` 为上游最后一页的全局 token,可能跳过当前页剩余未扫描项。建议一次性拉取较大 `pageSize` 以减少分页遗漏风险。 + +## Local Checks + +```bash +cd services +npm run validate -- --service-dir chaitin__cloudwalker +npm test -- --service-dir chaitin__cloudwalker +npm run pack:check +``` + +## OctoBus Runtime Validation + +```bash +# 导入服务 +octobus service import cloudwalker ./services/chaitin__cloudwalker + +# 创建实例 +octobus instance create cloudwalker-demo \ + --service cloudwalker \ + --config-json '{"baseUrl":"https://cnapp.demo.chaitin.cn","referer":"https://cnapp.demo.chaitin.cn/profile/apitoken"}' \ + --secret-json '{"token":"","cookie":""}' + +# 创建 capset 并绑定实例 +octobus capset create prod +octobus capset add-instance prod --service cloudwalker --instance cloudwalker-demo + +# 验证方法注册 +octobus catalog prod --all --json + +# 调用测试 +curl -X POST http://127.0.0.1:9000/capsets/prod/connect/cloudwalker-demo/CloudWalker.CloudWalker/ListClusters \ + -H 'Content-Type: application/json' \ + -d '{"pageSize": 5}' +``` + +**注意**:方法路径格式为 `Chaitin_CloudWalker.Chaitin_CloudWalker/`,与 proto `package Chaitin_CloudWalker` 保持一致。 diff --git a/services/chaitin__cloudwalker/bin/cloudwalker.js b/services/chaitin__cloudwalker/bin/cloudwalker.js new file mode 100755 index 00000000..5f9a3061 --- /dev/null +++ b/services/chaitin__cloudwalker/bin/cloudwalker.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__cloudwalker/config.schema.json b/services/chaitin__cloudwalker/config.schema.json new file mode 100644 index 00000000..cb5e9acf --- /dev/null +++ b/services/chaitin__cloudwalker/config.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string", + "description": "CloudWalker API base URL." + }, + "referer": { + "type": "string", + "description": "Browser referer header used by CloudWalker session-based authentication flows." + } + }, + "required": ["baseUrl"] +} diff --git a/services/chaitin__cloudwalker/package.json b/services/chaitin__cloudwalker/package.json new file mode 100644 index 00000000..2d70c8b9 --- /dev/null +++ b/services/chaitin__cloudwalker/package.json @@ -0,0 +1,15 @@ +{ + "name": "cloudwalker", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "cloudwalker": "bin/cloudwalker.js" + }, + "scripts": { + "test": "node --test test/cloudwalker.test.js test/cloudwalker-client.test.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0" + } +} diff --git a/services/chaitin__cloudwalker/proto/cloudwalker.proto b/services/chaitin__cloudwalker/proto/cloudwalker.proto new file mode 100644 index 00000000..99a33d65 --- /dev/null +++ b/services/chaitin__cloudwalker/proto/cloudwalker.proto @@ -0,0 +1,135 @@ +syntax = "proto3"; + +package Chaitin_CloudWalker; + +option go_package = "miner/grpc-service/Chaitin_CloudWalker"; + +service Chaitin_CloudWalker { + rpc ListClusters(ListClustersRequest) returns (ListClustersResponse) {} + rpc GetClusterInfo(GetClusterInfoRequest) returns (Cluster) {} + rpc ListClusterVulnEvents(ListClusterVulnEventsRequest) returns (ListVulnEventsResponse) {} + rpc GetClusterVulnEvent(GetClusterVulnEventRequest) returns (VulnEvent) {} + rpc ListMicroserviceVulnEvents(ListMicroserviceVulnEventsRequest) returns (ListVulnEventsResponse) {} + rpc GetMicroserviceVulnEvent(GetMicroserviceVulnEventRequest) returns (VulnEvent) {} +} + +message ListClustersRequest { + int32 page_size = 1; + string page_token = 2; + string name = 3; + int32 status = 4; +} + +message GetClusterInfoRequest { + string cluster_id = 1; +} + +message ListClusterVulnEventsRequest { + string cluster_id = 1; + int32 page_size = 2; + string page_token = 3; + string cve = 4; + string name = 5; + string cnvd = 6; + string cnnvd = 7; + string node_name = 8; + string cluster_name = 9; + string order_by = 10; + repeated int32 risk = 11; + repeated int32 state = 12; + repeated string characteristic = 13; + int32 order = 14; +} + +message GetClusterVulnEventRequest { + string event_id = 1; +} + +message ListMicroserviceVulnEventsRequest { + int32 page_size = 1; + string page_token = 2; + string service_name = 3; + string service_type = 4; + string cluster_name = 5; + string name = 6; + string cve = 7; + string cnvd = 8; + string cnnvd = 9; + string order_by = 10; + repeated string characteristic = 11; + repeated int32 risk = 12; + repeated int32 state = 13; + int32 order = 14; +} + +message GetMicroserviceVulnEventRequest { + string event_id = 1; +} + +message ListClustersResponse { + repeated Cluster clusters = 1; + string next_page_token = 2; +} + +message ListVulnEventsResponse { + repeated VulnEvent vuln_events = 1; + string next_page_token = 2; +} + +message ModuleStatus { + string version = 1; + int32 module_type = 2; + int32 status = 3; +} + +message Cluster { + string cluster_id = 1; + string cluster_name = 2; + string status = 3; + string risk_level = 4; + string created_at = 5; + string updated_at = 6; + + // Extended fields from CloudWalker API + string api_version = 7; + repeated string master_ips = 8; + repeated ModuleStatus module_status = 9; + int32 cluster_type = 10; + int32 reachable = 11; + int32 integration_status = 12; +} + +message VulnEvent { + string event_id = 1; + string cluster_id = 2; + string microservice_id = 3; + string microservice_name = 4; + string level = 5; + string status = 6; + string title = 7; + string cve = 8; + string package_name = 9; + string package_version = 10; + string fixed_version = 11; + string image_name = 12; + string discovered_at = 13; + string updated_at = 14; + + // Extended fields for better data coverage + string node_name = 15; + string cluster_name = 16; + int32 risk = 17; + int32 original_risk = 18; + int32 custom_risk = 19; + repeated string characteristic = 20; + string service_uid = 21; + string service_type = 22; + string description = 23; + string solution = 24; + int32 manage_status = 25; + bool node_exist = 26; + string first_discovery_time = 27; + string last_discovery_time = 28; + string cnvd = 29; + string cnnvd = 30; +} diff --git a/services/chaitin__cloudwalker/secret.schema.json b/services/chaitin__cloudwalker/secret.schema.json new file mode 100644 index 00000000..67625cd5 --- /dev/null +++ b/services/chaitin__cloudwalker/secret.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "token": { + "type": "string", + "description": "CloudWalker API token header value." + }, + "cookie": { + "type": "string", + "description": "Browser session cookie. Required for demo environments that enforce session-based auth. May be omitted for environments that accept token-only authentication." + } + }, + "required": ["token"] +} diff --git a/services/chaitin__cloudwalker/service.json b/services/chaitin__cloudwalker/service.json new file mode 100644 index 00000000..57411a09 --- /dev/null +++ b/services/chaitin__cloudwalker/service.json @@ -0,0 +1,27 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "cloudwalker", + "displayName": "CloudWalker", + "description": "OctoBus package for CloudWalker cluster and vulnerability read APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": ["proto"], + "files": ["proto/cloudwalker.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusters": { "name": "list-clusters", "description": "List CloudWalker clusters." }, + "Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterInfo": { "name": "get-cluster-info", "description": "Get a CloudWalker cluster." }, + "Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusterVulnEvents": { "name": "list-cluster-vuln-events", "description": "List vulnerability events for a cluster." }, + "Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterVulnEvent": { "name": "get-cluster-vuln-event", "description": "Get a cluster vulnerability event." }, + "Chaitin_CloudWalker.Chaitin_CloudWalker/ListMicroserviceVulnEvents": { "name": "list-microservice-vuln-events", "description": "List vulnerability events for microservices." }, + "Chaitin_CloudWalker.Chaitin_CloudWalker/GetMicroserviceVulnEvent": { "name": "get-microservice-vuln-event", "description": "Get a microservice vulnerability event." } + } + } + } +} diff --git a/services/chaitin__cloudwalker/src/cloudwalker.js b/services/chaitin__cloudwalker/src/cloudwalker.js new file mode 100644 index 00000000..8dff82ce --- /dev/null +++ b/services/chaitin__cloudwalker/src/cloudwalker.js @@ -0,0 +1,799 @@ +const grpcStatus = Object.freeze({ + INVALID_ARGUMENT: 3, + NOT_FOUND: 5, + ALREADY_EXISTS: 6, + RESOURCE_EXHAUSTED: 8, + FAILED_PRECONDITION: 9, + DEADLINE_EXCEEDED: 4, + UNAVAILABLE: 14, + PERMISSION_DENIED: 7, + UNAUTHENTICATED: 16, +}); + +const endpoints = Object.freeze({ + listClusters: '/cluster/cluster_list', + getClusterInfo: '/cluster/cluster_info', + listClusterVulnEvents: '/cluster_vuln/vuln_event_list', + getClusterVulnEvent: '/cluster_vuln/vuln_event_info', + listMicroserviceVulnEvents: '/cluster_microservice/vuln_event_list', + getMicroserviceVulnEvent: '/cluster_microservice/vuln_event_info', +}); + +class CloudWalkerError extends Error { + constructor(message, { code, details, httpStatus } = {}) { + super(message); + this.name = 'CloudWalkerError'; + this.code = code ?? grpcStatus.UNAVAILABLE; + this.details = details ?? message; + this.httpStatus = httpStatus; + } +} + +const toCamelKey = (key) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + +const toCamelCase = (value) => { + if (Array.isArray(value)) { + return value.map((item) => toCamelCase(item)); + } + + if (!value || typeof value !== 'object') { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [toCamelKey(key), toCamelCase(item)]), + ); +}; + +const normalizeCluster = (clusterData) => { + if (!clusterData) { + return {}; + } + const camelData = toCamelCase(clusterData); + + return { + clusterId: String(camelData.id || ''), + clusterName: camelData.name || '', + status: camelData.status === undefined || camelData.status === null ? '' : String(camelData.status), + riskLevel: '', + createdAt: String(camelData.createdAt || ''), + updatedAt: String(camelData.updatedAt || ''), + // Extended fields + apiVersion: camelData.apiVersion || '', + masterIps: camelData.masterIps || [], + moduleStatus: camelData.moduleStatus || [], + clusterType: camelData.clusterType || 0, + reachable: camelData.reachable || 0, + integrationStatus: camelData.integrationStatus || 0, + }; +}; + +const normalizeVulnEvent = (vulnData) => { + if (!vulnData) { + return {}; + } + const camelData = toCamelCase(vulnData); + + return { + eventId: String(camelData.id || ''), + clusterId: String(camelData.clusterId || ''), + microserviceId: camelData.serviceUid || '', + microserviceName: camelData.serviceName || '', + level: camelData.risk === undefined || camelData.risk === null ? '' : String(camelData.risk), + status: camelData.manageStatus === undefined || camelData.manageStatus === null ? '' : String(camelData.manageStatus), + title: camelData.name || '', + cve: camelData.cve || '', + cnvd: camelData.cnvd || '', + cnnvd: camelData.cnnvd || '', + packageName: '', + packageVersion: String(camelData.packageVersion || ''), + fixedVersion: String(camelData.fixedVersion || ''), + imageName: '', + discoveredAt: String(camelData.discoveryTime || camelData.firstDiscoveryTime || ''), + updatedAt: String(camelData.lastDiscoveryTime || camelData.discoveryTime || ''), + // Extended fields + nodeName: camelData.nodeName || '', + clusterName: camelData.clusterName || '', + risk: camelData.risk || 0, + originalRisk: camelData.originalRisk || 0, + customRisk: camelData.customRisk || 0, + characteristic: camelData.characteristic || [], + serviceUid: camelData.serviceUid || '', + serviceType: camelData.serviceType || '', + description: camelData.description || '', + solution: camelData.solution || '', + manageStatus: camelData.manageStatus || 0, + nodeExist: camelData.nodeExist || false, + firstDiscoveryTime: String(camelData.firstDiscoveryTime || ''), + lastDiscoveryTime: String(camelData.lastDiscoveryTime || ''), + }; +}; + +const buildPaginationQuery = ({ pageSize, pageToken } = {}) => { + const query = new URLSearchParams(); + + if (Number.isInteger(pageSize) && pageSize > 0) { + query.set('page_size', String(pageSize)); + } + + if (pageToken) { + query.set('offset', String(pageToken)); + } + + return query; +}; + +const appendScalarQuery = (query, entries) => { + for (const [key, value] of entries) { + if (value === undefined || value === null) { + continue; + } + if (typeof value === 'string' && value.trim() === '') { + continue; + } + // Skip numeric zero values — they are proto3 defaults and not meaningful filter parameters + if (typeof value === 'number' && value === 0) { + continue; + } + query.set(key, String(value)); + } + + return query; +}; + +const appendRepeatedQuery = (query, key, values) => { + if (!Array.isArray(values) || values.length === 0) { + return query; + } + + for (const value of values) { + if (value === undefined || value === null) { + continue; + } + if (typeof value === 'string' && value.trim() === '') { + continue; + } + query.append(key, String(value)); + } + + return query; +}; + +const hasMeaningfulValue = (value) => { + if (value === undefined || value === null) { + return false; + } + if (typeof value === 'string') { + return value.trim() !== ''; + } + if (Array.isArray(value)) { + return value.some((item) => hasMeaningfulValue(item)); + } + return true; +}; + +const isHtmlResponseError = (error) => ( + error instanceof CloudWalkerError + && error.httpStatus === 200 + && typeof error.details === 'string' + && error.details.toLowerCase().includes('') +); + +const isNetworkError = (error) => ( + error instanceof CloudWalkerError + && error.httpStatus === undefined +); + +const normalizeComparableString = (value) => String(value || '').trim(); + +const collectFilteredItems = async ({ + request = {}, + fetchPage, + matchesItem, + scanPageSize = 50, + maxPages = 10, +}) => { + const targetCount = Number.isInteger(request.pageSize) && request.pageSize > 0 ? request.pageSize : scanPageSize; + const pageSize = Math.max(targetCount, scanPageSize); + let pageToken = request.pageToken || ''; + let lastNextPageToken = ''; + const matched = []; + + for (let page = 0; page < maxPages && matched.length < targetCount; page += 1) { + const result = await fetchPage({ pageSize, pageToken }); + const items = result.items || []; + lastNextPageToken = result.nextPageToken || ''; + + if (items.length === 0) { + break; + } + + for (const item of items) { + if (await matchesItem(item)) { + matched.push(item); + if (matched.length >= targetCount) { + break; + } + } + } + + if (!lastNextPageToken) { + break; + } + + pageToken = lastNextPageToken; + } + + return { + items: matched.slice(0, targetCount), + nextPageToken: lastNextPageToken || '', + }; +}; + +const buildListClustersQuery = (request = {}) => { + const query = buildPaginationQuery(request); + return appendScalarQuery(query, [ + ['name', request.name], + ['status', request.status], + ]); +}; + +const buildListClusterVulnEventsQuery = ({ clusterId, pageSize, pageToken, cve, name, cnvd, cnnvd, nodeName, clusterName, orderBy, risk, state, characteristic, order } = {}) => { + const query = buildPaginationQuery({ pageSize, pageToken }); + + appendScalarQuery(query, [ + ['cluster_id', clusterId], + ['cve', cve], + ['name', name], + ['cnvd', cnvd], + ['cnnvd', cnnvd], + ['node_name', nodeName], + ['cluster_name', clusterName], + ['order_by', orderBy], + ['order', order], + ]); + + appendRepeatedQuery(query, 'risk', risk); + appendRepeatedQuery(query, 'state', state); + appendRepeatedQuery(query, 'characteristic', characteristic); + + return query; +}; + +const buildListMicroserviceVulnEventsQuery = ({ pageSize, pageToken, serviceName, serviceType, clusterName, name, cve, cnvd, cnnvd, orderBy, characteristic, risk, state, order } = {}) => { + const query = buildPaginationQuery({ pageSize, pageToken }); + + appendScalarQuery(query, [ + ['service_name', serviceName], + ['service_type', serviceType], + ['cluster_name', clusterName], + ['name', name], + ['cve', cve], + ['cnvd', cnvd], + ['cnnvd', cnnvd], + ['order_by', orderBy], + ['order', order], + ]); + + appendRepeatedQuery(query, 'characteristic', characteristic); + appendRepeatedQuery(query, 'risk', risk); + appendRepeatedQuery(query, 'state', state); + + return query; +}; + +const enrichVulnEventsWithDetails = async (events, detailCache, loadDetail) => { + const enriched = []; + + for (const event of events) { + if (!detailCache.has(event.eventId)) { + detailCache.set(event.eventId, normalizeVulnEvent(await loadDetail(event.eventId))); + } + const detail = detailCache.get(event.eventId); + enriched.push(detail ? { ...detail, ...event, cnvd: detail.cnvd || event.cnvd || '', cnnvd: detail.cnnvd || event.cnnvd || '' } : event); + } + + return enriched; +}; + +const normalizeListPayload = (payload, collectionKey) => { + const camelPayload = toCamelCase(payload); + + // CloudWalker API returns {data: {data: [...]}} format + const candidate = camelPayload?.data?.data || camelPayload.items || camelPayload[collectionKey]; + const rawItems = Array.isArray(candidate) ? candidate : []; + + // Normalize each item based on collection type + let items; + if (collectionKey === 'clusters') { + items = rawItems.map(normalizeCluster); + } else if (collectionKey === 'vulnEvents') { + items = rawItems.map(normalizeVulnEvent); + } else { + items = rawItems; + } + + return { + [collectionKey]: items, + nextPageToken: camelPayload.nextPageToken ?? '', + }; +}; + +const readPayload = async (response) => { + const text = await response.text(); + + if (!text) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return { message: text }; + } +}; + +const isJsonContentType = (contentType) => { + if (!contentType) { + return false; + } + + return contentType.includes('application/json') || contentType.includes('+json'); +}; + +const buildHttpError = (status, payload) => { + const message = payload?.message || payload?.error || `CloudWalker upstream returned HTTP ${status}`; + let code = grpcStatus.UNAVAILABLE; + + if (status === 400) { + code = grpcStatus.INVALID_ARGUMENT; + } else if (status === 401) { + code = grpcStatus.UNAUTHENTICATED; + } else if (status === 403) { + code = grpcStatus.PERMISSION_DENIED; + } else if (status === 404) { + code = grpcStatus.NOT_FOUND; + } else if (status === 409) { + code = grpcStatus.ALREADY_EXISTS; + } else if (status === 429) { + code = grpcStatus.RESOURCE_EXHAUSTED; + } else if (status === 412) { + code = grpcStatus.FAILED_PRECONDITION; + } else if (status === 504) { + code = grpcStatus.DEADLINE_EXCEEDED; + } + + return new CloudWalkerError(message, { + code, + details: message, + httpStatus: status, + }); +}; + +export class CloudWalkerClient { + constructor({ baseUrl, token, cookie = '', referer = '', fetchImpl }) { + this.baseUrl = String(baseUrl || '').replace(/\/$/, ''); + this.token = token; + this.cookie = cookie; + this.referer = referer; + // Use provided fetchImpl or fall back to global fetch + // Note: In some environments, global fetch may have issues with HTTPS + this.fetchImpl = fetchImpl || fetch; + } + + async get(path, query) { + const url = new URL(`${this.baseUrl}${path}`); + if (query && query.toString()) { + url.search = query.toString(); + } + + const headers = { + accept: 'application/json, text/plain, */*', + authorization: `Bearer ${this.token}`, + token: this.token, + 'x-auth-token': this.token, + 'x-requested-with': 'XMLHttpRequest', + }; + + if (this.cookie) { + headers.cookie = this.cookie; + } + + if (this.referer) { + headers.referer = this.referer; + } + + let response; + try { + response = await this.fetchImpl(url, { + method: 'GET', + headers, + redirect: 'manual', + signal: AbortSignal.timeout(30000), + }); + } catch (networkError) { + const isTimeout = networkError.name === 'TimeoutError' || networkError.name === 'AbortError'; + throw new CloudWalkerError(networkError.message || 'CloudWalker upstream unreachable', { + code: isTimeout ? grpcStatus.DEADLINE_EXCEEDED : grpcStatus.UNAVAILABLE, + details: networkError.message || 'Network error', + httpStatus: undefined, + }); + } + + // Detect session-expired redirects (302 to /login) before they are silently followed + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location') || ''; + throw new CloudWalkerError('CloudWalker session expired or unauthorized redirect', { + code: grpcStatus.UNAUTHENTICATED, + details: `Upstream returned ${response.status} redirect to ${location || '(unknown)'}`, + httpStatus: response.status, + }); + } + + const payload = await readPayload(response); + if (!response.ok) { + throw buildHttpError(response.status, payload); + } + + const contentType = response.headers.get('content-type') || ''; + if (!isJsonContentType(contentType)) { + throw new CloudWalkerError('CloudWalker upstream returned non-JSON content', { + code: grpcStatus.UNAVAILABLE, + details: payload?.message || `Unexpected content-type: ${contentType || 'unknown'}`, + httpStatus: response.status, + }); + } + + return payload ?? {}; + } + + async listClusters(request) { + try { + return normalizeListPayload(await this.get(endpoints.listClusters, buildListClustersQuery(request)), 'clusters'); + } catch (error) { + if (isNetworkError(error) || !isHtmlResponseError(error) || (!hasMeaningfulValue(request?.name) && !hasMeaningfulValue(request?.status))) { + throw error; + } + + const fallback = await collectFilteredItems({ + request, + fetchPage: async ({ pageSize, pageToken }) => { + const payload = await this.get(endpoints.listClusters, buildPaginationQuery({ pageSize, pageToken })); + const response = normalizeListPayload(payload, 'clusters'); + return { + items: response.clusters, + nextPageToken: response.nextPageToken, + }; + }, + matchesItem: async (cluster) => { + if (hasMeaningfulValue(request.name) && normalizeComparableString(cluster.clusterName) !== normalizeComparableString(request.name)) { + return false; + } + if (hasMeaningfulValue(request.status) && String(cluster.status) !== String(request.status)) { + return false; + } + return true; + }, + }); + + return { + clusters: fallback.items, + nextPageToken: fallback.nextPageToken, + }; + } + } + + async getClusterInfo({ clusterId }) { + const query = new URLSearchParams(); + query.set('cluster_id', clusterId); + const response = await this.get(endpoints.getClusterInfo, query); + + // CloudWalker API returns {data: {data: {cluster_info: {...}}}} format + const clusterInfo = response?.data?.data?.cluster_info || response?.clusterInfo || response; + + return normalizeCluster(clusterInfo); + } + + async getClusterVulnEventPayload(eventId) { + const query = new URLSearchParams(); + query.set('id', eventId); + const response = await this.get(endpoints.getClusterVulnEvent, query); + return response?.data || response; + } + + async getClusterVulnEvent({ eventId }) { + return normalizeVulnEvent(await this.getClusterVulnEventPayload(eventId)); + } + + async listClusterVulnEvents(request) { + const requiresFallback = hasMeaningfulValue(request?.clusterName) || hasMeaningfulValue(request?.cnvd) || hasMeaningfulValue(request?.cnnvd); + const needsDetailEnrichment = hasMeaningfulValue(request?.cnvd) || hasMeaningfulValue(request?.cnnvd); + const detailCache = new Map(); + + if (!requiresFallback) { + return normalizeListPayload(await this.get(endpoints.listClusterVulnEvents, buildListClusterVulnEventsQuery(request)), 'vulnEvents'); + } + + let directError = null; + try { + const direct = await this.get(endpoints.listClusterVulnEvents, buildListClusterVulnEventsQuery(request)); + const normalized = normalizeListPayload(direct, 'vulnEvents'); + if (!hasMeaningfulValue(request?.clusterName) || normalized.vulnEvents.length > 0) { + if (!needsDetailEnrichment) { + return normalized; + } + return { + vulnEvents: await enrichVulnEventsWithDetails(normalized.vulnEvents, detailCache, (eventId) => this.getClusterVulnEventPayload(eventId)), + nextPageToken: normalized.nextPageToken, + }; + } + } catch (error) { + directError = error; + if (isNetworkError(error) || !(isHtmlResponseError(error) || error instanceof CloudWalkerError)) { + throw error; + } + } + + const fallbackRequest = { + ...request, + clusterName: undefined, + cnvd: undefined, + cnnvd: undefined, + }; + + const fallback = await collectFilteredItems({ + request, + fetchPage: async ({ pageSize, pageToken }) => { + const payload = await this.get(endpoints.listClusterVulnEvents, buildListClusterVulnEventsQuery({ ...fallbackRequest, pageSize, pageToken })); + const response = normalizeListPayload(payload, 'vulnEvents'); + return { + items: response.vulnEvents, + nextPageToken: response.nextPageToken, + }; + }, + matchesItem: async (event) => { + let detail = null; + if (hasMeaningfulValue(request.clusterName) || hasMeaningfulValue(request.cnvd) || hasMeaningfulValue(request.cnnvd)) { + if (!detailCache.has(event.eventId)) { + detailCache.set(event.eventId, normalizeVulnEvent(await this.getClusterVulnEventPayload(event.eventId))); + } + detail = detailCache.get(event.eventId); + } + + const candidateClusterName = detail?.clusterName || event.clusterName; + if (hasMeaningfulValue(request.clusterName) && normalizeComparableString(candidateClusterName) !== normalizeComparableString(request.clusterName)) { + return false; + } + + if (hasMeaningfulValue(request.cnvd) && normalizeComparableString(detail?.cnvd) !== normalizeComparableString(request.cnvd)) { + return false; + } + if (hasMeaningfulValue(request.cnnvd) && normalizeComparableString(detail?.cnnvd) !== normalizeComparableString(request.cnnvd)) { + return false; + } + + return true; + }, + }); + + if (fallback.items.length > 0 || directError) { + return { + vulnEvents: await enrichVulnEventsWithDetails(fallback.items, detailCache, (eventId) => this.getClusterVulnEventPayload(eventId)), + nextPageToken: fallback.nextPageToken, + }; + } + + return { + vulnEvents: fallback.items, + nextPageToken: fallback.nextPageToken, + }; + } + + async getMicroserviceVulnEventPayload(eventId) { + const query = new URLSearchParams(); + query.set('id', eventId); + const response = await this.get(endpoints.getMicroserviceVulnEvent, query); + return response?.data || response; + } + + async getMicroserviceVulnEvent({ eventId }) { + return normalizeVulnEvent(await this.getMicroserviceVulnEventPayload(eventId)); + } + + async listMicroserviceVulnEvents(request) { + const requiresFallback = hasMeaningfulValue(request?.clusterName) || hasMeaningfulValue(request?.cnvd) || hasMeaningfulValue(request?.cnnvd); + const needsDetailEnrichment = hasMeaningfulValue(request?.cnvd) || hasMeaningfulValue(request?.cnnvd); + const detailCache = new Map(); + + if (!requiresFallback) { + return normalizeListPayload(await this.get(endpoints.listMicroserviceVulnEvents, buildListMicroserviceVulnEventsQuery(request)), 'vulnEvents'); + } + + let directError = null; + try { + const direct = await this.get(endpoints.listMicroserviceVulnEvents, buildListMicroserviceVulnEventsQuery(request)); + const normalized = normalizeListPayload(direct, 'vulnEvents'); + if (!hasMeaningfulValue(request?.clusterName) || normalized.vulnEvents.length > 0) { + if (!needsDetailEnrichment) { + return normalized; + } + return { + vulnEvents: await enrichVulnEventsWithDetails(normalized.vulnEvents, detailCache, (eventId) => this.getMicroserviceVulnEventPayload(eventId)), + nextPageToken: normalized.nextPageToken, + }; + } + } catch (error) { + directError = error; + if (isNetworkError(error) || !(isHtmlResponseError(error) || error instanceof CloudWalkerError)) { + throw error; + } + } + + const fallbackRequest = { + ...request, + clusterName: undefined, + cnvd: undefined, + cnnvd: undefined, + }; + + const fallback = await collectFilteredItems({ + request, + fetchPage: async ({ pageSize, pageToken }) => { + const payload = await this.get(endpoints.listMicroserviceVulnEvents, buildListMicroserviceVulnEventsQuery({ ...fallbackRequest, pageSize, pageToken })); + const response = normalizeListPayload(payload, 'vulnEvents'); + return { + items: response.vulnEvents, + nextPageToken: response.nextPageToken, + }; + }, + matchesItem: async (event) => { + let detail = null; + if (hasMeaningfulValue(request.clusterName) || hasMeaningfulValue(request.cnvd) || hasMeaningfulValue(request.cnnvd)) { + if (!detailCache.has(event.eventId)) { + detailCache.set(event.eventId, normalizeVulnEvent(await this.getMicroserviceVulnEventPayload(event.eventId))); + } + detail = detailCache.get(event.eventId); + } + + const candidateClusterName = detail?.clusterName || event.clusterName; + if (hasMeaningfulValue(request.clusterName) && normalizeComparableString(candidateClusterName) !== normalizeComparableString(request.clusterName)) { + return false; + } + + if (hasMeaningfulValue(request.cnvd) && normalizeComparableString(detail?.cnvd) !== normalizeComparableString(request.cnvd)) { + return false; + } + if (hasMeaningfulValue(request.cnnvd) && normalizeComparableString(detail?.cnnvd) !== normalizeComparableString(request.cnnvd)) { + return false; + } + + return true; + }, + }); + + if (fallback.items.length > 0 || directError) { + return { + vulnEvents: await enrichVulnEventsWithDetails(fallback.items, detailCache, (eventId) => this.getMicroserviceVulnEventPayload(eventId)), + nextPageToken: fallback.nextPageToken, + }; + } + + return { + vulnEvents: fallback.items, + nextPageToken: fallback.nextPageToken, + }; + } +} + +export const createClient = (options) => new CloudWalkerClient(options); +export { CloudWalkerError, grpcStatus, toCamelCase, collectFilteredItems }; + +// --- SDK handler conventions --- + +const METHOD_LIST_CLUSTERS_PATH = '/Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusters'; +const METHOD_GET_CLUSTER_INFO_PATH = '/Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterInfo'; +const METHOD_LIST_CLUSTER_VULN_EVENTS_PATH = '/Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusterVulnEvents'; +const METHOD_GET_CLUSTER_VULN_EVENT_PATH = '/Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterVulnEvent'; +const METHOD_LIST_MICROSERVICE_VULN_EVENTS_PATH = '/Chaitin_CloudWalker.Chaitin_CloudWalker/ListMicroserviceVulnEvents'; +const METHOD_GET_MICROSERVICE_VULN_EVENT_PATH = '/Chaitin_CloudWalker.Chaitin_CloudWalker/GetMicroserviceVulnEvent'; + +const METHOD_LIST_CLUSTERS_FULL = 'Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusters'; +const METHOD_GET_CLUSTER_INFO_FULL = 'Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterInfo'; +const METHOD_LIST_CLUSTER_VULN_EVENTS_FULL = 'Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusterVulnEvents'; +const METHOD_GET_CLUSTER_VULN_EVENT_FULL = 'Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterVulnEvent'; +const METHOD_LIST_MICROSERVICE_VULN_EVENTS_FULL = 'Chaitin_CloudWalker.Chaitin_CloudWalker/ListMicroserviceVulnEvents'; +const METHOD_GET_MICROSERVICE_VULN_EVENT_FULL = 'Chaitin_CloudWalker.Chaitin_CloudWalker/GetMicroserviceVulnEvent'; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: mergedBindings(ctx), + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.request ?? ctx.req ?? {}, +}); + +const resolveBaseUrl = (bindings) => + bindings.baseUrl ?? bindings.base_url ?? bindings.host ?? 'http://127.0.0.1:18080'; + +const resolveToken = (bindings) => + bindings.token ?? bindings.accessToken ?? bindings.access_token ?? ''; + +const resolveCookie = (bindings) => + bindings.cookie ?? ''; + +const resolveReferer = (bindings) => + bindings.referer ?? ''; + +const buildClientOptions = (ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const bindings = callCtx.bindings; + return { + baseUrl: resolveBaseUrl(bindings), + token: resolveToken(bindings), + cookie: resolveCookie(bindings), + referer: resolveReferer(bindings), + }; +}; + +const handleListClusters = async (req, callCtx) => { + const client = createClient(buildClientOptions(callCtx)); + return client.listClusters(req); +}; + +const handleGetClusterInfo = async (req, callCtx) => { + const client = createClient(buildClientOptions(callCtx)); + return client.getClusterInfo(req); +}; + +const handleListClusterVulnEvents = async (req, callCtx) => { + const client = createClient(buildClientOptions(callCtx)); + return client.listClusterVulnEvents(req); +}; + +const handleGetClusterVulnEvent = async (req, callCtx) => { + const client = createClient(buildClientOptions(callCtx)); + return client.getClusterVulnEvent(req); +}; + +const handleListMicroserviceVulnEvents = async (req, callCtx) => { + const client = createClient(buildClientOptions(callCtx)); + return client.listMicroserviceVulnEvents(req); +}; + +const handleGetMicroserviceVulnEvent = async (req, callCtx) => { + const client = createClient(buildClientOptions(callCtx)); + return client.getMicroserviceVulnEvent(req); +}; + +const registerHandlers = (ctx = {}) => { + const callCtx = resolveCallContext(ctx); + return { + [METHOD_LIST_CLUSTERS_PATH]: (req = callCtx.req) => handleListClusters(req ?? {}, callCtx), + [METHOD_GET_CLUSTER_INFO_PATH]: (req = callCtx.req) => handleGetClusterInfo(req ?? {}, callCtx), + [METHOD_LIST_CLUSTER_VULN_EVENTS_PATH]: (req = callCtx.req) => handleListClusterVulnEvents(req ?? {}, callCtx), + [METHOD_GET_CLUSTER_VULN_EVENT_PATH]: (req = callCtx.req) => handleGetClusterVulnEvent(req ?? {}, callCtx), + [METHOD_LIST_MICROSERVICE_VULN_EVENTS_PATH]: (req = callCtx.req) => handleListMicroserviceVulnEvents(req ?? {}, callCtx), + [METHOD_GET_MICROSERVICE_VULN_EVENT_PATH]: (req = callCtx.req) => handleGetMicroserviceVulnEvent(req ?? {}, callCtx), + }; +}; + +export function rpcdef(ctx = {}) { + return registerHandlers(ctx); +} + +const callSdkHandler = (ctx, path) => registerHandlers(ctx)[path](ctx?.request ?? ctx?.req ?? {}); + +export const handlers = { + [METHOD_LIST_CLUSTERS_FULL]: (ctx) => callSdkHandler(ctx, METHOD_LIST_CLUSTERS_PATH), + [METHOD_GET_CLUSTER_INFO_FULL]: (ctx) => callSdkHandler(ctx, METHOD_GET_CLUSTER_INFO_PATH), + [METHOD_LIST_CLUSTER_VULN_EVENTS_FULL]: (ctx) => callSdkHandler(ctx, METHOD_LIST_CLUSTER_VULN_EVENTS_PATH), + [METHOD_GET_CLUSTER_VULN_EVENT_FULL]: (ctx) => callSdkHandler(ctx, METHOD_GET_CLUSTER_VULN_EVENT_PATH), + [METHOD_LIST_MICROSERVICE_VULN_EVENTS_FULL]: (ctx) => callSdkHandler(ctx, METHOD_LIST_MICROSERVICE_VULN_EVENTS_PATH), + [METHOD_GET_MICROSERVICE_VULN_EVENT_FULL]: (ctx) => callSdkHandler(ctx, METHOD_GET_MICROSERVICE_VULN_EVENT_PATH), +}; diff --git a/services/chaitin__cloudwalker/src/service.js b/services/chaitin__cloudwalker/src/service.js new file mode 100644 index 00000000..6be9e497 --- /dev/null +++ b/services/chaitin__cloudwalker/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './cloudwalker.js'; + +export { handlers } from './cloudwalker.js'; + +export const service = defineService({ handlers }); diff --git a/services/chaitin__cloudwalker/test/cloudwalker-client.test.js b/services/chaitin__cloudwalker/test/cloudwalker-client.test.js new file mode 100644 index 00000000..a20f569a --- /dev/null +++ b/services/chaitin__cloudwalker/test/cloudwalker-client.test.js @@ -0,0 +1,485 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import http from 'node:http'; +import { URL } from 'node:url'; + +import { createClient, collectFilteredItems } from '../src/cloudwalker.js'; + +const requests = []; + +function createMockServer() { + return http.createServer((req, res) => { + requests.push({ + method: req.method, + url: req.url, + headers: req.headers, + }); + + if (req.url === '/html-success') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end('login'); + return; + } + + if (req.url === '/cluster/cluster_list?page_size=20&offset=cursor-1&name=prod&status=1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + items: [ + { + id: 'cluster-1', + name: 'prod-cluster', + risk_level: 'high' + } + ], + next_page_token: 'cursor-2' + })); + return; + } + + if (req.url === '/cluster/cluster_info?cluster_id=cluster-1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + data: { + cluster_info: { + id: 'cluster-1', + name: 'prod-cluster', + created_at: '2024-01-01T00:00:00Z' + } + } + } + })); + return; + } + + if (req.url?.startsWith('/cluster_vuln/vuln_event_list?')) { + const params = new URL(`http://127.0.0.1${req.url}`).searchParams; + const risk = params.getAll('risk'); + const state = params.getAll('state'); + const characteristic = params.getAll('characteristic'); + if ( + params.get('page_size') === '10' && + params.get('offset') === 'cursor-a' && + params.get('cluster_id') === 'cluster-1' && + params.get('cve') === 'CVE-2024-0001' && + params.get('name') === 'openssl vuln' && + params.get('cnvd') === 'CNVD-2024-1' && + params.get('cnnvd') === 'CNNVD-2024-1' && + params.get('node_name') === 'node-a' && + params.get('cluster_name') === 'prod-cluster' && + params.get('order_by') === 'risk' && + params.get('order') === '1' && + risk.join(',') === '4,5' && + state.join(',') === '1,2' && + characteristic.join(',') === 'EXP,NETWORK' + ) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + data: [ + { + id: 'event-1', + cluster_id: 'cluster-1', + name: 'openssl vuln', + cve: 'CVE-2024-0001', + risk: 4, + manage_status: 1, + characteristic: ['EXP', 'NETWORK'] + } + ] + }, + next_page_token: 'cursor-b' + })); + return; + } + + if ( + params.get('page_size') === '50' && + params.get('cluster_id') === 'cluster-1' && + !params.has('cluster_name') && + !params.has('cnvd') && + !params.has('cnnvd') + ) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + data: [ + { id: 'event-1', cluster_id: 'cluster-1', cluster_name: 'prod-cluster', name: 'openssl vuln', cve: 'CVE-2024-0001' }, + { id: 'event-2', cluster_id: 'cluster-1', cluster_name: 'other-cluster', name: 'other vuln', cve: 'CVE-2024-0002' } + ] + }, + next_page_token: '' + })); + return; + } + } + + if (req.url === '/cluster_vuln/vuln_event_info?id=event-1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + id: 'event-1', + cluster_id: 'cluster-1', + fixed_version: '3.0.0', + cnvd: 'CNVD-2024-1', + cnnvd: 'CNNVD-2024-1', + cluster_name: 'prod-cluster' + } + })); + return; + } + + if (req.url === '/cluster_vuln/vuln_event_info?id=event-2') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + id: 'event-2', + cluster_id: 'cluster-1', + fixed_version: '4.0.0', + cnvd: 'CNVD-2024-2', + cnnvd: 'CNNVD-2024-2', + cluster_name: 'other-cluster' + } + })); + return; + } + + if (req.url?.startsWith('/cluster_microservice/vuln_event_list?')) { + const params = new URL(`http://127.0.0.1${req.url}`).searchParams; + const risk = params.getAll('risk'); + const state = params.getAll('state'); + const characteristic = params.getAll('characteristic'); + if ( + params.get('page_size') === '5' && + params.get('offset') === 'cursor-m' && + params.get('service_name') === 'checkout' && + params.get('service_type') === 'ClusterIP' && + params.get('cluster_name') === 'prod-cluster' && + params.get('name') === 'jwt vuln' && + params.get('cve') === 'CVE-2019-20933' && + params.get('cnvd') === 'CNVD-2019-1' && + params.get('cnnvd') === 'CNNVD-2019-1' && + params.get('order_by') === 'risk' && + params.get('order') === '2' && + characteristic.join(',') === 'EXP,NETWORK' && + risk.join(',') === '4,5' && + state.join(',') === '1,2' + ) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + data: [ + { + id: 'ms-event-1', + cluster_id: 'cluster-1', + service_uid: 'service-1', + service_name: 'checkout', + cve: 'CVE-2019-20933', + risk: 5, + manage_status: 1, + characteristic: ['EXP', 'NETWORK'] + } + ] + }, + next_page_token: 'cursor-n' + })); + return; + } + + if ( + params.get('page_size') === '50' && + !params.has('cluster_name') && + !params.has('cnvd') && + !params.has('cnnvd') + ) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + data: [ + { id: 'ms-event-1', cluster_id: 'cluster-1', cluster_name: 'prod-cluster', service_uid: 'service-1', service_name: 'checkout', cve: 'CVE-2019-20933' }, + { id: 'ms-event-2', cluster_id: 'cluster-1', cluster_name: 'other-cluster', service_uid: 'service-2', service_name: 'billing', cve: 'CVE-2019-0002' } + ] + }, + next_page_token: '' + })); + return; + } + } + + if (req.url === '/cluster_microservice/vuln_event_info?id=ms-event-1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + id: 'ms-event-1', + service_uid: 'service-1', + service_name: 'checkout', + package_version: '1.0.0', + cnvd: 'CNVD-2019-1', + cnnvd: 'CNNVD-2019-1', + cluster_name: 'prod-cluster' + } + })); + return; + } + + if (req.url === '/cluster_microservice/vuln_event_info?id=ms-event-2') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + id: 'ms-event-2', + service_uid: 'service-2', + service_name: 'billing', + package_version: '2.0.0', + cnvd: 'CNVD-2019-2', + cnnvd: 'CNNVD-2019-2', + cluster_name: 'other-cluster' + } + })); + return; + } + + if (req.url === '/json-success') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ items: [], next_page_token: '' })); + return; + } + + if (req.url === '/unauthorized') { + res.writeHead(401, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'invalid token' })); + return; + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: `Unhandled path: ${req.url}` })); + }); +} + +let server; +let baseUrl; + +before(async () => { + server = createMockServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +}); + +describe('cloudwalker client', () => { + it('rejects html responses even when status is 200', async () => { + const client = createClient({ baseUrl, token: 'test-token' }); + + await assert.rejects(() => client.get('/html-success'), { + name: 'CloudWalkerError', + message: 'CloudWalker upstream returned non-JSON content', + code: 14, + httpStatus: 200, + }); + }); + + it('uses documented endpoints and auth headers', async () => { + const client = createClient({ baseUrl, token: 'test-token' }); + + const clusters = await client.listClusters({ pageSize: 20, pageToken: 'cursor-1', name: 'prod', status: 1 }); + assert.equal(clusters.clusters[0].clusterName, 'prod-cluster'); + + const cluster = await client.getClusterInfo({ clusterId: 'cluster-1' }); + assert.equal(cluster.createdAt, '2024-01-01T00:00:00Z'); + + const clusterEvents = await client.listClusterVulnEvents({ + clusterId: 'cluster-1', + pageSize: 10, + pageToken: 'cursor-a', + cve: 'CVE-2024-0001', + name: 'openssl vuln', + cnvd: 'CNVD-2024-1', + cnnvd: 'CNNVD-2024-1', + nodeName: 'node-a', + clusterName: 'prod-cluster', + orderBy: 'risk', + risk: [4, 5], + state: [1, 2], + characteristic: ['EXP', 'NETWORK'], + order: 1, + }); + assert.equal(clusterEvents.vulnEvents[0].title, 'openssl vuln'); + assert.deepEqual(clusterEvents.vulnEvents[0].characteristic, ['EXP', 'NETWORK']); + + const fallbackClusterEvents = await client.listClusterVulnEvents({ + clusterId: 'cluster-1', + pageSize: 1, + clusterName: 'prod-cluster', + cnvd: 'CNVD-2024-1', + cnnvd: 'CNNVD-2024-1', + }); + assert.equal(fallbackClusterEvents.vulnEvents.length, 1); + assert.equal(fallbackClusterEvents.vulnEvents[0].eventId, 'event-1'); + assert.equal(fallbackClusterEvents.vulnEvents[0].clusterName, 'prod-cluster'); + + const clusterEvent = await client.getClusterVulnEvent({ eventId: 'event-1' }); + assert.equal(clusterEvent.fixedVersion, '3.0.0'); + + const microserviceEvents = await client.listMicroserviceVulnEvents({ + pageSize: 5, + pageToken: 'cursor-m', + serviceName: 'checkout', + serviceType: 'ClusterIP', + clusterName: 'prod-cluster', + name: 'jwt vuln', + cve: 'CVE-2019-20933', + cnvd: 'CNVD-2019-1', + cnnvd: 'CNNVD-2019-1', + orderBy: 'risk', + characteristic: ['EXP', 'NETWORK'], + risk: [4, 5], + state: [1, 2], + order: 2, + }); + assert.equal(microserviceEvents.vulnEvents[0].microserviceName, 'checkout'); + assert.equal(microserviceEvents.vulnEvents[0].serviceType, ''); + + const fallbackMicroserviceEvents = await client.listMicroserviceVulnEvents({ + pageSize: 1, + clusterName: 'prod-cluster', + cnvd: 'CNVD-2019-1', + cnnvd: 'CNNVD-2019-1', + }); + assert.equal(fallbackMicroserviceEvents.vulnEvents.length, 1); + assert.equal(fallbackMicroserviceEvents.vulnEvents[0].eventId, 'ms-event-1'); + assert.equal(fallbackMicroserviceEvents.vulnEvents[0].microserviceName, 'checkout'); + + const microserviceEvent = await client.getMicroserviceVulnEvent({ eventId: 'ms-event-1' }); + assert.equal(microserviceEvent.packageVersion, '1.0.0'); + + const first = requests[0]; + assert.equal(first.headers.authorization, 'Bearer test-token'); + assert.equal(first.headers.token, 'test-token'); + assert.equal(first.headers['x-auth-token'], 'test-token'); + assert.equal(first.headers['x-requested-with'], 'XMLHttpRequest'); + }); + + it('still accepts normal json responses and wraps auth failures', async () => { + const client = createClient({ baseUrl, token: 'test-token' }); + + const payload = await client.get('/json-success'); + assert.deepEqual(payload, { items: [], next_page_token: '' }); + + await assert.rejects(() => client.get('/unauthorized'), { code: 16, details: 'invalid token' }); + }); + + it('preserves nextPageToken when fallback scan stops at maxPages', async () => { + let pageCalls = 0; + + const result = await collectFilteredItems({ + request: { pageSize: 1 }, + maxPages: 2, + fetchPage: async ({ pageToken }) => { + pageCalls += 1; + if (!pageToken) { + return { + items: [{ id: '1' }], + nextPageToken: 'cursor-2', + }; + } + return { + items: [{ id: '2' }], + nextPageToken: 'cursor-3', + }; + }, + matchesItem: async () => false, + }); + + assert.equal(pageCalls, 2); + assert.deepEqual(result.items, []); + assert.equal(result.nextPageToken, 'cursor-3'); + }); + + it('falls back from html response and keeps pagination token for later pages', async () => { + const client = createClient({ + baseUrl, + token: 'test-token', + fetchImpl: async (url) => { + const path = typeof url === 'string' ? url : url.toString(); + if (path.includes('/cluster/cluster_list?page_size=1&name=fallback')) { + return new Response('login', { + status: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + } + if (path.includes('/cluster/cluster_list?page_size=50')) { + return new Response(JSON.stringify({ + data: { + data: [ + { id: 'cluster-1', name: 'prod-cluster', status: 1 }, + ], + }, + next_page_token: 'cursor-2', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected path: ${path}`); + }, + }); + + const response = await client.listClusters({ pageSize: 1, name: 'fallback' }); + assert.equal(response.clusters.length, 0); + assert.equal(response.nextPageToken, 'cursor-2'); + }); + + it('preserves zero status values during cluster normalization', async () => { + const client = createClient({ + baseUrl, + token: 'test-token', + fetchImpl: async () => new Response(JSON.stringify({ + data: { + data: [ + { id: 'cluster-zero', name: 'zero-cluster', status: 0 } + ] + }, + next_page_token: '' + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }); + + const response = await client.listClusters({ pageSize: 1 }); + assert.equal(response.clusters[0].status, '0'); + }); + + it('preserves zero risk and manageStatus values during vuln event normalization', async () => { + const client = createClient({ + baseUrl, + token: 'test-token', + fetchImpl: async (url) => { + const path = typeof url === 'string' ? url : url.toString(); + if (path.includes('/cluster_vuln/vuln_event_list')) { + return new Response(JSON.stringify({ + data: { + data: [ + { id: 'vuln-zero', cluster_id: 'c1', risk: 0, manage_status: 0 } + ] + }, + next_page_token: '' + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected path: ${path}`); + }, + }); + + const response = await client.listClusterVulnEvents({ pageSize: 1 }); + assert.equal(response.vulnEvents[0].level, '0'); + assert.equal(response.vulnEvents[0].status, '0'); + assert.equal(response.vulnEvents[0].risk, 0); + assert.equal(response.vulnEvents[0].manageStatus, 0); + }); +}); diff --git a/services/chaitin__cloudwalker/test/cloudwalker.test.js b/services/chaitin__cloudwalker/test/cloudwalker.test.js new file mode 100644 index 00000000..beaad6c5 --- /dev/null +++ b/services/chaitin__cloudwalker/test/cloudwalker.test.js @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import http from 'node:http'; +import { URL } from 'node:url'; + +import { createClient, handlers } from '../src/cloudwalker.js'; + +const requests = []; + +function createMockServer() { + return http.createServer((req, res) => { + requests.push({ + method: req.method, + url: req.url, + headers: req.headers, + }); + + if (req.url === '/cluster/cluster_list?page_size=20&offset=cursor-1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + items: [ + { + id: 'cluster-1', + name: 'prod-cluster', + risk_level: 'high' + } + ], + next_page_token: 'cursor-2' + })); + return; + } + + if (req.url === '/cluster/cluster_info?cluster_id=cluster-1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + data: { + cluster_info: { + id: 'cluster-1', + name: 'prod-cluster', + created_at: '2024-01-01T00:00:00Z' + } + } + } + })); + return; + } + + if (req.url?.startsWith('/cluster_vuln/vuln_event_list?')) { + const params = new URL(`http://127.0.0.1${req.url}`).searchParams; + if ( + params.get('page_size') === '10' && + params.get('offset') === 'cursor-a' && + params.get('cluster_id') === 'cluster-1' + ) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + vuln_events: [ + { + id: 'event-1', + cluster_id: 'cluster-1', + name: 'openssl', + service_uid: '', + service_name: '' + } + ], + next_page_token: 'cursor-b' + })); + return; + } + } + + if (req.url === '/cluster_vuln/vuln_event_info?id=event-1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + id: 'event-1', + cluster_id: 'cluster-1', + fixed_version: '3.0.0' + } + })); + return; + } + + if (req.url === '/cluster_microservice/vuln_event_list?page_size=5&offset=cursor-m') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + items: [ + { + id: 'ms-event-1', + service_uid: 'service-1', + service_name: 'checkout' + } + ], + next_page_token: 'cursor-n' + })); + return; + } + + if (req.url === '/cluster_microservice/vuln_event_info?id=ms-event-1') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + data: { + id: 'ms-event-1', + service_uid: 'service-1', + service_name: 'checkout', + package_version: '1.0.0' + } + })); + return; + } + + if (req.url === '/bad-request') { + res.writeHead(400, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'invalid parameter' })); + return; + } + + if (req.url === '/forbidden') { + res.writeHead(403, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'access denied' })); + return; + } + + if (req.url === '/unauthorized') { + res.writeHead(401, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'invalid token' })); + return; + } + + if (req.url === '/conflict') { + res.writeHead(409, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'already exists' })); + return; + } + + if (req.url === '/rate-limited') { + res.writeHead(429, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'too many requests' })); + return; + } + + if (req.url === '/precondition') { + res.writeHead(412, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'precondition failed' })); + return; + } + + if (req.url === '/missing') { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'not found' })); + return; + } + + if (req.url === '/broken') { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'upstream exploded' })); + return; + } + + if (req.url === '/gateway-timeout') { + res.writeHead(504, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: 'gateway timeout' })); + return; + } + + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ message: `Unhandled path: ${req.url}` })); + }); +} + +let server; +let baseUrl; + +before(async () => { + server = createMockServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); +}); + +describe('cloudwalker client', () => { + it('lists clusters and maps pagination plus auth', async () => { + const client = createClient({ baseUrl, token: 'test-token' }); + + const response = await client.listClusters({ pageSize: 20, pageToken: 'cursor-1' }); + + assert.deepEqual(response.clusters[0], { + clusterId: 'cluster-1', + clusterName: 'prod-cluster', + status: '', + riskLevel: '', + createdAt: '', + updatedAt: '', + apiVersion: '', + masterIps: [], + moduleStatus: [], + clusterType: 0, + reachable: 0, + integrationStatus: 0, + }); + assert.equal(response.nextPageToken, 'cursor-2'); + assert.equal(requests.at(-1).headers.authorization, 'Bearer test-token'); + assert.equal(requests.at(-1).url, '/cluster/cluster_list?page_size=20&offset=cursor-1'); + }); + + it('returns cluster and event details with camelCase mapping', async () => { + const client = createClient({ baseUrl, token: 'test-token' }); + + const cluster = await client.getClusterInfo({ clusterId: 'cluster-1' }); + assert.equal(cluster.clusterName, 'prod-cluster'); + assert.equal(cluster.createdAt, '2024-01-01T00:00:00Z'); + + const clusterEvent = await client.getClusterVulnEvent({ eventId: 'event-1' }); + assert.equal(clusterEvent.fixedVersion, '3.0.0'); + + const microserviceEvent = await client.getMicroserviceVulnEvent({ eventId: 'ms-event-1' }); + assert.equal(microserviceEvent.microserviceName, 'checkout'); + }); + + it('lists vulnerability events for cluster and microservice scopes', async () => { + const client = createClient({ baseUrl, token: 'test-token' }); + + const clusterEvents = await client.listClusterVulnEvents({ + clusterId: 'cluster-1', + pageSize: 10, + pageToken: 'cursor-a' + }); + assert.equal(clusterEvents.vulnEvents[0].title, 'openssl'); + assert.equal(clusterEvents.nextPageToken, 'cursor-b'); + + const microserviceEvents = await client.listMicroserviceVulnEvents({ + pageSize: 5, + pageToken: 'cursor-m' + }); + assert.equal(microserviceEvents.vulnEvents[0].microserviceName, 'checkout'); + assert.equal(microserviceEvents.nextPageToken, 'cursor-n'); + }); + + it('wraps upstream HTTP errors to gRPC status codes', async () => { + const client = createClient({ baseUrl, token: 'test-token' }); + + // 400 -> INVALID_ARGUMENT (3) + await assert.rejects(() => client.get('/bad-request'), { code: 3, details: 'invalid parameter' }); + // 401 -> UNAUTHENTICATED (16) + await assert.rejects(() => client.get('/unauthorized'), { code: 16, details: 'invalid token' }); + // 403 -> PERMISSION_DENIED (7) + await assert.rejects(() => client.get('/forbidden'), { code: 7, details: 'access denied' }); + // 404 -> NOT_FOUND (5) + await assert.rejects(() => client.get('/missing'), { code: 5, details: 'not found' }); + // 409 -> ALREADY_EXISTS (6) + await assert.rejects(() => client.get('/conflict'), { code: 6, details: 'already exists' }); + // 412 -> FAILED_PRECONDITION (9) + await assert.rejects(() => client.get('/precondition'), { code: 9, details: 'precondition failed' }); + // 429 -> RESOURCE_EXHAUSTED (8) + await assert.rejects(() => client.get('/rate-limited'), { code: 8, details: 'too many requests' }); + // 500 -> UNAVAILABLE (14) + await assert.rejects(() => client.get('/broken'), { code: 14, details: 'upstream exploded' }); + // 504 -> DEADLINE_EXCEEDED (4) + await assert.rejects(() => client.get('/gateway-timeout'), { code: 4, details: 'gateway timeout' }); + }); +}); + +describe('cloudwalker handlers', () => { + it('builds the client from context config, secrets and bindings', async () => { + const response = await handlers['Chaitin_CloudWalker.Chaitin_CloudWalker/ListClusters']( + { + config: { + baseUrl, + referer: 'https://cnapp.demo.chaitin.cn/profile/apitoken' + }, + secret: { + token: 'handler-token', + cookie: 'session=handler' + }, + request: { pageSize: 20, pageToken: 'cursor-1' } + } + ); + + assert.equal(response.clusters[0].clusterId, 'cluster-1'); + const request = requests.at(-1); + assert.equal(request.headers.authorization, 'Bearer handler-token'); + assert.equal(request.headers.cookie, 'session=handler'); + assert.equal(request.headers.referer, 'https://cnapp.demo.chaitin.cn/profile/apitoken'); + }); + + it('accepts cluster vuln detail requests without clusterId', async () => { + const response = await handlers['Chaitin_CloudWalker.Chaitin_CloudWalker/GetClusterVulnEvent']( + { + config: { baseUrl }, + secret: { token: 'handler-token' }, + request: { eventId: 'event-1' } + } + ); + + assert.equal(response.eventId, 'event-1'); + assert.equal(requests.at(-1).url, '/cluster_vuln/vuln_event_info?id=event-1'); + }); +}); diff --git a/services/chaitin__cloudwalker/test/mock_upstream.js b/services/chaitin__cloudwalker/test/mock_upstream.js new file mode 100644 index 00000000..110c22ed --- /dev/null +++ b/services/chaitin__cloudwalker/test/mock_upstream.js @@ -0,0 +1,213 @@ +/** + * Standalone mock upstream server for CloudWalker service integration tests. + * + * Covers: + * - one success response per endpoint + * - one 401 auth failure + * - one 403 permission denied + * - one 404 not found + * - one 400 bad request + * - one 429 rate limited + * - one 500 upstream error + * - one timeout simulation (delayed response) + * + * Usage: + * node test/mock_upstream.js [port] + * + * Defaults to port 18080. + */ + +import http from 'node:http'; + +const PORT = parseInt(process.argv[2] || '18080', 10); + +const sampleCluster = { + id: '1', + name: 'mock-cluster', + status: 1, + api_version: 'v1.21.4', + master_ips: ['10.0.0.1'], + module_status: [{ version: 'v1.0.0', module_type: 1, status: 1 }], + cluster_type: 1, + reachable: 2, + integration_status: 1, + updated_at: 1700000000, +}; + +const sampleVulnEvent = { + id: '100', + cluster_id: '1', + name: 'mock vuln', + cve: 'CVE-2024-0001', + risk: 4, + manage_status: 0, + characteristic: ['EXP'], + node_name: 'node-1', + cluster_name: 'mock-cluster', + service_uid: 'svc-1', + service_name: 'mock-service', + service_type: 'ClusterIP', + cnvd: 'CNVD-2024-1', + cnnvd: 'CNNVD-2024-1', + description: 'A mock vulnerability', + solution: 'Upgrade to latest version', + discovery_time: '1700000000', + first_discovery_time: '1699900000', + last_discovery_time: '1700000000', + package_version: '1.0.0', + fixed_version: '2.0.0', + original_risk: 4, + custom_risk: 0, +}; + +const sampleMicroserviceVulnEvent = { + ...sampleVulnEvent, + id: '200', + service_uid: 'svc-2', + service_name: 'mock-ms-service', + service_type: 'ClusterIP', +}; + +function jsonResponse(res, statusCode, data) { + const body = JSON.stringify(data); + res.writeHead(statusCode, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + +function htmlResponse(res, statusCode) { + const body = 'Login Required'; + res.writeHead(statusCode, { + 'content-type': 'text/html; charset=utf-8', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + const path = url.pathname; + const params = url.searchParams; + + // --- Error simulation routes --- + + if (path === '/error/400') { + return jsonResponse(res, 400, { message: 'bad request: invalid parameter' }); + } + if (path === '/error/401') { + return jsonResponse(res, 401, { message: 'unauthorized: invalid token' }); + } + if (path === '/error/403') { + return jsonResponse(res, 403, { message: 'forbidden: insufficient permissions' }); + } + if (path === '/error/404') { + return jsonResponse(res, 404, { message: 'not found' }); + } + if (path === '/error/409') { + return jsonResponse(res, 409, { message: 'conflict: already exists' }); + } + if (path === '/error/412') { + return jsonResponse(res, 412, { message: 'precondition failed' }); + } + if (path === '/error/429') { + return jsonResponse(res, 429, { message: 'too many requests: rate limit exceeded' }); + } + if (path === '/error/500') { + return jsonResponse(res, 500, { message: 'internal server error' }); + } + if (path === '/error/504') { + return jsonResponse(res, 504, { message: 'gateway timeout' }); + } + if (path === '/error/html-login') { + return htmlResponse(res, 200); + } + if (path === '/error/timeout') { + // Simulate a 5s delay then respond + setTimeout(() => { + jsonResponse(res, 200, { data: { data: [] }, next_page_token: '' }); + }, 5000); + return; + } + + // --- Success routes --- + + if (path === '/cluster/cluster_list') { + const name = params.get('name'); + const status = params.get('status'); + let clusters = [{ ...sampleCluster }]; + if (name && sampleCluster.name !== name) { + clusters = []; + } + if (status && String(sampleCluster.status) !== status) { + clusters = []; + } + return jsonResponse(res, 200, { + data: { data: clusters }, + next_page_token: '', + }); + } + + if (path === '/cluster/cluster_info') { + const clusterId = params.get('cluster_id'); + if (!clusterId) { + return jsonResponse(res, 400, { message: 'cluster_id is required' }); + } + return jsonResponse(res, 200, { + data: { + data: { + cluster_info: { ...sampleCluster, id: clusterId }, + }, + }, + }); + } + + if (path === '/cluster_vuln/vuln_event_list') { + return jsonResponse(res, 200, { + data: { data: [sampleVulnEvent] }, + next_page_token: '', + }); + } + + if (path === '/cluster_vuln/vuln_event_info') { + const eventId = params.get('id'); + if (!eventId) { + return jsonResponse(res, 400, { message: 'id is required' }); + } + return jsonResponse(res, 200, { + data: { ...sampleVulnEvent, id: eventId }, + }); + } + + if (path === '/cluster_microservice/vuln_event_list') { + return jsonResponse(res, 200, { + data: { data: [sampleMicroserviceVulnEvent] }, + next_page_token: '', + }); + } + + if (path === '/cluster_microservice/vuln_event_info') { + const eventId = params.get('id'); + if (!eventId) { + return jsonResponse(res, 400, { message: 'id is required' }); + } + return jsonResponse(res, 200, { + data: { ...sampleMicroserviceVulnEvent, id: eventId }, + }); + } + + // Default 404 + jsonResponse(res, 404, { message: `Unknown path: ${path}` }); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`CloudWalker mock upstream listening on http://127.0.0.1:${PORT}`); + console.log('Endpoints:'); + console.log(' Success: /cluster/cluster_list, /cluster/cluster_info,'); + console.log(' /cluster_vuln/vuln_event_list, /cluster_vuln/vuln_event_info,'); + console.log(' /cluster_microservice/vuln_event_list, /cluster_microservice/vuln_event_info'); + console.log(' Errors: /error/400, /error/401, /error/403, /error/404, /error/409,'); + console.log(' /error/412, /error/429, /error/500, /error/504, /error/html-login,'); + console.log(' /error/timeout'); +}); diff --git a/services/package.json b/services/package.json index d6243de4..081562d9 100644 --- a/services/package.json +++ b/services/package.json @@ -8,6 +8,7 @@ "aliyun-waf3": "bin/aliyun-waf3.js", "alibaba-cloud-simple-application-server-firewall": "bin/alibaba-cloud-simple-application-server-firewall.js", "cloudatlas": "bin/cloudatlas.js", + "cloudwalker": "bin/cloudwalker.js", "das-gateway-v3": "bin/das-gateway-v3.js", "das-tgfw-v6": "bin/das-tgfw-v6.js", "dbaudit": "bin/dbaudit.js", @@ -75,6 +76,7 @@ "bin/aliyun-waf3.js", "bin/alibaba-cloud-simple-application-server-firewall.js", "bin/cloudatlas.js", + "bin/cloudwalker.js", "bin/das-gateway-v3.js", "bin/das-tgfw-v6.js", "bin/dbaudit.js", @@ -140,6 +142,7 @@ "alibaba-cloud__simple-application-server-firewall", "chaitin__cloudatlas", "chaitin__dsensor_ds-s_h_40-25.07.001", + "chaitin__cloudwalker", "das__gateway_v3", "das__tgfw_v6", "das__dbaudit",