diff --git a/services/qianxin__caasm_v1/README.md b/services/qianxin__caasm_v1/README.md new file mode 100644 index 00000000..db4423c3 --- /dev/null +++ b/services/qianxin__caasm_v1/README.md @@ -0,0 +1,174 @@ +# QiAnXin CAASM OctoBus Service + +奇安信网络资产攻击面管理系统 (CAASM) API 封装。支持资产查询、漏洞查询和系统管理数据查询,共 12 个 RPC 方法。 + +Import it into OctoBus with: + +```bash +octobus service import qianxin-caasm ./services/qianxin__caasm_v1 +``` + +## Package Files + +- `service.json`: OctoBus service manifest. +- `proto/caasm.proto`: gRPC API definition (3 services, 12 methods). +- `config.schema.json`: non-secret base URL, path prefix, timeout, and TLS settings. +- `secret.schema.json`: CAASM appKey and appSecret fields. +- `src/auth.js`: HMAC-SHA256 signature generation for CAASM Zeus authentication. +- `src/client.js`: HTTP/HTTPS client with configurable TLS verification. +- `src/mappers.js`: Request/response mapping and pagination logic. +- `bin/qianxin-caasm.js`: OctoBus SDK `defineService` entry point with handler factories. +- `test/mappers.test.js`: Unit tests for request building, response wrapping, and pagination. +- `test/auth.test.js`: Unit tests for HMAC-SHA256 signature generation. + +## Supported Version + +- **Platform**: 奇安信网络资产攻击面管理系统 (CAASM) +- **Auth Scheme**: Zeus HMAC-SHA256 (`appKey`, `appSecret`) +- **API Path Prefix**: `/caasm/v1/biz-service` + +## Configuration + +Use `config` for base URL and HTTP settings: + +```json +{ + "baseUrl": "https://caasm.example.com", + "pathPrefix": "/caasm/v1/biz-service", + "timeoutMs": 30000 +} +``` + +For self-signed certificates (common in internal deployments): + +```json +{ + "baseUrl": "https://caasm.example.com", + "insecureTls": true +} +``` + +Use `secret` for the CAASM API credentials: + +```json +{ + "appKey": "your-caasm-app-key", + "appSecret": "your-caasm-app-secret" +} +``` + +> **凭据获取方式**: 联系 CAASM 平台管理员获取 `appKey` 和 `appSecret`。 + +## RPC Methods + +### AssetService (资产) + +| Method | Description | Notes | +|--------|-------------|-------| +| `GetDevices` | 查询硬件资产 | 155K+ records | +| `GetSoftware` | 查询已安装软件 | 5M+ records, large table | +| `GetServices` | 查询网络服务 | 15M+ records, large table ⚠️ may timeout | +| `GetComponents` | 查询软件组件 | 19M+ records, large table | +| `GetWebsites` | 查询 Web 应用 | 4K+ records | + +### VulnerabilityService (漏洞) + +| Method | Description | Notes | +|--------|-------------|-------| +| `GetSysVulnerabilities` | 查询系统漏洞 | 167K+ records | +| `GetSysWeakPasswords` | 查询系统弱口令 | 4K+ records | +| `GetWebVulnerabilities` | 查询 Web 漏洞 | | +| `GetWebWeakPasswords` | 查询 Web 弱口令 | | + +### AdminService (管理) + +| Method | Description | Notes | +|--------|-------------|-------| +| `GetUsers` | 查询用户列表 | Client-side pagination (CAASM ignores offset/limit) | +| `GetOrganizations` | 查询组织架构树 | Client-side pagination (CAASM ignores offset/limit) | +| `GetRoles` | 查询角色列表 | | + +## Pagination + +All methods accept `offset`, `limit`, and optional `filter` (JSON string) parameters. + +- **Normal endpoints**: Offset and limit are forwarded to CAASM. Max limit is 100. +- **Large-table endpoints** (software, services, components): Max limit is clamped to 10 to avoid CAASM gateway timeouts. +- **Admin endpoints** (users, organizations): CAASM ignores pagination entirely, so the handler sends an empty body and applies client-side slicing. The `total` field always reflects the real upstream count. + +## Response Format + +All methods return a JSON string in the `json` field: + +```json +{ + "items": [ + { "asset_code": "Dev-001", "hostname": "web-server-01", ... } + ], + "total": 155951 +} +``` + +This format avoids proto Struct's verbose `{kind:{oneofKind:"stringValue",...}}` wrapper, making MCP `structuredContent` directly readable by AI tools. + +## Error Mapping + +| Scenario | gRPC Status | +|----------|-------------| +| Missing `baseUrl` in config | `UNAUTHENTICATED` | +| Missing `appKey` or `appSecret` | `UNAUTHENTICATED` | +| Invalid filter JSON string | `INVALID_ARGUMENT` | +| HTTP 401 / 403 | `UNAUTHENTICATED` | +| HTTP 5xx | `UNAVAILABLE` | +| Network / timeout / DNS / TLS error | `UNAVAILABLE` | +| Non-JSON response body | `UNAVAILABLE` | + +## Risk Notes + +- **只读操作**: 本 service 仅提供查询,不涉及任何写入/修改/删除操作。 +- **API 凭据安全**: `appKey` 和 `appSecret` 通过 `secret.schema.json` 管理,创建实例时由管理员填入,不在代码/日志中暴露。 +- **大表查询限制**: `GetServices` (15M+)、`GetComponents` (19M+)、`GetSoftware` (5M+) 查询需要精确的过滤条件,无条件全量查询可能导致 CAASM nginx 网关超时。 +- **分页不一致**: `GetUsers` 和 `GetOrganizations` 的 CAASM API 不接受 offset/limit,handler 通过客户端切片补偿。 + +## Suggested Capsets + +- `caasm-asset`: 暴露 AssetService 全部 5 个方法,适用于资产盘点 Agent。 +- `caasm-vuln`: 暴露 VulnerabilityService 全部 4 个方法,适用于漏洞扫描 Agent。 +- `caasm-admin`: 暴露 AdminService 全部 3 个方法,适用于用户/组织管理 Agent。 +- `caasm-full`: 暴露全部 12 个方法,适用于需要完整 CAASM 能力的综合安全 Agent。 + +## Local Checks + +```bash +cd services/qianxin__caasm_v1 +npm install +npx octobus-sdk validate --strict +node --test +npm pack --dry-run +``` + +## Usage Example + +```bash +# Import service +octobus service import qianxin-caasm ./services/qianxin__caasm_v1 + +# Create instance +octobus instance create caasm-prod --service qianxin-caasm \ + --config-json '{"baseUrl":"https://caasm.example.com","timeoutMs":30000}' \ + --secret-json '{"appKey":"your-app-key","appSecret":"your-app-secret"}' + +# Create capset and bind +octobus capset create asset-search --name "Asset Search Agent" +octobus capset add-instance asset-search caasm-prod + +# Call via Connect RPC +curl -X POST http://127.0.0.1:9000/capsets/asset-search/connect/caasm-prod/AssetService/GetDevices \ + -H 'Content-Type: application/json' \ + -d '{"offset":0,"limit":10}' + +# Call via MCP (AI Agent) +curl -X POST http://127.0.0.1:9000/capsets/asset-search/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"caasm_service__caasm_prod__get_devices","arguments":{"limit":10}}}' +``` diff --git a/services/qianxin__caasm_v1/bin/qianxin-caasm.js b/services/qianxin__caasm_v1/bin/qianxin-caasm.js new file mode 100755 index 00000000..414501dd --- /dev/null +++ b/services/qianxin__caasm_v1/bin/qianxin-caasm.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { defineService, runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { createClient } from "../src/client.js"; +import { buildRequestBody, buildPageResponse, API_PATHS, MAX_LIMITS } from "../src/mappers.js"; + +// ---- helpers ---- + +/** + * Create a unary handler for a given CAASM API path. + * @param {string} path - API path (e.g. "/api/entity/dev") + * @param {"default"|"largeTable"|"noPagination"} limitProfile + * - default: normal endpoint, forwards offset/limit to CAASM + * - largeTable: service/component/software tables (millions of rows), tiny limit + * - noPagination: user/list, org/list — CAASM ignores pagination; we + * send an empty body and slice client-side + */ +function makeHandler(path, limitProfile = "default") { + const maxLimit = MAX_LIMITS[limitProfile] ?? MAX_LIMITS.default; + const skipPagination = limitProfile === "noPagination"; + + return async (ctx) => { + const client = createClient(ctx.config, ctx.secret); + let body, pageParams; + + if (skipPagination) { + // user/list and org/list ignore offset/limit — send minimal body + body = {}; + pageParams = { + offset: Number(ctx.request.offset) || 0, + limit: Math.min(Number(ctx.request.limit) || 10, maxLimit), + }; + } else { + body = buildRequestBody(ctx.request, { maxLimit }); + pageParams = { offset: body.offset, limit: body.limit }; + } + + const raw = await client(path, body); + return buildPageResponse(raw, pageParams); + }; +} + +const service = defineService({ + handlers: { + // ---- Asset ---- + "AssetService/GetDevices": makeHandler(API_PATHS.dev), + "AssetService/GetSoftware": makeHandler(API_PATHS.software, "largeTable"), + "AssetService/GetServices": makeHandler(API_PATHS.service, "largeTable"), + "AssetService/GetComponents": makeHandler(API_PATHS.component, "largeTable"), + "AssetService/GetWebsites": makeHandler(API_PATHS.website), + + // ---- Vulnerability ---- + "VulnerabilityService/GetSysVulnerabilities": makeHandler(API_PATHS.vulnSys), + "VulnerabilityService/GetSysWeakPasswords": makeHandler(API_PATHS.weakpwdSys), + "VulnerabilityService/GetWebVulnerabilities": makeHandler(API_PATHS.vulnWeb), + "VulnerabilityService/GetWebWeakPasswords": makeHandler(API_PATHS.weakpwdWeb), + + // ---- Admin ---- + "AdminService/GetUsers": makeHandler(API_PATHS.user, "noPagination"), + "AdminService/GetOrganizations": makeHandler(API_PATHS.org, "noPagination"), + "AdminService/GetRoles": makeHandler(API_PATHS.role), + }, +}); + +runServiceMain(service); diff --git a/services/qianxin__caasm_v1/config.schema.json b/services/qianxin__caasm_v1/config.schema.json new file mode 100644 index 00000000..93c58404 --- /dev/null +++ b/services/qianxin__caasm_v1/config.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["baseUrl"], + "properties": { + "baseUrl": { + "type": "string", + "description": "CAASM server base URL, e.g. https://caasm.example.com" + }, + "pathPrefix": { + "type": "string", + "default": "/caasm/v1/biz-service", + "description": "URL path prefix prepended to all API calls. Defaults to /caasm/v1/biz-service." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1000, + "default": 30000, + "description": "HTTP request timeout in milliseconds" + }, + "insecureTls": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification. Set to true when connecting to servers with self-signed certificates." + } + } +} diff --git a/services/qianxin__caasm_v1/package.json b/services/qianxin__caasm_v1/package.json new file mode 100644 index 00000000..21a24cbf --- /dev/null +++ b/services/qianxin__caasm_v1/package.json @@ -0,0 +1,17 @@ +{ + "name": "qianxin-caasm", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "qianxin-caasm": "bin/qianxin-caasm.js" + }, + "scripts": { + "validate": "octobus-sdk validate --strict", + "test": "node --test", + "pack:check": "npm pack --dry-run" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0" + } +} diff --git a/services/qianxin__caasm_v1/proto/caasm.proto b/services/qianxin__caasm_v1/proto/caasm.proto new file mode 100644 index 00000000..f42530d2 --- /dev/null +++ b/services/qianxin__caasm_v1/proto/caasm.proto @@ -0,0 +1,83 @@ +// CAASM Service — unified proto contract. +// +// Only verified, working endpoints are included. +// Attack surface endpoints (as_ip/as_service/as_url/as_domain), +// tickets, biz_system, network_range, entity_relation, and dict/list +// were removed because the CAASM server returns "不支持查询" or 404. +syntax = "proto3"; + +// ────────────────────────── Common Messages ────────────────────────── + +// Standard paginated query request. +// Corresponds to CAASM POST body: { offset, limit, filter, login_name } +message PageRequest { + // Pagination offset, defaults to 0 server-side when unset. + int32 offset = 1; + // Page size. Server defaults to 10. Handler clamps to a safe maximum. + int32 limit = 2; + // Optional filter as a JSON string. + // Example: "{\"asset_code\": \"Dev-xxx\"}" + string filter = 3; + // Optional data-scoping login name. + string login_name = 4 [json_name = "loginName"]; +} + +// Response wrapping CAASM JSON as a string. +// This keeps MCP structuredContent clean — the AI tool receives plain JSON +// instead of proto Struct's verbose {kind:{oneofKind:"stringValue",...}} wrapper. +message PageResponse { + // JSON string: { "items": [...], "total": N } + // Each item in items is a plain JSON object with the CAASM record fields. + string json = 1; +} + +// ─────────────────── Asset Service ─────────────────── + +// Read-only access to CAASM asset entities. +service AssetService { + // Query devices (hardware assets). + rpc GetDevices(PageRequest) returns (PageResponse); + + // Query installed software. + rpc GetSoftware(PageRequest) returns (PageResponse); + + // Query network services. ⚠️ Very large table (15M+ rows) — keep limit small. + rpc GetServices(PageRequest) returns (PageResponse); + + // Query software components (libraries, frameworks). ⚠️ Very large table (19M+ rows). + rpc GetComponents(PageRequest) returns (PageResponse); + + // Query web applications / websites. + rpc GetWebsites(PageRequest) returns (PageResponse); +} + +// ─────────────── Vulnerability Service ─────────────── + +// Read-only access to CAASM vulnerability / weakness data. +service VulnerabilityService { + // Query system / software vulnerabilities. + rpc GetSysVulnerabilities(PageRequest) returns (PageResponse); + + // Query system weak passwords. + rpc GetSysWeakPasswords(PageRequest) returns (PageResponse); + + // Query web application vulnerabilities. + rpc GetWebVulnerabilities(PageRequest) returns (PageResponse); + + // Query web application weak passwords. + rpc GetWebWeakPasswords(PageRequest) returns (PageResponse); +} + +// ───────────────── Admin Service ───────────────── + +// Read-only access to CAASM system administration data. +service AdminService { + // Query users with roles. + rpc GetUsers(PageRequest) returns (PageResponse); + + // Query organization tree. + rpc GetOrganizations(PageRequest) returns (PageResponse); + + // Query roles. + rpc GetRoles(PageRequest) returns (PageResponse); +} diff --git a/services/qianxin__caasm_v1/secret.schema.json b/services/qianxin__caasm_v1/secret.schema.json new file mode 100644 index 00000000..0384b4b8 --- /dev/null +++ b/services/qianxin__caasm_v1/secret.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["appKey", "appSecret"], + "properties": { + "appKey": { + "type": "string", + "minLength": 1, + "description": "CAASM Zeus platform application key (应用唯一标识)" + }, + "appSecret": { + "type": "string", + "minLength": 1, + "description": "CAASM Zeus platform application secret key (应用密钥)" + } + } +} diff --git a/services/qianxin__caasm_v1/service.json b/services/qianxin__caasm_v1/service.json new file mode 100644 index 00000000..6d90ff60 --- /dev/null +++ b/services/qianxin__caasm_v1/service.json @@ -0,0 +1,69 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "qianxin-caasm", + "displayName": "QiAnXin CAASM", + "description": "奇安信网络资产攻击面管理系统 (CAASM) API wrapper — exposes asset, vulnerability, and admin queries as gRPC unary methods.", + "runtime": { + "mode": "on-demand" + }, + "proto": { + "roots": ["proto"], + "files": ["proto/caasm.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "AssetService/GetDevices": { + "name": "get-devices", + "description": "Query devices (hardware assets) from CAASM." + }, + "AssetService/GetSoftware": { + "name": "get-software", + "description": "Query installed software from CAASM." + }, + "AssetService/GetServices": { + "name": "get-services", + "description": "Query network services from CAASM. ⚠️ Very large table (15M+ rows) — keep limit small." + }, + "AssetService/GetComponents": { + "name": "get-components", + "description": "Query software components (libraries, frameworks) from CAASM. ⚠️ Very large table (19M+ rows)." + }, + "AssetService/GetWebsites": { + "name": "get-websites", + "description": "Query web applications / websites from CAASM." + }, + "VulnerabilityService/GetSysVulnerabilities": { + "name": "get-sys-vulnerabilities", + "description": "Query system / software vulnerabilities from CAASM." + }, + "VulnerabilityService/GetSysWeakPasswords": { + "name": "get-sys-weak-passwords", + "description": "Query system weak passwords from CAASM." + }, + "VulnerabilityService/GetWebVulnerabilities": { + "name": "get-web-vulnerabilities", + "description": "Query web application vulnerabilities from CAASM." + }, + "VulnerabilityService/GetWebWeakPasswords": { + "name": "get-web-weak-passwords", + "description": "Query web application weak passwords from CAASM." + }, + "AdminService/GetUsers": { + "name": "get-users", + "description": "Query users with roles from CAASM admin API." + }, + "AdminService/GetOrganizations": { + "name": "get-organizations", + "description": "Query organization tree from CAASM admin API." + }, + "AdminService/GetRoles": { + "name": "get-roles", + "description": "Query roles from CAASM admin API." + } + } + } + } +} diff --git a/services/qianxin__caasm_v1/src/auth.js b/services/qianxin__caasm_v1/src/auth.js new file mode 100644 index 00000000..a55d61b0 --- /dev/null +++ b/services/qianxin__caasm_v1/src/auth.js @@ -0,0 +1,28 @@ +import crypto from "node:crypto"; + +/** + * Build a CAASM HMAC-SHA256 signature and auth-credentials header value. + * + * CAASM Zeus auth scheme (from API docs §3): + * signString = "appKey:{appKey}&nonce:{nonce}×tamp:{timestamp}" + * signature = HMAC-SHA256(signString, appSecret) → hex + * header = "appKey=...,nonce=...,timestamp=...,version=1.0.0,signature=..." + * + * @param {string} appKey + * @param {string} appSecret + * @returns {{ header: string, nonce: string, timestamp: number }} + */ +export function buildAuthHeader(appKey, appSecret) { + const nonce = String(crypto.randomInt(100000, 1000000)); + const timestamp = Math.floor(Date.now() / 1000); + + const signString = `appKey:${appKey}&nonce:${nonce}×tamp:${timestamp}`; + const signature = crypto + .createHmac("sha256", appSecret) + .update(signString) + .digest("hex"); + + const header = `appKey=${appKey},nonce=${nonce},timestamp=${timestamp},version=1.0.0,signature=${signature}`; + + return { header, nonce, timestamp }; +} diff --git a/services/qianxin__caasm_v1/src/client.js b/services/qianxin__caasm_v1/src/client.js new file mode 100644 index 00000000..74c8e29d --- /dev/null +++ b/services/qianxin__caasm_v1/src/client.js @@ -0,0 +1,125 @@ +import https from "node:https"; +import http from "node:http"; + +import { grpcUnavailableError, grpcUnauthenticatedError, grpcInvalidArgumentError } from "@chaitin-ai/octobus-sdk"; + +/** + * Create a minimal POST-only HTTP/HTTPS client for CAASM. + * + * Uses Node built-in https.request / http.request because Node 20's native + * fetch does not allow per-request TLS overrides. All requests are POST+JSON. + * + * @param {object} config - resolved instance config + * @param {object} secret - resolved instance secret + * @returns {function} - async (path, body) => parsed JSON response + */ +export function createClient(config, secret) { + const baseUrl = String(config.baseUrl ?? "").replace(/\/+$/, ""); + if (!baseUrl) { + throw grpcUnauthenticatedError("config.baseUrl is required"); + } + + const appKey = String(secret.appKey ?? ""); + const appSecret = String(secret.appSecret ?? ""); + if (!appKey || !appSecret) { + throw grpcUnauthenticatedError("secret.appKey and secret.appSecret are required"); + } + + const timeoutMs = Number(config.timeoutMs) || 30000; + const insecureTls = config.insecureTls === true; + const pathPrefix = String(config.pathPrefix ?? "/caasm/v1/biz-service").replace(/\/+$/, ""); + + // Parse host:port and protocol from baseUrl + let urlObj; + try { + urlObj = new URL(baseUrl); + } catch { + throw grpcInvalidArgumentError(`config.baseUrl is not a valid URL: ${baseUrl}`); + } + const hostname = urlObj.hostname; + const isHttps = urlObj.protocol === "https:"; + const port = urlObj.port ? Number(urlObj.port) : (isHttps ? 443 : 80); + const request = isHttps ? https.request : http.request; + + // ---- auth header builder (lazy import) ---- + let buildAuthHeader; + async function loadAuth() { + if (!buildAuthHeader) { + ({ buildAuthHeader } = await import("./auth.js")); + } + } + + /** + * POST JSON to CAASM and return parsed response. + * @param {string} path - API path, e.g. "/api/entity/dev" + * @param {object} body - request body + * @returns {Promise} parsed JSON body + */ + async function call(path, body) { + await loadAuth(); + const { header: credentials } = buildAuthHeader(appKey, appSecret); + + const reqPath = `${pathPrefix}${path}`; + const payload = JSON.stringify(body); + + return new Promise((resolve, reject) => { + const req = request({ + hostname, + port, + path: reqPath, + method: "POST", + rejectUnauthorized: !insecureTls, + timeout: timeoutMs, + headers: { + "content-type": "application/json", + "auth-credentials": credentials, + "content-length": Buffer.byteLength(payload), + }, + }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("error", (err) => { + reject(grpcUnavailableError(`CAASM response stream error: ${err.message}`)); + }); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + + if (res.statusCode === 401 || res.statusCode === 403) { + reject(grpcUnauthenticatedError(`CAASM auth rejected (HTTP ${res.statusCode})`)); + return; + } + + if (res.statusCode >= 500) { + reject(grpcUnavailableError(`CAASM upstream error (HTTP ${res.statusCode})`)); + return; + } + + if (!res.statusCode || res.statusCode >= 400) { + reject(grpcUnavailableError(`CAASM HTTP ${res.statusCode}`)); + return; + } + + try { + resolve(JSON.parse(text)); + } catch { + reject(grpcUnavailableError(`CAASM returned non-JSON (HTTP ${res.statusCode})`)); + } + }); + }); + + req.on("error", (err) => { + reject(grpcUnavailableError(`CAASM request failed: ${err.message}`)); + }); + + req.on("timeout", () => { + req.destroy(); + reject(grpcUnavailableError(`CAASM request timed out after ${timeoutMs}ms: ${path}`)); + }); + + req.write(payload); + req.end(); + }); + } + + return call; +} diff --git a/services/qianxin__caasm_v1/src/mappers.js b/services/qianxin__caasm_v1/src/mappers.js new file mode 100644 index 00000000..4b628fd6 --- /dev/null +++ b/services/qianxin__caasm_v1/src/mappers.js @@ -0,0 +1,100 @@ +import { grpcInvalidArgumentError } from "@chaitin-ai/octobus-sdk"; + +/** + * Build the upstream request body from the proto PageRequest. + * + * The CAASM API expects: + * { offset, limit, filter?, login_name? } + * + * @param {object} request - decoded proto PageRequest + * @param {object} options + * @param {number} options.defaultLimit - fallback when request.limit is 0 + * @param {number} options.maxLimit - clamp limit to this maximum + * @returns {{ offset: number, limit: number, filter?: object, login_name?: string }} + */ +export function buildRequestBody(request, { defaultLimit = 10, maxLimit = 100 } = {}) { + let offset = Number(request.offset) || 0; + let limit = Number(request.limit) || defaultLimit; + + if (limit > maxLimit) limit = maxLimit; + if (limit < 1) limit = defaultLimit; + if (offset < 0) offset = 0; + + const body = { offset, limit }; + + // Filter is now a plain JSON string — parse it + if (request.filter && typeof request.filter === "string" && request.filter.trim()) { + let parsed; + try { + parsed = JSON.parse(request.filter); + } catch { + throw grpcInvalidArgumentError("filter must be valid JSON"); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw grpcInvalidArgumentError("filter must be a JSON object"); + } + body.filter = parsed; + } + + if (request.loginName) { + body.login_name = request.loginName; + } + + return body; +} + +/** + * Build the CAASM API path for a given entity type. + * + * Maps logical names to the known CAASM API paths. + */ +export const API_PATHS = { + // Asset + dev: "/api/entity/dev", + software: "/api/entity/software", + service: "/api/entity/service", + component: "/api/entity/component", + website: "/api/entity/website", + + // Vulnerability + vulnSys: "/api/entity/vuln_sys", + weakpwdSys: "/api/entity/weakpwd_sys", + vulnWeb: "/api/entity/vuln_web", + weakpwdWeb: "/api/entity/weakpwd_web", + + // Admin + user: "/api/system/user/list", + org: "/api/system/org/list", + role: "/api/system/role/list", +}; + +/** Table-specific max limits */ +export const MAX_LIMITS = { + default: 100, + largeTable: 10, // service (15M+), component (19M+), software (5M+) — keep very small +}; + +/** + * Build proto PageResponse — return the CAASM JSON as a string field. + * + * Applies client-side slicing because some CAASM endpoints (user/list, + * org/list) ignore offset/limit and return all records. The total field + * always reflects the real upstream total. + * + * @param {object} raw - the parsed JSON from CAASM: { items: [...], total: N } + * @param {{ offset: number, limit: number }} page - the requested page params + * @returns {{ json: string }} + */ +export function buildPageResponse(raw, { offset = 0, limit = 100 } = {}) { + const allItems = raw.items || []; + const total = Number(raw.total) || 0; + + // Client-side slice + const start = Math.max(0, offset); + const end = start + Math.max(1, limit); + const sliced = allItems.slice(start, end); + + return { + json: JSON.stringify({ items: sliced, total }), + }; +} diff --git a/services/qianxin__caasm_v1/test/auth.test.js b/services/qianxin__caasm_v1/test/auth.test.js new file mode 100644 index 00000000..728fc090 --- /dev/null +++ b/services/qianxin__caasm_v1/test/auth.test.js @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import crypto from "node:crypto"; + +import { buildAuthHeader } from "../src/auth.js"; + +describe("buildAuthHeader", () => { + it("returns required fields", () => { + const result = buildAuthHeader("myKey", "mySecret"); + assert.ok(result.header, "header should exist"); + assert.ok(result.nonce, "nonce should exist"); + assert.ok(result.timestamp, "timestamp should exist"); + }); + + it("header contains expected key-value pairs", () => { + const result = buildAuthHeader("testKey", "testSecret"); + assert.match(result.header, /appKey=testKey/); + assert.match(result.header, /nonce=\d{6}/); + assert.match(result.header, /timestamp=\d{10}/); + assert.match(result.header, /version=1\.0\.0/); + assert.match(result.header, /signature=[a-f0-9]{64}/); + }); + + it("nonce is a 6-digit string", () => { + const result = buildAuthHeader("k", "s"); + assert.match(result.nonce, /^\d{6}$/); + }); + + it("timestamp is recent (within 5 seconds)", () => { + const result = buildAuthHeader("k", "s"); + const now = Math.floor(Date.now() / 1000); + assert.ok(Math.abs(now - result.timestamp) < 5, "timestamp should be within 5s"); + }); + + it("produces a valid HMAC-SHA256 signature", () => { + const appKey = "myAppKey"; + const appSecret = "myAppSecret"; + const result = buildAuthHeader(appKey, appSecret); + + // Recompute the expected signature + const expectedSignStr = `appKey:${appKey}&nonce:${result.nonce}×tamp:${result.timestamp}`; + const expectedSig = crypto + .createHmac("sha256", appSecret) + .update(expectedSignStr) + .digest("hex"); + + const actualSig = result.header.split("signature=")[1]; + assert.equal(actualSig, expectedSig); + }); + + it("produces different signatures with different nonces", () => { + const r1 = buildAuthHeader("key", "secret"); + const r2 = buildAuthHeader("key", "secret"); + // Nonces should differ (random), so signatures should differ too + if (r1.nonce !== r2.nonce) { + assert.notEqual( + r1.header.split("signature=")[1], + r2.header.split("signature=")[1] + ); + } + // But the key part should remain the same + assert.match(r1.header, /appKey=key/); + assert.match(r2.header, /appKey=key/); + }); + + it("produces different signatures for different secrets", () => { + const r1 = buildAuthHeader("sameKey", "secret1"); + const r2 = buildAuthHeader("sameKey", "secret2"); + // With same key and likely same timestamp but different secrets, + // signatures should differ + assert.notEqual( + r1.header.split("signature=")[1], + r2.header.split("signature=")[1] + ); + }); +}); diff --git a/services/qianxin__caasm_v1/test/client.test.js b/services/qianxin__caasm_v1/test/client.test.js new file mode 100644 index 00000000..532be008 --- /dev/null +++ b/services/qianxin__caasm_v1/test/client.test.js @@ -0,0 +1,214 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import { createServer } from "node:http"; +import { once } from "node:events"; + +import { createClient } from "../src/client.js"; + +let server; +let baseUrl; +let lastRequest; + +// Start a mock CAASM upstream that records the last request and replies +// according to the configured handler logic. +before(async () => { + server = createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + let body = null; + try { body = raw ? JSON.parse(raw) : null; } catch { body = raw; } + lastRequest = { method: req.method, url: req.url, headers: req.headers, body }; + + // Route by URL path suffix to simulate different CAASM responses + // (pathPrefix may prepend segments, so match on suffix) + const suffix = req.url.split("/").pop(); + if (suffix === "ok") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ items: [{ id: 1 }], total: 1 })); + return; + } + if (suffix === "unauth") { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "invalid credentials" })); + return; + } + if (suffix === "forbidden") { + res.writeHead(403, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "no permission" })); + return; + } + if (suffix === "server-error") { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ trace: "internal stack trace" })); + return; + } + if (suffix === "client-error") { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + if (suffix === "non-json") { + res.writeHead(200, { "content-type": "text/html" }); + res.end("not json"); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "unknown route" })); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const addr = server.address(); + baseUrl = `http://${addr.address}:${addr.port}`; +}); + +after(async () => { + server.closeAllConnections?.(); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +}); + +const validSecret = { appKey: "test-key", appSecret: "test-secret" }; + +function makeClient(overrides = {}) { + return createClient( + { baseUrl, pathPrefix: "", timeoutMs: 5000, ...overrides }, + validSecret + ); +} + +describe("createClient", () => { + it("sends POST with JSON body and parses JSON response", async () => { + const call = makeClient(); + const result = await call("/ok", { offset: 0, limit: 10 }); + assert.deepEqual(result, { items: [{ id: 1 }], total: 1 }); + assert.equal(lastRequest.method, "POST"); + assert.equal(lastRequest.url, "/ok"); + assert.equal(lastRequest.headers["content-type"], "application/json"); + assert.deepEqual(lastRequest.body, { offset: 0, limit: 10 }); + // auth-credentials header should be present + assert.ok(lastRequest.headers["auth-credentials"]); + }); + + it("includes auth-credentials header from buildAuthHeader", async () => { + const call = makeClient(); + await call("/ok", {}); + const creds = lastRequest.headers["auth-credentials"]; + assert.match(creds, /appKey=test-key/); + assert.match(creds, /nonce=\d{6}/); + assert.match(creds, /timestamp=\d{10}/); + assert.match(creds, /version=1\.0\.0/); + assert.match(creds, /signature=[a-f0-9]{64}/); + }); + + it("prepends pathPrefix to the request path", async () => { + const call = makeClient({ pathPrefix: "/caasm/v1/biz-service" }); + await call("/ok", {}); + assert.equal(lastRequest.url, "/caasm/v1/biz-service/ok"); + }); + + it("returns UNAUTHENTICATED on HTTP 401", async () => { + const call = makeClient(); + await assert.rejects(() => call("/unauth", {}), (err) => { + assert.match(err.message, /CAASM auth rejected \(HTTP 401\)/); + return true; + }); + }); + + it("returns UNAUTHENTICATED on HTTP 403", async () => { + const call = makeClient(); + await assert.rejects(() => call("/forbidden", {}), (err) => { + assert.match(err.message, /CAASM auth rejected \(HTTP 403\)/); + return true; + }); + }); + + it("returns UNAVAILABLE on HTTP 5xx without leaking response body", async () => { + const call = makeClient(); + await assert.rejects(() => call("/server-error", {}), (err) => { + assert.match(err.message, /CAASM upstream error \(HTTP 500\)/); + // Response body (internal stack trace) must NOT appear in the error message + assert.doesNotMatch(err.message, /internal stack trace/); + return true; + }); + }); + + it("returns UNAVAILABLE on HTTP 4xx without leaking response body", async () => { + const call = makeClient(); + await assert.rejects(() => call("/client-error", {}), (err) => { + assert.match(err.message, /CAASM HTTP 404/); + assert.doesNotMatch(err.message, /not found/); + return true; + }); + }); + + it("returns UNAVAILABLE on non-JSON response without leaking body", async () => { + const call = makeClient(); + await assert.rejects(() => call("/non-json", {}), (err) => { + assert.match(err.message, /CAASM returned non-JSON \(HTTP 200\)/); + assert.doesNotMatch(err.message, /|not json/); + return true; + }); + }); + + it("returns UNAVAILABLE on network error (ECONNREFUSED)", async () => { + // Point to a port that is not listening — server.close() in `after` frees the real port + const call = createClient( + { baseUrl: "http://127.0.0.1:1", pathPrefix: "", timeoutMs: 1000 }, + validSecret + ); + await assert.rejects(() => call("/ok", {}), (err) => { + assert.match(err.message, /CAASM request failed/); + return true; + }); + }); + + it("returns UNAVAILABLE on timeout", async () => { + // Use a separate server that never responds + const slowServer = createServer((req, res) => { + // intentionally never end the response + }); + slowServer.listen(0, "127.0.0.1"); + await once(slowServer, "listening"); + const addr = slowServer.address(); + const slowUrl = `http://${addr.address}:${addr.port}`; + + const call = createClient( + { baseUrl: slowUrl, pathPrefix: "", timeoutMs: 200 }, + validSecret + ); + try { + await assert.rejects(() => call("/ok", {}), (err) => { + assert.match(err.message, /timed out after 200ms/); + return true; + }); + } finally { + slowServer.closeAllConnections?.(); + await new Promise((r) => slowServer.close(() => r())); + } + }); + + it("throws INVALID_ARGUMENT for invalid baseUrl", () => { + assert.throws( + () => createClient({ baseUrl: "not-a-valid-url", pathPrefix: "" }, validSecret), + /config.baseUrl is not a valid URL/ + ); + }); + + it("throws UNAUTHENTICATED when baseUrl is missing", () => { + assert.throws( + () => createClient({}, validSecret), + /config.baseUrl is required/ + ); + }); + + it("throws UNAUTHENTICATED when appKey/appSecret are missing", () => { + assert.throws( + () => createClient({ baseUrl: "http://127.0.0.1" }, {}), + /secret.appKey and secret.appSecret are required/ + ); + }); +}); diff --git a/services/qianxin__caasm_v1/test/mappers.test.js b/services/qianxin__caasm_v1/test/mappers.test.js new file mode 100644 index 00000000..ebfc8f7b --- /dev/null +++ b/services/qianxin__caasm_v1/test/mappers.test.js @@ -0,0 +1,180 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; + +import { + buildRequestBody, + buildPageResponse, + API_PATHS, + MAX_LIMITS, +} from "../src/mappers.js"; + +describe("buildRequestBody", () => { + it("defaults offset and limit when all zeros", () => { + const body = buildRequestBody({ offset: 0, limit: 0 }); + assert.equal(body.offset, 0); + assert.equal(body.limit, 10); + }); + + it("clamps limit at maxLimit", () => { + const body = buildRequestBody({ limit: 500 }, { maxLimit: 100 }); + assert.equal(body.limit, 100); + }); + + it("clamps limit at largeTable max", () => { + const body = buildRequestBody({ limit: 200 }, { maxLimit: 10 }); + assert.equal(body.limit, 10); + }); + + it("clamps negative offset to 0", () => { + const body = buildRequestBody({ offset: -5 }); + assert.equal(body.offset, 0); + }); + + it("preserves valid offset and limit", () => { + const body = buildRequestBody({ offset: 20, limit: 30 }); + assert.equal(body.offset, 20); + assert.equal(body.limit, 30); + }); + + it("includes loginName when provided", () => { + const body = buildRequestBody({ loginName: "testuser" }); + assert.equal(body.login_name, "testuser"); + }); + + it("excludes login_name when not provided", () => { + const body = buildRequestBody({}); + assert.ok(!("login_name" in body)); + }); + + it("parses filter JSON string", () => { + const body = buildRequestBody({ filter: '{"asset_code":"Dev-ABC"}' }); + assert.deepEqual(body.filter, { asset_code: "Dev-ABC" }); + }); + + it("ignores empty filter string", () => { + const body = buildRequestBody({ filter: "" }); + assert.ok(!body.filter); + }); + + it("ignores blank filter string", () => { + const body = buildRequestBody({ filter: " " }); + assert.ok(!body.filter); + }); + + it("rejects invalid JSON filter", () => { + assert.throws( + () => buildRequestBody({ filter: "{not json" }), + /filter must be valid JSON/ + ); + }); + + it("rejects non-object JSON filter (primitive)", () => { + assert.throws( + () => buildRequestBody({ filter: "123" }), + /filter must be a JSON object/ + ); + }); + + it("rejects non-object JSON filter (null)", () => { + assert.throws( + () => buildRequestBody({ filter: "null" }), + /filter must be a JSON object/ + ); + }); + + it("rejects non-object JSON filter (array)", () => { + assert.throws( + () => buildRequestBody({ filter: "[1,2,3]" }), + /filter must be a JSON object/ + ); + }); +}); + +describe("buildPageResponse", () => { + it("returns json string with items and total", () => { + const raw = { + items: [{ name: "Item1" }, { name: "Item2" }], + total: 100, + }; + const resp = buildPageResponse(raw, { offset: 0, limit: 10 }); + const data = JSON.parse(resp.json); + assert.equal(data.total, 100); + assert.equal(data.items.length, 2); + // Fields are plain JSON, no proto Struct wrapper + assert.equal(data.items[0].name, "Item1"); + }); + + it("handles empty items", () => { + const resp = buildPageResponse({ items: [], total: 0 }); + const data = JSON.parse(resp.json); + assert.deepEqual(data.items, []); + assert.equal(data.total, 0); + }); + + it("handles missing total", () => { + const resp = buildPageResponse({ items: [{ x: 1 }] }); + const data = JSON.parse(resp.json); + assert.equal(data.total, 0); + assert.equal(data.items.length, 1); + }); + + it("client-side slices to requested limit", () => { + const raw = { + items: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }], + total: 100, + }; + const resp = buildPageResponse(raw, { offset: 0, limit: 2 }); + const data = JSON.parse(resp.json); + assert.equal(data.total, 100); + assert.equal(data.items.length, 2); + assert.equal(data.items[0].id, 1); + }); + + it("client-side slices with offset", () => { + const raw = { + items: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }], + total: 100, + }; + const resp = buildPageResponse(raw, { offset: 2, limit: 2 }); + const data = JSON.parse(resp.json); + assert.equal(data.items.length, 2); + assert.equal(data.items[0].id, 3); + assert.equal(data.items[1].id, 4); + }); + + it("preserves null, nested objects, arrays", () => { + const raw = { + items: [{ + name: "test", + value: null, + nested: { inner: "val" }, + arr: [1, 2, 3], + flag: true, + }], + total: 1, + }; + const resp = buildPageResponse(raw); + const data = JSON.parse(resp.json); + const item = data.items[0]; + assert.equal(item.name, "test"); + assert.equal(item.value, null); + assert.deepEqual(item.nested, { inner: "val" }); + assert.deepEqual(item.arr, [1, 2, 3]); + assert.equal(item.flag, true); + }); +}); + +describe("API_PATHS", () => { + it("has expected path mappings", () => { + assert.equal(API_PATHS.dev, "/api/entity/dev"); + assert.equal(API_PATHS.service, "/api/entity/service"); + assert.equal(API_PATHS.user, "/api/system/user/list"); + }); +}); + +describe("MAX_LIMITS", () => { + it("has expected values", () => { + assert.equal(MAX_LIMITS.default, 100); + assert.equal(MAX_LIMITS.largeTable, 10); + }); +});