diff --git a/services/qianxin__skyeye_v3.0.14.0/README.md b/services/qianxin__skyeye_v3.0.14.0/README.md new file mode 100644 index 00000000..e34bef92 --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/README.md @@ -0,0 +1,84 @@ +# QianXin SkyEye V3.0.14.0 + +OctoBus service package for QianXin SkyEye (天眼) V3.0.14.0 alarm and log queries. + +## Import + +```bash +octobus service import --id qianxin-skyeye-v3-0-14-0 ./services/qianxin__skyeye_v3.0.14.0 +``` + +## Package Layout + +``` +services/qianxin__skyeye_v3.0.14.0/ + package.json # NPM manifest + service.json # OctoBus service manifest + config.schema.json # Config bindings schema + secret.schema.json # Secret bindings schema + proto/qianxin_skyeye_v3_0_14_0.proto # gRPC service definition + src/qianxin-skyeye-v3.0.14.0.js # Core handler logic + src/service.js # Service assembly + bin/qianxin-skyeye-v3.0.14.0.js # CLI entry point + test/ # Unit tests + mock upstream + README.md +``` + +## Authentication + +Two modes supported: + +### Recommended: `login_key` dynamic auth + +Uses `login_key` to dynamically authenticate via the 2-step SkyEye auth flow: + +1. POST `/skyeye/v1/admin/auth` → `access_token` +2. GET `/skyeye/v1/admin/auth?token=...` → session cookies + csrf_token from HTML + +Auth is performed for each call to match the verified Python script behavior. API requests carry cookies + csrf_token. + +| Binding | Key | Aliases | Description | +|---------|-----|---------|-------------| +| secret | `skyeye_login_key` | `login_key` | SkyEye login key (derived to client_id/client_secret via SHA256) | +| config | `skyeye_domain` | `domain`, `baseUrl` | SkyEye API base URL (e.g. `https://10.0.0.1:443`) | +| config | `skyeye_user_name` | `user_name` | SkyEye platform username for auth | +| config | `client_id_random` | | Built-in random string for client_id derivation (default: empty) | +| config | `client_secret_random` | | Built-in random string for client_secret derivation (default: empty) | + +### Legacy: static `csrf_token` + +If `login_key` is not configured, falls back to a pre-obtained `csrf_token`. No session cookies are sent. + +| Binding | Key | Aliases | Description | +|---------|-----|---------|-------------| +| secret | `skyeye_csrf_token` | `csrf_token` | Pre-obtained CSRF token | +| config | `skyeye_staff_name` | `staff_name` | Staff name for alarm list queries | + +## Other Config Bindings + +| Key | Aliases | Description | +|-----|---------|-------------| +| `skyeye_domain` | `domain`, `baseUrl` | SkyEye API base URL | +| `skyeye_staff_name` | `staff_name` | Staff name for alarm list queries | +| `timeoutMs` | | HTTP timeout in ms (default 10000) | +| `skipTlsVerify` | `tlsInsecureSkipVerify`, `insecureSkipVerify` | Skip TLS verification | + +## RPC Methods + +| Full Method | HTTP Path | +|-------------|-----------| +| `QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmList` | GET `/skyeye/v1/alarm/alarm/list` | +| `QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmPacket` | GET `/skyeye/v1/alarm/alarm/info/packet` | +| `QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/DownloadAlarmPcap` | GET `/skyeye/v1/alarm/alarm/info/pcap/download` | +| `QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryNetworkLog` | GET `/skyeye/v1/analysis/log-search/list` | + +## Behavior + +Each handler: +1. Resolves auth: if `login_key` configured, performs 2-step auth flow; otherwise uses static `csrf_token` +2. Builds query params: request fields + `csrf_token` + auto-generated `r` (random number) +3. If auth flow was used, includes session cookies in Cookie header +4. Makes HTTP GET to the SkyEye API +5. Maps the upstream response `{ status, message, data }` to gRPC `{ response_code, verbose_msg, data }` + +On upstream HTTP errors (4xx/5xx), returns a gRPC error with the status code and body. diff --git a/services/qianxin__skyeye_v3.0.14.0/bin/qianxin-skyeye-v3.0.14.0.js b/services/qianxin__skyeye_v3.0.14.0/bin/qianxin-skyeye-v3.0.14.0.js new file mode 100755 index 00000000..8e0066ef --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/bin/qianxin-skyeye-v3.0.14.0.js @@ -0,0 +1,6 @@ +#!/usr/bin/env node +import { runServiceMain } from '@chaitin-ai/octobus-sdk'; + +import { service } from '../src/service.js'; + +runServiceMain(service); diff --git a/services/qianxin__skyeye_v3.0.14.0/config.schema.json b/services/qianxin__skyeye_v3.0.14.0/config.schema.json new file mode 100644 index 00000000..2e3f92a1 --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/config.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "skyeye_domain": { + "type": "string", + "description": "QianXin SkyEye API base URL, for example https://10.0.0.1:443." + }, + "domain": { + "type": "string", + "description": "Alias for skyeye_domain." + }, + "baseUrl": { + "type": "string", + "description": "Alias for skyeye_domain." + }, + "skyeye_user_name": { + "type": "string", + "description": "SkyEye platform username for authentication. Defaults to skyeye_staff_name if not set." + }, + "user_name": { + "type": "string", + "description": "Alias for skyeye_user_name." + }, + "skyeye_staff_name": { + "type": "string", + "description": "Staff name for alarm list queries." + }, + "staff_name": { + "type": "string", + "description": "Alias for skyeye_staff_name." + }, + "client_id_random": { + "type": "string", + "default": "", + "description": "Built-in random string for client_id derivation in authentication." + }, + "client_secret_random": { + "type": "string", + "default": "", + "description": "Built-in random string for client_secret derivation in authentication." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 10000, + "description": "HTTP timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private deployments." + }, + "tlsInsecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Legacy alias for skipTlsVerify." + }, + "insecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Alias for skipTlsVerify." + } + } +} diff --git a/services/qianxin__skyeye_v3.0.14.0/package.json b/services/qianxin__skyeye_v3.0.14.0/package.json new file mode 100644 index 00000000..ba6c66b6 --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/package.json @@ -0,0 +1,15 @@ +{ + "name": "qianxin-skyeye-v3.0.14.0", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "qianxin-skyeye-v3.0.14.0": "bin/qianxin-skyeye-v3.0.14.0.js" + }, + "scripts": { + "test": "node --test test/qianxin-skyeye-v3.0.14.0.test.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/qianxin__skyeye_v3.0.14.0/proto/qianxin_skyeye_v3_0_14_0.proto b/services/qianxin__skyeye_v3.0.14.0/proto/qianxin_skyeye_v3_0_14_0.proto new file mode 100644 index 00000000..9d012752 --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/proto/qianxin_skyeye_v3_0_14_0.proto @@ -0,0 +1,105 @@ +syntax = "proto3"; + +package QianXin_SkyEye_V3_0_14_0; + +option go_package = "miner/grpc-service/QianXin_SkyEye_V3_0_14_0"; + +service QianXin_SkyEye_V3_0_14_0 { + rpc QueryAlarmList(QueryAlarmListRequest) returns (QueryAlarmListResponse) {} + rpc QueryAlarmPacket(QueryAlarmPacketRequest) returns (QueryAlarmPacketResponse) {} + rpc DownloadAlarmPcap(DownloadAlarmPcapRequest) returns (DownloadAlarmPcapResponse) {} + rpc QueryNetworkLog(QueryNetworkLogRequest) returns (QueryNetworkLogResponse) {} +} + +message QueryAlarmListRequest { + string start_time = 1; + string end_time = 2; + optional string threat_type = 3; + optional int32 hazard_level = 4; + optional string host_state = 5; + optional string status = 6; + optional string data_source = 7; + optional string alarm_sip = 8; + optional string attack_sip = 9; + optional string attack_stage = 10; + optional string asset_group = 11; + optional string attack_dimension = 12; + optional string alarm_id = 13; + optional string focus_label = 14; + optional int32 is_alarm_black_ip = 15; + optional string black_ip = 16; + optional int32 is_white = 17; + optional string threat_name = 18; + optional int32 is_accurate = 19; + optional int32 offset = 20; + optional int32 limit = 21; + optional string order_by = 22; +} + +message QueryAlarmListResponse { + int32 response_code = 1; + string verbose_msg = 2; + string data = 3; +} + +message QueryAlarmPacketRequest { + string alarm_sip = 1; + string attack_sip = 2; + string start_time = 3; + string end_time = 4; + string alarm_id = 5; + optional string skyeye_type = 6; + optional string ioc = 7; + optional string branch_id = 8; + optional string host_state = 9; +} + +message QueryAlarmPacketResponse { + int32 response_code = 1; + string verbose_msg = 2; + string data = 3; +} + +message DownloadAlarmPcapRequest { + string alarm_sip = 1; + string attack_sip = 2; + string start_time = 3; + string end_time = 4; + optional string skyeye_type = 5; + optional string ioc = 6; + optional string type = 7; + optional string branch_id = 8; +} + +message DownloadAlarmPcapResponse { + int32 response_code = 1; + string verbose_msg = 2; + string data = 3; +} + +message QueryNetworkLogRequest { + string start_time = 1; + string end_time = 2; + string index = 3; + string category = 4; + string mode = 5; + string offset = 6; + string limit = 7; + optional string branch_id = 8; + optional string keyword = 9; + optional string asset_group_ids = 10; + optional string stime = 11; + optional string etime = 12; + optional string interval = 13; + optional string page = 14; + optional string size = 15; + optional string key_fields = 16; + optional string graph_conf = 17; + optional string curBranch = 18; +} + +message QueryNetworkLogResponse { + int32 response_code = 1; + string verbose_msg = 2; + string data = 3; +} diff --git a/services/qianxin__skyeye_v3.0.14.0/secret.schema.json b/services/qianxin__skyeye_v3.0.14.0/secret.schema.json new file mode 100644 index 00000000..a5dbf71e --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/secret.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "skyeye_login_key": { + "type": "string", + "description": "SkyEye login key for dynamic authentication (preferred over csrf_token)." + }, + "login_key": { + "type": "string", + "description": "Alias for skyeye_login_key." + }, + "skyeye_csrf_token": { + "type": "string", + "description": "SkyEye CSRF token (legacy, use login_key instead)." + }, + "csrf_token": { + "type": "string", + "description": "Alias for skyeye_csrf_token." + } + } +} diff --git a/services/qianxin__skyeye_v3.0.14.0/service.json b/services/qianxin__skyeye_v3.0.14.0/service.json new file mode 100644 index 00000000..5c5e558a --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/service.json @@ -0,0 +1,37 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "qianxin-skyeye-v3.0.14.0", + "displayName": "QianXin SkyEye V3.0.14.0", + "description": "OctoBus package for QianXin SkyEye (TianYan) V3.0.14.0 alarm queries, payload retrieval, pcap download, and network log search.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": ["proto"], + "files": ["proto/qianxin_skyeye_v3_0_14_0.proto"] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmList": { + "name": "query-alarm-list", + "description": "Query QianXin SkyEye alarm list." + }, + "QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmPacket": { + "name": "query-alarm-packet", + "description": "Query QianXin SkyEye alarm payload content." + }, + "QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/DownloadAlarmPcap": { + "name": "download-alarm-pcap", + "description": "Download QianXin SkyEye alarm pcap." + }, + "QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryNetworkLog": { + "name": "query-network-log", + "description": "Query QianXin SkyEye network log." + } + } + } + } +} diff --git a/services/qianxin__skyeye_v3.0.14.0/src/qianxin-skyeye-v3.0.14.0.js b/services/qianxin__skyeye_v3.0.14.0/src/qianxin-skyeye-v3.0.14.0.js new file mode 100644 index 00000000..e878aa2e --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/src/qianxin-skyeye-v3.0.14.0.js @@ -0,0 +1,718 @@ +import { createHash } from 'crypto'; +import http from 'node:http'; +import https from 'node:https'; +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +export const METHOD_QUERY_ALARM_LIST_PATH = '/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmList'; +export const METHOD_QUERY_ALARM_LIST_FULL = 'QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmList'; +export const METHOD_QUERY_ALARM_PACKET_PATH = '/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmPacket'; +export const METHOD_QUERY_ALARM_PACKET_FULL = 'QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmPacket'; +export const METHOD_DOWNLOAD_ALARM_PCAP_PATH = '/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/DownloadAlarmPcap'; +export const METHOD_DOWNLOAD_ALARM_PCAP_FULL = 'QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/DownloadAlarmPcap'; +export const METHOD_QUERY_NETWORK_LOG_PATH = '/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryNetworkLog'; +export const METHOD_QUERY_NETWORK_LOG_FULL = 'QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryNetworkLog'; + +export const DEFAULT_TIMEOUT_MS = 10000; + +export const ALARM_LIST_HTTP_PATH = '/skyeye/v1/alarm/alarm/list'; +export const ALARM_PACKET_HTTP_PATH = '/skyeye/v1/alarm/alarm/info/packet'; +export const ALARM_PCAP_HTTP_PATH = '/skyeye/v1/alarm/alarm/info/pcap/download'; +export const NETWORK_LOG_HTTP_PATH = '/skyeye/v1/analysis/log-search/list'; +export const AUTH_HTTP_PATH = '/skyeye/v1/admin/auth'; + +export const AUTH_CACHE_TTL_MS = 0; // Cache disabled - re-auth every call to match Python script behavior + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); +const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null); + +const unwrapScalar = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'object' && value !== null && hasOwn(value, 'value')) return unwrapScalar(value.value); + return value; +}; + +const toTrimmedString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return ''; + return String(raw).trim(); +}; + +const normalizeBaseUrl = (value) => { + const raw = toTrimmedString(value); + if (!/^https?:\/\//i.test(raw)) return ''; + return raw.replace(/\/+$/, ''); +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: mergedBindings(ctx), + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.req ?? ctx.request ?? {}, +}); + +// ─── Binding resolvers ─── + +const resolveDomain = (bindings = {}) => normalizeBaseUrl(firstDefined( + bindings.skyeye_domain, + bindings.domain, + bindings.restBaseUrl, + bindings.baseUrl, +)); + +const resolveLoginKey = (bindings = {}) => toTrimmedString(firstDefined( + bindings.skyeye_login_key, + bindings.login_key, +)); + +const resolveCsrfToken = (bindings = {}) => toTrimmedString(firstDefined( + bindings.skyeye_csrf_token, + bindings.csrf_token, +)); + +const resolveUserName = (bindings = {}) => toTrimmedString(firstDefined( + bindings.skyeye_user_name, + bindings.user_name, +)); + +const resolveStaffName = (bindings = {}) => toTrimmedString(firstDefined( + bindings.skyeye_staff_name, + bindings.staff_name, +)); + +const resolveClientIdRandom = (bindings = {}) => toTrimmedString(firstDefined( + bindings.client_id_random, + bindings.clientIdRandom, +)) || ''; + +const resolveClientSecretRandom = (bindings = {}) => toTrimmedString(firstDefined( + bindings.client_secret_random, + bindings.clientSecretRandom, +)) || ''; + +const resolveTimeoutMs = (ctx = {}) => { + const raw = Number(firstDefined(ctx.limits?.timeoutMs, ctx.bindings?.timeoutMs, DEFAULT_TIMEOUT_MS)); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS; +}; + +const buildTlsOptions = (bindings = {}) => { + const enabled = Boolean(bindings.skipTlsVerify || bindings.tlsInsecureSkipVerify || bindings.insecureSkipVerify); + if (!enabled) return {}; + return { + skipTlsVerify: true, + tlsInsecureSkipVerify: true, + insecureSkipVerify: true, + }; +}; + +const shouldSkipTls = (bindings = {}) => + Boolean(bindings.skipTlsVerify || bindings.tlsInsecureSkipVerify || bindings.insecureSkipVerify); + +// ---------- low-level HTTP using node:http/https ---------- + +const MAX_REDIRECTS = 5; +const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]); + +const singleRequest = (url, options, body, extraCookies) => new Promise((resolve, reject) => { + const parsed = new URL(url); + const isHttps = parsed.protocol === 'https:'; + const lib = isHttps ? https : http; + const headers = { ...options.headers }; + if (extraCookies) headers['Cookie'] = extraCookies; + const reqOpts = { + method: options.method || 'GET', + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + headers, + timeout: options.timeout || 10000, + }; + if (isHttps && options.rejectUnauthorized === false) { + reqOpts.rejectUnauthorized = false; + } + const req = lib.request(reqOpts, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const responseBody = Buffer.concat(chunks).toString('utf-8'); + const setCookieHeaders = res.headers['set-cookie'] || []; + resolve({ + status: res.statusCode || 0, + headers: res.headers, + body: responseBody, + cookies: setCookieHeaders, + }); + }); + }); + req.on('error', (err) => reject(err)); + req.on('timeout', () => { req.destroy(new Error(`request timeout after ${options.timeout}ms`)); }); + if (body) req.write(body, 'utf-8'); + req.end(); +}); + +const parseCookiePart = (cookie) => { + const part = String(cookie || '').split(';')[0].trim(); + const idx = part.indexOf('='); + if (idx <= 0) return null; + return { name: part.slice(0, idx), value: part.slice(idx + 1) }; +}; + +const mergeCookieParts = (existingCookies = [], newCookies = []) => { + const jar = new Map(); + for (const cookie of existingCookies) { + const parsed = parseCookiePart(cookie); + if (parsed) jar.set(parsed.name, parsed.value); + } + for (const cookie of newCookies) { + const parsed = parseCookiePart(cookie); + if (parsed) jar.set(parsed.name, parsed.value); + } + return Array.from(jar, ([name, value]) => `${name}=${value}`); +}; + +const collectCookieStr = (cookieParts, newCookies) => { + cookieParts.splice(0, cookieParts.length, ...mergeCookieParts(cookieParts, newCookies)); + return cookieParts.join('; '); +}; + +const httpRequest = async (url, options, body) => { + const followRedirect = options.followRedirect !== false; + const allCookies = options.initialCookies ? [...options.initialCookies] : []; + let currentUrl = url; + let cookieHeader = allCookies.join('; '); + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + const res = await singleRequest(currentUrl, options, i === 0 ? body : null, cookieHeader || undefined); + const setCookieHeaders = res.cookies || []; + cookieHeader = collectCookieStr(allCookies, setCookieHeaders); + + if (!REDIRECT_CODES.has(res.status) || !followRedirect) { + return { + status: res.status, + ok: res.status >= 200 && res.status < 300, + text: () => res.body, + json: () => JSON.parse(res.body), + headers: { + get: (name) => res.headers[name.toLowerCase()] ?? null, + getSetCookie: () => allCookies, + }, + }; + } + + const location = res.headers['location']; + if (!location) { + return { + status: res.status, + ok: false, + text: () => res.body, + json: () => JSON.parse(res.body), + headers: { + get: (name) => res.headers[name.toLowerCase()] ?? null, + getSetCookie: () => allCookies, + }, + }; + } + + const nextUrl = new URL(location, currentUrl).toString(); + currentUrl = nextUrl; + // Redirects become GET + options = { ...options, method: 'GET' }; + } + + throw new Error(`too many redirects (>${MAX_REDIRECTS})`); +}; + +const requireDomain = (ctx = {}) => { + const domain = resolveDomain(ctx.bindings || {}); + if (!domain) throw errorWithCode('INVALID_ARGUMENT', 'skyeye_domain is required in bindings'); + return domain; +}; + +const requireStaffName = (ctx = {}) => { + const name = resolveStaffName(ctx.bindings || {}); + if (!name) throw errorWithCode('INVALID_ARGUMENT', 'skyeye_staff_name is required in bindings'); + return name; +}; + +const requireField = (req = {}, fieldName) => { + const camelName = fieldName.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); + const value = toTrimmedString(firstDefined(req[camelName], req[fieldName])); + if (!value) throw errorWithCode('INVALID_ARGUMENT', `${fieldName} is required`); + return value; +}; + +const reqField = (req = {}, fieldName) => { + const camelName = fieldName.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); + const value = firstDefined(req[camelName], req[fieldName]); + return value; +}; + +const logFlow = (ctx, action, details) => { + const meta = ctx.meta || {}; + const trace = []; + if (meta.instance_id || meta.instanceId) trace.push(`inst=${meta.instance_id || meta.instanceId}`); + if (meta.request_id || meta.requestId) trace.push(`req=${meta.request_id || meta.requestId}`); + const prefix = `[QianXin_SkyEye_V3_0_14_0][${action}]${trace.length ? `[${trace.join(' ')}]` : ''}`; + try { + console.log(prefix, JSON.stringify(details)); + } catch { + console.log(prefix, details); + } +}; + +// ─── Authentication ─── + +const authCache = new Map(); + +const collectCookiesFromResponse = (res, cookies) => { + try { + if (typeof res.headers.getSetCookie === 'function') { + cookies.splice(0, cookies.length, ...mergeCookieParts(cookies, res.headers.getSetCookie())); + } else { + const setCookie = res.headers.get('set-cookie'); + if (setCookie) { + cookies.splice(0, cookies.length, ...mergeCookieParts(cookies, String(setCookie).split(','))); + } + } + } catch { + // Cookie extraction best-effort + } +}; + +const authenticate = async (domain, loginKey, userName, clientIdRandom, clientSecretRandom, timeoutMs, skipTlsVerify) => { + const cacheKey = `${domain}:${loginKey}`; + const cached = authCache.get(cacheKey); + if (cached && Date.now() < cached.expiresAt) { + logFlow({}, 'auth:cached', { expiresAt: cached.expiresAt }); + return cached; + } + + const clientId = createHash('sha256').update(`${clientIdRandom}|${loginKey}`).digest('hex'); + const clientSecret = createHash('sha256').update(`${clientSecretRandom}|${loginKey}`).digest('hex'); + const xTimestamp = String(Math.floor(Date.now() / 1000)); + const rawString = `{"client_id":"${clientId}","username":"${userName}"}${xTimestamp}${clientSecret}`; + const xAuthorization = createHash('sha256').update(rawString).digest('hex'); + + const tlsRejectUnauthorized = skipTlsVerify ? false : undefined; + const authUrl = `${domain}${AUTH_HTTP_PATH}`; + + // Step 1: POST to auth endpoint + const cookies = []; + let res1; + try { + res1 = await httpRequest(authUrl, { + method: 'POST', + headers: { + 'X-Authorization': xAuthorization, + 'X-Timestamp': xTimestamp, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + timeout: timeoutMs, + rejectUnauthorized: tlsRejectUnauthorized, + followRedirect: false, + }, `client_id=${encodeURIComponent(clientId)}&username=${encodeURIComponent(userName)}`); + } catch (err) { + const errMsg = err?.message || 'request failed'; + logFlow({}, 'auth:post:error', { error: errMsg }); + throw errorWithCode('UNAVAILABLE', `SkyEye auth POST failed: ${errMsg}`); + } + + collectCookiesFromResponse(res1, cookies); + + if (!res1.ok) { + const text = await res1.text(); + if (res1.status === 401 || res1.status === 403) { + throw errorWithCode('PERMISSION_DENIED', `SkyEye auth POST denied: ${res1.status} ${text}`); + } + throw errorWithCode('UNAVAILABLE', `SkyEye auth POST failed: ${res1.status} ${text}`); + } + + let res1Body; + try { + res1Body = JSON.parse(await res1.text()); + } catch { + throw errorWithCode('INTERNAL', 'SkyEye auth POST returned invalid JSON'); + } + const accessToken = res1Body.access_token || ''; + if (!accessToken) { + throw errorWithCode('PERMISSION_DENIED', `SkyEye auth POST returned no access_token: ${JSON.stringify(res1Body).substring(0, 300)}`); + } + + // Step 2: GET auth endpoint with token to obtain csrf_token + session + const verifyUrl = `${authUrl}?token=${encodeURIComponent(accessToken)}`; + let res2; + try { + res2 = await httpRequest(verifyUrl, { + method: 'GET', + timeout: timeoutMs, + rejectUnauthorized: tlsRejectUnauthorized, + initialCookies: cookies, + }); + } catch (err) { + const errMsg = err?.message || 'request failed'; + logFlow({}, 'auth:get:error', { error: errMsg }); + throw errorWithCode('UNAVAILABLE', `SkyEye auth GET failed: ${errMsg}`); + } + + collectCookiesFromResponse(res2, cookies); + + if (!res2.ok) { + const text = await res2.text(); + throw errorWithCode('UNAVAILABLE', `SkyEye auth GET failed: ${res2.status} ${text}`); + } + + const res2Body = await res2.text(); + + // Parse csrf_token from HTML meta tag + const csrfMatch = res2Body.match(/ 0 ? Date.now() + AUTH_CACHE_TTL_MS : 0 }; + if (AUTH_CACHE_TTL_MS > 0) authCache.set(cacheKey, result); + return result; +}; + +const resolveAuth = async (ctx = {}) => { + const bindings = ctx.bindings || {}; + const loginKey = resolveLoginKey(bindings); + + if (loginKey) { + const domain = resolveDomain(bindings); + if (!domain) throw errorWithCode('INVALID_ARGUMENT', 'skyeye_domain is required when using login_key'); + const userName = resolveUserName(bindings); + const clientIdRandom = resolveClientIdRandom(bindings); + const clientSecretRandom = resolveClientSecretRandom(bindings); + const skipTlsVerify = Boolean(bindings.skipTlsVerify || bindings.tlsInsecureSkipVerify || bindings.insecureSkipVerify); + const timeoutMs = resolveTimeoutMs(ctx); + return authenticate(domain, loginKey, userName, clientIdRandom, clientSecretRandom, timeoutMs, skipTlsVerify); + } + + // Legacy: static csrf_token without cookies + const csrfToken = resolveCsrfToken(bindings); + if (!csrfToken) { + throw errorWithCode('INVALID_ARGUMENT', 'skyeye_login_key or skyeye_csrf_token is required in bindings'); + } + return { csrfToken, cookieHeader: '', expiresAt: Infinity }; +}; + +const withAuthRetry = async (callCtx, doFetch) => { + let auth = await resolveAuth(callCtx); + let result = await doFetch(auth); + + if (result.httpStatus === 401 || result.httpStatus === 403) { + authCache.clear(); + auth = await resolveAuth(callCtx); + result = await doFetch(auth); + } + + return result; +}; + +// ─── HTTP helpers ─── + +const encodeQueryPairs = (query = {}) => { + const parts = []; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue; + const strVal = typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value); + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(strVal)}`); + } + return parts.join('&'); +}; + +const buildQueryUrl = (domain, path, query) => `${domain}${path}?${encodeQueryPairs(query)}`; + +const buildAuthQuery = (csrfToken) => ({ + csrf_token: csrfToken, + r: Math.random().toString(), +}); + +const fetchWithStatus = async (url, ctx = {}, extraHeaders = {}) => { + const bindings = ctx.bindings || {}; + const timeoutMs = resolveTimeoutMs(ctx); + const headers = { ...extraHeaders }; + try { + const res = await httpRequest(url, { + method: 'GET', + headers, + timeout: timeoutMs, + rejectUnauthorized: shouldSkipTls(bindings) ? false : undefined, + }); + const httpBody = await res.text(); + const httpStatus = Number(res.status || 0); + logFlow(ctx, 'fetch:response', { url, httpStatus, bodyLength: httpBody?.length || 0 }); + return { httpStatus, httpBody }; + } catch (err) { + const errMsg = err?.message || 'request failed'; + logFlow(ctx, 'fetch:error', { url, error: errMsg }); + return { httpStatus: 0, httpBody: errMsg }; + } +}; + +const mapHttpStatusToCode = (httpStatus) => { + if (httpStatus === 401 || httpStatus === 403) return 'PERMISSION_DENIED'; + if (httpStatus >= 400 && httpStatus < 500) return 'FAILED_PRECONDITION'; + return 'UNAVAILABLE'; +}; + +const parseSkyEyeResponse = (httpBody) => { + try { + const parsed = JSON.parse(httpBody); + const outerStatus = Number(parsed.status ?? 0); + const outerMessage = String(parsed.message ?? ''); + const dataObj = typeof parsed.data === 'object' && parsed.data !== null ? parsed.data : null; + + const innerStatus = dataObj ? Number(dataObj.status ?? 0) : 0; + const innerMessage = dataObj ? String(dataObj.message ?? '') : ''; + + const responseCode = outerStatus || innerStatus; + const verboseMsg = outerMessage || innerMessage; + + let data = ''; + if (responseCode === outerStatus && outerStatus !== 0) { + data = parsed.data !== undefined ? JSON.stringify(parsed.data) : ''; + } else if (responseCode === innerStatus && innerStatus !== 0) { + if (dataObj.data !== undefined) { + data = JSON.stringify(dataObj.data); + } else { + data = JSON.stringify(dataObj); + } + } else if (parsed.data !== undefined) { + data = JSON.stringify(parsed.data); + } + + return { responseCode, verboseMsg, data }; + } catch { + return { responseCode: 0, verboseMsg: '', data: '' }; + } +}; + +const handleHttpResponse = (httpStatus, httpBody, ctx, action) => { + if (httpStatus >= 200 && httpStatus < 300) { + const parsed = parseSkyEyeResponse(httpBody); + return { response_code: parsed.responseCode, verbose_msg: parsed.verboseMsg, data: parsed.data }; + } + const code = mapHttpStatusToCode(httpStatus); + throw errorWithCode(code, `upstream http ${httpStatus}: ${httpBody}`); +}; + +// ─── Method handlers ─── + +const handleQueryAlarmList = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const domain = requireDomain(callCtx); + const staffName = requireStaffName(callCtx); + const startTime = requireField(req, 'start_time'); + const endTime = requireField(req, 'end_time'); + const dataSource = toTrimmedString(reqField(req, 'data_source')) || '0'; + + const { httpStatus, httpBody } = await withAuthRetry(callCtx, async (auth) => { + const authQuery = buildAuthQuery(auth.csrfToken); + const query = { ...authQuery, start_time: startTime, end_time: endTime, staff_name: staffName, data_source: dataSource }; + + if (reqField(req, 'threat_type')) query.threat_type = toTrimmedString(reqField(req, 'threat_type')); + if (reqField(req, 'threat_name')) query.threat_name = toTrimmedString(reqField(req, 'threat_name')); + if (reqField(req, 'hazard_level') !== undefined && reqField(req, 'hazard_level') !== null) query.hazard_level = Number(reqField(req, 'hazard_level')); + if (reqField(req, 'host_state')) query.host_state = toTrimmedString(reqField(req, 'host_state')); + if (reqField(req, 'status')) query.status = toTrimmedString(reqField(req, 'status')); + if (reqField(req, 'alarm_sip')) query.alarm_sip = toTrimmedString(reqField(req, 'alarm_sip')); + if (reqField(req, 'attack_sip')) query.attack_sip = toTrimmedString(reqField(req, 'attack_sip')); + if (reqField(req, 'attack_stage')) query.attack_stage = toTrimmedString(reqField(req, 'attack_stage')); + if (reqField(req, 'asset_group')) query.asset_group = toTrimmedString(reqField(req, 'asset_group')); + if (reqField(req, 'attack_dimension')) query.attack_dimension = toTrimmedString(reqField(req, 'attack_dimension')); + if (reqField(req, 'alarm_id')) query.alarm_id = toTrimmedString(reqField(req, 'alarm_id')); + if (reqField(req, 'focus_label')) query.focus_label = toTrimmedString(reqField(req, 'focus_label')); + if (reqField(req, 'is_alarm_black_ip') !== undefined && reqField(req, 'is_alarm_black_ip') !== null) query.is_alarm_black_ip = Number(reqField(req, 'is_alarm_black_ip')); + if (reqField(req, 'black_ip')) query.black_ip = toTrimmedString(reqField(req, 'black_ip')); + if (reqField(req, 'is_white') !== undefined && reqField(req, 'is_white') !== null) query.is_white = Number(reqField(req, 'is_white')); + if (reqField(req, 'is_accurate') !== undefined && reqField(req, 'is_accurate') !== null) query.is_accurate = Number(reqField(req, 'is_accurate')); + if (reqField(req, 'offset') !== undefined && reqField(req, 'offset') !== null) query.offset = Number(reqField(req, 'offset')); + if (reqField(req, 'limit') !== undefined && reqField(req, 'limit') !== null) query.limit = Number(reqField(req, 'limit')); + if (reqField(req, 'order_by')) query.order_by = toTrimmedString(reqField(req, 'order_by')); + + const url = buildQueryUrl(domain, ALARM_LIST_HTTP_PATH, query); + const extraHeaders = auth.cookieHeader ? { Cookie: auth.cookieHeader } : {}; + logFlow(callCtx, 'QueryAlarmList', { path: ALARM_LIST_HTTP_PATH }); + return fetchWithStatus(url, callCtx, extraHeaders); + }); + return handleHttpResponse(httpStatus, httpBody, callCtx, 'QueryAlarmList'); +}; + +const handleQueryAlarmPacket = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const domain = requireDomain(callCtx); + const alarmSip = requireField(req, 'alarm_sip'); + const attackSip = requireField(req, 'attack_sip'); + const startTime = requireField(req, 'start_time'); + const endTime = requireField(req, 'end_time'); + const alarmId = requireField(req, 'alarm_id'); + + const { httpStatus, httpBody } = await withAuthRetry(callCtx, async (auth) => { + const authQuery = buildAuthQuery(auth.csrfToken); + const query = { ...authQuery, alarm_sip: alarmSip, attack_sip: attackSip, start_time: startTime, end_time: endTime, alarm_id: alarmId }; + + if (reqField(req, 'skyeye_type')) query.skyeye_type = toTrimmedString(reqField(req, 'skyeye_type')); + if (reqField(req, 'ioc')) query.ioc = toTrimmedString(reqField(req, 'ioc')); + if (reqField(req, 'branch_id')) query.branch_id = toTrimmedString(reqField(req, 'branch_id')); + if (reqField(req, 'host_state')) query.host_state = toTrimmedString(reqField(req, 'host_state')); + + const url = buildQueryUrl(domain, ALARM_PACKET_HTTP_PATH, query); + const extraHeaders = auth.cookieHeader ? { Cookie: auth.cookieHeader } : {}; + logFlow(callCtx, 'QueryAlarmPacket', { path: ALARM_PACKET_HTTP_PATH }); + return fetchWithStatus(url, callCtx, extraHeaders); + }); + return handleHttpResponse(httpStatus, httpBody, callCtx, 'QueryAlarmPacket'); +}; + +const handleDownloadAlarmPcap = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const domain = requireDomain(callCtx); + const alarmSip = requireField(req, 'alarm_sip'); + const attackSip = requireField(req, 'attack_sip'); + const startTime = requireField(req, 'start_time'); + const endTime = requireField(req, 'end_time'); + + const { httpStatus, httpBody } = await withAuthRetry(callCtx, async (auth) => { + const authQuery = buildAuthQuery(auth.csrfToken); + const query = { ...authQuery, alarm_sip: alarmSip, attack_sip: attackSip, start_time: startTime, end_time: endTime }; + + if (reqField(req, 'skyeye_type')) query.skyeye_type = toTrimmedString(reqField(req, 'skyeye_type')); + if (reqField(req, 'ioc')) query.ioc = toTrimmedString(reqField(req, 'ioc')); + if (reqField(req, 'type')) query.type = toTrimmedString(reqField(req, 'type')); + if (reqField(req, 'branch_id')) query.branch_id = toTrimmedString(reqField(req, 'branch_id')); + + const url = buildQueryUrl(domain, ALARM_PCAP_HTTP_PATH, query); + const extraHeaders = auth.cookieHeader ? { Cookie: auth.cookieHeader } : {}; + logFlow(callCtx, 'DownloadAlarmPcap', { path: ALARM_PCAP_HTTP_PATH }); + return fetchWithStatus(url, callCtx, extraHeaders); + }); + return handleHttpResponse(httpStatus, httpBody, callCtx, 'DownloadAlarmPcap'); +}; + +const handleQueryNetworkLog = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const domain = requireDomain(callCtx); + const startTime = requireField(req, 'start_time'); + const endTime = requireField(req, 'end_time'); + const index = requireField(req, 'index'); + const category = requireField(req, 'category'); + const mode = requireField(req, 'mode'); + const offset = requireField(req, 'offset'); + const limit = requireField(req, 'limit'); + + const { httpStatus, httpBody } = await withAuthRetry(callCtx, async (auth) => { + const authQuery = buildAuthQuery(auth.csrfToken); + const query = { ...authQuery, start_time: startTime, end_time: endTime, index, category, mode, offset, limit }; + + if (reqField(req, 'branch_id')) query.branch_id = toTrimmedString(reqField(req, 'branch_id')); + if (reqField(req, 'keyword')) query.keyword = toTrimmedString(reqField(req, 'keyword')); + if (reqField(req, 'asset_group_ids')) query.asset_group_ids = toTrimmedString(reqField(req, 'asset_group_ids')); + if (reqField(req, 'stime')) query.stime = toTrimmedString(reqField(req, 'stime')); + if (reqField(req, 'etime')) query.etime = toTrimmedString(reqField(req, 'etime')); + if (reqField(req, 'interval')) query.interval = toTrimmedString(reqField(req, 'interval')); + if (reqField(req, 'page')) query.page = toTrimmedString(reqField(req, 'page')); + if (reqField(req, 'size')) query.size = toTrimmedString(reqField(req, 'size')); + if (reqField(req, 'key_fields')) query.key_fields = toTrimmedString(reqField(req, 'key_fields')); + if (reqField(req, 'graph_conf')) query.graph_conf = toTrimmedString(reqField(req, 'graph_conf')); + if (reqField(req, 'curBranch')) query.curBranch = toTrimmedString(reqField(req, 'curBranch')); + + const url = buildQueryUrl(domain, NETWORK_LOG_HTTP_PATH, query); + const extraHeaders = auth.cookieHeader ? { Cookie: auth.cookieHeader } : {}; + logFlow(callCtx, 'QueryNetworkLog', { path: NETWORK_LOG_HTTP_PATH }); + return fetchWithStatus(url, callCtx, extraHeaders); + }); + return handleHttpResponse(httpStatus, httpBody, callCtx, 'QueryNetworkLog'); +}; + +export function rpcdef(ctx = {}) { + const callCtx = resolveCallContext(ctx); + return { + [METHOD_QUERY_ALARM_LIST_PATH]: async (req) => handleQueryAlarmList(req ?? callCtx.req ?? {}, callCtx), + [METHOD_QUERY_ALARM_PACKET_PATH]: async (req) => handleQueryAlarmPacket(req ?? callCtx.req ?? {}, callCtx), + [METHOD_DOWNLOAD_ALARM_PCAP_PATH]: async (req) => handleDownloadAlarmPcap(req ?? callCtx.req ?? {}, callCtx), + [METHOD_QUERY_NETWORK_LOG_PATH]: async (req) => handleQueryNetworkLog(req ?? callCtx.req ?? {}, callCtx), + }; +} + +const adaptHandler = (fn) => (reqOrSdkArg, ctx) => { + if (ctx !== undefined) return fn(reqOrSdkArg, ctx); + if (reqOrSdkArg && typeof reqOrSdkArg === 'object' && 'request' in reqOrSdkArg) { + const { request: req, ...rest } = reqOrSdkArg; + return fn(req ?? {}, rest); + } + return fn(reqOrSdkArg, {}); +}; + +export const handlers = { + [METHOD_QUERY_ALARM_LIST_FULL]: adaptHandler(handleQueryAlarmList), + [METHOD_QUERY_ALARM_PACKET_FULL]: adaptHandler(handleQueryAlarmPacket), + [METHOD_DOWNLOAD_ALARM_PCAP_FULL]: adaptHandler(handleDownloadAlarmPcap), + [METHOD_QUERY_NETWORK_LOG_FULL]: adaptHandler(handleQueryNetworkLog), +}; + +export const _test = { + authenticate, + authCache, + buildAuthQuery, + buildTlsOptions, + collectCookiesFromResponse, + encodeQueryPairs, + buildQueryUrl, + errorWithCode, + fetchWithStatus, + firstDefined, + grpcCodeFor, + handleHttpResponse, + handleQueryAlarmList, + handleQueryAlarmPacket, + handleDownloadAlarmPcap, + handleQueryNetworkLog, + hasOwn, + logFlow, + mapHttpStatusToCode, + mergedBindings, + normalizeBaseUrl, + parseSkyEyeResponse, + requireDomain, + requireField, + resolveAuth, + resolveCallContext, + resolveCsrfToken, + resolveDomain, + resolveLoginKey, + resolveStaffName, + resolveUserName, + resolveClientIdRandom, + resolveClientSecretRandom, + resolveTimeoutMs, + reqField, + toTrimmedString, + unwrapScalar, + withAuthRetry, +}; diff --git a/services/qianxin__skyeye_v3.0.14.0/src/service.js b/services/qianxin__skyeye_v3.0.14.0/src/service.js new file mode 100644 index 00000000..661e6627 --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './qianxin-skyeye-v3.0.14.0.js'; + +export { handlers } from './qianxin-skyeye-v3.0.14.0.js'; + +export const service = defineService({ handlers }); diff --git a/services/qianxin__skyeye_v3.0.14.0/test/mock_upstream.js b/services/qianxin__skyeye_v3.0.14.0/test/mock_upstream.js new file mode 100644 index 00000000..d87d606d --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/test/mock_upstream.js @@ -0,0 +1,148 @@ +import { createServer } from 'node:http'; + +const createMockServer = () => { + const requests = []; + let server; + + const handler = (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const params = Object.fromEntries(url.searchParams); + requests.push({ method: req.method, path: url.pathname, params }); + + // Auth endpoint: POST → access_token, GET → HTML with csrf-token + cookies + if (url.pathname === '/skyeye/v1/admin/auth') { + if (req.method === 'POST') { + res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': 'sessionid=mock-session-id; Path=/' }); + res.end(JSON.stringify({ access_token: 'mock-access-token' })); + return; + } + // GET with token → HTML with csrf-token + session cookie + const token = url.searchParams.get('token'); + if (token) { + res.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': ['sessionid=mock-session-id; Path=/', 'skyeye_session=mock-skyeye-session; Path=/'], + }); + res.end('
OK'); + return; + } + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 400, message: 'token required' })); + return; + } + + // Legacy: check csrf_token in query params (backward compat) + const csrfToken = url.searchParams.get('csrf_token'); + if (!csrfToken) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 401, message: 'csrf_token required', data: null })); + return; + } + + // Verify staff_name is present for alarm list + if (url.pathname === '/skyeye/v1/alarm/alarm/list' && !url.searchParams.get('staff_name')) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: { status: 400, message: 'staff_name required' } })); + return; + } + + let body; + switch (url.pathname) { + // Alarm list: status/message nested inside data (per real API) + case '/skyeye/v1/alarm/alarm/list': + body = { + data: { + items: [{ + id: '20200716_491cfaf5ab369d49238d4afd332a2824', + alarm_sip: '239.238.57.59', + attack_sip: '214.221.191.197', + threat_name: 'SQL注入攻击', + hazard_level: '高危', + attack_stage: '入侵', + status: '已处置', + earliest_time: 1594886124000, + latest_time: 1594886124000, + repeat_count: 1, + }], + status: 1000, + message: 'success', + token: 'mock-token', + }, + }; + break; + // Packet: status/message at top level (per real API) + case '/skyeye/v1/alarm/alarm/info/packet': + body = { + data: { + items: [{ + sip: '192.168.227.1', + sport: 51448, + dip: '192.168.227.135', + dport: 80, + req_header: 'GET /test HTTP/1.1', + req_body: '', + rsp_header: 'HTTP/1.1 200 OK', + rsp_body: 'OK', + }], + }, + status: 200, + message: 'ok', + token: 'mock-token', + }; + break; + // Pcap: status/message at top level (consistent with packet) + case '/skyeye/v1/alarm/alarm/info/pcap/download': + body = { + data: 'base64-encoded-pcap-content', + status: 1000, + message: 'success', + token: 'mock-token', + }; + break; + // Network log: doubly-nested data (per real API) + case '/skyeye/v1/analysis/log-search/list': + body = { + data: { + status: 1000, + message: 'success', + token: 'mock-token', + data: { + field_mapping: {}, + fields: ['sip', 'dip', 'sport', 'dport'], + search: { + hits: [{ + _id: 'AW7Gm1WuER7uHLKN7a-U', + _index: 'skyeye-alarm_collection-2019.12.02', + _source: { sip: '10.0.0.1', dip: '10.0.0.2', sport: 12345, dport: 80 }, + _type: 'skyeye-alarm_collection', + }], + total: 1, + }, + }, + }, + }; + break; + default: + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 404, message: 'not found', data: null })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + return new Promise((resolve) => { + server = createServer(handler); + server.listen(0, () => { + const { port } = server.address(); + resolve({ + requests, + url: `http://127.0.0.1:${port}`, + close: () => new Promise((r) => server.close(r)), + }); + }); + }); +}; + +export { createMockServer }; diff --git a/services/qianxin__skyeye_v3.0.14.0/test/qianxin-skyeye-v3.0.14.0.test.js b/services/qianxin__skyeye_v3.0.14.0/test/qianxin-skyeye-v3.0.14.0.test.js new file mode 100644 index 00000000..64a54fef --- /dev/null +++ b/services/qianxin__skyeye_v3.0.14.0/test/qianxin-skyeye-v3.0.14.0.test.js @@ -0,0 +1,443 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + _test, + METHOD_QUERY_ALARM_LIST_FULL, + METHOD_QUERY_ALARM_PACKET_FULL, + METHOD_DOWNLOAD_ALARM_PCAP_FULL, + METHOD_QUERY_NETWORK_LOG_FULL, + handlers, + rpcdef, +} from '../src/qianxin-skyeye-v3.0.14.0.js'; + +import { createMockServer } from './mock_upstream.js'; + +const { + requireDomain, requireField, + resolveDomain, resolveCsrfToken, resolveLoginKey, resolveStaffName, resolveUserName, + buildAuthQuery, buildQueryUrl, encodeQueryPairs, + parseSkyEyeResponse, handleHttpResponse, + normalizeBaseUrl, toTrimmedString, firstDefined, + mergedBindings, resolveCallContext, errorWithCode, + authenticate, resolveAuth, authCache, + withAuthRetry, +} = _test; + +const buildCtx = (overrides = {}) => ({ + config: { skyeye_domain: 'https://skyeye.example.com', skyeye_staff_name: 'admin' }, + secret: { skyeye_csrf_token: 'test-csrf-token' }, + ...overrides, +}); + +const buildLoginKeyCtx = (domain, overrides = {}) => ({ + config: { skyeye_domain: domain, skyeye_user_name: 'admin', skyeye_staff_name: 'admin' }, + secret: { skyeye_login_key: 'test-login-key' }, + ...overrides, +}); + +let mockServer; + +before(async () => { + mockServer = await createMockServer(); + authCache.clear(); +}); + +after(async () => { + if (mockServer) await mockServer.close(); +}); + +// ─── Binding resolvers ─── + +describe('resolveDomain', () => { + it('resolves skyeye_domain', () => { + assert.equal(resolveDomain({ skyeye_domain: 'https://10.0.0.1:443' }), 'https://10.0.0.1:443'); + }); + + it('resolves domain alias', () => { + assert.equal(resolveDomain({ domain: 'https://10.0.0.1:443' }), 'https://10.0.0.1:443'); + }); + + it('returns empty for missing', () => { + assert.equal(resolveDomain({}), ''); + }); +}); + +describe('resolveLoginKey', () => { + it('resolves skyeye_login_key', () => { + assert.equal(resolveLoginKey({ skyeye_login_key: 'abc' }), 'abc'); + }); + + it('resolves login_key alias', () => { + assert.equal(resolveLoginKey({ login_key: 'abc' }), 'abc'); + }); + + it('returns empty for missing', () => { + assert.equal(resolveLoginKey({}), ''); + }); +}); + +describe('resolveCsrfToken', () => { + it('resolves skyeye_csrf_token', () => { + assert.equal(resolveCsrfToken({ skyeye_csrf_token: 'abc123' }), 'abc123'); + }); + + it('resolves csrf_token alias', () => { + assert.equal(resolveCsrfToken({ csrf_token: 'abc123' }), 'abc123'); + }); +}); + +describe('resolveUserName', () => { + it('resolves skyeye_user_name', () => { + assert.equal(resolveUserName({ skyeye_user_name: 'admin' }), 'admin'); + }); + + it('returns empty string when not set', () => { + assert.equal(resolveUserName({ skyeye_staff_name: 'admin' }), ''); + }); +}); + +describe('resolveStaffName', () => { + it('resolves skyeye_staff_name', () => { + assert.equal(resolveStaffName({ skyeye_staff_name: 'admin' }), 'admin'); + }); + + it('returns empty string when not set', () => { + assert.equal(resolveStaffName({ skyeye_user_name: 'admin' }), ''); + }); +}); + +describe('requireDomain', () => { + it('throws when missing', () => { + assert.throws(() => requireDomain({ bindings: {} }), /skyeye_domain is required/); + }); + + it('returns domain when present', () => { + assert.equal(requireDomain({ bindings: { skyeye_domain: 'https://10.0.0.1:443' } }), 'https://10.0.0.1:443'); + }); +}); + +describe('requireField', () => { + it('throws when missing', () => { + assert.throws(() => requireField({}, 'start_time'), /start_time is required/); + }); + + it('returns value when present', () => { + assert.equal(requireField({ start_time: '1571385615000' }, 'start_time'), '1571385615000'); + }); + + it('reads camelCase variant', () => { + assert.equal(requireField({ startTime: '1571385615000' }, 'start_time'), '1571385615000'); + }); +}); + +// ─── Auth flow ─── + +describe('authenticate', () => { + it('obtains csrf_token and cookies via 2-step auth', async () => { + authCache.clear(); + const result = await authenticate(mockServer.url, 'test-login-key', 'admin', '', '', 10000, false); + assert.equal(result.csrfToken, 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'); + assert.ok(result.cookieHeader.includes('sessionid=mock-session-id')); + assert.equal(result.expiresAt, 0, 'expiresAt is 0 when cache is disabled'); + }); + + it('does not cache when AUTH_CACHE_TTL_MS is 0', async () => { + authCache.clear(); + mockServer.requests.length = 0; + const first = await authenticate(mockServer.url, 'test-key-cache', 'admin', '', '', 10000, false); + const firstAuthReqs = mockServer.requests.filter((r) => r.path === '/skyeye/v1/admin/auth'); + assert.equal(firstAuthReqs.length, 2); + mockServer.requests.length = 0; + const second = await authenticate(mockServer.url, 'test-key-cache', 'admin', '', '', 10000, false); + const secondAuthReqs = mockServer.requests.filter((r) => r.path === '/skyeye/v1/admin/auth'); + assert.equal(secondAuthReqs.length, 2, 'should re-authenticate every call when cache is disabled'); + assert.equal(first.csrfToken, second.csrfToken); + }); +}); + +describe('resolveAuth', () => { + it('uses auth flow when login_key is configured', async () => { + authCache.clear(); + const ctx = resolveCallContext(buildLoginKeyCtx(mockServer.url)); + const result = await resolveAuth(ctx); + assert.equal(result.csrfToken, 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'); + assert.ok(result.cookieHeader); + }); + + it('uses static csrf_token when login_key is not configured', async () => { + const ctx = resolveCallContext(buildCtx()); + const result = await resolveAuth(ctx); + assert.equal(result.csrfToken, 'test-csrf-token'); + assert.equal(result.cookieHeader, ''); + }); + + it('throws when neither login_key nor csrf_token is configured', async () => { + const ctx = resolveCallContext({ config: { skyeye_domain: mockServer.url } }); + await assert.rejects(() => resolveAuth(ctx), /skyeye_login_key or skyeye_csrf_token is required/); + }); +}); + +// ─── withAuthRetry ─── + +describe('withAuthRetry', () => { + it('retries on 401 with fresh auth', async () => { + authCache.clear(); + let callCount = 0; + const doFetch = async (auth) => { + callCount++; + if (callCount === 1) return { httpStatus: 401, httpBody: '{"error":"unauthorized"}' }; + return { httpStatus: 200, httpBody: '{"status":1000,"message":"success","data":{}}' }; + }; + const ctx = resolveCallContext(buildLoginKeyCtx(mockServer.url)); + const result = await withAuthRetry(ctx, doFetch); + assert.equal(result.httpStatus, 200); + assert.equal(callCount, 2); + }); + + it('does not retry on 200', async () => { + authCache.clear(); + let callCount = 0; + const doFetch = async () => { + callCount++; + return { httpStatus: 200, httpBody: '{"status":1000,"message":"success","data":{}}' }; + }; + const ctx = resolveCallContext(buildLoginKeyCtx(mockServer.url)); + const result = await withAuthRetry(ctx, doFetch); + assert.equal(result.httpStatus, 200); + assert.equal(callCount, 1); + }); + + it('does not retry on 500', async () => { + authCache.clear(); + let callCount = 0; + const doFetch = async () => { + callCount++; + return { httpStatus: 500, httpBody: 'internal error' }; + }; + const ctx = resolveCallContext(buildLoginKeyCtx(mockServer.url)); + const result = await withAuthRetry(ctx, doFetch); + assert.equal(result.httpStatus, 500); + assert.equal(callCount, 1); + }); +}); + +// ─── buildAuthQuery ─── + +describe('buildAuthQuery', () => { + it('includes csrf_token and r', () => { + const result = buildAuthQuery('test-token'); + assert.equal(result.csrf_token, 'test-token'); + assert.ok(result.r); + assert.ok(typeof result.r === 'string'); + }); +}); + +// ─── parseSkyEyeResponse ─── + +describe('parseSkyEyeResponse', () => { + it('parses top-level status/message (packet/pcap style)', () => { + const result = parseSkyEyeResponse('{"status":200,"message":"ok","data":{"items":[]}}'); + assert.equal(result.responseCode, 200); + assert.equal(result.verboseMsg, 'ok'); + assert.ok(result.data); + }); + + it('parses nested status/message inside data (alarm list style)', () => { + const result = parseSkyEyeResponse('{"data":{"items":[],"status":1000,"message":"success"}}'); + assert.equal(result.responseCode, 1000); + assert.equal(result.verboseMsg, 'success'); + assert.ok(result.data); + }); + + it('parses doubly-nested data (network log style)', () => { + const result = parseSkyEyeResponse('{"data":{"status":1000,"message":"success","data":{"field_mapping":{},"search":{"total":1}}}}'); + assert.equal(result.responseCode, 1000); + assert.equal(result.verboseMsg, 'success'); + const parsed = JSON.parse(result.data); + assert.ok(parsed.search); + assert.equal(parsed.search.total, 1); + }); + + it('handles empty data', () => { + const result = parseSkyEyeResponse('{"status":200,"message":"ok"}'); + assert.equal(result.responseCode, 200); + assert.equal(result.verboseMsg, 'ok'); + assert.equal(result.data, ''); + }); + + it('handles invalid JSON', () => { + const result = parseSkyEyeResponse('not json'); + assert.equal(result.responseCode, 0); + }); +}); + +// ─── Handlers with static csrf_token (legacy) ─── + +describe('handlers (legacy csrf_token) - QueryAlarmList', () => { + it('calls upstream and returns mapped response', async () => { + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + const result = await handler( + { start_time: '1571385615000', end_time: '1571385617000' }, + { config: { skyeye_domain: mockServer.url, skyeye_staff_name: 'admin' }, secret: { skyeye_csrf_token: 'test-token' } }, + ); + assert.equal(result.response_code, 1000); + assert.equal(result.verbose_msg, 'success'); + assert.ok(result.data); + }); + + it('throws on missing domain', async () => { + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + await assert.rejects( + () => handler({ start_time: '1571385615000', end_time: '1571385617000' }, {}), + /skyeye_domain is required/, + ); + }); + + it('throws on missing staff_name', async () => { + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + await assert.rejects( + () => handler( + { start_time: '1571385615000', end_time: '1571385617000' }, + { config: { skyeye_domain: mockServer.url }, secret: { skyeye_csrf_token: 'test-token' } }, + ), + /skyeye_staff_name is required/, + ); + }); + + it('defaults data_source to 0 when not provided', async () => { + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + await handler( + { start_time: '1571385615000', end_time: '1571385617000' }, + { config: { skyeye_domain: mockServer.url, skyeye_staff_name: 'admin' }, secret: { skyeye_csrf_token: 'test-token' } }, + ); + const lastReq = mockServer.requests[mockServer.requests.length - 1]; + assert.equal(lastReq.params.data_source, '0'); + }); +}); + +describe('handlers (legacy csrf_token) - QueryAlarmPacket', () => { + it('calls upstream and returns mapped response', async () => { + const handler = handlers[METHOD_QUERY_ALARM_PACKET_FULL]; + const result = await handler( + { alarm_sip: '10.0.0.1', attack_sip: '10.0.0.2', start_time: '1571385615000', end_time: '1571385617000', alarm_id: 'test-id' }, + { config: { skyeye_domain: mockServer.url }, secret: { skyeye_csrf_token: 'test-token' } }, + ); + assert.equal(result.response_code, 200); + assert.equal(result.verbose_msg, 'ok'); + }); +}); + +describe('handlers (legacy csrf_token) - DownloadAlarmPcap', () => { + it('calls upstream and returns mapped response', async () => { + const handler = handlers[METHOD_DOWNLOAD_ALARM_PCAP_FULL]; + const result = await handler( + { alarm_sip: '10.0.0.1', attack_sip: '10.0.0.2', start_time: '1571385615000', end_time: '1571385617000' }, + { config: { skyeye_domain: mockServer.url }, secret: { skyeye_csrf_token: 'test-token' } }, + ); + assert.equal(result.response_code, 1000); + }); +}); + +describe('handlers (legacy csrf_token) - QueryNetworkLog', () => { + it('calls upstream and returns mapped response', async () => { + const handler = handlers[METHOD_QUERY_NETWORK_LOG_FULL]; + const result = await handler( + { start_time: '1571385615000', end_time: '1571385617000', index: 'alarm_collection', category: 'event', mode: 'advance_model', offset: '1', limit: '50' }, + { config: { skyeye_domain: mockServer.url }, secret: { skyeye_csrf_token: 'test-token' } }, + ); + assert.equal(result.response_code, 1000); + assert.equal(result.verbose_msg, 'success'); + const parsed = JSON.parse(result.data); + assert.ok(parsed.search); + }); +}); + +// ─── Handlers with login_key auth flow ─── + +describe('handlers (login_key auth) - QueryAlarmList', () => { + it('authenticates and calls upstream', async () => { + authCache.clear(); + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + const result = await handler( + { start_time: '1571385615000', end_time: '1571385617000' }, + buildLoginKeyCtx(mockServer.url), + ); + assert.equal(result.response_code, 1000); + assert.equal(result.verbose_msg, 'success'); + }); + + it('sends Cookie header after auth', async () => { + authCache.clear(); + mockServer.requests.length = 0; + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + await handler( + { start_time: '1571385615000', end_time: '1571385617000' }, + buildLoginKeyCtx(mockServer.url), + ); + // Should have: POST auth, GET auth, GET alarm list + const authPost = mockServer.requests.find((r) => r.method === 'POST' && r.path === '/skyeye/v1/admin/auth'); + assert.ok(authPost, 'POST auth request should exist'); + const alarmReq = mockServer.requests.find((r) => r.path === '/skyeye/v1/alarm/alarm/list'); + assert.ok(alarmReq, 'alarm list request should exist'); + // The csrf_token should come from auth, not static + assert.equal(alarmReq.params.csrf_token, 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6'); + }); +}); + +describe('handlers (login_key auth) - QueryAlarmPacket', () => { + it('authenticates and calls upstream', async () => { + authCache.clear(); + const handler = handlers[METHOD_QUERY_ALARM_PACKET_FULL]; + const result = await handler( + { alarm_sip: '10.0.0.1', attack_sip: '10.0.0.2', start_time: '1571385615000', end_time: '1571385617000', alarm_id: 'test-id' }, + buildLoginKeyCtx(mockServer.url), + ); + assert.equal(result.response_code, 200); + }); +}); + +describe('handlers (login_key auth) - QueryNetworkLog', () => { + it('authenticates and calls upstream', async () => { + authCache.clear(); + const handler = handlers[METHOD_QUERY_NETWORK_LOG_FULL]; + const result = await handler( + { start_time: '1571385615000', end_time: '1571385617000', index: 'alarm_collection', category: 'event', mode: 'advance_model', offset: '1', limit: '50' }, + buildLoginKeyCtx(mockServer.url), + ); + assert.equal(result.response_code, 1000); + }); +}); + +// ─── rpcdef & adaptHandler ─── + +describe('rpcdef', () => { + it('returns method map with all 4 handlers', () => { + const def = rpcdef(buildCtx()); + assert.ok(def['/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmList']); + assert.ok(def['/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryAlarmPacket']); + assert.ok(def['/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/DownloadAlarmPcap']); + assert.ok(def['/QianXin_SkyEye_V3_0_14_0.QianXin_SkyEye_V3_0_14_0/QueryNetworkLog']); + }); +}); + +describe('adaptHandler', () => { + it('handles (req, ctx) two-arg call', async () => { + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + const result = await handler( + { start_time: '1571385615000', end_time: '1571385617000' }, + { config: { skyeye_domain: mockServer.url, skyeye_staff_name: 'admin' }, secret: { skyeye_csrf_token: 'test-token' } }, + ); + assert.equal(result.response_code, 1000); + }); + + it('handles SDK-style single-arg call', async () => { + const handler = handlers[METHOD_QUERY_ALARM_LIST_FULL]; + const result = await handler({ + request: { start_time: '1571385615000', end_time: '1571385617000' }, + config: { skyeye_domain: mockServer.url, skyeye_staff_name: 'admin' }, + secret: { skyeye_csrf_token: 'test-token' }, + }); + assert.equal(result.response_code, 1000); + }); +});