diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 4124b804..6c0a31ee 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -21,6 +21,10 @@ const services = { entryFile: "../chaitin__cloudatlas/bin/cloudatlas.js", serviceModule: "../chaitin__cloudatlas/src/service.js", }, + "t-answer-ndr": { + entryFile: "../chaitin__t-answer-ndr/bin/t-answer-ndr.js", + serviceModule: "../chaitin__t-answer-ndr/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/bin/t-answer-ndr.js b/services/bin/t-answer-ndr.js new file mode 100755 index 00000000..12b5e70b --- /dev/null +++ b/services/bin/t-answer-ndr.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__t-answer-ndr/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../chaitin__t-answer-ndr/bin/t-answer-ndr.js", import.meta.url)), +}); diff --git a/services/chaitin__t-answer-ndr/README.md b/services/chaitin__t-answer-ndr/README.md new file mode 100644 index 00000000..177e0a3c --- /dev/null +++ b/services/chaitin__t-answer-ndr/README.md @@ -0,0 +1,101 @@ +# Chaitin T-Answer NDR OctoBus Service + +OctoBus service package for Chaitin T-Answer (TA, 全悉/Quanxi), an NDR/full-traffic detection product. + +This package exposes security-operations APIs from the supplied product OpenAPI +document and the offline pcap detection web workflow. Network configuration and +user/permission administration APIs are intentionally excluded. High-frequency +read-only methods keep typed request fields; complex mutating methods use +`raw_params_json` to pass the upstream JSON-RPC params unchanged. + +Core read-only methods include: + +- `ListAlerts` -> `AlarmService.SearchAlarmList` +- `GetAlert` -> `AlarmService.GetAlarm` +- `GetAlertRawDocument` -> `AlarmService.GetAlarmDocument` +- `ListAssets` -> `AssetService.GetAssetList` +- `GetAsset` -> `AssetService.GetAssetInfo` +- `SearchAssetTree` -> `AssetService.SearchAssetTree` +- `ListAssetGroups` -> `AssetService.SearchGroups` +- `ListAssetTags` -> `AssetService.SearchTags` +- `ListDiscoveredAssets` -> `AssetIdentifyApi.SearchAssetIdentify` + +## Configuration + +`config.endpoint` is the management platform base URL: + +```json +{ + "endpoint": "https://t-answer.example.com", + "timeoutMs": 5000, + "skipTlsVerify": true +} +``` + +`secret.apiToken` is sent as the `API-Token` header. Offline pcap upload and +task creation can also use service-held web console credentials: + +```json +{ + "apiToken": "replace-with-device-api-token", + "webUsername": "replace-with-web-username", + "webPassword": "replace-with-web-password" +} +``` + +`skipTlsVerify` passes OctoBus-compatible per-request TLS options to the runtime. +When running this package directly with Node.js against a private certificate, +TLS verification can be disabled for the isolated service process with: + +```bash +NODE_TLS_REJECT_UNAUTHORIZED=0 +``` + +## Import Into OctoBus + +```bash +octobus service import t-answer-ndr ./services/chaitin__t-answer-ndr + +octobus instance create t-answer-ndr-demo \ + --service t-answer-ndr \ + --config-json '{"endpoint":"https://t-answer.example.com","timeoutMs":5000,"skipTlsVerify":true}' \ + --secret-json '{"apiToken":"replace-with-device-api-token"}' + +octobus capset create soc-agent --name SOCAgent +octobus capset add-instance soc-agent t-answer-ndr-demo +octobus catalog soc-agent --all --json +``` + +## Local CLI Example + +```bash +OCTOBUS_SERVICE_CONTEXT='{"config":{"endpoint":"https://t-answer.example.com","timeoutMs":5000,"skipTlsVerify":true},"secret":{"apiToken":"replace-with-device-api-token"}}' \ +node bin/t-answer-ndr.js list-assets --data-json '{"count":10,"offset":0}' +``` + +## Risk Boundary + +This package contains write, delete, enable/disable, block-rule, whitelist, and +tcpdump operations. The service does not implement an extra `approval_token` +gate; query/write separation should be enforced by OctoBus capsets, capset token +distribution, and the calling agent's human-approval workflow. For routine +demonstrations, prefer read-only tools such as `ListAssets`, `ListAlerts`, +`SearchAssetTree`, `ListPcapDetectTasks`, and `ListTcpdumpProcesses`. + +Recommended capset design: + +- Read-only SOC capset: list/search/get/analyze/report methods. +- Operator capset: read-only methods plus scoped write methods such as pcap + task creation, tcpdump task control, custom intelligence/rule maintenance, + and response whitelist/block-rule operations. +- Exclude full-delete methods such as `DeleteAllFirewallWhiteList` and + `DeleteAllBlockRules` unless the operator workflow explicitly requires them. + +## Local Checks + +```bash +cd services +npm run validate -- --service-dir chaitin__t-answer-ndr +npm test -- --service-dir chaitin__t-answer-ndr +npm pack --dry-run +``` diff --git a/services/chaitin__t-answer-ndr/bin/t-answer-ndr.js b/services/chaitin__t-answer-ndr/bin/t-answer-ndr.js new file mode 100755 index 00000000..508272f0 --- /dev/null +++ b/services/chaitin__t-answer-ndr/bin/t-answer-ndr.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__t-answer-ndr/config.schema.json b/services/chaitin__t-answer-ndr/config.schema.json new file mode 100644 index 00000000..a0ce18ad --- /dev/null +++ b/services/chaitin__t-answer-ndr/config.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "endpoint": { + "type": "string", + "description": "Management platform base URL, for example https://t-answer.example.com." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "JSON-RPC request timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Pass OctoBus-compatible per-request TLS verification bypass options to the runtime." + } + }, + "required": [ + "endpoint" + ] +} diff --git a/services/chaitin__t-answer-ndr/package.json b/services/chaitin__t-answer-ndr/package.json new file mode 100644 index 00000000..035e92a4 --- /dev/null +++ b/services/chaitin__t-answer-ndr/package.json @@ -0,0 +1,15 @@ +{ + "name": "t-answer-ndr", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "t-answer-ndr": "bin/t-answer-ndr.js" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0" + } +} diff --git a/services/chaitin__t-answer-ndr/proto/t_answer_ndr.proto b/services/chaitin__t-answer-ndr/proto/t_answer_ndr.proto new file mode 100644 index 00000000..40b4302a --- /dev/null +++ b/services/chaitin__t-answer-ndr/proto/t_answer_ndr.proto @@ -0,0 +1,296 @@ +syntax = "proto3"; + +package chaitin.t_answer_ndr.v1; + +service TAnswerNdrService { + rpc ListAlerts(ListAlertsRequest) returns (JsonRpcResult) {} + rpc SearchAttackRecords(SearchAttackRecordsRequest) returns (JsonRpcResult) {} + rpc AnalyzeIpActivity(IpActivityRequest) returns (JsonRpcResult) {} + rpc HuntThreats(HuntThreatsRequest) returns (JsonRpcResult) {} + rpc InvestigateAttackCampaign(HuntThreatsRequest) returns (JsonRpcResult) {} + rpc AssessIpThreatProfile(IpActivityRequest) returns (JsonRpcResult) {} + rpc GetAlert(GetAlertRequest) returns (JsonRpcResult) {} + rpc GetAlertRawDocument(GetAlertRawDocumentRequest) returns (JsonRpcResult) {} + rpc CountAlerts(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListAlertAggregations(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListAlertAggregationsT2(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListAlertAggTop(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListAlertChart(RawJsonRequest) returns (JsonRpcResult) {} + rpc LoadAlertPcapFile(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListAlertPcapFrames(RawJsonRequest) returns (JsonRpcResult) {} + rpc FilterAlertPcapFrames(RawJsonRequest) returns (JsonRpcResult) {} + rpc GetAlertPcapFrame(RawJsonRequest) returns (JsonRpcResult) {} + rpc CheckAlertPcapDownload(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListCustomIntelligences(RawJsonRequest) returns (JsonRpcResult) {} + rpc CreateCustomIntelligence(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateCustomIntelligence(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateCustomIntelligenceStatus(RawJsonRequest) returns (JsonRpcResult) {} + rpc DeleteCustomIntelligence(IdsRequest) returns (JsonRpcResult) {} + rpc ListCustomRules(RawJsonRequest) returns (JsonRpcResult) {} + rpc CreateCustomRule(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateCustomRule(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateCustomRuleStatus(RawJsonRequest) returns (JsonRpcResult) {} + rpc DeleteCustomRule(IdsRequest) returns (JsonRpcResult) {} + rpc CreateAlarmWhiteList(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateAlarmWhiteList(RawJsonRequest) returns (JsonRpcResult) {} + rpc DeleteAlarmWhiteList(IdsActionRequest) returns (JsonRpcResult) {} + rpc ListAssets(ListAssetsRequest) returns (JsonRpcResult) {} + rpc GetAsset(GetAssetRequest) returns (JsonRpcResult) {} + rpc SearchAssetTree(SearchAssetTreeRequest) returns (JsonRpcResult) {} + rpc ListAssetGroups(ListAssetGroupsRequest) returns (JsonRpcResult) {} + rpc ListAssetTags(ListAssetTagsRequest) returns (JsonRpcResult) {} + rpc ListDiscoveredAssets(ListDiscoveredAssetsRequest) returns (JsonRpcResult) {} + rpc BatchCreateFirewallWhiteList(RawJsonRequest) returns (JsonRpcResult) {} + rpc CreateFirewallWhiteList(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateFirewallWhiteList(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateFirewallWhiteListStatus(RawJsonRequest) returns (JsonRpcResult) {} + rpc DeleteFirewallWhiteList(IdsRequest) returns (JsonRpcResult) {} + rpc DeleteAllFirewallWhiteList(RawJsonRequest) returns (JsonRpcResult) {} + rpc CreateBlockRules(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateBlockRules(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateBlockRulesStatus(RawJsonRequest) returns (JsonRpcResult) {} + rpc DeleteAllBlockRules(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListBlockRules(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListBlockRulesTrend(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListTapBlockRecords(RawJsonRequest) returns (JsonRpcResult) {} + rpc CountTapBlocks(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListTopTapBlocks(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListPcapDetectTasks(RawJsonRequest) returns (JsonRpcResult) {} + rpc UploadPcapDetectFiles(UploadPcapDetectFilesRequest) returns (JsonRpcResult) {} + rpc CreatePcapDetectTask(PcapDetectTaskRequest) returns (JsonRpcResult) {} + rpc DeletePcapDetectTask(DeletePcapDetectTaskRequest) returns (JsonRpcResult) {} + rpc AnalyzePcapFiles(AnalyzePcapFilesRequest) returns (JsonRpcResult) {} + rpc ListPcapDetectAlerts(RawJsonRequest) returns (JsonRpcResult) {} + rpc GetPcapDetectAlert(GetAlertRequest) returns (JsonRpcResult) {} + rpc GetPcapDetectAlertRawDocument(GetAlertRawDocumentRequest) returns (JsonRpcResult) {} + rpc DownloadPcap(RawJsonRequest) returns (JsonRpcResult) {} + rpc MultiDownloadPcap(RawJsonRequest) returns (JsonRpcResult) {} + rpc SearchHttpLogs(RawJsonRequest) returns (JsonRpcResult) {} + rpc SearchDnsLogs(RawJsonRequest) returns (JsonRpcResult) {} + rpc SearchTcpUdpLogs(RawJsonRequest) returns (JsonRpcResult) {} + rpc SearchOtherLogs(RawJsonRequest) returns (JsonRpcResult) {} + rpc SearchTrafficLogs(TrafficLogSearchRequest) returns (JsonRpcResult) {} + rpc GetOriginalLogDetail(RawJsonRequest) returns (JsonRpcResult) {} + rpc MultiDownloadLogJson(RawJsonRequest) returns (JsonRpcResult) {} + rpc ListTcpdumpProcesses(RawJsonRequest) returns (JsonRpcResult) {} + rpc CreateTcpdumpProcess(RawJsonRequest) returns (JsonRpcResult) {} + rpc UpdateTcpdumpProcess(RawJsonRequest) returns (JsonRpcResult) {} + rpc StartTcpdumpProcess(IdRequest) returns (JsonRpcResult) {} + rpc CancelTcpdumpProcess(IdRequest) returns (JsonRpcResult) {} + rpc DeleteTcpdumpProcess(IdsRequest) returns (JsonRpcResult) {} + rpc DownloadFile(DownloadFileRequest) returns (DownloadFileResult) {} +} + +message Filter { + string oper = 1; + string target = 2; +} + +message SortField { + string field = 1; + bool ascending = 2; +} + +message JsonRpcResult { + string result_json = 1; +} + +message RawJsonRequest { + string raw_params_json = 1; +} + +message SearchAttackRecordsRequest { + int64 time_range_start = 1; + int64 time_range_end = 2; + int64 days = 3; + string attack_name = 4; + string keyword = 5; + string attacker_ip = 6; + string victim_ip = 7; + string asset_ip = 8; + string severity = 9; + string result = 10; + int64 offset = 11; + int64 count = 12; + string raw_params_json = 100; +} + +message IpActivityRequest { + string ip = 1; + string role = 2; + int64 time_range_start = 3; + int64 time_range_end = 4; + int64 days = 5; + int64 count = 6; + bool include_raw_logs = 7; + string raw_params_json = 100; +} + +message HuntThreatsRequest { + int64 time_range_start = 1; + int64 time_range_end = 2; + int64 days = 3; + string query = 4; + string attack_name = 5; + string attacker_ip = 6; + string victim_ip = 7; + string severity = 8; + int64 count = 9; + string raw_params_json = 100; +} + +message TrafficLogSearchRequest { + string protocol = 1; + int64 time_range_start = 2; + int64 time_range_end = 3; + int64 days = 4; + string src_ip = 5; + string dest_ip = 6; + string ip = 7; + string keyword = 8; + int64 offset = 9; + int64 count = 10; + string raw_params_json = 100; +} + +message IdRequest { + int64 id = 1; + string raw_params_json = 100; +} + +message IdsRequest { + repeated int64 ids = 1; + string raw_params_json = 100; +} + +message IdsActionRequest { + repeated int64 ids = 1; + string action = 2; + string raw_params_json = 100; +} + +message PcapInputFile { + string file_name = 1; + string content_base64 = 2; +} + +message UploadedPcapFile { + string id = 1; + string file_name = 2; +} + +message UploadPcapDetectFilesRequest { + repeated PcapInputFile pcap_files = 1; +} + +message PcapDetectTaskRequest { + repeated UploadedPcapFile files = 1; + int64 detect_pattern = 2; + string raw_params_json = 100; +} + +message DeletePcapDetectTaskRequest { + int64 id = 1; + int64 delete_strategy = 2; + string raw_params_json = 100; +} + +message AnalyzePcapFilesRequest { + repeated PcapInputFile pcap_files = 1; + int64 detect_pattern = 2; + bool wait_for_completion = 3; + int64 poll_interval_ms = 4; + int64 timeout_ms = 5; +} + +message DownloadFileRequest { + string id = 1; + string query = 2; + string query_json = 3; +} + +message DownloadFileResult { + string content_type = 1; + string filename = 2; + string content_base64 = 3; +} + +message ListAlertsRequest { + int64 time_range_start = 1; + int64 time_range_end = 2; + int64 offset = 3; + int64 count = 4; + repeated Filter src_ip = 5; + repeated Filter dest_ip = 6; + repeated Filter attacker = 7; + repeated Filter victim = 8; + repeated Filter severity = 9; + repeated Filter result = 10; + repeated Filter keyword = 11; + repeated Filter name = 12; + repeated Filter tag = 13; + repeated SortField sort = 14; + string raw_params_json = 100; +} + +message GetAlertRequest { + string doc_id = 1; +} + +message GetAlertRawDocumentRequest { + string doc_id = 1; +} + +message ListAssetsRequest { + int64 offset = 1; + int64 count = 2; + int64 id = 3; + int64 group_id = 4; + int64 importance = 5; + string ip = 6; + string mac = 7; + string name = 8; + string asset_type = 9; + repeated int64 tag_id = 10; + string raw_params_json = 100; +} + +message GetAssetRequest { + int64 id = 1; +} + +message SearchAssetTreeRequest { + string keyword = 1; + string raw_params_json = 100; +} + +message ListAssetGroupsRequest { + int64 id = 1; + string name = 2; + string raw_params_json = 100; +} + +message ListAssetTagsRequest { + int64 offset = 1; + int64 count = 2; + string name = 3; + string raw_params_json = 100; +} + +message ListDiscoveredAssetsRequest { + int64 offset = 1; + int64 count = 2; + int64 group_id = 3; + int64 importance = 4; + int64 port = 5; + int64 time_start = 6; + int64 time_end = 7; + string ip_addr = 8; + string mac = 9; + string name = 10; + string os = 11; + string asset_type = 12; + repeated string service = 13; + repeated int64 tag_id = 14; + string raw_params_json = 100; +} diff --git a/services/chaitin__t-answer-ndr/secret.schema.json b/services/chaitin__t-answer-ndr/secret.schema.json new file mode 100644 index 00000000..839b33b7 --- /dev/null +++ b/services/chaitin__t-answer-ndr/secret.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "apiToken": { + "type": "string", + "description": "API token sent in the API-Token header." + }, + "webUsername": { + "type": "string", + "description": "Optional web console username used only for pcap upload and offline detection task APIs that are not covered by API-Token authentication." + }, + "webPassword": { + "type": "string", + "description": "Optional web console password used only for pcap upload and offline detection task APIs that are not covered by API-Token authentication." + } + }, + "required": [ + "apiToken" + ] +} diff --git a/services/chaitin__t-answer-ndr/service.json b/services/chaitin__t-answer-ndr/service.json new file mode 100644 index 00000000..5ac4d05e --- /dev/null +++ b/services/chaitin__t-answer-ndr/service.json @@ -0,0 +1,329 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "t-answer-ndr", + "displayName": "Chaitin T-Answer NDR (全悉/Quanxi)", + "description": "OctoBus service for Chaitin T-Answer (TA, 全悉/Quanxi) NDR full-traffic detection security operations APIs. Network/user-permission configuration APIs are intentionally excluded.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/t_answer_ndr.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlerts": { + "name": "list-alerts", + "description": "Search threat alerts." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchAttackRecords": { + "name": "search-attack-records", + "description": "Scenario tool: search alert records by exact attack_name, keyword, attacker_ip, victim_ip, asset_ip, severity, result, and days or explicit Unix-ms time range." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/AnalyzeIpActivity": { + "name": "analyze-ip-activity", + "description": "Scenario tool: correlate one IP across assets, attacker-side alerts, victim-side alerts, and optional raw logs. Use role=any, attacker, or victim." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/HuntThreats": { + "name": "hunt-threats", + "description": "Scenario tool: threat hunting over recent alerts. Provide query or attack_name plus optional IP/severity filters; returns matching alerts and top attacker/victim/attack-name aggregations." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/InvestigateAttackCampaign": { + "name": "investigate-attack-campaign", + "description": "Report tool: investigate an attack campaign by query or attack_name and return distilled totals, top attack names, top attackers, top victims, sample alerts, and pivot IPs." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/AssessIpThreatProfile": { + "name": "assess-ip-threat-profile", + "description": "Report tool: assess one IP as attacker and victim, returning alert totals, top related entities, sample alerts, and optional traffic-log totals." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAlert": { + "name": "get-alert", + "description": "Get threat alert details." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAlertRawDocument": { + "name": "get-alert-raw-document", + "description": "Get raw alert document." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListCustomIntelligences": { + "name": "list-custom-intelligences", + "description": "Search custom threat intelligence." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateCustomIntelligence": { + "name": "create-custom-intelligence", + "description": "Create custom threat intelligence." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomIntelligence": { + "name": "update-custom-intelligence", + "description": "Update custom threat intelligence." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomIntelligenceStatus": { + "name": "update-custom-intelligence-status", + "description": "Enable or disable custom threat intelligence." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteCustomIntelligence": { + "name": "delete-custom-intelligence", + "description": "Delete custom threat intelligence." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListCustomRules": { + "name": "list-custom-rules", + "description": "Search custom detection rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateCustomRule": { + "name": "create-custom-rule", + "description": "Create a custom detection rule." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomRule": { + "name": "update-custom-rule", + "description": "Update a custom detection rule." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomRuleStatus": { + "name": "update-custom-rule-status", + "description": "Enable or disable custom detection rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteCustomRule": { + "name": "delete-custom-rule", + "description": "Delete custom detection rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateAlarmWhiteList": { + "name": "create-alarm-white-list", + "description": "Create an alarm whitelist rule." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateAlarmWhiteList": { + "name": "update-alarm-white-list", + "description": "Update an alarm whitelist rule." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteAlarmWhiteList": { + "name": "delete-alarm-white-list", + "description": "Delete, enable, or disable alarm whitelist rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssets": { + "name": "list-assets", + "description": "Search managed assets." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAsset": { + "name": "get-asset", + "description": "Get asset details." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchAssetTree": { + "name": "search-asset-tree", + "description": "Search the asset tree." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssetGroups": { + "name": "list-asset-groups", + "description": "List asset groups." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssetTags": { + "name": "list-asset-tags", + "description": "List asset tags." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListDiscoveredAssets": { + "name": "list-discovered-assets", + "description": "Search discovered assets." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/BatchCreateFirewallWhiteList": { + "name": "batch-create-firewall-white-list", + "description": "Batch create response-disposal whitelist entries." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateFirewallWhiteList": { + "name": "create-firewall-white-list", + "description": "Create a response-disposal whitelist entry." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateFirewallWhiteList": { + "name": "update-firewall-white-list", + "description": "Update a response-disposal whitelist entry." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateFirewallWhiteListStatus": { + "name": "update-firewall-white-list-status", + "description": "Enable or disable response-disposal whitelist entries." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteFirewallWhiteList": { + "name": "delete-firewall-white-list", + "description": "Delete response-disposal whitelist entries." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteAllFirewallWhiteList": { + "name": "delete-all-firewall-white-list", + "description": "Delete all response-disposal whitelist entries." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateBlockRules": { + "name": "create-block-rules", + "description": "Create block rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateBlockRules": { + "name": "update-block-rules", + "description": "Update block rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateBlockRulesStatus": { + "name": "update-block-rules-status", + "description": "Enable, disable, or delete block rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteAllBlockRules": { + "name": "delete-all-block-rules", + "description": "Delete all block rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListPcapDetectTasks": { + "name": "list-pcap-detect-tasks", + "description": "List offline pcap detection tasks." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UploadPcapDetectFiles": { + "name": "upload-pcap-detect-files", + "description": "Upload pcap or pcapng files for offline detection. Uses service-held web console credentials when needed." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreatePcapDetectTask": { + "name": "create-pcap-detect-task", + "description": "Create an offline pcap detection task from uploaded file ids. Uses service-held web console credentials when needed." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeletePcapDetectTask": { + "name": "delete-pcap-detect-task", + "description": "Delete an offline pcap detection task. Uses service-held web console credentials when needed." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/AnalyzePcapFiles": { + "name": "analyze-pcap-files", + "description": "Upload pcap files, create offline detection tasks, optionally wait for completion, and return related alerts. Uses service-held web console credentials when needed." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListPcapDetectAlerts": { + "name": "list-pcap-detect-alerts", + "description": "Search offline pcap detection alerts." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetPcapDetectAlert": { + "name": "get-pcap-detect-alert", + "description": "Get offline pcap detection alert details." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetPcapDetectAlertRawDocument": { + "name": "get-pcap-detect-alert-raw-document", + "description": "Get raw offline pcap detection alert document." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListTcpdumpProcesses": { + "name": "list-tcpdump-processes", + "description": "List online packet capture tasks." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateTcpdumpProcess": { + "name": "create-tcpdump-process", + "description": "Create an online packet capture task." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateTcpdumpProcess": { + "name": "update-tcpdump-process", + "description": "Update an online packet capture task." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/StartTcpdumpProcess": { + "name": "start-tcpdump-process", + "description": "Start an online packet capture task." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CancelTcpdumpProcess": { + "name": "cancel-tcpdump-process", + "description": "Cancel an online packet capture task." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteTcpdumpProcess": { + "name": "delete-tcpdump-process", + "description": "Delete online packet capture tasks." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DownloadFile": { + "name": "download-file", + "description": "Download a file from the unified download API." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CountAlerts": { + "name": "count-alerts", + "description": "Count threat alerts with raw JSON-RPC params." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertAggregations": { + "name": "list-alert-aggregations", + "description": "Search aggregated threat alerts." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertAggregationsT2": { + "name": "list-alert-aggregations-t2", + "description": "Search secondary aggregated threat alerts." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertAggTop": { + "name": "list-alert-agg-top", + "description": "Get top threat alert aggregations." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertChart": { + "name": "list-alert-chart", + "description": "Get threat alert time-series chart data." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/LoadAlertPcapFile": { + "name": "load-alert-pcap-file", + "description": "Load an alert pcap file for frame inspection." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertPcapFrames": { + "name": "list-alert-pcap-frames", + "description": "List frames from a loaded alert pcap." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/FilterAlertPcapFrames": { + "name": "filter-alert-pcap-frames", + "description": "Filter frames from an alert pcap using Wireshark-style filters." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAlertPcapFrame": { + "name": "get-alert-pcap-frame", + "description": "Get one frame from an alert pcap." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CheckAlertPcapDownload": { + "name": "check-alert-pcap-download", + "description": "Check whether alert pcap download is available." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListBlockRules": { + "name": "list-block-rules", + "description": "Search block rules." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListBlockRulesTrend": { + "name": "list-block-rules-trend", + "description": "Search block rule trend data." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListTapBlockRecords": { + "name": "list-tap-block-records", + "description": "Search bypass block records." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/CountTapBlocks": { + "name": "count-tap-blocks", + "description": "Get bypass block count statistics." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListTopTapBlocks": { + "name": "list-top-tap-blocks", + "description": "Get top bypass block targets." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/DownloadPcap": { + "name": "download-pcap", + "description": "Create or fetch a pcap download for matching traffic." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/MultiDownloadPcap": { + "name": "multi-download-pcap", + "description": "Create or fetch multi-pcap downloads for matching traffic." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchHttpLogs": { + "name": "search-http-logs", + "description": "Search original HTTP traffic logs." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchDnsLogs": { + "name": "search-dns-logs", + "description": "Search original DNS traffic logs." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchTcpUdpLogs": { + "name": "search-tcp-udp-logs", + "description": "Search original TCP/UDP traffic logs." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchOtherLogs": { + "name": "search-other-logs", + "description": "Search other original traffic logs." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchTrafficLogs": { + "name": "search-traffic-logs", + "description": "Scenario tool: search original traffic logs with typed protocol, src_ip, dest_ip, ip, keyword, days, count, and offset. protocol supports all, http, dns, tcp_udp, and other." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetOriginalLogDetail": { + "name": "get-original-log-detail", + "description": "Get original traffic log details." + }, + "chaitin.t_answer_ndr.v1.TAnswerNdrService/MultiDownloadLogJson": { + "name": "multi-download-log-json", + "description": "Create or fetch JSON exports for original logs." + } + } + } + } +} diff --git a/services/chaitin__t-answer-ndr/src/service.js b/services/chaitin__t-answer-ndr/src/service.js new file mode 100644 index 00000000..65f6a89f --- /dev/null +++ b/services/chaitin__t-answer-ndr/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./t-answer-ndr.js"; + +export { handlers } from "./t-answer-ndr.js"; + +export const service = defineService({ handlers }); diff --git a/services/chaitin__t-answer-ndr/src/t-answer-ndr.js b/services/chaitin__t-answer-ndr/src/t-answer-ndr.js new file mode 100644 index 00000000..29a4a52f --- /dev/null +++ b/services/chaitin__t-answer-ndr/src/t-answer-ndr.js @@ -0,0 +1,1409 @@ +import { GrpcError, grpcStatus } from "@chaitin-ai/octobus-sdk"; + +const DEFAULT_TIMEOUT_MS = 5000; +const DEFAULT_LOOKBACK_DAYS = 30; +const DEFAULT_PAGE_SIZE = 20; +const DEFAULT_PCAP_ANALYSIS_TIMEOUT_MS = 60000; +const MAX_PCAP_ANALYSIS_TIMEOUT_MS = 600000; +const DEFAULT_PCAP_POLL_INTERVAL_MS = 5000; +const MAX_PCAP_POLL_INTERVAL_MS = 60000; +const MIN_PCAP_DURATION_MS = 1000; + +const METHOD = { + listAlerts: "AlarmService.SearchAlarmList", + getAlert: "AlarmService.GetAlarm", + getAlertRawDocument: "AlarmService.GetAlarmDocument", + countAlerts: "AlarmService.SearchAlarmCount", + listAlertAggregations: "AlarmService.SearchAlarmAggList", + listAlertAggregationsT2: "AlarmService.SearchAlarmAggListT2", + listAlertAggTop: "AlarmService.SearchAlarmAggTop", + listAlertChart: "AlarmService.SearchAlarmListChart", + loadAlertPcapFile: "AlarmService.LoadPcapFile", + listAlertPcapFrames: "AlarmService.ListPcapFrames", + filterAlertPcapFrames: "AlarmService.FilterPcapFrames", + getAlertPcapFrame: "AlarmService.GetPcapFrame", + checkAlertPcapDownload: "AlarmService.DownloadCheckPcap", + listCustomIntelligences: "AlarmService.SearchAlarmCustomIntelligenceList", + createCustomIntelligence: "AlarmService.CreateAlarmCustomIntelligence", + updateCustomIntelligence: "AlarmService.UpdateAlarmCustomIntelligence", + updateCustomIntelligenceStatus: "AlarmService.UpdateAlarmCustomIntelligenceStatus", + deleteCustomIntelligence: "AlarmService.DeleteAlarmCustomIntelligence", + listCustomRules: "AlarmService.SearchAlarmCustomRuleList", + createCustomRule: "AlarmService.CreateAlarmCustomRule", + updateCustomRule: "AlarmService.UpdateAlarmCustomRule", + updateCustomRuleStatus: "AlarmService.UpdateAlarmCustomRuleStatus", + deleteCustomRule: "AlarmService.DeleteAlarmCustomRule", + createAlarmWhiteList: "AlarmService.CreateWhiteList", + updateAlarmWhiteList: "AlarmService.UpdateWhiteList", + deleteAlarmWhiteList: "AlarmService.DeleteWhiteList", + listAssets: "AssetService.GetAssetList", + getAsset: "AssetService.GetAssetInfo", + searchAssetTree: "AssetService.SearchAssetTree", + listAssetGroups: "AssetService.SearchGroups", + listAssetTags: "AssetService.SearchTags", + listDiscoveredAssets: "AssetIdentifyApi.SearchAssetIdentify", + batchCreateFirewallWhiteList: "FirewallService.BatchCreateWhiteList", + createFirewallWhiteList: "FirewallService.CreateWhiteList", + updateFirewallWhiteList: "FirewallService.UpdateWhiteList", + updateFirewallWhiteListStatus: "FirewallService.UpdateWhiteListStatus", + deleteFirewallWhiteList: "FirewallService.DeleteWhiteList", + deleteAllFirewallWhiteList: "FirewallService.DeleteAllWhiteList", + createBlockRules: "RulesService.CreateBlockRules", + updateBlockRules: "RulesService.UpdateBlockRules", + updateBlockRulesStatus: "RulesService.UpdateBlockRulesStatus", + deleteAllBlockRules: "RulesService.DeleteAllBlockRules", + listBlockRules: "RulesService.SearchBlockRules", + listBlockRulesTrend: "RulesService.SearchBlockRulesTrend", + listTapBlockRecords: "RulesService.SearchTapBlockRecordList", + countTapBlocks: "RulesService.TapBlockCountStatistics", + listTopTapBlocks: "RulesService.TapBlockTop", + listPcapDetectTasks: "PcapDetectService.GetPcapDetectTaskList", + uploadPcapDetectFiles: "PcapDetectUploadService.UploadPcapDetectFiles", + createPcapDetectTask: "PcapDetectService.CreatePcapDetectTask", + deletePcapDetectTask: "PcapDetectService.DeletePcapDetectTask", + listPcapDetectAlerts: "PcapDetectService.SearchPcapDetectAlarmList", + getPcapDetectAlert: "PcapDetectService.GetAlarm", + getPcapDetectAlertRawDocument: "PcapDetectService.GetAlarmDocument", + downloadPcap: "PcapDownloadService.DownloadPcap", + multiDownloadPcap: "PcapDownloadService.MultiDownloadPcap", + searchHttpLogs: "LogSearchService.SearchOrigDataHTTPLog", + searchDnsLogs: "LogSearchService.SearchOrigDataDNSLog", + searchTcpUdpLogs: "LogSearchService.SearchOrigDataTCPUDPLog", + searchOtherLogs: "LogSearchService.SearchOtherOrigDataLog", + getOriginalLogDetail: "LogSearchService.GetOrigDataLogDetail", + multiDownloadLogJson: "LogSearchService.MultiDownloadLogJSON", + listTcpdumpProcesses: "TcpdumpService.SearchTcpdumpProcess", + createTcpdumpProcess: "TcpdumpService.CreateTcpdumpProcess", + updateTcpdumpProcess: "TcpdumpService.UpdateTcpdumpProcess", + startTcpdumpProcess: "TcpdumpService.StartTcpdumpProcess", + cancelTcpdumpProcess: "TcpdumpService.CancelTcpdumpProcess", + deleteTcpdumpProcess: "TcpdumpService.DeleteTcpdumpProcess", +}; + +const FULL_METHOD = { + listAlerts: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlerts", + searchAttackRecords: "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchAttackRecords", + analyzeIpActivity: "chaitin.t_answer_ndr.v1.TAnswerNdrService/AnalyzeIpActivity", + huntThreats: "chaitin.t_answer_ndr.v1.TAnswerNdrService/HuntThreats", + investigateAttackCampaign: "chaitin.t_answer_ndr.v1.TAnswerNdrService/InvestigateAttackCampaign", + assessIpThreatProfile: "chaitin.t_answer_ndr.v1.TAnswerNdrService/AssessIpThreatProfile", + getAlert: "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAlert", + getAlertRawDocument: "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAlertRawDocument", + countAlerts: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CountAlerts", + listAlertAggregations: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertAggregations", + listAlertAggregationsT2: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertAggregationsT2", + listAlertAggTop: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertAggTop", + listAlertChart: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertChart", + loadAlertPcapFile: "chaitin.t_answer_ndr.v1.TAnswerNdrService/LoadAlertPcapFile", + listAlertPcapFrames: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlertPcapFrames", + filterAlertPcapFrames: "chaitin.t_answer_ndr.v1.TAnswerNdrService/FilterAlertPcapFrames", + getAlertPcapFrame: "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAlertPcapFrame", + checkAlertPcapDownload: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CheckAlertPcapDownload", + listCustomIntelligences: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListCustomIntelligences", + createCustomIntelligence: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateCustomIntelligence", + updateCustomIntelligence: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomIntelligence", + updateCustomIntelligenceStatus: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomIntelligenceStatus", + deleteCustomIntelligence: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteCustomIntelligence", + listCustomRules: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListCustomRules", + createCustomRule: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateCustomRule", + updateCustomRule: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomRule", + updateCustomRuleStatus: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomRuleStatus", + deleteCustomRule: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteCustomRule", + createAlarmWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateAlarmWhiteList", + updateAlarmWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateAlarmWhiteList", + deleteAlarmWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteAlarmWhiteList", + listAssets: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssets", + getAsset: "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetAsset", + searchAssetTree: "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchAssetTree", + listAssetGroups: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssetGroups", + listAssetTags: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssetTags", + listDiscoveredAssets: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListDiscoveredAssets", + batchCreateFirewallWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/BatchCreateFirewallWhiteList", + createFirewallWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateFirewallWhiteList", + updateFirewallWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateFirewallWhiteList", + updateFirewallWhiteListStatus: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateFirewallWhiteListStatus", + deleteFirewallWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteFirewallWhiteList", + deleteAllFirewallWhiteList: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteAllFirewallWhiteList", + createBlockRules: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateBlockRules", + updateBlockRules: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateBlockRules", + updateBlockRulesStatus: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateBlockRulesStatus", + deleteAllBlockRules: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteAllBlockRules", + listBlockRules: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListBlockRules", + listBlockRulesTrend: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListBlockRulesTrend", + listTapBlockRecords: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListTapBlockRecords", + countTapBlocks: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CountTapBlocks", + listTopTapBlocks: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListTopTapBlocks", + listPcapDetectTasks: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListPcapDetectTasks", + uploadPcapDetectFiles: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UploadPcapDetectFiles", + createPcapDetectTask: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreatePcapDetectTask", + deletePcapDetectTask: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeletePcapDetectTask", + analyzePcapFiles: "chaitin.t_answer_ndr.v1.TAnswerNdrService/AnalyzePcapFiles", + listPcapDetectAlerts: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListPcapDetectAlerts", + getPcapDetectAlert: "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetPcapDetectAlert", + getPcapDetectAlertRawDocument: "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetPcapDetectAlertRawDocument", + downloadPcap: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DownloadPcap", + multiDownloadPcap: "chaitin.t_answer_ndr.v1.TAnswerNdrService/MultiDownloadPcap", + searchHttpLogs: "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchHttpLogs", + searchDnsLogs: "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchDnsLogs", + searchTcpUdpLogs: "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchTcpUdpLogs", + searchOtherLogs: "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchOtherLogs", + searchTrafficLogs: "chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchTrafficLogs", + getOriginalLogDetail: "chaitin.t_answer_ndr.v1.TAnswerNdrService/GetOriginalLogDetail", + multiDownloadLogJson: "chaitin.t_answer_ndr.v1.TAnswerNdrService/MultiDownloadLogJson", + listTcpdumpProcesses: "chaitin.t_answer_ndr.v1.TAnswerNdrService/ListTcpdumpProcesses", + createTcpdumpProcess: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateTcpdumpProcess", + updateTcpdumpProcess: "chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateTcpdumpProcess", + startTcpdumpProcess: "chaitin.t_answer_ndr.v1.TAnswerNdrService/StartTcpdumpProcess", + cancelTcpdumpProcess: "chaitin.t_answer_ndr.v1.TAnswerNdrService/CancelTcpdumpProcess", + deleteTcpdumpProcess: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DeleteTcpdumpProcess", + downloadFile: "chaitin.t_answer_ndr.v1.TAnswerNdrService/DownloadFile", +}; + +const toGrpcError = (code, message) => new GrpcError(code, message); + +const normalizeEndpoint = (endpoint) => { + const value = String(endpoint || "").trim(); + if (!/^https?:\/\//i.test(value)) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "config.endpoint must be an http(s) URL"); + } + return value.replace(/\/+$/, ""); +}; + +const getConfig = (ctx) => { + const config = ctx?.config ?? {}; + const secret = ctx?.secret ?? {}; + if (!secret.apiToken) { + throw toGrpcError(grpcStatus.UNAUTHENTICATED, "secret.apiToken is required"); + } + return { + endpoint: normalizeEndpoint(config.endpoint), + timeoutMs: Number.isInteger(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS, + skipTlsVerify: Boolean(config.skipTlsVerify), + apiToken: secret.apiToken, + }; +}; + +const buildTlsOptions = (skipTlsVerify) => + skipTlsVerify + ? { + insecureSkipVerify: true, + tlsInsecureSkipVerify: true, + skipTlsVerify: true, + } + : {}; + +const getWebCredentials = (ctx) => { + const secret = ctx?.secret ?? {}; + const username = String(secret.webUsername || "").trim(); + const password = String(secret.webPassword || ""); + if (!username || !password) { + throw toGrpcError( + grpcStatus.UNAUTHENTICATED, + "secret.webUsername and secret.webPassword are required for pcap upload/task operations", + ); + } + return { username, password }; +}; + +const nowRequestId = () => `octobus-${Date.now()}-${Math.floor(Math.random() * 100000)}`; + +const statusForJsonRpcError = (error) => { + if (!error || typeof error !== "object") return grpcStatus.UNKNOWN; + if (error.code === 1 || /需要登录|未登录|登录/i.test(String(error.message || ""))) { + return grpcStatus.UNAUTHENTICATED; + } + if (error.code === 2 || error.code === 3 || /权限|permission|forbidden/i.test(String(error.message || ""))) { + return grpcStatus.PERMISSION_DENIED; + } + if (error.code === -32602 || error.code === -32000 || /必填|参数|invalid/i.test(String(error.message || ""))) { + return grpcStatus.INVALID_ARGUMENT; + } + return grpcStatus.FAILED_PRECONDITION; +}; + +async function callJsonRpc(ctx, method, params) { + const config = getConfig(ctx); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.timeoutMs); + let response; + try { + response = await fetch(`${config.endpoint}/rpc`, { + ...buildTlsOptions(config.skipTlsVerify), + method: "POST", + headers: { + "content-type": "application/json;charset=UTF-8", + "API-Token": config.apiToken, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: nowRequestId(), + method, + params, + }), + signal: controller.signal, + }); + } catch (error) { + if (error?.name === "AbortError") { + throw toGrpcError(grpcStatus.DEADLINE_EXCEEDED, `upstream JSON-RPC timeout after ${config.timeoutMs}ms`); + } + throw toGrpcError(grpcStatus.UNAVAILABLE, `upstream JSON-RPC request failed: ${error.message}`); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + if (response.status === 401) { + throw toGrpcError(grpcStatus.UNAUTHENTICATED, "upstream rejected API token"); + } + if (response.status === 403) { + throw toGrpcError(grpcStatus.PERMISSION_DENIED, "upstream permission denied"); + } + if (response.status >= 500) { + throw toGrpcError(grpcStatus.UNAVAILABLE, `upstream HTTP ${response.status}`); + } + throw toGrpcError(grpcStatus.FAILED_PRECONDITION, `upstream HTTP ${response.status}`); + } + + let payload; + try { + payload = await response.json(); + } catch (error) { + throw toGrpcError(grpcStatus.UNKNOWN, `upstream returned non-JSON response: ${error.message}`); + } + + if (payload?.error) { + throw toGrpcError(statusForJsonRpcError(payload.error), payload.error.message || JSON.stringify(payload.error)); + } + + return { + resultJson: JSON.stringify(payload?.result ?? null), + }; +} + +const parseJsonResponse = async (response, description) => { + try { + return await response.json(); + } catch (error) { + throw toGrpcError(grpcStatus.UNKNOWN, `${description} returned non-JSON response: ${error.message}`); + } +}; + +const cookieFromHeaders = (headers) => { + const getSetCookie = typeof headers.getSetCookie === "function" ? headers.getSetCookie() : []; + const raw = getSetCookie.length ? getSetCookie[0] : headers.get("set-cookie"); + const cookie = String(raw || "").split(";")[0].trim(); + if (!cookie) { + throw toGrpcError(grpcStatus.UNAUTHENTICATED, "web login did not return a session cookie"); + } + return cookie; +}; + +async function webLogin(ctx) { + const config = getConfig(ctx); + const { username, password } = getWebCredentials(ctx); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.timeoutMs); + let response; + try { + response = await fetch(`${config.endpoint}/rpc`, { + ...buildTlsOptions(config.skipTlsVerify), + method: "POST", + headers: { + "content-type": "application/json;charset=UTF-8", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: nowRequestId(), + method: "HeraAccountNoAuthService.Login", + params: { username, password }, + }), + signal: controller.signal, + }); + } catch (error) { + if (error?.name === "AbortError") { + throw toGrpcError(grpcStatus.DEADLINE_EXCEEDED, `web login timeout after ${config.timeoutMs}ms`); + } + throw toGrpcError(grpcStatus.UNAVAILABLE, `web login request failed: ${error.message}`); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + throw toGrpcError(grpcStatus.UNAVAILABLE, `web login HTTP ${response.status}`); + } + const payload = await parseJsonResponse(response, "web login"); + if (payload?.error) { + throw toGrpcError(statusForJsonRpcError(payload.error), payload.error.message || JSON.stringify(payload.error)); + } + return cookieFromHeaders(response.headers); +} + +async function callWebJsonRpc(ctx, method, params, sessionCookie) { + const config = getConfig(ctx); + const cookie = sessionCookie || (await webLogin(ctx)); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.timeoutMs); + let response; + try { + response = await fetch(`${config.endpoint}/rpc`, { + ...buildTlsOptions(config.skipTlsVerify), + method: "POST", + headers: { + "content-type": "application/json;charset=UTF-8", + cookie, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: nowRequestId(), + method, + params, + }), + signal: controller.signal, + }); + } catch (error) { + if (error?.name === "AbortError") { + throw toGrpcError(grpcStatus.DEADLINE_EXCEEDED, `upstream web JSON-RPC timeout after ${config.timeoutMs}ms`); + } + throw toGrpcError(grpcStatus.UNAVAILABLE, `upstream web JSON-RPC request failed: ${error.message}`); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + throw toGrpcError(grpcStatus.UNAVAILABLE, `upstream web JSON-RPC HTTP ${response.status}`); + } + const payload = await parseJsonResponse(response, "upstream web JSON-RPC"); + if (payload?.error) { + throw toGrpcError(statusForJsonRpcError(payload.error), payload.error.message || JSON.stringify(payload.error)); + } + + return { + resultJson: JSON.stringify(payload?.result ?? null), + }; +} + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const firstPresent = (obj, ...keys) => { + for (const key of keys) { + if (hasOwn(obj, key) && obj[key] !== undefined && obj[key] !== null && obj[key] !== "") return obj[key]; + } + return undefined; +}; + +const fromValue = (value) => { + if (value === undefined) return undefined; + if (value === null) return null; + if (Array.isArray(value)) return value.map(fromValue); + if (typeof value !== "object") return value; + if (hasOwn(value, "nullValue")) return null; + if (hasOwn(value, "numberValue")) return value.numberValue; + if (hasOwn(value, "stringValue")) return value.stringValue; + if (hasOwn(value, "boolValue")) return value.boolValue; + if (hasOwn(value, "listValue")) return (value.listValue?.values ?? []).map(fromValue); + if (hasOwn(value, "structValue")) return fromStruct(value.structValue); + if (hasOwn(value, "fields")) return fromStruct(value); + const out = {}; + for (const [key, val] of Object.entries(value)) out[key] = fromValue(val); + return out; +}; + +const fromStruct = (struct) => { + const out = {}; + for (const [key, value] of Object.entries(struct?.fields ?? {})) out[key] = fromValue(value); + return out; +}; + +const addIfPresent = (params, req, outKey, ...keys) => { + const value = firstPresent(req, ...keys); + if (value !== undefined) params[outKey] = fromValue(value); +}; + +const addNumericIfPositive = (params, req, outKey, ...keys) => { + const value = Number(firstPresent(req, ...keys)); + if (Number.isInteger(value) && value > 0) params[outKey] = value; +}; + +const addNumericIfNonNegative = (params, req, outKey, ...keys) => { + const raw = firstPresent(req, ...keys); + if (raw === undefined) return; + const value = Number(raw); + if (Number.isInteger(value) && value >= 0) params[outKey] = value; +}; + +const addArrayIfPresent = (params, req, outKey, ...keys) => { + const value = firstPresent(req, ...keys); + if (value === undefined) return; + if (Array.isArray(value)) { + params[outKey] = value.map(fromValue); + return; + } + params[outKey] = fromValue(value); +}; + +const addFilters = (params, req, names) => { + for (const name of names) { + const value = firstPresent(req, name, snakeToCamel(name)); + if (!Array.isArray(value) || value.length === 0) continue; + params[name] = value.map((item) => ({ + oper: firstPresent(item, "oper") ?? "=", + target: fromValue(firstPresent(item, "target")), + })); + } +}; + +const snakeToCamel = (name) => name.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); + +const mergeRawParams = (params, req) => ({ + ...params, + ...parseRawParams(firstPresent(req, "raw_params_json", "rawParamsJson")), +}); + +const parseRawParams = (value) => { + if (value === undefined) return {}; + try { + const parsed = JSON.parse(String(value)); + if (!parsed || typeof parsed !== "object") { + throw new Error("raw_params_json must decode to an object or array"); + } + return parsed; + } catch (error) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `invalid raw_params_json: ${error.message}`); + } +}; + +const buildRawParams = (req) => parseRawParams(firstPresent(req, "raw_params_json", "rawParamsJson")); + +const parsePositiveInteger = (value, fallback) => { + const number = Number(value); + return Number.isInteger(number) && number > 0 ? number : fallback; +}; + +const parseNonNegativeInteger = (value, fallback) => { + const number = Number(value); + return Number.isInteger(number) && number >= 0 ? number : fallback; +}; + +const normalizedString = (value) => String(value ?? "").trim(); + +const eqFilter = (value) => [{ oper: "=", target: String(value) }]; + +const addStringArray = (params, outKey, value) => { + const text = normalizedString(value); + if (text) params[outKey] = [text]; +}; + +const addEqFilter = (params, outKey, value, numeric = false) => { + const text = normalizedString(value); + if (!text) return; + params[outKey] = [{ oper: "=", target: numeric ? Number(text) : text }]; +}; + +const buildTimeRange = (req, defaultDays = DEFAULT_LOOKBACK_DAYS) => { + const explicitStart = Number(firstPresent(req, "time_range_start", "timeRangeStart", "start_time", "startTime")); + const explicitEnd = Number(firstPresent(req, "time_range_end", "timeRangeEnd", "end_time", "endTime")); + if (Number.isInteger(explicitStart) && explicitStart > 0 && Number.isInteger(explicitEnd) && explicitEnd > 0) { + return { time_range_start: explicitStart, time_range_end: explicitEnd }; + } + const days = parsePositiveInteger(firstPresent(req, "days"), defaultDays); + const timeRangeEnd = Number.isInteger(explicitEnd) && explicitEnd > 0 ? explicitEnd : Date.now(); + return { + time_range_start: timeRangeEnd - days * 24 * 60 * 60 * 1000, + time_range_end: timeRangeEnd, + }; +}; + +const buildScenarioAlertParams = (req, options = {}) => { + const params = { + ...buildTimeRange(req, options.defaultDays ?? DEFAULT_LOOKBACK_DAYS), + offset: parseNonNegativeInteger(firstPresent(req, "offset"), 0), + count: parsePositiveInteger(firstPresent(req, "count"), options.defaultCount ?? DEFAULT_PAGE_SIZE), + sort: [{ field: "timestamp", ascending: false }], + }; + const attackName = firstPresent(req, "attack_name", "attackName"); + const query = firstPresent(req, "query", "keyword"); + addEqFilter(params, "name", attackName); + if (!attackName) addStringArray(params, "keyword", query); + addEqFilter(params, "attacker", firstPresent(req, "attacker_ip", "attackerIp")); + addEqFilter(params, "victim", firstPresent(req, "victim_ip", "victimIp")); + addStringArray(params, "asset_ip", firstPresent(req, "asset_ip", "assetIp")); + addEqFilter(params, "severity", firstPresent(req, "severity"), true); + addEqFilter(params, "result", firstPresent(req, "result")); + return mergeRawParams(params, req); +}; + +const buildTrafficLogParams = (req) => { + const params = { + ...buildTimeRange(req, DEFAULT_LOOKBACK_DAYS), + offset: parseNonNegativeInteger(firstPresent(req, "offset"), 0), + count: parsePositiveInteger(firstPresent(req, "count"), DEFAULT_PAGE_SIZE), + sort: [{ field: "timestamp", ascending: false }], + }; + addEqFilter(params, "src_ip", firstPresent(req, "src_ip", "srcIp")); + addEqFilter(params, "dest_ip", firstPresent(req, "dest_ip", "destIp")); + addStringArray(params, "keyword", firstPresent(req, "keyword", "query")); + return mergeRawParams(params, req); +}; + +const buildMutableParams = (req, params) => params; + +const callMutableJsonRpc = (ctx, method, buildParams) => { + const req = ctx.request ?? {}; + return callJsonRpc(ctx, method, buildMutableParams(req, buildParams(req))); +}; + + +const buildIdParams = (req) => mergeRawParams({ id: requireId(req) }, req); + +const buildIdsParams = (req) => { + const rawIds = firstPresent(req, "ids"); + const ids = Array.isArray(rawIds) + ? rawIds.map((item) => Number(fromValue(item))).filter((item) => Number.isInteger(item) && item > 0) + : []; + if (ids.length === 0 && firstPresent(req, "raw_params_json", "rawParamsJson") === undefined) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "ids must contain at least one positive integer"); + } + return mergeRawParams(ids.length > 0 ? { ids } : {}, req); +}; + +const buildIdsActionParams = (req) => { + const params = buildIdsParams(req); + addIfPresent(params, req, "action", "action"); + return params; +}; + +const buildListAlertsParams = (req) => { + const params = {}; + addNumericIfNonNegative(params, req, "offset", "offset"); + addNumericIfPositive(params, req, "count", "count"); + addNumericIfPositive(params, req, "time_range_start", "time_range_start", "timeRangeStart"); + addNumericIfPositive(params, req, "time_range_end", "time_range_end", "timeRangeEnd"); + addArrayIfPresent(params, req, "sort", "sort"); + addFilters(params, req, [ + "src_ip", + "dest_ip", + "attacker", + "victim", + "severity", + "result", + "keyword", + "name", + "tag", + ]); + return mergeRawParams(params, req); +}; + +const buildListAssetsParams = (req) => { + const params = {}; + addNumericIfNonNegative(params, req, "offset", "offset"); + addNumericIfPositive(params, req, "count", "count"); + addNumericIfPositive(params, req, "id", "id"); + addNumericIfPositive(params, req, "group_id", "group_id", "groupId"); + addNumericIfPositive(params, req, "importance", "importance"); + addIfPresent(params, req, "ip", "ip"); + addIfPresent(params, req, "mac", "mac"); + addIfPresent(params, req, "name", "name"); + addIfPresent(params, req, "asset_type", "asset_type", "assetType"); + addArrayIfPresent(params, req, "tag_id", "tag_id", "tagId"); + return mergeRawParams(params, req); +}; + +const buildDiscoveredAssetsParams = (req) => { + const params = {}; + addNumericIfNonNegative(params, req, "offset", "offset"); + addNumericIfPositive(params, req, "count", "count"); + addNumericIfPositive(params, req, "group_id", "group_id", "groupId"); + addNumericIfPositive(params, req, "importance", "importance"); + addNumericIfPositive(params, req, "port", "port"); + addNumericIfPositive(params, req, "time_start", "time_start", "timeStart"); + addNumericIfPositive(params, req, "time_end", "time_end", "timeEnd"); + addIfPresent(params, req, "ip_addr", "ip_addr", "ipAddr"); + addIfPresent(params, req, "mac", "mac"); + addIfPresent(params, req, "name", "name"); + addIfPresent(params, req, "os", "os"); + addIfPresent(params, req, "asset_type", "asset_type", "assetType"); + addArrayIfPresent(params, req, "service", "service"); + addArrayIfPresent(params, req, "tag_id", "tag_id", "tagId"); + return mergeRawParams(params, req); +}; + +const requireId = (req, name = "id") => { + const value = Number(firstPresent(req, name)); + if (!Number.isInteger(value) || value <= 0) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `${name} must be a positive integer`); + } + return value; +}; + +const requireDocId = (req) => { + const docId = String(firstPresent(req, "doc_id", "docId") || "").trim(); + if (!docId) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "doc_id is required"); + } + return docId; +}; + +const getPcapFiles = (req) => { + const rawFiles = firstPresent(req, "pcap_files", "pcapFiles"); + if (!Array.isArray(rawFiles) || rawFiles.length === 0) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "pcap_files must contain at least one file"); + } + return rawFiles.map((item, index) => { + const fileName = String(firstPresent(item, "file_name", "fileName") || "").trim(); + const contentBase64 = String(firstPresent(item, "content_base64", "contentBase64") || ""); + if (!fileName) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `pcap_files[${index}].file_name is required`); + } + if (!contentBase64) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `pcap_files[${index}].content_base64 is required`); + } + let bytes; + try { + bytes = Buffer.from(contentBase64, "base64"); + } catch (error) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `pcap_files[${index}].content_base64 is invalid: ${error.message}`); + } + if (bytes.length === 0) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `pcap_files[${index}] is empty`); + } + return { fileName, bytes }; + }); +}; + +async function uploadPcapDetectFilesInternal(ctx, pcapFiles, sessionCookie) { + const config = getConfig(ctx); + const cookie = sessionCookie || (await webLogin(ctx)); + const form = new FormData(); + for (const file of pcapFiles) { + form.append("file", new Blob([file.bytes]), file.fileName); + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.timeoutMs); + let response; + try { + response = await fetch(`${config.endpoint}/api/upload?id=${encodeURIComponent(METHOD.uploadPcapDetectFiles)}`, { + ...buildTlsOptions(config.skipTlsVerify), + method: "POST", + headers: { cookie }, + body: form, + signal: controller.signal, + }); + } catch (error) { + if (error?.name === "AbortError") { + throw toGrpcError(grpcStatus.DEADLINE_EXCEEDED, `pcap upload timeout after ${config.timeoutMs}ms`); + } + throw toGrpcError(grpcStatus.UNAVAILABLE, `pcap upload request failed: ${error.message}`); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + throw toGrpcError(grpcStatus.UNAVAILABLE, `pcap upload HTTP ${response.status}`); + } + const payload = await parseJsonResponse(response, "pcap upload"); + if (payload?.error) { + throw toGrpcError(statusForJsonRpcError(payload.error), payload.error.message || JSON.stringify(payload.error)); + } + return payload?.result ?? null; +} + +const uploadedFilesFromRequest = (req) => { + const files = firstPresent(req, "files"); + if (!Array.isArray(files) || files.length === 0) return undefined; + return files.map((item, index) => { + const id = String(firstPresent(item, "id") || "").trim(); + const fileName = String(firstPresent(item, "file_name", "fileName") || "").trim(); + if (!id) throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `files[${index}].id is required`); + if (!fileName) throw toGrpcError(grpcStatus.INVALID_ARGUMENT, `files[${index}].file_name is required`); + return { id, file_name: fileName }; + }); +}; + +const buildPcapDetectTaskParams = (req) => { + const params = {}; + const files = uploadedFilesFromRequest(req); + if (files) params.files = files; + const detectPattern = Number(firstPresent(req, "detect_pattern", "detectPattern")); + params.detect_pattern = Number.isInteger(detectPattern) && detectPattern >= 0 ? detectPattern : 2; + return mergeRawParams(params, req); +}; + +const buildDeletePcapDetectTaskParams = (req) => { + const params = { id: requireId(req) }; + const deleteStrategy = Number(firstPresent(req, "delete_strategy", "deleteStrategy")); + params.delete_strategy = Number.isInteger(deleteStrategy) && deleteStrategy > 0 ? deleteStrategy : 1; + return mergeRawParams(params, req); +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const normalizeDurationMs = (raw, fallback, minimum, maximum) => { + const value = Number(raw); + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.min(Math.max(Math.trunc(value), minimum), maximum); +}; + +const parseResultJson = (result) => JSON.parse(result.resultJson); + +const safeCall = async (ctx, method, params, { web = false, sessionCookie } = {}) => { + try { + const response = web ? await callWebJsonRpc(ctx, method, params, sessionCookie) : await callJsonRpc(ctx, method, params); + return { ok: true, params, result: parseResultJson(response) }; + } catch (error) { + return { + ok: false, + params, + error: { + message: error?.message || String(error), + code: error?.code, + }, + }; + } +}; + +const resultRows = (value) => { + if (!value || typeof value !== "object") return []; + if (Array.isArray(value.data)) return value.data; + if (Array.isArray(value.list)) return value.list; + if (Array.isArray(value.result?.data)) return value.result.data; + return []; +}; + +const summarizeAgg = (call) => + resultRows(call?.result) + .map((item) => ({ + key: item.key ?? item.name ?? item.value ?? "", + count: Number(item.doc_count ?? item.count ?? 0), + })) + .filter((item) => item.key !== ""); + +const summarizeAlerts = (callOrResult, limit = 5) => { + const rows = resultRows(callOrResult?.result ?? callOrResult); + return rows.slice(0, limit).map((item) => ({ + doc_id: item.doc_id, + name: item.name, + severity: item.severity, + result: item.result, + attacker: item.attacker ?? item.attacker_ip ?? item.src_ip, + victim: item.victim ?? item.victim_ip ?? item.dest_ip, + src_ip: item.src_ip, + dest_ip: item.dest_ip, + timestamp: item.timestamp, + msg: item.msg, + })); +}; + +const totalOf = (callOrResult) => Number((callOrResult?.result ?? callOrResult)?.total ?? 0); + +const uniqueStrings = (values) => [...new Set(values.filter((item) => typeof item === "string" && item.trim()))]; + +const trafficTotals = (rawLogs) => { + const totals = {}; + for (const [key, value] of Object.entries(rawLogs ?? {})) { + totals[key] = value?.ok ? totalOf(value) : null; + } + return totals; +}; + +const collectPivotIps = (...samples) => + uniqueStrings( + samples + .flat() + .flatMap((item) => [item.attacker, item.victim, item.src_ip, item.dest_ip]) + .filter(Boolean), + ); + +async function searchAttackRecords(ctx) { + const req = ctx.request ?? {}; + return callJsonRpc(ctx, METHOD.listAlerts, buildScenarioAlertParams(req)); +} + +async function analyzeIpActivity(ctx) { + const req = ctx.request ?? {}; + const ip = normalizedString(firstPresent(req, "ip")); + if (!ip) throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "ip is required"); + const role = normalizedString(firstPresent(req, "role")).toLowerCase() || "any"; + const count = parsePositiveInteger(firstPresent(req, "count"), 10); + const timeRange = buildTimeRange(req, DEFAULT_LOOKBACK_DAYS); + const base = { ...timeRange, offset: 0, count, sort: [{ field: "timestamp", ascending: false }] }; + const includeRawLogs = Boolean(firstPresent(req, "include_raw_logs", "includeRawLogs")); + const result = { + ip, + role, + timeRange, + guidance: [ + "alerts_as_attacker shows attacks where the IP is the attacker.", + "alerts_as_victim shows attacks where the IP is the victim.", + "asset summarizes the managed asset record when the IP is known to the device.", + ], + asset: await safeCall(ctx, METHOD.listAssets, { ip, offset: 0, count: 5 }), + }; + const needsWebAggregations = role === "any" || role === "attacker" || role === "victim"; + const sessionCookie = needsWebAggregations ? await webLogin(ctx).catch(() => undefined) : undefined; + + if (role === "any" || role === "attacker") { + result.alerts_as_attacker = await safeCall(ctx, METHOD.listAlerts, { ...base, attacker: eqFilter(ip) }); + result.top_attack_names_as_attacker = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "name", top: 10, attacker: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + } + if (role === "any" || role === "victim") { + result.alerts_as_victim = await safeCall(ctx, METHOD.listAlerts, { ...base, victim: eqFilter(ip) }); + result.top_attack_names_as_victim = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "name", top: 10, victim: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + } + if (includeRawLogs) { + result.raw_logs = { + http_src: await safeCall(ctx, METHOD.searchHttpLogs, { ...base, src_ip: eqFilter(ip) }), + http_dest: await safeCall(ctx, METHOD.searchHttpLogs, { ...base, dest_ip: eqFilter(ip) }), + dns_src: await safeCall(ctx, METHOD.searchDnsLogs, { ...base, src_ip: eqFilter(ip) }), + dns_dest: await safeCall(ctx, METHOD.searchDnsLogs, { ...base, dest_ip: eqFilter(ip) }), + }; + } + return { resultJson: JSON.stringify(result) }; +} + +async function huntThreats(ctx) { + const req = ctx.request ?? {}; + const alertParams = buildScenarioAlertParams(req, { defaultCount: parsePositiveInteger(firstPresent(req, "count"), 20) }); + const { time_range_start: timeRangeStart, time_range_end: timeRangeEnd } = alertParams; + const topBase = { + time_range_start: timeRangeStart, + time_range_end: timeRangeEnd, + }; + for (const key of ["name", "keyword", "attacker", "victim", "asset_ip", "severity", "result"]) { + if (alertParams[key] !== undefined) topBase[key] = alertParams[key]; + } + const alerts = await safeCall(ctx, METHOD.listAlerts, alertParams); + const sessionCookie = await webLogin(ctx).catch(() => undefined); + const result = { + query: firstPresent(req, "query", "keyword", "attack_name", "attackName") ?? "", + alertParams, + alerts, + top_attack_names: await safeCall(ctx, METHOD.listAlertAggTop, { ...topBase, agg: "name", top: 10 }, { web: true, sessionCookie }), + top_attackers: await safeCall(ctx, METHOD.listAlertAggTop, { ...topBase, agg: "attacker", top: 10 }, { web: true, sessionCookie }), + top_victims: await safeCall(ctx, METHOD.listAlertAggTop, { ...topBase, agg: "victim", top: 10 }, { web: true, sessionCookie }), + }; + return { resultJson: JSON.stringify(result) }; +} + +async function investigateAttackCampaign(ctx) { + const req = ctx.request ?? {}; + const alertParams = buildScenarioAlertParams(req, { defaultCount: parsePositiveInteger(firstPresent(req, "count"), 10) }); + const timeRange = { + time_range_start: alertParams.time_range_start, + time_range_end: alertParams.time_range_end, + }; + const topBase = { ...timeRange }; + for (const key of ["name", "keyword", "attacker", "victim", "asset_ip", "severity", "result"]) { + if (alertParams[key] !== undefined) topBase[key] = alertParams[key]; + } + const alerts = await safeCall(ctx, METHOD.listAlerts, alertParams); + const sessionCookie = await webLogin(ctx).catch(() => undefined); + const topAttackNames = await safeCall(ctx, METHOD.listAlertAggTop, { ...topBase, agg: "name", top: 10 }, { web: true, sessionCookie }); + const topAttackers = await safeCall(ctx, METHOD.listAlertAggTop, { ...topBase, agg: "attacker", top: 10 }, { web: true, sessionCookie }); + const topVictims = await safeCall(ctx, METHOD.listAlertAggTop, { ...topBase, agg: "victim", top: 10 }, { web: true, sessionCookie }); + const topResults = await safeCall(ctx, METHOD.listAlertAggTop, { ...topBase, agg: "result", top: 10 }, { web: true, sessionCookie }); + const sampleAlerts = summarizeAlerts(alerts, parsePositiveInteger(firstPresent(req, "count"), 10)); + const pivotIps = collectPivotIps(sampleAlerts); + return { + resultJson: JSON.stringify({ + type: "attack_campaign_investigation", + query: firstPresent(req, "query", "keyword", "attack_name", "attackName") ?? "", + timeRange, + filters: alertParams, + summary: { + total_alerts: totalOf(alerts), + top_attack_names: summarizeAgg(topAttackNames), + top_attackers: summarizeAgg(topAttackers), + top_victims: summarizeAgg(topVictims), + top_results: summarizeAgg(topResults), + pivot_ips: pivotIps, + }, + evidence: { + sample_alerts: sampleAlerts, + }, + raw: { + alerts, + top_attack_names: topAttackNames, + top_attackers: topAttackers, + top_victims: topVictims, + top_results: topResults, + }, + }), + }; +} + +async function assessIpThreatProfile(ctx) { + const req = ctx.request ?? {}; + const ip = normalizedString(firstPresent(req, "ip")); + if (!ip) throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "ip is required"); + const count = parsePositiveInteger(firstPresent(req, "count"), 5); + const includeRawLogs = Boolean(firstPresent(req, "include_raw_logs", "includeRawLogs")); + const timeRange = buildTimeRange(req, DEFAULT_LOOKBACK_DAYS); + const base = { ...timeRange, offset: 0, count, sort: [{ field: "timestamp", ascending: false }] }; + const asset = await safeCall(ctx, METHOD.listAssets, { ip, offset: 0, count: 5 }); + const alertsAsAttacker = await safeCall(ctx, METHOD.listAlerts, { ...base, attacker: eqFilter(ip) }); + const alertsAsVictim = await safeCall(ctx, METHOD.listAlerts, { ...base, victim: eqFilter(ip) }); + const sessionCookie = await webLogin(ctx).catch(() => undefined); + const topNamesAsAttacker = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "name", top: 10, attacker: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + const topVictims = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "victim", top: 10, attacker: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + const topNamesAsVictim = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "name", top: 10, victim: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + const topAttackers = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "attacker", top: 10, victim: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + const topResultsAsAttacker = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "result", top: 10, attacker: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + const topResultsAsVictim = await safeCall( + ctx, + METHOD.listAlertAggTop, + { ...timeRange, agg: "result", top: 10, victim: eqFilter(ip) }, + { web: true, sessionCookie }, + ); + + let rawLogs; + if (includeRawLogs) { + rawLogs = { + http_src: await safeCall(ctx, METHOD.searchHttpLogs, { ...base, src_ip: eqFilter(ip) }), + http_dest: await safeCall(ctx, METHOD.searchHttpLogs, { ...base, dest_ip: eqFilter(ip) }), + dns_src: await safeCall(ctx, METHOD.searchDnsLogs, { ...base, src_ip: eqFilter(ip) }), + dns_dest: await safeCall(ctx, METHOD.searchDnsLogs, { ...base, dest_ip: eqFilter(ip) }), + tcp_udp_src: await safeCall(ctx, METHOD.searchTcpUdpLogs, { ...base, src_ip: eqFilter(ip) }), + tcp_udp_dest: await safeCall(ctx, METHOD.searchTcpUdpLogs, { ...base, dest_ip: eqFilter(ip) }), + other_src: await safeCall(ctx, METHOD.searchOtherLogs, { ...base, src_ip: eqFilter(ip) }), + other_dest: await safeCall(ctx, METHOD.searchOtherLogs, { ...base, dest_ip: eqFilter(ip) }), + }; + } + + const attackerSamples = summarizeAlerts(alertsAsAttacker, count); + const victimSamples = summarizeAlerts(alertsAsVictim, count); + const attackerTotal = totalOf(alertsAsAttacker); + const victimTotal = totalOf(alertsAsVictim); + const dominantRole = attackerTotal === 0 && victimTotal === 0 ? "none" : attackerTotal === victimTotal ? "mixed" : attackerTotal > victimTotal ? "attacker" : "victim"; + return { + resultJson: JSON.stringify({ + type: "ip_threat_profile", + ip, + timeRange, + summary: { + asset_query_ok: Boolean(asset?.ok), + asset_known: totalOf(asset) > 0 || resultRows(asset?.result).length > 0, + alerts_as_attacker: attackerTotal, + alerts_as_victim: victimTotal, + dominant_role: dominantRole, + top_attack_names_as_attacker: summarizeAgg(topNamesAsAttacker), + top_victims_when_attacker: summarizeAgg(topVictims), + top_results_as_attacker: summarizeAgg(topResultsAsAttacker), + top_attack_names_as_victim: summarizeAgg(topNamesAsVictim), + top_attackers_when_victim: summarizeAgg(topAttackers), + top_results_as_victim: summarizeAgg(topResultsAsVictim), + traffic_totals: includeRawLogs ? trafficTotals(rawLogs) : undefined, + pivot_ips: collectPivotIps(attackerSamples, victimSamples), + }, + evidence: { + asset: asset?.result, + sample_alerts_as_attacker: attackerSamples, + sample_alerts_as_victim: victimSamples, + }, + raw: { + asset, + alerts_as_attacker: alertsAsAttacker, + alerts_as_victim: alertsAsVictim, + top_attack_names_as_attacker: topNamesAsAttacker, + top_victims_when_attacker: topVictims, + top_results_as_attacker: topResultsAsAttacker, + top_attack_names_as_victim: topNamesAsVictim, + top_attackers_when_victim: topAttackers, + top_results_as_victim: topResultsAsVictim, + raw_logs: rawLogs, + }, + }), + }; +} + +async function searchTrafficLogs(ctx) { + const req = ctx.request ?? {}; + const protocol = normalizedString(firstPresent(req, "protocol")).toLowerCase() || "all"; + const methodByProtocol = { + http: METHOD.searchHttpLogs, + dns: METHOD.searchDnsLogs, + tcp_udp: METHOD.searchTcpUdpLogs, + tcpudp: METHOD.searchTcpUdpLogs, + "tcp/udp": METHOD.searchTcpUdpLogs, + other: METHOD.searchOtherLogs, + }; + const selected = + protocol === "all" + ? [ + ["http", METHOD.searchHttpLogs], + ["dns", METHOD.searchDnsLogs], + ["tcp_udp", METHOD.searchTcpUdpLogs], + ["other", METHOD.searchOtherLogs], + ] + : [[protocol, methodByProtocol[protocol]]]; + if (selected.some(([, method]) => !method)) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "protocol must be one of all, http, dns, tcp_udp, tcpudp, tcp/udp, other"); + } + + const ip = normalizedString(firstPresent(req, "ip")); + const params = buildTrafficLogParams(req); + if (!ip) { + if (selected.length === 1) return callJsonRpc(ctx, selected[0][1], params); + const result = {}; + for (const [name, method] of selected) result[name] = await safeCall(ctx, method, params); + return { resultJson: JSON.stringify({ protocol, params, results: result }) }; + } + + const result = {}; + for (const [name, method] of selected) { + result[`${name}_src`] = await safeCall(ctx, method, { ...params, src_ip: eqFilter(ip) }); + result[`${name}_dest`] = await safeCall(ctx, method, { ...params, dest_ip: eqFilter(ip) }); + } + return { resultJson: JSON.stringify({ protocol, ip, params, results: result }) }; +} + +async function analyzePcapFiles(ctx) { + const req = ctx.request ?? {}; + const pcapFiles = getPcapFiles(req); + const detectPattern = Number(firstPresent(req, "detect_pattern", "detectPattern")); + const sessionCookie = await webLogin(ctx); + const upload = await uploadPcapDetectFilesInternal(ctx, pcapFiles, sessionCookie); + const files = upload?.files ?? []; + if (!Array.isArray(files) || files.length === 0) { + return { resultJson: JSON.stringify({ upload, createTask: null, tasks: [], alerts: [] }) }; + } + + const createTask = parseResultJson( + await callWebJsonRpc( + ctx, + METHOD.createPcapDetectTask, + { files, detect_pattern: Number.isInteger(detectPattern) && detectPattern >= 0 ? detectPattern : 2 }, + sessionCookie, + ), + ); + const createdTasks = Array.isArray(createTask?.pcap_detect_tasks) ? createTask.pcap_detect_tasks : []; + const taskIds = createdTasks + .map((item) => Number(firstPresent(item, "pcap_detect_task_id", "pcapDetectTaskId"))) + .filter((item) => Number.isInteger(item) && item > 0); + + let tasks = []; + if (firstPresent(req, "wait_for_completion", "waitForCompletion")) { + const timeoutMs = normalizeDurationMs( + firstPresent(req, "timeout_ms", "timeoutMs"), + DEFAULT_PCAP_ANALYSIS_TIMEOUT_MS, + MIN_PCAP_DURATION_MS, + MAX_PCAP_ANALYSIS_TIMEOUT_MS, + ); + const pollIntervalMs = normalizeDurationMs( + firstPresent(req, "poll_interval_ms", "pollIntervalMs"), + DEFAULT_PCAP_POLL_INTERVAL_MS, + MIN_PCAP_DURATION_MS, + MAX_PCAP_POLL_INTERVAL_MS, + ); + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const taskList = parseResultJson( + await callJsonRpc(ctx, METHOD.listPcapDetectTasks, { limit: 100, offset: 0 }), + ); + tasks = Array.isArray(taskList?.pcap_detect_task_list) + ? taskList.pcap_detect_task_list.filter((item) => taskIds.includes(Number(item.id))) + : []; + if (tasks.length >= taskIds.length && tasks.every((item) => [2, 3].includes(Number(item.replay_status)))) break; + await sleep(pollIntervalMs); + } + } + + const alerts = []; + for (const taskId of taskIds) { + const alertResult = parseResultJson( + await callJsonRpc(ctx, METHOD.listPcapDetectAlerts, { + count: 100, + offset: 0, + pcap_detect_task_id: [{ oper: "=", target: String(taskId) }], + }), + ); + alerts.push({ taskId, result: alertResult }); + } + + return { + resultJson: JSON.stringify({ upload, createTask, tasks, alerts }), + }; +} + +const parseFilename = (contentDisposition) => { + const value = String(contentDisposition || ""); + const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8Match) { + try { + return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, "")); + } catch { + // Fall back to the plain filename parameter when the upstream encoding is malformed. + } + } + const asciiMatch = value.match(/filename="?([^";]+)"?/i); + return asciiMatch ? asciiMatch[1].trim() : ""; +}; + +async function downloadFile(ctx) { + const config = getConfig(ctx); + const req = ctx.request ?? {}; + const id = String(firstPresent(req, "id") || "").trim(); + const query = String(firstPresent(req, "query") || "").trim(); + const queryJson = firstPresent(req, "query_json", "queryJson"); + if (!id) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "id is required"); + } + if (!query && queryJson === undefined) { + throw toGrpcError(grpcStatus.INVALID_ARGUMENT, "query or query_json is required"); + } + + const params = new URLSearchParams(); + params.set("id", id); + params.set("query", query || Buffer.from(String(queryJson)).toString("base64")); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), config.timeoutMs); + let response; + try { + response = await fetch(`${config.endpoint}/api/download?${params.toString()}`, { + ...buildTlsOptions(config.skipTlsVerify), + method: "GET", + headers: { + "API-Token": config.apiToken, + }, + signal: controller.signal, + }); + } catch (error) { + if (error?.name === "AbortError") { + throw toGrpcError(grpcStatus.DEADLINE_EXCEEDED, `upstream download timeout after ${config.timeoutMs}ms`); + } + throw toGrpcError(grpcStatus.UNAVAILABLE, `upstream download request failed: ${error.message}`); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + if (response.status === 401) throw toGrpcError(grpcStatus.UNAUTHENTICATED, "upstream rejected API token"); + if (response.status === 403) throw toGrpcError(grpcStatus.PERMISSION_DENIED, "upstream permission denied"); + if (response.status >= 500) throw toGrpcError(grpcStatus.UNAVAILABLE, `upstream HTTP ${response.status}`); + throw toGrpcError(grpcStatus.FAILED_PRECONDITION, `upstream HTTP ${response.status}`); + } + + const bytes = Buffer.from(await response.arrayBuffer()); + return { + contentType: response.headers.get("content-type") || "", + filename: parseFilename(response.headers.get("content-disposition")), + contentBase64: bytes.toString("base64"), + }; +} + +export const handlers = { + [FULL_METHOD.listAlerts]: (ctx) => callJsonRpc(ctx, METHOD.listAlerts, buildListAlertsParams(ctx.request ?? {})), + [FULL_METHOD.searchAttackRecords]: searchAttackRecords, + [FULL_METHOD.analyzeIpActivity]: analyzeIpActivity, + [FULL_METHOD.huntThreats]: huntThreats, + [FULL_METHOD.investigateAttackCampaign]: investigateAttackCampaign, + [FULL_METHOD.assessIpThreatProfile]: assessIpThreatProfile, + + [FULL_METHOD.getAlert]: (ctx) => callJsonRpc(ctx, METHOD.getAlert, { doc_id: requireDocId(ctx.request ?? {}) }), + + [FULL_METHOD.getAlertRawDocument]: (ctx) => + callJsonRpc(ctx, METHOD.getAlertRawDocument, { doc_id: requireDocId(ctx.request ?? {}) }), + [FULL_METHOD.countAlerts]: (ctx) => callWebJsonRpc(ctx, METHOD.countAlerts, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listAlertAggregations]: (ctx) => + callWebJsonRpc(ctx, METHOD.listAlertAggregations, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listAlertAggregationsT2]: (ctx) => + callWebJsonRpc(ctx, METHOD.listAlertAggregationsT2, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listAlertAggTop]: (ctx) => callWebJsonRpc(ctx, METHOD.listAlertAggTop, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listAlertChart]: (ctx) => callWebJsonRpc(ctx, METHOD.listAlertChart, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.loadAlertPcapFile]: (ctx) => + callWebJsonRpc(ctx, METHOD.loadAlertPcapFile, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listAlertPcapFrames]: (ctx) => + callWebJsonRpc(ctx, METHOD.listAlertPcapFrames, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.filterAlertPcapFrames]: (ctx) => + callWebJsonRpc(ctx, METHOD.filterAlertPcapFrames, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.getAlertPcapFrame]: (ctx) => + callWebJsonRpc(ctx, METHOD.getAlertPcapFrame, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.checkAlertPcapDownload]: (ctx) => + callWebJsonRpc(ctx, METHOD.checkAlertPcapDownload, buildRawParams(ctx.request ?? {})), + + [FULL_METHOD.listCustomIntelligences]: (ctx) => + callJsonRpc(ctx, METHOD.listCustomIntelligences, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.createCustomIntelligence]: (ctx) => + callMutableJsonRpc(ctx, METHOD.createCustomIntelligence, buildRawParams), + [FULL_METHOD.updateCustomIntelligence]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateCustomIntelligence, buildRawParams), + [FULL_METHOD.updateCustomIntelligenceStatus]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateCustomIntelligenceStatus, buildRawParams), + [FULL_METHOD.deleteCustomIntelligence]: (ctx) => + callMutableJsonRpc(ctx, METHOD.deleteCustomIntelligence, buildIdsParams), + [FULL_METHOD.listCustomRules]: (ctx) => callJsonRpc(ctx, METHOD.listCustomRules, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.createCustomRule]: (ctx) => callMutableJsonRpc(ctx, METHOD.createCustomRule, buildRawParams), + [FULL_METHOD.updateCustomRule]: (ctx) => callMutableJsonRpc(ctx, METHOD.updateCustomRule, buildRawParams), + [FULL_METHOD.updateCustomRuleStatus]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateCustomRuleStatus, buildRawParams), + [FULL_METHOD.deleteCustomRule]: (ctx) => callMutableJsonRpc(ctx, METHOD.deleteCustomRule, buildIdsParams), + [FULL_METHOD.createAlarmWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.createAlarmWhiteList, buildRawParams), + [FULL_METHOD.updateAlarmWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateAlarmWhiteList, buildRawParams), + [FULL_METHOD.deleteAlarmWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.deleteAlarmWhiteList, buildIdsActionParams), + + [FULL_METHOD.listAssets]: (ctx) => callJsonRpc(ctx, METHOD.listAssets, buildListAssetsParams(ctx.request ?? {})), + + [FULL_METHOD.getAsset]: (ctx) => callJsonRpc(ctx, METHOD.getAsset, { id: requireId(ctx.request ?? {}) }), + + [FULL_METHOD.searchAssetTree]: (ctx) => { + const req = ctx.request ?? {}; + const params = {}; + addIfPresent(params, req, "keyword", "keyword"); + return callJsonRpc(ctx, METHOD.searchAssetTree, mergeRawParams(params, req)); + }, + + [FULL_METHOD.listAssetGroups]: (ctx) => { + const req = ctx.request ?? {}; + const params = {}; + addNumericIfPositive(params, req, "id", "id"); + addIfPresent(params, req, "name", "name"); + return callJsonRpc(ctx, METHOD.listAssetGroups, mergeRawParams(params, req)); + }, + + [FULL_METHOD.listAssetTags]: (ctx) => { + const req = ctx.request ?? {}; + const params = {}; + addNumericIfNonNegative(params, req, "offset", "offset"); + addNumericIfPositive(params, req, "count", "count"); + addIfPresent(params, req, "name", "name"); + return callJsonRpc(ctx, METHOD.listAssetTags, mergeRawParams(params, req)); + }, + + [FULL_METHOD.listDiscoveredAssets]: (ctx) => + callJsonRpc(ctx, METHOD.listDiscoveredAssets, buildDiscoveredAssetsParams(ctx.request ?? {})), + + [FULL_METHOD.batchCreateFirewallWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.batchCreateFirewallWhiteList, buildRawParams), + [FULL_METHOD.createFirewallWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.createFirewallWhiteList, buildRawParams), + [FULL_METHOD.updateFirewallWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateFirewallWhiteList, buildRawParams), + [FULL_METHOD.updateFirewallWhiteListStatus]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateFirewallWhiteListStatus, buildRawParams), + [FULL_METHOD.deleteFirewallWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.deleteFirewallWhiteList, buildIdsParams), + [FULL_METHOD.deleteAllFirewallWhiteList]: (ctx) => + callMutableJsonRpc(ctx, METHOD.deleteAllFirewallWhiteList, buildRawParams), + [FULL_METHOD.createBlockRules]: (ctx) => callMutableJsonRpc(ctx, METHOD.createBlockRules, buildRawParams), + [FULL_METHOD.updateBlockRules]: (ctx) => callMutableJsonRpc(ctx, METHOD.updateBlockRules, buildRawParams), + [FULL_METHOD.updateBlockRulesStatus]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateBlockRulesStatus, buildRawParams), + [FULL_METHOD.deleteAllBlockRules]: (ctx) => + callMutableJsonRpc(ctx, METHOD.deleteAllBlockRules, buildRawParams), + [FULL_METHOD.listBlockRules]: (ctx) => callJsonRpc(ctx, METHOD.listBlockRules, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listBlockRulesTrend]: (ctx) => + callJsonRpc(ctx, METHOD.listBlockRulesTrend, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listTapBlockRecords]: (ctx) => + callJsonRpc(ctx, METHOD.listTapBlockRecords, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.countTapBlocks]: (ctx) => callJsonRpc(ctx, METHOD.countTapBlocks, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listTopTapBlocks]: (ctx) => callJsonRpc(ctx, METHOD.listTopTapBlocks, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listPcapDetectTasks]: (ctx) => + callJsonRpc(ctx, METHOD.listPcapDetectTasks, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.uploadPcapDetectFiles]: async (ctx) => { + const req = ctx.request ?? {}; + return { + resultJson: JSON.stringify(await uploadPcapDetectFilesInternal(ctx, getPcapFiles(req))), + }; + }, + [FULL_METHOD.createPcapDetectTask]: (ctx) => { + const req = ctx.request ?? {}; + return callWebJsonRpc(ctx, METHOD.createPcapDetectTask, buildMutableParams(req, buildPcapDetectTaskParams(req))); + }, + [FULL_METHOD.deletePcapDetectTask]: (ctx) => { + const req = ctx.request ?? {}; + return callWebJsonRpc(ctx, METHOD.deletePcapDetectTask, buildMutableParams(req, buildDeletePcapDetectTaskParams(req))); + }, + [FULL_METHOD.analyzePcapFiles]: analyzePcapFiles, + [FULL_METHOD.listPcapDetectAlerts]: (ctx) => + callJsonRpc(ctx, METHOD.listPcapDetectAlerts, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.getPcapDetectAlert]: (ctx) => + callJsonRpc(ctx, METHOD.getPcapDetectAlert, { doc_id: requireDocId(ctx.request ?? {}) }), + [FULL_METHOD.getPcapDetectAlertRawDocument]: (ctx) => + callWebJsonRpc(ctx, METHOD.getPcapDetectAlertRawDocument, { doc_id: requireDocId(ctx.request ?? {}) }), + [FULL_METHOD.downloadPcap]: (ctx) => callJsonRpc(ctx, METHOD.downloadPcap, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.multiDownloadPcap]: (ctx) => callJsonRpc(ctx, METHOD.multiDownloadPcap, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.searchHttpLogs]: (ctx) => callJsonRpc(ctx, METHOD.searchHttpLogs, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.searchDnsLogs]: (ctx) => callJsonRpc(ctx, METHOD.searchDnsLogs, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.searchTcpUdpLogs]: (ctx) => + callJsonRpc(ctx, METHOD.searchTcpUdpLogs, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.searchOtherLogs]: (ctx) => + callJsonRpc(ctx, METHOD.searchOtherLogs, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.searchTrafficLogs]: searchTrafficLogs, + [FULL_METHOD.getOriginalLogDetail]: (ctx) => + callJsonRpc(ctx, METHOD.getOriginalLogDetail, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.multiDownloadLogJson]: (ctx) => + callJsonRpc(ctx, METHOD.multiDownloadLogJson, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.listTcpdumpProcesses]: (ctx) => + callJsonRpc(ctx, METHOD.listTcpdumpProcesses, buildRawParams(ctx.request ?? {})), + [FULL_METHOD.createTcpdumpProcess]: (ctx) => + callMutableJsonRpc(ctx, METHOD.createTcpdumpProcess, buildRawParams), + [FULL_METHOD.updateTcpdumpProcess]: (ctx) => + callMutableJsonRpc(ctx, METHOD.updateTcpdumpProcess, buildRawParams), + [FULL_METHOD.startTcpdumpProcess]: (ctx) => + callMutableJsonRpc(ctx, METHOD.startTcpdumpProcess, buildIdParams), + [FULL_METHOD.cancelTcpdumpProcess]: (ctx) => + callMutableJsonRpc(ctx, METHOD.cancelTcpdumpProcess, buildIdParams), + [FULL_METHOD.deleteTcpdumpProcess]: (ctx) => + callMutableJsonRpc(ctx, METHOD.deleteTcpdumpProcess, buildIdsParams), + [FULL_METHOD.downloadFile]: downloadFile, +}; + +export const internals = { + buildTlsOptions, + buildListAlertsParams, + buildScenarioAlertParams, + buildTrafficLogParams, + buildTimeRange, + buildListAssetsParams, + buildDiscoveredAssetsParams, + buildRawParams, + buildIdsParams, + buildIdsActionParams, + buildPcapDetectTaskParams, + buildDeletePcapDetectTaskParams, + normalizeDurationMs, + parseFilename, + fromValue, +}; diff --git a/services/chaitin__t-answer-ndr/test/t-answer-ndr.test.js b/services/chaitin__t-answer-ndr/test/t-answer-ndr.test.js new file mode 100644 index 00000000..017805b8 --- /dev/null +++ b/services/chaitin__t-answer-ndr/test/t-answer-ndr.test.js @@ -0,0 +1,735 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { grpcStatus } from "@chaitin-ai/octobus-sdk"; + +import { handlers, internals } from "../src/t-answer-ndr.js"; + +const ctx = (request = {}, config = {}) => ({ + request, + config: { + endpoint: "https://answer.example", + timeoutMs: 1000, + ...config, + }, + secret: { + apiToken: "test-token", + webUsername: "test-user", + webPassword: "password", + }, +}); + +test("skipTlsVerify is passed as request-scoped runtime options", async () => { + const originalFetch = globalThis.fetch; + const originalTlsEnv = process.env.NODE_TLS_REJECT_UNAUTHORIZED; + try { + let options; + globalThis.fetch = async (_url, requestOptions) => { + options = requestOptions; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { total: 0, data: [] } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssets"]( + ctx({ count: 1 }, { skipTlsVerify: true }), + ); + + assert.equal(options.skipTlsVerify, true); + assert.equal(options.tlsInsecureSkipVerify, true); + assert.equal(options.insecureSkipVerify, true); + assert.equal(process.env.NODE_TLS_REJECT_UNAUTHORIZED, originalTlsEnv); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("buildListAlertsParams maps common filters and raw params", () => { + const params = internals.buildListAlertsParams({ + timeRangeStart: 1700000000000, + timeRangeEnd: 1700003600000, + count: 20, + offset: 0, + srcIp: [{ oper: "=", target: "1.1.1.1" }], + severity: [{ oper: "=", target: "1" }], + rawParamsJson: "{\"read\":0}", + }); + + assert.equal(params.time_range_start, 1700000000000); + assert.equal(params.time_range_end, 1700003600000); + assert.equal(params.count, 20); + assert.deepEqual(params.src_ip, [{ oper: "=", target: "1.1.1.1" }]); + assert.deepEqual(params.severity, [{ oper: "=", target: "1" }]); + assert.equal(params.read, 0); +}); + +test("ListAssets sends API-Token and JSON-RPC body", async () => { + const originalFetch = globalThis.fetch; + try { + let captured; + globalThis.fetch = async (url, options) => { + captured = { url, options }; + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: "ok", + result: { total: 1, data: [{ id: 1, ip: "192.0.2.10" }] }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssets"]( + ctx({ count: 10, ip: "192.0.2.10" }), + ); + + assert.equal(captured.url, "https://answer.example/rpc"); + assert.equal(captured.options.headers["API-Token"], "test-token"); + const body = JSON.parse(captured.options.body); + assert.equal(body.method, "AssetService.GetAssetList"); + assert.deepEqual(body.params, { count: 10, ip: "192.0.2.10" }); + assert.deepEqual(JSON.parse(result.resultJson), { total: 1, data: [{ id: 1, ip: "192.0.2.10" }] }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("JSON-RPC parameter error maps to INVALID_ARGUMENT", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: "bad", + error: { code: -32000, message: "FieldName:TimeRangeStart 为必填项" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + + await assert.rejects( + () => handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAlerts"](ctx({ count: 10 })), + /TimeRangeStart/, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("non-JSON HTTP failures map status before response parsing", async (t) => { + const originalFetch = globalThis.fetch; + const cases = [ + { status: 401, code: grpcStatus.UNAUTHENTICATED, message: /rejected API token/ }, + { status: 403, code: grpcStatus.PERMISSION_DENIED, message: /permission denied/ }, + { status: 500, code: grpcStatus.UNAVAILABLE, message: /upstream HTTP 500/ }, + ]; + + try { + for (const item of cases) { + await t.test(`HTTP ${item.status}`, async () => { + globalThis.fetch = async () => + new Response("upstream error", { + status: item.status, + headers: { "content-type": "text/html" }, + }); + + await assert.rejects( + () => handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/ListAssets"](ctx({ count: 1 })), + (error) => { + assert.equal(error.code, item.code); + assert.match(error.message, item.message); + return true; + }, + ); + }); + } + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("raw JSON methods pass params through to the selected upstream method", async () => { + const originalFetch = globalThis.fetch; + try { + let captured; + globalThis.fetch = async (url, options) => { + captured = { url, options }; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { ok: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateBlockRules"]( + ctx({ rawParamsJson: "{\"rules\":[{\"ip\":\"203.0.113.10\"}],\"status\":1}" }), + ); + + const body = JSON.parse(captured.options.body); + assert.equal(body.method, "RulesService.CreateBlockRules"); + assert.deepEqual(body.params, { rules: [{ ip: "203.0.113.10" }], status: 1 }); + assert.deepEqual(JSON.parse(result.resultJson), { ok: true }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("write methods forward without service-side approval gate", async () => { + const originalFetch = globalThis.fetch; + try { + let body; + globalThis.fetch = async (url, options) => { + body = JSON.parse(options.body); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { ok: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/CreateBlockRules"]( + ctx({ rawParamsJson: "{\"rules\":[{\"ip\":\"203.0.113.10\"}]}" }), + ); + + assert.equal(body.method, "RulesService.CreateBlockRules"); + assert.deepEqual(body.params, { rules: [{ ip: "203.0.113.10" }] }); + assert.deepEqual(JSON.parse(result.resultJson), { ok: true }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("raw array params are forwarded for positional JSON-RPC methods", async () => { + const originalFetch = globalThis.fetch; + try { + let body; + globalThis.fetch = async (url, options) => { + body = JSON.parse(options.body); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { ok: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/UpdateCustomIntelligence"]( + ctx({ rawParamsJson: "[{\"id\":1,\"status\":1}]" }), + ); + + assert.equal(body.method, "AlarmService.UpdateAlarmCustomIntelligence"); + assert.deepEqual(body.params, [{ id: 1, status: 1 }]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("ids action methods build convenience params and allow raw overrides", async () => { + const params = internals.buildIdsActionParams({ + ids: [1, 2], + action: "hide", + rawParamsJson: "{\"reason\":\"maintenance\"}", + }); + + assert.deepEqual(params, { ids: [1, 2], action: "hide", reason: "maintenance" }); +}); + +test("UploadPcapDetectFiles logs in and uploads multipart pcap with session cookie", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + if (String(url).endsWith("/rpc")) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-1; Path=/; HttpOnly" }, + }); + } + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + result: { files: [{ id: "file-1", file_name: "sample.pcap" }], err_msg: "" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/UploadPcapDetectFiles"]( + ctx({ + pcapFiles: [{ fileName: "sample.pcap", contentBase64: Buffer.from("pcap").toString("base64") }], + }), + ); + + assert.equal(calls.length, 2); + assert.equal(JSON.parse(calls[0].options.body).method, "HeraAccountNoAuthService.Login"); + assert.equal( + calls[1].url, + "https://answer.example/api/upload?id=PcapDetectUploadService.UploadPcapDetectFiles", + ); + assert.equal(calls[1].options.headers.cookie, "sessionid=session-1"); + assert.deepEqual(JSON.parse(result.resultJson), { + files: [{ id: "file-1", file_name: "sample.pcap" }], + err_msg: "", + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("CreatePcapDetectTask uses web session JSON-RPC", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + if (calls.length === 1) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-2; Path=/; HttpOnly" }, + }); + } + return new Response( + JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { pcap_detect_tasks: [{ pcap_detect_task_id: 9 }] } }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/CreatePcapDetectTask"]( + ctx({ + files: [{ id: "file-1", fileName: "sample.pcap" }], + detectPattern: 2, + }), + ); + + const body = JSON.parse(calls[1].options.body); + assert.equal(calls[1].options.headers.cookie, "sessionid=session-2"); + assert.equal(body.method, "PcapDetectService.CreatePcapDetectTask"); + assert.deepEqual(body.params, { files: [{ id: "file-1", file_name: "sample.pcap" }], detect_pattern: 2 }); + assert.deepEqual(JSON.parse(result.resultJson), { pcap_detect_tasks: [{ pcap_detect_task_id: 9 }] }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("web-session HTTP failures map status before response parsing", async (t) => { + const originalFetch = globalThis.fetch; + const taskRequest = { + files: [{ id: "file-1", fileName: "sample.pcap" }], + detectPattern: 2, + }; + + const assertUnavailable = async (operation, message) => { + await assert.rejects(operation, (error) => { + assert.equal(error.code, grpcStatus.UNAVAILABLE); + assert.match(error.message, message); + return true; + }); + }; + + try { + await t.test("web login", async () => { + globalThis.fetch = async () => + new Response("login unavailable", { + status: 502, + headers: { "content-type": "text/html" }, + }); + + await assertUnavailable( + () => handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/CreatePcapDetectTask"](ctx(taskRequest)), + /web login HTTP 502/, + ); + }); + + await t.test("web JSON-RPC", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + if (calls === 1) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-error; Path=/; HttpOnly" }, + }); + } + return new Response("RPC unavailable", { + status: 503, + headers: { "content-type": "text/html" }, + }); + }; + + await assertUnavailable( + () => handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/CreatePcapDetectTask"](ctx(taskRequest)), + /upstream web JSON-RPC HTTP 503/, + ); + }); + + await t.test("pcap upload", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + if (calls === 1) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-upload-error; Path=/; HttpOnly" }, + }); + } + return new Response("upload unavailable", { + status: 500, + headers: { "content-type": "text/html" }, + }); + }; + + await assertUnavailable( + () => + handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/UploadPcapDetectFiles"]( + ctx({ + pcapFiles: [{ fileName: "sample.pcap", contentBase64: Buffer.from("pcap").toString("base64") }], + }), + ), + /pcap upload HTTP 500/, + ); + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GetPcapDetectAlertRawDocument uses web session JSON-RPC", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + if (calls.length === 1) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-raw; Path=/; HttpOnly" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { data: { payload: "raw" } } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/GetPcapDetectAlertRawDocument"]( + ctx({ docId: "doc-1" }), + ); + + const body = JSON.parse(calls[1].options.body); + assert.equal(calls[1].options.headers.cookie, "sessionid=session-raw"); + assert.equal(body.method, "PcapDetectService.GetAlarmDocument"); + assert.deepEqual(body.params, { doc_id: "doc-1" }); + assert.deepEqual(JSON.parse(result.resultJson), { data: { payload: "raw" } }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("DownloadFile calls unified download API and returns base64 content", async () => { + const originalFetch = globalThis.fetch; + try { + let captured; + globalThis.fetch = async (url, options) => { + captured = { url, options }; + return new Response(Buffer.from("pcap-data"), { + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=\"sample.pcap\"", + }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/DownloadFile"]( + ctx({ id: "LogSearchService.SearchSrcIpAgg", queryJson: "{\"count\":1}" }), + ); + + assert.equal(captured.options.headers["API-Token"], "test-token"); + assert.match(captured.url, /^https:\/\/answer\.example\/api\/download\?/); + assert.equal(new URL(captured.url).searchParams.get("id"), "LogSearchService.SearchSrcIpAgg"); + assert.equal(result.contentType, "application/octet-stream"); + assert.equal(result.filename, "sample.pcap"); + assert.equal(Buffer.from(result.contentBase64, "base64").toString(), "pcap-data"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("parseFilename falls back when the UTF-8 filename is malformed", () => { + assert.equal( + internals.parseFilename("attachment; filename*=UTF-8''capture%ZZ.pcap; filename=\"fallback.pcap\""), + "fallback.pcap", + ); + assert.equal(internals.parseFilename("attachment; filename*=UTF-8''capture%ZZ.pcap"), ""); +}); + +test("pcap analysis polling durations are finite and bounded", () => { + assert.equal(internals.normalizeDurationMs(Number.MAX_SAFE_INTEGER, 60000, 1000, 600000), 600000); + assert.equal(internals.normalizeDurationMs(999999, 5000, 1000, 60000), 60000); + assert.equal(internals.normalizeDurationMs(1, 5000, 1000, 60000), 1000); + assert.equal(internals.normalizeDurationMs(-1, 5000, 1000, 60000), 5000); + assert.equal(internals.normalizeDurationMs(Number.POSITIVE_INFINITY, 5000, 1000, 60000), 5000); +}); + + +test("P0 raw JSON methods route to the expected upstream services", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push(JSON.parse(options.body)); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { ok: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchHttpLogs"](ctx({ rawParamsJson: "{\"count\":1}" })); + await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/ListBlockRules"](ctx({ rawParamsJson: "{\"count\":1}" })); + await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/DownloadPcap"](ctx({ rawParamsJson: "{\"doc_id\":\"doc-1\"}" })); + + assert.deepEqual(calls.map((item) => item.method), [ + "LogSearchService.SearchOrigDataHTTPLog", + "RulesService.SearchBlockRules", + "PcapDownloadService.DownloadPcap", + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + + +test("CountAlerts uses web session JSON-RPC", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push({ url, options }); + if (calls.length === 1) { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-count; Path=/; HttpOnly" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { total: 1, ratio_total: 0 } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/CountAlerts"]( + ctx({ rawParamsJson: "{\"time_range_start\":1,\"time_range_end\":2}" }), + ); + + const body = JSON.parse(calls[1].options.body); + assert.equal(calls[1].options.headers.cookie, "sessionid=session-count"); + assert.equal(body.method, "AlarmService.SearchAlarmCount"); + assert.deepEqual(JSON.parse(result.resultJson), { total: 1, ratio_total: 0 }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("scenario attack search builds stable alert params", async () => { + const params = internals.buildScenarioAlertParams({ + timeRangeStart: 1700000000000, + timeRangeEnd: 1700003600000, + attackName: "MYSQL 弱口令登录", + assetIp: "192.0.2.10", + severity: "2", + count: 5, + }); + + assert.equal(params.time_range_start, 1700000000000); + assert.equal(params.time_range_end, 1700003600000); + assert.deepEqual(params.name, [{ oper: "=", target: "MYSQL 弱口令登录" }]); + assert.deepEqual(params.asset_ip, ["192.0.2.10"]); + assert.deepEqual(params.severity, [{ oper: "=", target: 2 }]); + assert.equal(params.count, 5); +}); + +test("SearchTrafficLogs with any-direction ip fans out into src and dest queries", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + calls.push(JSON.parse(options.body)); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { ok: true } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/SearchTrafficLogs"]( + ctx({ + protocol: "http", + ip: "192.0.2.20", + timeRangeStart: 1700000000000, + timeRangeEnd: 1700003600000, + count: 2, + }), + ); + + assert.deepEqual(calls.map((item) => item.method), [ + "LogSearchService.SearchOrigDataHTTPLog", + "LogSearchService.SearchOrigDataHTTPLog", + ]); + assert.deepEqual(calls[0].params.src_ip, [{ oper: "=", target: "192.0.2.20" }]); + assert.deepEqual(calls[1].params.dest_ip, [{ oper: "=", target: "192.0.2.20" }]); + const parsed = JSON.parse(result.resultJson); + assert.equal(parsed.protocol, "http"); + assert.equal(parsed.ip, "192.0.2.20"); + assert.equal(parsed.results.http_src.ok, true); + assert.equal(parsed.results.http_dest.ok, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("HuntThreats combines alert search with web aggregation calls", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + const body = JSON.parse(options.body); + calls.push(body); + if (body.method === "HeraAccountNoAuthService.Login") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-hunt; Path=/; HttpOnly" }, + }); + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: { method: body.method } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/HuntThreats"]( + ctx({ + query: "弱口令", + timeRangeStart: 1700000000000, + timeRangeEnd: 1700003600000, + }), + ); + + assert.deepEqual(calls.map((item) => item.method), [ + "AlarmService.SearchAlarmList", + "HeraAccountNoAuthService.Login", + "AlarmService.SearchAlarmAggTop", + "AlarmService.SearchAlarmAggTop", + "AlarmService.SearchAlarmAggTop", + ]); + const parsed = JSON.parse(result.resultJson); + assert.deepEqual(parsed.alertParams.keyword, ["弱口令"]); + assert.equal(parsed.alerts.ok, true); + assert.equal(parsed.top_attack_names.ok, true); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("InvestigateAttackCampaign returns distilled campaign summary", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + const body = JSON.parse(options.body); + calls.push(body); + if (body.method === "HeraAccountNoAuthService.Login") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-campaign; Path=/; HttpOnly" }, + }); + } + const resultByMethod = { + "AlarmService.SearchAlarmList": { + total: 2, + data: [ + { + doc_id: "doc-1", + name: "MYSQL 弱口令登录", + attacker: "192.0.2.101", + victim: "203.0.113.227", + result: "success", + severity: 3, + }, + ], + }, + "AlarmService.SearchAlarmAggTop": { + data: [{ key: body.params.agg === "attacker" ? "192.0.2.101" : "MYSQL 弱口令登录", doc_count: 2 }], + total: 1, + }, + }; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result: resultByMethod[body.method] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/InvestigateAttackCampaign"]( + ctx({ query: "弱口令", timeRangeStart: 1700000000000, timeRangeEnd: 1700003600000 }), + ); + const parsed = JSON.parse(result.resultJson); + + assert.equal(parsed.type, "attack_campaign_investigation"); + assert.equal(parsed.summary.total_alerts, 2); + assert.deepEqual(parsed.summary.pivot_ips, ["192.0.2.101", "203.0.113.227"]); + assert.deepEqual(parsed.evidence.sample_alerts[0].attacker, "192.0.2.101"); + assert.deepEqual(calls.map((item) => item.method), [ + "AlarmService.SearchAlarmList", + "HeraAccountNoAuthService.Login", + "AlarmService.SearchAlarmAggTop", + "AlarmService.SearchAlarmAggTop", + "AlarmService.SearchAlarmAggTop", + "AlarmService.SearchAlarmAggTop", + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("AssessIpThreatProfile summarizes IP roles and related entities", async () => { + const originalFetch = globalThis.fetch; + try { + const calls = []; + globalThis.fetch = async (url, options) => { + const body = JSON.parse(options.body); + calls.push(body); + if (body.method === "HeraAccountNoAuthService.Login") { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "login", result: { id: 1 } }), { + status: 200, + headers: { "set-cookie": "sessionid=session-profile; Path=/; HttpOnly" }, + }); + } + let result = { total: 0, data: [] }; + if (body.method === "AssetService.GetAssetList") result = { total: 1, data: [{ ip: "203.0.113.227" }] }; + if (body.method === "AlarmService.SearchAlarmList" && body.params.victim) { + result = { + total: 7, + data: [{ name: "MYSQL 弱口令登录", attacker: "192.0.2.101", victim: "203.0.113.227" }], + }; + } + if (body.method === "AlarmService.SearchAlarmAggTop") { + result = { total: 1, data: [{ key: "MYSQL 弱口令登录", doc_count: 7 }] }; + } + return new Response(JSON.stringify({ jsonrpc: "2.0", id: "ok", result }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await handlers["chaitin.t_answer_ndr.v1.TAnswerNdrService/AssessIpThreatProfile"]( + ctx({ ip: "203.0.113.227", timeRangeStart: 1700000000000, timeRangeEnd: 1700003600000 }), + ); + const parsed = JSON.parse(result.resultJson); + + assert.equal(parsed.type, "ip_threat_profile"); + assert.equal(parsed.summary.asset_known, true); + assert.equal(parsed.summary.asset_query_ok, true); + assert.equal(parsed.summary.alerts_as_attacker, 0); + assert.equal(parsed.summary.alerts_as_victim, 7); + assert.equal(parsed.summary.dominant_role, "victim"); + assert.deepEqual(parsed.summary.pivot_ips, ["192.0.2.101", "203.0.113.227"]); + assert.equal(calls.filter((item) => item.method === "AlarmService.SearchAlarmAggTop").length, 6); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/services/package.json b/services/package.json index 2fbea088..256e09e6 100644 --- a/services/package.json +++ b/services/package.json @@ -45,6 +45,7 @@ "sangfor-xdr-v2-0-45": "bin/sangfor-xdr-v2-0-45.js", "skycloud-inet": "bin/skycloud-inet.js", "slack-group-robot": "bin/slack-group-robot.js", + "t-answer-ndr": "bin/t-answer-ndr.js", "tencent-qyweixin-group-robot": "bin/tencent-qyweixin-group-robot.js", "tencent-tix-saas": "bin/tencent-tix-saas.js", "tencent-tsec-v2-5-1": "bin/tencent-tsec-v2-5-1.js", @@ -109,6 +110,7 @@ "bin/safeline-waf.js", "bin/sangfor-fw-v8-0-45.js", "bin/sangfor-xdr-v2-0-45.js", + "bin/t-answer-ndr.js", "bin/tencent-qyweixin-group-robot.js", "bin/tencent-tix-saas.js", "bin/tencent-tsec-v2-5-1.js", @@ -169,6 +171,7 @@ "slack__group-robot", "chaitin__safeline-waf-eliminate-false-positive", "chaitin__safeline-waf", + "chaitin__t-answer-ndr", "sangfor__fw_v8-0-45", "sangfor__xdr_v2-0-45", "tencent__qyweixin-group-robot",