From 0518fc4911f9608ef1a1d3f219c2c5019d307f13 Mon Sep 17 00:00:00 2001 From: wayto213 Date: Sat, 27 Jun 2026 16:59:07 +0800 Subject: [PATCH 1/3] Add Faraday v5.22 service --- services/bin/infobyte-faraday-v5-22.js | 10 + services/bin/octobus-tentacles.js | 4 + services/infobyte__faraday_v5-22/README.md | 71 ++ .../bin/infobyte-faraday-v5-22.js | 6 + .../config.schema.json | 32 + services/infobyte__faraday_v5-22/package.json | 12 + .../proto/infobyte_faraday_v5_22.proto | 114 ++++ .../secret.schema.json | 23 + services/infobyte__faraday_v5-22/service.json | 53 ++ .../src/infobyte-faraday-v5-22.js | 641 ++++++++++++++++++ .../infobyte__faraday_v5-22/src/service.js | 7 + .../test/infobyte-faraday-v5-22.test.js | 384 +++++++++++ services/package.json | 3 + 13 files changed, 1360 insertions(+) create mode 100755 services/bin/infobyte-faraday-v5-22.js create mode 100644 services/infobyte__faraday_v5-22/README.md create mode 100755 services/infobyte__faraday_v5-22/bin/infobyte-faraday-v5-22.js create mode 100644 services/infobyte__faraday_v5-22/config.schema.json create mode 100644 services/infobyte__faraday_v5-22/package.json create mode 100644 services/infobyte__faraday_v5-22/proto/infobyte_faraday_v5_22.proto create mode 100644 services/infobyte__faraday_v5-22/secret.schema.json create mode 100644 services/infobyte__faraday_v5-22/service.json create mode 100644 services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js create mode 100644 services/infobyte__faraday_v5-22/src/service.js create mode 100644 services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js diff --git a/services/bin/infobyte-faraday-v5-22.js b/services/bin/infobyte-faraday-v5-22.js new file mode 100755 index 00000000..bd688a2d --- /dev/null +++ b/services/bin/infobyte-faraday-v5-22.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../infobyte__faraday_v5-22/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../infobyte__faraday_v5-22/bin/infobyte-faraday-v5-22.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js index 6138109e..bfe93513 100755 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -81,6 +81,10 @@ const services = { entryFile: "../imperva__waf-gateway_v13-6-90/bin/imperva-waf-gateway-v13-6-90.js", serviceModule: "../imperva__waf-gateway_v13-6-90/src/service.js", }, + "infobyte-faraday-v5-22": { + entryFile: "../infobyte__faraday_v5-22/bin/infobyte-faraday-v5-22.js", + serviceModule: "../infobyte__faraday_v5-22/src/service.js", + }, "nsfocus-ads-v4-5-r90-f06": { entryFile: "../nsfocus__ads_v4-5-r90-f06/bin/nsfocus-ads-v4-5-r90-f06.js", serviceModule: "../nsfocus__ads_v4-5-r90-f06/src/service.js", diff --git a/services/infobyte__faraday_v5-22/README.md b/services/infobyte__faraday_v5-22/README.md new file mode 100644 index 00000000..9b3a4320 --- /dev/null +++ b/services/infobyte__faraday_v5-22/README.md @@ -0,0 +1,71 @@ +# Faraday v5.22 OctoBus Service + +This package adapts Faraday v5.22.0 workspace, host, and vulnerability APIs for OctoBus. + +## Configuration + +`config`: + +- `faraday_base_url`: Faraday base URL, for example `http://127.0.0.1:5985`. +- `headers`: optional extra HTTP headers. +- `timeoutMs`: optional HTTP timeout in milliseconds, default `5000`. + +`secret`: + +- `faraday_username`: Faraday username. +- `faraday_password`: Faraday password. + +Authentication uses Faraday Basic authentication. The service intentionally rejects `skipTlsVerify`, `tlsInsecureSkipVerify`, and `insecureSkipVerify`; use a trusted TLS certificate for HTTPS endpoints. + +## Methods + +- `InfobyteFaradayV522.Faraday/ListWorkspaces`: `GET /_api/v3/ws` +- `InfobyteFaradayV522.Faraday/CreateWorkspace`: `POST /_api/v3/ws` +- `InfobyteFaradayV522.Faraday/ListHosts`: `GET /_api/v3/ws/{workspace_name}/hosts` +- `InfobyteFaradayV522.Faraday/CreateHost`: `POST /_api/v3/ws/{workspace_name}/hosts` +- `InfobyteFaradayV522.Faraday/ListVulnerabilities`: `GET /_api/v3/ws/{workspace_name}/vulns` +- `InfobyteFaradayV522.Faraday/GetVulnerability`: `GET /_api/v3/ws/{workspace_name}/vulns/{object_id}` +- `InfobyteFaradayV522.Faraday/CreateVulnerability`: `POST /_api/v3/ws/{workspace_name}/vulns` + +## Risk and Authorization Notes + +Recommended capset split: + +- Read-only operations: `ListWorkspaces`, `ListHosts`, `ListVulnerabilities`, `GetVulnerability`. +- Write operations: `CreateWorkspace`, `CreateHost`, `CreateVulnerability`. + +Write operations create data in Faraday and should be granted only to automation that is allowed to synchronize security assessment assets and vulnerabilities. Use a dedicated Faraday account with the minimum workspace permissions required by the workflow. + +Write behavior: + +- `CreateWorkspace` creates a workspace. If the same name already exists, Faraday returns an upstream error; callers should treat workspace names as unique. +- `CreateHost` creates a host in the target workspace. Use test-only IPs and hostnames when validating. +- `CreateVulnerability` creates a vulnerability under a host or other Faraday parent object. Use a test parent object and an `external_id` that identifies the source workflow. +- The service does not implement delete or update operations, so rollback should be performed in Faraday UI/API by deleting the test workspace or created objects. +- Audit fields can be carried through `external_id`, `tool`, `metadata`, `description`, and Faraday response metadata such as `owner`, `create_time`, and `update_time`. + +Known limits: + +- TLS verification cannot be disabled through service config. +- Responses include Faraday raw response bodies to preserve evidence and troubleshooting details; do not pass production secrets or sensitive business data in free-form fields. + +## Example + +```bash +node services/bin/infobyte-faraday-v5-22.js list-workspaces \ + --config '{"faraday_base_url":"http://127.0.0.1:5985"}' \ + --secret '{"faraday_username":"faraday","faraday_password":"***"}' +``` + +Responses include the upstream HTTP status, raw response body, parsed raw JSON, and list results where applicable so PR validation can cite the real Faraday request and response without inventing evidence. + +## Validation + +```bash +cd services +npm run validate -- --service-dir infobyte__faraday_v5-22 +npm test -- --service-dir infobyte__faraday_v5-22 +npm run pack:check +``` + +For integration validation, import the service into OctoBus, create an instance with `faraday_base_url`, `faraday_username`, and `faraday_password`, add that instance to a capset, and call at least one read method and one write method against a test Faraday workspace. diff --git a/services/infobyte__faraday_v5-22/bin/infobyte-faraday-v5-22.js b/services/infobyte__faraday_v5-22/bin/infobyte-faraday-v5-22.js new file mode 100755 index 00000000..8e0066ef --- /dev/null +++ b/services/infobyte__faraday_v5-22/bin/infobyte-faraday-v5-22.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/infobyte__faraday_v5-22/config.schema.json b/services/infobyte__faraday_v5-22/config.schema.json new file mode 100644 index 00000000..ab0732da --- /dev/null +++ b/services/infobyte__faraday_v5-22/config.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "faraday_base_url": { + "type": "string", + "description": "Faraday base URL, for example http://localhost:5985." + }, + "baseUrl": { + "type": "string", + "description": "Alias for faraday_base_url." + }, + "restBaseUrl": { + "type": "string", + "description": "Legacy alias for faraday_base_url." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional extra HTTP headers sent to Faraday." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 5000, + "description": "HTTP timeout in milliseconds." + } + } +} diff --git a/services/infobyte__faraday_v5-22/package.json b/services/infobyte__faraday_v5-22/package.json new file mode 100644 index 00000000..c1814114 --- /dev/null +++ b/services/infobyte__faraday_v5-22/package.json @@ -0,0 +1,12 @@ +{ + "name": "infobyte-faraday-v5-22", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "infobyte-faraday-v5-22": "bin/infobyte-faraday-v5-22.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/infobyte__faraday_v5-22/proto/infobyte_faraday_v5_22.proto b/services/infobyte__faraday_v5-22/proto/infobyte_faraday_v5_22.proto new file mode 100644 index 00000000..b870fcfa --- /dev/null +++ b/services/infobyte__faraday_v5-22/proto/infobyte_faraday_v5_22.proto @@ -0,0 +1,114 @@ +syntax = "proto3"; + +package InfobyteFaradayV522; + +import "google/protobuf/struct.proto"; +import "google/protobuf/wrappers.proto"; + +option go_package = "miner/grpc-service/InfobyteFaradayV522"; + +service Faraday { + // 获取 Faraday 工作区列表。 + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListResponse) {} + + // 创建 Faraday 工作区。 + rpc CreateWorkspace(CreateWorkspaceRequest) returns (ObjectResponse) {} + + // 获取指定工作区的主机列表。 + rpc ListHosts(ListHostsRequest) returns (ListResponse) {} + + // 在指定工作区创建主机。 + rpc CreateHost(CreateHostRequest) returns (ObjectResponse) {} + + // 获取指定工作区的漏洞列表。 + rpc ListVulnerabilities(ListVulnerabilitiesRequest) returns (ListResponse) {} + + // 获取指定工作区的单个漏洞。 + rpc GetVulnerability(GetVulnerabilityRequest) returns (ObjectResponse) {} + + // 在指定工作区创建漏洞。 + rpc CreateVulnerability(CreateVulnerabilityRequest) returns (ObjectResponse) {} +} + +message ListWorkspacesRequest { + google.protobuf.BoolValue histogram = 1; + google.protobuf.Int64Value histogram_days = 2; +} + +message CreateWorkspaceRequest { + string name = 1; + string description = 2; + string customer = 3; + google.protobuf.BoolValue active = 4; + google.protobuf.BoolValue public = 5; + google.protobuf.Int64Value importance = 6; + google.protobuf.Struct extra_fields = 7; +} + +message ListHostsRequest { + string workspace_name = 1; + google.protobuf.BoolValue stats = 2; +} + +message CreateHostRequest { + string workspace_name = 1; + string ip = 2; + string description = 3; + string os = 4; + string mac = 5; + google.protobuf.BoolValue owned = 6; + google.protobuf.Int64Value importance = 7; + google.protobuf.ListValue hostnames = 8; + google.protobuf.Struct metadata = 9; + google.protobuf.Struct extra_fields = 10; +} + +message ListVulnerabilitiesRequest { + string workspace_name = 1; +} + +message GetVulnerabilityRequest { + string workspace_name = 1; + int64 object_id = 2; +} + +message CreateVulnerabilityRequest { + string workspace_name = 1; + string name = 2; + string severity = 3; + string type = 4; + string desc = 5; + string data = 6; + string resolution = 7; + string status = 8; + google.protobuf.BoolValue confirmed = 9; + string tool = 10; + string external_id = 11; + google.protobuf.ListValue cve = 12; + google.protobuf.ListValue cwe = 13; + google.protobuf.ListValue refs = 14; + google.protobuf.Struct metadata = 15; + google.protobuf.Value parent = 16; + string parent_type = 17; + string website = 18; + string path = 19; + string method = 20; + string request = 21; + string response = 22; + google.protobuf.Int64Value status_code = 23; + google.protobuf.Struct extra_fields = 24; +} + +message ListResponse { + int32 http_status = 1; + string raw_body = 2; + int64 count = 3; + repeated google.protobuf.Value results = 4; + google.protobuf.Value raw_json = 5; +} + +message ObjectResponse { + int32 http_status = 1; + string raw_body = 2; + google.protobuf.Value raw_json = 3; +} diff --git a/services/infobyte__faraday_v5-22/secret.schema.json b/services/infobyte__faraday_v5-22/secret.schema.json new file mode 100644 index 00000000..062fccac --- /dev/null +++ b/services/infobyte__faraday_v5-22/secret.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "faraday_username": { + "type": "string", + "description": "Faraday username for Basic authentication." + }, + "faraday_password": { + "type": "string", + "description": "Faraday password for Basic authentication." + }, + "username": { + "type": "string", + "description": "Alias for faraday_username." + }, + "password": { + "type": "string", + "description": "Alias for faraday_password." + } + } +} diff --git a/services/infobyte__faraday_v5-22/service.json b/services/infobyte__faraday_v5-22/service.json new file mode 100644 index 00000000..523d926c --- /dev/null +++ b/services/infobyte__faraday_v5-22/service.json @@ -0,0 +1,53 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "infobyte-faraday-v5-22", + "displayName": "Faraday v5.22", + "description": "OctoBus package for Faraday v5.22.0 workspace, host, and vulnerability APIs.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/infobyte_faraday_v5_22.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "InfobyteFaradayV522.Faraday/ListWorkspaces": { + "name": "list-workspaces", + "description": "List Faraday workspaces." + }, + "InfobyteFaradayV522.Faraday/CreateWorkspace": { + "name": "create-workspace", + "description": "Create a Faraday workspace." + }, + "InfobyteFaradayV522.Faraday/ListHosts": { + "name": "list-hosts", + "description": "List Faraday hosts in a workspace." + }, + "InfobyteFaradayV522.Faraday/CreateHost": { + "name": "create-host", + "description": "Create a Faraday host in a workspace." + }, + "InfobyteFaradayV522.Faraday/ListVulnerabilities": { + "name": "list-vulnerabilities", + "description": "List Faraday vulnerabilities in a workspace." + }, + "InfobyteFaradayV522.Faraday/GetVulnerability": { + "name": "get-vulnerability", + "description": "Get one Faraday vulnerability by object ID." + }, + "InfobyteFaradayV522.Faraday/CreateVulnerability": { + "name": "create-vulnerability", + "description": "Create a Faraday vulnerability in a workspace." + } + } + } + } +} diff --git a/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js b/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js new file mode 100644 index 00000000..0f3fb038 --- /dev/null +++ b/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js @@ -0,0 +1,641 @@ +import { Buffer } from 'node:buffer'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +export const METHOD_LIST_WORKSPACES_PATH = '/InfobyteFaradayV522.Faraday/ListWorkspaces'; +export const METHOD_CREATE_WORKSPACE_PATH = '/InfobyteFaradayV522.Faraday/CreateWorkspace'; +export const METHOD_LIST_HOSTS_PATH = '/InfobyteFaradayV522.Faraday/ListHosts'; +export const METHOD_CREATE_HOST_PATH = '/InfobyteFaradayV522.Faraday/CreateHost'; +export const METHOD_LIST_VULNERABILITIES_PATH = '/InfobyteFaradayV522.Faraday/ListVulnerabilities'; +export const METHOD_GET_VULNERABILITY_PATH = '/InfobyteFaradayV522.Faraday/GetVulnerability'; +export const METHOD_CREATE_VULNERABILITY_PATH = '/InfobyteFaradayV522.Faraday/CreateVulnerability'; + +export const METHOD_LIST_WORKSPACES_FULL = 'InfobyteFaradayV522.Faraday/ListWorkspaces'; +export const METHOD_CREATE_WORKSPACE_FULL = 'InfobyteFaradayV522.Faraday/CreateWorkspace'; +export const METHOD_LIST_HOSTS_FULL = 'InfobyteFaradayV522.Faraday/ListHosts'; +export const METHOD_CREATE_HOST_FULL = 'InfobyteFaradayV522.Faraday/CreateHost'; +export const METHOD_LIST_VULNERABILITIES_FULL = 'InfobyteFaradayV522.Faraday/ListVulnerabilities'; +export const METHOD_GET_VULNERABILITY_FULL = 'InfobyteFaradayV522.Faraday/GetVulnerability'; +export const METHOD_CREATE_VULNERABILITY_FULL = 'InfobyteFaradayV522.Faraday/CreateVulnerability'; + +export const DEFAULT_TIMEOUT_MS = 5000; + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), String(message ?? '')); + err.legacyCode = code; + return err; +}; + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); + +const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null); + +const field = (obj, snakeName, camelName = snakeName) => firstDefined(obj?.[snakeName], obj?.[camelName]); + +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 protobufValueToPlain = (value) => { + if (value === undefined || value === null) return undefined; + if (Array.isArray(value)) return value.map((item) => protobufValueToPlain(item)); + if (typeof value !== 'object') return value; + + if (value.kind && typeof value.kind === 'object' && hasOwn(value.kind, 'case')) { + return protobufValueToPlain(value.kind.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')) return protobufValueToPlain(value.listValue?.values ?? []); + if (hasOwn(value, 'structValue')) return protobufValueToPlain(value.structValue?.fields ?? {}); + if (hasOwn(value, 'fields')) { + const out = {}; + for (const [key, innerValue] of Object.entries(value.fields ?? {})) { + out[key] = protobufValueToPlain(innerValue); + } + return out; + } + if (hasOwn(value, 'values')) return protobufValueToPlain(value.values ?? []); + const entries = Object.entries(value); + if (entries.length > 0 && entries.every(([key]) => /^\d+$/.test(key))) { + return entries + .sort(([left], [right]) => Number(left) - Number(right)) + .map(([, innerValue]) => protobufValueToPlain(innerValue)); + } + + const out = {}; + for (const [key, innerValue] of entries) { + out[key] = protobufValueToPlain(innerValue); + } + return out; +}; + +const toTrimmedString = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return ''; + return String(raw).trim(); +}; + +const toOptionalInt = (value, options = {}) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return undefined; + const num = Number(raw); + if (!Number.isInteger(num) || Number.isNaN(num)) return undefined; + if (options.min !== undefined && num < options.min) return undefined; + return num; +}; + +const toOptionalBool = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return undefined; + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') { + if (raw === 1) return true; + if (raw === 0) return false; + return undefined; + } + const normalized = String(raw).trim().toLowerCase(); + if (normalized === 'true' || normalized === '1') return true; + if (normalized === 'false' || normalized === '0') return false; + return undefined; +}; + +const normalizeBaseUrl = (value) => { + const raw = toTrimmedString(value); + if (!/^https?:\/\//i.test(raw)) return ''; + return raw.replace(/\/+$/, ''); +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx.config ?? {}), + ...(ctx.secret ?? {}), + ...(ctx.bindings ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: mergedBindings(ctx), + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.req ?? ctx.request ?? {}, +}); + +const isSdkCallContext = (value) => ( + value != null + && typeof value === 'object' + && ( + hasOwn(value, 'request') + || hasOwn(value, 'config') + || hasOwn(value, 'secret') + || hasOwn(value, 'metadata') + || hasOwn(value, 'method') + || hasOwn(value, 'packageDir') + ) +); + +const resolveHandlerArgs = (reqOrCtx = {}, maybeCtx) => { + if (maybeCtx !== undefined) { + return { req: reqOrCtx ?? {}, ctx: maybeCtx ?? {} }; + } + if (isSdkCallContext(reqOrCtx)) { + return { req: reqOrCtx.request ?? reqOrCtx.req ?? {}, ctx: reqOrCtx }; + } + return { req: reqOrCtx ?? {}, ctx: {} }; +}; + +const resolveBaseUrl = (bindings = {}) => normalizeBaseUrl(firstDefined( + bindings.faraday_base_url, + bindings.baseUrl, + bindings.restBaseUrl, +)); + +const resolveUsername = (bindings = {}) => toTrimmedString(firstDefined( + bindings.faraday_username, + bindings.username, +)); + +const resolvePassword = (bindings = {}) => { + const value = firstDefined(bindings.faraday_password, bindings.password); + const raw = unwrapScalar(value); + if (raw === undefined || raw === null) return ''; + return String(raw); +}; + +const resolveTimeoutMs = (ctx = {}) => { + const raw = Number(firstDefined(ctx.limits?.timeoutMs, ctx.bindings?.timeoutMs, DEFAULT_TIMEOUT_MS)); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS; +}; + +const parseHeaders = (value) => { + if (value === undefined || value === null || value === '') return {}; + if (typeof value === 'object' && !Array.isArray(value)) return value; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch { + return {}; + } + } + return {}; +}; + +const tlsSkipRequested = (bindings = {}) => ( + Boolean(bindings.skipTlsVerify || bindings.tlsInsecureSkipVerify || bindings.insecureSkipVerify) +); + +const assertSupportedTlsConfig = (bindings = {}) => { + if (!tlsSkipRequested(bindings)) return; + throw errorWithCode( + 'INVALID_ARGUMENT', + 'skipTlsVerify is not supported by this service; use a trusted TLS certificate for the Faraday endpoint', + ); +}; + +const makeTimeoutSignal = (timeoutMs) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + return { signal: controller.signal, clear: () => clearTimeout(timeoutId) }; +}; + +const requireBaseUrl = (ctx = {}) => { + const baseUrl = resolveBaseUrl(ctx.bindings || {}); + if (!baseUrl) throw errorWithCode('INVALID_ARGUMENT', 'faraday_base_url is required in bindings'); + return baseUrl; +}; + +const requireUsername = (ctx = {}) => { + const username = resolveUsername(ctx.bindings || {}); + if (!username) throw errorWithCode('INVALID_ARGUMENT', 'faraday_username is required in bindings'); + return username; +}; + +const requirePassword = (ctx = {}) => { + const password = resolvePassword(ctx.bindings || {}); + if (!password) throw errorWithCode('INVALID_ARGUMENT', 'faraday_password is required in bindings'); + return password; +}; + +const requirePositiveId = (value, fieldName) => { + const id = toOptionalInt(value, { min: 1 }); + if (id === undefined) throw errorWithCode('INVALID_ARGUMENT', `${fieldName} must be a positive integer`); + return id; +}; + +const requireString = (value, fieldName) => { + const text = toTrimmedString(value); + if (!text) throw errorWithCode('INVALID_ARGUMENT', `${fieldName} is required`); + return text; +}; + +const encodeQueryPairs = (query = {}) => { + const parts = []; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue; + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + return parts.join('&'); +}; + +const buildUrl = (baseUrl, path, query) => { + const base = String(baseUrl || '').replace(/\/+$/, ''); + const normalizedPath = String(path || '').replace(/^\/+/, ''); + const qs = encodeQueryPairs(query); + const joined = `${base}/${normalizedPath}`; + return qs ? `${joined}?${qs}` : joined; +}; + +const encodePathSegment = (value) => encodeURIComponent(requireString(value, 'workspace_name')); + +const tryParseJson = (text) => { + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false }; + } +}; + +const toValue = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return { stringValue: value }; + if (typeof value === 'boolean') return { boolValue: value }; + if (typeof value === 'number') return Number.isFinite(value) ? { numberValue: value } : { stringValue: String(value) }; + if (Array.isArray(value)) { + return { + listValue: { + values: value.map((item) => toValue(item) ?? { nullValue: 'NULL_VALUE' }), + }, + }; + } + if (typeof value === 'object') { + const fields = {}; + for (const [key, innerValue] of Object.entries(value)) { + fields[key] = toValue(innerValue) ?? { nullValue: 'NULL_VALUE' }; + } + return { structValue: { fields } }; + } + return { stringValue: String(value) }; +}; + +const throwStructuredError = (code, message, options = {}) => { + const payload = { + code, + message, + http_status: Number(options.httpStatus ?? 0), + raw_body: String(options.rawBody ?? ''), + }; + if (options.reason) payload.reason = String(options.reason); + if (options.rawJson !== undefined) payload.raw_json = options.rawJson; + throw errorWithCode(code, JSON.stringify(payload)); +}; + +const mapHttpStatusToGrpcCode = (status) => { + if (status === 401 || status === 403) return 'PERMISSION_DENIED'; + if (status >= 400 && status < 500) return 'FAILED_PRECONDITION'; + if (status >= 500) return 'UNAVAILABLE'; + return 'UNAVAILABLE'; +}; + +const basicAuthHeader = (ctx = {}) => { + const username = requireUsername(ctx); + const password = requirePassword(ctx); + return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; +}; + +const buildRequestHeaders = (ctx = {}, extra = {}) => ({ + Accept: 'application/json', + ...parseHeaders(ctx.bindings?.headers), + ...extra, + Authorization: basicAuthHeader(ctx), +}); + +const requestUpstream = async (url, ctx = {}, options = {}) => { + const bindings = ctx.bindings || {}; + assertSupportedTlsConfig(bindings); + + const method = options.method || 'GET'; + const headers = buildRequestHeaders(ctx, options.headers); + const init = { + method, + headers, + signal: undefined, + }; + if (options.body !== undefined) init.body = options.body; + + const timeout = makeTimeoutSignal(resolveTimeoutMs(ctx)); + init.signal = timeout.signal; + + let res; + let httpStatus = 0; + try { + res = await fetch(url, init); + httpStatus = Number(res?.status || 0); + const rawBody = String(await res.text() ?? ''); + return { httpStatus, rawBody }; + } catch (err) { + throwStructuredError('UNAVAILABLE', 'faraday upstream request failed', { + httpStatus, + rawBody: '', + reason: err?.cause?.message || err?.message || 'fetch failed', + }); + } finally { + timeout.clear(); + } +}; + +const fetchUpstream = (url, ctx = {}) => requestUpstream(url, ctx); + +const sendJsonUpstream = (url, ctx = {}, method, body = {}) => requestUpstream(url, ctx, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), +}); + +const parseFaradayResponse = ({ httpStatus, rawBody }) => { + const trimmed = String(rawBody ?? '').trim(); + const parsed = trimmed ? tryParseJson(trimmed) : { ok: false }; + if (httpStatus < 200 || httpStatus >= 300) { + throwStructuredError(mapHttpStatusToGrpcCode(httpStatus), 'faraday upstream http failure', { + httpStatus, + rawBody, + rawJson: parsed.ok ? parsed.value : undefined, + reason: `upstream http ${httpStatus}`, + }); + } + if (!parsed.ok) { + throwStructuredError('UNKNOWN', 'faraday response is not valid JSON', { + httpStatus, + rawBody, + reason: 'response is not valid JSON', + }); + } + return { + http_status: httpStatus, + raw_body: rawBody, + json: parsed.value, + raw_json: toValue(parsed.value), + }; +}; + +const extractResults = (json) => { + if (Array.isArray(json)) return json; + if (Array.isArray(json?.results)) return json.results; + if (Array.isArray(json?.rows)) return json.rows; + if (Array.isArray(json?.items)) return json.items; + if (Array.isArray(json?.vulnerabilities)) return json.vulnerabilities; + return []; +}; + +const parseListResponse = (result) => { + const parsed = parseFaradayResponse(result); + const results = extractResults(parsed.json); + return { + http_status: parsed.http_status, + raw_body: parsed.raw_body, + count: toOptionalInt(parsed.json?.count, { min: 0 }) + ?? toOptionalInt(parsed.json?.total, { min: 0 }) + ?? results.length, + results: results.map((item) => toValue(item) ?? { nullValue: 'NULL_VALUE' }), + raw_json: parsed.raw_json, + }; +}; + +const parseObjectResponse = (result) => { + const parsed = parseFaradayResponse(result); + return { + http_status: parsed.http_status, + raw_body: parsed.raw_body, + raw_json: parsed.raw_json, + }; +}; + +const maybeSet = (body, key, value) => { + if (value === undefined || value === null || value === '') return; + body[key] = value; +}; + +const maybeSetString = (body, key, value) => maybeSet(body, key, toTrimmedString(value)); +const maybeSetInt = (body, key, value, options = {}) => maybeSet(body, key, toOptionalInt(value, options)); +const maybeSetBool = (body, key, value) => maybeSet(body, key, toOptionalBool(value)); + +const applyExtraFields = (body, extraFields) => { + const extra = protobufValueToPlain(extraFields); + if (extra && typeof extra === 'object' && !Array.isArray(extra)) { + Object.assign(body, extra); + } + return body; +}; + +const commonWorkspaceQuery = (req = {}) => ({ + histogram: toOptionalBool(req.histogram), + histogram_days: toOptionalInt(field(req, 'histogram_days', 'histogramDays'), { min: 1 }), +}); + +const handleListWorkspaces = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const url = buildUrl(requireBaseUrl(callCtx), '/_api/v3/ws', commonWorkspaceQuery(req)); + return parseListResponse(await fetchUpstream(url, callCtx)); +}; + +const handleCreateWorkspace = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const body = applyExtraFields({ + name: requireString(req.name, 'name'), + }, field(req, 'extra_fields', 'extraFields')); + maybeSetString(body, 'description', req.description); + maybeSetString(body, 'customer', req.customer); + maybeSetBool(body, 'active', req.active); + maybeSetBool(body, 'public', req.public); + maybeSetInt(body, 'importance', req.importance); + const url = buildUrl(requireBaseUrl(callCtx), '/_api/v3/ws'); + return parseObjectResponse(await sendJsonUpstream(url, callCtx, 'POST', body)); +}; + +const handleListHosts = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const workspace = encodePathSegment(field(req, 'workspace_name', 'workspaceName')); + const url = buildUrl(requireBaseUrl(callCtx), `/_api/v3/ws/${workspace}/hosts`, { + stats: toOptionalBool(req.stats), + }); + return parseListResponse(await fetchUpstream(url, callCtx)); +}; + +const handleCreateHost = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const workspace = encodePathSegment(field(req, 'workspace_name', 'workspaceName')); + const body = applyExtraFields({ + description: requireString(req.description, 'description'), + }, field(req, 'extra_fields', 'extraFields')); + maybeSetString(body, 'ip', req.ip); + maybeSetString(body, 'os', req.os); + maybeSetString(body, 'mac', req.mac); + maybeSetBool(body, 'owned', req.owned); + maybeSetInt(body, 'importance', req.importance); + const hostnames = protobufValueToPlain(req.hostnames); + if (hostnames !== undefined) body.hostnames = hostnames; + const metadata = protobufValueToPlain(req.metadata); + if (metadata !== undefined) body.metadata = metadata; + const url = buildUrl(requireBaseUrl(callCtx), `/_api/v3/ws/${workspace}/hosts`); + return parseObjectResponse(await sendJsonUpstream(url, callCtx, 'POST', body)); +}; + +const handleListVulnerabilities = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const workspace = encodePathSegment(field(req, 'workspace_name', 'workspaceName')); + const url = buildUrl(requireBaseUrl(callCtx), `/_api/v3/ws/${workspace}/vulns`); + return parseListResponse(await fetchUpstream(url, callCtx)); +}; + +const handleGetVulnerability = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const workspace = encodePathSegment(field(req, 'workspace_name', 'workspaceName')); + const id = requirePositiveId(field(req, 'object_id', 'objectId'), 'object_id'); + const url = buildUrl(requireBaseUrl(callCtx), `/_api/v3/ws/${workspace}/vulns/${id}`); + return parseObjectResponse(await fetchUpstream(url, callCtx)); +}; + +const handleCreateVulnerability = async (req = {}, ctx = {}) => { + const callCtx = resolveCallContext(ctx); + const workspace = encodePathSegment(field(req, 'workspace_name', 'workspaceName')); + const body = applyExtraFields({ + name: requireString(req.name, 'name'), + severity: requireString(req.severity, 'severity'), + type: requireString(req.type, 'type'), + }, field(req, 'extra_fields', 'extraFields')); + maybeSetString(body, 'desc', req.desc); + maybeSetString(body, 'data', req.data); + maybeSetString(body, 'resolution', req.resolution); + maybeSetString(body, 'status', req.status); + maybeSetBool(body, 'confirmed', req.confirmed); + maybeSetString(body, 'tool', req.tool); + maybeSetString(body, 'external_id', field(req, 'external_id', 'externalId')); + maybeSetString(body, 'parent_type', field(req, 'parent_type', 'parentType')); + maybeSetString(body, 'website', req.website); + maybeSetString(body, 'path', req.path); + maybeSetString(body, 'method', req.method); + maybeSetString(body, 'request', req.request); + maybeSetString(body, 'response', req.response); + maybeSetInt(body, 'status_code', field(req, 'status_code', 'statusCode'), { min: 100 }); + + const parent = protobufValueToPlain(req.parent); + if (parent !== undefined) body.parent = parent; + const cve = protobufValueToPlain(req.cve); + if (cve !== undefined) body.cve = cve; + const cwe = protobufValueToPlain(req.cwe); + if (cwe !== undefined) body.cwe = cwe; + const refs = protobufValueToPlain(req.refs); + if (refs !== undefined) body.refs = refs; + const metadata = protobufValueToPlain(req.metadata); + if (metadata !== undefined) body.metadata = metadata; + + const url = buildUrl(requireBaseUrl(callCtx), `/_api/v3/ws/${workspace}/vulns`); + return parseObjectResponse(await sendJsonUpstream(url, callCtx, 'POST', body)); +}; + +export function rpcdef(ctx = {}) { + const callCtx = resolveCallContext(ctx); + return { + [METHOD_LIST_WORKSPACES_PATH]: async (req) => handleListWorkspaces(req ?? callCtx.req ?? {}, callCtx), + [METHOD_CREATE_WORKSPACE_PATH]: async (req) => handleCreateWorkspace(req ?? callCtx.req ?? {}, callCtx), + [METHOD_LIST_HOSTS_PATH]: async (req) => handleListHosts(req ?? callCtx.req ?? {}, callCtx), + [METHOD_CREATE_HOST_PATH]: async (req) => handleCreateHost(req ?? callCtx.req ?? {}, callCtx), + [METHOD_LIST_VULNERABILITIES_PATH]: async (req) => handleListVulnerabilities(req ?? callCtx.req ?? {}, callCtx), + [METHOD_GET_VULNERABILITY_PATH]: async (req) => handleGetVulnerability(req ?? callCtx.req ?? {}, callCtx), + [METHOD_CREATE_VULNERABILITY_PATH]: async (req) => handleCreateVulnerability(req ?? callCtx.req ?? {}, callCtx), + }; +} + +export const handlers = { + [METHOD_LIST_WORKSPACES_FULL]: (reqOrCtx, maybeCtx) => { + const call = resolveHandlerArgs(reqOrCtx, maybeCtx); + return handleListWorkspaces(call.req, call.ctx); + }, + [METHOD_CREATE_WORKSPACE_FULL]: (reqOrCtx, maybeCtx) => { + const call = resolveHandlerArgs(reqOrCtx, maybeCtx); + return handleCreateWorkspace(call.req, call.ctx); + }, + [METHOD_LIST_HOSTS_FULL]: (reqOrCtx, maybeCtx) => { + const call = resolveHandlerArgs(reqOrCtx, maybeCtx); + return handleListHosts(call.req, call.ctx); + }, + [METHOD_CREATE_HOST_FULL]: (reqOrCtx, maybeCtx) => { + const call = resolveHandlerArgs(reqOrCtx, maybeCtx); + return handleCreateHost(call.req, call.ctx); + }, + [METHOD_LIST_VULNERABILITIES_FULL]: (reqOrCtx, maybeCtx) => { + const call = resolveHandlerArgs(reqOrCtx, maybeCtx); + return handleListVulnerabilities(call.req, call.ctx); + }, + [METHOD_GET_VULNERABILITY_FULL]: (reqOrCtx, maybeCtx) => { + const call = resolveHandlerArgs(reqOrCtx, maybeCtx); + return handleGetVulnerability(call.req, call.ctx); + }, + [METHOD_CREATE_VULNERABILITY_FULL]: (reqOrCtx, maybeCtx) => { + const call = resolveHandlerArgs(reqOrCtx, maybeCtx); + return handleCreateVulnerability(call.req, call.ctx); + }, +}; + +export const _test = { + applyExtraFields, + assertSupportedTlsConfig, + basicAuthHeader, + buildRequestHeaders, + buildUrl, + commonWorkspaceQuery, + encodePathSegment, + encodeQueryPairs, + errorWithCode, + extractResults, + fetchUpstream, + field, + firstDefined, + grpcCodeFor, + handleCreateHost, + handleCreateVulnerability, + handleCreateWorkspace, + handleGetVulnerability, + handleListHosts, + handleListVulnerabilities, + handleListWorkspaces, + hasOwn, + mapHttpStatusToGrpcCode, + mergedBindings, + normalizeBaseUrl, + parseFaradayResponse, + parseHeaders, + parseListResponse, + parseObjectResponse, + protobufValueToPlain, + requireBaseUrl, + requirePassword, + requirePositiveId, + requireString, + requireUsername, + resolveBaseUrl, + resolveCallContext, + resolveHandlerArgs, + resolvePassword, + resolveTimeoutMs, + resolveUsername, + sendJsonUpstream, + throwStructuredError, + toOptionalBool, + toOptionalInt, + toTrimmedString, + toValue, + tryParseJson, + unwrapScalar, +}; diff --git a/services/infobyte__faraday_v5-22/src/service.js b/services/infobyte__faraday_v5-22/src/service.js new file mode 100644 index 00000000..540be048 --- /dev/null +++ b/services/infobyte__faraday_v5-22/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './infobyte-faraday-v5-22.js'; + +export { handlers } from './infobyte-faraday-v5-22.js'; + +export const service = defineService({ handlers }); diff --git a/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js b/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js new file mode 100644 index 00000000..927b2a81 --- /dev/null +++ b/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js @@ -0,0 +1,384 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const listWorkspacesPath = '/InfobyteFaradayV522.Faraday/ListWorkspaces'; +const createWorkspacePath = '/InfobyteFaradayV522.Faraday/CreateWorkspace'; +const listHostsPath = '/InfobyteFaradayV522.Faraday/ListHosts'; +const createHostPath = '/InfobyteFaradayV522.Faraday/CreateHost'; +const listVulnsPath = '/InfobyteFaradayV522.Faraday/ListVulnerabilities'; +const getVulnPath = '/InfobyteFaradayV522.Faraday/GetVulnerability'; +const createVulnPath = '/InfobyteFaradayV522.Faraday/CreateVulnerability'; + +const buildCtx = (req = {}, overrides = {}) => ({ + bindings: { + faraday_base_url: 'http://localhost:5985', + faraday_username: 'faraday', + faraday_password: 'secret', + headers: { 'X-Test': 'yes' }, + ...overrides.bindings, + }, + limits: { timeoutMs: 10_000, ...overrides.limits }, + meta: { instance_id: 'inst', request_id: 'req', ...overrides.meta }, + req, +}); + +const loadHandler = async (path, req, overrides = {}) => { + const { rpcdef } = await import('../src/infobyte-faraday-v5-22.js'); + const ctx = buildCtx(req, overrides); + return rpcdef(ctx)[path]; +}; + +const setFetch = (impl) => { + global.fetch = async (...args) => impl(...args); +}; + +test('helpers normalize bindings, protobuf values, query strings, and headers', async () => { + const { _test } = await import('../src/infobyte-faraday-v5-22.js'); + + assert.deepEqual(_test.mergedBindings({ + config: { faraday_base_url: 'http://config', keep: 'config' }, + secret: { faraday_username: 'user', faraday_password: 'pw' }, + bindings: { faraday_base_url: 'http://binding' }, + }), { + faraday_base_url: 'http://binding', + keep: 'config', + faraday_username: 'user', + faraday_password: 'pw', + }); + assert.equal(_test.normalizeBaseUrl('http://localhost:5985/'), 'http://localhost:5985'); + assert.equal(_test.normalizeBaseUrl('ftp://bad'), ''); + assert.equal(_test.resolveBaseUrl({ baseUrl: 'http://base' }), 'http://base'); + assert.equal(_test.resolveUsername({ username: 'faraday' }), 'faraday'); + assert.equal(_test.resolvePassword({ password: ' pass with spaces ' }), ' pass with spaces '); + assert.equal(_test.toOptionalInt({ value: '7' }, { min: 1 }), 7); + assert.equal(_test.toOptionalBool({ value: 'false' }), false); + assert.deepEqual(_test.parseHeaders('{"X-A":"1"}'), { 'X-A': '1' }); + assert.deepEqual(_test.parseHeaders('{'), {}); + assert.deepEqual(_test.protobufValueToPlain({ + structValue: { + fields: { + tags: { listValue: { values: [{ stringValue: 'octobus' }] } }, + score: { numberValue: 8.8 }, + }, + }, + }), { tags: ['octobus'], score: 8.8 }); + assert.equal(_test.protobufValueToPlain({ kind: { case: 'stringValue', value: 'octobus' } }), 'octobus'); + assert.deepEqual(_test.protobufValueToPlain({ 0: 'one', 1: 'two' }), ['one', 'two']); + assert.equal(_test.encodeQueryPairs({ a: 'x y', empty: '', missing: undefined }), 'a=x%20y'); + assert.equal(_test.buildUrl('http://x/', '/_api/v3/ws', { histogram: true }), 'http://x/_api/v3/ws?histogram=true'); + assert.equal(_test.encodePathSegment('demo/workspace'), 'demo%2Fworkspace'); + assert.throws( + () => _test.assertSupportedTlsConfig({ skipTlsVerify: true }), + /skipTlsVerify is not supported/, + ); + assert.deepEqual(_test.buildRequestHeaders(buildCtx()), { + Accept: 'application/json', + 'X-Test': 'yes', + Authorization: 'Basic ZmFyYWRheTpzZWNyZXQ=', + }); +}); + +test('ListWorkspaces forwards histogram query and maps array response', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url, init }; + return { + status: 200, + text: async () => JSON.stringify([{ id: 1, name: 'octobus-demo' }]), + }; + }); + + const handler = await loadHandler(listWorkspacesPath, { + histogram: { value: true }, + histogram_days: { value: 7 }, + }); + const res = await handler(); + + assert.equal(captured.url, 'http://localhost:5985/_api/v3/ws?histogram=true&histogram_days=7'); + assert.equal(captured.init.method, 'GET'); + assert.equal(captured.init.headers.Authorization, 'Basic ZmFyYWRheTpzZWNyZXQ='); + assert.equal(captured.init.headers['X-Test'], 'yes'); + assert.equal(Object.hasOwn(captured.init, 'timeoutMs'), false); + assert.equal(Object.hasOwn(captured.init, 'skipTlsVerify'), false); + assert.ok(captured.init.signal instanceof AbortSignal); + assert.equal(res.count, 1); + assert.equal(res.results[0].structValue.fields.name.stringValue, 'octobus-demo'); +}); + +test('sdk handlers accept single call context with request, config, and secret', async () => { + const { handlers } = await import('../src/infobyte-faraday-v5-22.js'); + let captured; + setFetch(async (url, init) => { + captured = { url, init }; + return { + status: 200, + text: async () => JSON.stringify([{ id: 2, name: 'sdk-ws' }]), + }; + }); + + const res = await handlers['InfobyteFaradayV522.Faraday/ListWorkspaces']({ + request: { histogram: false }, + config: { faraday_base_url: 'http://localhost:5985' }, + secret: { faraday_username: 'sdk', faraday_password: 'token' }, + }); + + assert.equal(captured.url, 'http://localhost:5985/_api/v3/ws?histogram=false'); + assert.equal(captured.init.headers.Authorization, 'Basic c2RrOnRva2Vu'); + assert.equal(res.results[0].structValue.fields.name.stringValue, 'sdk-ws'); +}); + +test('sdk handlers accept lowerCamelCase request fields from JSON transcoding', async () => { + const { handlers } = await import('../src/infobyte-faraday-v5-22.js'); + let captured; + setFetch(async (url, init) => { + captured = { url, init, body: init.body ? JSON.parse(init.body) : undefined }; + return { + status: 201, + text: async () => JSON.stringify({ id: 12, name: 'camel vuln' }), + }; + }); + + const res = await handlers['InfobyteFaradayV522.Faraday/CreateVulnerability']({ + request: { + workspaceName: 'camel ws', + name: 'camel vuln', + severity: 'medium', + type: 'Vulnerability', + objectId: 12, + parentType: 'Host', + externalId: 'camel-001', + statusCode: 418, + extraFields: { fields: { parent: { numberValue: 1 } } }, + }, + config: { faraday_base_url: 'http://localhost:5985' }, + secret: { faraday_username: 'sdk', faraday_password: 'token' }, + }); + + assert.equal(captured.url, 'http://localhost:5985/_api/v3/ws/camel%20ws/vulns'); + assert.equal(captured.body.parent, 1); + assert.equal(captured.body.parent_type, 'Host'); + assert.equal(captured.body.external_id, 'camel-001'); + assert.equal(captured.body.status_code, 418); + assert.equal(res.raw_json.structValue.fields.name.stringValue, 'camel vuln'); +}); + +test('CreateWorkspace sends required and optional JSON body', async () => { + let captured; + setFetch(async (url, init) => { + captured = { url, init, body: JSON.parse(init.body) }; + return { + status: 201, + text: async () => JSON.stringify({ id: 3, name: 'octobus-ws', active: true }), + }; + }); + + const handler = await loadHandler(createWorkspacePath, { + name: 'octobus-ws', + description: 'Created by OctoBus', + customer: 'security', + active: { value: true }, + public: false, + importance: { value: 2 }, + extra_fields: { fields: { readonly: { boolValue: false } } }, + }); + const res = await handler(); + + assert.equal(captured.url, 'http://localhost:5985/_api/v3/ws'); + assert.equal(captured.init.method, 'POST'); + assert.equal(captured.init.headers['content-type'], 'application/json'); + assert.deepEqual(captured.body, { + name: 'octobus-ws', + readonly: false, + description: 'Created by OctoBus', + customer: 'security', + active: true, + public: false, + importance: 2, + }); + assert.equal(res.raw_json.structValue.fields.name.stringValue, 'octobus-ws'); +}); + +test('ListHosts and CreateHost map workspace path and host body', async () => { + let capturedUrl; + setFetch(async (url) => { + capturedUrl = url; + return { + status: 200, + text: async () => JSON.stringify({ count: 1, rows: [{ id: 5, ip: '10.0.0.8' }] }), + }; + }); + + const listHandler = await loadHandler(listHostsPath, { + workspace_name: 'octobus demo', + stats: { value: false }, + }); + const listRes = await listHandler(); + + assert.equal(capturedUrl, 'http://localhost:5985/_api/v3/ws/octobus%20demo/hosts?stats=false'); + assert.equal(listRes.count, 1); + assert.equal(listRes.results[0].structValue.fields.ip.stringValue, '10.0.0.8'); + + let captured; + setFetch(async (url, init) => { + captured = { url, init, body: JSON.parse(init.body) }; + return { + status: 201, + text: async () => JSON.stringify({ id: 6, ip: '10.0.0.9' }), + }; + }); + + const createHandler = await loadHandler(createHostPath, { + workspace_name: 'octobus demo', + ip: '10.0.0.9', + description: 'OctoBus validation host', + os: 'Linux', + owned: '0', + hostnames: { values: [{ stringValue: 'demo.local' }] }, + metadata: { fields: { source: { stringValue: 'octobus' } } }, + }); + const createRes = await createHandler(); + + assert.equal(captured.url, 'http://localhost:5985/_api/v3/ws/octobus%20demo/hosts'); + assert.deepEqual(captured.body, { + description: 'OctoBus validation host', + ip: '10.0.0.9', + os: 'Linux', + owned: false, + hostnames: ['demo.local'], + metadata: { source: 'octobus' }, + }); + assert.equal(createRes.raw_json.structValue.fields.ip.stringValue, '10.0.0.9'); +}); + +test('ListVulnerabilities, GetVulnerability, and CreateVulnerability map API calls', async () => { + let capturedUrl; + setFetch(async (url) => { + capturedUrl = url; + return { + status: 200, + text: async () => JSON.stringify({ vulnerabilities: [{ id: 10, name: 'SQL Injection' }], count: 1 }), + }; + }); + + const listHandler = await loadHandler(listVulnsPath, { workspace_name: 'octobus-ws' }); + const listRes = await listHandler(); + + assert.equal(capturedUrl, 'http://localhost:5985/_api/v3/ws/octobus-ws/vulns'); + assert.equal(listRes.count, 1); + assert.equal(listRes.results[0].structValue.fields.name.stringValue, 'SQL Injection'); + + setFetch(async (url) => { + capturedUrl = url; + return { + status: 200, + text: async () => JSON.stringify({ id: 10, name: 'SQL Injection' }), + }; + }); + + const getHandler = await loadHandler(getVulnPath, { workspace_name: 'octobus-ws', object_id: 10 }); + const getRes = await getHandler(); + + assert.equal(capturedUrl, 'http://localhost:5985/_api/v3/ws/octobus-ws/vulns/10'); + assert.equal(getRes.raw_json.structValue.fields.name.stringValue, 'SQL Injection'); + + let captured; + setFetch(async (url, init) => { + captured = { url, init, body: JSON.parse(init.body) }; + return { + status: 201, + text: async () => JSON.stringify({ id: 11, name: 'OctoBus validation vuln' }), + }; + }); + + const createHandler = await loadHandler(createVulnPath, { + workspace_name: 'octobus-ws', + name: 'OctoBus validation vuln', + severity: 'high', + type: 'Vulnerability', + desc: 'Created by OctoBus test', + confirmed: true, + cve: { values: [{ stringValue: 'CVE-2026-0001' }] }, + metadata: { fields: { source: { stringValue: 'octobus' } } }, + extra_fields: { fields: { custom_field: { stringValue: 'kept' } } }, + }); + const createRes = await createHandler(); + + assert.equal(captured.url, 'http://localhost:5985/_api/v3/ws/octobus-ws/vulns'); + assert.equal(captured.init.method, 'POST'); + assert.deepEqual(captured.body, { + name: 'OctoBus validation vuln', + severity: 'high', + type: 'Vulnerability', + custom_field: 'kept', + desc: 'Created by OctoBus test', + confirmed: true, + cve: ['CVE-2026-0001'], + metadata: { source: 'octobus' }, + }); + assert.equal(createRes.raw_json.structValue.fields.id.numberValue, 11); +}); + +test('validates required fields before downstream calls', async () => { + const noBaseUrl = await loadHandler(listWorkspacesPath, {}, { bindings: { faraday_base_url: '' } }); + await assert.rejects(() => noBaseUrl(), /faraday_base_url is required/); + + const noUsername = await loadHandler(listWorkspacesPath, {}, { bindings: { faraday_username: '' } }); + await assert.rejects(() => noUsername(), /faraday_username is required/); + + const noPassword = await loadHandler(listWorkspacesPath, {}, { bindings: { faraday_password: '' } }); + await assert.rejects(() => noPassword(), /faraday_password is required/); + + const noWorkspaceName = await loadHandler(listHostsPath, {}); + await assert.rejects(() => noWorkspaceName(), /workspace_name is required/); + + const badVulnId = await loadHandler(getVulnPath, { workspace_name: 'octobus-ws', object_id: 0 }); + await assert.rejects(() => badVulnId(), /object_id must be a positive integer/); + + const noHostDesc = await loadHandler(createHostPath, { workspace_name: 'octobus-ws' }); + await assert.rejects(() => noHostDesc(), /description is required/); + + const noVulnName = await loadHandler(createVulnPath, { + workspace_name: 'octobus-ws', + severity: 'high', + type: 'Vulnerability', + }); + await assert.rejects(() => noVulnName(), /name is required/); +}); + +test('errors cover upstream failures, HTTP errors, invalid JSON, and TLS skip', async () => { + setFetch(async () => { + throw new Error('connect failed'); + }); + const unavailable = await loadHandler(listWorkspacesPath, {}); + await assert.rejects(() => unavailable(), /faraday upstream request failed/); + + setFetch(async () => ({ + status: 403, + text: async () => JSON.stringify({ message: 'forbidden' }), + })); + const forbidden = await loadHandler(listWorkspacesPath, {}); + await assert.rejects(() => forbidden(), /PERMISSION_DENIED|faraday upstream http failure/); + + setFetch(async () => ({ + status: 200, + text: async () => 'not-json', + })); + const invalidJson = await loadHandler(listWorkspacesPath, {}); + await assert.rejects(() => invalidJson(), /response is not valid JSON/); + + const invalidTls = await loadHandler(listWorkspacesPath, {}, { bindings: { skipTlsVerify: true } }); + await assert.rejects(() => invalidTls(), /skipTlsVerify is not supported/); +}); + +test('applies upstream timeout through AbortController across response body reads', async () => { + setFetch(async (url, init) => ({ + status: 200, + text: async () => new Promise((resolve, reject) => { + assert.ok(init.signal instanceof AbortSignal); + init.signal.addEventListener('abort', () => reject(new Error('aborted by response timeout')), { once: true }); + }), + })); + + const handler = await loadHandler(listWorkspacesPath, {}, { limits: { timeoutMs: 1 } }); + await assert.rejects(() => handler(), /aborted by response timeout/); +}); diff --git a/services/package.json b/services/package.json index dd6a1015..a462b94d 100644 --- a/services/package.json +++ b/services/package.json @@ -22,6 +22,7 @@ "hillstone-fw-v5-5-r10": "bin/hillstone-fw-v5-5-r10.js", "huawei-fw-usg6000e": "bin/huawei-fw-usg6000e.js", "imperva-waf-gateway-v13-6-90": "bin/imperva-waf-gateway-v13-6-90.js", + "infobyte-faraday-v5-22": "bin/infobyte-faraday-v5-22.js", "nsfocus-ads-v4-5-r90-f06": "bin/nsfocus-ads-v4-5-r90-f06.js", "nsfocus-nips-v5-6-r11": "bin/nsfocus-nips-v5-6-r11.js", "panabit-tang-r1": "bin/panabit-tang-r1.js", @@ -70,6 +71,7 @@ "bin/hillstone-fw-v5-5-r10.js", "bin/huawei-fw-usg6000e.js", "bin/imperva-waf-gateway-v13-6-90.js", + "bin/infobyte-faraday-v5-22.js", "bin/nsfocus-ads-v4-5-r90-f06.js", "bin/nsfocus-nips-v5-6-r11.js", "bin/panabit-tang-r1.js", @@ -116,6 +118,7 @@ "hillstone__fw_v5-5-r10", "huawei__fw-usg6000e", "imperva__waf-gateway_v13-6-90", + "infobyte__faraday_v5-22", "nsfocus__ads_v4-5-r90-f06", "nsfocus__nips_v5-6-r11", "panabit__tang-r1", From aa27d755aaea21472be0003670e9e809fad34a14 Mon Sep 17 00:00:00 2001 From: wayto213 Date: Sat, 27 Jun 2026 17:14:09 +0800 Subject: [PATCH 2/3] Address Faraday review findings --- .../src/infobyte-faraday-v5-22.js | 12 ++++++++-- .../test/infobyte-faraday-v5-22.test.js | 24 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js b/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js index 0f3fb038..b113c8f4 100644 --- a/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js +++ b/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js @@ -257,7 +257,13 @@ const buildUrl = (baseUrl, path, query) => { return qs ? `${joined}?${qs}` : joined; }; -const encodePathSegment = (value) => encodeURIComponent(requireString(value, 'workspace_name')); +const encodePathSegment = (value) => { + const segment = requireString(value, 'workspace_name'); + if (segment === '.' || segment === '..' || segment.includes('..') || segment.includes('/') || segment.includes('\\')) { + throw errorWithCode('INVALID_ARGUMENT', 'workspace_name must be a safe single path segment'); + } + return encodeURIComponent(segment); +}; const tryParseJson = (text) => { try { @@ -433,7 +439,9 @@ const maybeSetBool = (body, key, value) => maybeSet(body, key, toOptionalBool(va const applyExtraFields = (body, extraFields) => { const extra = protobufValueToPlain(extraFields); if (extra && typeof extra === 'object' && !Array.isArray(extra)) { - Object.assign(body, extra); + for (const [key, value] of Object.entries(extra)) { + if (!hasOwn(body, key)) body[key] = value; + } } return body; }; diff --git a/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js b/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js index 927b2a81..cfd7581d 100644 --- a/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js +++ b/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js @@ -66,7 +66,11 @@ test('helpers normalize bindings, protobuf values, query strings, and headers', assert.deepEqual(_test.protobufValueToPlain({ 0: 'one', 1: 'two' }), ['one', 'two']); assert.equal(_test.encodeQueryPairs({ a: 'x y', empty: '', missing: undefined }), 'a=x%20y'); assert.equal(_test.buildUrl('http://x/', '/_api/v3/ws', { histogram: true }), 'http://x/_api/v3/ws?histogram=true'); - assert.equal(_test.encodePathSegment('demo/workspace'), 'demo%2Fworkspace'); + assert.equal(_test.encodePathSegment('demo workspace'), 'demo%20workspace'); + assert.throws(() => _test.encodePathSegment('.'), /safe single path segment/); + assert.throws(() => _test.encodePathSegment('..'), /safe single path segment/); + assert.throws(() => _test.encodePathSegment('demo/hosts'), /safe single path segment/); + assert.throws(() => _test.encodePathSegment('demo..hosts'), /safe single path segment/); assert.throws( () => _test.assertSupportedTlsConfig({ skipTlsVerify: true }), /skipTlsVerify is not supported/, @@ -179,7 +183,12 @@ test('CreateWorkspace sends required and optional JSON body', async () => { active: { value: true }, public: false, importance: { value: 2 }, - extra_fields: { fields: { readonly: { boolValue: false } } }, + extra_fields: { + fields: { + name: { stringValue: 'overridden-name' }, + readonly: { boolValue: false }, + }, + }, }); const res = await handler(); @@ -299,7 +308,13 @@ test('ListVulnerabilities, GetVulnerability, and CreateVulnerability map API cal confirmed: true, cve: { values: [{ stringValue: 'CVE-2026-0001' }] }, metadata: { fields: { source: { stringValue: 'octobus' } } }, - extra_fields: { fields: { custom_field: { stringValue: 'kept' } } }, + extra_fields: { + fields: { + name: { stringValue: 'overridden-vuln' }, + severity: { stringValue: 'low' }, + custom_field: { stringValue: 'kept' }, + }, + }, }); const createRes = await createHandler(); @@ -331,6 +346,9 @@ test('validates required fields before downstream calls', async () => { const noWorkspaceName = await loadHandler(listHostsPath, {}); await assert.rejects(() => noWorkspaceName(), /workspace_name is required/); + const unsafeWorkspaceName = await loadHandler(listHostsPath, { workspace_name: '../hosts' }); + await assert.rejects(() => unsafeWorkspaceName(), /safe single path segment/); + const badVulnId = await loadHandler(getVulnPath, { workspace_name: 'octobus-ws', object_id: 0 }); await assert.rejects(() => badVulnId(), /object_id must be a positive integer/); From 552f2027477cb9660cca51af535422d2dfa1ca01 Mon Sep 17 00:00:00 2001 From: wayto213 Date: Sat, 27 Jun 2026 17:51:15 +0800 Subject: [PATCH 3/3] Refine Faraday workspace path validation --- services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js | 2 +- .../test/infobyte-faraday-v5-22.test.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js b/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js index b113c8f4..7c85553a 100644 --- a/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js +++ b/services/infobyte__faraday_v5-22/src/infobyte-faraday-v5-22.js @@ -259,7 +259,7 @@ const buildUrl = (baseUrl, path, query) => { const encodePathSegment = (value) => { const segment = requireString(value, 'workspace_name'); - if (segment === '.' || segment === '..' || segment.includes('..') || segment.includes('/') || segment.includes('\\')) { + if (segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\')) { throw errorWithCode('INVALID_ARGUMENT', 'workspace_name must be a safe single path segment'); } return encodeURIComponent(segment); diff --git a/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js b/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js index cfd7581d..d652b253 100644 --- a/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js +++ b/services/infobyte__faraday_v5-22/test/infobyte-faraday-v5-22.test.js @@ -67,10 +67,11 @@ test('helpers normalize bindings, protobuf values, query strings, and headers', assert.equal(_test.encodeQueryPairs({ a: 'x y', empty: '', missing: undefined }), 'a=x%20y'); assert.equal(_test.buildUrl('http://x/', '/_api/v3/ws', { histogram: true }), 'http://x/_api/v3/ws?histogram=true'); assert.equal(_test.encodePathSegment('demo workspace'), 'demo%20workspace'); + assert.equal(_test.encodePathSegment('demo..hosts'), 'demo..hosts'); + assert.equal(_test.encodePathSegment('v1.2.3'), 'v1.2.3'); assert.throws(() => _test.encodePathSegment('.'), /safe single path segment/); assert.throws(() => _test.encodePathSegment('..'), /safe single path segment/); assert.throws(() => _test.encodePathSegment('demo/hosts'), /safe single path segment/); - assert.throws(() => _test.encodePathSegment('demo..hosts'), /safe single path segment/); assert.throws( () => _test.assertSupportedTlsConfig({ skipTlsVerify: true }), /skipTlsVerify is not supported/,