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..f33a7919 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -181,6 +181,10 @@ const services = { entryFile: "../sangfor__fw_v8-0-45/bin/sangfor-fw-v8-0-45.js", serviceModule: "../sangfor__fw_v8-0-45/src/service.js", }, + "sangfor-sip": { + entryFile: "../sangfor__sip/bin/sangfor-sip.js", + serviceModule: "../sangfor__sip/src/service.js", + }, "sangfor-xdr-v2-0-45": { entryFile: "../sangfor__xdr_v2-0-45/bin/sangfor-xdr-v2-0-45.js", serviceModule: "../sangfor__xdr_v2-0-45/src/service.js", diff --git a/services/bin/sangfor-sip.js b/services/bin/sangfor-sip.js new file mode 100755 index 00000000..b2cf4887 --- /dev/null +++ b/services/bin/sangfor-sip.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../sangfor__sip/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../sangfor__sip/bin/sangfor-sip.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..c434dd6d 100644 --- a/services/package.json +++ b/services/package.json @@ -49,6 +49,7 @@ "safeline-waf": "bin/safeline-waf.js", "safeline-waf-eliminate-false-positive": "bin/safeline-waf-eliminate-false-positive.js", "sangfor-fw-v8-0-45": "bin/sangfor-fw-v8-0-45.js", + "sangfor-sip": "bin/sangfor-sip.js", "sangfor-xdr-v2-0-45": "bin/sangfor-xdr-v2-0-45.js", "skycloud-inet": "bin/skycloud-inet.js", "slack-group-robot": "bin/slack-group-robot.js", @@ -119,6 +120,7 @@ "bin/safeline-waf-eliminate-false-positive.js", "bin/safeline-waf.js", "bin/sangfor-fw-v8-0-45.js", + "bin/sangfor-sip.js", "bin/sangfor-xdr-v2-0-45.js", "bin/tencent-qyweixin-group-robot.js", "bin/tencent-tix-saas.js", @@ -183,6 +185,7 @@ "chaitin__safeline-waf-eliminate-false-positive", "chaitin__safeline-waf", "sangfor__fw_v8-0-45", + "sangfor__sip", "sangfor__xdr_v2-0-45", "tencent__qyweixin-group-robot", "tencent__tix-saas", diff --git a/services/sangfor__sip/README.md b/services/sangfor__sip/README.md new file mode 100644 index 00000000..4998a51a --- /dev/null +++ b/services/sangfor__sip/README.md @@ -0,0 +1,46 @@ +# sangfor-sip + +OctoBus service package for Sangfor SIP (Security Intelligence Platform / 深信服安全感知平台). + +Pulls security events, risk assets, risk terminals, server/terminal inventory, IP groups, and vulnerability data (weak passwords, CVEs, plaintext transmission) via the SIP third-party REST API. + +## Authentication + +POST `/sangforinter/v1/auth/party/login` with SHA1-based auth token: + +``` +auth = sha1(rand + password + "sangfor3party" + userName) +``` + +Credentials are configured via `secret.yaml`: + +```yaml +userName: <认证账号> +password: <认证密码> +platformName: <平台名称> +``` + +## Configuration + +```yaml +host: https://10.0.0.1:7443 +skipTlsVerify: true +``` + +## Methods + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GetSecurityEvents` | `GET /sangforinter/v1/data/riskevent` | Pull security events | +| `GetRiskBusiness` | `GET /sangforinter/v1/data/riskbusiness` | Pull risk business assets | +| `GetRiskTerminals` | `GET /sangforinter/v1/data/riskterminal` | Pull risk terminal assets | +| `GetServers` | `GET /sangforinter/v1/data/business` | Pull configured server assets | +| `GetTerminals` | `GET /sangforinter/v1/data/terminal` | Pull terminal assets | +| `GetIPGroups` | `GET /sangforinter/v1/data/ipgroup` | Pull monitored IP groups | +| `GetWeakPasswords` | `GET /sangforinter/v1/data/weakpasswd` | Pull weak password vulnerabilities | +| `GetVulnerabilities` | `GET /sangforinter/v1/data/hole` | Pull CVE vulnerability records | +| `GetPlaintextTransmissions` | `GET /sangforinter/v1/data/plaintexttransmission` | Pull plaintext transmission vulnerabilities | + +## API version + +Compatible with SIP v92+. Server port: `7443`. diff --git a/services/sangfor__sip/bin/sangfor-sip.js b/services/sangfor__sip/bin/sangfor-sip.js new file mode 100755 index 00000000..8e0066ef --- /dev/null +++ b/services/sangfor__sip/bin/sangfor-sip.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/sangfor__sip/config.schema.json b/services/sangfor__sip/config.schema.json new file mode 100644 index 00000000..478159d1 --- /dev/null +++ b/services/sangfor__sip/config.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "host": { + "type": "string", + "description": "Sangfor SIP base URL, e.g. https://10.0.0.1:7443" + }, + "baseUrl": { + "type": "string", + "description": "Alias for host." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 10000, + "description": "HTTP timeout in milliseconds." + }, + "skipTlsVerify": { + "type": "boolean", + "default": true, + "description": "Skip TLS certificate verification (SIP often uses self-signed certs)." + } + } +} diff --git a/services/sangfor__sip/package.json b/services/sangfor__sip/package.json new file mode 100644 index 00000000..c74814ca --- /dev/null +++ b/services/sangfor__sip/package.json @@ -0,0 +1,12 @@ +{ + "name": "sangfor-sip", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "sangfor-sip": "bin/sangfor-sip.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/sangfor__sip/proto/sangfor_sip.proto b/services/sangfor__sip/proto/sangfor_sip.proto new file mode 100644 index 00000000..dcd1f0ed --- /dev/null +++ b/services/sangfor__sip/proto/sangfor_sip.proto @@ -0,0 +1,126 @@ +syntax = "proto3"; + +package SANGFOR_SIP; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +service SANGFOR_SIP { + rpc GetSecurityEvents (GetSecurityEventsRequest) returns (GetSecurityEventsResponse); + rpc GetRiskBusiness (GetRiskBusinessRequest) returns (GetRiskBusinessResponse); + rpc GetRiskTerminals (GetRiskTerminalsRequest) returns (GetRiskTerminalsResponse); + rpc GetServers (GetServersRequest) returns (GetServersResponse); + rpc GetTerminals (GetTerminalsRequest) returns (GetTerminalsResponse); + rpc GetIPGroups (GetIPGroupsRequest) returns (GetIPGroupsResponse); + rpc GetWeakPasswords (GetWeakPasswordsRequest) returns (GetWeakPasswordsResponse); + rpc GetVulnerabilities (GetVulnerabilitiesRequest) returns (GetVulnerabilitiesResponse); + rpc GetPlaintextTransmissions (GetPlaintextTransmissionsRequest) returns (GetPlaintextTransmissionsResponse); +} + +// GetSecurityEvents — pull security events (安全事件) +message GetSecurityEventsRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetSecurityEventsResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetRiskBusiness — pull risk business assets (风险业务) +message GetRiskBusinessRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetRiskBusinessResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetRiskTerminals — pull risk terminal assets (风险终端) +message GetRiskTerminalsRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetRiskTerminalsResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetServers — pull configured server assets (已配置服务器) +message GetServersRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetServersResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetTerminals — pull terminal assets (资产终端) +message GetTerminalsRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetTerminalsResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetIPGroups — pull monitored IP groups (受监控IP组) +message GetIPGroupsRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetIPGroupsResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetWeakPasswords — pull weak password vulnerabilities (弱密码) +message GetWeakPasswordsRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetWeakPasswordsResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetVulnerabilities — pull vulnerability information (漏洞) +message GetVulnerabilitiesRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetVulnerabilitiesResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} + +// GetPlaintextTransmissions — pull plaintext transmission vulnerabilities (明文传输) +message GetPlaintextTransmissionsRequest { + int64 from_time = 1; + int64 to_time = 2; + google.protobuf.Int32Value max_count = 3; +} + +message GetPlaintextTransmissionsResponse { + repeated google.protobuf.Value items = 1; + int32 count = 2; +} diff --git a/services/sangfor__sip/secret.schema.json b/services/sangfor__sip/secret.schema.json new file mode 100644 index 00000000..45935d31 --- /dev/null +++ b/services/sangfor__sip/secret.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "userName": { + "type": "string", + "description": "SIP authentication account (认证账号), must match platform configuration." + }, + "password": { + "type": "string", + "description": "SIP authentication password (认证密码), must match platform configuration." + }, + "platformName": { + "type": "string", + "description": "SIP unique platform name (平台名称), must match platform configuration." + } + } +} diff --git a/services/sangfor__sip/service.json b/services/sangfor__sip/service.json new file mode 100644 index 00000000..8a50a167 --- /dev/null +++ b/services/sangfor__sip/service.json @@ -0,0 +1,61 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "sangfor-sip", + "displayName": "Sangfor SIP", + "description": "OctoBus package for Sangfor SIP (Security Intelligence Platform): pull security events, risk assets/terminals, server/terminal inventory, IP groups, and vulnerability data (weak passwords, CVEs, plaintext transmission).", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/sangfor_sip.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "SANGFOR_SIP.SANGFOR_SIP/GetSecurityEvents": { + "name": "get-security-events", + "description": "Pull security events from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetRiskBusiness": { + "name": "get-risk-business", + "description": "Pull risk business assets from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetRiskTerminals": { + "name": "get-risk-terminals", + "description": "Pull risk terminal assets from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetServers": { + "name": "get-servers", + "description": "Pull configured server asset records from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetTerminals": { + "name": "get-terminals", + "description": "Pull terminal asset records from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetIPGroups": { + "name": "get-ip-groups", + "description": "Pull monitored IP group records from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetWeakPasswords": { + "name": "get-weak-passwords", + "description": "Pull weak password vulnerability records from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetVulnerabilities": { + "name": "get-vulnerabilities", + "description": "Pull vulnerability (CVE/hole) records from Sangfor SIP within a time range." + }, + "SANGFOR_SIP.SANGFOR_SIP/GetPlaintextTransmissions": { + "name": "get-plaintext-transmissions", + "description": "Pull plaintext transmission vulnerability records from Sangfor SIP within a time range." + } + } + } + } +} diff --git a/services/sangfor__sip/src/sangfor-sip.js b/services/sangfor__sip/src/sangfor-sip.js new file mode 100644 index 00000000..1b16bb47 --- /dev/null +++ b/services/sangfor__sip/src/sangfor-sip.js @@ -0,0 +1,225 @@ +import { createHash } from 'node:crypto'; +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +const PKG = 'SANGFOR_SIP'; +const P = `/${PKG}.${PKG}/`; + +const PATHS = { + GET_SECURITY_EVENTS: `${P}GetSecurityEvents`, + GET_RISK_BUSINESS: `${P}GetRiskBusiness`, + GET_RISK_TERMINALS: `${P}GetRiskTerminals`, + GET_SERVERS: `${P}GetServers`, + GET_TERMINALS: `${P}GetTerminals`, + GET_IP_GROUPS: `${P}GetIPGroups`, + GET_WEAK_PASSWORDS: `${P}GetWeakPasswords`, + GET_VULNERABILITIES: `${P}GetVulnerabilities`, + GET_PLAINTEXT_TRANSMISSIONS: `${P}GetPlaintextTransmissions`, +}; + +const API_PATHS = { + auth: '/sangforinter/v1/auth/party/login', + securityEvents: '/sangforinter/v1/data/riskevent', + riskBusiness: '/sangforinter/v1/data/riskbusiness', + riskTerminals: '/sangforinter/v1/data/riskterminal', + servers: '/sangforinter/v1/data/business', + terminals: '/sangforinter/v1/data/terminal', + ipGroups: '/sangforinter/v1/data/ipgroup', + weakPasswords: '/sangforinter/v1/data/weakpasswd', + vulnerabilities: '/sangforinter/v1/data/hole', + plaintextTransmissions: '/sangforinter/v1/data/plaintexttransmission', +}; + +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 { nullValue: 'NULL_VALUE' }; + 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) } }; + if (typeof val === 'object') { + const fields = {}; + for (const [k, v] of Object.entries(val)) fields[k] = toValue(v); + 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.isFinite(n) ? Math.trunc(n) : null; +}; + +const unwrap = (val) => { + if (val === undefined || val === null) return undefined; + if (typeof val === 'object' && 'value' in val) return val.value == null ? undefined : 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().replace(/\/+$/, ''); + if (!s || !/^https?:\/\//i.test(s)) return null; + return s; +}; + +export const sipAuth3 = (userName, password, rand) => { + return createHash('sha1') + .update(String(rand) + password + 'sangfor3party' + userName) + .digest('hex'); +}; + +export function rpcdef(ctx) { + const bindings = mergedBindings(ctx); + + const baseUrl = () => { + const u = normalizeBaseUrl(firstDefined(bindings.host, bindings.baseUrl, bindings.restBaseUrl)); + if (!u) throw err('INVALID_ARGUMENT', 'config.host is required (e.g. https://10.0.0.1:7443)'); + return u; + }; + + const doFetch = async (url, init) => { + try { + return await fetch(url, init); + } catch (e) { + throw err('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); + } + }; + + const readJson = async (res) => { + let text; + try { text = await res.text(); } catch { throw err('UNKNOWN', 'failed to read response body'); } + if (res.status === 403) throw err('PERMISSION_DENIED', `http 403: ${text}`); + if (res.status >= 500) throw err('UNAVAILABLE', `http ${res.status}: ${text}`); + if (!text.trim()) throw err('UNKNOWN', 'empty response body'); + try { return JSON.parse(text); } catch { throw err('UNKNOWN', 'response is not valid JSON'); } + }; + + const getToken = async (base) => { + const userName = String(firstDefined(bindings.userName, bindings.username) ?? '').trim(); + const password = String(firstDefined(bindings.password) ?? '').trim(); + const platformName = String(firstDefined(bindings.platformName) ?? '').trim(); + + if (!userName) throw err('INVALID_ARGUMENT', 'secret.userName is required'); + if (!password) throw err('INVALID_ARGUMENT', 'secret.password is required'); + if (!platformName) throw err('INVALID_ARGUMENT', 'secret.platformName is required'); + + const rand = Math.floor(Math.random() * 2147483647); + const auth = sipAuth3(userName, password, rand); + + const body = JSON.stringify({ rand, userName, clientProduct: '', clientVersion: '', clientId: 0, desc: '', auth, platformName }); + const res = await doFetch(base + API_PATHS.auth, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + const json = await readJson(res); + if (json?.code === 13) throw err('PERMISSION_DENIED', json.message ?? 'SIP permission denied'); + if (json?.code !== 0) throw err('FAILED_PRECONDITION', `SIP auth failed: code=${json?.code} ${json?.message ?? ''}`); + + const token = json?.data?.token; + if (!token) throw err('UNKNOWN', 'SIP auth response missing token'); + return token; + }; + + const pullData = async (apiPath, req) => { + const base = baseUrl(); + const token = await getToken(base); + + const fromTime = toInt(firstDefined(req?.from_time)); + const toTime = toInt(firstDefined(req?.to_time)); + + if (fromTime === null) throw err('INVALID_ARGUMENT', 'from_time is required'); + if (toTime === null) throw err('INVALID_ARGUMENT', 'to_time is required'); + if (fromTime >= toTime) throw err('INVALID_ARGUMENT', 'from_time must be less than to_time'); + + const maxCount = toInt(firstDefined(req?.max_count)) ?? 2000; + + const params = new URLSearchParams({ token, fromActionTime: String(fromTime), toActionTime: String(toTime), maxCount: String(maxCount) }); + const res = await doFetch(`${base}${apiPath}?${params.toString()}`); + const json = await readJson(res); + + if (json?.code === 13) throw err('PERMISSION_DENIED', json.message ?? 'SIP permission denied'); + if (json?.code !== 0) throw err('FAILED_PRECONDITION', `SIP error: code=${json?.code} ${json?.message ?? ''}`); + + const items = Array.isArray(json?.data?.items) ? json.data.items : []; + const count = typeof json?.data?.count === 'number' ? json.data.count : items.length; + return { items: items.map(toValue), count }; + }; + + const callGetSecurityEvents = async (req) => pullData(API_PATHS.securityEvents, req); + const callGetRiskBusiness = async (req) => pullData(API_PATHS.riskBusiness, req); + const callGetRiskTerminals = async (req) => pullData(API_PATHS.riskTerminals, req); + const callGetServers = async (req) => pullData(API_PATHS.servers, req); + const callGetTerminals = async (req) => pullData(API_PATHS.terminals, req); + const callGetIPGroups = async (req) => pullData(API_PATHS.ipGroups, req); + const callGetWeakPasswords = async (req) => pullData(API_PATHS.weakPasswords, req); + const callGetVulnerabilities = async (req) => pullData(API_PATHS.vulnerabilities, req); + const callGetPlaintextTransmissions = async (req) => pullData(API_PATHS.plaintextTransmissions, req); + + return { + [PATHS.GET_SECURITY_EVENTS]: async () => callGetSecurityEvents(ctx.req), + [PATHS.GET_RISK_BUSINESS]: async () => callGetRiskBusiness(ctx.req), + [PATHS.GET_RISK_TERMINALS]: async () => callGetRiskTerminals(ctx.req), + [PATHS.GET_SERVERS]: async () => callGetServers(ctx.req), + [PATHS.GET_TERMINALS]: async () => callGetTerminals(ctx.req), + [PATHS.GET_IP_GROUPS]: async () => callGetIPGroups(ctx.req), + [PATHS.GET_WEAK_PASSWORDS]: async () => callGetWeakPasswords(ctx.req), + [PATHS.GET_VULNERABILITIES]: async () => callGetVulnerabilities(ctx.req), + [PATHS.GET_PLAINTEXT_TRANSMISSIONS]: async () => callGetPlaintextTransmissions(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 = { sipAuth3, toValue, normalizeBaseUrl, PATHS, API_PATHS }; diff --git a/services/sangfor__sip/src/service.js b/services/sangfor__sip/src/service.js new file mode 100644 index 00000000..8e203a66 --- /dev/null +++ b/services/sangfor__sip/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './sangfor-sip.js'; + +export { handlers } from './sangfor-sip.js'; + +export const service = defineService({ handlers }); diff --git a/services/sangfor__sip/test/sangfor-sip.test.js b/services/sangfor__sip/test/sangfor-sip.test.js new file mode 100644 index 00000000..49c0acb3 --- /dev/null +++ b/services/sangfor__sip/test/sangfor-sip.test.js @@ -0,0 +1,712 @@ +import { describe, it, afterEach } from 'node:test'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { rpcdef, handlers, _test } from '../src/sangfor-sip.js'; + +const PKG = 'SANGFOR_SIP'; +const P = `/${PKG}.${PKG}/`; + +const PATH_GET_SECURITY_EVENTS = `${P}GetSecurityEvents`; +const PATH_GET_RISK_BUSINESS = `${P}GetRiskBusiness`; +const PATH_GET_RISK_TERMINALS = `${P}GetRiskTerminals`; +const PATH_GET_SERVERS = `${P}GetServers`; +const PATH_GET_TERMINALS = `${P}GetTerminals`; +const PATH_GET_IP_GROUPS = `${P}GetIPGroups`; +const PATH_GET_WEAK_PASSWORDS = `${P}GetWeakPasswords`; +const PATH_GET_VULNERABILITIES = `${P}GetVulnerabilities`; +const PATH_GET_PLAINTEXT_TRANSMISSIONS = `${P}GetPlaintextTransmissions`; + +const KEY_GET_SECURITY_EVENTS = `${PKG}.${PKG}/GetSecurityEvents`; +const KEY_GET_RISK_BUSINESS = `${PKG}.${PKG}/GetRiskBusiness`; +const KEY_GET_RISK_TERMINALS = `${PKG}.${PKG}/GetRiskTerminals`; +const KEY_GET_SERVERS = `${PKG}.${PKG}/GetServers`; +const KEY_GET_TERMINALS = `${PKG}.${PKG}/GetTerminals`; +const KEY_GET_IP_GROUPS = `${PKG}.${PKG}/GetIPGroups`; +const KEY_GET_WEAK_PASSWORDS = `${PKG}.${PKG}/GetWeakPasswords`; +const KEY_GET_VULNERABILITIES = `${PKG}.${PKG}/GetVulnerabilities`; +const KEY_GET_PLAINTEXT_TRANSMISSIONS = `${PKG}.${PKG}/GetPlaintextTransmissions`; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +const AUTH_TOKEN_BODY = JSON.stringify({ + code: 0, + message: 'success', + data: { token: 'test_sip_token' }, +}); + +const makeHeaders = () => ({ get: () => null }); + +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() }; + } + const text = typeof apiBody === 'string' ? apiBody : JSON.stringify(apiBody); + return { ok: apiStatus >= 200 && apiStatus < 300, status: apiStatus, text: async () => text, headers: makeHeaders() }; + }; +}; + +const networkErrorFetch = async () => { throw new Error('ECONNREFUSED'); }; + +const buildCtx = (reqOverrides = {}, secretOverrides = {}) => ({ + bindings: { + host: 'https://sip.example.com:7443', + ...(secretOverrides), + }, + config: {}, + secret: { + userName: 'testuser', + password: 'testpass', + platformName: 'MyPlatform', + ...secretOverrides, + }, + limits: { timeoutMs: 5000 }, + meta: {}, + req: { + from_time: 1700000000, + to_time: 1700003600, + ...reqOverrides, + }, +}); + +const expectGrpcError = async (fn, legacyCode) => { + let caught; + try { await fn(); } catch (e) { caught = e; } + assert.ok(caught, 'expected function to throw'); + assert.ok(caught instanceof GrpcError, `expected GrpcError, got: ${caught}`); + assert.equal(caught.legacyCode, legacyCode, `expected legacyCode ${legacyCode}, got ${caught.legacyCode}`); + assert.match(caught.message, new RegExp(`^${legacyCode}:`)); +}; + +const originalFetch = globalThis.fetch; +test.afterEach(() => { globalThis.fetch = originalFetch; }); + +// --------------------------------------------------------------------------- +// Shape +// --------------------------------------------------------------------------- +test('rpcdef exposes all 9 paths', () => { + const def = rpcdef(buildCtx()); + assert.equal(typeof def[PATH_GET_SECURITY_EVENTS], 'function'); + assert.equal(typeof def[PATH_GET_RISK_BUSINESS], 'function'); + assert.equal(typeof def[PATH_GET_RISK_TERMINALS], 'function'); + assert.equal(typeof def[PATH_GET_SERVERS], 'function'); + assert.equal(typeof def[PATH_GET_TERMINALS], 'function'); + assert.equal(typeof def[PATH_GET_IP_GROUPS], 'function'); + assert.equal(typeof def[PATH_GET_WEAK_PASSWORDS], 'function'); + assert.equal(typeof def[PATH_GET_VULNERABILITIES], 'function'); + assert.equal(typeof def[PATH_GET_PLAINTEXT_TRANSMISSIONS], 'function'); +}); + +test('handlers exposes all 9 keys without leading slash', () => { + assert.equal(typeof handlers[KEY_GET_SECURITY_EVENTS], 'function'); + assert.equal(typeof handlers[KEY_GET_RISK_BUSINESS], 'function'); + assert.equal(typeof handlers[KEY_GET_RISK_TERMINALS], 'function'); + assert.equal(typeof handlers[KEY_GET_SERVERS], 'function'); + assert.equal(typeof handlers[KEY_GET_TERMINALS], 'function'); + assert.equal(typeof handlers[KEY_GET_IP_GROUPS], 'function'); + assert.equal(typeof handlers[KEY_GET_WEAK_PASSWORDS], 'function'); + assert.equal(typeof handlers[KEY_GET_VULNERABILITIES], 'function'); + assert.equal(typeof handlers[KEY_GET_PLAINTEXT_TRANSMISSIONS], 'function'); +}); + +// --------------------------------------------------------------------------- +// _test helpers +// --------------------------------------------------------------------------- +describe('sipAuth3', () => { + it('returns consistent hex string', () => { + const { sipAuth3 } = _test; + const h = sipAuth3('user1', 'pass1', 12345); + assert.equal(typeof h, 'string'); + assert.match(h, /^[0-9a-f]{40}$/); + }); + + it('is deterministic for same inputs', () => { + const { sipAuth3 } = _test; + assert.equal(sipAuth3('u', 'p', 999), sipAuth3('u', 'p', 999)); + }); + + it('differs on different password', () => { + const { sipAuth3 } = _test; + assert.notEqual(sipAuth3('u', 'pass1', 100), sipAuth3('u', 'pass2', 100)); + }); +}); + +describe('normalizeBaseUrl', () => { + it('removes trailing slash', () => { + assert.equal(_test.normalizeBaseUrl('https://sip.local:7443/'), 'https://sip.local:7443'); + }); + + it('returns null for empty', () => { + assert.equal(_test.normalizeBaseUrl(''), null); + }); + + it('returns null for non-http', () => { + assert.equal(_test.normalizeBaseUrl('ftp://host'), null); + }); + + it('accepts http', () => { + assert.equal(_test.normalizeBaseUrl('http://10.0.0.1:7443'), 'http://10.0.0.1:7443'); + }); +}); + +describe('toValue', () => { + const { toValue } = _test; + it('wraps string', () => { assert.deepEqual(toValue('abc'), { stringValue: 'abc' }); }); + it('wraps number', () => { assert.deepEqual(toValue(42), { numberValue: 42 }); }); + it('wraps boolean', () => { assert.deepEqual(toValue(true), { boolValue: true }); }); + it('wraps null', () => { assert.deepEqual(toValue(null), { nullValue: 'NULL_VALUE' }); }); + it('wraps array', () => { + const r = toValue([1, 'x']); + assert.ok(r.listValue); + assert.equal(r.listValue.values.length, 2); + }); + it('wraps object', () => { + const r = toValue({ a: 1 }); + assert.ok(r.structValue); + assert.deepEqual(r.structValue.fields.a, { numberValue: 1 }); + }); +}); + +// --------------------------------------------------------------------------- +// GetSecurityEvents +// --------------------------------------------------------------------------- +describe('GetSecurityEvents success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns items and count', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [ + { ip: '1.1.1.1', eventDes: 'test event', priority: 3 }, + { ip: '2.2.2.2', eventDes: 'another', priority: 1 }, + ], + count: 2, + device_info: { source: 'SIP' }, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const ctx = buildCtx(); + const r = await rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](); + assert.equal(r.count, 2); + assert.equal(r.items.length, 2); + assert.ok(r.items[0].structValue); + assert.deepEqual(r.items[0].structValue.fields.ip, { stringValue: '1.1.1.1' }); + }); + + it('handles empty items array', async () => { + const apiResp = { code: 0, message: 'success', data: { items: [], count: 0 } }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](); + assert.equal(r.count, 0); + assert.equal(r.items.length, 0); + }); + + it('uses custom maxCount', async () => { + let capturedUrl; + globalThis.fetch = async (url, init) => { + capturedUrl = url; + return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + }; + // First call is auth, second call is data + let callNum = 0; + globalThis.fetch = async (url, init) => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + capturedUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx({ max_count: { value: 500 } }))[PATH_GET_SECURITY_EVENTS](); + assert.ok(capturedUrl.includes('maxCount=500'), `expected maxCount=500 in ${capturedUrl}`); + }); + + it('sends token in query string', async () => { + let dataUrl; + let callNum = 0; + globalThis.fetch = async (url) => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + dataUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](); + assert.ok(dataUrl.includes('token=test_sip_token'), `expected token in URL: ${dataUrl}`); + }); +}); + +// --------------------------------------------------------------------------- +// GetSecurityEvents — validation errors +// --------------------------------------------------------------------------- +describe('GetSecurityEvents validation', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws INVALID_ARGUMENT when host missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({}, { host: '', userName: 'u', password: 'p', platformName: 'plt' }); + ctx.secret = { userName: 'u', password: 'p', platformName: 'plt' }; + ctx.bindings = { host: '' }; + await expectGrpcError(() => rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](), 'INVALID_ARGUMENT'); + }); + + it('throws INVALID_ARGUMENT when userName missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx(); + ctx.secret = { password: 'p', platformName: 'plt' }; + ctx.bindings = { host: 'https://sip.example.com:7443' }; + await expectGrpcError(() => rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](), 'INVALID_ARGUMENT'); + }); + + it('throws INVALID_ARGUMENT when password missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx(); + ctx.secret = { userName: 'u', platformName: 'plt' }; + ctx.bindings = { host: 'https://sip.example.com:7443' }; + await expectGrpcError(() => rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](), 'INVALID_ARGUMENT'); + }); + + it('throws INVALID_ARGUMENT when platformName missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx(); + ctx.secret = { userName: 'u', password: 'p' }; + ctx.bindings = { host: 'https://sip.example.com:7443' }; + await expectGrpcError(() => rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](), 'INVALID_ARGUMENT'); + }); + + it('throws INVALID_ARGUMENT when from_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ from_time: null }); + await expectGrpcError(() => rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](), 'INVALID_ARGUMENT'); + }); + + it('throws INVALID_ARGUMENT when to_time missing', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ to_time: null }); + await expectGrpcError(() => rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](), 'INVALID_ARGUMENT'); + }); + + it('throws INVALID_ARGUMENT when from_time >= to_time', async () => { + globalThis.fetch = makeSeqFetch({}); + const ctx = buildCtx({ from_time: 1700003600, to_time: 1700000000 }); + await expectGrpcError(() => rpcdef(ctx)[PATH_GET_SECURITY_EVENTS](), 'INVALID_ARGUMENT'); + }); +}); + +// --------------------------------------------------------------------------- +// GetSecurityEvents — auth errors +// --------------------------------------------------------------------------- +describe('GetSecurityEvents auth errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws PERMISSION_DENIED on auth code 13', async () => { + globalThis.fetch = async () => ({ + ok: true, status: 200, + text: async () => JSON.stringify({ code: 13, message: 'permission denied' }), + headers: makeHeaders(), + }); + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'PERMISSION_DENIED'); + }); + + it('throws FAILED_PRECONDITION on non-zero auth code', async () => { + globalThis.fetch = async () => ({ + ok: true, status: 200, + text: async () => JSON.stringify({ code: 301, message: 'invalid argument' }), + headers: makeHeaders(), + }); + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'FAILED_PRECONDITION'); + }); + + it('throws UNKNOWN when auth response missing token', async () => { + globalThis.fetch = async () => ({ + ok: true, status: 200, + text: async () => JSON.stringify({ code: 0, message: 'success', data: {} }), + headers: makeHeaders(), + }); + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'UNKNOWN'); + }); +}); + +// --------------------------------------------------------------------------- +// GetSecurityEvents — API response errors +// --------------------------------------------------------------------------- +describe('GetSecurityEvents API response errors', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('throws PERMISSION_DENIED on HTTP 403 from data endpoint', async () => { + let callNum = 0; + globalThis.fetch = async () => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + return { ok: false, status: 403, text: async () => 'Forbidden', headers: makeHeaders() }; + }; + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'PERMISSION_DENIED'); + }); + + it('throws UNAVAILABLE on HTTP 500', async () => { + let callNum = 0; + globalThis.fetch = async () => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + return { ok: false, status: 500, text: async () => 'Internal Error', headers: makeHeaders() }; + }; + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'UNAVAILABLE'); + }); + + it('throws PERMISSION_DENIED on data code 13', async () => { + const apiResp = { code: 13, message: 'Permission denied!' }; + globalThis.fetch = makeSeqFetch(apiResp); + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'PERMISSION_DENIED'); + }); + + it('throws FAILED_PRECONDITION on data code 301', async () => { + const apiResp = { code: 301, message: 'Invalid argument' }; + globalThis.fetch = makeSeqFetch(apiResp); + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'FAILED_PRECONDITION'); + }); + + it('throws UNAVAILABLE on network error during data fetch', async () => { + let callNum = 0; + globalThis.fetch = async () => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + throw new Error('ECONNREFUSED'); + }; + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'UNAVAILABLE'); + }); + + it('throws UNAVAILABLE on network error during auth', async () => { + globalThis.fetch = networkErrorFetch; + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'UNAVAILABLE'); + }); + + it('throws UNKNOWN on non-JSON data response', async () => { + globalThis.fetch = makeSeqFetch('not json at all', 200); + await expectGrpcError(() => rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](), 'UNKNOWN'); + }); +}); + +// --------------------------------------------------------------------------- +// GetRiskBusiness +// --------------------------------------------------------------------------- +describe('GetRiskBusiness success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns items with risk business fields', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ ip: '10.0.0.1', riskLevel: 3, dealStatus: 0, groupName: 'biz1' }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_RISK_BUSINESS](); + assert.equal(r.count, 1); + assert.equal(r.items.length, 1); + assert.deepEqual(r.items[0].structValue.fields.ip, { stringValue: '10.0.0.1' }); + }); +}); + +// --------------------------------------------------------------------------- +// GetRiskTerminals +// --------------------------------------------------------------------------- +describe('GetRiskTerminals success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns items with risk terminal fields', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ ip: '192.168.1.50', fallLevel: 2, hostName: 'desktop1' }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_RISK_TERMINALS](); + assert.equal(r.count, 1); + assert.deepEqual(r.items[0].structValue.fields.hostName, { stringValue: 'desktop1' }); + }); +}); + +// --------------------------------------------------------------------------- +// GetServers +// --------------------------------------------------------------------------- +describe('GetServers success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns server asset records', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ assetIp: '10.1.0.5', system: 'linux', findType: 'auto', status: 0 }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_SERVERS](); + assert.equal(r.count, 1); + assert.deepEqual(r.items[0].structValue.fields.system, { stringValue: 'linux' }); + }); +}); + +// --------------------------------------------------------------------------- +// GetTerminals +// --------------------------------------------------------------------------- +describe('GetTerminals success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns terminal asset records', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ ip: '192.168.0.10', hostName: 'PC001', findType: 'manual', type: 3 }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_TERMINALS](); + assert.equal(r.count, 1); + assert.deepEqual(r.items[0].structValue.fields.hostName, { stringValue: 'PC001' }); + }); +}); + +// --------------------------------------------------------------------------- +// GetIPGroups +// --------------------------------------------------------------------------- +describe('GetIPGroups success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns IP group records', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ name: 'group1', type: 1, ipRange: ['10.0.0.0-10.255.255.255'] }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_IP_GROUPS](); + assert.equal(r.count, 1); + assert.deepEqual(r.items[0].structValue.fields.name, { stringValue: 'group1' }); + }); +}); + +// --------------------------------------------------------------------------- +// GetWeakPasswords +// --------------------------------------------------------------------------- +describe('GetWeakPasswords success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns weak password records', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ ip: '10.0.0.2', weakType: 'FTP登录弱密码', user: 'admin', dstPort: 21 }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_WEAK_PASSWORDS](); + assert.equal(r.count, 1); + assert.deepEqual(r.items[0].structValue.fields.weakType, { stringValue: 'FTP登录弱密码' }); + }); +}); + +// --------------------------------------------------------------------------- +// GetVulnerabilities +// --------------------------------------------------------------------------- +describe('GetVulnerabilities success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns vulnerability records', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ ip: '10.0.0.3', holeName: 'CVE-2021-1234', level: 3, holeId: 10010236 }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_VULNERABILITIES](); + assert.equal(r.count, 1); + assert.deepEqual(r.items[0].structValue.fields.holeName, { stringValue: 'CVE-2021-1234' }); + }); +}); + +// --------------------------------------------------------------------------- +// GetPlaintextTransmissions +// --------------------------------------------------------------------------- +describe('GetPlaintextTransmissions success', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('returns plaintext transmission records', async () => { + const apiResp = { + code: 0, message: 'success', + data: { + items: [{ ip: '10.0.0.4', url: 'http://internal.corp/login', level: 2, ruleId: '502001' }], + count: 1, + }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const r = await rpcdef(buildCtx())[PATH_GET_PLAINTEXT_TRANSMISSIONS](); + assert.equal(r.count, 1); + assert.deepEqual(r.items[0].structValue.fields.url, { stringValue: 'http://internal.corp/login' }); + }); +}); + +// --------------------------------------------------------------------------- +// handlers — invocation via SDK-style call +// --------------------------------------------------------------------------- +describe('handlers SDK-style call', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('GetSecurityEvents handler resolves via req+ctx args', async () => { + const apiResp = { + code: 0, message: 'success', + data: { items: [{ ip: '5.5.5.5' }], count: 1 }, + }; + globalThis.fetch = makeSeqFetch(apiResp); + const req = { from_time: 1700000000, to_time: 1700003600 }; + const ctx = { + bindings: { host: 'https://sip.example.com:7443' }, + secret: { userName: 'testuser', password: 'testpass', platformName: 'MyPlatform' }, + }; + const r = await handlers[KEY_GET_SECURITY_EVENTS](req, ctx); + assert.equal(r.count, 1); + }); + + it('GetRiskBusiness handler works via SDK-style call', async () => { + globalThis.fetch = makeSeqFetch({ code: 0, message: 'success', data: { items: [], count: 0 } }); + const r = await handlers[KEY_GET_RISK_BUSINESS]( + { from_time: 1700000000, to_time: 1700003600 }, + { bindings: { host: 'https://sip.example.com:7443' }, secret: { userName: 'u', password: 'p', platformName: 'plt' } }, + ); + assert.equal(r.count, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Auth POST body validation +// --------------------------------------------------------------------------- +describe('Auth POST body', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('sends correct Content-Type and body fields', async () => { + let authBody; + let callNum = 0; + globalThis.fetch = async (url, init) => { + callNum++; + if (callNum === 1) { + authBody = JSON.parse(init.body); + return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + } + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](); + + assert.equal(authBody.userName, 'testuser'); + assert.equal(authBody.platformName, 'MyPlatform'); + assert.equal(typeof authBody.rand, 'number'); + assert.equal(typeof authBody.auth, 'string'); + assert.match(authBody.auth, /^[0-9a-f]{40}$/); + assert.equal(authBody.clientProduct, ''); + assert.equal(authBody.clientId, 0); + }); + + it('auth value matches sipAuth3 formula', async () => { + const { sipAuth3 } = _test; + let capturedRand, capturedAuth; + let callNum = 0; + globalThis.fetch = async (url, init) => { + callNum++; + if (callNum === 1) { + const body = JSON.parse(init.body); + capturedRand = body.rand; + capturedAuth = body.auth; + return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + } + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](); + assert.equal(capturedAuth, sipAuth3('testuser', 'testpass', capturedRand)); + }); +}); + +// --------------------------------------------------------------------------- +// URL and query string +// --------------------------------------------------------------------------- +describe('Data URL construction', () => { + afterEach(() => { globalThis.fetch = originalFetch; }); + + it('includes correct API path for security events', async () => { + let dataUrl; + let callNum = 0; + globalThis.fetch = async (url) => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + dataUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx())[PATH_GET_SECURITY_EVENTS](); + assert.ok(dataUrl.includes('/sangforinter/v1/data/riskevent'), `unexpected URL: ${dataUrl}`); + }); + + it('includes fromActionTime and toActionTime in query', async () => { + let dataUrl; + let callNum = 0; + globalThis.fetch = async (url) => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + dataUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx({ from_time: 1700000000, to_time: 1700003600 }))[PATH_GET_SECURITY_EVENTS](); + assert.ok(dataUrl.includes('fromActionTime=1700000000'), `expected fromActionTime in URL: ${dataUrl}`); + assert.ok(dataUrl.includes('toActionTime=1700003600'), `expected toActionTime in URL: ${dataUrl}`); + }); + + it('uses correct API path for vulnerabilities', async () => { + let dataUrl; + let callNum = 0; + globalThis.fetch = async (url) => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + dataUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx())[PATH_GET_VULNERABILITIES](); + assert.ok(dataUrl.includes('/sangforinter/v1/data/hole'), `unexpected URL: ${dataUrl}`); + }); + + it('uses correct API path for weak passwords', async () => { + let dataUrl; + let callNum = 0; + globalThis.fetch = async (url) => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + dataUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx())[PATH_GET_WEAK_PASSWORDS](); + assert.ok(dataUrl.includes('/sangforinter/v1/data/weakpasswd'), `unexpected URL: ${dataUrl}`); + }); + + it('uses correct API path for plaintext transmissions', async () => { + let dataUrl; + let callNum = 0; + globalThis.fetch = async (url) => { + callNum++; + if (callNum === 1) return { ok: true, status: 200, text: async () => AUTH_TOKEN_BODY, headers: makeHeaders() }; + dataUrl = url; + return { ok: true, status: 200, text: async () => JSON.stringify({ code: 0, message: 'success', data: { items: [], count: 0 } }), headers: makeHeaders() }; + }; + await rpcdef(buildCtx())[PATH_GET_PLAINTEXT_TRANSMISSIONS](); + assert.ok(dataUrl.includes('/sangforinter/v1/data/plaintexttransmission'), `unexpected URL: ${dataUrl}`); + }); +});