From 445c47ef7dd8b2ac90dffa5666a4bacd5ffa4e10 Mon Sep 17 00:00:00 2001 From: flynnnnnn9 <82627152+flynnnnnn9@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:24:17 +0800 Subject: [PATCH 1/2] Add Volcengine WAF service --- services/bin/octobus-tentacles.js | 4 + services/bin/volcengine-waf.js | 10 + services/package.json | 3 + services/volcengine__waf/README.md | 34 ++ .../volcengine__waf/bin/volcengine-waf.js | 6 + services/volcengine__waf/config.schema.json | 29 ++ services/volcengine__waf/package.json | 15 + .../proto/volcengine_waf.proto | 32 ++ services/volcengine__waf/secret.schema.json | 31 ++ services/volcengine__waf/service.json | 81 ++++ services/volcengine__waf/src/service.js | 7 + .../volcengine__waf/src/volcengine-waf.js | 394 ++++++++++++++++++ .../test/volcengine-waf.test.js | 233 +++++++++++ 13 files changed, 879 insertions(+) create mode 100755 services/bin/volcengine-waf.js create mode 100644 services/volcengine__waf/README.md create mode 100755 services/volcengine__waf/bin/volcengine-waf.js create mode 100644 services/volcengine__waf/config.schema.json create mode 100644 services/volcengine__waf/package.json create mode 100644 services/volcengine__waf/proto/volcengine_waf.proto create mode 100644 services/volcengine__waf/secret.schema.json create mode 100644 services/volcengine__waf/service.json create mode 100644 services/volcengine__waf/src/service.js create mode 100644 services/volcengine__waf/src/volcengine-waf.js create mode 100644 services/volcengine__waf/test/volcengine-waf.test.js diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 4124b804..56ac1b4e 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -229,6 +229,10 @@ const services = { entryFile: "../volcengine__cloud-firewall/bin/volcengine-cloud-firewall.js", serviceModule: "../volcengine__cloud-firewall/src/service.js", }, + "volcengine-waf": { + entryFile: "../volcengine__waf/bin/volcengine-waf.js", + serviceModule: "../volcengine__waf/src/service.js", + }, "wangsu-label-ip": { entryFile: "../wangsu__label-ip/bin/wangsu-label-ip.js", serviceModule: "../wangsu__label-ip/src/service.js", diff --git a/services/bin/volcengine-waf.js b/services/bin/volcengine-waf.js new file mode 100755 index 00000000..efc7c550 --- /dev/null +++ b/services/bin/volcengine-waf.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../volcengine__waf/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../volcengine__waf/bin/volcengine-waf.js", import.meta.url)), +}); diff --git a/services/package.json b/services/package.json index 2fbea088..c8b6cedf 100644 --- a/services/package.json +++ b/services/package.json @@ -61,6 +61,7 @@ "topsec-waf-v3-2294-20238": "bin/topsec-waf-v3-2294-20238.js", "venus-ads-v3-6": "bin/venus-ads-v3-6.js", "volcengine-cloud-firewall": "bin/volcengine-cloud-firewall.js", + "volcengine-waf": "bin/volcengine-waf.js", "wangsu-label-ip": "bin/wangsu-label-ip.js", "wd-k01": "bin/wd-k01.js", "tencent-bh": "bin/tencent-bh.js", @@ -125,6 +126,7 @@ "bin/topsec-waf-v3-2294-20238.js", "bin/venus-ads-v3-6.js", "bin/volcengine-cloud-firewall.js", + "bin/volcengine-waf.js", "bin/wd-k01.js", "bin/tencent-bh.js", "bin/alibaba-sas.js", @@ -187,6 +189,7 @@ "topsec__waf_v3-2294-20238", "venus__ads_v3-6", "volcengine__cloud-firewall", + "volcengine__waf", "wd__k01", "tencent__bh", "alibaba__sas", diff --git a/services/volcengine__waf/README.md b/services/volcengine__waf/README.md new file mode 100644 index 00000000..b5ad96ea --- /dev/null +++ b/services/volcengine__waf/README.md @@ -0,0 +1,34 @@ +# Volcengine WAF OctoBus Service + +## Supported RPCs + +This service package exposes read-only Volcengine WAF query APIs through OctoBus RPCs. + +| RPC | Volcengine Action | Purpose | +|---|---|---| +| `ListDomain` | `ListDomain` | Query protected domains. | +| `ListLoadBalancer` | `ListLoadBalancer` | Query load balancer access resources. | +| `QueryProtectionOverviewLb` | `QueryProtectionOverviewLb` | Query protection overview metrics. | +| `QueryAttackSecurityEvent` | `QueryAttackSecurityEvent` | Query attack security events. | +| `QueryAttackAnalysisWithRuleAggLb` | `QueryAttackAnalysisWithRuleAggLb` | Query attack analysis grouped by protection rule. | +| `QueryFlowOverviewLb` | `QueryFlowOverviewLb` | Query traffic overview metrics. | +| `ListCustomPage` | `ListCustomPage` | Query custom response pages. | +| `GetTLSConfig` | `GetTLSConfig` | Query log configuration. | +| `GetVulnerabilityConfig` | `GetVulnerabilityConfig` | Query vulnerability protection configuration. | +| `ListVulnerabilityRule` | `ListVulnerabilityRule` | Query vulnerability protection rules. | +| `ListCustomBotConfig` | `ListCustomBotConfig` | Query custom bot protection configuration. | +| `ListWafServiceCertificate` | `ListWafServiceCertificate` | Query WAF service certificates. | +| `ListBlockRule` | `ListBlockRule` | Query block rules. | +| `ListAllowRule` | `ListAllowRule` | Query allow rules. | + +## Configuration + +Use the service `config.schema.json` and `secret.schema.json` files for required endpoint, region, and credential fields. Secrets must be supplied at runtime and are not stored in this package. + +## Tests + +Run the service test with Node.js from the repository or service worktree: + +```bash +node --test test/*.test.js +``` diff --git a/services/volcengine__waf/bin/volcengine-waf.js b/services/volcengine__waf/bin/volcengine-waf.js new file mode 100755 index 00000000..8e0066ef --- /dev/null +++ b/services/volcengine__waf/bin/volcengine-waf.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/volcengine__waf/config.schema.json b/services/volcengine__waf/config.schema.json new file mode 100644 index 00000000..0eb7dda1 --- /dev/null +++ b/services/volcengine__waf/config.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "region": { + "type": "string", + "default": "cn-beijing", + "description": "Volcengine region used for signing and regional WAF endpoints." + }, + "endpoint": { + "type": "string", + "description": "Optional Volcengine WAF endpoint override." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "HTTP timeout in milliseconds." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional additional HTTP headers. Signing headers are set by the service." + } + } +} diff --git a/services/volcengine__waf/package.json b/services/volcengine__waf/package.json new file mode 100644 index 00000000..779da9f0 --- /dev/null +++ b/services/volcengine__waf/package.json @@ -0,0 +1,15 @@ +{ + "name": "@chaitin-ai/octobus-service-volcengine-waf", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "volcengine-waf": "bin/volcengine-waf.js" + }, + "scripts": { + "test": "node --test test/*.test.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0" + } +} diff --git a/services/volcengine__waf/proto/volcengine_waf.proto b/services/volcengine__waf/proto/volcengine_waf.proto new file mode 100644 index 00000000..992d5fd3 --- /dev/null +++ b/services/volcengine__waf/proto/volcengine_waf.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package Volcengine_WAF; + +import "google/protobuf/struct.proto"; + +option go_package = "miner/grpc-service/Volcengine_WAF"; + +service Volcengine_WAF { + rpc ListDomain(VolcengineRequest) returns (VolcengineResponse) {} + rpc ListLoadBalancer(VolcengineRequest) returns (VolcengineResponse) {} + rpc QueryAttackAnalysisWithRuleAggLb(VolcengineRequest) returns (VolcengineResponse) {} + rpc QueryAttackSecurityEvent(VolcengineRequest) returns (VolcengineResponse) {} + rpc QueryFlowOverviewLb(VolcengineRequest) returns (VolcengineResponse) {} + rpc QueryProtectionOverviewLb(VolcengineRequest) returns (VolcengineResponse) {} + rpc ListCustomPage(VolcengineRequest) returns (VolcengineResponse) {} + rpc ListVulnerabilityRule(VolcengineRequest) returns (VolcengineResponse) {} + rpc GetVulnerabilityConfig(VolcengineRequest) returns (VolcengineResponse) {} + rpc ListCustomBotConfig(VolcengineRequest) returns (VolcengineResponse) {} + rpc GetTLSConfig(VolcengineRequest) returns (VolcengineResponse) {} + rpc ListWafServiceCertificate(VolcengineRequest) returns (VolcengineResponse) {} + rpc ListBlockRule(VolcengineRequest) returns (VolcengineResponse) {} + rpc ListAllowRule(VolcengineRequest) returns (VolcengineResponse) {} +} + +message VolcengineRequest { + google.protobuf.Struct payload = 1; +} + +message VolcengineResponse { + google.protobuf.Value response = 1; +} diff --git a/services/volcengine__waf/secret.schema.json b/services/volcengine__waf/secret.schema.json new file mode 100644 index 00000000..83d930bb --- /dev/null +++ b/services/volcengine__waf/secret.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "accessKeyId": { + "type": "string", + "description": "Volcengine AccessKeyID." + }, + "access_key_id": { + "type": "string", + "description": "Alias for accessKeyId." + }, + "secretAccessKey": { + "type": "string", + "description": "Volcengine SecretAccessKey." + }, + "secret_access_key": { + "type": "string", + "description": "Alias for secretAccessKey." + }, + "sessionToken": { + "type": "string", + "description": "Optional temporary security token." + }, + "session_token": { + "type": "string", + "description": "Alias for sessionToken." + } + } +} diff --git a/services/volcengine__waf/service.json b/services/volcengine__waf/service.json new file mode 100644 index 00000000..b0dc8219 --- /dev/null +++ b/services/volcengine__waf/service.json @@ -0,0 +1,81 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "volcengine-waf", + "displayName": "Volcengine WAF", + "description": "OctoBus package for Volcengine Web Application Firewall read-only query APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/volcengine_waf.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Volcengine_WAF.Volcengine_WAF/ListDomain": { + "name": "list-domain", + "description": "Query Volcengine WAF protected domains." + }, + "Volcengine_WAF.Volcengine_WAF/ListLoadBalancer": { + "name": "list-load-balancer", + "description": "Query Volcengine WAF load balancer access resources." + }, + "Volcengine_WAF.Volcengine_WAF/QueryProtectionOverviewLb": { + "name": "query-protection-overview-lb", + "description": "Query Volcengine WAF protection overview metrics." + }, + "Volcengine_WAF.Volcengine_WAF/QueryAttackAnalysisWithRuleAggLb": { + "name": "query-attack-analysis-with-rule-agg-lb", + "description": "Query Volcengine WAF attack analysis grouped by protection rule." + }, + "Volcengine_WAF.Volcengine_WAF/QueryAttackSecurityEvent": { + "name": "query-attack-security-event", + "description": "Query Volcengine WAF attack security events." + }, + "Volcengine_WAF.Volcengine_WAF/QueryFlowOverviewLb": { + "name": "query-flow-overview-lb", + "description": "Query Volcengine WAF traffic overview metrics." + }, + "Volcengine_WAF.Volcengine_WAF/ListCustomPage": { + "name": "list-custom-page", + "description": "Query Volcengine WAF custom response pages." + }, + "Volcengine_WAF.Volcengine_WAF/GetTLSConfig": { + "name": "get-tls-config", + "description": "Query Volcengine WAF log configuration." + }, + "Volcengine_WAF.Volcengine_WAF/GetVulnerabilityConfig": { + "name": "get-vulnerability-config", + "description": "Query Volcengine WAF vulnerability protection configuration." + }, + "Volcengine_WAF.Volcengine_WAF/ListVulnerabilityRule": { + "name": "list-vulnerability-rule", + "description": "Query Volcengine WAF vulnerability protection rules." + }, + "Volcengine_WAF.Volcengine_WAF/ListCustomBotConfig": { + "name": "list-custom-bot-config", + "description": "Query Volcengine WAF custom bot protection configuration." + }, + "Volcengine_WAF.Volcengine_WAF/ListWafServiceCertificate": { + "name": "list-waf-service-certificate", + "description": "Query Volcengine WAF service certificates." + }, + "Volcengine_WAF.Volcengine_WAF/ListBlockRule": { + "name": "list-block-rule", + "description": "Query Volcengine WAF block rules." + }, + "Volcengine_WAF.Volcengine_WAF/ListAllowRule": { + "name": "list-allow-rule", + "description": "Query Volcengine WAF allow rules." + } + } + } + } +} diff --git a/services/volcengine__waf/src/service.js b/services/volcengine__waf/src/service.js new file mode 100644 index 00000000..39bd9eac --- /dev/null +++ b/services/volcengine__waf/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './volcengine-waf.js'; + +export { handlers } from './volcengine-waf.js'; + +export const service = defineService({ handlers }); diff --git a/services/volcengine__waf/src/volcengine-waf.js b/services/volcengine__waf/src/volcengine-waf.js new file mode 100644 index 00000000..7ec5c1c3 --- /dev/null +++ b/services/volcengine__waf/src/volcengine-waf.js @@ -0,0 +1,394 @@ +import crypto from 'node:crypto'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +export const SERVICE_PACKAGE = 'Volcengine_WAF.Volcengine_WAF'; +export const DEFAULT_REGION = 'cn-beijing'; +export const DEFAULT_TIMEOUT_MS = 5000; +export const SIGNING_ALGORITHM = 'HMAC-SHA256'; +export const SIGNING_TERMINATOR = 'request'; + +export const SERVICE_DEFINITIONS = { + waf: { + defaultVersion: '2023-12-25', + endpoint: () => 'https://waf.volcengineapi.com', + }, +}; + +export const READ_ONLY_ACTIONS = [ + { methodName: 'ListDomain', action: 'ListDomain' }, + { methodName: 'ListLoadBalancer', action: 'ListLoadBalancer' }, + { methodName: 'QueryAttackAnalysisWithRuleAggLb', action: 'QueryAttackAnalysisWithRuleAggLb' }, + { methodName: 'QueryAttackSecurityEvent', action: 'QueryAttackSecurityEvent' }, + { methodName: 'QueryFlowOverviewLb', action: 'QueryFlowOverviewLb' }, + { methodName: 'QueryProtectionOverviewLb', action: 'QueryProtectionOverviewLb' }, + { methodName: 'ListCustomPage', action: 'ListCustomPage' }, + { methodName: 'ListVulnerabilityRule', action: 'ListVulnerabilityRule' }, + { methodName: 'GetVulnerabilityConfig', action: 'GetVulnerabilityConfig' }, + { methodName: 'ListCustomBotConfig', action: 'ListCustomBotConfig' }, + { methodName: 'GetTLSConfig', action: 'GetTLSConfig' }, + { methodName: 'ListWafServiceCertificate', action: 'ListWafServiceCertificate' }, + { methodName: 'ListBlockRule', action: 'ListBlockRule' }, + { methodName: 'ListAllowRule', action: 'ListAllowRule' }, +]; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); +const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null); + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +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 optionalUint32 = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return undefined; + const num = Number(raw); + if (!Number.isFinite(num) || num <= 0) return undefined; + return Math.trunc(num); +}; + +const normalizeStructValue = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'object') return value; + if (hasOwn(value, 'stringValue')) return value.stringValue; + if (hasOwn(value, 'numberValue')) return value.numberValue; + if (hasOwn(value, 'boolValue')) return value.boolValue; + if (hasOwn(value, 'nullValue')) return null; + if (hasOwn(value, 'listValue') && Array.isArray(value.listValue?.values)) return value.listValue.values.map((item) => normalizeStructValue(item)); + if (hasOwn(value, 'structValue') && value.structValue?.fields) return normalizeStruct(value.structValue); + if (hasOwn(value, 'fields')) return normalizeStruct(value); + if (hasOwn(value, 'value')) return normalizeStructValue(value.value); + if (Array.isArray(value)) return value.map((item) => normalizeStructValue(item)); + + const result = {}; + for (const [key, innerValue] of Object.entries(value)) result[key] = normalizeStructValue(innerValue); + return result; +}; + +const normalizeStruct = (value) => { + if (!value) return {}; + if (hasOwn(value, 'fields') && value.fields && typeof value.fields === 'object') { + const result = {}; + for (const [key, innerValue] of Object.entries(value.fields)) result[key] = normalizeStructValue(innerValue); + return result; + } + if (typeof value === 'object' && !Array.isArray(value)) return normalizeStructValue(value); + return {}; +}; + +const toValue = (value) => { + if (value === undefined || value === null) return { nullValue: 'NULL_VALUE' }; + if (typeof value === 'string') return { stringValue: value }; + if (typeof value === 'number') return { numberValue: value }; + if (typeof value === 'boolean') return { boolValue: value }; + if (Array.isArray(value)) return { listValue: { values: value.map(toValue) } }; + if (typeof value === 'object') { + const fields = {}; + for (const [key, innerValue] of Object.entries(value)) fields[key] = toValue(innerValue); + return { structValue: { fields } }; + } + return { stringValue: String(value) }; +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), +}); + +const isSdkHandlerContext = (value) => value && typeof value === 'object' + && Object.prototype.hasOwnProperty.call(value, 'request') + && ( + Object.prototype.hasOwnProperty.call(value, 'config') + || Object.prototype.hasOwnProperty.call(value, 'secret') + || Object.prototype.hasOwnProperty.call(value, 'bindings') + || Object.prototype.hasOwnProperty.call(value, 'limits') + || Object.prototype.hasOwnProperty.call(value, 'meta') + ); + +const handlerRequest = (req, ctx) => (ctx === undefined && isSdkHandlerContext(req) ? (req.request ?? {}) : (req ?? {})); +const handlerContext = (req, ctx) => (ctx === undefined && isSdkHandlerContext(req) ? req : (ctx ?? {})); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: mergedBindings(ctx), + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, +}); + +const resolveTimeoutMs = (ctx = {}) => optionalUint32(ctx.bindings?.timeoutMs) ?? optionalUint32(ctx.limits?.timeoutMs) ?? DEFAULT_TIMEOUT_MS; + +const validateBindings = (bindings = {}) => { + const accessKeyId = toTrimmedString(firstDefined(bindings.accessKeyId, bindings.access_key_id, bindings.AccessKeyID)); + const secretAccessKey = toTrimmedString(firstDefined(bindings.secretAccessKey, bindings.secret_access_key, bindings.SecretAccessKey)); + if (!accessKeyId) throw errorWithCode('FAILED_PRECONDITION', 'secret "accessKeyId" is required but not configured'); + if (!secretAccessKey) throw errorWithCode('FAILED_PRECONDITION', 'secret "secretAccessKey" is required but not configured'); + + return { + accessKeyId, + secretAccessKey, + sessionToken: toTrimmedString(firstDefined(bindings.sessionToken, bindings.session_token)), + region: toTrimmedString(bindings.region) || DEFAULT_REGION, + endpoint: toTrimmedString(firstDefined(bindings.endpoint, bindings.host, bindings.baseUrl)), + }; +}; + +const uriEscape = (value) => encodeURIComponent(String(value)) + .replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`); + +const assertQueryParamValue = (key, value) => { + if (value !== null && typeof value === 'object') { + throw errorWithCode('INVALID_ARGUMENT', `nested object not supported in query params for key "${key}"`); + } +}; + +const queryParamsToString = (params = {}) => Object.keys(params) + .filter((key) => params[key] !== undefined && params[key] !== null) + .sort() + .flatMap((key) => { + const escapedKey = uriEscape(key); + const value = params[key]; + if (Array.isArray(value)) return value.map((item) => { + assertQueryParamValue(key, item); + return `${escapedKey}=${uriEscape(item)}`; + }).sort(); + assertQueryParamValue(key, value); + return [`${escapedKey}=${uriEscape(value)}`]; + }) + .join('&'); + +const hashSHA256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); +const hmacSHA256 = (key, value, encoding) => crypto.createHmac('sha256', key).update(value).digest(encoding); + +const iso8601Basic = (date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[:\-]|\.\d{3}/g, ''); + +const getSigningKey = (secretAccessKey, date, region, serviceCode) => { + const kDate = hmacSHA256(secretAccessKey, date); + const kRegion = hmacSHA256(kDate, region); + const kService = hmacSHA256(kRegion, serviceCode); + return hmacSHA256(kService, SIGNING_TERMINATOR); +}; + +const canonicalHeaderValues = (value) => String(value).replace(/\s+/g, ' ').trim(); + +const signRequest = ({ method, url, query, bodyText, accessKeyId, secretAccessKey, sessionToken, region, serviceCode, date = new Date() }) => { + const datetime = iso8601Basic(date); + const dateStamp = datetime.slice(0, 8); + const bodyHash = hashSHA256(bodyText || ''); + const headers = { + 'Content-Type': 'application/json', + Host: url.host, + 'X-Date': datetime, + 'X-Content-Sha256': bodyHash, + }; + if (sessionToken) headers['X-Security-Token'] = sessionToken; + + const signedHeaderKeys = Object.keys(headers).map((key) => key.toLowerCase()).sort(); + const canonicalHeaders = signedHeaderKeys.map((key) => { + const originalKey = Object.keys(headers).find((headerKey) => headerKey.toLowerCase() === key); + return `${key}:${canonicalHeaderValues(headers[originalKey])}`; + }).join('\n'); + const signedHeaders = signedHeaderKeys.join(';'); + const canonicalRequest = [ + method.toUpperCase(), + url.pathname || '/', + queryParamsToString(query), + `${canonicalHeaders}\n`, + signedHeaders, + bodyHash, + ].join('\n'); + const credentialScope = `${dateStamp}/${region}/${serviceCode}/${SIGNING_TERMINATOR}`; + const stringToSign = [ + SIGNING_ALGORITHM, + datetime, + credentialScope, + hashSHA256(canonicalRequest), + ].join('\n'); + const signingKey = getSigningKey(secretAccessKey, dateStamp, region, serviceCode); + const signature = hmacSHA256(signingKey, stringToSign, 'hex'); + const authorization = `${SIGNING_ALGORITHM} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + + return { + headers: { + ...headers, + Authorization: authorization, + }, + canonicalRequest, + stringToSign, + signature, + }; +}; + +const endpointFor = ({ serviceCode, region, endpoint }) => { + const service = SERVICE_DEFINITIONS[serviceCode]; + if (!service) throw errorWithCode('INVALID_ARGUMENT', `unsupported Volcengine WAF service_code "${serviceCode}"`); + const raw = endpoint || service.endpoint(region); + try { + const url = new URL(raw); + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('bad protocol'); + return url; + } catch { + throw errorWithCode('FAILED_PRECONDITION', 'endpoint must be a valid http or https URL'); + } +}; + +const validateActionName = (action) => { + const value = toTrimmedString(action); + if (!value) throw errorWithCode('INVALID_ARGUMENT', 'action must be a non-empty string'); + if (!READ_ONLY_ACTIONS.some((entry) => entry.action === value)) { + throw errorWithCode('INVALID_ARGUMENT', `Volcengine WAF action "${value}" is not supported by this service`); + } + return value; +}; + +const validateMethod = (method) => { + const value = (toTrimmedString(method) || 'POST').toUpperCase(); + if (!['GET', 'POST'].includes(value)) throw errorWithCode('INVALID_ARGUMENT', 'method must be GET or POST'); + return value; +}; + +const validateActionSpec = (spec = {}) => { + const serviceCode = toTrimmedString(firstDefined(spec.serviceCode, spec.service_code)) || 'waf'; + const service = SERVICE_DEFINITIONS[serviceCode]; + if (!service) throw errorWithCode('INVALID_ARGUMENT', `unsupported Volcengine WAF service_code "${serviceCode}"`); + const action = validateActionName(spec.action); + const version = toTrimmedString(spec.version) || service.defaultVersion; + const method = validateMethod(firstDefined(spec.httpMethod, spec.method)); + return { action, serviceCode, version, httpMethod: method, endpoint: toTrimmedString(spec.endpoint) }; +}; + +const buildHeaders = (ctx, signedHeaders) => ({ + ...(ctx.bindings?.headers && typeof ctx.bindings.headers === 'object' ? ctx.bindings.headers : {}), + ...signedHeaders, +}); + +const errorFromVolcengineResponse = (body) => { + const err = body?.ResponseMetadata?.Error || body?.Error; + if (!err) return null; + const code = String(err.Code || err.CodeN || ''); + const message = err.Message || 'Volcengine API returned an error'; + if (/Auth|Unauthorized|Forbidden|Denied|InvalidAccessKey|Signature/i.test(code)) return errorWithCode('PERMISSION_DENIED', `${code}: ${message}`); + if (/InvalidParameter|Missing|Parameter|NotFound|Unsupported/i.test(code)) return errorWithCode('INVALID_ARGUMENT', `${code}: ${message}`); + return errorWithCode('UNKNOWN', `${code || 'VolcengineError'}: ${message}`); +}; + +const parseVolcengineResponse = async (res) => { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + throw errorWithCode('UNKNOWN', `Volcengine API returned non-JSON response: ${text.slice(0, 200)}`); + } +}; + +const payloadFromRequest = (req = {}) => normalizeStruct(req.payload ?? {}); + +const invokeVolcengine = async (spec, payload, ctx = {}) => { + const actionSpec = validateActionSpec(spec); + const callCtx = resolveCallContext(ctx); + const bindings = validateBindings(callCtx.bindings); + const endpoint = endpointFor({ serviceCode: actionSpec.serviceCode, region: bindings.region, endpoint: actionSpec.endpoint || bindings.endpoint }); + const query = { Action: actionSpec.action, Version: actionSpec.version }; + let bodyText = ''; + if (actionSpec.httpMethod === 'GET') { + Object.assign(query, payload ?? {}); + } else { + bodyText = JSON.stringify(payload ?? {}); + } + endpoint.search = queryParamsToString(query); + + const date = callCtx.meta?.date instanceof Date + ? callCtx.meta.date + : (toTrimmedString(callCtx.meta?.date) ? new Date(toTrimmedString(callCtx.meta.date)) : new Date()); + const signed = signRequest({ + method: actionSpec.httpMethod, + url: endpoint, + query, + bodyText, + accessKeyId: bindings.accessKeyId, + secretAccessKey: bindings.secretAccessKey, + sessionToken: bindings.sessionToken, + region: bindings.region, + serviceCode: actionSpec.serviceCode, + date, + }); + const headers = buildHeaders(callCtx, signed.headers); + const timeoutMs = resolveTimeoutMs(callCtx); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + let res; + let body; + try { + res = await fetch(endpoint.toString(), { + method: actionSpec.httpMethod, + headers, + body: actionSpec.httpMethod === 'GET' ? undefined : bodyText, + signal: controller.signal, + }); + body = await parseVolcengineResponse(res); + } catch (err) { + if (err.legacyCode) throw err; + if (err.name === 'AbortError' || err.name === 'TimeoutError') { + throw errorWithCode('DEADLINE_EXCEEDED', `Volcengine WAF API request timed out after ${timeoutMs}ms`); + } + throw errorWithCode('UNAVAILABLE', `failed to call Volcengine WAF API: ${err.message}`); + } finally { + clearTimeout(timeout); + } + if (res.status < 200 || res.status >= 300) { + throw errorWithCode('UNAVAILABLE', `Volcengine WAF API HTTP ${res.status}: ${JSON.stringify(body).slice(0, 500)}`); + } + const volcError = errorFromVolcengineResponse(body); + if (volcError) throw volcError; + return { response: toValue(body) }; +}; + +const buildActionHandler = (spec) => async (req, ctx) => invokeVolcengine( + spec, + payloadFromRequest(handlerRequest(req, ctx)), + handlerContext(req, ctx), +); + +export const handlers = Object.fromEntries([ + ...READ_ONLY_ACTIONS.map((entry) => [`${SERVICE_PACKAGE}/${entry.methodName}`, buildActionHandler(entry)]), +]); + +export const rpcdef = () => Object.fromEntries([ + ...READ_ONLY_ACTIONS.map((entry) => [`/${SERVICE_PACKAGE}/${entry.methodName}`, handlers[`${SERVICE_PACKAGE}/${entry.methodName}`]]), +]); + +export const _test = { + normalizeStruct, + normalizeStructValue, + toValue, + validateBindings, + validateActionName, + validateActionSpec, + queryParamsToString, + signRequest, + invokeVolcengine, +}; diff --git a/services/volcengine__waf/test/volcengine-waf.test.js b/services/volcengine__waf/test/volcengine-waf.test.js new file mode 100644 index 00000000..49093f6e --- /dev/null +++ b/services/volcengine__waf/test/volcengine-waf.test.js @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { + READ_ONLY_ACTIONS, + SERVICE_PACKAGE, + _test, + handlers, + rpcdef, +} from '../src/volcengine-waf.js'; +import { service } from '../src/service.js'; + +const originalFetch = globalThis.fetch; + +const response = (status, body) => ({ + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), +}); + +const setFetch = (impl) => { + globalThis.fetch = impl; +}; + +const buildCtx = (overrides = {}) => ({ + config: { + region: 'cn-beijing', + ...(overrides.config || {}), + }, + secret: { + accessKeyId: 'AKLTEXAMPLE', + secretAccessKey: 'SECRETEXAMPLE', + ...(overrides.secret || {}), + }, + bindings: { + headers: { 'X-Custom': 'trace' }, + ...(overrides.bindings || {}), + }, + limits: { timeoutMs: 9000, ...(overrides.limits || {}) }, + meta: { date: new Date('2024-01-16T08:00:00Z'), ...(overrides.meta || {}) }, +}); + +const expectGrpcError = async (fn, legacyCode, checker = () => {}) => { + let caught; + try { + await fn(); + } catch (err) { + caught = err; + } + assert.ok(caught, 'expected function to reject'); + assert.ok(caught instanceof GrpcError); + assert.equal(caught.legacyCode, legacyCode); + assert.equal(caught.code, ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED, + UNKNOWN: grpcStatus.UNKNOWN, + })[legacyCode]); + checker(caught); +}; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test('service exports handlers and rpcdef paths', () => { + assert.equal(typeof service, 'object'); + for (const entry of READ_ONLY_ACTIONS) { + assert.equal(typeof handlers[`${SERVICE_PACKAGE}/${entry.methodName}`], 'function'); + assert.equal(typeof rpcdef()[`/${SERVICE_PACKAGE}/${entry.methodName}`], 'function'); + } +}); + +test('validates required credentials and supported actions', () => { + assert.equal(_test.validateBindings({ + AccessKeyID: 'id', + SecretAccessKey: 'key', + region: 'cn-shanghai', + }).region, 'cn-shanghai'); + + assert.throws(() => _test.validateBindings({ secretAccessKey: 'key' }), /accessKeyId/); + assert.throws(() => _test.validateBindings({ accessKeyId: 'id' }), /secretAccessKey/); + assert.equal(_test.validateActionName('ListDomain'), 'ListDomain'); + assert.throws(() => _test.validateActionName('UpdateInstance'), /not supported/); + assert.throws(() => _test.validateActionSpec({ action: 'ListDomain', serviceCode: 'ecs' }), /unsupported/); +}); + +test('escapes Volcengine query params and rejects nested GET query values', () => { + assert.equal(_test.queryParamsToString({ Special: "!'()*", Text: 'hello world', CN: '中文' }), 'CN=%E4%B8%AD%E6%96%87&Special=%21%27%28%29%2A&Text=hello%20world'); + assert.throws(() => _test.queryParamsToString({ Filter: { Name: 'status' } }), /nested object/); + assert.throws(() => _test.queryParamsToString({ Filter: ['ok', { Name: 'status' }] }), /nested object/); +}); + +test('normalizes protobuf Struct payloads', () => { + assert.deepEqual(_test.normalizeStruct({ + fields: { + BeginTime: { numberValue: 1712642400 }, + IpList: { listValue: { values: [{ stringValue: '192.0.2.1' }, { nullValue: 'NULL_VALUE' }] } }, + Exact: { boolValue: true }, + }, + }), { + BeginTime: 1712642400, + IpList: ['192.0.2.1', null], + Exact: true, + }); +}); + +test('signs and sends POST WAF list-domain request with body payload', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init, body: JSON.parse(init.body) }; + return response(200, { + ResponseMetadata: { + RequestId: 'req-1', + Action: 'ListDomain', + Version: '2023-12-25', + Service: 'waf', + Region: 'cn-beijing', + }, + Result: { List: [{ Host: 'example.com' }], Total: 1 }, + }); + }); + + const result = await handlers[`${SERVICE_PACKAGE}/ListDomain`]({ + payload: { fields: { Page: { numberValue: 1 }, PageSize: { numberValue: 10 } } }, + }, buildCtx({ bindings: { timeoutMs: 25 } })); + + const url = new URL(captured.url); + assert.equal(url.origin, 'https://waf.volcengineapi.com'); + assert.equal(url.searchParams.get('Action'), 'ListDomain'); + assert.equal(url.searchParams.get('Version'), '2023-12-25'); + assert.deepEqual(captured.body, { Page: 1, PageSize: 10 }); + assert.equal(captured.init.method, 'POST'); + assert.equal(Object.hasOwn(captured.init, 'timeoutMs'), false); + assert.equal(typeof captured.init.signal?.aborted, 'boolean'); + assert.equal(captured.init.headers['X-Custom'], 'trace'); + assert.equal(captured.init.headers['Content-Type'], 'application/json'); + assert.equal(captured.init.headers.Host, 'waf.volcengineapi.com'); + assert.equal(captured.init.headers['X-Date'], '20240116T080000Z'); + assert.match(captured.init.headers['X-Content-Sha256'], /^[0-9a-f]{64}$/); + assert.match( + captured.init.headers.Authorization, + /^HMAC-SHA256 Credential=AKLTEXAMPLE\/20240116\/cn-beijing\/waf\/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature=[0-9a-f]{64}$/, + ); + assert.equal(result.response.structValue.fields.Result.structValue.fields.Total.numberValue, 1); +}); + +test('maps Volcengine and transport errors', async () => { + setFetch(async () => response(200, { ResponseMetadata: { Error: { Code: 'InvalidAccessKey', Message: 'denied' } } })); + await expectGrpcError( + () => handlers[`${SERVICE_PACKAGE}/ListDomain`]({}, buildCtx()), + 'PERMISSION_DENIED', + (err) => assert.match(err.message, /InvalidAccessKey/), + ); + + setFetch(async () => response(200, { ResponseMetadata: { Error: { Code: 'MissingParameter', Message: 'missing' } } })); + await expectGrpcError( + () => handlers[`${SERVICE_PACKAGE}/ListDomain`]({}, buildCtx()), + 'INVALID_ARGUMENT', + (err) => assert.match(err.message, /MissingParameter/), + ); + + setFetch(async () => response(503, { ResponseMetadata: { Error: { Code: 'InternalError', Message: 'busy' } } })); + await expectGrpcError( + () => handlers[`${SERVICE_PACKAGE}/ListDomain`]({}, buildCtx()), + 'UNAVAILABLE', + (err) => assert.match(err.message, /HTTP 503/), + ); + + setFetch(async () => response(200, 'not json')); + await expectGrpcError( + () => handlers[`${SERVICE_PACKAGE}/ListDomain`]({}, buildCtx()), + 'UNKNOWN', + (err) => assert.match(err.message, /non-JSON/), + ); + + setFetch(async () => { + const err = new Error('timeout'); + err.name = 'TimeoutError'; + throw err; + }); + await expectGrpcError( + () => handlers[`${SERVICE_PACKAGE}/ListDomain`]({}, buildCtx({ limits: { timeoutMs: 25 } })), + 'DEADLINE_EXCEEDED', + (err) => assert.match(err.message, /timed out after 25ms/), + ); + + setFetch(async (_url, init) => ({ + status: 200, + text: () => new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => { + const err = new Error('body stream timeout'); + err.name = 'AbortError'; + reject(err); + }, { once: true }); + setTimeout(() => reject(new Error('signal was not aborted')), 100); + }), + })); + await expectGrpcError( + () => handlers[`${SERVICE_PACKAGE}/ListDomain`]({}, buildCtx({ limits: { timeoutMs: 5 } })), + 'DEADLINE_EXCEEDED', + (err) => assert.match(err.message, /timed out after 5ms/), + ); +}); + +test('handler accepts OctoBus SDK single-argument context', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url: String(url), init }; + return response(200, { ResponseMetadata: { RequestId: 'req-sdk' }, Result: { Total: 0 } }); + }); + + await handlers[`${SERVICE_PACKAGE}/ListDomain`]({ + request: { + payload: { fields: { Page: { numberValue: 1 }, PageSize: { numberValue: 5 } } }, + }, + config: { region: 'cn-shanghai' }, + secret: { + accessKeyId: 'SDKID', + secretAccessKey: 'SDKKEY', + }, + limits: { timeoutMs: 10_000 }, + meta: { date: new Date('2024-01-16T08:00:00Z') }, + }); + + const url = new URL(captured.url); + assert.equal(url.searchParams.get('Action'), 'ListDomain'); + assert.match(captured.init.headers.Authorization, /^HMAC-SHA256 Credential=SDKID\//); + assert.match(captured.init.headers.Authorization, /\/cn-shanghai\/waf\/request,/); +}); From 6290a929203ccb214f98cf68dc9b17431141723c Mon Sep 17 00:00:00 2001 From: flynnnnnn9 <82627152+flynnnnnn9@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:56:24 +0800 Subject: [PATCH 2/2] Fix Volcengine WAF review findings --- services/volcengine__waf/README.md | 2 +- services/volcengine__waf/service.json | 2 +- .../volcengine__waf/src/volcengine-waf.js | 29 ++++++++++++++----- .../test/volcengine-waf.test.js | 8 +++++ 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/services/volcengine__waf/README.md b/services/volcengine__waf/README.md index b5ad96ea..1aab8768 100644 --- a/services/volcengine__waf/README.md +++ b/services/volcengine__waf/README.md @@ -13,7 +13,7 @@ This service package exposes read-only Volcengine WAF query APIs through OctoBus | `QueryAttackAnalysisWithRuleAggLb` | `QueryAttackAnalysisWithRuleAggLb` | Query attack analysis grouped by protection rule. | | `QueryFlowOverviewLb` | `QueryFlowOverviewLb` | Query traffic overview metrics. | | `ListCustomPage` | `ListCustomPage` | Query custom response pages. | -| `GetTLSConfig` | `GetTLSConfig` | Query log configuration. | +| `GetTLSConfig` | `GetTLSConfig` | Query TLS configuration. | | `GetVulnerabilityConfig` | `GetVulnerabilityConfig` | Query vulnerability protection configuration. | | `ListVulnerabilityRule` | `ListVulnerabilityRule` | Query vulnerability protection rules. | | `ListCustomBotConfig` | `ListCustomBotConfig` | Query custom bot protection configuration. | diff --git a/services/volcengine__waf/service.json b/services/volcengine__waf/service.json index b0dc8219..6cb1a3c5 100644 --- a/services/volcengine__waf/service.json +++ b/services/volcengine__waf/service.json @@ -49,7 +49,7 @@ }, "Volcengine_WAF.Volcengine_WAF/GetTLSConfig": { "name": "get-tls-config", - "description": "Query Volcengine WAF log configuration." + "description": "Query Volcengine WAF TLS configuration." }, "Volcengine_WAF.Volcengine_WAF/GetVulnerabilityConfig": { "name": "get-vulnerability-config", diff --git a/services/volcengine__waf/src/volcengine-waf.js b/services/volcengine__waf/src/volcengine-waf.js index 7ec5c1c3..6b8371c4 100644 --- a/services/volcengine__waf/src/volcengine-waf.js +++ b/services/volcengine__waf/src/volcengine-waf.js @@ -171,10 +171,15 @@ const queryParamsToString = (params = {}) => Object.keys(params) .flatMap((key) => { const escapedKey = uriEscape(key); const value = params[key]; - if (Array.isArray(value)) return value.map((item) => { - assertQueryParamValue(key, item); - return `${escapedKey}=${uriEscape(item)}`; - }).sort(); + if (Array.isArray(value)) { + return value + .filter((item) => item !== undefined && item !== null) + .map((item) => { + assertQueryParamValue(key, item); + return `${escapedKey}=${uriEscape(item)}`; + }) + .sort(); + } assertQueryParamValue(key, value); return [`${escapedKey}=${uriEscape(value)}`]; }) @@ -307,6 +312,17 @@ const parseVolcengineResponse = async (res) => { const payloadFromRequest = (req = {}) => normalizeStruct(req.payload ?? {}); +const resolveSigningDate = (meta = {}) => { + const raw = meta.date; + const date = raw instanceof Date + ? raw + : (toTrimmedString(raw) ? new Date(toTrimmedString(raw)) : new Date()); + if (Number.isNaN(date.getTime())) { + throw errorWithCode('INVALID_ARGUMENT', 'meta.date must be a valid date'); + } + return date; +}; + const invokeVolcengine = async (spec, payload, ctx = {}) => { const actionSpec = validateActionSpec(spec); const callCtx = resolveCallContext(ctx); @@ -321,9 +337,7 @@ const invokeVolcengine = async (spec, payload, ctx = {}) => { } endpoint.search = queryParamsToString(query); - const date = callCtx.meta?.date instanceof Date - ? callCtx.meta.date - : (toTrimmedString(callCtx.meta?.date) ? new Date(toTrimmedString(callCtx.meta.date)) : new Date()); + const date = resolveSigningDate(callCtx.meta); const signed = signRequest({ method: actionSpec.httpMethod, url: endpoint, @@ -388,6 +402,7 @@ export const _test = { validateBindings, validateActionName, validateActionSpec, + resolveSigningDate, queryParamsToString, signRequest, invokeVolcengine, diff --git a/services/volcengine__waf/test/volcengine-waf.test.js b/services/volcengine__waf/test/volcengine-waf.test.js index 49093f6e..9a36981b 100644 --- a/services/volcengine__waf/test/volcengine-waf.test.js +++ b/services/volcengine__waf/test/volcengine-waf.test.js @@ -90,10 +90,18 @@ test('validates required credentials and supported actions', () => { test('escapes Volcengine query params and rejects nested GET query values', () => { assert.equal(_test.queryParamsToString({ Special: "!'()*", Text: 'hello world', CN: '中文' }), 'CN=%E4%B8%AD%E6%96%87&Special=%21%27%28%29%2A&Text=hello%20world'); + assert.equal(_test.queryParamsToString({ Filter: ['enabled', null, undefined, 'blocked'] }), 'Filter=blocked&Filter=enabled'); assert.throws(() => _test.queryParamsToString({ Filter: { Name: 'status' } }), /nested object/); assert.throws(() => _test.queryParamsToString({ Filter: ['ok', { Name: 'status' }] }), /nested object/); }); +test('validates signing date metadata', () => { + assert.equal(_test.resolveSigningDate({ date: new Date('2024-01-16T08:00:00Z') }).toISOString(), '2024-01-16T08:00:00.000Z'); + assert.equal(_test.resolveSigningDate({ date: '2024-01-16T08:00:00Z' }).toISOString(), '2024-01-16T08:00:00.000Z'); + assert.throws(() => _test.resolveSigningDate({ date: new Date('invalid') }), /meta.date/); + assert.throws(() => _test.resolveSigningDate({ date: 'invalid-date' }), /meta.date/); +}); + test('normalizes protobuf Struct payloads', () => { assert.deepEqual(_test.normalizeStruct({ fields: {