diff --git a/services/bin/m01-intelligence.js b/services/bin/m01-intelligence.js old mode 100644 new mode 100755 diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js old mode 100644 new mode 100755 index 412b8988..285e98aa --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -141,6 +141,10 @@ const services = { entryFile: "../qianxin__fw-secgate3600/bin/qianxin-fw-secgate3600.js", serviceModule: "../qianxin__fw-secgate3600/src/service.js", }, + "qianxin-tianyan-platform": { + entryFile: "../qianxin__tianyan-platform/bin/qianxin-tianyan-platform.js", + serviceModule: "../qianxin__tianyan-platform/src/service.js", + }, "qianxin-fw-secgate3600-http-x": { entryFile: "../qianxin__fw-secgate3600-http-x/bin/qianxin-fw-secgate3600-http-x.js", serviceModule: "../qianxin__fw-secgate3600-http-x/src/service.js", diff --git a/services/bin/qianxin-tianyan-platform.js b/services/bin/qianxin-tianyan-platform.js new file mode 100755 index 00000000..fa02f90e --- /dev/null +++ b/services/bin/qianxin-tianyan-platform.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../qianxin__tianyan-platform/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../qianxin__tianyan-platform/bin/qianxin-tianyan-platform.js", import.meta.url)), +}); diff --git a/services/m01__intelligence/bin/m01-intelligence.js b/services/m01__intelligence/bin/m01-intelligence.js old mode 100644 new mode 100755 diff --git a/services/package.json b/services/package.json index 91438aeb..58bcb6eb 100644 --- a/services/package.json +++ b/services/package.json @@ -37,6 +37,7 @@ "owasp-dependency-track-sca-v5-0": "bin/owasp-dependency-track-sca-v5-0.js", "panabit-tang-r1": "bin/panabit-tang-r1.js", "qianxin-fw-secgate3600": "bin/qianxin-fw-secgate3600.js", + "qianxin-tianyan-platform": "bin/qianxin-tianyan-platform.js", "qianxin-fw-secgate3600-http-x": "bin/qianxin-fw-secgate3600-http-x.js", "qianxin-hunter": "bin/qianxin-hunter.js", "qiming-tianqing-waf": "bin/qiming-tianqing-waf.js", @@ -105,6 +106,7 @@ "bin/owasp-dependency-track-sca-v5-0.js", "bin/panabit-tang-r1.js", "bin/qianxin-fw-secgate3600.js", + "bin/qianxin-tianyan-platform.js", "bin/qianxin-fw-secgate3600-http-x.js", "bin/qianxin-hunter.js", "bin/qiming-tianqing-waf.js", @@ -169,6 +171,7 @@ "owasp__dependency-track-sca_v5-0", "panabit__tang-r1", "qianxin__fw-secgate3600", + "qianxin__tianyan-platform", "qianxin__fw-secgate3600-http-x", "qianxin__hunter_v23-1", "qiming-tianqing__waf", diff --git a/services/qianxin__tianyan-platform/README.md b/services/qianxin__tianyan-platform/README.md new file mode 100644 index 00000000..fcbbe706 --- /dev/null +++ b/services/qianxin__tianyan-platform/README.md @@ -0,0 +1,51 @@ +# QIANXIN TianYan Platform + +OctoBus service package for **QIANXIN TianYan** (奇安信网神威胁监测与分析系统 - 天眼分析平台) V4.0.12.0. + +- **Vendor**: QIANXIN (奇安信) +- **Product**: 天眼分析平台 V4.0.12.0 +- **Proto package**: `QIANXIN_TianYan_Platform` + +## Authentication + +Fully automatic — no manual token management required. + +Set `secret.login_key` (the platform's passwordless login key, found under 系统管理 → 帐号管理 → 本地帐号管理 → 免密LOGIN密钥). On each call the package: + +1. Derives `client_id` and `client_secret` from `login_key` via SHA-256. +2. POSTs to `/skyeye/v1/admin/auth` to obtain an `access_token`. +3. GETs `/skyeye/v1/admin/auth?token=...` to acquire a session cookie and CSRF token from the HTML response. +4. Calls the target API with the CSRF token and session cookie. + +The default login username is `tapadmin`; override via `secret.username`. + +## Config + +| Field | Required | Description | +|-------|----------|-------------| +| `restBaseUrl` | Yes | TianYan base URL, e.g. `https://tianyan.example.com:443` | +| `timeoutMs` | No | HTTP timeout in ms (default 15000) | +| `tlsInsecureSkipVerify` | No | Skip TLS verification for self-signed certs | + +## Methods + +| Method | HTTP | Endpoint | Description | +|--------|------|----------|-------------| +| `ListAlarms` | GET | `/skyeye/v1/alarm/alarm/list` | Query threat alarms with optional filters for hazard level, time range, attacker/victim IP (gzip+base64 encoded), IOC, threat type, and disposition status. | +| `UpdateAlarmStatus` | PUT | `/alarm/alarm/list` | Update alarm disposition: 0=未处置, 1=已处置, 6=忽略, 7=误报. | +| `SearchLogs` | GET | `/analysis/log-search/list` | Search raw security logs by keyword, time range, log index, and category. | +| `SPLSearch` | GET | `/analysis/log-search/spl-search` | Expert SPL query with structured field extraction. | +| `ListAssets` | GET | `/asset/asset/manage/info` | Query asset inventory with optional IP, name, group, port, and type filters. | +| `ListVulnerabilities` | GET | `/asset/vul/leaks/list` | List asset vulnerabilities with optional IP, name, and severity filters. | +| `ThreatHuntSearch` | GET | `/analysis/hunt/search` | Build a threat relationship graph for an IOC keyword (IP, domain, URL, MD5, or email). | +| `AddFlowWhitelist` | POST | `/system/rule_cfg/white_list_flow` | Add an IP, IOC, or threat type to the flow sensor whitelist to suppress future alerts. | +| `GetCompromisedHostStatus` | GET | `/analysis/hunting/stuck_host/status` | Check whether a host is compromised and return its alarm count, risk score, and IOC count. | + +## Test + +```bash +cd services +npm run validate -- --service-dir qianxin__tianyan-platform +npm test -- --service-dir qianxin__tianyan-platform +npm run pack:check +``` diff --git a/services/qianxin__tianyan-platform/bin/qianxin-tianyan-platform.js b/services/qianxin__tianyan-platform/bin/qianxin-tianyan-platform.js new file mode 100755 index 00000000..508272f0 --- /dev/null +++ b/services/qianxin__tianyan-platform/bin/qianxin-tianyan-platform.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/qianxin__tianyan-platform/config.schema.json b/services/qianxin__tianyan-platform/config.schema.json new file mode 100644 index 00000000..59764d1d --- /dev/null +++ b/services/qianxin__tianyan-platform/config.schema.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["restBaseUrl"], + "properties": { + "restBaseUrl": {"type": "string", "description": "TianYan base URL, e.g. https://tianyan.example.com:443"}, + "timeoutMs": {"type": "integer", "default": 15000, "description": "HTTP timeout in ms."}, + "tlsInsecureSkipVerify": {"type": "boolean", "default": false} + }, + "additionalProperties": false +} diff --git a/services/qianxin__tianyan-platform/package.json b/services/qianxin__tianyan-platform/package.json new file mode 100644 index 00000000..e0bf385f --- /dev/null +++ b/services/qianxin__tianyan-platform/package.json @@ -0,0 +1,12 @@ +{ + "name": "qianxin-tianyan-platform", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "qianxin-tianyan-platform": "bin/qianxin-tianyan-platform.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/qianxin__tianyan-platform/proto/qianxin_tianyan_platform.proto b/services/qianxin__tianyan-platform/proto/qianxin_tianyan_platform.proto new file mode 100644 index 00000000..a2ceb675 --- /dev/null +++ b/services/qianxin__tianyan-platform/proto/qianxin_tianyan_platform.proto @@ -0,0 +1,157 @@ +syntax = "proto3"; + +package QIANXIN_TianYan_Platform; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +service QIANXIN_TianYan_Platform { + rpc ListAlarms (ListAlarmsRequest) returns (ListAlarmsResponse); + rpc UpdateAlarmStatus (UpdateAlarmStatusRequest) returns (UpdateAlarmStatusResponse); + rpc SearchLogs (SearchLogsRequest) returns (SearchLogsResponse); + rpc SPLSearch (SPLSearchRequest) returns (SPLSearchResponse); + rpc ListAssets (ListAssetsRequest) returns (ListAssetsResponse); + rpc ListVulnerabilities (ListVulnerabilitiesRequest) returns (ListVulnerabilitiesResponse); + rpc ThreatHuntSearch (ThreatHuntSearchRequest) returns (ThreatHuntSearchResponse); + rpc AddFlowWhitelist (AddFlowWhitelistRequest) returns (AddFlowWhitelistResponse); + rpc GetCompromisedHostStatus (GetCompromisedHostStatusRequest) returns (GetCompromisedHostStatusResponse); +} + +// ListAlarms — query threat alarms with filters +message ListAlarmsRequest { + int32 offset = 1; + int32 limit = 2; + google.protobuf.Int32Value hazard_level = 3; + google.protobuf.Int64Value start_time = 4; + google.protobuf.Int64Value end_time = 5; + google.protobuf.StringValue threat_name = 6; + google.protobuf.StringValue attack_sip = 7; + google.protobuf.StringValue alarm_sip = 8; + google.protobuf.StringValue status = 9; + google.protobuf.StringValue threat_type = 10; + google.protobuf.StringValue host_state = 11; + google.protobuf.StringValue data_source = 12; + google.protobuf.StringValue serial_num = 13; + google.protobuf.StringValue ioc = 14; + google.protobuf.StringValue order_by = 15; +} + +message ListAlarmsResponse { + int32 total = 1; + repeated google.protobuf.Value items = 2; +} + +// UpdateAlarmStatus — update alarm disposition (handled/ignored/false-positive) +message UpdateAlarmStatusRequest { + string ids = 1; + int32 status = 2; +} + +message UpdateAlarmStatusResponse {} + +// SearchLogs — raw log search with keyword or SPL filter +message SearchLogsRequest { + int64 start_time = 1; + int64 end_time = 2; + int32 offset = 3; + int32 limit = 4; + google.protobuf.StringValue branch_id = 5; + google.protobuf.StringValue index = 6; + google.protobuf.StringValue category = 7; + google.protobuf.StringValue mode = 8; + google.protobuf.StringValue keyword = 9; +} + +message SearchLogsResponse { + int32 total = 1; + repeated google.protobuf.Value items = 2; + repeated string fields = 3; +} + +// SPLSearch — expert SPL query with field extraction +message SPLSearchRequest { + int64 start_time = 1; + int64 end_time = 2; + google.protobuf.StringValue category = 3; + google.protobuf.StringValue index = 4; + google.protobuf.StringValue branch_id = 5; + google.protobuf.StringValue keyword = 6; +} + +message SPLSearchResponse { + repeated string fields = 1; + repeated google.protobuf.Value results = 2; +} + +// ListAssets — asset inventory query with filters +message ListAssetsRequest { + int32 offset = 1; + int32 limit = 2; + google.protobuf.StringValue ipaddrs = 3; + google.protobuf.StringValue sname = 4; + google.protobuf.StringValue group_ids = 5; + google.protobuf.StringValue port = 6; + google.protobuf.StringValue stype_ids = 7; + google.protobuf.StringValue branch_id = 8; +} + +message ListAssetsResponse { + int32 total = 1; + repeated google.protobuf.Value data = 2; +} + +// ListVulnerabilities — asset vulnerability list +message ListVulnerabilitiesRequest { + int32 limit = 1; + int32 offset = 2; + google.protobuf.StringValue ip = 3; + google.protobuf.StringValue name = 4; + google.protobuf.Int32Value level = 5; + google.protobuf.StringValue branch_id = 6; +} + +message ListVulnerabilitiesResponse { + int32 total = 1; + repeated google.protobuf.Value items = 2; +} + +// ThreatHuntSearch — build threat relationship graph for an IOC +message ThreatHuntSearchRequest { + string kwd = 1; + int64 start_time = 2; + int64 end_time = 3; +} + +message ThreatHuntSearchResponse { + repeated google.protobuf.Value nodes = 1; + repeated google.protobuf.Value links = 2; +} + +// AddFlowWhitelist — add IP/IOC to flow sensor whitelist +message AddFlowWhitelistRequest { + google.protobuf.StringValue alarm_sips = 1; + google.protobuf.StringValue attack_sips = 2; + google.protobuf.StringValue ioc = 3; + google.protobuf.StringValue threat_name = 4; + google.protobuf.StringValue type_chain = 5; + google.protobuf.Int64Value end_time = 6; + google.protobuf.Int64Value start_time = 7; +} + +message AddFlowWhitelistResponse { + google.protobuf.StringValue id = 1; +} + +// GetCompromisedHostStatus — check if a host is compromised +message GetCompromisedHostStatusRequest { + int64 start_time = 1; + int64 end_time = 2; + string asset_ip = 3; +} + +message GetCompromisedHostStatusResponse { + int32 alarm_count = 1; + int32 risk_value = 2; + int32 ioc_count = 3; + google.protobuf.Value detail = 4; +} diff --git a/services/qianxin__tianyan-platform/secret.schema.json b/services/qianxin__tianyan-platform/secret.schema.json new file mode 100644 index 00000000..4be1b082 --- /dev/null +++ b/services/qianxin__tianyan-platform/secret.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["login_key"], + "properties": { + "login_key": {"type": "string", "description": "Passwordless login key from TianYan: 系统管理 → 帐号管理 → 本地帐号管理 → 免密LOGIN密钥."}, + "username": {"type": "string", "default": "tapadmin", "description": "Login username. Defaults to tapadmin."} + }, + "additionalProperties": false +} diff --git a/services/qianxin__tianyan-platform/service.json b/services/qianxin__tianyan-platform/service.json new file mode 100644 index 00000000..33a8a42d --- /dev/null +++ b/services/qianxin__tianyan-platform/service.json @@ -0,0 +1,57 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "qianxin-tianyan-platform", + "displayName": "QIANXIN TianYan Platform", + "description": "OctoBus package for QIANXIN TianYan threat detection platform: alarm query/update, log search, asset management, vulnerability list, threat hunting, and IP whitelist management.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": ["proto"], + "files": ["proto/qianxin_tianyan_platform.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/ListAlarms": { + "name": "list-alarms", + "description": "Query threat alarms with optional filters for level, time range, attacker/victim IP (gzip+base64 encoded), IOC, and disposition status." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/UpdateAlarmStatus": { + "name": "update-alarm-status", + "description": "Update the disposition status of one or more alarms: 0=unhandled, 1=handled, 6=ignored, 7=false-positive." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/SearchLogs": { + "name": "search-logs", + "description": "Search raw security logs by keyword or SPL filter with time range, log index, and category." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/SPLSearch": { + "name": "spl-search", + "description": "Expert SPL query mode with structured field extraction and result rows." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/ListAssets": { + "name": "list-assets", + "description": "Query asset inventory with optional filters for IP, name, group, port, and type." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/ListVulnerabilities": { + "name": "list-vulnerabilities", + "description": "List asset vulnerabilities with optional filters for IP, name, and severity level." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/ThreatHuntSearch": { + "name": "threat-hunt-search", + "description": "Build a threat relationship graph for an IOC keyword (IP, domain, URL, MD5, or email)." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/AddFlowWhitelist": { + "name": "add-flow-whitelist", + "description": "Add an IP, IOC, or threat type to the flow sensor whitelist to suppress future alerts." + }, + "QIANXIN_TianYan_Platform.QIANXIN_TianYan_Platform/GetCompromisedHostStatus": { + "name": "get-compromised-host-status", + "description": "Check whether a host is compromised and return its alarm count, risk score, and IOC count." + } + } + } + } +} diff --git a/services/qianxin__tianyan-platform/src/qianxin-tianyan-platform.js b/services/qianxin__tianyan-platform/src/qianxin-tianyan-platform.js new file mode 100644 index 00000000..1bdac43d --- /dev/null +++ b/services/qianxin__tianyan-platform/src/qianxin-tianyan-platform.js @@ -0,0 +1,626 @@ +import { createHash } from 'node:crypto'; +import { gzipSync } from 'node:zlib'; +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +const DEFAULT_TIMEOUT_MS = 15000; + +const PKG = 'QIANXIN_TianYan_Platform'; +const P = `/${PKG}.${PKG}/`; + +const PATHS = { + LIST_ALARMS: `${P}ListAlarms`, + UPDATE_ALARM_STATUS: `${P}UpdateAlarmStatus`, + SEARCH_LOGS: `${P}SearchLogs`, + SPL_SEARCH: `${P}SPLSearch`, + LIST_ASSETS: `${P}ListAssets`, + LIST_VULNERABILITIES: `${P}ListVulnerabilities`, + THREAT_HUNT_SEARCH: `${P}ThreatHuntSearch`, + ADD_FLOW_WHITELIST: `${P}AddFlowWhitelist`, + GET_COMPROMISED_HOST_STATUS: `${P}GetCompromisedHostStatus`, +}; + +const sha256 = (s) => createHash('sha256').update(s).digest('hex'); + +// IP filter params for alarm queries must be gzip-compressed then base64-encoded +const encodeIp = (ip) => gzipSync(Buffer.from(String(ip), 'utf8')).toString('base64'); + +const grpcCodeFor = (code) => ({ + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const err = (code, msg) => { + const e = new GrpcError(grpcCodeFor(code), `${code}: ${msg}`); + e.legacyCode = code; + return e; +}; + +const toValue = (val) => { + if (val === undefined || val === null) return undefined; + if (typeof val === 'string') return { stringValue: val }; + if (typeof val === 'number') return { numberValue: val }; + if (typeof val === 'boolean') return { boolValue: val }; + if (Array.isArray(val)) { + return { listValue: { values: val.map(toValue).filter((v) => v !== undefined) } }; + } + if (typeof val === 'object') { + const fields = {}; + for (const [k, v] of Object.entries(val)) { + const nv = toValue(v); + fields[k] = nv === undefined ? { nullValue: 'NULL_VALUE' } : nv; + } + return { structValue: { fields } }; + } + return { stringValue: String(val) }; +}; + +const toInt = (val) => { + if (val === undefined || val === null) return null; + if (typeof val === 'object' && 'value' in val) return toInt(val.value); + const n = Number(val); + return Number.isInteger(n) && !Number.isNaN(n) ? n : null; +}; + +const unwrap = (val) => { + if (val === undefined || val === null) return undefined; + if (typeof val === 'object' && 'value' in val) return String(val.value ?? ''); + return String(val); +}; + +const firstDefined = (...vals) => vals.find((v) => v !== undefined && v !== null); + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), + ...(ctx?.bindings ?? {}), +}); + +const normalizeBaseUrl = (url) => { + const s = String(url || '').trim(); + if (!/^https?:\/\//i.test(s)) return null; + return s.replace(/\/+$/, ''); +}; + +const checkApiError = (json) => { + if (json?.error) { + const msg = String(json.error?.message || json.error?.detail || 'API error'); + const code = json.error?.code; + if (code === 1013 || code === 1002) throw err('PERMISSION_DENIED', msg); + throw err('FAILED_PRECONDITION', `TianYan error ${code !== undefined ? code : ''}: ${msg}`); + } +}; + +export function rpcdef(ctx) { + const bindings = mergedBindings(ctx); + const timeoutMs = ctx.limits?.timeoutMs || DEFAULT_TIMEOUT_MS; + const skipTls = Boolean( + bindings.tlsInsecureSkipVerify || bindings.skipTlsVerify || + bindings.skip_tls_verify || bindings.tls_insecure_skip_verify, + ); + + const tlsOpts = () => (skipTls ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } : {}); + + const baseUrl = () => { + const u = normalizeBaseUrl(firstDefined( + bindings.restBaseUrl, bindings.rest_base_url, + bindings.baseUrl, bindings.base_url, bindings.endpoint, + )); + if (!u) throw err('INVALID_ARGUMENT', 'restBaseUrl is required (http/https)'); + return u; + }; + + const doFetch = async (url, init) => { + try { + return await fetch(url, { ...init, timeoutMs, ...tlsOpts() }); + } catch (e) { + throw err('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); + } + }; + + const readJson = async (res) => { + const text = await res.text(); + if (!res.ok) { + const s = res.status; + if (s === 401 || s === 403) throw err('PERMISSION_DENIED', `http ${s}: ${text}`); + if (s >= 400 && s < 500) throw err('FAILED_PRECONDITION', `http ${s}: ${text}`); + throw err('UNAVAILABLE', `http ${s}: ${text}`); + } + if (!text.trim()) throw err('UNKNOWN', 'empty response body'); + try { return JSON.parse(text); } catch { throw err('UNKNOWN', 'response is not valid JSON'); } + }; + + // 3-step SSO: login_key → sha256 derivation → POST access_token → GET csrf_token + cookie + const getSession = async (base) => { + const loginKey = String(firstDefined(bindings.login_key, bindings.loginKey) || '').trim(); + if (!loginKey) throw err('INVALID_ARGUMENT', 'login_key is required'); + const username = String(firstDefined(bindings.username, bindings.user) || 'tapadmin').trim(); + + const clientId = sha256('mNSLP9UJCtBHtegjDPJnK3v|' + loginKey); + const clientSecret = sha256('3460681205014671737|' + loginKey); + const timestamp = String(Math.floor(Date.now() / 1000)); + const xAuthorization = sha256(JSON.stringify({ client_id: clientId, username }) + timestamp + clientSecret); + + const res1 = await doFetch(`${base}/skyeye/v1/admin/auth`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'X-Authorization': xAuthorization, + 'X-Timestamp': timestamp, + }, + body: new URLSearchParams({ client_id: clientId, username }).toString(), + }); + + let authJson; + try { + authJson = await readJson(res1); + } catch (e) { + if (e?.legacyCode === 'PERMISSION_DENIED' || e?.legacyCode === 'FAILED_PRECONDITION') { + throw err('PERMISSION_DENIED', 'auth step1 failed: ' + (e.message || '')); + } + throw e; + } + + const accessToken = String(authJson?.access_token || '').trim(); + if (!accessToken) throw err('PERMISSION_DENIED', 'auth step1 returned no access_token'); + + const res2 = await doFetch(`${base}/skyeye/v1/admin/auth?token=${encodeURIComponent(accessToken)}`, { + method: 'GET', + }); + + const html = await res2.text(); + if (!res2.ok) { + const s = res2.status; + if (s === 401 || s === 403) throw err('PERMISSION_DENIED', `auth step2 failed: http ${s}`); + throw err('UNAVAILABLE', `auth step2 failed: http ${s}`); + } + + const setCookies = res2.headers.getSetCookie(); + const cookie = setCookies + .map((c) => c.split(';')[0].trim()) + .filter(Boolean) + .join('; '); + + const csrfMatch = html.match(/name="csrf-token"\s+content="([0-9a-fA-F]+)"/) || + html.match(/csrf-token.*?content="([0-9a-fA-F]+)"/); + if (!csrfMatch) throw err('UNKNOWN', 'csrf-token not found in auth response HTML'); + const csrfToken = csrfMatch[1]; + + return { csrfToken, cookie }; + }; + + // GET /skyeye/v1/alarm/alarm/list + // IP filter params (attack_sip, alarm_sip) must be gzip+base64 encoded + const callListAlarms = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const offset = toInt(firstDefined(req?.offset)); + if (offset === null) throw err('INVALID_ARGUMENT', 'offset is required'); + const limit = toInt(firstDefined(req?.limit)); + if (limit === null) throw err('INVALID_ARGUMENT', 'limit is required'); + + const params = new URLSearchParams(); + params.set('offset', String(offset)); + params.set('limit', String(limit)); + params.set('csrf_token', csrfToken); + + const hazardLevel = toInt(firstDefined(req?.hazard_level)); + if (hazardLevel !== null) params.set('hazard_level', String(hazardLevel)); + + const startTime = toInt(firstDefined(req?.start_time)); + if (startTime !== null) params.set('start_time', String(startTime)); + + const endTime = toInt(firstDefined(req?.end_time)); + if (endTime !== null) params.set('end_time', String(endTime)); + + const threatName = unwrap(firstDefined(req?.threat_name)); + if (threatName !== undefined) params.set('threat_name', threatName); + + const attackSip = unwrap(firstDefined(req?.attack_sip)); + if (attackSip !== undefined) params.set('attack_sip', encodeIp(attackSip)); + + const alarmSip = unwrap(firstDefined(req?.alarm_sip)); + if (alarmSip !== undefined) params.set('alarm_sip', encodeIp(alarmSip)); + + const status = unwrap(firstDefined(req?.status)); + if (status !== undefined) params.set('status', status); + + const threatType = unwrap(firstDefined(req?.threat_type)); + if (threatType !== undefined) params.set('threat_type', threatType); + + const hostState = unwrap(firstDefined(req?.host_state)); + if (hostState !== undefined) params.set('host_state', hostState); + + const dataSource = unwrap(firstDefined(req?.data_source)); + if (dataSource !== undefined) params.set('data_source', dataSource); + + const serialNum = unwrap(firstDefined(req?.serial_num)); + if (serialNum !== undefined) params.set('serial_num', serialNum); + + const ioc = unwrap(firstDefined(req?.ioc)); + if (ioc !== undefined) params.set('ioc', ioc); + + const orderBy = unwrap(firstDefined(req?.order_by)); + if (orderBy !== undefined) params.set('order_by', orderBy); + + const res = await doFetch(`${base}/skyeye/v1/alarm/alarm/list?${params.toString()}`, { + method: 'GET', + headers: { Cookie: cookie }, + }); + const json = await readJson(res); + checkApiError(json); + + const items = Array.isArray(json?.data?.items) ? json.data.items : []; + return { + total: toInt(json?.data?.total) ?? 0, + items: items.map(toValue).filter(Boolean), + }; + }; + + // PUT /alarm/alarm/list — update alarm disposition status + // status: 0=未处置 1=已处置 6=忽略 7=误报 + const callUpdateAlarmStatus = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const ids = String(req?.ids ?? '').trim(); + if (!ids) throw err('INVALID_ARGUMENT', 'ids is required (comma-separated alarm IDs)'); + const status = toInt(firstDefined(req?.status)); + if (status === null) throw err('INVALID_ARGUMENT', 'status is required (0=未处置 1=已处置 6=忽略 7=误报)'); + if (![0, 1, 6, 7].includes(status)) { + throw err('INVALID_ARGUMENT', `invalid status ${status}: must be 0, 1, 6, or 7`); + } + + const idList = ids.split(',').map((s) => s.trim()).filter(Boolean); + const res = await doFetch(`${base}/alarm/alarm/list?csrf_token=${encodeURIComponent(csrfToken)}`, { + method: 'PUT', + headers: { Cookie: cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ ids: idList, status }), + }); + const json = await readJson(res); + checkApiError(json); + return {}; + }; + + // GET /analysis/log-search/list — raw log search + const callSearchLogs = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const startTime = req?.start_time; + const endTime = req?.end_time; + if (startTime == null) throw err('INVALID_ARGUMENT', 'start_time is required (13-digit ms timestamp)'); + if (endTime == null) throw err('INVALID_ARGUMENT', 'end_time is required (13-digit ms timestamp)'); + + const offset = toInt(firstDefined(req?.offset)); + if (offset === null) throw err('INVALID_ARGUMENT', 'offset is required'); + const limit = toInt(firstDefined(req?.limit)); + if (limit === null) throw err('INVALID_ARGUMENT', 'limit is required'); + + const params = new URLSearchParams(); + params.set('start_time', String(startTime)); + params.set('end_time', String(endTime)); + params.set('offset', String(offset)); + params.set('limit', String(limit)); + params.set('csrf_token', csrfToken); + + const branchId = unwrap(firstDefined(req?.branch_id)); + if (branchId !== undefined) params.set('branch_id', branchId); + + const index = unwrap(firstDefined(req?.index)); + if (index !== undefined) params.set('index', index); + + const category = unwrap(firstDefined(req?.category)); + if (category !== undefined) params.set('category', category); + + const mode = unwrap(firstDefined(req?.mode)); + if (mode !== undefined) params.set('mode', mode); + + const keyword = unwrap(firstDefined(req?.keyword)); + if (keyword !== undefined) params.set('keyword', keyword); + + const res = await doFetch(`${base}/analysis/log-search/list?${params.toString()}`, { + method: 'GET', + headers: { Cookie: cookie }, + }); + const json = await readJson(res); + checkApiError(json); + + const search = json?.data?.data?.search ?? {}; + const hits = Array.isArray(search?.hits) ? search.hits : []; + const fields = Array.isArray(json?.data?.data?.fields) ? json.data.data.fields : []; + + return { + total: toInt(search?.total ?? json?.data?.total) ?? 0, + items: hits.map((h) => toValue(h?._source ?? h)).filter(Boolean), + fields: fields.map(String), + }; + }; + + // GET /analysis/log-search/spl-search — SPL expert mode log search + const callSPLSearch = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const startTime = req?.start_time; + const endTime = req?.end_time; + if (startTime == null) throw err('INVALID_ARGUMENT', 'start_time is required'); + if (endTime == null) throw err('INVALID_ARGUMENT', 'end_time is required'); + + const params = new URLSearchParams(); + params.set('start_time', String(startTime)); + params.set('end_time', String(endTime)); + params.set('csrf_token', csrfToken); + + const category = unwrap(firstDefined(req?.category)); + if (category !== undefined) params.set('category', category); + + const index = unwrap(firstDefined(req?.index)); + if (index !== undefined) params.set('index', index); + + const branchId = unwrap(firstDefined(req?.branch_id)); + if (branchId !== undefined) params.set('branch_id', branchId); + + const keyword = unwrap(firstDefined(req?.keyword)); + if (keyword !== undefined) params.set('keyword', keyword); + + const res = await doFetch(`${base}/analysis/log-search/spl-search?${params.toString()}`, { + method: 'GET', + headers: { Cookie: cookie }, + }); + const json = await readJson(res); + checkApiError(json); + + const d = json?.data?.data ?? {}; + return { + fields: Array.isArray(d.fields) ? d.fields.map(String) : [], + results: Array.isArray(d.results) ? d.results.map(toValue).filter(Boolean) : [], + }; + }; + + // GET /asset/asset/manage/info — asset list with filters + const callListAssets = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const offset = toInt(firstDefined(req?.offset)); + if (offset === null) throw err('INVALID_ARGUMENT', 'offset is required'); + const limit = toInt(firstDefined(req?.limit)); + if (limit === null) throw err('INVALID_ARGUMENT', 'limit is required'); + + const params = new URLSearchParams(); + params.set('offset', String(offset)); + params.set('limit', String(limit)); + params.set('csrf_token', csrfToken); + + const ipaddrs = unwrap(firstDefined(req?.ipaddrs)); + if (ipaddrs !== undefined) params.set('ipaddrs', ipaddrs); + + const sname = unwrap(firstDefined(req?.sname)); + if (sname !== undefined) params.set('sname', sname); + + const groupIds = unwrap(firstDefined(req?.group_ids)); + if (groupIds !== undefined) params.set('group_ids', groupIds); + + const port = unwrap(firstDefined(req?.port)); + if (port !== undefined) params.set('port', port); + + const stypeIds = unwrap(firstDefined(req?.stype_ids)); + if (stypeIds !== undefined) params.set('stype_ids', stypeIds); + + const branchId = unwrap(firstDefined(req?.branch_id)); + if (branchId !== undefined) params.set('branch_id', branchId); + + const res = await doFetch(`${base}/asset/asset/manage/info?${params.toString()}`, { + method: 'GET', + headers: { Cookie: cookie }, + }); + const json = await readJson(res); + checkApiError(json); + + const data = Array.isArray(json?.data?.data) ? json.data.data : []; + return { + total: toInt(json?.data?.total) ?? 0, + data: data.map(toValue).filter(Boolean), + }; + }; + + // GET /asset/vul/leaks/list — vulnerability list + const callListVulnerabilities = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const limit = toInt(firstDefined(req?.limit)); + if (limit === null) throw err('INVALID_ARGUMENT', 'limit is required'); + const offset = toInt(firstDefined(req?.offset)); + if (offset === null) throw err('INVALID_ARGUMENT', 'offset is required'); + + const params = new URLSearchParams(); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + params.set('csrf_token', csrfToken); + + const ip = unwrap(firstDefined(req?.ip)); + if (ip !== undefined) params.set('ip', ip); + + const name = unwrap(firstDefined(req?.name)); + if (name !== undefined) params.set('name', name); + + const level = toInt(firstDefined(req?.level)); + if (level !== null) params.set('level', String(level)); + + const branchId = unwrap(firstDefined(req?.branch_id)); + if (branchId !== undefined) params.set('branch_id', branchId); + + const res = await doFetch(`${base}/asset/vul/leaks/list?${params.toString()}`, { + method: 'GET', + headers: { Cookie: cookie }, + }); + const json = await readJson(res); + checkApiError(json); + + const items = Array.isArray(json?.data?.items) ? json.data.items : []; + return { + total: toInt(json?.data?.total) ?? 0, + items: items.map(toValue).filter(Boolean), + }; + }; + + // GET /analysis/hunt/search — build threat relationship graph for IP/domain/MD5/URI + const callThreatHuntSearch = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const kwd = String(req?.kwd ?? '').trim(); + if (!kwd) throw err('INVALID_ARGUMENT', 'kwd is required (IP, domain, URL, MD5, or email)'); + const startTime = req?.start_time; + const endTime = req?.end_time; + if (startTime == null) throw err('INVALID_ARGUMENT', 'start_time is required'); + if (endTime == null) throw err('INVALID_ARGUMENT', 'end_time is required'); + + const params = new URLSearchParams(); + params.set('kwd', kwd); + params.set('start_time', String(startTime)); + params.set('end_time', String(endTime)); + params.set('csrf_token', csrfToken); + + const res = await doFetch(`${base}/analysis/hunt/search?${params.toString()}`, { + method: 'GET', + headers: { Cookie: cookie }, + }); + const json = await readJson(res); + checkApiError(json); + + const d = json?.data ?? {}; + return { + nodes: Array.isArray(d.nodes) ? d.nodes.map(toValue).filter(Boolean) : [], + links: Array.isArray(d.links) ? d.links.map(toValue).filter(Boolean) : [], + }; + }; + + // POST /system/rule_cfg/white_list_flow — add IP/IOC to flow sensor whitelist + const callAddFlowWhitelist = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const alarmSips = unwrap(firstDefined(req?.alarm_sips)); + const attackSips = unwrap(firstDefined(req?.attack_sips)); + const ioc = unwrap(firstDefined(req?.ioc)); + const threatName = unwrap(firstDefined(req?.threat_name)); + const typeChain = unwrap(firstDefined(req?.type_chain)); + + if (!alarmSips && !attackSips && !ioc && !threatName && !typeChain) { + throw err('INVALID_ARGUMENT', 'at least one of alarm_sips, attack_sips, ioc, threat_name, type_chain is required'); + } + + const body = {}; + if (alarmSips) body.alarm_sips = alarmSips; + if (attackSips) body.attack_sips = attackSips; + if (ioc) body.ioc = ioc; + if (threatName) body.threat_name = threatName; + if (typeChain) body.type_chain = typeChain; + + const endTime = req?.end_time; + if (endTime != null) { + body.end_time = typeof endTime === 'object' && 'value' in endTime ? endTime.value : endTime; + } + const startTime = req?.start_time; + if (startTime != null) { + body.start_time = typeof startTime === 'object' && 'value' in startTime ? startTime.value : startTime; + } + + const res = await doFetch(`${base}/system/rule_cfg/white_list_flow?csrf_token=${encodeURIComponent(csrfToken)}`, { + method: 'POST', + headers: { Cookie: cookie, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = await readJson(res); + checkApiError(json); + + const id = String(json?.data?.id ?? json?.data?.data?.id ?? ''); + return { id: id ? { value: id } : undefined }; + }; + + // GET /analysis/hunting/stuck_host/status — check if a host is compromised + const callGetCompromisedHostStatus = async (req) => { + const base = baseUrl(); + const { csrfToken, cookie } = await getSession(base); + + const assetIp = String(req?.asset_ip ?? '').trim(); + if (!assetIp) throw err('INVALID_ARGUMENT', 'asset_ip is required'); + const startTime = req?.start_time; + const endTime = req?.end_time; + if (startTime == null) throw err('INVALID_ARGUMENT', 'start_time is required'); + if (endTime == null) throw err('INVALID_ARGUMENT', 'end_time is required'); + + const params = new URLSearchParams(); + params.set('start_time', String(startTime)); + params.set('end_time', String(endTime)); + params.set('asset_ip', assetIp); + params.set('csrf_token', csrfToken); + + const res = await doFetch(`${base}/analysis/hunting/stuck_host/status?${params.toString()}`, { + method: 'GET', + headers: { Cookie: cookie }, + }); + const json = await readJson(res); + checkApiError(json); + + const d = json?.data ?? {}; + return { + alarm_count: toInt(d.alarm_count) ?? 0, + risk_value: toInt(d.risk_value) ?? 0, + ioc_count: toInt(d.ioc_count) ?? 0, + detail: toValue(d) ?? { nullValue: 'NULL_VALUE' }, + }; + }; + + return { + [PATHS.LIST_ALARMS]: async () => callListAlarms(ctx.req), + [PATHS.UPDATE_ALARM_STATUS]: async () => callUpdateAlarmStatus(ctx.req), + [PATHS.SEARCH_LOGS]: async () => callSearchLogs(ctx.req), + [PATHS.SPL_SEARCH]: async () => callSPLSearch(ctx.req), + [PATHS.LIST_ASSETS]: async () => callListAssets(ctx.req), + [PATHS.LIST_VULNERABILITIES]: async () => callListVulnerabilities(ctx.req), + [PATHS.THREAT_HUNT_SEARCH]: async () => callThreatHuntSearch(ctx.req), + [PATHS.ADD_FLOW_WHITELIST]: async () => callAddFlowWhitelist(ctx.req), + [PATHS.GET_COMPROMISED_HOST_STATUS]: async () => callGetCompromisedHostStatus(ctx.req), + }; +} + +const mergeCtx = (base, inner) => ({ + ...(base ?? {}), ...(inner ?? {}), + bindings: { ...(base?.bindings ?? {}), ...(inner?.bindings ?? {}) }, + config: { ...(base?.config ?? {}), ...(inner?.config ?? {}) }, + secret: { ...(base?.secret ?? {}), ...(inner?.secret ?? {}) }, + limits: inner?.limits ?? base?.limits ?? {}, + meta: inner?.meta ?? base?.meta ?? {}, +}); + +const resolveCallContext = (baseCtx, reqOrCtx, maybeCtx) => { + if (maybeCtx !== undefined) return { req: reqOrCtx ?? {}, ctx: mergeCtx(baseCtx, maybeCtx) }; + const inner = reqOrCtx ?? {}; + return { req: inner.request ?? inner.req ?? {}, ctx: mergeCtx(baseCtx, inner) }; +}; + +const wrapHandler = (baseCtx, path) => async (reqOrCtx, maybeCtx) => { + const { req, ctx } = resolveCallContext(baseCtx, reqOrCtx, maybeCtx); + return rpcdef({ ...ctx, req })[path](); +}; + +const registerHandlers = (ctx = {}) => Object.fromEntries( + Object.values(PATHS).map((p) => [p, wrapHandler(ctx, p)]), +); + +const sdkHandlers = registerHandlers({}); + +export const handlers = Object.fromEntries( + Object.values(PATHS).map((p) => [p.replace(/^\//, ''), sdkHandlers[p]]), +); + +export const _test = { + err, firstDefined, mergedBindings, normalizeBaseUrl, toInt, toValue, unwrap, sha256, encodeIp, checkApiError, +}; diff --git a/services/qianxin__tianyan-platform/src/service.js b/services/qianxin__tianyan-platform/src/service.js new file mode 100644 index 00000000..11af71a0 --- /dev/null +++ b/services/qianxin__tianyan-platform/src/service.js @@ -0,0 +1,6 @@ +import { rpcdef, handlers } from "./qianxin-tianyan-platform.js"; + +export const service = { + rpcdef, + handlers, +}; diff --git a/services/qianxin__tianyan-platform/test/qianxin-tianyan-platform.test.js b/services/qianxin__tianyan-platform/test/qianxin-tianyan-platform.test.js new file mode 100644 index 00000000..2fd0475d --- /dev/null +++ b/services/qianxin__tianyan-platform/test/qianxin-tianyan-platform.test.js @@ -0,0 +1,1111 @@ +import { describe, it, afterEach } from 'node:test'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { rpcdef, handlers, _test } from '../src/qianxin-tianyan-platform.js'; + +const PKG = 'QIANXIN_TianYan_Platform'; +const PREFIX = `/${PKG}.${PKG}/`; + +const PATH_LIST_ALARMS = `${PREFIX}ListAlarms`; +const PATH_UPDATE_ALARM_STATUS = `${PREFIX}UpdateAlarmStatus`; +const PATH_SEARCH_LOGS = `${PREFIX}SearchLogs`; +const PATH_SPL_SEARCH = `${PREFIX}SPLSearch`; +const PATH_LIST_ASSETS = `${PREFIX}ListAssets`; +const PATH_LIST_VULNERABILITIES = `${PREFIX}ListVulnerabilities`; +const PATH_THREAT_HUNT_SEARCH = `${PREFIX}ThreatHuntSearch`; +const PATH_ADD_FLOW_WHITELIST = `${PREFIX}AddFlowWhitelist`; +const PATH_GET_COMPROMISED_HOST = `${PREFIX}GetCompromisedHostStatus`; + +const KEY_LIST_ALARMS = `${PKG}.${PKG}/ListAlarms`; +const KEY_UPDATE_ALARM_STATUS = `${PKG}.${PKG}/UpdateAlarmStatus`; +const KEY_SEARCH_LOGS = `${PKG}.${PKG}/SearchLogs`; +const KEY_SPL_SEARCH = `${PKG}.${PKG}/SPLSearch`; +const KEY_LIST_ASSETS = `${PKG}.${PKG}/ListAssets`; +const KEY_LIST_VULNERABILITIES = `${PKG}.${PKG}/ListVulnerabilities`; +const KEY_THREAT_HUNT_SEARCH = `${PKG}.${PKG}/ThreatHuntSearch`; +const KEY_ADD_FLOW_WHITELIST = `${PKG}.${PKG}/AddFlowWhitelist`; +const KEY_GET_COMPROMISED_HOST = `${PKG}.${PKG}/GetCompromisedHostStatus`; + +// --------------------------------------------------------------------------- +// Shared mock helpers +// --------------------------------------------------------------------------- + +const AUTH_TOKEN_BODY = JSON.stringify({ access_token: 'test_token', status: 200 }); +const AUTH_HTML_BODY = '
'; + +const makeHeaders = (entries = {}) => ({ + get: (name) => entries[name.toLowerCase()] ?? null, + getSetCookie: () => { + const v = entries['set-cookie']; + if (!v) return []; + return Array.isArray(v) ? v : [v]; + }, +}); + +const makeSeqFetch = (apiBody, apiStatus = 200) => { + let call = 0; + return async (_url, _init) => { + call++; + if (call === 1) { + return { + ok: true, status: 200, + text: async () => AUTH_TOKEN_BODY, + headers: makeHeaders({}), + }; + } + if (call === 2) { + return { + ok: true, status: 200, + text: async () => AUTH_HTML_BODY, + headers: makeHeaders({ 'set-cookie': 'session=xyz; Path=/' }), + }; + } + return { + ok: apiStatus >= 200 && apiStatus < 300, + status: apiStatus, + text: async () => (typeof apiBody === 'string' ? apiBody : JSON.stringify(apiBody)), + headers: makeHeaders({}), + }; + }; +}; + +const networkErrorFetch = async () => { throw new Error('ECONNREFUSED'); }; + +const buildCtx = (overrides = {}) => ({ + bindings: { restBaseUrl: 'https://tianyan.example.com', ...(overrides.bindings ?? {}) }, + config: overrides.config ?? {}, + secret: overrides.secret !== undefined ? overrides.secret : { login_key: 'my-login-key' }, + limits: { timeoutMs: 5000, ...(overrides.limits ?? {}) }, + meta: overrides.meta ?? {}, + req: overrides.req ?? {}, +}); + +const originalFetch = globalThis.fetch; +test.afterEach(() => { globalThis.fetch = originalFetch; }); + +// --------------------------------------------------------------------------- +// Shape +// --------------------------------------------------------------------------- +test('rpcdef exposes all 9 paths', () => { + globalThis.fetch = makeSeqFetch({}); + const def = rpcdef(buildCtx()); + assert.equal(typeof def[PATH_LIST_ALARMS], 'function'); + assert.equal(typeof def[PATH_UPDATE_ALARM_STATUS], 'function'); + assert.equal(typeof def[PATH_SEARCH_LOGS], 'function'); + assert.equal(typeof def[PATH_SPL_SEARCH], 'function'); + assert.equal(typeof def[PATH_LIST_ASSETS], 'function'); + assert.equal(typeof def[PATH_LIST_VULNERABILITIES], 'function'); + assert.equal(typeof def[PATH_THREAT_HUNT_SEARCH], 'function'); + assert.equal(typeof def[PATH_ADD_FLOW_WHITELIST], 'function'); + assert.equal(typeof def[PATH_GET_COMPROMISED_HOST], 'function'); +}); + +test('handlers exposes keys without leading slash', () => { + assert.equal(typeof handlers[KEY_LIST_ALARMS], 'function'); + assert.equal(typeof handlers[KEY_UPDATE_ALARM_STATUS], 'function'); + assert.equal(typeof handlers[KEY_SEARCH_LOGS], 'function'); + assert.equal(typeof handlers[KEY_LIST_ASSETS], 'function'); + assert.equal(typeof handlers[KEY_GET_COMPROMISED_HOST], 'function'); +}); + +// --------------------------------------------------------------------------- +// ListAlarms — success cases +// --------------------------------------------------------------------------- +describe('ListAlarms success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('required params only: offset + limit', async () => { + const apiResp = { data: { items: [{ id: 1 }, { id: 2 }], total: 5, status: 1000 } }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ req: { offset: 1, limit: 20 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 5); + assert.equal(r.items.length, 2); + }); + + it('all optional filters passed', async () => { + const apiResp = { data: { items: [{ id: 3 }], total: 1, status: 1000 } }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ + req: { + offset: 1, limit: 10, + hazard_level: { value: 2 }, + start_time: { value: 1700000000000 }, + end_time: { value: 1700086400000 }, + threat_name: { value: 'Mimikatz' }, + attack_sip: { value: '10.0.0.1' }, + alarm_sip: { value: '192.168.1.5' }, + status: { value: '0' }, + threat_type: { value: 'lateral_movement' }, + host_state: { value: '1' }, + data_source: { value: '1' }, + serial_num: { value: 'SN-12345' }, + ioc: { value: 'malware.exe' }, + order_by: { value: 'access_time:desc' }, + }, + }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 1); + }); + + it('attack_sip and alarm_sip are gzip+base64 encoded in the request URL', async () => { + let capturedUrl = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { items: [], total: 0 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 10, attack_sip: { value: '10.0.0.1' } } }); + await rpcdef(ctx)[PATH_LIST_ALARMS](); + const u = new URL(capturedUrl); + const encoded = u.searchParams.get('attack_sip'); + // encoded should be base64 (not the raw IP) + assert.ok(encoded !== '10.0.0.1', 'attack_sip must be encoded, not plain text'); + assert.ok(encoded.length > 10, 'encoded value should be longer than raw IP'); + }); + + it('empty items array', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 0, status: 1000 } }); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 0); + assert.deepEqual(r.items, []); + }); + + it('missing data.items falls back to []', async () => { + globalThis.fetch = makeSeqFetch({ data: { total: 0 } }); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.deepEqual(r.items, []); + }); + + it('items wrapped as google.protobuf.Value structValues', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [{ id: 99, name: 'alert' }], total: 1 } }); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.ok(r.items[0].structValue); + assert.deepEqual(r.items[0].structValue.fields.id, { numberValue: 99 }); + }); + + it('handlers two-arg convention', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 3 } }); + const r = await handlers[KEY_LIST_ALARMS]({ offset: 1, limit: 5 }, buildCtx()); + assert.equal(r.total, 3); + }); + + it('handlers single ctx-object convention', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 7 } }); + const ctx = buildCtx({ req: { offset: 1, limit: 5 } }); + const r = await handlers[KEY_LIST_ALARMS](ctx); + assert.equal(r.total, 7); + }); + + it('username from secret overrides default tapadmin', async () => { + let capturedBody = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) { capturedBody = init?.body ?? ''; return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; } + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=abc' }) }; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { items: [], total: 0 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ secret: { login_key: 'key', username: 'customuser' }, req: { offset: 1, limit: 10 } }); + await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.ok(capturedBody.includes('customuser')); + }); + + it('cookie forwarded from Set-Cookie', async () => { + let capturedCookie = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=xyz123; Path=/' }) }; + capturedCookie = init?.headers?.Cookie ?? ''; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { items: [], total: 0 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 5 } }); + await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.ok(capturedCookie.includes('session=xyz123')); + }); + + it('csrf_token forwarded in query string', async () => { + let capturedUrl = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { items: [], total: 0 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 5 } }); + await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.ok(capturedUrl.includes('csrf_token=abc123def456')); + }); + + it('multiple cookies joined with "; "', async () => { + let capturedCookie = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': ['session=abc; Path=/', 'token=xyz; HttpOnly'] }) }; + capturedCookie = init?.headers?.Cookie ?? ''; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { items: [], total: 0 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 5 } }); + await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.ok(capturedCookie.includes('session=abc')); + assert.ok(capturedCookie.includes('token=xyz')); + }); + + it('throws FAILED_PRECONDITION when API returns error envelope', async () => { + globalThis.fetch = makeSeqFetch({ error: { message: 'no permission', code: 5000 } }); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /FAILED_PRECONDITION/); + }); +}); + +// --------------------------------------------------------------------------- +// ListAlarms — validation errors +// --------------------------------------------------------------------------- +describe('ListAlarms validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when offset missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when limit missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { offset: 1 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when login_key missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ secret: {}, req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when restBaseUrl missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ bindings: { restBaseUrl: '' }, req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// ListAlarms — auth errors +// --------------------------------------------------------------------------- +describe('ListAlarms auth errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws PERMISSION_DENIED on HTTP 401 from auth step1', async () => { + globalThis.fetch = async () => ({ ok: false, status: 401, text: async () => 'Unauthorized', headers: makeHeaders({}) }); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /PERMISSION_DENIED/); + }); + + it('throws PERMISSION_DENIED on HTTP 403 from auth step1', async () => { + globalThis.fetch = async () => ({ ok: false, status: 403, text: async () => 'Forbidden', headers: makeHeaders({}) }); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /PERMISSION_DENIED/); + }); + + it('throws PERMISSION_DENIED when access_token absent', async () => { + let call = 0; + globalThis.fetch = async () => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => JSON.stringify({ status: 200 }), headers: makeHeaders({}) }; + return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /PERMISSION_DENIED/); + }); + + it('throws UNKNOWN when csrf_token not found in HTML', async () => { + let call = 0; + globalThis.fetch = async () => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => 'no csrf here', headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + return { ok: true, status: 200, text: async () => '{}', headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /UNKNOWN/); + }); + + it('throws PERMISSION_DENIED on HTTP 403 from auth step2', async () => { + let call = 0; + globalThis.fetch = async () => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + return { ok: false, status: 403, text: async () => 'Forbidden', headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /PERMISSION_DENIED/); + }); + + it('throws UNAVAILABLE on HTTP 500 from auth step2', async () => { + let call = 0; + globalThis.fetch = async () => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + return { ok: false, status: 500, text: async () => 'Internal Server Error', headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /UNAVAILABLE/); + }); + + it('csrf-token alternate regex pattern', async () => { + let call = 0; + globalThis.fetch = async () => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => '', headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { items: [], total: 0 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 0); + }); +}); + +// --------------------------------------------------------------------------- +// ListAlarms — network / HTTP errors +// --------------------------------------------------------------------------- +describe('ListAlarms network errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws UNAVAILABLE on network error', async () => { + globalThis.fetch = networkErrorFetch; + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /UNAVAILABLE/); + }); + + it('throws UNAVAILABLE on HTTP 500', async () => { + globalThis.fetch = makeSeqFetch('Internal Server Error', 500); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /UNAVAILABLE/); + }); + + it('throws FAILED_PRECONDITION on HTTP 422', async () => { + globalThis.fetch = makeSeqFetch('Unprocessable', 422); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /FAILED_PRECONDITION/); + }); + + it('throws UNKNOWN on non-JSON response', async () => { + globalThis.fetch = makeSeqFetch('error'); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /UNKNOWN/); + }); + + it('throws UNKNOWN on empty response', async () => { + globalThis.fetch = makeSeqFetch(''); + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /UNKNOWN/); + }); + + it('network error with cause.message in UNAVAILABLE', async () => { + globalThis.fetch = async () => { const e = new Error('wrapper'); e.cause = new Error('connect ECONNREFUSED'); throw e; }; + const ctx = buildCtx({ req: { offset: 1, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ALARMS](), /UNAVAILABLE/); + }); +}); + +// --------------------------------------------------------------------------- +// UpdateAlarmStatus +// --------------------------------------------------------------------------- +describe('UpdateAlarmStatus success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('marks single alarm as handled (status=1)', async () => { + let capturedBody = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedBody = init?.body ?? ''; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { status: 1000 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { ids: 'alarm-001', status: 1 } }); + const r = await rpcdef(ctx)[PATH_UPDATE_ALARM_STATUS](); + assert.deepEqual(r, {}); + const parsed = JSON.parse(capturedBody); + assert.deepEqual(parsed.ids, ['alarm-001']); + assert.equal(parsed.status, 1); + }); + + it('marks multiple alarms as false-positive (status=7)', async () => { + let capturedBody = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedBody = init?.body ?? ''; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { status: 1000 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { ids: 'a1, a2, a3', status: 7 } }); + const r = await rpcdef(ctx)[PATH_UPDATE_ALARM_STATUS](); + assert.deepEqual(r, {}); + const parsed = JSON.parse(capturedBody); + assert.equal(parsed.ids.length, 3); + assert.equal(parsed.status, 7); + }); + + it('uses PUT method to /alarm/alarm/list', async () => { + let capturedMethod = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedMethod = init?.method ?? ''; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: {} }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { ids: 'x', status: 0 } }); + await rpcdef(ctx)[PATH_UPDATE_ALARM_STATUS](); + assert.equal(capturedMethod, 'PUT'); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: { status: 1000 } }); + const r = await handlers[KEY_UPDATE_ALARM_STATUS]({ ids: 'a1', status: 6 }, buildCtx()); + assert.deepEqual(r, {}); + }); +}); + +describe('UpdateAlarmStatus validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when ids missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { status: 1 } }); + await assert.rejects(rpcdef(ctx)[PATH_UPDATE_ALARM_STATUS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when status missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { ids: 'a1' } }); + await assert.rejects(rpcdef(ctx)[PATH_UPDATE_ALARM_STATUS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when status is invalid value', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { ids: 'a1', status: 99 } }); + await assert.rejects(rpcdef(ctx)[PATH_UPDATE_ALARM_STATUS](), /INVALID_ARGUMENT/); + }); + + it('accepts all valid status values: 0, 1, 6, 7', async () => { + for (const s of [0, 1, 6, 7]) { + globalThis.fetch = makeSeqFetch({ data: { status: 1000 } }); + const ctx = buildCtx({ req: { ids: 'x', status: s } }); + const r = await rpcdef(ctx)[PATH_UPDATE_ALARM_STATUS](); + assert.deepEqual(r, {}); + } + }); +}); + +// --------------------------------------------------------------------------- +// SearchLogs +// --------------------------------------------------------------------------- +describe('SearchLogs success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns total and items from search.hits', async () => { + const apiResp = { + data: { + status: 1000, + data: { + search: { hits: [{ _source: { sip: '1.1.1.1', proto: 'http' } }], total: 42 }, + fields: ['sip', 'proto'], + }, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000, offset: 0, limit: 20 } }); + const r = await rpcdef(ctx)[PATH_SEARCH_LOGS](); + assert.equal(r.total, 42); + assert.equal(r.items.length, 1); + assert.ok(r.items[0].structValue); + assert.deepEqual(r.fields, ['sip', 'proto']); + }); + + it('keyword passed as query param', async () => { + let capturedUrl = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { data: { search: { hits: [], total: 0 }, fields: [] } } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000, offset: 0, limit: 10, keyword: { value: 'sip:10.0.0.1' } } }); + await rpcdef(ctx)[PATH_SEARCH_LOGS](); + assert.ok(capturedUrl.includes('keyword=sip%3A10.0.0.1') || capturedUrl.includes('keyword=sip:10.0.0.1')); + }); + + it('empty result falls back gracefully', async () => { + globalThis.fetch = makeSeqFetch({ data: { data: {} } }); + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000, offset: 0, limit: 10 } }); + const r = await rpcdef(ctx)[PATH_SEARCH_LOGS](); + assert.equal(r.total, 0); + assert.deepEqual(r.items, []); + assert.deepEqual(r.fields, []); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: { data: { search: { hits: [], total: 5 }, fields: [] } } }); + const r = await handlers[KEY_SEARCH_LOGS]({ start_time: 1700000000000, end_time: 1700086400000, offset: 0, limit: 5 }, buildCtx()); + assert.equal(r.total, 5); + }); +}); + +describe('SearchLogs validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when start_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { end_time: 1700086400000, offset: 0, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_SEARCH_LOGS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when end_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { start_time: 1700000000000, offset: 0, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_SEARCH_LOGS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when offset missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000, limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_SEARCH_LOGS](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// SPLSearch +// --------------------------------------------------------------------------- +describe('SPLSearch success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns fields and results', async () => { + const apiResp = { data: { status: 1000, data: { fields: ['sip', 'dport'], results: [{ sip: '1.2.3.4', dport: 80 }] } } }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000 } }); + const r = await rpcdef(ctx)[PATH_SPL_SEARCH](); + assert.deepEqual(r.fields, ['sip', 'dport']); + assert.equal(r.results.length, 1); + assert.ok(r.results[0].structValue); + }); + + it('empty result', async () => { + globalThis.fetch = makeSeqFetch({ data: { data: { fields: [], results: [] } } }); + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000 } }); + const r = await rpcdef(ctx)[PATH_SPL_SEARCH](); + assert.deepEqual(r.fields, []); + assert.deepEqual(r.results, []); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: { data: { fields: ['f1'], results: [] } } }); + const r = await handlers[KEY_SPL_SEARCH]({ start_time: 1700000000000, end_time: 1700086400000 }, buildCtx()); + assert.deepEqual(r.fields, ['f1']); + }); +}); + +describe('SPLSearch validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when start_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { end_time: 1700086400000 } }); + await assert.rejects(rpcdef(ctx)[PATH_SPL_SEARCH](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when end_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { start_time: 1700000000000 } }); + await assert.rejects(rpcdef(ctx)[PATH_SPL_SEARCH](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// ListAssets +// --------------------------------------------------------------------------- +describe('ListAssets success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns total and data array', async () => { + const apiResp = { data: { status: 1000, total: 3, data: [{ id: 1, asset_sip: '10.0.0.1' }, { id: 2, asset_sip: '10.0.0.2' }] } }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ req: { offset: 0, limit: 10 } }); + const r = await rpcdef(ctx)[PATH_LIST_ASSETS](); + assert.equal(r.total, 3); + assert.equal(r.data.length, 2); + assert.ok(r.data[0].structValue); + }); + + it('optional ip filter included in URL', async () => { + let capturedUrl = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { total: 0, data: [] } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { offset: 0, limit: 10, ipaddrs: { value: '10.0.0.1' } } }); + await rpcdef(ctx)[PATH_LIST_ASSETS](); + assert.ok(capturedUrl.includes('ipaddrs=10.0.0.1')); + }); + + it('empty data array', async () => { + globalThis.fetch = makeSeqFetch({ data: { total: 0, data: [] } }); + const ctx = buildCtx({ req: { offset: 0, limit: 10 } }); + const r = await rpcdef(ctx)[PATH_LIST_ASSETS](); + assert.equal(r.total, 0); + assert.deepEqual(r.data, []); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: { total: 1, data: [{ id: 5 }] } }); + const r = await handlers[KEY_LIST_ASSETS]({ offset: 0, limit: 5 }, buildCtx()); + assert.equal(r.total, 1); + }); +}); + +describe('ListAssets validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when offset missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ASSETS](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when limit missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { offset: 0 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_ASSETS](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// ListVulnerabilities +// --------------------------------------------------------------------------- +describe('ListVulnerabilities success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns total and items', async () => { + const apiResp = { data: { status: 1000, total: 10, items: [{ id: 'v1', cve: 'CVE-2023-0001' }] } }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ req: { limit: 20, offset: 0 } }); + const r = await rpcdef(ctx)[PATH_LIST_VULNERABILITIES](); + assert.equal(r.total, 10); + assert.equal(r.items.length, 1); + assert.ok(r.items[0].structValue); + }); + + it('ip filter passed to URL', async () => { + let capturedUrl = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { total: 0, items: [] } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { limit: 10, offset: 0, ip: { value: '192.168.1.100' } } }); + await rpcdef(ctx)[PATH_LIST_VULNERABILITIES](); + assert.ok(capturedUrl.includes('ip=192.168.1.100')); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: { total: 2, items: [] } }); + const r = await handlers[KEY_LIST_VULNERABILITIES]({ limit: 10, offset: 0 }, buildCtx()); + assert.equal(r.total, 2); + }); +}); + +describe('ListVulnerabilities validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when limit missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { offset: 0 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_VULNERABILITIES](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when offset missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { limit: 10 } }); + await assert.rejects(rpcdef(ctx)[PATH_LIST_VULNERABILITIES](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// ThreatHuntSearch +// --------------------------------------------------------------------------- +describe('ThreatHuntSearch success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns nodes and links arrays', async () => { + const apiResp = { data: { nodes: [{ id: 'n1', type: 'ip' }], links: [{ source: 'n1', target: 'n2' }] } }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ req: { kwd: '10.0.0.1', start_time: 1700000000000, end_time: 1700086400000 } }); + const r = await rpcdef(ctx)[PATH_THREAT_HUNT_SEARCH](); + assert.equal(r.nodes.length, 1); + assert.equal(r.links.length, 1); + assert.ok(r.nodes[0].structValue); + assert.ok(r.links[0].structValue); + }); + + it('kwd passed in URL', async () => { + let capturedUrl = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { nodes: [], links: [] } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { kwd: 'evil.com', start_time: 1700000000000, end_time: 1700086400000 } }); + await rpcdef(ctx)[PATH_THREAT_HUNT_SEARCH](); + assert.ok(capturedUrl.includes('kwd=evil.com')); + }); + + it('empty graph', async () => { + globalThis.fetch = makeSeqFetch({ data: {} }); + const ctx = buildCtx({ req: { kwd: 'x', start_time: 1700000000000, end_time: 1700086400000 } }); + const r = await rpcdef(ctx)[PATH_THREAT_HUNT_SEARCH](); + assert.deepEqual(r.nodes, []); + assert.deepEqual(r.links, []); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: { nodes: [], links: [] } }); + const r = await handlers[KEY_THREAT_HUNT_SEARCH]({ kwd: '1.2.3.4', start_time: 1700000000000, end_time: 1700086400000 }, buildCtx()); + assert.deepEqual(r.nodes, []); + }); +}); + +describe('ThreatHuntSearch validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when kwd missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000 } }); + await assert.rejects(rpcdef(ctx)[PATH_THREAT_HUNT_SEARCH](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when start_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { kwd: 'x', end_time: 1700086400000 } }); + await assert.rejects(rpcdef(ctx)[PATH_THREAT_HUNT_SEARCH](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when end_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { kwd: 'x', start_time: 1700000000000 } }); + await assert.rejects(rpcdef(ctx)[PATH_THREAT_HUNT_SEARCH](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// AddFlowWhitelist +// --------------------------------------------------------------------------- +describe('AddFlowWhitelist success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('adds whitelist by alarm_sips', async () => { + let capturedBody = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedBody = init?.body ?? ''; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: { id: 'wl-001', status: 1000 } }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { alarm_sips: { value: '192.168.1.100,192.168.1.101' } } }); + const r = await rpcdef(ctx)[PATH_ADD_FLOW_WHITELIST](); + assert.ok(r.id?.value === 'wl-001'); + const parsed = JSON.parse(capturedBody); + assert.equal(parsed.alarm_sips, '192.168.1.100,192.168.1.101'); + }); + + it('adds whitelist by attack_sips', async () => { + globalThis.fetch = makeSeqFetch({ data: { id: 'wl-002' } }); + const ctx = buildCtx({ req: { attack_sips: { value: '10.0.0.1' } } }); + const r = await rpcdef(ctx)[PATH_ADD_FLOW_WHITELIST](); + assert.ok(r.id?.value === 'wl-002'); + }); + + it('adds whitelist by ioc', async () => { + globalThis.fetch = makeSeqFetch({ data: { data: { id: 'wl-003' } } }); + const ctx = buildCtx({ req: { ioc: { value: 'evil.exe' } } }); + const r = await rpcdef(ctx)[PATH_ADD_FLOW_WHITELIST](); + assert.ok(r.id?.value === 'wl-003'); + }); + + it('adds whitelist by threat_name', async () => { + globalThis.fetch = makeSeqFetch({ data: {} }); + const ctx = buildCtx({ req: { threat_name: { value: 'Mimikatz' } } }); + const r = await rpcdef(ctx)[PATH_ADD_FLOW_WHITELIST](); + assert.ok(r.id === undefined); + }); + + it('uses POST method', async () => { + let capturedMethod = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedMethod = init?.method ?? ''; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: {} }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { alarm_sips: { value: '1.2.3.4' } } }); + await rpcdef(ctx)[PATH_ADD_FLOW_WHITELIST](); + assert.equal(capturedMethod, 'POST'); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: {} }); + const r = await handlers[KEY_ADD_FLOW_WHITELIST]({ ioc: { value: 'bad.dll' } }, buildCtx()); + assert.ok('id' in r || r.id === undefined); + }); +}); + +describe('AddFlowWhitelist validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when none of the required target fields provided', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: {} }); + await assert.rejects(rpcdef(ctx)[PATH_ADD_FLOW_WHITELIST](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// GetCompromisedHostStatus +// --------------------------------------------------------------------------- +describe('GetCompromisedHostStatus success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns alarm_count, risk_value, ioc_count and detail', async () => { + const apiResp = { data: { alarm_count: 5, risk_value: 85, ioc_count: 3, threat_name: 'Cobalt Strike' } }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx({ req: { asset_ip: '10.0.0.50', start_time: 1700000000000, end_time: 1700086400000 } }); + const r = await rpcdef(ctx)[PATH_GET_COMPROMISED_HOST](); + assert.equal(r.alarm_count, 5); + assert.equal(r.risk_value, 85); + assert.equal(r.ioc_count, 3); + assert.ok(r.detail?.structValue); + }); + + it('missing counts fall back to 0', async () => { + globalThis.fetch = makeSeqFetch({ data: {} }); + const ctx = buildCtx({ req: { asset_ip: '1.2.3.4', start_time: 1700000000000, end_time: 1700086400000 } }); + const r = await rpcdef(ctx)[PATH_GET_COMPROMISED_HOST](); + assert.equal(r.alarm_count, 0); + assert.equal(r.risk_value, 0); + assert.equal(r.ioc_count, 0); + }); + + it('asset_ip in URL', async () => { + let capturedUrl = ''; + let call = 0; + globalThis.fetch = async (url, init) => { + call++; + if (call === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders({}) }; + if (call === 2) return { ok: true, status: 200, text: async () => AUTH_HTML_BODY, headers: makeHeaders({ 'set-cookie': 'session=s' }) }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ data: {} }), headers: makeHeaders({}) }; + }; + const ctx = buildCtx({ req: { asset_ip: '172.16.0.1', start_time: 1700000000000, end_time: 1700086400000 } }); + await rpcdef(ctx)[PATH_GET_COMPROMISED_HOST](); + assert.ok(capturedUrl.includes('asset_ip=172.16.0.1')); + }); + + it('handlers key works', async () => { + globalThis.fetch = makeSeqFetch({ data: { alarm_count: 2, risk_value: 40, ioc_count: 1 } }); + const r = await handlers[KEY_GET_COMPROMISED_HOST]({ asset_ip: '10.0.0.1', start_time: 1700000000000, end_time: 1700086400000 }, buildCtx()); + assert.equal(r.alarm_count, 2); + }); +}); + +describe('GetCompromisedHostStatus validation errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when asset_ip missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { start_time: 1700000000000, end_time: 1700086400000 } }); + await assert.rejects(rpcdef(ctx)[PATH_GET_COMPROMISED_HOST](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when start_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { asset_ip: '1.2.3.4', end_time: 1700086400000 } }); + await assert.rejects(rpcdef(ctx)[PATH_GET_COMPROMISED_HOST](), /INVALID_ARGUMENT/); + }); + + it('throws INVALID_ARGUMENT when end_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ req: { asset_ip: '1.2.3.4', start_time: 1700000000000 } }); + await assert.rejects(rpcdef(ctx)[PATH_GET_COMPROMISED_HOST](), /INVALID_ARGUMENT/); + }); +}); + +// --------------------------------------------------------------------------- +// TLS options +// --------------------------------------------------------------------------- +describe('TLS skip verify', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('tlsInsecureSkipVerify: true still calls fetch', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 0 } }); + const ctx = buildCtx({ bindings: { restBaseUrl: 'https://tianyan.example.com', tlsInsecureSkipVerify: true }, req: { offset: 1, limit: 5 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 0); + }); + + it('skip_tls_verify alias works', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 0 } }); + const ctx = buildCtx({ bindings: { restBaseUrl: 'https://tianyan.example.com', skip_tls_verify: true }, req: { offset: 1, limit: 5 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 0); + }); +}); + +// --------------------------------------------------------------------------- +// URL aliases +// --------------------------------------------------------------------------- +describe('URL aliases', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('base_url alias accepted', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 0 } }); + const ctx = { bindings: { base_url: 'https://tianyan.example.com' }, config: {}, secret: { login_key: 'k' }, limits: { timeoutMs: 5000 }, meta: {}, req: { offset: 1, limit: 5 } }; + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 0); + }); + + it('endpoint alias accepted', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 0 } }); + const ctx = { bindings: { endpoint: 'https://tianyan.example.com' }, config: {}, secret: { login_key: 'k' }, limits: { timeoutMs: 5000 }, meta: {}, req: { offset: 1, limit: 5 } }; + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 0); + }); +}); + +// --------------------------------------------------------------------------- +// timeoutMs fallback +// --------------------------------------------------------------------------- +describe('timeoutMs', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('timeoutMs=0 falls back to default', async () => { + globalThis.fetch = makeSeqFetch({ data: { items: [], total: 0 } }); + const ctx = buildCtx({ limits: { timeoutMs: 0 }, req: { offset: 1, limit: 5 } }); + const r = await rpcdef(ctx)[PATH_LIST_ALARMS](); + assert.equal(r.total, 0); + }); +}); + +// --------------------------------------------------------------------------- +// _test helpers +// --------------------------------------------------------------------------- +describe('_test.sha256', () => { + it('produces expected hex digest', () => { + assert.equal(_test.sha256('hello'), '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'); + }); + it('produces 64-char hex string', () => { + assert.equal(_test.sha256('test').length, 64); + }); +}); + +describe('_test.encodeIp', () => { + it('returns base64 string for an IP', () => { + const encoded = _test.encodeIp('10.0.0.1'); + assert.equal(typeof encoded, 'string'); + assert.ok(encoded.length > 0); + assert.ok(encoded !== '10.0.0.1'); + }); + it('different IPs produce different encodings', () => { + assert.notEqual(_test.encodeIp('1.1.1.1'), _test.encodeIp('2.2.2.2')); + }); +}); + +describe('_test.checkApiError', () => { + it('throws FAILED_PRECONDITION on error envelope', () => { + assert.throws(() => _test.checkApiError({ error: { message: 'bad', code: 9999 } }), /FAILED_PRECONDITION/); + }); + it('throws PERMISSION_DENIED on error code 1013', () => { + assert.throws(() => _test.checkApiError({ error: { message: 'expired', code: 1013 } }), /PERMISSION_DENIED/); + }); + it('does not throw when no error key', () => { + assert.doesNotThrow(() => _test.checkApiError({ data: { status: 1000 } })); + }); + it('does not throw on null/undefined', () => { + assert.doesNotThrow(() => _test.checkApiError(null)); + assert.doesNotThrow(() => _test.checkApiError(undefined)); + }); +}); + +describe('_test.toValue', () => { + it('null -> undefined', () => assert.equal(_test.toValue(null), undefined)); + it('undefined -> undefined', () => assert.equal(_test.toValue(undefined), undefined)); + it('string -> stringValue', () => assert.deepEqual(_test.toValue('hi'), { stringValue: 'hi' })); + it('number -> numberValue', () => assert.deepEqual(_test.toValue(42), { numberValue: 42 })); + it('true -> boolValue', () => assert.deepEqual(_test.toValue(true), { boolValue: true })); + it('array -> listValue', () => assert.ok(_test.toValue([1, 'x']).listValue)); + it('object -> structValue', () => assert.ok(_test.toValue({ a: 1 }).structValue)); +}); + +describe('_test.normalizeBaseUrl', () => { + it('valid https -> normalized', () => assert.equal(_test.normalizeBaseUrl('https://host:8443'), 'https://host:8443')); + it('trailing slash stripped', () => assert.equal(_test.normalizeBaseUrl('https://host:8443/'), 'https://host:8443')); + it('no scheme -> null', () => assert.equal(_test.normalizeBaseUrl('host:8443'), null)); + it('empty string -> null', () => assert.equal(_test.normalizeBaseUrl(''), null)); +}); + +describe('_test.toInt', () => { + it('integer -> same', () => assert.equal(_test.toInt(5), 5)); + it('0 is valid', () => assert.equal(_test.toInt(0), 0)); + it('float -> null', () => assert.equal(_test.toInt(1.5), null)); + it('null -> null', () => assert.equal(_test.toInt(null), null)); + it('wrapper {value} -> extracts', () => assert.equal(_test.toInt({ value: 7 }), 7)); +}); + +describe('_test.unwrap', () => { + it('null -> undefined', () => assert.equal(_test.unwrap(null), undefined)); + it('string -> same', () => assert.equal(_test.unwrap('hello'), 'hello')); + it('{value: str} -> extracts', () => assert.equal(_test.unwrap({ value: 'abc' }), 'abc')); + it('{value: null} -> empty string', () => assert.equal(_test.unwrap({ value: null }), '')); +}); + +describe('_test.mergedBindings', () => { + it('bindings wins over config and secret', () => { + const ctx = { config: { restBaseUrl: 'from-config' }, secret: { login_key: 'k' }, bindings: { restBaseUrl: 'from-bindings' } }; + const merged = _test.mergedBindings(ctx); + assert.equal(merged.restBaseUrl, 'from-bindings'); + }); + it('empty ctx -> returns object', () => assert.equal(typeof _test.mergedBindings({}), 'object')); +}); diff --git a/services/qianxin__tianyan-platform/tianyan_api_reference.md b/services/qianxin__tianyan-platform/tianyan_api_reference.md new file mode 100644 index 00000000..05ca5e43 --- /dev/null +++ b/services/qianxin__tianyan-platform/tianyan_api_reference.md @@ -0,0 +1,1576 @@ +# TianYan Analysis Platform — Complete API Reference + +> **Platform**: 奇安信天眼威胁监测与分析系统(TianYan Threat Detection & Analysis Platform) +> **Version**: 30140.sp2 / V3.0.10.0 / V4.0.10.0+ +> **Base URL**: `https://