diff --git a/services/bin/m01-intelligence.js b/services/bin/m01-intelligence.js old mode 100644 new mode 100755 diff --git a/services/bin/nsfocus-rsas-v6-0r04f04sp09.js b/services/bin/nsfocus-rsas-v6-0r04f04sp09.js new file mode 100755 index 00000000..890dfaaf --- /dev/null +++ b/services/bin/nsfocus-rsas-v6-0r04f04sp09.js @@ -0,0 +1,10 @@ +#!/usr/bin/env node + +import { fileURLToPath } from "node:url"; +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../nsfocus__rsas_v6-0r04f04sp09/src/service.js"; + +runServiceMain(service, { + entryFile: fileURLToPath(new URL("../nsfocus__rsas_v6-0r04f04sp09/bin/nsfocus-rsas-v6-0r04f04sp09.js", import.meta.url)), +}); diff --git a/services/bin/octobus-tentacles.js b/services/bin/octobus-tentacles.js old mode 100644 new mode 100755 index 412b8988..fb142ba2 --- a/services/bin/octobus-tentacles.js +++ b/services/bin/octobus-tentacles.js @@ -129,6 +129,10 @@ const services = { entryFile: "../nsfocus__nips_v5-6-r11/bin/nsfocus-nips-v5-6-r11.js", serviceModule: "../nsfocus__nips_v5-6-r11/src/service.js", }, + "nsfocus-rsas-v6-0r04f04sp09": { + entryFile: "../nsfocus__rsas_v6-0r04f04sp09/bin/nsfocus-rsas-v6-0r04f04sp09.js", + serviceModule: "../nsfocus__rsas_v6-0r04f04sp09/src/service.js", + }, "owasp-dependency-track-sca-v5-0": { entryFile: "../owasp__dependency-track-sca_v5-0/bin/owasp-dependency-track-sca-v5-0.js", serviceModule: "../owasp__dependency-track-sca_v5-0/src/service.js", diff --git a/services/m01__intelligence/bin/m01-intelligence.js b/services/m01__intelligence/bin/m01-intelligence.js old mode 100644 new mode 100755 diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/README.md b/services/nsfocus__rsas_v6-0r04f04sp09/README.md new file mode 100644 index 00000000..1704b45e --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/README.md @@ -0,0 +1,99 @@ +# NSFOCUS RSAS V6.0R04F04SP09 + +OctoBus package for the NSFOCUS RSAS (Remote Security Assessment System, +绿盟网络安全漏洞扫描系统) V6.0R04F04SP09 REST API. + +## Authentication + +RSAS authenticates with a username/password pair passed as URL query +parameters (`?username=...&password=...`). Provide them through the secret +binding (`user`/`username` + `password`); every request also sends +`format=json` and `curr_lang` (default `cn`). + +## Configuration + +| Binding | Location | Description | +| --- | --- | --- | +| `host` | config | Base URL with scheme, e.g. `https://10.65.193.127` | +| `user` / `username` | secret (or config) | Login username | +| `password` | secret | Login password | +| `currLang` | config | Response language `cn` (default) or `en` | +| `timeoutMs` | config | HTTP timeout in ms (default 30000) | +| `skipTlsVerify` | config | Skip TLS verification for self-signed device certs | +| `headers` | config | Extra HTTP headers | + +## RPCs + +All responses carry the RSAS envelope fields `ret_code`, `ret_msg`, `data` +(as a `google.protobuf.Value`) and the transport `http_status`. Task-creation +RPCs additionally surface `task_id`. Download RPCs return `BinaryResponse` +with base64 body plus content metadata. + +### Task lifecycle +- `CreateTask` — `POST /api/task/create` (config.xml payload) +- `CreateVulnTask` — `POST /api/task/vul/create` +- `CreateBaselineTask` — `POST /api/task/baseline/create` +- `CreatePwdTask` — `POST /api/task/pwd/create` +- `CreateWebTask` — `POST /api/task/web/create` +- `CreateOfflineTask` — `POST /api/task/offline/create` +- `CreateDockerTask` — `POST /api/task/docker/create` +- `CreateCodeauditTask` — `POST /api/task/codeaudit/create` +- `CreateHostAssetsTask` — `POST /api/task/hostassets/create` +- `CreateWebAssetsTask` — `POST /api/task/webassets/create` +- `GetTaskStatus` — `GET /api/task/status/{task_id}` +- `PauseTask` / `ResumeTask` / `StopTask` / `DeleteTask` — `POST /api/task/{action}/{task_id}` +- `BatchDeleteTasks` — `POST /api/task/batch_delete` +- `ListTasks` — `GET /api/task/list` +- `ListActiveTasks` — `GET /api/task/active_list` +- `GetTaskResult` — `GET /api/report/task/{task_id}` +- `CreateAuthInfo` — `POST /api/authinfo/create` +- `LoginVerify` — `POST /api/auth/login_verify` + +### Templates and dictionaries +- `ListSysvulnTemplate` — `GET /api/template/sysvuln/list` +- `ListWebvulnTemplate` — `GET /api/template/webvuln/list` +- `ListBaselineTemplate` — `GET /api/template/baseline/list` +- `GetBaselineParams` — `GET /api/template/baseline/params` +- `ListCodeauditTemplate` — `GET /api/template/codeaudit/list` +- `ListAssetTemplate` — `GET /api/template/asset/list` +- `CreateBaselineTemplate` — `POST /api/template/baseline/create` +- `ListUserpwd` — `GET /api/userpwd/list` +- `CreateUserpwd` — `POST /api/userpwd/create` + +### System and audit +- `GetSystemStatus` — `GET /api/system/status` +- `GetLogInfo` — `GET /api/log/getlogInfo` + +### Reports +- `ListReportTemplate` — `GET /api/report/template/list` +- `GenerateReport` — `POST /api/generate_report/` +- `GetReportProgress` — `GET /api/get_report_progress/report_id/{report_id}` +- `DownloadReport` — `GET /api/download_report/report_id/{report_id}/report_type/{report_type}` +- `DeleteReports` — `POST /api/delete_reports` + +### Agent +- `GetAgentMethodConfig` / `SetAgentMethodConfig` — `/api/agent/agent_method_config` +- `GetAgentAuth` — `GET /api/agent/get_agent_auth` +- `GetAgentPackageUrl` — `GET /api/agent/get_agentpackage_url` +- `DownloadAgent` — `GET /api/agent/download/{linux|windows}` + +## Error mapping + +| Condition | gRPC status | +| --- | --- | +| Missing required argument | `INVALID_ARGUMENT` | +| HTTP 401 / 403 | `PERMISSION_DENIED` | +| HTTP 404 / 405 | `INVALID_ARGUMENT` | +| HTTP 429 (rate limit) | `RESOURCE_EXHAUSTED` | +| HTTP 5xx / network failure | `UNAVAILABLE` | +| Non-JSON or empty body | `UNKNOWN` | + +RSAS applies its own rate limits (600 req/min globally; 300 req/min for task +status and report progress; 150 req/min for other interfaces), returning HTTP +429 when exceeded. + +## Testing + +``` +npm test -- --service-dir nsfocus__rsas_v6-0r04f04sp09 +``` diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/bin/nsfocus-rsas-v6-0r04f04sp09.js b/services/nsfocus__rsas_v6-0r04f04sp09/bin/nsfocus-rsas-v6-0r04f04sp09.js new file mode 100755 index 00000000..8e0066ef --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/bin/nsfocus-rsas-v6-0r04f04sp09.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/nsfocus__rsas_v6-0r04f04sp09/config.schema.json b/services/nsfocus__rsas_v6-0r04f04sp09/config.schema.json new file mode 100644 index 00000000..88a333bc --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/config.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "host": { + "type": "string", + "description": "NSFOCUS RSAS base URL with http(s) scheme, e.g. https://10.65.193.127." + }, + "restBaseUrl": { + "type": "string", + "description": "Alias for host." + }, + "baseUrl": { + "type": "string", + "description": "Alias for host." + }, + "rest_base_url": { + "type": "string", + "description": "Alias for host." + }, + "base_url": { + "type": "string", + "description": "Alias for host." + }, + "user": { + "type": "string", + "description": "Login username. Prefer secret.user for persistent credentials." + }, + "username": { + "type": "string", + "description": "Alias for user." + }, + "currLang": { + "type": "string", + "enum": ["cn", "en"], + "default": "cn", + "description": "Response language (RSAS curr_lang parameter)." + }, + "curr_lang": { + "type": "string", + "description": "Alias for currLang." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 30000, + "description": "HTTP timeout in milliseconds. RSAS scan-result and report calls can be slow." + }, + "timeout_ms": { + "type": "integer", + "minimum": 1, + "description": "Alias for timeoutMs." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification for private deployments with self-signed certs." + }, + "tlsInsecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Legacy alias for skipTlsVerify." + }, + "insecureSkipVerify": { + "type": "boolean", + "default": false, + "description": "Alias for skipTlsVerify." + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional additional HTTP headers." + } + } +} diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/package.json b/services/nsfocus__rsas_v6-0r04f04sp09/package.json new file mode 100644 index 00000000..509aaa20 --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/package.json @@ -0,0 +1,13 @@ +{ + "name": "nsfocus-rsas-v6-0r04f04sp09", + "version": "0.0.0", + "private": true, + "type": "module", + "bin": { + "nsfocus-rsas-v6-0r04f04sp09": "bin/nsfocus-rsas-v6-0r04f04sp09.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.6.0", + "undici": "^7.16.0" + } +} diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/proto/nsfocus_rsas_v6_0r04f04sp09.proto b/services/nsfocus__rsas_v6-0r04f04sp09/proto/nsfocus_rsas_v6_0r04f04sp09.proto new file mode 100644 index 00000000..53788eb0 --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/proto/nsfocus_rsas_v6_0r04f04sp09.proto @@ -0,0 +1,297 @@ +syntax = "proto3"; + +package Nsfocus_RSAS_V60R04F04SP09; + +import "google/protobuf/struct.proto"; + +option go_package = "miner/grpc-service/Nsfocus_RSAS_V60R04F04SP09"; + +// NSFOCUS RSAS (Remote Security Assessment System) V6.0R04F04SP09 REST API. +// Credentials (username/password) are provided through config/secret bindings +// and appended to every request as query parameters, matching the device's +// URL-parameter authentication scheme. +service Nsfocus_RSAS_V60R04F04SP09 { + // Task lifecycle + rpc CreateTask(CreateTaskRequest) returns (TaskCreateResponse) {} + rpc CreateVulnTask(CreateVulnTaskRequest) returns (TaskCreateResponse) {} + rpc CreateBaselineTask(CreateBaselineTaskRequest) returns (TaskCreateResponse) {} + rpc CreatePwdTask(CreatePwdTaskRequest) returns (TaskCreateResponse) {} + rpc CreateWebTask(CreateWebTaskRequest) returns (TaskCreateResponse) {} + rpc CreateOfflineTask(CreateOfflineTaskRequest) returns (TaskCreateResponse) {} + rpc CreateDockerTask(CreateDockerTaskRequest) returns (TaskCreateResponse) {} + rpc CreateCodeauditTask(CreateCodeauditTaskRequest) returns (TaskCreateResponse) {} + rpc CreateHostAssetsTask(CreateHostAssetsTaskRequest) returns (TaskCreateResponse) {} + rpc CreateWebAssetsTask(CreateWebAssetsTaskRequest) returns (TaskCreateResponse) {} + rpc GetTaskStatus(TaskIdRequest) returns (RsasResponse) {} + rpc PauseTask(TaskIdRequest) returns (RsasResponse) {} + rpc ResumeTask(TaskIdRequest) returns (RsasResponse) {} + rpc StopTask(TaskIdRequest) returns (RsasResponse) {} + rpc DeleteTask(TaskIdRequest) returns (RsasResponse) {} + rpc BatchDeleteTasks(BatchDeleteTasksRequest) returns (RsasResponse) {} + rpc ListTasks(ListTasksRequest) returns (RsasResponse) {} + rpc ListActiveTasks(EmptyRequest) returns (RsasResponse) {} + rpc GetTaskResult(GetTaskResultRequest) returns (RsasResponse) {} + rpc CreateAuthInfo(CreateAuthInfoRequest) returns (RsasResponse) {} + rpc LoginVerify(LoginVerifyRequest) returns (RsasResponse) {} + + // Templates and dictionaries + rpc ListSysvulnTemplate(EmptyRequest) returns (RsasResponse) {} + rpc ListWebvulnTemplate(EmptyRequest) returns (RsasResponse) {} + rpc ListBaselineTemplate(ListBaselineTemplateRequest) returns (RsasResponse) {} + rpc GetBaselineParams(GetBaselineParamsRequest) returns (RsasResponse) {} + rpc ListCodeauditTemplate(ListCodeauditTemplateRequest) returns (RsasResponse) {} + rpc ListAssetTemplate(EmptyRequest) returns (RsasResponse) {} + rpc CreateBaselineTemplate(CreateBaselineTemplateRequest) returns (RsasResponse) {} + rpc ListUserpwd(EmptyRequest) returns (RsasResponse) {} + rpc CreateUserpwd(CreateUserpwdRequest) returns (RsasResponse) {} + + // System and audit + rpc GetSystemStatus(EmptyRequest) returns (RsasResponse) {} + rpc GetLogInfo(GetLogInfoRequest) returns (RsasResponse) {} + + // Reports + rpc ListReportTemplate(EmptyRequest) returns (RsasResponse) {} + rpc GenerateReport(GenerateReportRequest) returns (RsasResponse) {} + rpc GetReportProgress(GetReportProgressRequest) returns (RsasResponse) {} + rpc DownloadReport(DownloadReportRequest) returns (BinaryResponse) {} + rpc DeleteReports(DeleteReportsRequest) returns (RsasResponse) {} + + // Agent + rpc GetAgentMethodConfig(EmptyRequest) returns (RsasResponse) {} + rpc SetAgentMethodConfig(SetAgentMethodConfigRequest) returns (RsasResponse) {} + rpc GetAgentAuth(EmptyRequest) returns (RsasResponse) {} + rpc GetAgentPackageUrl(EmptyRequest) returns (RsasResponse) {} + rpc DownloadAgent(DownloadAgentRequest) returns (BinaryResponse) {} +} + +message EmptyRequest { +} + +// Standard RSAS JSON envelope: {"ret_code":0,"ret_msg":"success","data":{...}}. +message RsasResponse { + int64 ret_code = 1; + string ret_msg = 2; + google.protobuf.Value data = 3; + int32 http_status = 4; +} + +// Task creation responses additionally surface the created task_id for convenience. +message TaskCreateResponse { + int64 ret_code = 1; + string ret_msg = 2; + google.protobuf.Value data = 3; + int32 http_status = 4; + string task_id = 5; +} + +// Binary download responses (report zip, agent installer). +message BinaryResponse { + int32 http_status = 1; + string content_type = 2; + int64 content_length = 3; + string filename = 4; + string body_base64 = 5; +} + +message TaskIdRequest { + string task_id = 1; +} + +message CreateTaskRequest { + // Task type validation hint: 1 vuln, 2 baseline, 3 vuln+baseline, 4 pwd, 8 web. + int64 type = 1; + // config.xml content (the file body); sent as a multipart file field. + string config_xml = 2; + // Optional file name for the multipart part (default config.xml). + string config_xml_filename = 3; +} + +message CreateVulnTaskRequest { + string name = 1; + string targets = 2; + int64 template_id = 3; +} + +message CreateBaselineTaskRequest { + string name = 1; + string targets = 2; + string login_type = 3; + string login_port = 4; + string login_name = 5; + string login_password = 6; + string template_uuid = 7; + string template_param = 8; + string protect_level = 9; +} + +message CreatePwdTaskRequest { + string name = 1; + string targets = 2; + string template_id = 3; + string service_type = 4; + string pass_mode = 5; + int64 port = 6; +} + +message CreateWebTaskRequest { + string name = 1; + string targets = 2; + string template_id = 3; + string component_scan = 4; +} + +message CreateOfflineTaskRequest { + // Offline scan result file content, base64-encoded. + string task_field_base64 = 1; + string filename = 2; +} + +message CreateDockerTaskRequest { + string name = 1; + string targets = 2; + string registry_address = 3; + string registry_username = 4; + string registry_password = 5; +} + +message CreateCodeauditTaskRequest { + string name = 1; + int64 code_source = 2; + string repo_path = 3; + string repo_username = 4; + string repo_password = 5; + int64 template_id = 6; + string exclude_files = 7; + string exclude_dirs = 8; + string git_branch = 9; +} + +message CreateHostAssetsTaskRequest { + string name = 1; + string targets = 2; + string is_related = 3; + string template_id = 4; + string prohibit_config = 5; + string prohibit_ports = 6; +} + +message CreateWebAssetsTaskRequest { + string name = 1; + string targets = 2; + string is_related = 3; + string is_sitetree = 4; +} + +message BatchDeleteTasksRequest { + repeated int64 task_ids = 1; +} + +message ListTasksRequest { + int64 type = 1; + string end_time_start = 2; + string end_time_end = 3; + string isfinished = 4; + int64 page = 5; + int64 page_size = 6; +} + +message GetTaskResultRequest { + string task_id = 1; + string targets = 2; + int64 page = 3; + int64 page_size = 4; +} + +message AuthInfoEntry { + string accountname = 1; + string accountpwd = 2; + string port = 3; + string protocol = 4; + string ip = 5; +} + +message CreateAuthInfoRequest { + repeated AuthInfoEntry authinfo = 1; +} + +message JumpHost { + string ip_range = 1; + string protocol = 2; + string user_name = 3; + string user_pwd = 4; + int64 port = 5; +} + +message LoginVerifyRequest { + string ip = 1; + string protocol = 2; + int64 port = 3; + string user_name = 4; + string userpwd = 5; + bool jump_ifuse = 6; + repeated JumpHost jumparray = 7; + string web_login_url = 8; + string web_login_cookie = 9; +} + +message ListBaselineTemplateRequest { + string industry = 1; +} + +message GetBaselineParamsRequest { + string uuid = 1; +} + +message ListCodeauditTemplateRequest { + int64 type = 1; +} + +message CreateBaselineTemplateRequest { + // Exported template file content, base64-encoded. + string template_base64 = 1; + string filename = 2; +} + +message CreateUserpwdRequest { + // JSON array of {type,title,data} where data is base64 line content. + string userpwd = 1; +} + +message GetLogInfoRequest { + string account = 1; + string type = 2; + string starttime = 3; + string endtime = 4; + string ip = 5; +} + +message GenerateReportRequest { + string task_id = 1; + string summary_template_id = 2; + string host_template_id = 3; + string report_type = 4; +} + +message GetReportProgressRequest { + string report_id = 1; +} + +message DownloadReportRequest { + string report_id = 1; + string report_type = 2; +} + +message DeleteReportsRequest { + // Report ids: repeated integers, or the literal "all". + repeated int64 report_ids = 1; + bool all = 2; +} + +message SetAgentMethodConfigRequest { + int64 agent_method = 1; +} + +message DownloadAgentRequest { + // Target platform: "linux" or "windows". + string platform = 1; +} diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/secret.schema.json b/services/nsfocus__rsas_v6-0r04f04sp09/secret.schema.json new file mode 100644 index 00000000..57646eda --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/secret.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "user": { + "type": "string", + "description": "Login username." + }, + "username": { + "type": "string", + "description": "Alias for user." + }, + "password": { + "type": "string", + "description": "Login password." + } + } +} diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/service.json b/services/nsfocus__rsas_v6-0r04f04sp09/service.json new file mode 100644 index 00000000..ee27c279 --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/service.json @@ -0,0 +1,193 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "nsfocus-rsas-v6-0r04f04sp09", + "displayName": "NSFOCUS RSAS V6.0R04F04SP09", + "description": "OctoBus package for NSFOCUS RSAS (Remote Security Assessment System) V6.0R04F04SP09 REST APIs: scan tasks, templates, reports, and agent management.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/nsfocus_rsas_v6_0r04f04sp09.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateTask": { + "name": "create-task", + "description": "Create a task from a config.xml payload." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateVulnTask": { + "name": "create-vuln-task", + "description": "Create a vulnerability scan task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateBaselineTask": { + "name": "create-baseline-task", + "description": "Create a configuration/baseline check task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreatePwdTask": { + "name": "create-pwd-task", + "description": "Create a password guessing task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateWebTask": { + "name": "create-web-task", + "description": "Create a web application scan task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateOfflineTask": { + "name": "create-offline-task", + "description": "Create an offline scan task from a result file." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateDockerTask": { + "name": "create-docker-task", + "description": "Create a container image scan task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateCodeauditTask": { + "name": "create-codeaudit-task", + "description": "Create a source code audit task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateHostAssetsTask": { + "name": "create-host-assets-task", + "description": "Create a host asset discovery task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateWebAssetsTask": { + "name": "create-web-assets-task", + "description": "Create a web asset discovery task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetTaskStatus": { + "name": "get-task-status", + "description": "Query the status of a task by id." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/PauseTask": { + "name": "pause-task", + "description": "Pause a running task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ResumeTask": { + "name": "resume-task", + "description": "Resume a paused task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/StopTask": { + "name": "stop-task", + "description": "Stop a task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/DeleteTask": { + "name": "delete-task", + "description": "Delete a task by id." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/BatchDeleteTasks": { + "name": "batch-delete-tasks", + "description": "Delete multiple tasks by id list." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListTasks": { + "name": "list-tasks", + "description": "List tasks with optional filters." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListActiveTasks": { + "name": "list-active-tasks", + "description": "List currently running tasks." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetTaskResult": { + "name": "get-task-result", + "description": "Fetch scan results for a task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateAuthInfo": { + "name": "create-auth-info", + "description": "Push scan authentication credentials." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/LoginVerify": { + "name": "login-verify", + "description": "Verify a scan login credential against a target." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListSysvulnTemplate": { + "name": "list-sysvuln-template", + "description": "List system vulnerability templates." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListWebvulnTemplate": { + "name": "list-webvuln-template", + "description": "List web vulnerability templates." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListBaselineTemplate": { + "name": "list-baseline-template", + "description": "List configuration/baseline templates." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetBaselineParams": { + "name": "get-baseline-params", + "description": "Get parameters for a baseline template." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListCodeauditTemplate": { + "name": "list-codeaudit-template", + "description": "List code audit templates." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListAssetTemplate": { + "name": "list-asset-template", + "description": "List asset discovery templates." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateBaselineTemplate": { + "name": "create-baseline-template", + "description": "Import a baseline template file." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListUserpwd": { + "name": "list-userpwd", + "description": "List password dictionaries." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/CreateUserpwd": { + "name": "create-userpwd", + "description": "Push a custom password dictionary." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetSystemStatus": { + "name": "get-system-status", + "description": "Get device system status and resource usage." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetLogInfo": { + "name": "get-log-info", + "description": "Query audit logs." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/ListReportTemplate": { + "name": "list-report-template", + "description": "List report templates." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GenerateReport": { + "name": "generate-report", + "description": "Generate a report for a task." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetReportProgress": { + "name": "get-report-progress", + "description": "Query report generation progress." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/DownloadReport": { + "name": "download-report", + "description": "Download a generated report zip." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/DeleteReports": { + "name": "delete-reports", + "description": "Delete reports by id list or all." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetAgentMethodConfig": { + "name": "get-agent-method-config", + "description": "Get the agent deployment method." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/SetAgentMethodConfig": { + "name": "set-agent-method-config", + "description": "Set the agent deployment method." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetAgentAuth": { + "name": "get-agent-auth", + "description": "Get agent authorization/registration counts." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/GetAgentPackageUrl": { + "name": "get-agent-package-url", + "description": "Get agent installer download links." + }, + "Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09/DownloadAgent": { + "name": "download-agent", + "description": "Download the agent installer for a platform." + } + } + } + } +} diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/src/nsfocus-rsas-v6-0r04f04sp09.js b/services/nsfocus__rsas_v6-0r04f04sp09/src/nsfocus-rsas-v6-0r04f04sp09.js new file mode 100644 index 00000000..593755a2 --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/src/nsfocus-rsas-v6-0r04f04sp09.js @@ -0,0 +1,812 @@ +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import { Agent } from 'undici'; + +export const SERVICE_FQN = 'Nsfocus_RSAS_V60R04F04SP09.Nsfocus_RSAS_V60R04F04SP09'; +const method = (name) => `${SERVICE_FQN}/${name}`; + +export const DEFAULT_TIMEOUT_MS = 30000; + +// --------------------------------------------------------------------------- +// Context, bindings, credentials +// --------------------------------------------------------------------------- + +const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key); +const firstDefined = (...vals) => vals.find((val) => val !== undefined && val !== null); + +const unwrapScalar = (value) => { + if (value === undefined || value === null) return undefined; + if (typeof value === 'object' && value !== null && hasOwn(value, 'value')) return unwrapScalar(value.value); + return value; +}; + +const grpcCodeFor = (code) => ({ + FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, + INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, + PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, + RESOURCE_EXHAUSTED: grpcStatus.RESOURCE_EXHAUSTED, + UNAVAILABLE: grpcStatus.UNAVAILABLE, + UNKNOWN: grpcStatus.UNKNOWN, +})[code] ?? grpcStatus.UNKNOWN; + +const errorWithCode = (code, message) => { + const err = new GrpcError(grpcCodeFor(code), `${code}: ${message}`); + err.legacyCode = code; + return err; +}; + +const mergedBindings = (ctx = {}) => ({ + ...(ctx?.bindings ?? {}), + ...(ctx?.config ?? {}), + ...(ctx?.secret ?? {}), +}); + +const resolveCallContext = (ctx = {}) => ({ + ...ctx, + bindings: mergedBindings(ctx), + config: ctx.config ?? {}, + secret: ctx.secret ?? {}, + limits: ctx.limits ?? {}, + meta: ctx.meta ?? {}, + req: ctx.request ?? ctx.req ?? {}, +}); + +const snakeCaseKey = (key) => key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); + +// The SDK serializes request fields with protobuf jsonName (camelCase), so the +// runtime delivers e.g. task_id as taskId. Handlers read snake_case, so mirror +// every camelCase key to its snake_case form — recursively, to also cover +// nested messages such as jumparray entries (ip_range/user_name/user_pwd) — +// without clobbering any snake_case key that is already present. +const normalizeRequest = (value) => { + if (Array.isArray(value)) return value.map((item) => normalizeRequest(item)); + if (value && typeof value === 'object') { + const out = {}; + for (const [key, val] of Object.entries(value)) out[key] = normalizeRequest(val); + for (const key of Object.keys(value)) { + if (!key.includes('_') && /[A-Z]/.test(key)) { + const snakeKey = snakeCaseKey(key); + if (!(snakeKey in out)) out[snakeKey] = out[key]; + } + } + return out; + } + return value; +}; + +const requestFromContext = (ctx = {}) => normalizeRequest(ctx?.request ?? ctx?.req ?? {}); + +const normalizeBaseUrl = (value) => { + const raw = String(unwrapScalar(value) ?? '').trim(); + if (!/^https?:\/\//i.test(raw)) return ''; + return raw.replace(/\/+$/, ''); +}; + +const resolveBaseUrl = (bindings) => normalizeBaseUrl(firstDefined( + bindings?.host, + bindings?.restBaseUrl, + bindings?.rest_base_url, + bindings?.baseUrl, + bindings?.base_url, +)); + +const requireHost = (callCtx) => { + const host = resolveBaseUrl(callCtx?.bindings || {}); + if (!host) throw errorWithCode('INVALID_ARGUMENT', 'host is required in bindings'); + return host; +}; + +const resolveTimeoutMs = (callCtx) => { + const bindings = callCtx?.bindings ?? {}; + const raw = Number(firstDefined(callCtx?.limits?.timeoutMs, bindings.timeoutMs, bindings.timeout_ms, DEFAULT_TIMEOUT_MS)); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS; +}; + +const resolveCurrLang = (callCtx) => { + const bindings = callCtx?.bindings ?? {}; + const raw = String(unwrapScalar(firstDefined(bindings.currLang, bindings.curr_lang)) ?? '').trim().toLowerCase(); + return raw === 'en' ? 'en' : 'cn'; +}; + +// scopeKeys selects which binding spaces to search. Password must stay +// secret-only: config permits arbitrary extra properties and is far more +// likely to be logged, version-controlled, or exported than secret. +const pickCredential = (callCtx, fieldNames, fieldLabel, scopeKeys = ['secret', 'config', 'bindings']) => { + const sources = scopeKeys.map((key) => callCtx?.[key] || {}); + for (const field of fieldNames) { + for (const source of sources) { + const text = String(unwrapScalar(source[field]) ?? '').trim(); + if (text) return text; + } + } + throw errorWithCode('INVALID_ARGUMENT', `${fieldLabel} is required`); +}; + +const authQuery = (callCtx) => ({ + username: pickCredential(callCtx, ['user', 'username'], 'username'), + password: pickCredential(callCtx, ['password'], 'password', ['secret']), + format: 'json', + curr_lang: resolveCurrLang(callCtx), +}); + +// --------------------------------------------------------------------------- +// TLS, coercion helpers +// --------------------------------------------------------------------------- + +const toBoolean = (value) => { + const raw = unwrapScalar(value); + if (typeof raw === 'boolean') return raw; + if (typeof raw === 'number') return raw !== 0; + if (typeof raw === 'string') { + const normalized = raw.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'n', 'off', ''].includes(normalized)) return false; + } + return false; +}; + +let insecureTlsDispatcher; +const getInsecureTlsDispatcher = () => { + insecureTlsDispatcher ??= new Agent({ connect: { rejectUnauthorized: false } }); + return insecureTlsDispatcher; +}; + +const buildTlsOptions = (bindings) => { + if (!toBoolean(bindings?.skipTlsVerify) && !toBoolean(bindings?.tlsInsecureSkipVerify) && !toBoolean(bindings?.insecureSkipVerify)) return {}; + return { dispatcher: getInsecureTlsDispatcher() }; +}; + +const toInteger = (value, fallback = 0) => { + const num = Number(unwrapScalar(value)); + if (!Number.isFinite(num) || Number.isNaN(num)) return fallback; + return Math.trunc(num); +}; + +// For optional int64 request fields: proto3 scalars decode unset values as 0, +// so `=== undefined` cannot distinguish "unset" from "0". These fields +// (template_id, port, type, page, page_size) have no valid 0 business value, +// so treat 0/unset alike and omit them rather than sending 0 downstream. +const toOptionalPositiveInt = (value) => { + const raw = unwrapScalar(value); + if (raw === undefined || raw === null || raw === '') return undefined; + const num = toInteger(raw, 0); + return num > 0 ? num : undefined; +}; + +const toTrimmed = (value) => String(unwrapScalar(value) ?? '').trim(); + +const requireField = (value, field) => { + const text = toTrimmed(value); + if (!text) throw errorWithCode('INVALID_ARGUMENT', `${field} is required`); + return text; +}; + +const toValue = (val) => { + const raw = unwrapScalar(val); + if (raw === undefined || raw === null) return undefined; + if (typeof raw === 'string') return { stringValue: raw }; + if (typeof raw === 'number') return { numberValue: raw }; + if (typeof raw === 'boolean') return { boolValue: raw }; + if (Array.isArray(raw)) { + return { listValue: { values: raw.map((item) => toValue(item)).filter((item) => item !== undefined) } }; + } + if (typeof raw === 'object') { + const fields = {}; + for (const [key, value] of Object.entries(raw)) { + const normalized = toValue(value); + fields[key] = normalized === undefined ? { nullValue: 'NULL_VALUE' } : normalized; + } + return { structValue: { fields } }; + } + return { stringValue: String(raw) }; +}; + +const appendQuery = (url, params = {}) => { + const pairs = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + if (pairs.length === 0) return url; + return url.includes('?') ? `${url}&${pairs.join('&')}` : `${url}?${pairs.join('&')}`; +}; + +// --------------------------------------------------------------------------- +// HTTP transport +// --------------------------------------------------------------------------- + +const parseJsonOrThrow = (text) => { + try { + return JSON.parse(text); + } catch { + throw errorWithCode('UNKNOWN', 'response is not valid JSON'); + } +}; + +// Map RSAS transport-level HTTP failures to gRPC legacy codes before body parsing. +const guardHttpStatus = (status) => { + if (status === 401) throw errorWithCode('PERMISSION_DENIED', 'authentication failed (HTTP 401)'); + if (status === 403) throw errorWithCode('PERMISSION_DENIED', 'not authorized (HTTP 403)'); + if (status === 404) throw errorWithCode('INVALID_ARGUMENT', 'no such interface (HTTP 404)'); + if (status === 405) throw errorWithCode('INVALID_ARGUMENT', 'method not allowed (HTTP 405)'); + if (status === 429) throw errorWithCode('RESOURCE_EXHAUSTED', 'too many requests (HTTP 429)'); + if (status >= 500) throw errorWithCode('UNAVAILABLE', `upstream error (HTTP ${status})`); +}; + +const doFetch = async (callCtx, url, init = {}) => { + let res; + try { + res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(resolveTimeoutMs(callCtx)), + ...buildTlsOptions(callCtx.bindings), + headers: { + ...(callCtx.bindings?.headers || {}), + ...(init.headers || {}), + }, + }); + } catch (err) { + throw errorWithCode('UNAVAILABLE', err?.cause?.message || err?.message || 'fetch failed'); + } + return res; +}; + +// Send a request whose response is expected to be the standard RSAS JSON envelope. +const requestJson = async (callCtx, { path, query = {}, method: httpMethod = 'GET', form, json }) => { + const host = requireHost(callCtx); + const url = appendQuery(`${host}${path}`, { ...authQuery(callCtx), ...query }); + const init = { method: httpMethod }; + if (form !== undefined) { + init.body = form; // FormData sets its own multipart Content-Type + boundary. + } else if (json !== undefined) { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(json); + } + const res = await doFetch(callCtx, url, init); + const status = toInteger(res.status, 0); + const text = await res.text(); + guardHttpStatus(status); + if (!String(text || '').trim()) throw errorWithCode('UNKNOWN', 'response body is empty'); + return { status, json: parseJsonOrThrow(text) }; +}; + +// Send a request whose response is a binary file (report zip / agent installer). +const requestBinary = async (callCtx, { path, query = {}, method: httpMethod = 'GET' }) => { + const host = requireHost(callCtx); + const url = appendQuery(`${host}${path}`, { ...authQuery(callCtx), ...query }); + const res = await doFetch(callCtx, url, { method: httpMethod }); + const status = toInteger(res.status, 0); + const contentType = String(res.headers?.get?.('content-type') ?? ''); + // A JSON body on a download endpoint signals an error envelope, not a file. + if (/application\/json/i.test(contentType)) { + const text = await res.text(); + guardHttpStatus(status); + const parsed = parseJsonOrThrow(text || '{}'); + throw errorWithCode('UNKNOWN', String(parsed?.ret_msg ?? 'download failed')); + } + guardHttpStatus(status); + const buffer = Buffer.from(await res.arrayBuffer()); + return { + http_status: status, + content_type: contentType, + content_length: buffer.length, + filename: filenameFromContentDisposition(res.headers?.get?.('content-disposition')), + body_base64: buffer.toString('base64'), + }; +}; + +const filenameFromContentDisposition = (value) => { + const raw = String(value ?? ''); + const star = raw.match(/filename\*=(?:UTF-8'')?([^;]+)/i); + if (star?.[1]) { + try { + return decodeURIComponent(star[1].trim().replace(/^"|"$/g, '')); + } catch { + return star[1].trim().replace(/^"|"$/g, ''); + } + } + const plain = raw.match(/filename="?([^";]+)"?/i); + return plain?.[1] ? plain[1].trim() : ''; +}; + +// --------------------------------------------------------------------------- +// Response normalization +// --------------------------------------------------------------------------- + +const toRsasResponse = ({ status, json }) => ({ + ret_code: toInteger(json?.ret_code, 0), + ret_msg: String(json?.ret_msg ?? ''), + data: toValue(json?.data), + http_status: toInteger(status, 0), +}); + +const toTaskCreateResponse = ({ status, json }) => { + const data = json?.data; + const taskId = firstDefined(data?.task_id, data?.taskId); + return { + ...toRsasResponse({ status, json }), + task_id: taskId === undefined || taskId === null ? '' : String(taskId), + }; +}; + +// --------------------------------------------------------------------------- +// Multipart form builders +// --------------------------------------------------------------------------- + +const formFromFields = (fields) => { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + if (value === undefined || value === null || value === '') continue; + form.append(key, String(value)); + } + return form; +}; + +const appendFile = (form, field, base64, filename, contentType = 'application/octet-stream') => { + const buffer = Buffer.from(String(base64 ?? ''), 'base64'); + form.append(field, new Blob([buffer], { type: contentType }), filename); +}; + +// --------------------------------------------------------------------------- +// Handlers: task lifecycle +// --------------------------------------------------------------------------- + +const handleCreateTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const configXml = requireField(req?.config_xml, 'config_xml'); + const form = formFromFields({ type: toTrimmed(req?.type) || '1' }); + const filename = toTrimmed(req?.config_xml_filename) || 'config.xml'; + form.append('config_xml', new Blob([Buffer.from(configXml, 'utf8')], { type: 'text/xml' }), filename); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/create', method: 'POST', form })); +}; + +const handleCreateVulnTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + targets: requireField(req?.targets, 'targets'), + template_id: toOptionalPositiveInt(req?.template_id), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/vul/create', method: 'POST', form })); +}; + +const handleCreateBaselineTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + targets: requireField(req?.targets, 'targets'), + login_type: requireField(req?.login_type, 'login_type'), + login_port: requireField(req?.login_port, 'login_port'), + login_name: requireField(req?.login_name, 'login_name'), + login_password: requireField(req?.login_password, 'login_password'), + template_uuid: requireField(req?.template_uuid, 'template_uuid'), + template_param: toTrimmed(req?.template_param) || '{}', + protect_level: toTrimmed(req?.protect_level), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/baseline/create', method: 'POST', form })); +}; + +const handleCreatePwdTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + targets: requireField(req?.targets, 'targets'), + template_id: requireField(req?.template_id, 'template_id'), + service_type: requireField(req?.service_type, 'service_type'), + pass_mode: toTrimmed(req?.pass_mode), + port: toOptionalPositiveInt(req?.port), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/pwd/create', method: 'POST', form })); +}; + +const handleCreateWebTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + targets: requireField(req?.targets, 'targets'), + template_id: toTrimmed(req?.template_id), + component_scan: toTrimmed(req?.component_scan), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/web/create', method: 'POST', form })); +}; + +const handleCreateOfflineTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const base64 = requireField(req?.task_field_base64, 'task_field_base64'); + const form = new FormData(); + appendFile(form, 'taskField', base64, toTrimmed(req?.filename) || 'offline_result.dat'); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/offline/create', method: 'POST', form })); +}; + +const handleCreateDockerTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + targets: requireField(req?.targets, 'targets'), + registry_address: toTrimmed(req?.registry_address), + registry_username: toTrimmed(req?.registry_username), + registry_password: toTrimmed(req?.registry_password), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/docker/create', method: 'POST', form })); +}; + +const handleCreateCodeauditTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + code_source: toInteger(requireField(req?.code_source, 'code_source'), 0), + repo_path: requireField(req?.repo_path, 'repo_path'), + repo_username: toTrimmed(req?.repo_username), + repo_password: toTrimmed(req?.repo_password), + template_id: toOptionalPositiveInt(req?.template_id), + exclude_files: toTrimmed(req?.exclude_files), + exclude_dirs: toTrimmed(req?.exclude_dirs), + git_branch: toTrimmed(req?.git_branch), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/codeaudit/create', method: 'POST', form })); +}; + +const handleCreateHostAssetsTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + targets: requireField(req?.targets, 'targets'), + is_related: toTrimmed(req?.is_related), + template_id: toTrimmed(req?.template_id), + prohibitConfig: toTrimmed(req?.prohibit_config), + prohibitPorts: toTrimmed(req?.prohibit_ports), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/hostassets/create', method: 'POST', form })); +}; + +const handleCreateWebAssetsTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + name: requireField(req?.name, 'name'), + targets: requireField(req?.targets, 'targets'), + is_related: toTrimmed(req?.is_related), + is_sitetree: toTrimmed(req?.is_sitetree), + }); + return toTaskCreateResponse(await requestJson(callCtx, { path: '/api/task/webassets/create', method: 'POST', form })); +}; + +const requireTaskId = (req) => encodeURIComponent(requireField(req?.task_id, 'task_id')); + +const handleGetTaskStatus = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: `/api/task/status/${requireTaskId(req)}`, method: 'GET' })); +}; + +const handlePauseTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: `/api/task/pause/${requireTaskId(req)}`, method: 'POST' })); +}; + +const handleResumeTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: `/api/task/resume/${requireTaskId(req)}`, method: 'POST' })); +}; + +const handleStopTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: `/api/task/stop/${requireTaskId(req)}`, method: 'POST' })); +}; + +const handleDeleteTask = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: `/api/task/delete/${requireTaskId(req)}`, method: 'POST' })); +}; + +const handleBatchDeleteTasks = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const ids = (Array.isArray(req?.task_ids) ? req.task_ids : []).map((id) => toInteger(id, 0)).filter((id) => id > 0); + if (ids.length === 0) throw errorWithCode('INVALID_ARGUMENT', 'task_ids must contain positive integers'); + return toRsasResponse(await requestJson(callCtx, { path: '/api/task/batch_delete', method: 'POST', json: { task_ids: ids } })); +}; + +const handleListTasks = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const pageVal = toInteger(req?.page, 0); + const pageSizeVal = toInteger(req?.page_size, 0); + const query = { + type: toOptionalPositiveInt(req?.type), + end_time_start: toTrimmed(req?.end_time_start), + end_time_end: toTrimmed(req?.end_time_end), + isfinished: toTrimmed(req?.isfinished), + // RSAS pagination is 1-based; send sane defaults rather than a proto3 0. + page: pageVal > 0 ? pageVal : 1, + page_size: pageSizeVal > 0 ? pageSizeVal : 10, + }; + return toRsasResponse(await requestJson(callCtx, { path: '/api/task/list', method: 'GET', query })); +}; + +const handleListActiveTasks = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/task/active_list', method: 'GET' })); +}; + +const handleGetTaskResult = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const pageVal = toInteger(req?.page, 0); + const pageSizeVal = toInteger(req?.page_size, 0); + const query = { + targets: toTrimmed(req?.targets), + page: pageVal > 0 ? pageVal : 1, + page_size: pageSizeVal > 0 ? pageSizeVal : 20, + }; + return toRsasResponse(await requestJson(callCtx, { path: `/api/report/task/${requireTaskId(req)}`, method: 'GET', query })); +}; + +const handleCreateAuthInfo = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const entries = Array.isArray(req?.authinfo) ? req.authinfo : []; + if (entries.length === 0) throw errorWithCode('INVALID_ARGUMENT', 'authinfo must contain at least one entry'); + const payload = entries.map((entry) => ({ + accountname: requireField(entry?.accountname, 'authinfo.accountname'), + accountpwd: toTrimmed(entry?.accountpwd), + port: requireField(entry?.port, 'authinfo.port'), + protocol: requireField(entry?.protocol, 'authinfo.protocol'), + ip: requireField(entry?.ip, 'authinfo.ip'), + })); + const form = formFromFields({ authinfo: JSON.stringify(payload) }); + return toRsasResponse(await requestJson(callCtx, { path: '/api/authinfo/create', method: 'POST', form })); +}; + +const handleLoginVerify = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const jumparray = (Array.isArray(req?.jumparray) ? req.jumparray : []).map((jh) => ({ + ip_range: toTrimmed(jh?.ip_range), + protocol: toTrimmed(jh?.protocol), + user_name: toTrimmed(jh?.user_name), + user_pwd: toTrimmed(jh?.user_pwd), + port: toInteger(jh?.port, 0), + })); + const form = formFromFields({ + ip: requireField(req?.ip, 'ip'), + protocol: requireField(req?.protocol, 'protocol'), + port: toInteger(requireField(req?.port, 'port'), 0), + user_name: requireField(req?.user_name, 'user_name'), + userpwd: requireField(req?.userpwd, 'userpwd'), + jump_ifuse: toBoolean(req?.jump_ifuse) ? 'true' : 'false', + jumparray: jumparray.length ? JSON.stringify(jumparray) : undefined, + web_login_url: toTrimmed(req?.web_login_url), + web_login_cookie: toTrimmed(req?.web_login_cookie), + format: 'json', + }); + return toRsasResponse(await requestJson(callCtx, { path: '/api/auth/login_verify', method: 'POST', form })); +}; + +// --------------------------------------------------------------------------- +// Handlers: templates and dictionaries +// --------------------------------------------------------------------------- + +const handleListSysvulnTemplate = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/template/sysvuln/list', method: 'GET' })); +}; + +const handleListWebvulnTemplate = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/template/webvuln/list', method: 'GET' })); +}; + +const handleListBaselineTemplate = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const query = { industry: toTrimmed(req?.industry) }; + return toRsasResponse(await requestJson(callCtx, { path: '/api/template/baseline/list', method: 'GET', query })); +}; + +const handleGetBaselineParams = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const query = { uuid: requireField(req?.uuid, 'uuid') }; + return toRsasResponse(await requestJson(callCtx, { path: '/api/template/baseline/params', method: 'GET', query })); +}; + +const handleListCodeauditTemplate = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const query = { type: req?.type === undefined ? undefined : toInteger(req.type, 0) }; + return toRsasResponse(await requestJson(callCtx, { path: '/api/template/codeaudit/list', method: 'GET', query })); +}; + +const handleListAssetTemplate = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/template/asset/list', method: 'GET' })); +}; + +const handleCreateBaselineTemplate = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const base64 = requireField(req?.template_base64, 'template_base64'); + const form = new FormData(); + appendFile(form, 'template', base64, toTrimmed(req?.filename) || 'template.dat'); + return toRsasResponse(await requestJson(callCtx, { path: '/api/template/baseline/create', method: 'POST', form })); +}; + +const handleListUserpwd = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/userpwd/list', method: 'GET' })); +}; + +const handleCreateUserpwd = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ userpwd: requireField(req?.userpwd, 'userpwd') }); + return toRsasResponse(await requestJson(callCtx, { path: '/api/userpwd/create', method: 'POST', form })); +}; + +// --------------------------------------------------------------------------- +// Handlers: system, logs, reports +// --------------------------------------------------------------------------- + +const handleGetSystemStatus = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/system/status', method: 'GET' })); +}; + +const handleGetLogInfo = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const query = { + account: toTrimmed(req?.account), + type: toTrimmed(req?.type), + starttime: toTrimmed(req?.starttime), + endtime: toTrimmed(req?.endtime), + ip: toTrimmed(req?.ip), + }; + return toRsasResponse(await requestJson(callCtx, { path: '/api/log/getlogInfo', method: 'GET', query })); +}; + +const handleListReportTemplate = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/report/template/list', method: 'GET' })); +}; + +const handleGenerateReport = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const form = formFromFields({ + task_id: requireField(req?.task_id, 'task_id'), + summary_template_id: toTrimmed(req?.summary_template_id), + host_template_id: toTrimmed(req?.host_template_id), + report_type: requireField(req?.report_type, 'report_type'), + }); + return toRsasResponse(await requestJson(callCtx, { path: '/api/generate_report/', method: 'POST', form })); +}; + +const handleGetReportProgress = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const reportId = encodeURIComponent(requireField(req?.report_id, 'report_id')); + return toRsasResponse(await requestJson(callCtx, { path: `/api/get_report_progress/report_id/${reportId}`, method: 'GET' })); +}; + +const handleDownloadReport = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const reportId = encodeURIComponent(requireField(req?.report_id, 'report_id')); + const reportType = encodeURIComponent(requireField(req?.report_type, 'report_type')); + return requestBinary(callCtx, { path: `/api/download_report/report_id/${reportId}/report_type/${reportType}`, method: 'GET' }); +}; + +const handleDeleteReports = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + let payload; + if (toBoolean(req?.all)) { + payload = { report_ids: 'all' }; + } else { + const ids = (Array.isArray(req?.report_ids) ? req.report_ids : []).map((id) => toInteger(id, 0)).filter((id) => id > 0); + if (ids.length === 0) throw errorWithCode('INVALID_ARGUMENT', 'report_ids must contain positive integers or set all=true'); + payload = { report_ids: ids }; + } + return toRsasResponse(await requestJson(callCtx, { path: '/api/delete_reports', method: 'POST', json: payload })); +}; + +// --------------------------------------------------------------------------- +// Handlers: agent +// --------------------------------------------------------------------------- + +const handleGetAgentMethodConfig = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/agent/agent_method_config', method: 'GET' })); +}; + +const handleSetAgentMethodConfig = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const agentMethod = toInteger(req?.agent_method, 0); + return toRsasResponse(await requestJson(callCtx, { path: '/api/agent/agent_method_config', method: 'POST', json: { agent_method: agentMethod } })); +}; + +const handleGetAgentAuth = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/agent/get_agent_auth', method: 'GET' })); +}; + +const handleGetAgentPackageUrl = async (_req, ctx) => { + const callCtx = resolveCallContext(ctx); + return toRsasResponse(await requestJson(callCtx, { path: '/api/agent/get_agentpackage_url', method: 'GET' })); +}; + +const handleDownloadAgent = async (req, ctx) => { + const callCtx = resolveCallContext(ctx); + const platform = requireField(req?.platform, 'platform').toLowerCase(); + if (platform !== 'linux' && platform !== 'windows') { + throw errorWithCode('INVALID_ARGUMENT', 'platform must be "linux" or "windows"'); + } + return requestBinary(callCtx, { path: `/api/agent/download/${platform}`, method: 'GET' }); +}; + +// --------------------------------------------------------------------------- +// Handler registry +// --------------------------------------------------------------------------- + +const HANDLERS = { + CreateTask: handleCreateTask, + CreateVulnTask: handleCreateVulnTask, + CreateBaselineTask: handleCreateBaselineTask, + CreatePwdTask: handleCreatePwdTask, + CreateWebTask: handleCreateWebTask, + CreateOfflineTask: handleCreateOfflineTask, + CreateDockerTask: handleCreateDockerTask, + CreateCodeauditTask: handleCreateCodeauditTask, + CreateHostAssetsTask: handleCreateHostAssetsTask, + CreateWebAssetsTask: handleCreateWebAssetsTask, + GetTaskStatus: handleGetTaskStatus, + PauseTask: handlePauseTask, + ResumeTask: handleResumeTask, + StopTask: handleStopTask, + DeleteTask: handleDeleteTask, + BatchDeleteTasks: handleBatchDeleteTasks, + ListTasks: handleListTasks, + ListActiveTasks: handleListActiveTasks, + GetTaskResult: handleGetTaskResult, + CreateAuthInfo: handleCreateAuthInfo, + LoginVerify: handleLoginVerify, + ListSysvulnTemplate: handleListSysvulnTemplate, + ListWebvulnTemplate: handleListWebvulnTemplate, + ListBaselineTemplate: handleListBaselineTemplate, + GetBaselineParams: handleGetBaselineParams, + ListCodeauditTemplate: handleListCodeauditTemplate, + ListAssetTemplate: handleListAssetTemplate, + CreateBaselineTemplate: handleCreateBaselineTemplate, + ListUserpwd: handleListUserpwd, + CreateUserpwd: handleCreateUserpwd, + GetSystemStatus: handleGetSystemStatus, + GetLogInfo: handleGetLogInfo, + ListReportTemplate: handleListReportTemplate, + GenerateReport: handleGenerateReport, + GetReportProgress: handleGetReportProgress, + DownloadReport: handleDownloadReport, + DeleteReports: handleDeleteReports, + GetAgentMethodConfig: handleGetAgentMethodConfig, + SetAgentMethodConfig: handleSetAgentMethodConfig, + GetAgentAuth: handleGetAgentAuth, + GetAgentPackageUrl: handleGetAgentPackageUrl, + DownloadAgent: handleDownloadAgent, +}; + +// Exported as { [fullMethodName]: (ctx) => Promise } — single-arg ctx signature +// is required by the package validator. +export const handlers = Object.fromEntries( + Object.entries(HANDLERS).map(([name, fn]) => [method(name), (ctx = {}) => fn(requestFromContext(ctx), ctx)]), +); + +export const METHODS = Object.fromEntries(Object.keys(HANDLERS).map((name) => [name, method(name)])); + +export const _test = { + appendQuery, + authQuery, + buildTlsOptions, + errorWithCode, + filenameFromContentDisposition, + formFromFields, + guardHttpStatus, + normalizeBaseUrl, + normalizeRequest, + pickCredential, + requestBinary, + requestJson, + requireField, + resolveBaseUrl, + resolveCallContext, + resolveCurrLang, + resolveTimeoutMs, + toBoolean, + toInteger, + toOptionalPositiveInt, + toRsasResponse, + toTaskCreateResponse, + toValue, +}; diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/src/service.js b/services/nsfocus__rsas_v6-0r04f04sp09/src/service.js new file mode 100644 index 00000000..edf735e3 --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from '@chaitin-ai/octobus-sdk'; + +import { handlers } from './nsfocus-rsas-v6-0r04f04sp09.js'; + +export { handlers } from './nsfocus-rsas-v6-0r04f04sp09.js'; + +export const service = defineService({ handlers }); diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/test/mock_upstream.js b/services/nsfocus__rsas_v6-0r04f04sp09/test/mock_upstream.js new file mode 100644 index 00000000..84759802 Binary files /dev/null and b/services/nsfocus__rsas_v6-0r04f04sp09/test/mock_upstream.js differ diff --git a/services/nsfocus__rsas_v6-0r04f04sp09/test/nsfocus-rsas-v6-0r04f04sp09.test.js b/services/nsfocus__rsas_v6-0r04f04sp09/test/nsfocus-rsas-v6-0r04f04sp09.test.js new file mode 100644 index 00000000..a9932800 --- /dev/null +++ b/services/nsfocus__rsas_v6-0r04f04sp09/test/nsfocus-rsas-v6-0r04f04sp09.test.js @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { METHODS, _test, handlers } from '../src/nsfocus-rsas-v6-0r04f04sp09.js'; +import { service } from '../src/service.js'; +import { createMockServer } from './mock_upstream.js'; + +const originalFetch = globalThis.fetch; + +const buildCtx = (overrides = {}) => ({ + bindings: { host: 'http://device.example:8443', ...(overrides.bindings || {}) }, + config: overrides.config || {}, + secret: { user: 'admin', password: 'Rsas@123', ...(overrides.secret || {}) }, + limits: { timeoutMs: 10_000, ...(overrides.limits || {}) }, + meta: { instance_id: overrides.instance_id || 'inst-1', request_id: 'req' }, + request: overrides.request || {}, +}); + +const call = (methodName, request = {}, ctx = {}) => handlers[METHODS[methodName]]({ ...buildCtx(ctx), request }); + +const setFetch = (impl) => { globalThis.fetch = impl; }; + +const expectGrpc = async (fn, legacyCode) => { + let caught; + try { + await fn(); + } catch (err) { + caught = err; + } + assert.ok(caught instanceof GrpcError, 'expected GrpcError'); + assert.equal(caught.legacyCode, legacyCode); + assert.match(caught.message, new RegExp(`^${legacyCode}:`)); +}; + +test.afterEach(() => { globalThis.fetch = originalFetch; }); + +test('service and handler registry are wired', () => { + assert.equal(typeof service, 'object'); + assert.equal(Object.keys(handlers).length, 42); + assert.equal(typeof handlers[METHODS.GetSystemStatus], 'function'); + assert.equal(typeof handlers[METHODS.DownloadAgent], 'function'); +}); + +test('helpers: base url, coercion, currLang', () => { + assert.equal(_test.resolveBaseUrl({ host: 'https://d/' }), 'https://d'); + assert.equal(_test.resolveBaseUrl({ base_url: 'ftp://x' }), ''); + assert.equal(_test.toInteger('42abc', 0), 0); + assert.equal(_test.toInteger(' 7 ', 0), 7); + assert.equal(_test.toBoolean('yes'), true); + assert.equal(_test.toBoolean('off'), false); + assert.equal(_test.resolveCurrLang(_test.resolveCallContext({ config: { currLang: 'en' } })), 'en'); + assert.equal(_test.resolveCurrLang(_test.resolveCallContext({})), 'cn'); +}); + +test('authQuery pulls credentials with precedence and rejects when missing', () => { + const ctx = _test.resolveCallContext(buildCtx({ secret: { user: 'u', password: 'p' } })); + assert.deepEqual(_test.authQuery(ctx), { username: 'u', password: 'p', format: 'json', curr_lang: 'cn' }); + const noCred = _test.resolveCallContext({ bindings: { host: 'http://x' } }); + assert.throws(() => _test.authQuery(noCred), /INVALID_ARGUMENT/); +}); + +test('password is read from secret only, never from config or bindings', () => { + // username may fall back to config for dev, but password must come from secret. + const ctx = _test.resolveCallContext({ + secret: { user: 'u' }, + config: { password: 'leaky-from-config' }, + bindings: { host: 'http://x', password: 'leaky-from-bindings' }, + }); + assert.throws(() => _test.authQuery(ctx), /password is required/); + const ok = _test.resolveCallContext({ secret: { user: 'u', password: 'p' }, config: { user: 'cfg-user' } }); + assert.equal(_test.authQuery(ok).password, 'p'); +}); + +test('toOptionalPositiveInt treats 0/unset as omitted (proto3 default guard)', () => { + assert.equal(_test.toOptionalPositiveInt(undefined), undefined); + assert.equal(_test.toOptionalPositiveInt(0), undefined); + assert.equal(_test.toOptionalPositiveInt('0'), undefined); + assert.equal(_test.toOptionalPositiveInt(''), undefined); + assert.equal(_test.toOptionalPositiveInt(5), 5); + assert.equal(_test.toOptionalPositiveInt('22'), 22); +}); + +test('filenameFromContentDisposition parses plain and extended forms', () => { + assert.equal(_test.filenameFromContentDisposition('attachment; filename="a.zip"'), 'a.zip'); + assert.equal(_test.filenameFromContentDisposition("attachment; filename*=UTF-8''b%20c.zip"), 'b c.zip'); + assert.equal(_test.filenameFromContentDisposition(''), ''); +}); + +test('appendQuery encodes and skips empties', () => { + assert.equal(_test.appendQuery('http://x/api', { a: '1 2', b: '', c: undefined }), 'http://x/api?a=1%202'); + assert.equal(_test.appendQuery('http://x?z=1', { a: 'b' }), 'http://x?z=1&a=b'); +}); + +test('missing host and required args reject with INVALID_ARGUMENT', async () => { + await expectGrpc(() => call('GetSystemStatus', {}, { bindings: { host: 'device.local' } }), 'INVALID_ARGUMENT'); + await expectGrpc(() => call('CreateVulnTask', { targets: '1.1.1.1' }), 'INVALID_ARGUMENT'); + await expectGrpc(() => call('GetTaskStatus', {}), 'INVALID_ARGUMENT'); + await expectGrpc(() => call('BatchDeleteTasks', { task_ids: [] }), 'INVALID_ARGUMENT'); + await expectGrpc(() => call('DownloadAgent', { platform: 'mac' }), 'INVALID_ARGUMENT'); + await expectGrpc(() => call('DeleteReports', {}), 'INVALID_ARGUMENT'); +}); + +test('HTTP status mapping via guardHttpStatus', () => { + assert.throws(() => _test.guardHttpStatus(401), /PERMISSION_DENIED/); + assert.throws(() => _test.guardHttpStatus(403), /PERMISSION_DENIED/); + assert.throws(() => _test.guardHttpStatus(404), /INVALID_ARGUMENT/); + assert.throws(() => _test.guardHttpStatus(429), /RESOURCE_EXHAUSTED/); + assert.throws(() => _test.guardHttpStatus(500), /UNAVAILABLE/); + assert.doesNotThrow(() => _test.guardHttpStatus(200)); +}); + +test('network failure maps to UNAVAILABLE', async () => { + setFetch(async () => { throw new Error('ECONNREFUSED'); }); + await expectGrpc(() => call('GetSystemStatus'), 'UNAVAILABLE'); +}); + +test('empty body maps to UNKNOWN', async () => { + setFetch(async () => ({ status: 200, headers: { get: () => 'application/json' }, text: async () => '', arrayBuffer: async () => new ArrayBuffer(0) })); + await expectGrpc(() => call('GetSystemStatus'), 'UNKNOWN'); +}); + +// --- integration against the mock device --- + +test('GetSystemStatus returns normalized envelope', async () => { + const mock = await createMockServer(); + try { + const out = await call('GetSystemStatus', {}, { bindings: { host: mock.url } }); + assert.equal(out.ret_code, 0); + assert.equal(out.http_status, 200); + assert.equal(out.data.structValue.fields.version.stringValue, 'V6.0R04F04SP09'); + const req = mock.requests.find((r) => r.pathname === '/api/system/status'); + assert.equal(req.query.username, 'admin'); + assert.equal(req.query.format, 'json'); + } finally { + await mock.close(); + } +}); + +test('bad credentials produce PERMISSION_DENIED', async () => { + const mock = await createMockServer(); + try { + await expectGrpc(() => call('GetSystemStatus', {}, { bindings: { host: mock.url }, secret: { user: 'admin', password: 'wrong' } }), 'PERMISSION_DENIED'); + } finally { + await mock.close(); + } +}); + +test('CreateVulnTask surfaces task_id and posts multipart form', async () => { + const mock = await createMockServer(); + try { + const out = await call('CreateVulnTask', { name: 't', targets: '10.65.194.153', template_id: 0 }, { bindings: { host: mock.url } }); + assert.equal(out.ret_code, 0); + assert.equal(out.task_id, '60'); + const req = mock.requests.find((r) => r.pathname === '/api/task/vul/create'); + assert.equal(req.method, 'POST'); + assert.match(req.rawBody, /name="targets"/); + assert.match(req.rawBody, /10\.65\.194\.153/); + } finally { + await mock.close(); + } +}); + +test('GetTaskStatus, ListTasks, GetTaskResult round-trip', async () => { + const mock = await createMockServer(); + try { + const status = await call('GetTaskStatus', { task_id: '60' }, { bindings: { host: mock.url } }); + assert.equal(status.data.structValue.fields.status.numberValue, 4); + const list = await call('ListTasks', { type: 1, page: 1, page_size: 10 }, { bindings: { host: mock.url } }); + assert.equal(list.ret_code, 0); + const listReq = mock.requests.find((r) => r.pathname === '/api/task/list'); + assert.equal(listReq.query.type, '1'); + const result = await call('GetTaskResult', { task_id: '60', targets: '10.65.194.153' }, { bindings: { host: mock.url } }); + assert.equal(result.ret_code, 0); + } finally { + await mock.close(); + } +}); + +test('ListTasks omits type but defaults page/page_size on proto3 default 0', async () => { + const mock = await createMockServer(); + try { + // Simulates a decoded proto3 request where unset int64 fields arrive as 0. + await call('ListTasks', { type: 0, page: 0, page_size: 0 }, { bindings: { host: mock.url } }); + const req = mock.requests.find((r) => r.pathname === '/api/task/list'); + assert.equal(req.query.type, undefined); + // 1-based pagination defaults, never a proto3 0. + assert.equal(req.query.page, '1'); + assert.equal(req.query.page_size, '10'); + assert.equal(req.query.format, 'json'); + } finally { + await mock.close(); + } +}); + +test('normalizeRequest mirrors camelCase runtime keys to snake_case, recursively', () => { + const out = _test.normalizeRequest({ + taskId: '60', + templateUuid: 'u-1', + jumparray: [{ ipRange: '10.0.0.2', userName: 'root', userPwd: 'x', port: 22 }], + }); + assert.equal(out.task_id, '60'); + assert.equal(out.template_uuid, 'u-1'); + assert.equal(out.jumparray[0].ip_range, '10.0.0.2'); + assert.equal(out.jumparray[0].user_name, 'root'); + assert.equal(out.jumparray[0].user_pwd, 'x'); + // pre-existing snake_case keys are preserved and not clobbered + const keep = _test.normalizeRequest({ task_id: 'a', taskId: 'b' }); + assert.equal(keep.task_id, 'a'); +}); + +test('handlers resolve camelCase request keys end-to-end (real-runtime shape)', async () => { + const mock = await createMockServer(); + try { + // The CLI runtime delivers task_id as taskId; the handler must still work. + const out = await call('GetTaskStatus', { taskId: '60' }, { bindings: { host: mock.url } }); + assert.equal(out.ret_code, 0); + const req = mock.requests.find((r) => r.pathname.startsWith('/api/task/status/')); + assert.equal(req.pathname, '/api/task/status/60'); + } finally { + await mock.close(); + } +}); + +test('CreatePwdTask omits port when it is proto3 default 0', async () => { + const mock = await createMockServer(); + try { + // pwd endpoint returns 404 in mock, but we only assert the outgoing form here. + await call('CreatePwdTask', { + name: 't', targets: '1.1.1.1', template_id: '188', service_type: 'SSH', port: 0, + }, { bindings: { host: mock.url } }).catch(() => {}); + const req = mock.requests.find((r) => r.pathname === '/api/task/pwd/create'); + assert.ok(req, 'expected a request to pwd/create'); + assert.doesNotMatch(req.rawBody, /name="port"/); + } finally { + await mock.close(); + } +}); + +test('BatchDeleteTasks sends JSON body', async () => { + const mock = await createMockServer(); + try { + const out = await call('BatchDeleteTasks', { task_ids: [1, 2, 0, -3] }, { bindings: { host: mock.url } }); + assert.equal(out.ret_code, 0); + const req = mock.requests.find((r) => r.pathname === '/api/task/batch_delete'); + assert.deepEqual(JSON.parse(req.rawBody), { task_ids: [1, 2] }); + } finally { + await mock.close(); + } +}); + +test('report generate + progress + download flow', async () => { + const mock = await createMockServer(); + try { + const gen = await call('GenerateReport', { task_id: '60', report_type: 'pdf,html' }, { bindings: { host: mock.url } }); + assert.equal(gen.data.structValue.fields.report_id.numberValue, 16); + const prog = await call('GetReportProgress', { report_id: '16' }, { bindings: { host: mock.url } }); + assert.equal(prog.data.structValue.fields.progress.numberValue, 100); + const dl = await call('DownloadReport', { report_id: '16', report_type: 'html' }, { bindings: { host: mock.url } }); + assert.equal(dl.http_status, 200); + assert.equal(dl.content_type, 'application/zip'); + assert.equal(dl.filename, 'report_30.zip'); + const decoded = Buffer.from(dl.body_base64, 'base64'); + assert.equal(decoded.subarray(0, 2).toString('ascii'), 'PK'); + assert.ok(decoded.toString('utf8').includes('fake-report-zip')); + assert.equal(dl.content_length, decoded.length); + } finally { + await mock.close(); + } +}); + +test('agent config get/set and installer download', async () => { + const mock = await createMockServer(); + try { + const get = await call('GetAgentMethodConfig', {}, { bindings: { host: mock.url } }); + assert.equal(get.data.structValue.fields.agent_method.stringValue, '1'); + const set = await call('SetAgentMethodConfig', { agent_method: 1 }, { bindings: { host: mock.url } }); + assert.equal(set.ret_code, 0); + const setReq = mock.requests.find((r) => r.pathname === '/api/agent/agent_method_config' && r.method === 'POST'); + assert.deepEqual(JSON.parse(setReq.rawBody), { agent_method: 1 }); + const dl = await call('DownloadAgent', { platform: 'Linux' }, { bindings: { host: mock.url } }); + assert.equal(dl.content_type, 'application/octet-stream'); + assert.equal(dl.filename, 'NSFOCUS-Agent-Linux_x86-1.0.0.run'); + } finally { + await mock.close(); + } +}); + +test('ListSysvulnTemplate returns list data', async () => { + const mock = await createMockServer(); + try { + const out = await call('ListSysvulnTemplate', {}, { bindings: { host: mock.url } }); + assert.equal(out.data.listValue.values[0].structValue.fields.name.stringValue, '端口扫描'); + } finally { + await mock.close(); + } +}); + +test('skipTlsVerify wires an undici dispatcher', () => { + assert.deepEqual(_test.buildTlsOptions({}), {}); + const opts = _test.buildTlsOptions({ skipTlsVerify: true }); + assert.ok(opts.dispatcher, 'expected dispatcher when skipTlsVerify is set'); +}); diff --git a/services/package.json b/services/package.json index 91438aeb..b114c660 100644 --- a/services/package.json +++ b/services/package.json @@ -34,6 +34,7 @@ "nsfocus-ads-v4-5-r90-f06": "bin/nsfocus-ads-v4-5-r90-f06.js", "nsfocus-ngfw-v60-9900": "bin/nsfocus-ngfw-v60-9900.js", "nsfocus-nips-v5-6-r11": "bin/nsfocus-nips-v5-6-r11.js", + "nsfocus-rsas-v6-0r04f04sp09": "bin/nsfocus-rsas-v6-0r04f04sp09.js", "owasp-dependency-track-sca-v5-0": "bin/owasp-dependency-track-sca-v5-0.js", "panabit-tang-r1": "bin/panabit-tang-r1.js", "qianxin-fw-secgate3600": "bin/qianxin-fw-secgate3600.js", @@ -102,6 +103,7 @@ "bin/nsfocus-ads-v4-5-r90-f06.js", "bin/nsfocus-ngfw-v60-9900.js", "bin/nsfocus-nips-v5-6-r11.js", + "bin/nsfocus-rsas-v6-0r04f04sp09.js", "bin/owasp-dependency-track-sca-v5-0.js", "bin/panabit-tang-r1.js", "bin/qianxin-fw-secgate3600.js", @@ -166,6 +168,7 @@ "nsfocus__ads_v4-5-r90-f06", "nsfocus__ngfw_v60-9900", "nsfocus__nips_v5-6-r11", + "nsfocus__rsas_v6-0r04f04sp09", "owasp__dependency-track-sca_v5-0", "panabit__tang-r1", "qianxin__fw-secgate3600",