diff --git a/services/geyecloud__atd_v2-3-6/README.md b/services/geyecloud__atd_v2-3-6/README.md new file mode 100644 index 00000000..04cde68b --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/README.md @@ -0,0 +1,85 @@ +# geyecloud-ATD OctoBus Service + +Read-only OctoBus service adapter for the geyecloud-ATD / Advanced Threat Detection workbench API. + +## Supported Product + +- Product: geyecloud-ATD, shown by the UI as "高级威胁检测系统" +- API document: `ATD API接口汇总.pdf` +- Validated environment shape: `https://:5443/workbenchApi/furious/...` + +## Authentication + +The UI login returns an `apiKey`. Runtime calls send it as the `api-key` header. The browser bundle also emits ATD request metadata headers: + +- `api-key: ` +- `user-key: ""` +- `X-Ca-Timestamp` +- `X-Ca-Nonce` +- `X-Ca-Sign: md5(timestamp + nonce)` + +The adapter does not store usernames or passwords. + +## Configuration + +```json +{ + "baseUrl": "https://192.0.2.10:5443", + "skipTlsVerify": true, + "timeoutMs": 10000 +} +``` + +## Secret + +```json +{ + "apiKey": "" +} +``` + +## Methods + +| RPC | Upstream endpoint | Risk | +|---|---|---| +| `AggregateThreatEvents` | `POST /workbenchApi/furious/elasticSearch/aggregate` | read-only | +| `GetNetworkLogTimeTrend` | `POST /workbenchApi/furious/netWorkLog/timeTrend` | read-only | +| `GetNetworkLogDimensionStatistics` | `POST /workbenchApi/furious/netWorkLog/dimensionStatistics` | read-only | +| `ListNetworkLogs` | `POST /workbenchApi/furious/netWorkLog/list` | read-only | +| `GetNetworkLogDetail` | `POST /workbenchApi/furious/netWorkLog/details` | read-only | +| `ListFileDetectionLogs` | `POST /workbenchApi/furious/fileDetectionLog/list` | read-only | +| `GetFileDetectionLogDetail` | `POST /workbenchApi/furious/fileDetectionLog/details` | read-only | +| `GetFileDetectionSeverityStats` | `POST /workbenchApi/furious/fileDetectionLog/severityNumber` | read-only | +| `ListSceneMonitors` | `POST /workbenchApi/furious/sceneMonitor/querySceneMonitor` | read-only | +| `SearchSceneMonitorEvents` | `POST /workbenchApi/furious/sceneMonitor/searchFromMonitor` | read-only | +| `GetSituationOverview` | `GET /workbenchApi/furious/situationAwareness/...` | read-only | +| `SearchThreatEvents` | `POST /workbenchApi/furious/searchCenter/advanced/searchData` | read-only | +| `GetThreatEventDetail` | `POST /workbenchApi/furious/searchCenter/advanced/searchDataDetail` | read-only | + +## Suggested Capset + +Use this service in a read-only investigation capset, for example `geyecloud-atd-readonly-investigation`. + +## Validation + +Local checks: + +```bash +npm test +npm run validate +npm run pack:check +``` + +Real API validation performed against the provided demo environment: + +- Request: `POST /workbenchApi/furious/elasticSearch/aggregate` +- Headers: `api-key` masked, ATD signature headers present +- Body: `{"terms":"severity","tableName":"hw","from":1,"size":10,...}` +- Result: `HTTP 200`, `code: 200`, `msg: "success"`, aggregate buckets returned +- P0 expansion adds mocked coverage for file detection pagination, situation overview GET calls, and advanced threat search response mapping. + +## Known Limits + +- First version is intentionally read-only. +- Login automation is not part of the adapter because the current environment has captcha enabled. Provide an `apiKey` from login/API-key provisioning as the secret. +- The PDF contains many writable configuration and management APIs; those are deferred until a controlled write-test object and cleanup path are available. diff --git a/services/geyecloud__atd_v2-3-6/bin/geyecloud-atd.js b/services/geyecloud__atd_v2-3-6/bin/geyecloud-atd.js new file mode 100755 index 00000000..2d8c5748 --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/bin/geyecloud-atd.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("./geyecloud-atd.js", import.meta.url)), +}); diff --git a/services/geyecloud__atd_v2-3-6/config.schema.json b/services/geyecloud__atd_v2-3-6/config.schema.json new file mode 100644 index 00000000..b3f96912 --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/config.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "baseUrl" + ], + "properties": { + "baseUrl": { + "type": "string", + "title": "geyecloud-ATD Base URL", + "description": "geyecloud-ATD workbench base URL, for example https://192.0.2.10:5443" + }, + "timeoutMs": { + "type": "integer", + "title": "Request Timeout (ms)", + "default": 10000, + "minimum": 1000 + }, + "skipTlsVerify": { + "type": "boolean", + "title": "Skip TLS Verification", + "default": false + } + } +} diff --git a/services/geyecloud__atd_v2-3-6/package.json b/services/geyecloud__atd_v2-3-6/package.json new file mode 100644 index 00000000..513222d1 --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/package.json @@ -0,0 +1,26 @@ +{ + "name": "geyecloud-atd", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "README.md", + "bin/", + "src/", + "proto/", + "service.json", + "config.schema.json", + "secret.schema.json" + ], + "bin": { + "geyecloud-atd": "bin/geyecloud-atd.js" + }, + "scripts": { + "test": "node --test test/*.test.js", + "pack:check": "npm pack --dry-run", + "validate": "octobus-sdk validate --strict" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0" + } +} diff --git a/services/geyecloud__atd_v2-3-6/proto/geyecloud_atd.proto b/services/geyecloud__atd_v2-3-6/proto/geyecloud_atd.proto new file mode 100644 index 00000000..332edb9a --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/proto/geyecloud_atd.proto @@ -0,0 +1,346 @@ +syntax = "proto3"; + +package geyecloud.atd.v1; + +service GEYECloudATD { + rpc AggregateThreatEvents(AggregateThreatEventsRequest) returns (AggregateThreatEventsResponse); + rpc GetNetworkLogTimeTrend(NetworkLogQueryRequest) returns (NetworkLogTimeTrendResponse); + rpc GetNetworkLogDimensionStatistics(NetworkLogDimensionStatisticsRequest) returns (NetworkLogDimensionStatisticsResponse); + rpc ListNetworkLogs(ListNetworkLogsRequest) returns (ListNetworkLogsResponse); + rpc GetNetworkLogDetail(GetNetworkLogDetailRequest) returns (GetNetworkLogDetailResponse); + rpc ListFileDetectionLogs(ListFileDetectionLogsRequest) returns (ListFileDetectionLogsResponse); + rpc GetFileDetectionLogDetail(GetFileDetectionLogDetailRequest) returns (GetFileDetectionLogDetailResponse); + rpc GetFileDetectionSeverityStats(FileDetectionQueryRequest) returns (FileDetectionSeverityStatsResponse); + rpc ListSceneMonitors(ListSceneMonitorsRequest) returns (ListSceneMonitorsResponse); + rpc SearchSceneMonitorEvents(SearchSceneMonitorEventsRequest) returns (SearchThreatEventsResponse); + rpc GetSituationOverview(GetSituationOverviewRequest) returns (GetSituationOverviewResponse); + rpc SearchThreatEvents(SearchThreatEventsRequest) returns (SearchThreatEventsResponse); + rpc GetThreatEventDetail(GetThreatEventDetailRequest) returns (GetThreatEventDetailResponse); +} + +message AggregateThreatEventsRequest { + int64 start = 1; + int64 end = 2; + int32 page = 3; + int32 page_size = 4; + string terms = 5; + string table_name = 6; +} + +message AggregateItem { + string key = 1; + int64 value = 2; +} + +message AggregateThreatEventsResponse { + int32 code = 1; + string message = 2; + int64 count = 3; + int64 total_count = 4; + repeated AggregateItem items = 5; +} + +message NetworkLogQueryRequest { + int64 start = 1; + int64 end = 2; + string src_ip = 3; + string dst_ip = 4; + string app_proto = 5; + string host = 6; + string query_string = 7; + string filter_query_string = 8; +} + +message TrendPoint { + int64 timestamp = 1; + string time = 2; + int64 count = 3; +} + +message NetworkLogTimeTrendResponse { + int32 code = 1; + string message = 2; + repeated TrendPoint items = 3; +} + +message NetworkLogDimensionStatisticsRequest { + int64 start = 1; + int64 end = 2; + string dimension_key = 3; + string src_ip = 4; + string dst_ip = 5; + string app_proto = 6; + string host = 7; + string query_string = 8; + string filter_query_string = 9; + int32 page = 10; + int32 page_size = 11; +} + +message DimensionStatistic { + string key = 1; + string name = 2; + int64 value = 3; + int64 count = 4; +} + +message NetworkLogDimensionStatisticsResponse { + int32 code = 1; + string message = 2; + repeated DimensionStatistic items = 3; +} + +message ListNetworkLogsRequest { + int64 start = 1; + int64 end = 2; + string src_ip = 3; + string dst_ip = 4; + string src_port = 5; + string dst_port = 6; + string app_proto = 7; + string host = 8; + string uri = 9; + string uid = 10; + string uuid = 11; + string query_string = 12; + string filter_query_string = 13; + string sort = 14; + string order = 15; + int32 page = 16; + int32 page_size = 17; + bool is_translate = 18; +} + +message NetworkLog { + int64 timestamp = 1; + string uid = 2; + string src_ip = 3; + int32 src_port = 4; + string dst_ip = 5; + int32 dst_port = 6; + string app_proto = 7; + string host = 8; + string uri = 9; + string proto = 10; + int64 bytes = 11; + int64 packets = 12; + int64 sensor_id = 13; +} + +message ListNetworkLogsResponse { + int32 code = 1; + string message = 2; + int64 total = 3; + int32 page_size = 4; + int32 current = 5; + int32 pages = 6; + repeated NetworkLog items = 7; +} + +message GetNetworkLogDetailRequest { + string detail_id = 1; + int64 query_timestamp = 2; + int64 start = 3; + int64 end = 4; +} + +message GetNetworkLogDetailResponse { + int32 code = 1; + string message = 2; + NetworkLog log = 3; +} + +message FileDetectionQueryRequest { + int64 start = 1; + int64 end = 2; + string file_md5 = 3; + string email_from = 4; + string src_ip = 5; + string dst_ip = 6; + string app_proto = 7; + string host = 8; + string query_string = 9; + string filter_query_string = 10; + string file_type = 11; +} + +message ListFileDetectionLogsRequest { + int64 start = 1; + int64 end = 2; + string file_md5 = 3; + string email_from = 4; + string src_ip = 5; + string dst_ip = 6; + string app_proto = 7; + string host = 8; + string query_string = 9; + string filter_query_string = 10; + string file_type = 11; + string sort = 12; + string order = 13; + int32 page = 14; + int32 page_size = 15; + bool is_translate = 16; +} + +message FileDetectionLog { + int64 timestamp = 1; + string uuid = 2; + string file_name = 3; + string file_md5 = 4; + string file_type = 5; + string file_size = 6; + string src_ip = 7; + string dst_ip = 8; + int32 severity = 9; + string classtype = 10; + string category = 11; + string engine_type = 12; + int64 sensor_id = 13; +} + +message ListFileDetectionLogsResponse { + int32 code = 1; + string message = 2; + int64 total = 3; + int32 page_size = 4; + int32 current = 5; + int32 pages = 6; + repeated FileDetectionLog items = 7; +} + +message GetFileDetectionLogDetailRequest { + string detail_id = 1; + int64 query_timestamp = 2; + int64 start = 3; + int64 end = 4; + string file_md5 = 5; +} + +message GetFileDetectionLogDetailResponse { + int32 code = 1; + string message = 2; + FileDetectionLog log = 3; +} + +message FileDetectionSeverityStatsResponse { + int32 code = 1; + string message = 2; + int64 total = 3; + int64 danger = 4; + int64 high = 5; + int64 middle = 6; + int64 low = 7; + int64 safe = 8; +} + +message ListSceneMonitorsRequest { + string name = 1; + int32 page = 2; + int32 page_size = 3; + string table_name = 4; + int64 id = 5; + int32 type = 6; + int64 start = 7; + int64 end = 8; + string index_name = 9; + string user_name = 10; +} + +message SceneMonitor { + string raw_json = 1; +} + +message ListSceneMonitorsResponse { + int32 code = 1; + string message = 2; + int64 total = 3; + repeated SceneMonitor items = 4; +} + +message SearchSceneMonitorEventsRequest { + int64 scene_id = 1; + int64 start = 2; + int64 end = 3; + int32 page = 4; + int32 page_size = 5; + string table_name = 6; +} + +message GetSituationOverviewRequest {} + +message SituationSection { + string name = 1; + string raw_json = 2; +} + +message GetSituationOverviewResponse { + int32 code = 1; + string message = 2; + repeated SituationSection sections = 3; +} + +message SearchThreatEventsRequest { + int64 start = 1; + int64 end = 2; + string table_name = 3; + string severity = 4; + string category = 5; + string classtype = 6; + string src_ip = 7; + string dst_ip = 8; + string attack_status = 9; + string app_proto = 10; + string sensor_id = 11; + string visit_direction = 12; + string append_query = 13; + string quick_query = 14; + string filter_query = 15; + string filter_string = 16; + string order_by = 17; + string order = 18; + int32 page = 19; + int32 page_size = 20; +} + +message ThreatEvent { + int64 timestamp = 1; + string uuid = 2; + int32 severity = 3; + string category = 4; + string classtype = 5; + string src_ip = 6; + string dst_ip = 7; + string attack_status = 8; + string kill_chain = 9; + string app_proto = 10; + int64 sensor_id = 11; + string event_source = 12; + string severity_text = 13; +} + +message SearchThreatEventsResponse { + int32 code = 1; + string message = 2; + int64 total = 3; + int32 page_size = 4; + int32 current = 5; + int32 pages = 6; + repeated ThreatEvent items = 7; +} + +message GetThreatEventDetailRequest { + string uuid = 1; + string sensor_id = 2; + string ip = 3; + int64 start = 4; + int64 end = 5; + string table_name = 6; +} + +message GetThreatEventDetailResponse { + int32 code = 1; + string message = 2; + ThreatEvent event = 3; + string raw_json = 4; +} diff --git a/services/geyecloud__atd_v2-3-6/secret.schema.json b/services/geyecloud__atd_v2-3-6/secret.schema.json new file mode 100644 index 00000000..113ed003 --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/secret.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "apiKey" + ], + "properties": { + "apiKey": { + "type": "string", + "title": "geyecloud-ATD API Key", + "description": "API key returned by ATD login or generated for API access. Sent as the api-key header." + } + } +} diff --git a/services/geyecloud__atd_v2-3-6/service.json b/services/geyecloud__atd_v2-3-6/service.json new file mode 100644 index 00000000..e499d11b --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/service.json @@ -0,0 +1,77 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "geyecloud-atd", + "displayName": "geyecloud-ATD", + "description": "Read-only OctoBus adapter for geyecloud-ATD threat aggregation and network log query APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/geyecloud_atd.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "geyecloud.atd.v1.GEYECloudATD/AggregateThreatEvents": { + "name": "aggregate-threat-events", + "description": "Aggregate threat events by severity, kill chain, attack status, or class type." + }, + "geyecloud.atd.v1.GEYECloudATD/GetNetworkLogTimeTrend": { + "name": "get-network-log-time-trend", + "description": "Query network log count trend over time." + }, + "geyecloud.atd.v1.GEYECloudATD/GetNetworkLogDimensionStatistics": { + "name": "get-network-log-dimension-statistics", + "description": "Aggregate network logs by a selected dimension." + }, + "geyecloud.atd.v1.GEYECloudATD/ListNetworkLogs": { + "name": "list-network-logs", + "description": "List network logs with time range, filters, sorting, and pagination." + }, + "geyecloud.atd.v1.GEYECloudATD/GetNetworkLogDetail": { + "name": "get-network-log-detail", + "description": "Get one network log detail by detail ID and timestamp." + }, + "geyecloud.atd.v1.GEYECloudATD/ListFileDetectionLogs": { + "name": "list-file-detection-logs", + "description": "List file detection logs with time range, filters, sorting, and pagination." + }, + "geyecloud.atd.v1.GEYECloudATD/GetFileDetectionLogDetail": { + "name": "get-file-detection-log-detail", + "description": "Get one file detection log detail by UUID and timestamp." + }, + "geyecloud.atd.v1.GEYECloudATD/GetFileDetectionSeverityStats": { + "name": "get-file-detection-severity-stats", + "description": "Query file detection severity counters." + }, + "geyecloud.atd.v1.GEYECloudATD/ListSceneMonitors": { + "name": "list-scene-monitors", + "description": "List configured scene monitors." + }, + "geyecloud.atd.v1.GEYECloudATD/SearchSceneMonitorEvents": { + "name": "search-scene-monitor-events", + "description": "Search events for one scene monitor." + }, + "geyecloud.atd.v1.GEYECloudATD/GetSituationOverview": { + "name": "get-situation-overview", + "description": "Query common read-only situation overview dashboard sections." + }, + "geyecloud.atd.v1.GEYECloudATD/SearchThreatEvents": { + "name": "search-threat-events", + "description": "Search threat events through the advanced search API." + }, + "geyecloud.atd.v1.GEYECloudATD/GetThreatEventDetail": { + "name": "get-threat-event-detail", + "description": "Get one threat event detail by UUID and optional sensor/IP filters." + } + } + } + } +} diff --git a/services/geyecloud__atd_v2-3-6/src/geyecloud-atd.js b/services/geyecloud__atd_v2-3-6/src/geyecloud-atd.js new file mode 100644 index 00000000..eb0e5a7b --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/src/geyecloud-atd.js @@ -0,0 +1,586 @@ +import crypto from "node:crypto"; +import http from "node:http"; +import https from "node:https"; +import { GrpcError, grpcStatus } from "@chaitin-ai/octobus-sdk"; + +const API_PREFIX = "/workbenchApi/furious"; +const DEFAULT_TIMEOUT_MS = 10_000; + +const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null && value !== ""); + +const getReq = (ctx) => ctx?.request ?? ctx?.req ?? {}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), +}); + +const toTrimmedString = (value) => { + if (value === undefined || value === null) return ""; + return String(value).trim(); +}; + +const toInt = (value, fallback = 0) => { + const number = Number(value); + return Number.isFinite(number) ? Math.trunc(number) : fallback; +}; + +const toBool = (value, fallback = false) => { + if (value === undefined || value === null || value === "") return fallback; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["false", "0", "no"].includes(normalized)) return false; + if (["true", "1", "yes"].includes(normalized)) return true; + } + return Boolean(value); +}; + +const requireString = (value, field) => { + const str = toTrimmedString(value); + if (!str) throw new GrpcError(grpcStatus.INVALID_ARGUMENT, `${field} is required`); + return str; +}; + +const requireInt64 = (value, field) => { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) { + throw new GrpcError(grpcStatus.INVALID_ARGUMENT, `${field} must be a positive millisecond timestamp`); + } + return Math.trunc(number); +}; + +const requirePositiveInteger = (value, field) => { + const number = Number(value); + if (!Number.isFinite(number) || number <= 0) { + throw new GrpcError(grpcStatus.INVALID_ARGUMENT, `${field} must be a positive integer`); + } + return Math.trunc(number); +}; + +export const normalizeBaseUrl = (value) => { + const baseUrl = requireString(value, "config.baseUrl"); + let parsed; + try { + parsed = new URL(baseUrl); + } catch { + throw new GrpcError(grpcStatus.INVALID_ARGUMENT, "config.baseUrl is not a valid URL"); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new GrpcError(grpcStatus.INVALID_ARGUMENT, "config.baseUrl must use http or https"); + } + parsed.username = ""; + parsed.password = ""; + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); +}; + +const resolveTimeoutMs = (bindings = {}) => { + const raw = Number(bindings.timeoutMs ?? DEFAULT_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS; +}; + +const shouldSkipTlsVerify = (bindings = {}) => + Boolean(bindings.skipTlsVerify ?? bindings.tlsInsecureSkipVerify ?? bindings.insecureSkipVerify); + +export const buildAuthHeaders = (apiKey, timestamp = Date.now(), nonce = crypto.randomUUID()) => { + const timestampText = String(timestamp); + return { + "Content-Type": "application/json", + "api-key": requireString(apiKey, "secret.apiKey"), + "user-key": "", + "X-Ca-Timestamp": timestampText, + "X-Ca-Nonce": nonce, + "X-Ca-Sign": crypto.createHash("md5").update(`${timestampText}${nonce}`).digest("hex"), + }; +}; + +const buildUrl = (baseUrl, path) => new URL(`${baseUrl}${API_PREFIX}${path}`); + +const httpRequest = (url, options, bindings) => + new Promise((resolve, reject) => { + if (typeof globalThis.__geyeCloudAtdTestRequest === "function") { + Promise.resolve(globalThis.__geyeCloudAtdTestRequest(url, options, bindings)).then(resolve, reject); + return; + } + const body = options.body ? Buffer.from(options.body) : undefined; + const transport = url.protocol === "https:" ? https : http; + const req = transport.request( + url, + { + method: options.method, + headers: { + ...options.headers, + ...(body ? { "Content-Length": String(body.length) } : {}), + }, + rejectUnauthorized: !shouldSkipTlsVerify(bindings), + }, + (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + res.on("end", () => { + resolve({ + statusCode: res.statusCode ?? 500, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + }, + ); + req.on("error", reject); + req.setTimeout(resolveTimeoutMs(bindings), () => { + req.destroy(new Error("request timeout")); + }); + if (body) req.write(body); + req.end(); + }); + +const mapUpstreamError = (json) => { + const code = toInt(json?.code, 0); + const message = json?.msg || json?.message || `upstream returned code ${code}`; + if ([401, 40101, 40102, 40203, 40204].includes(code)) { + return new GrpcError(grpcStatus.UNAUTHENTICATED, message); + } + if ([403, 40201, 40205].includes(code)) { + return new GrpcError(grpcStatus.PERMISSION_DENIED, message); + } + if (code === 404) return new GrpcError(grpcStatus.NOT_FOUND, message); + if (code >= 500) return new GrpcError(grpcStatus.UNAVAILABLE, message); + return new GrpcError(grpcStatus.UNKNOWN, message); +}; + +const looksLikePagination = (json) => + Boolean(json && typeof json === "object" && (Array.isArray(json.records) || json.total !== undefined)); + +const normalizeSuccessEnvelope = (json) => { + if (looksLikePagination(json)) { + return { + msg: json.msg ?? "success", + code: toInt(json.code, 200), + data: json, + }; + } + if (looksLikePagination(json?.data)) return json; + return json; +}; + +const callAtd = async (ctx, path, body, requestOptions = {}) => { + const bindings = mergedBindings(ctx); + const baseUrl = normalizeBaseUrl(bindings.baseUrl ?? bindings.host); + const apiKey = requireString(bindings.apiKey, "secret.apiKey"); + const method = requestOptions.method ?? "POST"; + let response; + try { + response = await httpRequest( + buildUrl(baseUrl, path), + { + method, + headers: buildAuthHeaders(apiKey), + body: method === "GET" ? undefined : JSON.stringify(body ?? {}), + }, + bindings, + ); + } catch (err) { + throw new GrpcError(grpcStatus.UNAVAILABLE, err?.message || "upstream request failed"); + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new GrpcError(grpcStatus.UNAVAILABLE, `upstream http ${response.statusCode}`); + } + + let json; + try { + json = JSON.parse(response.body); + } catch { + throw new GrpcError(grpcStatus.UNKNOWN, "upstream response is not valid JSON"); + } + + const normalized = normalizeSuccessEnvelope(json); + if (toInt(normalized.code, 0) !== 200 || (normalized.msg && normalized.msg !== "success")) { + throw mapUpstreamError(json); + } + return normalized; +}; + +const baseTimeRange = (req) => ({ + start: requireInt64(firstDefined(req.start, req.startTime), "start"), + end: requireInt64(firstDefined(req.end, req.endTime), "end"), +}); + +const addOptional = (target, pairs) => { + for (const [key, value] of pairs) { + if (value !== undefined && value !== null && value !== "") target[key] = value; + } + return target; +}; + +const networkLogFilters = (req) => + addOptional(baseTimeRange(req), [ + ["srcIp", toTrimmedString(firstDefined(req.src_ip, req.srcIp))], + ["dstIp", toTrimmedString(firstDefined(req.dst_ip, req.dstIp))], + ["appProto", toTrimmedString(firstDefined(req.app_proto, req.appProto))], + ["host", toTrimmedString(req.host)], + ["queryString", toTrimmedString(firstDefined(req.query_string, req.queryString))], + ["filterQueryString", toTrimmedString(firstDefined(req.filter_query_string, req.filterQueryString))], + ]); + +const fileDetectionFilters = (req) => + addOptional(baseTimeRange(req), [ + ["fileMd5", toTrimmedString(firstDefined(req.file_md5, req.fileMd5))], + ["emailFrom", toTrimmedString(firstDefined(req.email_from, req.emailFrom))], + ["srcIp", toTrimmedString(firstDefined(req.src_ip, req.srcIp))], + ["dstIp", toTrimmedString(firstDefined(req.dst_ip, req.dstIp))], + ["appProto", toTrimmedString(firstDefined(req.app_proto, req.appProto))], + ["host", toTrimmedString(req.host)], + ["queryString", toTrimmedString(firstDefined(req.query_string, req.queryString))], + ["filterQueryString", toTrimmedString(firstDefined(req.filter_query_string, req.filterQueryString))], + ["fileType", toTrimmedString(firstDefined(req.file_type, req.fileType))], + ]); + +const mapNetworkLog = (item = {}) => ({ + timestamp: toInt(item.timestamp, 0), + uid: toTrimmedString(firstDefined(item.uid, item.uuid)), + src_ip: toTrimmedString(item.src_ip), + src_port: toInt(item.src_port, 0), + dst_ip: toTrimmedString(item.dst_ip), + dst_port: toInt(item.dst_port, 0), + app_proto: toTrimmedString(item.app_proto), + host: toTrimmedString(item.host), + uri: toTrimmedString(item.uri), + proto: toTrimmedString(item.proto), + bytes: toInt(item.bytes, 0), + packets: toInt(item.packets, 0), + sensor_id: toInt(item.sensor_id, 0), +}); + +const mapFileDetectionLog = (item = {}) => ({ + timestamp: toInt(item.timestamp, 0), + uuid: toTrimmedString(item.uuid), + file_name: toTrimmedString(item.file_name), + file_md5: toTrimmedString(item.file_md5), + file_type: toTrimmedString(item.file_type), + file_size: toTrimmedString(item.file_size), + src_ip: toTrimmedString(item.src_ip), + dst_ip: toTrimmedString(item.dst_ip), + severity: toInt(item.severity, 0), + classtype: toTrimmedString(item.classtype), + category: toTrimmedString(item.category), + engine_type: toTrimmedString(item.engine_type), + sensor_id: toInt(item.sensor_id, 0), +}); + +const mapThreatEvent = (item = {}) => ({ + timestamp: toInt(item.timestamp, 0), + uuid: toTrimmedString(item.uuid), + severity: toInt(item.severity, 0), + severity_text: typeof item.severity === "string" ? toTrimmedString(item.severity) : toTrimmedString(item.severity_text), + category: toTrimmedString(item.category), + classtype: toTrimmedString(item.classtype), + src_ip: toTrimmedString(item.src_ip), + dst_ip: toTrimmedString(item.dst_ip), + attack_status: toTrimmedString(item.attack_status), + kill_chain: toTrimmedString(item.kill_chain), + app_proto: toTrimmedString(item.app_proto), + sensor_id: toInt(item.sensor_id, 0), + event_source: toTrimmedString(item.event_source), +}); + +const pagedResponse = (json, mapper) => { + const data = looksLikePagination(json) ? json : json.data ?? {}; + return { + code: toInt(json.code, 200), + message: json.msg ?? "success", + total: toInt(data.total, 0), + page_size: toInt(data.size, 0), + current: toInt(data.current, 0), + pages: toInt(data.page, 0), + items: Array.isArray(data.records) ? data.records.map(mapper) : [], + }; +}; + +const advancedThreatFilters = (req, { requireTimeRange = true } = {}) => + addOptional(requireTimeRange ? baseTimeRange(req) : {}, [ + ["tableName", toTrimmedString(firstDefined(req.table_name, req.tableName)) || "hw"], + ["from", toInt(firstDefined(req.page, req.from), 1)], + ["size", toInt(firstDefined(req.page_size, req.pageSize, req.size), 20)], + ["severity", toTrimmedString(req.severity)], + ["category", toTrimmedString(req.category)], + ["classtype", toTrimmedString(req.classtype)], + ["src_ip", toTrimmedString(firstDefined(req.src_ip, req.srcIp))], + ["dst_ip", toTrimmedString(firstDefined(req.dst_ip, req.dstIp))], + ["attack_status", toTrimmedString(firstDefined(req.attack_status, req.attackStatus))], + ["app_proto", toTrimmedString(firstDefined(req.app_proto, req.appProto))], + ["sensor_id", toTrimmedString(firstDefined(req.sensor_id, req.sensorId))], + ["visit_direction", toTrimmedString(firstDefined(req.visit_direction, req.visitDirection))], + ["appendQuery", toTrimmedString(firstDefined(req.append_query, req.appendQuery))], + ["quickQuery", toTrimmedString(firstDefined(req.quick_query, req.quickQuery))], + ["filterQuery", toTrimmedString(firstDefined(req.filter_query, req.filterQuery))], + ["filterString", toTrimmedString(firstDefined(req.filter_string, req.filterString))], + ["orderBy", toTrimmedString(firstDefined(req.order_by, req.orderBy))], + ["order", toTrimmedString(req.order) || "desc"], + ]); + +const sceneMonitorFilters = (req) => + addOptional( + { + from: toInt(firstDefined(req.page, req.from), 1), + size: toInt(firstDefined(req.page_size, req.pageSize, req.size), 10), + }, + [ + ["name", toTrimmedString(req.name)], + ["tableName", toTrimmedString(firstDefined(req.table_name, req.tableName))], + ["id", firstDefined(req.id, req.scene_id, req.sceneId)], + ["type", firstDefined(req.type, req.scene_type, req.sceneType)], + ["start", firstDefined(req.start, req.startTime)], + ["end", firstDefined(req.end, req.endTime)], + ["indexName", toTrimmedString(firstDefined(req.index_name, req.indexName))], + ["userName", toTrimmedString(firstDefined(req.user_name, req.userName))], + ], + ); + +const SITUATION_SECTIONS = [ + ["logs_statistics", "/situationAwareness/security_pyramid/logs_statistics"], + ["access_logs_trend", "/situationAwareness/security_pyramid/access_logs_trend"], + ["event_classification", "/situationAwareness/host-threat-event/event_classification"], + ["hot_event", "/situationAwareness/host-threat-event/hot_event"], + ["attacker_total", "/situationAwareness/attacker/total"], +]; + +export const handlers = { + "geyecloud.atd.v1.GEYECloudATD/AggregateThreatEvents": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd(ctx, "/elasticSearch/aggregate", { + ...baseTimeRange(req), + from: toInt(firstDefined(req.page, req.from), 1), + size: toInt(firstDefined(req.page_size, req.pageSize, req.size), 10), + terms: requireString(req.terms, "terms"), + tableName: toTrimmedString(firstDefined(req.table_name, req.tableName)) || "hw", + }); + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + count: toInt(json.agg?.count, 0), + total_count: toInt(json.agg?.totalCount, 0), + items: Array.isArray(json.agg?.list) + ? json.agg.list.map((item) => ({ key: toTrimmedString(item.key), value: toInt(item.value, 0) })) + : [], + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/GetNetworkLogTimeTrend": async (ctx) => { + const json = await callAtd(ctx, "/netWorkLog/timeTrend", networkLogFilters(getReq(ctx))); + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + items: Array.isArray(json.data) + ? json.data.map((item) => ({ + timestamp: toInt(item.timestamp, 0), + time: toTrimmedString(item.time), + count: toInt(item.count, 0), + })) + : [], + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/GetNetworkLogDimensionStatistics": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd( + ctx, + "/netWorkLog/dimensionStatistics", + addOptional( + { + ...networkLogFilters(req), + dimensionKey: requireString(firstDefined(req.dimension_key, req.dimensionKey), "dimension_key"), + }, + [ + ["pageNo", toInt(firstDefined(req.page, req.pageNo), 1)], + ["size", toInt(firstDefined(req.page_size, req.pageSize, req.size), 10)], + ], + ), + ); + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + items: Array.isArray(json.data) + ? json.data.map((item) => ({ + key: toTrimmedString(item.key), + name: toTrimmedString(item.name), + value: toInt(item.value, 0), + count: toInt(item.count, 0), + })) + : [], + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/ListNetworkLogs": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd( + ctx, + "/netWorkLog/list", + addOptional(networkLogFilters(req), [ + ["srcPort", toTrimmedString(firstDefined(req.src_port, req.srcPort))], + ["dstPort", toTrimmedString(firstDefined(req.dst_port, req.dstPort))], + ["uri", toTrimmedString(req.uri)], + ["uid", toTrimmedString(req.uid)], + ["uuid", toTrimmedString(req.uuid)], + ["sort", toTrimmedString(req.sort)], + ["order", toTrimmedString(req.order) || "desc"], + ["pageNo", toInt(firstDefined(req.page, req.pageNo), 1)], + ["size", toInt(firstDefined(req.page_size, req.pageSize, req.size), 20)], + ["isTranslate", toBool(firstDefined(req.is_translate, req.isTranslate), false)], + ]), + ); + const data = json.data ?? {}; + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + total: toInt(data.total, 0), + page_size: toInt(data.size, 0), + current: toInt(data.current, 0), + pages: toInt(data.page, 0), + items: Array.isArray(data.records) ? data.records.map(mapNetworkLog) : [], + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/GetNetworkLogDetail": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd( + ctx, + "/netWorkLog/details", + addOptional( + { + uuid: requireString(firstDefined(req.detail_id, req.detailId, req.uuid), "detail_id"), + queryTimestamp: requireInt64(firstDefined(req.query_timestamp, req.queryTimestamp), "query_timestamp"), + }, + [ + ["start", firstDefined(req.start, req.startTime)], + ["end", firstDefined(req.end, req.endTime)], + ], + ), + ); + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + log: mapNetworkLog(json.data ?? {}), + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/ListFileDetectionLogs": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd( + ctx, + "/fileDetectionLog/list", + addOptional(fileDetectionFilters(req), [ + ["sort", toTrimmedString(req.sort)], + ["order", toTrimmedString(req.order) || "desc"], + ["pageNo", toInt(firstDefined(req.page, req.pageNo), 1)], + ["size", toInt(firstDefined(req.page_size, req.pageSize, req.size), 20)], + ["isTranslate", toBool(firstDefined(req.is_translate, req.isTranslate), false)], + ]), + ); + return pagedResponse(json, mapFileDetectionLog); + }, + + "geyecloud.atd.v1.GEYECloudATD/GetFileDetectionLogDetail": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd( + ctx, + "/fileDetectionLog/details", + addOptional( + { + detailId: requireString(firstDefined(req.detail_id, req.detailId, req.uuid), "detail_id"), + queryTimestamp: requireInt64(firstDefined(req.query_timestamp, req.queryTimestamp), "query_timestamp"), + }, + [ + ["start", firstDefined(req.start, req.startTime)], + ["end", firstDefined(req.end, req.endTime)], + ["fileMd5", toTrimmedString(firstDefined(req.file_md5, req.fileMd5))], + ], + ), + ); + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + log: mapFileDetectionLog(json.data ?? {}), + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/GetFileDetectionSeverityStats": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd(ctx, "/fileDetectionLog/severityNumber", fileDetectionFilters(req)); + const data = json.data ?? {}; + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + total: toInt(data.total, 0), + danger: toInt(data.danger, 0), + high: toInt(data.high, 0), + middle: toInt(data.middle, 0), + low: toInt(data.low, 0), + safe: toInt(data.safe, 0), + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/ListSceneMonitors": async (ctx) => { + const json = await callAtd(ctx, "/sceneMonitor/querySceneMonitor", sceneMonitorFilters(getReq(ctx))); + const data = looksLikePagination(json) ? json : json.data ?? {}; + const records = Array.isArray(data.records) ? data.records : Array.isArray(data.data?.records) ? data.data.records : []; + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + total: toInt(firstDefined(data.total, data.data?.total), 0), + items: records.map((item) => ({ raw_json: JSON.stringify(item) })), + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/SearchSceneMonitorEvents": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd( + ctx, + "/sceneMonitor/searchFromMonitor", + { + ...sceneMonitorFilters(req), + id: requirePositiveInteger(firstDefined(req.id, req.scene_id, req.sceneId), "scene_id"), + }, + ); + return pagedResponse(json, mapThreatEvent); + }, + + "geyecloud.atd.v1.GEYECloudATD/GetSituationOverview": async (ctx) => { + const sections = []; + for (const [name, path] of SITUATION_SECTIONS) { + const json = await callAtd(ctx, path, undefined, { method: "GET" }); + sections.push({ name, raw_json: JSON.stringify(json.data ?? null) }); + } + return { + code: 200, + message: "success", + sections, + }; + }, + + "geyecloud.atd.v1.GEYECloudATD/SearchThreatEvents": async (ctx) => { + const json = await callAtd(ctx, "/searchCenter/advanced/searchData", advancedThreatFilters(getReq(ctx))); + return pagedResponse(json, mapThreatEvent); + }, + + "geyecloud.atd.v1.GEYECloudATD/GetThreatEventDetail": async (ctx) => { + const req = getReq(ctx); + const json = await callAtd( + ctx, + "/searchCenter/advanced/searchDataDetail", + addOptional(advancedThreatFilters(req, { requireTimeRange: false }), [ + ["uuid", requireString(req.uuid, "uuid")], + ["sensorId", toTrimmedString(firstDefined(req.sensor_id, req.sensorId))], + ["ip", toTrimmedString(req.ip)], + ]), + ); + return { + code: toInt(json.code, 0), + message: json.msg ?? "", + event: mapThreatEvent(json.data ?? {}), + raw_json: JSON.stringify(json.data ?? null), + }; + }, +}; diff --git a/services/geyecloud__atd_v2-3-6/src/service.js b/services/geyecloud__atd_v2-3-6/src/service.js new file mode 100644 index 00000000..8404fb3a --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./geyecloud-atd.js"; + +export { handlers } from "./geyecloud-atd.js"; + +export const service = defineService({ handlers }); diff --git a/services/geyecloud__atd_v2-3-6/test/geyecloud-atd.test.js b/services/geyecloud__atd_v2-3-6/test/geyecloud-atd.test.js new file mode 100644 index 00000000..be92bb66 --- /dev/null +++ b/services/geyecloud__atd_v2-3-6/test/geyecloud-atd.test.js @@ -0,0 +1,405 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import test from "node:test"; + +import { grpcStatus } from "@chaitin-ai/octobus-sdk"; + +import { buildAuthHeaders, handlers, normalizeBaseUrl } from "../src/geyecloud-atd.js"; + +const assertGrpcError = async (fn, code, message) => { + await assert.rejects(fn, (err) => { + assert.equal(err.code, code); + if (message) assert.match(err.message, message); + return true; + }); +}; + +test("normalizeBaseUrl removes UI hash and trailing slash", () => { + assert.equal(normalizeBaseUrl("https://192.0.2.10:5443/#/workBench"), "https://192.0.2.10:5443"); +}); + +test("normalizeBaseUrl rejects malformed and unsupported URLs", () => { + assert.throws( + () => normalizeBaseUrl("not a url"), + (err) => { + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /valid URL/); + return true; + }, + ); + assert.throws( + () => normalizeBaseUrl("ftp://192.0.2.10"), + (err) => { + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.match(err.message, /http or https/); + return true; + }, + ); +}); + +test("normalizeBaseUrl strips embedded credentials, query, and hash", () => { + assert.equal( + normalizeBaseUrl("https://user:pass@192.0.2.10:5443/?token=secret#/workBench"), + "https://192.0.2.10:5443", + ); +}); + +test("buildAuthHeaders sends api-key and ATD signature headers", () => { + const headers = buildAuthHeaders("dummy-api-key", 1774249367000, "nonce-value"); + assert.equal(headers["api-key"], "dummy-api-key"); + assert.equal(headers["user-key"], ""); + assert.equal(headers["X-Ca-Timestamp"], "1774249367000"); + assert.equal(headers["X-Ca-Nonce"], "nonce-value"); + assert.equal(headers["X-Ca-Sign"], crypto.createHash("md5").update("1774249367000nonce-value").digest("hex")); +}); + +test("AggregateThreatEvents accepts lowerCamelCase request fields and maps aggregate response", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/AggregateThreatEvents"]; + const calls = []; + globalThis.__geyeCloudAtdTestRequest = async (url, options) => { + calls.push({ url, options }); + return { + statusCode: 200, + body: JSON.stringify({ + msg: "success", + code: 200, + agg: { + count: 2, + totalCount: 12, + list: [ + { key: "high", value: 10 }, + { key: "low", value: 2 } + ] + } + }) + }; + }; + try { + const result = await handler({ + request: { + start: 1773644567000, + end: 1774249367000, + page: 1, + pageSize: 10, + terms: "severity", + tableName: "hw" + }, + config: { baseUrl: "https://atd.example.com/#/workBench", skipTlsVerify: true }, + secret: { apiKey: "dummy-api-key" } + }); + assert.equal(result.code, 200); + assert.equal(result.count, 2); + assert.equal(result.total_count, 12); + assert.equal(result.items[0].key, "high"); + assert.equal(calls[0].url.pathname, "/workbenchApi/furious/elasticSearch/aggregate"); + assert.equal(calls[0].options.headers["api-key"], "dummy-api-key"); + assert.deepEqual(JSON.parse(calls[0].options.body), { + start: 1773644567000, + end: 1774249367000, + from: 1, + size: 10, + terms: "severity", + tableName: "hw" + }); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("ListFileDetectionLogs maps file detection pagination", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/ListFileDetectionLogs"]; + const calls = []; + globalThis.__geyeCloudAtdTestRequest = async (url, options) => { + calls.push({ url, options }); + return { + statusCode: 200, + body: JSON.stringify({ + msg: "success", + code: 200, + data: { + total: 1, + size: 20, + current: 1, + page: 1, + records: [ + { + timestamp: 1774249367000, + uuid: "evt-1", + file_name: "sample.exe", + file_md5: "44d88612fea8a8f36de82e1278abb02f", + file_type: "exe", + file_size: "12KB", + src_ip: "192.0.2.20", + dst_ip: "198.51.100.10", + severity: 3, + classtype: "恶意文件", + category: "特洛伊木马通信", + engine_type: "sandbox", + sensor_id: 1001 + } + ] + } + }) + }; + }; + try { + const result = await handler({ + request: { + start: 1773644567000, + end: 1774249367000, + page: 1, + pageSize: 20, + isTranslate: "false", + fileMd5: "44d88612fea8a8f36de82e1278abb02f" + }, + config: { baseUrl: "https://atd.example.com", skipTlsVerify: true }, + secret: { apiKey: "dummy-api-key" } + }); + assert.equal(calls[0].url.pathname, "/workbenchApi/furious/fileDetectionLog/list"); + const requestBody = JSON.parse(calls[0].options.body); + assert.equal(requestBody.fileMd5, "44d88612fea8a8f36de82e1278abb02f"); + assert.equal(requestBody.isTranslate, false); + assert.equal(result.total, 1); + assert.equal(result.items[0].file_name, "sample.exe"); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("GetSituationOverview performs readonly dashboard GET calls", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/GetSituationOverview"]; + const calls = []; + globalThis.__geyeCloudAtdTestRequest = async (url, options) => { + calls.push({ url, options }); + return { + statusCode: 200, + body: JSON.stringify({ msg: "success", code: 200, data: { value: calls.length } }) + }; + }; + try { + const result = await handler({ + request: {}, + config: { baseUrl: "https://atd.example.com", skipTlsVerify: true }, + secret: { apiKey: "dummy-api-key" } + }); + assert.equal(calls[0].options.method, "GET"); + assert.equal(calls[0].url.pathname, "/workbenchApi/furious/situationAwareness/security_pyramid/logs_statistics"); + assert.equal(result.sections.length, 5); + assert.equal(result.sections[0].name, "logs_statistics"); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("SearchThreatEvents maps advanced search response", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/SearchThreatEvents"]; + const calls = []; + globalThis.__geyeCloudAtdTestRequest = async (url, options) => { + calls.push({ url, options }); + return { + statusCode: 200, + body: JSON.stringify({ + total: 1, + size: 10, + current: 1, + page: 1, + records: [ + { + timestamp: 1774249367000, + uuid: "evt-2", + severity: "高危", + category: "特洛伊木马通信", + classtype: "恶意文件", + src_ip: "192.0.2.20", + dst_ip: "198.51.100.10", + attack_status: "攻击成功", + kill_chain: "命令控制", + app_proto: "https", + sensor_id: 1001, + event_source: "atd" + } + ] + }) + }; + }; + try { + const result = await handler({ + request: { + start: 1773644567000, + end: 1774249367000, + tableName: "hw", + srcIp: "192.0.2.20", + page: 1, + pageSize: 10 + }, + config: { baseUrl: "https://atd.example.com", skipTlsVerify: true }, + secret: { apiKey: "dummy-api-key" } + }); + assert.equal(calls[0].url.pathname, "/workbenchApi/furious/searchCenter/advanced/searchData"); + assert.equal(JSON.parse(calls[0].options.body).src_ip, "192.0.2.20"); + assert.equal(result.total, 1); + assert.equal(result.items[0].kill_chain, "命令控制"); + assert.equal(result.items[0].severity_text, "高危"); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("ListSceneMonitors maps unwrapped scene monitor pagination", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/ListSceneMonitors"]; + globalThis.__geyeCloudAtdTestRequest = async () => ({ + statusCode: 200, + body: JSON.stringify({ + total: 1, + size: 1, + current: 1, + page: 1, + records: [{ monitorId: 1, monitorName: "test" }] + }) + }); + try { + const result = await handler({ + request: { page: 1, pageSize: 1 }, + config: { baseUrl: "https://atd.example.com", skipTlsVerify: true }, + secret: { apiKey: "dummy-api-key" } + }); + assert.equal(result.total, 1); + assert.equal(JSON.parse(result.items[0].raw_json).monitorId, 1); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("GetNetworkLogDetail sends detail id as upstream uuid", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/GetNetworkLogDetail"]; + const calls = []; + globalThis.__geyeCloudAtdTestRequest = async (url, options) => { + calls.push({ url, options }); + return { + statusCode: 200, + body: JSON.stringify({ + msg: "success", + code: 200, + data: { + uuid: "net-1", + uid: "net-1", + timestamp: 1774249367000, + src_ip: "192.0.2.20", + dst_ip: "198.51.100.10" + } + }) + }; + }; + try { + const result = await handler({ + request: { + detailId: "net-1", + queryTimestamp: 1774249367000, + start: 1773644567000, + end: 1774249367000 + }, + config: { baseUrl: "https://atd.example.com", skipTlsVerify: true }, + secret: { apiKey: "dummy-api-key" } + }); + assert.equal(calls[0].url.pathname, "/workbenchApi/furious/netWorkLog/details"); + assert.equal(JSON.parse(calls[0].options.body).uuid, "net-1"); + assert.equal(JSON.parse(calls[0].options.body).detailId, undefined); + assert.equal(result.log.uid, "net-1"); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("upstream HTTP errors are mapped to unavailable", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/AggregateThreatEvents"]; + globalThis.__geyeCloudAtdTestRequest = async () => ({ + statusCode: 503, + body: "service unavailable", + }); + try { + await assertGrpcError( + () => + handler({ + request: { start: 1773644567000, end: 1774249367000, terms: "severity" }, + config: { baseUrl: "https://atd.example.com" }, + secret: { apiKey: "dummy-api-key" }, + }), + grpcStatus.UNAVAILABLE, + /upstream http 503/, + ); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("invalid JSON upstream responses are mapped to unknown", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/AggregateThreatEvents"]; + globalThis.__geyeCloudAtdTestRequest = async () => ({ + statusCode: 200, + body: "not json", + }); + try { + await assertGrpcError( + () => + handler({ + request: { start: 1773644567000, end: 1774249367000, terms: "severity" }, + config: { baseUrl: "https://atd.example.com" }, + secret: { apiKey: "dummy-api-key" }, + }), + grpcStatus.UNKNOWN, + /not valid JSON/, + ); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("network exceptions are mapped to unavailable", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/AggregateThreatEvents"]; + globalThis.__geyeCloudAtdTestRequest = async () => { + throw new Error("request timeout"); + }; + try { + await assertGrpcError( + () => + handler({ + request: { start: 1773644567000, end: 1774249367000, terms: "severity" }, + config: { baseUrl: "https://atd.example.com" }, + secret: { apiKey: "dummy-api-key" }, + }), + grpcStatus.UNAVAILABLE, + /request timeout/, + ); + } finally { + delete globalThis.__geyeCloudAtdTestRequest; + } +}); + +test("upstream application errors are mapped to grpc statuses", async () => { + const handler = handlers["geyecloud.atd.v1.GEYECloudATD/AggregateThreatEvents"]; + const cases = [ + [401, grpcStatus.UNAUTHENTICATED], + [403, grpcStatus.PERMISSION_DENIED], + [404, grpcStatus.NOT_FOUND], + [500, grpcStatus.UNAVAILABLE], + ]; + + for (const [upstreamCode, grpcCode] of cases) { + globalThis.__geyeCloudAtdTestRequest = async () => ({ + statusCode: 200, + body: JSON.stringify({ code: upstreamCode, msg: `error ${upstreamCode}` }), + }); + await assertGrpcError( + () => + handler({ + request: { start: 1773644567000, end: 1774249367000, terms: "severity" }, + config: { baseUrl: "https://atd.example.com" }, + secret: { apiKey: "dummy-api-key" }, + }), + grpcCode, + new RegExp(`error ${upstreamCode}`), + ); + } + + delete globalThis.__geyeCloudAtdTestRequest; +});