mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 00:27:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6b3773f00 | ||
|
|
c2b950ad53 | ||
|
|
0283fff743 | ||
|
|
018835d6b8 | ||
|
|
4b7df4e0f3 | ||
|
|
0324b41a01 | ||
|
|
4a19620137 | ||
|
|
f8110413c0 | ||
|
|
59dc7cf858 | ||
|
|
52595e07e5 | ||
|
|
e0965594bb | ||
|
|
9ef8263eaf | ||
|
|
3c54a67416 | ||
|
|
98ca395edd | ||
|
|
c616822cd6 | ||
|
|
ba1796d7ce | ||
|
|
dad14c55c1 | ||
|
|
cc0233d7b4 | ||
|
|
7b6f56e476 | ||
|
|
2522fc6ae2 | ||
|
|
99d7380450 | ||
|
|
837e41459a | ||
|
|
5cbd828cad | ||
|
|
8cb317cbd6 | ||
|
|
94a2ba0406 | ||
|
|
4ee7204509 | ||
|
|
c326adbb66 | ||
|
|
7d1e9bdac4 | ||
|
|
151b445c74 | ||
|
|
af4b25b84e | ||
|
|
f0b1955059 | ||
|
|
f5d580bbf0 | ||
|
|
44d069da2b | ||
|
|
9297e6e6ee | ||
|
|
9bbc28c14a | ||
|
|
4943b9419e | ||
|
|
446ccd3edb | ||
|
|
a00643b9c0 | ||
|
|
5c13819f66 | ||
|
|
a7190b8399 | ||
|
|
6f0eaf7667 | ||
|
|
f285d47404 | ||
|
|
273d63413b | ||
|
|
5656315b1e | ||
|
|
cee46f40fa | ||
|
|
4c55784dbc | ||
|
|
d304ff5c99 | ||
|
|
0cd4df3518 | ||
|
|
49333b856c | ||
|
|
a83490f29c | ||
|
|
dbc4ead040 | ||
|
|
4333cf1dd3 | ||
|
|
577c97aab0 | ||
|
|
93ab362b6f | ||
|
|
d9cb0b11c5 | ||
|
|
2d7f5322b3 | ||
|
|
3763da8773 | ||
|
|
3f2984b7c6 | ||
|
|
fbef2afd68 | ||
|
|
bccb324d2b | ||
|
|
b28fba3d68 | ||
|
|
8059b6d5b2 | ||
|
|
0821bb2911 | ||
|
|
e407f8203c | ||
|
|
a59253e828 | ||
|
|
4c989fbbe0 | ||
|
|
91239c9869 | ||
|
|
6892156b7c | ||
|
|
75489123f9 | ||
|
|
631ee6c447 | ||
|
|
7caf77683f | ||
|
|
fce4ffe3c4 | ||
|
|
75163f9269 | ||
|
|
06a9cea97d | ||
|
|
b62ba3b214 | ||
|
|
0062cebc6b | ||
|
|
25ceeb2535 | ||
|
|
0152781598 | ||
|
|
d3176f048d | ||
|
|
1fc522298a | ||
|
|
9464ffe105 | ||
|
|
599de1da0f | ||
|
|
1cb64ceaaa | ||
|
|
97d96d4d8b | ||
|
|
4bb12bbbdc | ||
|
|
c4a56c1ada | ||
|
|
8bd42a6f7a | ||
|
|
b54867e383 | ||
|
|
07229f0fde | ||
|
|
f30e8e7ca6 | ||
|
|
bfe1d28650 | ||
|
|
c925c2e74b | ||
|
|
4bbbe3bcf8 | ||
|
|
944957d4c5 | ||
|
|
fa9229b435 | ||
|
|
b707ea4e44 | ||
|
|
18824857be | ||
|
|
78c572927b | ||
|
|
cea8d4bcc0 | ||
|
|
25fac216ea | ||
|
|
e5507ed8d0 | ||
|
|
22cbc82ccc | ||
|
|
462aa2fb15 | ||
|
|
b1749ace16 | ||
|
|
5a48e2893d | ||
|
|
d7181cb1cd | ||
|
|
2b63e3b633 | ||
|
|
2de6073d34 | ||
|
|
ec3f881587 | ||
|
|
b51d428704 | ||
|
|
c50de7770b | ||
|
|
9bafc2ab98 | ||
|
|
437bae0b15 | ||
|
|
083992f63e | ||
|
|
47dc62ae57 | ||
|
|
c2e1d37058 | ||
|
|
c5f9a5494a | ||
|
|
ef2c3474fe | ||
|
|
c689122774 | ||
|
|
c1ab063b0d | ||
|
|
75ec563bd3 | ||
|
|
ef8028cfa4 | ||
|
|
fa76ebce71 | ||
|
|
b9e1d7b7a8 | ||
|
|
9a269ac0ec | ||
|
|
cb0d61a48d | ||
|
|
b7f9fa6173 | ||
|
|
00283d5300 | ||
|
|
e00e53808b | ||
|
|
c915abf310 | ||
|
|
5a282c8ed9 | ||
|
|
1f3472955c | ||
|
|
1923f7710a | ||
|
|
833e8f13ce | ||
|
|
79941207b9 | ||
|
|
1858f4533a | ||
|
|
3bb8efc892 | ||
|
|
b0d3ed8a87 | ||
|
|
9f092388e1 | ||
|
|
217f8f9648 | ||
|
|
0b638791eb | ||
|
|
4349742b12 | ||
|
|
f34abf4d76 | ||
|
|
b7a1710051 | ||
|
|
787982e802 | ||
|
|
184316ccf1 | ||
|
|
b7e45a319f | ||
|
|
3c2379758d | ||
|
|
fde6a714b8 | ||
|
|
d03cc47ac3 | ||
|
|
15cc08a95b | ||
|
|
bfefca0880 | ||
|
|
8e42e72e42 | ||
|
|
2147623543 | ||
|
|
eda3ba501c | ||
|
|
c721346ee6 | ||
|
|
3169e67ca0 | ||
|
|
e6ff33b169 | ||
|
|
64abe12889 | ||
|
|
b39fefd2d0 | ||
|
|
d244943019 | ||
|
|
c5c6b1cb4a | ||
|
|
d0609bcc49 | ||
|
|
9649f0e625 | ||
|
|
4df916cfc5 | ||
|
|
16be9edf51 | ||
|
|
7d73530538 | ||
|
|
10e606742b | ||
|
|
d9f0f9ba6e | ||
|
|
601a93d186 | ||
|
|
0f0801144a | ||
|
|
aa50f9dd05 | ||
|
|
364ca48846 | ||
|
|
73f0c35ef7 | ||
|
|
b57c48bb26 | ||
|
|
db115216a8 | ||
|
|
7810e0c7e8 | ||
|
|
295fa2ce74 | ||
|
|
88ca844c87 | ||
|
|
e830c3dbeb | ||
|
|
515bde49a0 | ||
|
|
10be4e32c7 | ||
|
|
a1925f2e03 | ||
|
|
2924d6636c | ||
|
|
a496bd7aee | ||
|
|
e98da9d1b3 | ||
|
|
a1a62657af | ||
|
|
7a3e74a3af | ||
|
|
a36acddcf4 | ||
|
|
4016eab95f | ||
|
|
51efdd7b60 | ||
|
|
c24fbf602c | ||
|
|
4b1f71b4a6 | ||
|
|
29bf0ff4da | ||
|
|
ccfb85e6cb | ||
|
|
efa8b262b1 | ||
|
|
bbbe77e90c | ||
|
|
6bafb8fe70 | ||
|
|
7d1e16b97b | ||
|
|
1be10cdf2c | ||
|
|
95909ce999 | ||
|
|
a603adb467 | ||
|
|
acfacfe1b3 | ||
|
|
2e5c1ff286 | ||
|
|
4ccb330ded | ||
|
|
a28dad4827 | ||
|
|
a6b7c7e7be | ||
|
|
e5a4703e91 | ||
|
|
8ea0ee4f1b | ||
|
|
386ec7b835 | ||
|
|
078f5cb222 | ||
|
|
7351793683 | ||
|
|
68e3ead4d7 | ||
|
|
597712a7b9 | ||
|
|
f77cc09477 | ||
|
|
5423bd5e1e | ||
|
|
a64c18df6d | ||
|
|
33b0b56514 | ||
|
|
23ec222b77 | ||
|
|
d36352e4dc | ||
|
|
ea22b1f3ba | ||
|
|
8a3229aa5a | ||
|
|
a497c4bfcd | ||
|
|
bd9116e9c3 | ||
|
|
6f39720669 | ||
|
|
f8481024ed | ||
|
|
6b3a7d81d2 | ||
|
|
25cf3c567b |
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="images/logo.png" alt="CyberStrikeAI Logo" width="200">
|
||||
<img src="images/logo.png" alt="CyberStrikeAI Logo" width="200" >
|
||||
</div>
|
||||
|
||||
# CyberStrikeAI
|
||||
@@ -7,31 +7,14 @@
|
||||
|
||||
[中文](README_CN.md) | [English](README.md)
|
||||
|
||||
**Community**: [Join us on Discord](https://discord.gg/8PjVCMu8Zw)
|
||||
**The system of action for AI-native cybersecurity—where intent becomes governed execution, evidence becomes operational memory, and every operation improves the next.**
|
||||
|
||||
**CyberStrikeAI is building the agentic execution layer for modern cyber security.**
|
||||
CyberStrikeAI connects planning, execution, human oversight, evidence, and replay in one auditable workspace. Built in Go, it combines Eino-powered agents, MCP-native tools, RAG knowledge, visual workflows, and attack-chain modeling and analysis for authorized security operations.
|
||||
|
||||
It brings AI agents, security tools, MCP-native integrations, knowledge systems, human oversight, and attack-chain intelligence into a unified workspace for authorized cyber engagements. Instead of treating tools, prompts, evidence, approvals, and reports as separate fragments, CyberStrikeAI turns security intent into auditable multi-agent workflows that can plan, execute, review, replay, and continuously accumulate operational context.
|
||||
**Start here:** [Quick start](#quick-start-one-command-deployment) · [Documentation](docs/en-US/README.md) · [Security hardening](docs/en-US/security-hardening.md)
|
||||
|
||||
Built in Go, CyberStrikeAI provides a full-stack foundation for AI-native security operations: 100+ curated tool recipes, role-based testing, Agent Skills, Eino-powered single-agent and multi-agent orchestration, RAG knowledge retrieval, graph workflows, vulnerability and task lifecycle management, WebShell operations, chatbot access, and a lightweight built-in C2 framework for authorized lab and engagement scenarios.
|
||||
|
||||
<details>
|
||||
<summary><strong>WeChat group</strong> (click to reveal QR code)</summary>
|
||||
|
||||
<img src="./images/wechat-group-cyberstrikeai-qr.jpg" alt="CyberStrikeAI WeChat group QR code" width="280">
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Sponsorship</strong> (click to expand)</summary>
|
||||
|
||||
If CyberStrikeAI helps you, you can support the project via **WeChat Pay** or **Alipay**:
|
||||
|
||||
<div align="center">
|
||||
<img src="./images/sponsor-wechat-alipay-qr.jpg" alt="WeChat Pay and Alipay sponsorship QR codes" width="480">
|
||||
</div>
|
||||
|
||||
</details>
|
||||
> [!IMPORTANT]
|
||||
> Use CyberStrikeAI only on systems you own or are explicitly authorized to test. For shared or production environments, review the [security model](docs/en-US/security-model.md) and [hardening guide](docs/en-US/security-hardening.md) before enabling high-risk tools, WebShell, or C2 capabilities.
|
||||
|
||||
## Interface & Integration Preview
|
||||
|
||||
@@ -54,6 +37,9 @@ If CyberStrikeAI helps you, you can support the project via **WeChat Pay** or **
|
||||
|
||||
*The dashboard provides a comprehensive overview of system runtime status, security vulnerabilities, tool usage, and knowledge base, helping users quickly understand the platform's core features and current state.*
|
||||
|
||||
<details>
|
||||
<summary><strong>More interface screenshots</strong></summary>
|
||||
|
||||
### Core Features Overview
|
||||
|
||||
<table>
|
||||
@@ -115,33 +101,50 @@ If CyberStrikeAI helps you, you can support the project via **WeChat Pay** or **
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
## Highlights
|
||||
|
||||
- 🤖 Agentic execution layer for translating natural-language intent into precise, governed, auditable security action
|
||||
- 🧩 Eino-powered single-agent and multi-agent orchestration with Deep, Plan-Execute, and Supervisor modes
|
||||
- 🔌 MCP-native tool execution with HTTP/stdio/SSE transports, external MCP federation, and dynamic tool discovery
|
||||
- 🧰 100+ curated security tool recipes, YAML-based extensions, and role-scoped tool control
|
||||
- 📄 Large-result pagination, compression, and searchable archives
|
||||
- 🔗 Attack-chain intelligence with graph views, risk scoring, project facts, and step-by-step replay
|
||||
- 🧑⚖️ Human-in-the-loop governance with approval modes, allowlists, audit-agent review, and traceable decisions
|
||||
- 🔒 Password-protected web UI, audit logs, SQLite persistence, and operational evidence retention
|
||||
- 🔐 **Platform RBAC** with multi-user accounts, system/custom roles, per-permission scopes (`all` / `assigned` / `own`), ownership, and explicit assignments enforced across APIs, Agents, MCP, background jobs, and chatbots; see the [RBAC administration guide](docs/en-US/rbac.md)
|
||||
- 📚 Knowledge base (RAG): **Eino MultiQuery** query rewrite + multi-path vector retrieval + **HTTP rerank** (DashScope `gte-rerank` / Cohere-compatible) + post-processing (dedupe, budget); **Eino Compose** indexing pipeline
|
||||
- 📁 Conversation grouping with pinning, rename, and batch management
|
||||
- 📂 **Project management**: shared facts (blackboard) across sessions, `upsert_project_fact` + `links` to chain paths; attack-chain and project fact graph views
|
||||
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
||||
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
||||
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
||||
- 🔀 **Graph orchestration**: visual workflow editor (Start / Agent / Tool / Condition / HITL / Output) with `{{previous.output}}` and `{{outputs.variable_name}}` for inter-node data passing; bind a graph to a role for automatic execution on chat. See [Graph orchestration guide](docs/en-US/workflow-graph.md)
|
||||
- 🧩 **Agent orchestration (CloudWeGo Eino)**: **single-agent** via **`/api/eino-agent/stream`** (Eino ADK `ChatModelAgent`); **multi-agent** via **`/api/multi-agent/stream`** with **`deep`** (coordinator + `task` sub-agents), **`plan_execute`**, or **`supervisor`** (`orchestration` in the request body). ADK **summarization** compresses long contexts; pre-compaction **transcripts** land at `data/conversation_artifacts/<conversation-id>/summarization/transcript.txt` (full user/assistant/tool turns; static system omitted). Markdown under `agents/`: `orchestrator.md`, `orchestrator-plan-execute.md`, `orchestrator-supervisor.md`, plus sub-agent `*.md` (see [Multi-agent doc](docs/en-US/MULTI_AGENT_EINO.md))
|
||||
- 🖼️ **Vision analysis (`analyze_image`)**: separate VL model (e.g. `qwen-vl-max`) via MCP for local screenshots, captchas, and UI; image bytes stay out of agent history (text summaries only). Configure `vision` in `config.yaml`; see [docs/en-US/VISION.md](docs/en-US/VISION.md)
|
||||
- 🎯 **Skills (refactored for Eino)**: packs under `skills_dir` follow **Agent Skills** layout (`SKILL.md` + optional files); **multi-agent** sessions use the official Eino ADK **`skill`** tool for **progressive disclosure** (load by name), with optional **host filesystem / shell** via `multi_agent.eino_skills`; optional **`eino_middleware`** adds patchtoolcalls, tool_search, **plantask** (`TaskCreate` / `TaskList` boards under `skills_dir/.eino/plantask/`), reduction, file **checkpoints** (`checkpoint_dir`), ChatModel **retries**, session **output key**, and Deep tuning—20+ sample domains (SQLi, XSS, API security, …) ship under `skills/`
|
||||
- 📱 **Chatbot**: Personal WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot—chat from mobile or IM apps (see [Robot / Chatbot guide](docs/en-US/robot.md))
|
||||
- 🧑⚖️ **Human-in-the-loop (HITL)**: Chat sidebar to set approval mode and tool allowlists (listed tools skip approval); global list in `config.yaml` under `hitl.tool_whitelist`; the Audit Agent can use a separate lightweight model via `hitl.audit_model`; **Apply** can merge new tools into the file and update the running server without restart; dedicated **HITL** page for pending approvals. See [HITL best practices](docs/en-US/hitl-best-practices.md)
|
||||
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
|
||||
- 📡 **Built-in C2**: AI-oriented lightweight command-and-control—**listeners** (TCP reverse, HTTP/HTTPS beacon, WebSocket), **encrypted** beacon channel, **session** and **task** queues with persistence, **payload** helpers (one-liner / build / download), **SSE** live events, REST under `/api/c2/*`, plus unified MCP tools (`c2_listener`, `c2_session`, **`c2_task`**, `c2_task_manage`, `c2_payload`, `c2_event`, `c2_profile`, `c2_file`); optional **HITL** approval for sensitive operations and OPSEC-style controls (e.g. command deny rules). **Authorized testing only.**
|
||||
### Agents and orchestration
|
||||
|
||||
- 🤖 **Agentic execution** translates natural-language intent into governed, auditable security actions.
|
||||
- 🧩 **Eino orchestration** supports single-agent execution plus Deep, Plan-Execute, and Supervisor multi-agent modes.
|
||||
- 🔀 **Graph workflows** combine Agents, tools, conditions, approvals, and outputs into reusable flows.
|
||||
- 🎭 **Role-based testing** provides focused prompts and tool policies for common security scenarios.
|
||||
|
||||
### Tools and knowledge
|
||||
|
||||
- 🧰 **Security tools** include 100+ curated YAML recipes with custom extensions and role-scoped access.
|
||||
- 🔌 **MCP integration** supports HTTP, stdio, SSE, external federation, and dynamic tool discovery.
|
||||
- ⏱️ **Resilient tool execution** runs blocking MCP/tool calls in workers with bounded agent waits, resumable `execution_id` polling, cancellation, per-server circuit breakers, concurrency limits, and unified output caps.
|
||||
- 🎯 **Agent Skills** follow the standard Skill layout and support progressive, on-demand loading.
|
||||
- 📚 **Knowledge base** combines query rewriting, vector retrieval, reranking, and result post-processing.
|
||||
- 🖼️ **Vision analysis** uses a separate vision model for screenshots, captchas, and UI while retaining text summaries only.
|
||||
|
||||
### Governance and audit
|
||||
|
||||
- 🧑⚖️ **Human in the loop** provides approval modes, tool allowlists, audit-agent review, and traceable decisions.
|
||||
- 🔐 **Platform RBAC** supports multiple users, system and custom roles, scoped permissions, ownership, and explicit assignments.
|
||||
- 🔒 **Security and audit** provide authenticated access, audit logs, SQLite persistence, and operational evidence retention.
|
||||
- 📄 **Result governance** stores the same capped tool result seen by the agent, protects resume paths from oversized historical output, and adds UI safeguards for large detail views. See [Tool Execution Governance](docs/en-US/tool-execution-governance.md).
|
||||
|
||||
### Security operations
|
||||
|
||||
- 📁 **Conversation management** provides grouping, pinning, renaming, and batch organization.
|
||||
- 📂 **Projects and attack chains** connect cross-session facts, risk scoring, graph views, and step-by-step replay.
|
||||
- 🗂️ **Asset management** normalizes and deduplicates domains, IP addresses, ports, and services; supports XLSX/CSV import and export, advanced filters and saved views, ownership and business metadata, cross-page bulk maintenance, and duplicate merging; and tracks scan coverage, linked vulnerabilities, and risk state. See the [Asset Management guide](docs/en-US/asset-management.md).
|
||||
- 🛡️ **Vulnerability management** provides severity classification, lifecycle tracking, filtering, and statistics.
|
||||
- 📋 **Batch tasks** provide queued execution, editing, status tracking, and retained results.
|
||||
- 📱 **Chatbots** connect Personal WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot.
|
||||
|
||||
### Authorized security operations
|
||||
|
||||
- 🐚 **WebShell management** provides connection management, a virtual terminal, file operations, and AI-assisted workflows.
|
||||
- 📡 **Built-in C2** provides listeners, encrypted beacons, sessions, task queues, payload helpers, and live events.
|
||||
|
||||
> WebShell, C2, and other high-risk capabilities are for systems you own or are explicitly authorized to test. See the [security model](docs/en-US/security-model.md) and [hardening guide](docs/en-US/security-hardening.md).
|
||||
|
||||
## Plugins
|
||||
|
||||
@@ -160,11 +163,14 @@ CyberStrikeAI includes optional integrations under `plugins/`.
|
||||
|
||||
CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
|
||||
|
||||
<details>
|
||||
<summary><strong>View the complete tool categories</strong></summary>
|
||||
|
||||
- **Network Scanners** – nmap, masscan, rustscan, arp-scan, nbtscan
|
||||
- **Web & App Scanners** – sqlmap, nikto, dirb, gobuster, feroxbuster, ffuf, httpx
|
||||
- **Vulnerability Scanners** – nuclei, wpscan, wafw00f, dalfox, xsser
|
||||
- **Subdomain Enumeration** – subfinder, amass, findomain, dnsenum, fierce
|
||||
- **Network Space Search Engines** – fofa_search, zoomeye_search
|
||||
- **Network Space Search Engines** – fofa_search, zoomeye_search, quake_search, shodan_search
|
||||
- **API Security** – graphql-scanner, arjun, api-fuzzer, api-schema-analyzer
|
||||
- **Container Security** – trivy, clair, docker-bench-security, kube-bench, kube-hunter
|
||||
- **Cloud Security** – prowler, scout-suite, cloudmapper, pacu, terrascan, checkov
|
||||
@@ -176,12 +182,16 @@ CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
|
||||
- **CTF Utilities** – stegsolve, zsteg, hash-identifier, fcrackzip, pdfcrack, cyberchef
|
||||
- **System Helpers** – exec, create-file, delete-file, list-files, modify-file
|
||||
|
||||
</details>
|
||||
|
||||
See [tools/README_EN.md](tools/README_EN.md) for tool definitions, customization, and usage notes.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Quick Start (One-Command Deployment)
|
||||
|
||||
**Prerequisites:**
|
||||
- Go 1.21+ ([Install](https://go.dev/dl/))
|
||||
- Go 1.25+ ([Install](https://go.dev/dl/); required by `go.mod`)
|
||||
- Python 3.10+ ([Install](https://www.python.org/downloads/))
|
||||
|
||||
**One-Command Deployment:**
|
||||
@@ -199,19 +209,32 @@ The `run.sh` script will automatically:
|
||||
- ✅ Build the project
|
||||
- ✅ Start the server
|
||||
|
||||
**Verify the startup:**
|
||||
|
||||
1. Confirm the terminal displays `● ONLINE` followed by the actual Web UI URL.
|
||||
2. Open that URL; the default HTTPS mode uses a local self-signed certificate, so accept the browser warning once.
|
||||
3. On a new installation, store the one-time `admin` password shown under `ADMIN SETUP REQUIRED`, sign in, and change it immediately.
|
||||
|
||||
**Networking defaults:** `run.sh` starts the server with **`--https`** and the repo **`config.yaml`** (local self-signed TLS; better for many concurrent streams). Use **`./run.sh --http`** for plain HTTP. In production, set **`server.tls_cert_path`** / **`server.tls_key_path`** in **`config.yaml`** (see comments there). For manual runs, add **`--https`** or **`CYBERSTRIKE_HTTPS=1`**; if **`-config`** is wrong, the binary prints a short usage hint on stderr.
|
||||
|
||||
**First-Time Configuration:**
|
||||
1. **Configure OpenAI-compatible API** (required before first use)
|
||||
1. **Configure AI channels** (required before first use)
|
||||
- After launch, open **`https://127.0.0.1:8080/`** (or **`https://localhost:8080/`**; replace **8080** with `server.port` in `config.yaml`) and accept the self-signed certificate warning once. If you used `./run.sh --http`, use **`http://`** instead.
|
||||
- Go to `Settings` → Fill in your API credentials:
|
||||
- Go to `System Settings` → `Basic Settings` → `AI Channel Configuration`, add or edit a channel, then fill in provider, Base URL, API key, model, and token limits. Click **Save changes**. The left channel list supports setting a default, copy, delete, and bulk probe.
|
||||
```yaml
|
||||
openai:
|
||||
api_key: "sk-your-key"
|
||||
base_url: "https://api.openai.com/v1" # or https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # or deepseek-chat, claude-3-opus, etc.
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1" # or https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # or deepseek-chat, qwen3-max, etc.
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
```
|
||||
- Or edit `config.yaml` directly before launching
|
||||
- Or edit `config.yaml` directly before launching. `ai.default_channel` is used for new conversations and tasks that do not explicitly select a channel; the chat page can also select any saved channel per session.
|
||||
2. **Login** - On first startup the console prints an auto-generated initial `admin` password; create accounts from **Platform permissions → User management**
|
||||
3. **Install security tools (optional)** - Install tools from `tools/` as needed; missing tools are skipped or substituted at runtime. Common examples:
|
||||
|
||||
@@ -243,9 +266,9 @@ If server logs show `client sent an HTTP request to an HTTPS server`, a client i
|
||||
|
||||
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
|
||||
|
||||
### Version Update (No Breaking Changes)
|
||||
### Upgrade and Compatibility
|
||||
|
||||
**CyberStrikeAI one-click upgrade (recommended):**
|
||||
**CyberStrikeAI one-click upgrade:**
|
||||
1. (First time) enable the script: `chmod +x upgrade.sh`
|
||||
2. Upgrade with: `./upgrade.sh` (optional flags: `--tag vX.Y.Z`, `--no-venv`, `--yes`). Local `tools/`, `roles/`, and `skills/` are always preserved.
|
||||
3. The script will back up your `config.yaml` and `data/`, upgrade the code from GitHub Release, update `config.yaml`'s `version`, then restart the server.
|
||||
@@ -260,401 +283,36 @@ Requirements / tips:
|
||||
* `rsync` is recommended/required for the safe code sync.
|
||||
* If GitHub API rate-limits you, set `export GITHUB_TOKEN="..."` before running `./upgrade.sh`.
|
||||
|
||||
⚠️ **Note:** This procedure only applies to version updates without compatibility or breaking changes. If a release includes compatibility changes, this method may not apply.
|
||||
|
||||
**Examples:** No breaking changes — e.g. v1.3.1 → v1.3.2; with breaking changes — e.g. v1.3.1 → v1.4.0. The project follows [Semantic Versioning](https://semver.org/) (SemVer): when only the patch version (third number) changes, this upgrade path is usually safe; when the minor or major version changes, config, data, or APIs may have changed — check the release notes before using this method.
|
||||
|
||||
### Core Workflows
|
||||
- **Conversation testing** – Natural-language prompts trigger toolchains with streaming SSE output.
|
||||
- **Single vs multi-agent** – Chat UI switches between **Eino single-agent** (`/api/eino-agent/stream`) and **multi-agent** (`/api/multi-agent/stream` with `orchestration`: `deep` | `plan_execute` | `supervisor`). Multi mode requires `multi_agent.enabled: true`. MCP tools are bridged the same way for both paths.
|
||||
- **Role-based testing** – Select from predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, etc.) to customize AI behavior and tool availability. Each role applies custom system prompts and can restrict available tools for focused testing scenarios.
|
||||
- **Graph orchestration** – Design flows on the **Graph Orchestration** page (drag nodes, connect edges, save); bind `workflow_id` on a role to run the graph on chat (Agent, MCP tools, condition branches). Use `{{outputs.variable_name}}` to pass data across non-adjacent nodes. See [Graph orchestration guide](docs/en-US/workflow-graph.md).
|
||||
- **Tool monitor** – Inspect running jobs, execution logs, and large-result attachments.
|
||||
- **History & audit** – Every conversation and tool invocation is stored in SQLite with replay.
|
||||
- **Conversation groups** – Organize conversations into groups, pin important groups, rename or delete groups via context menu.
|
||||
- **Vulnerability management** – Create, update, and track vulnerabilities discovered during testing. Filter by severity (critical/high/medium/low/info), status (open/confirmed/fixed/false_positive), and conversation. View statistics and export findings.
|
||||
- **Batch task management** – Create task queues with multiple tasks, add or edit tasks before execution, and run them sequentially. Each task executes as a separate conversation, with status tracking (pending/running/completed/failed/cancelled) and full execution history.
|
||||
- **WebShell management** – Add and manage WebShell connections (PHP/ASP/ASPX/JSP or custom). Use the virtual terminal to run commands, the file manager to list, read, edit, upload, and delete files, and the AI assistant tab to drive scripted tests with per-connection conversation history. Connections are stored in SQLite; supports GET/POST and configurable command parameter (e.g. IceSword/AntSword style).
|
||||
- **Built-in C2** – Create/start **listeners**, generate **payloads**, track **sessions**, enqueue **tasks**, and subscribe to **events** (SSE) from the Web UI or `/api/c2/*`. Agents and external clients use the C2 MCP tool family (including **`c2_task`**); when HITL is enabled, high-risk tasks can require human approval. Intended **only** for systems you are explicitly authorized to test.
|
||||
- **Settings** – Tweak provider keys, MCP enablement, tool toggles, and agent iteration limits.
|
||||
- **Human-in-the-loop (HITL)** – Sidebar sets mode and allowlisted tools (comma- or newline-separated); global list lives in `config.yaml` under `hitl.tool_whitelist`. The Audit Agent can use a separate low-cost model through `hitl.audit_model`, useful when human reviewers cannot keep up. **Apply** updates browser/server and can merge new tools into the file (**no restart**). **New chat** keeps sidebar choices; **HITL** nav shows pending approvals. Removing a tool in the sidebar does not remove it from the global list in `config.yaml`—edit the file if needed.
|
||||
|
||||
### Built-in Safeguards
|
||||
- Required-field validation prevents accidental blank API credentials.
|
||||
- Auto-generated 24-character initial `admin` password on first startup when no RBAC users exist (stored in the database only, not in `config.yaml`).
|
||||
- Unified auth middleware for every web/API call (Bearer token flow).
|
||||
- Timeout and sandbox guards per tool, plus structured logging for triage.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Role-Based Testing
|
||||
- **Predefined roles** – System includes 12+ predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, Binary Analysis, Cloud Security Audit, etc.) in the `roles/` directory.
|
||||
- **Custom prompts** – Each role can define a `user_prompt` that prepends to user messages, guiding the AI to adopt specialized testing methodologies and focus areas.
|
||||
- **Tool restrictions** – Roles can specify a `tools` list to limit available tools, ensuring focused testing workflows (e.g., CTF role restricts to CTF-specific utilities).
|
||||
- **Skills** – Skill packs live under `skills_dir` and load via the Eino ADK **`skill`** tool (**progressive disclosure**) in both **single- and multi-agent** sessions when **`multi_agent.eino_skills`** is enabled. Optional host **read_file / glob / grep / write / edit / execute** and **`eino_middleware`** (tool_search, plantask, reduction, checkpoints, summarization transcripts, etc.) apply per mode—see docs.
|
||||
- **Easy role creation** – Create custom roles by adding YAML files to the `roles/` directory. Each role defines `name`, `description`, `user_prompt`, `icon`, `tools`, and `enabled` fields.
|
||||
- **Web UI integration** – Select roles from a dropdown in the chat interface. Role selection affects both AI behavior and available tool suggestions.
|
||||
|
||||
**Creating a custom role (example):**
|
||||
1. Create a YAML file in `roles/` (e.g., `roles/custom-role.yaml`):
|
||||
```yaml
|
||||
name: Custom Role
|
||||
description: Specialized testing scenario
|
||||
user_prompt: You are a specialized security tester focusing on API security...
|
||||
icon: "\U0001F4E1"
|
||||
tools:
|
||||
- api-fuzzer
|
||||
- arjun
|
||||
- graphql-scanner
|
||||
enabled: true
|
||||
```
|
||||
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
||||
|
||||
### Multi-Agent Mode (Eino: Deep, Plan-Execute, Supervisor)
|
||||
- **What it is** – Multi-agent orchestration on CloudWeGo **Eino** `adk/prebuilt` (alongside **Eino single-agent** on `/api/eino-agent*`): **`deep`** — coordinator + **`task`** sub-agents for complex security testing and delegated synthesis; **`plan_execute`** — planner / executor / replanner for structured loops; **`supervisor`** — expert-routing mode with **`transfer`** / **`exit`** for multiple specialist sub-agents. Client sends **`orchestration`**: `deep` | `plan_execute` | `supervisor` (default `deep`).
|
||||
- **Markdown agents** – Under `agents_dir` (default `agents/`):
|
||||
- **Deep orchestrator**: `orchestrator.md` *or* one `.md` with `kind: orchestrator`. Body or `multi_agent.orchestrator_instruction`, then Eino defaults.
|
||||
- **Plan-Execute orchestrator**: fixed name **`orchestrator-plan-execute.md`** (plus optional `orchestrator_instruction_plan_execute` in YAML).
|
||||
- **Supervisor orchestrator**: fixed name **`orchestrator-supervisor.md`** (plus optional `orchestrator_instruction_supervisor`); requires at least one sub-agent, and one-sub-agent runs emit a hint that expert routing has limited value.
|
||||
- **Sub-agents** (for **deep** / **supervisor**): other `*.md` files (YAML front matter + body). Not used as **`task`** targets if marked orchestrator-only.
|
||||
- **Management** – Web UI: **Agents → Agent management**; API `/api/multi-agent/markdown-agents`.
|
||||
- **Config** – `multi_agent` in `config.yaml`: `enabled`, `robot_default_agent_mode`, `batch_use_multi_agent`, `max_iteration`, `plan_execute_loop_max_iterations`, per-mode orchestrator instruction fields, optional YAML `sub_agents` merged with disk (`id` clash → Markdown wins), **`eino_skills`**, **`eino_middleware`** (optional ADK middleware and Deep/Supervisor tuning).
|
||||
- **Resilience & long runs** – `checkpoint_dir` enables ADK **resume** after process crashes (distinct from trace-based “interrupt & continue”). `deep_model_retry_max_retries` retries transient LLM API failures within a single call. **Summarization** writes a filtered **transcript** when compression fires; the summary message includes the path so the model can `read_file` for scan output and other pre-compaction details.
|
||||
- **Details** – **[docs/en-US/MULTI_AGENT_EINO.md](docs/en-US/MULTI_AGENT_EINO.md)** (streaming, robots, batch, middleware caveats).
|
||||
|
||||
### Skills System (Agent Skills + Eino)
|
||||
- **Layout** – Each skill is a directory with **required** `SKILL.md` only ([Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview)): YAML front matter **only** `name` and `description`, plus Markdown body. Optional sibling files (`FORMS.md`, `REFERENCE.md`, `scripts/*`, …). **No** `SKILL.yaml` (not part of Claude or Eino specs); sections/scripts/progressive behavior are **derived at runtime** from Markdown and the filesystem.
|
||||
- **Runtime refactor** – **`skills_dir`** is the single root for packs. **Multi-agent** loads them through Eino’s official **`skill`** middleware (**progressive disclosure**: model calls `skill` with a pack **name** instead of receiving full SKILL text up front). Configure via **`multi_agent.eino_skills`**: `disable`, `filesystem_tools` (host read/glob/grep/write/edit/execute), `skill_tool_name`.
|
||||
- **Eino / RAG** – Packages are also split into `schema.Document` chunks for `FilesystemSkillsRetriever` (`skills.AsEinoRetriever()`) in **compose** graphs (e.g. knowledge/indexing pipelines).
|
||||
- **HTTP API** – `/api/skills` listing and `depth` (`summary` | `full`), `section`, and `resource_path` remain for the web UI and ops; **model-side** skill loading in multi-agent uses the **`skill`** tool, not MCP.
|
||||
- **Optional `eino_middleware`** – e.g. `tool_search` (dynamic MCP tool list), `patch_tool_calls`, **`plantask`** (Eino `TaskCreate` / `TaskGet` / `TaskUpdate` / `TaskList`; JSON under `skills_dir/.eino/plantask/<conversation-id>/`; Eino clears task files when **all** tasks are marked completed), `reduction`, **`checkpoint_dir`** (`data/eino-checkpoints/`), **`deep_model_retry_max_retries`**, **`deep_output_key`**, task-tool description prefix—see `config.yaml` and `internal/config/config.go`.
|
||||
- **Shipped demo** – `skills/cyberstrike-eino-demo/`; see `skills/README.md`.
|
||||
|
||||
**Creating a skill:**
|
||||
1. `mkdir skills/<skill-id>` and add standard `SKILL.md` (+ any optional files), or drop in an open-source skill folder as-is.
|
||||
2. Use **multi-agent** with **`multi_agent.eino_skills`** enabled so the model can call the **`skill`** tool with that pack **name**.
|
||||
|
||||
### Tool Orchestration & Extensions
|
||||
- **YAML recipes** in `tools/*.yaml` describe commands, arguments, prompts, and metadata.
|
||||
- **Directory hot-reload** – pointing `security.tools_dir` to a folder is usually enough; inline definitions in `config.yaml` remain supported for quick experiments.
|
||||
- **Large tool outputs** – outputs beyond `reduction_max_length_for_trunc` are summarized via Eino reduction with full content persisted under `tmp/reduction/`; use `read_file` on the path in `<persisted-output>`.
|
||||
- **Result compression** – multi-megabyte logs can be summarized or losslessly compressed before persisting to keep SQLite lean.
|
||||
|
||||
**Creating a custom tool (typical flow)**
|
||||
1. Copy an existing YAML file from `tools/` (for example `tools/nmap.yaml` or `tools/ffuf.yaml`).
|
||||
2. Update `name`, `command`, `args`, and `short_description`.
|
||||
3. Describe positional or flag parameters in `parameters[]` so the agent knows how to build CLI arguments.
|
||||
4. Provide a longer `description`/`notes` block if the agent needs extra context or post-processing tips.
|
||||
5. Restart the server or reload configuration; the new tool becomes available immediately and can be enabled/disabled from the Settings panel.
|
||||
|
||||
### Attack-Chain Intelligence
|
||||
- AI parses each conversation to assemble targets, tools, vulnerabilities, and relationships.
|
||||
- The web UI renders the chain as an interactive graph with severity scoring and step replay.
|
||||
- Export the chain or raw findings to external reporting pipelines.
|
||||
|
||||
### WebShell Management
|
||||
- **Connections** – From the Web UI, go to **WebShell Management** to add, edit, or delete WebShell connections. Each connection stores: Shell URL, password/key, shell type (PHP, ASP, ASPX, JSP, Custom), request method (GET/POST), command parameter name (default `cmd`), and an optional remark; all records persist in SQLite and are compatible with common clients such as IceSword and AntSword.
|
||||
- **Virtual terminal** – After selecting a connection, use the **Virtual terminal** tab to run arbitrary commands with history and quick commands (whoami/id/ls/pwd etc.). Output is streamed in the browser, and Ctrl+L clears the screen.
|
||||
- **File manager** – Use the **File manager** tab to list directories, read or edit files, delete files, create folders/files, upload files (including chunked uploads for large files), rename paths, and download selected files. Path navigation supports breadcrumbs, parent directory jumps, and name filtering.
|
||||
- **AI assistant** – Use the **AI assistant** tab to chat with an agent that understands the current WebShell connection, automatically runs tools and shell commands, and maintains per-connection conversation history with a sidebar of previous sessions.
|
||||
- **Connectivity test** – Use **Test connectivity** to verify that the shell URL, password, and command parameter are correct before running commands (sends a lightweight `echo 1` check).
|
||||
- **Persistence** – All WebShell connections and AI conversations are stored in SQLite (same database as conversations), so they persist across restarts.
|
||||
|
||||
### Built-in C2 (Command & Control)
|
||||
- **What it is** – A first-party, **AI-native** C2 stack: listeners accept implants (beacons), the server stores **sessions** and **tasks** in SQLite, pushes updates over an **event bus** (including **SSE**), and exposes everything through authenticated **REST** plus MCP.
|
||||
- **Listeners & transports** – `tcp_reverse`, `http_beacon`, `https_beacon`, and `websocket`; per-listener crypto keys; running listeners can be **restored after restart** when marked running in the database.
|
||||
- **Agent integration** – MCP exposes a small **C2 tool family** (listeners, sessions, **`c2_task`**, task management, payloads, events, profiles, files) so the same agent loop can orchestrate C2 alongside other tools; dangerous task types can go through the existing **HITL** bridge when your session policy requires it.
|
||||
- **Safety** – Use **only** in lab or **fully authorized** engagements; combine network isolation, strong auth, and HITL/allowlists as your policy demands.
|
||||
|
||||
### MCP Everywhere
|
||||
- **Web mode** – ships with HTTP MCP server automatically consumed by the UI.
|
||||
- **MCP stdio mode** – `go run cmd/mcp-stdio/main.go` exposes the agent to Cursor/CLI.
|
||||
- **External MCP federation** – register third-party MCP servers (HTTP, stdio, or SSE) from the UI, toggle them per engagement, and monitor their health and call volume in real time.
|
||||
- **Optional MCP servers** – the [`mcp-servers/`](mcp-servers/README.md) directory provides standalone MCPs (e.g. reverse shell). They speak standard MCP over stdio and work with CyberStrikeAI (Settings → External MCP), Cursor, VS Code, and other MCP clients.
|
||||
|
||||
#### MCP stdio quick start
|
||||
1. **Build the binary** (run from the project root):
|
||||
```bash
|
||||
go build -o cyberstrike-ai-mcp cmd/mcp-stdio/main.go
|
||||
```
|
||||
2. **Wire it up in Cursor**
|
||||
Open `Settings → Tools & MCP → Add Custom MCP`, pick **Command**, then point to the compiled binary and your config:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai": {
|
||||
"command": "/absolute/path/to/cyberstrike-ai-mcp",
|
||||
"args": [
|
||||
"--config",
|
||||
"/absolute/path/to/config.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Replace the paths with your local locations; Cursor will launch the stdio server automatically.
|
||||
|
||||
#### MCP HTTP quick start (Cursor / Claude Code)
|
||||
The HTTP MCP server runs on a separate port (default `8081`) and supports **header-based authentication** so only clients that send the correct header can call tools.
|
||||
|
||||
1. **Enable MCP in config** – In `config.yaml` set `mcp.enabled: true` and optionally `mcp.host` / `mcp.port`. For auth (recommended if the port is reachable from the network), set:
|
||||
- `mcp.auth_header` – header name (e.g. `X-MCP-Token`);
|
||||
- `mcp.auth_header_value` – secret value. **Leave it empty** if you want the server to **auto-generate** a random token on first start and write it back to the config.
|
||||
2. **Start the service** – Run `./run.sh` or `go run cmd/server/main.go`. The MCP endpoint is `http://<host>:<port>/mcp` (e.g. `http://localhost:8081/mcp`).
|
||||
3. **Copy the JSON from the terminal** – When MCP is enabled, the server prints a **ready-to-paste** JSON block. If `auth_header_value` was empty, it will have been generated and saved; the printed JSON includes the URL and headers.
|
||||
4. **Use in Cursor or Claude Code**:
|
||||
- **Cursor**: Paste the block into `~/.cursor/mcp.json` (or your project’s `.cursor/mcp.json`) under `mcpServers`, or merge it into your existing `mcpServers`.
|
||||
- **Claude Code**: Paste into `.mcp.json` or `~/.claude.json` under `mcpServers`.
|
||||
|
||||
Example of what the terminal prints (with auth enabled):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai": {
|
||||
"url": "http://localhost:8081/mcp",
|
||||
"headers": {
|
||||
"X-MCP-Token": "<auto-generated-or-your-value>"
|
||||
},
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
If you do not set `auth_header` / `auth_header_value`, the endpoint accepts requests without authentication (suitable only for localhost or trusted networks).
|
||||
|
||||
#### External MCP federation (HTTP/stdio/SSE)
|
||||
CyberStrikeAI supports connecting to external MCP servers via three transport modes:
|
||||
- **HTTP mode** – traditional request/response over HTTP POST
|
||||
- **stdio mode** – process-based communication via standard input/output
|
||||
- **SSE mode** – Server-Sent Events for real-time streaming communication
|
||||
|
||||
To add an external MCP server:
|
||||
1. Open the Web UI and navigate to **Settings → External MCP**.
|
||||
2. Click **Add External MCP** and provide the configuration in JSON format:
|
||||
|
||||
**HTTP mode example:**
|
||||
```json
|
||||
{
|
||||
"my-http-mcp": {
|
||||
"transport": "http",
|
||||
"url": "http://127.0.0.1:8081/mcp",
|
||||
"description": "HTTP MCP server",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**stdio mode example:**
|
||||
```json
|
||||
{
|
||||
"my-stdio-mcp": {
|
||||
"command": "python3",
|
||||
"args": ["/path/to/mcp-server.py"],
|
||||
"description": "stdio MCP server",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SSE mode example:**
|
||||
```json
|
||||
{
|
||||
"my-sse-mcp": {
|
||||
"transport": "sse",
|
||||
"url": "http://127.0.0.1:8082/sse",
|
||||
"description": "SSE MCP server",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Click **Save** and then **Start** to connect to the server.
|
||||
4. Monitor the connection status, tool count, and health in real time.
|
||||
|
||||
**SSE mode benefits:**
|
||||
- Real-time bidirectional communication via Server-Sent Events
|
||||
- Suitable for scenarios requiring continuous data streaming
|
||||
- Lower latency for push-based notifications
|
||||
|
||||
A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation purposes.
|
||||
|
||||
### Knowledge Base
|
||||
- **Vector search** – AI agent can automatically search the knowledge base for relevant security knowledge during conversations using the `search_knowledge_base` tool.
|
||||
- **RAG pipeline (always on)** – **MultiQuery** (LLM query rewrite) → vector prefetch & fusion → **HTTP rerank** (DashScope `gte-rerank` or Cohere-compatible `/v1/rerank`) → post-processing (normalized dedupe, char/token budget, final top_k). Rerank failures degrade to fusion order without breaking search.
|
||||
- **Vector retrieval** – cosine similarity over stored embeddings with configurable threshold, aligned with Eino `retriever.Retriever` usage.
|
||||
- **Auto-indexing** – scans the `knowledge_base/` directory for Markdown files and automatically indexes them with embeddings (Markdown header split + recursive chunking via Eino).
|
||||
- **Web management** – create, update, delete knowledge items through the web UI, with category-based organization; settings page exposes MultiQuery / rerank / prefetch options.
|
||||
- **Retrieval logs** – tracks all knowledge retrieval operations for audit and debugging.
|
||||
|
||||
**Setting up the knowledge base:**
|
||||
1. **Enable in config** – set `knowledge.enabled: true` in `config.yaml`:
|
||||
```yaml
|
||||
knowledge:
|
||||
enabled: true
|
||||
base_path: knowledge_base
|
||||
embedding:
|
||||
provider: openai
|
||||
model: text-embedding-v4
|
||||
base_url: "https://api.openai.com/v1" # or your embedding API
|
||||
api_key: "sk-xxx"
|
||||
retrieval:
|
||||
top_k: 5
|
||||
similarity_threshold: 0.7
|
||||
multi_query:
|
||||
max_queries: 4 # LLM rewrite variants (always on)
|
||||
rerank: # always on; empty fields inherit openai/embedding credentials
|
||||
provider: "" # auto: dashscope | cohere from base_url
|
||||
model: "" # empty: gte-rerank (DashScope) or rerank-multilingual-v3.0 (Cohere)
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # vector candidates per MultiQuery variant; 0 = max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
```
|
||||
2. **Add knowledge files** – place Markdown files in `knowledge_base/` directory, organized by category (e.g., `knowledge_base/SQL Injection/README.md`).
|
||||
3. **Scan and index** – use the web UI to scan the knowledge base directory, which will automatically import files and build vector embeddings.
|
||||
4. **Use in conversations** – the AI agent will automatically use `search_knowledge_base` when it needs security knowledge. You can also explicitly ask: "Search the knowledge base for SQL injection techniques".
|
||||
|
||||
**Knowledge base structure:**
|
||||
- Files are organized by category (directory name becomes the category).
|
||||
- Each Markdown file becomes a knowledge item with automatic chunking for vector search.
|
||||
- The system supports incremental updates – modified files are re-indexed automatically.
|
||||
⚠️ **Before upgrading:** review the target release notes for configuration, database, and API changes. Backups are required even for patch upgrades; a version number alone is not a compatibility guarantee.
|
||||
|
||||
|
||||
### Automation Hooks
|
||||
- **REST APIs** – everything the UI uses (auth, conversations, tool runs, monitor, vulnerabilities, roles) is available over JSON.
|
||||
- **Multi-agent APIs** – `POST /api/multi-agent/stream` (SSE, when enabled), `POST /api/multi-agent` (non-streaming), Markdown agents under `/api/multi-agent/markdown-agents` (list/get/create/update/delete).
|
||||
- **Role APIs** – manage security testing roles via `/api/roles` endpoints: `GET /api/roles` (list all roles), `GET /api/roles/:name` (get role), `POST /api/roles` (create role), `PUT /api/roles/:name` (update role), `DELETE /api/roles/:name` (delete role). Roles are stored as YAML files in the `roles/` directory and support hot-reload.
|
||||
- **Vulnerability APIs** – manage vulnerabilities via `/api/vulnerabilities` endpoints: `GET /api/vulnerabilities` (list with filters), `POST /api/vulnerabilities` (create), `GET /api/vulnerabilities/:id` (get), `PUT /api/vulnerabilities/:id` (update), `DELETE /api/vulnerabilities/:id` (delete), `GET /api/vulnerabilities/stats` (statistics).
|
||||
- **Batch Task APIs** – manage batch task queues via `/api/batch-tasks` endpoints: `POST /api/batch-tasks` (create queue), `GET /api/batch-tasks` (list queues), `GET /api/batch-tasks/:queueId` (get queue), `POST /api/batch-tasks/:queueId/start` (start execution), `POST /api/batch-tasks/:queueId/cancel` (cancel), `DELETE /api/batch-tasks/:queueId` (delete), `POST /api/batch-tasks/:queueId/tasks` (add task), `PUT /api/batch-tasks/:queueId/tasks/:taskId` (update task), `DELETE /api/batch-tasks/:queueId/tasks/:taskId` (delete task). Tasks execute sequentially, each creating a separate conversation with full status tracking.
|
||||
- **WebShell APIs** – manage WebShell connections and execute commands via `/api/webshell/connections` (GET list, POST create, PUT update, DELETE delete) and `/api/webshell/exec` (command execution), `/api/webshell/fileop` (list/read/write/delete files).
|
||||
- **C2 APIs** – manage listeners, sessions, tasks, payloads, files, and events under `/api/c2/*` (e.g. listeners CRUD/start/stop, session sleep, task create/cancel/wait, payload build/download, event stream).
|
||||
- **Task control** – pause/resume/stop long scans, re-run steps with new params, or stream transcripts.
|
||||
- **Audit & security** – rotate passwords via `/api/auth/change-password`, enforce short-lived sessions, and restrict MCP ports at the network layer when exposing the service.
|
||||
## Configuration
|
||||
|
||||
## Configuration Reference
|
||||
Use [`config.example.yaml`](config.example.yaml) as the authoritative configuration template and copy only the values required for your environment. At minimum, configure the server and one AI channel:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
session_duration_hours: 12
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
log:
|
||||
level: "info"
|
||||
output: "stdout"
|
||||
mcp:
|
||||
enabled: true
|
||||
host: "0.0.0.0"
|
||||
port: 8081
|
||||
auth_header: "X-MCP-Token" # optional; leave empty for no auth
|
||||
auth_header_value: "" # optional; leave empty to auto-generate on first start
|
||||
openai:
|
||||
api_key: "sk-xxx"
|
||||
base_url: "https://api.deepseek.com/v1"
|
||||
model: "deepseek-chat"
|
||||
database:
|
||||
path: "data/conversations.db"
|
||||
knowledge_db_path: "data/knowledge.db" # Optional: separate DB for knowledge base
|
||||
security:
|
||||
tools_dir: "tools"
|
||||
knowledge:
|
||||
enabled: false # Enable knowledge base feature
|
||||
base_path: "knowledge_base" # Path to knowledge base directory
|
||||
embedding:
|
||||
provider: "openai" # Embedding provider (currently only "openai")
|
||||
model: "text-embedding-v4" # Embedding model name
|
||||
base_url: "" # Leave empty to use OpenAI base_url
|
||||
api_key: "" # Leave empty to use OpenAI api_key
|
||||
retrieval:
|
||||
top_k: 5 # Number of top results to return
|
||||
similarity_threshold: 0.7 # Minimum cosine similarity (0-1)
|
||||
multi_query:
|
||||
max_queries: 4 # MultiQuery rewrite variants (always on)
|
||||
rerank: # HTTP rerank (always on); empty fields inherit openai/embedding credentials
|
||||
provider: ""
|
||||
model: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # per MultiQuery variant; 0 = max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
roles_dir: "roles" # Role configuration directory (relative to config file)
|
||||
skills_dir: "skills" # Skills directory (relative to config file)
|
||||
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
|
||||
multi_agent:
|
||||
enabled: false
|
||||
default_mode: "eino_single" # eino_single | multi (UI default when multi-agent is enabled)
|
||||
robot_default_agent_mode: eino_single
|
||||
batch_use_multi_agent: false
|
||||
orchestrator_instruction: "" # Deep; used when orchestrator.md body is empty
|
||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
||||
# eino_middleware: plantask_enable, checkpoint_dir, deep_model_retry_max_retries, deep_output_key, ...
|
||||
project:
|
||||
enabled: true # Enable project blackboard & fact MCP tools
|
||||
fact_index_max_runes: 65000
|
||||
fact_summary_max_runes: 24000
|
||||
default_inject_deprecated: false
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1"
|
||||
model: "your-model"
|
||||
```
|
||||
|
||||
### Tool Definition Example (`tools/nmap.yaml`)
|
||||
|
||||
```yaml
|
||||
name: "nmap"
|
||||
command: "nmap"
|
||||
args: ["-sT", "-sV", "-sC"]
|
||||
enabled: true
|
||||
short_description: "Network mapping & service fingerprinting"
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "IP or domain"
|
||||
required: true
|
||||
position: 0
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
flag: "-p"
|
||||
description: "Range, e.g. 1-1000"
|
||||
```
|
||||
|
||||
### Role Definition Example (`roles/penetration-testing.yaml`)
|
||||
|
||||
```yaml
|
||||
name: Penetration Testing
|
||||
description: Professional penetration testing expert for comprehensive security testing
|
||||
user_prompt: You are a professional cybersecurity penetration testing expert. Please use professional penetration testing methods and tools to conduct comprehensive security testing on targets, including but not limited to SQL injection, XSS, CSRF, file inclusion, command execution and other common vulnerabilities.
|
||||
icon: "\U0001F3AF"
|
||||
tools:
|
||||
- nmap
|
||||
- sqlmap
|
||||
- nuclei
|
||||
- burpsuite
|
||||
- metasploit
|
||||
- httpx
|
||||
- record_vulnerability
|
||||
- list_knowledge_risk_types
|
||||
- search_knowledge_base
|
||||
enabled: true
|
||||
```
|
||||
`openai` is a backward-compatible runtime field; maintain new model settings in `ai.channels`. Do not commit real credentials. Review the [configuration reference](docs/en-US/configuration.md), [recommended profiles](docs/en-US/configuration-profiles.md), and [security hardening guide](docs/en-US/security-hardening.md) before exposing the service beyond localhost.
|
||||
|
||||
## Related documentation
|
||||
|
||||
- [Documentation index](docs/README.md): deployment, configuration, security model, API, knowledge base, C2, WebShell, MCP, development, testing, and troubleshooting.
|
||||
- [Deployment guide](docs/en-US/deployment.md): source/binary startup, HTTPS, reverse proxy, systemd, backup, upgrade, and rollback.
|
||||
- [Runbooks](docs/en-US/runbooks.md): production setup, external MCP, knowledge base, authorized Web testing, and C2 cleanup workflows.
|
||||
- [Security hardening](docs/en-US/security-hardening.md): launch baseline, HITL allowlist, reverse proxy, file permissions, and periodic review.
|
||||
- [API recipes](docs/en-US/api-recipes.md): examples for login, Agent, streaming, multi-agent, uploads, vulnerabilities, KB, and audit export.
|
||||
- [Configuration reference](docs/en-US/configuration.md): main `config.yaml` sections, recommended values, and update guidance.
|
||||
- [Security model](docs/en-US/security-model.md): authentication, tool execution, HITL, audit, C2/WebShell, and data safety boundaries.
|
||||
- [RBAC administration](docs/en-US/rbac.md): platform users, system/custom roles, permission catalog, per-permission scopes, resource assignments, Agent/MCP/robot boundaries, and API examples.
|
||||
- [API reference](docs/en-US/api-reference.md): OpenAPI, authentication, Agent, projects, knowledge base, C2, WebShell, and other API entry points.
|
||||
- [Multi-agent mode (Eino)](docs/en-US/MULTI_AGENT_EINO.md): **Deep**, **Plan-Execute**, **Supervisor**, `agents/*.md`, `eino_skills` / `eino_middleware`, APIs, and chat/stream behavior.
|
||||
- [Graph orchestration guide](docs/en-US/workflow-graph.md): visual workflow design, node configuration, `previous` / `outputs` variable passing, and role binding.
|
||||
- [Robot / Chatbot guide](docs/en-US/robot.md): Platform setup, RBAC user-binding/service-account modes, sender allowlists, commands, verification, and troubleshooting.
|
||||
- [HITL best practices](docs/en-US/hitl-best-practices.md): reviewer modes, allowlists, Audit Agent prompts, and separate small-model configuration.
|
||||
- **New users:** [Deployment](docs/en-US/deployment.md) → [Configuration](docs/en-US/configuration.md) → [Troubleshooting](docs/en-US/troubleshooting.md)
|
||||
- **Operators:** [Configuration profiles](docs/en-US/configuration-profiles.md) → [Security hardening](docs/en-US/security-hardening.md) → [Runbooks](docs/en-US/runbooks.md)
|
||||
- **Integrators:** [API reference](docs/en-US/api-reference.md) → [API recipes](docs/en-US/api-recipes.md) → [MCP federation](docs/en-US/mcp-federation.md)
|
||||
- **Contributors:** [Developer guide](docs/en-US/developer-guide.md) → [Testing](docs/en-US/testing.md) → [Contributing](docs/en-US/contributing-guide.md)
|
||||
- **All topics:** [English documentation](docs/en-US/README.md) · [Bilingual documentation index](docs/README.md)
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -669,6 +327,7 @@ CyberStrikeAI/
|
||||
├── agents/ # Multi-agent Markdown (orchestrator.md + sub-agent *.md)
|
||||
├── docs/ # Topic docs (deployment, config, security, API, knowledge base, C2, WebShell, etc.)
|
||||
├── images/ # Docs screenshots & diagrams
|
||||
├── scripts/ # Repository maintenance checks, including documentation validation
|
||||
├── config.yaml # Runtime configuration
|
||||
├── run.sh # Convenience launcher
|
||||
└── README*.md
|
||||
@@ -710,6 +369,26 @@ CyberStrikeAI has joined [404Starlink](https://github.com/knownsec/404StarLink)
|
||||
|
||||
---
|
||||
|
||||
## Community and Support
|
||||
|
||||
- Join the community on [Discord](https://discord.gg/8PjVCMu8Zw).
|
||||
|
||||
<details>
|
||||
<summary><strong>WeChat group</strong></summary>
|
||||
|
||||
<img src="./images/wechat-group-cyberstrikeai-qr.jpg" alt="CyberStrikeAI WeChat group QR code" width="280">
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Sponsorship via WeChat Pay or Alipay</strong></summary>
|
||||
|
||||
<div align="center">
|
||||
<img src="./images/sponsor-wechat-alipay-qr.jpg" alt="WeChat Pay and Alipay sponsorship QR codes" width="480">
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
CyberStrikeAI is licensed under the Apache License 2.0.
|
||||
|
||||
+117
-438
@@ -6,31 +6,14 @@
|
||||
|
||||
[中文](README_CN.md) | [English](README.md)
|
||||
|
||||
**社区**:[加入 Discord](https://discord.gg/8PjVCMu8Zw)
|
||||
**CyberStrikeAI 是 AI 原生网络安全的智能执行中枢——让意图转化为受治理的行动,让证据沉淀为运营记忆,并让每次行动优化下一次行动。**
|
||||
|
||||
**CyberStrikeAI 正在构建现代网络安全的智能体执行层。**
|
||||
CyberStrikeAI 将规划、执行、人工监督、证据与复盘连接在同一个可审计工作空间中。项目基于 Go 构建,融合 Eino 智能体、MCP 原生工具、RAG 知识、可视化工作流以及攻击链建模与分析能力,面向已获得明确授权的安全任务。
|
||||
|
||||
它将 AI 智能体、安全工具、MCP 原生集成、知识系统、人工监督与攻击链智能汇聚到一个面向授权安全任务的统一工作空间中。CyberStrikeAI 不再把工具、提示词、证据、审批和报告视为割裂环节,而是将安全意图转化为可规划、可执行、可审查、可复盘、可持续沉淀上下文的多智能体工作流。
|
||||
**从这里开始:** [快速上手](#快速上手一条命令部署) · [中文文档](docs/zh-CN/README.md) · [安全加固](docs/zh-CN/security-hardening.md)
|
||||
|
||||
CyberStrikeAI 基于 Go 构建,为 AI 原生安全运营提供完整底座:100+ 精选工具配方、角色化测试、Agent Skills、基于 Eino 的单智能体与多智能体编排、RAG 知识检索、图工作流、漏洞与任务生命周期管理、WebShell 运营、机器人接入,以及面向授权实验室和安全任务场景的内置轻量 C2 框架。
|
||||
|
||||
<details>
|
||||
<summary><strong>微信群</strong>(点击展开二维码)</summary>
|
||||
|
||||
<img src="./images/wechat-group-cyberstrikeai-qr.jpg" alt="CyberStrikeAI 微信群二维码" width="280">
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>赞助</strong>(点击展开)</summary>
|
||||
|
||||
若 CyberStrikeAI 对您有帮助,可通过 **微信支付** 或 **支付宝** 赞助项目:
|
||||
|
||||
<div align="center">
|
||||
<img src="./images/sponsor-wechat-alipay-qr.jpg" alt="微信与支付宝赞助二维码" width="480">
|
||||
</div>
|
||||
|
||||
</details>
|
||||
> [!IMPORTANT]
|
||||
> 仅可对自有系统或已获得明确授权的目标使用 CyberStrikeAI。在共享或生产环境启用高风险工具、WebShell 或 C2 前,请先阅读[安全模型](docs/zh-CN/security-model.md)和[安全加固指南](docs/zh-CN/security-hardening.md)。
|
||||
|
||||
## 界面与集成预览
|
||||
|
||||
@@ -53,6 +36,9 @@ CyberStrikeAI 基于 Go 构建,为 AI 原生安全运营提供完整底座:1
|
||||
|
||||
*仪表盘提供系统运行状态、安全漏洞、工具使用情况和知识库的全面概览,帮助用户快速了解平台核心功能和当前状态。*
|
||||
|
||||
<details>
|
||||
<summary><strong>查看更多界面截图</strong></summary>
|
||||
|
||||
### 核心功能概览
|
||||
|
||||
<table>
|
||||
@@ -114,33 +100,50 @@ CyberStrikeAI 基于 Go 构建,为 AI 原生安全运营提供完整底座:1
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
## 特性速览
|
||||
|
||||
- 🤖 面向智能体时代的执行层,将自然语言意图转化为精准、受控、可审计的安全行动
|
||||
- 🧩 基于 Eino 的单智能体与多智能体编排,支持 Deep、Plan-Execute、Supervisor 等模式
|
||||
- 🔌 MCP 原生工具执行,支持 HTTP / stdio / SSE 传输、外部 MCP 联邦与动态工具发现
|
||||
- 🧰 100+ 精选安全工具配方、YAML 扩展机制与按角色收敛的工具控制
|
||||
- 📄 大结果分页、压缩与全文检索
|
||||
- 🔗 攻击链智能分析,支持图谱视图、风险打分、项目事实沉淀与步骤回放
|
||||
- 🧑⚖️ 人机协同治理,支持审批模式、免审批白名单、审计 Agent 复核与可追溯决策
|
||||
- 🔒 Web 登录保护、审计日志、SQLite 持久化与行动证据留存
|
||||
- 🔐 **平台 RBAC**:支持多用户、系统/自定义角色、逐权限 Scope(`all` / `assigned` / `own`)、资源归属与显式授权,并统一约束 API、Agent、MCP、后台任务和机器人;详见 [RBAC 权限管理](docs/zh-CN/rbac.md)
|
||||
- 📚 知识库(RAG):**Eino MultiQuery** 查询改写 + 多路向量检索 + **HTTP 精排**(DashScope `gte-rerank` / Cohere 兼容)+ 后处理(去重、预算);索引侧为 **Eino Compose** 流水线
|
||||
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
||||
- 📂 **项目管理**:共享事实(黑板)跨会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
|
||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
||||
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
||||
- 🔀 **图编排**:可视化流程编排(开始 / Agent / 工具 / 条件 / 审批 / 输出),节点间用 `{{previous.output}}` 或 `{{outputs.变量名}}` 传参;绑定角色后对话自动按图执行。详见 [图编排使用说明](docs/zh-CN/workflow-graph.md)
|
||||
- 🧩 **Agent 编排(CloudWeGo Eino)**:**单代理** `POST /api/eino-agent/stream`(Eino ADK);**多代理** `POST /api/multi-agent/stream`,`orchestration` 选 **`deep`** / **`plan_execute`** / **`supervisor`**。ADK **Summarization** 在上下文过长时压缩历史;压缩前将可恢复 **转录** 写入 `data/conversation_artifacts/<会话ID>/summarization/transcript.txt`(保留完整 user/assistant/tool 轮次,省略静态 system)。`agents/` 下主代理与子代理 Markdown 见 [多代理说明](docs/zh-CN/MULTI_AGENT_EINO.md)
|
||||
- 🖼️ **视觉分析(`analyze_image`)**:独立 Vision 模型(如 `qwen-vl-max`),MCP 工具分析本地截图/验证码/UI;图片仅在单次 VL 调用中出现,对话上下文只保留文字摘要。配置见 `config.yaml` → `vision` 与 [视觉分析说明](docs/zh-CN/VISION.md)
|
||||
- 🎯 **Skills(面向 Eino 重构)**:技能包放在 **`skills_dir`**,遵循 **Agent Skills** 目录规范(`SKILL.md` + 可选文件);**多代理** 下通过 Eino 官方 **`skill`** 工具 **渐进式披露**(按 name 加载)。**`multi_agent.eino_skills`** 控制是否启用、本机文件/Shell 工具、工具名覆盖;**`eino_middleware`** 可选 patch、tool_search、**plantask**(`TaskCreate` / `TaskList` 任务板,落在 `skills_dir/.eino/plantask/`)、reduction、文件型 **checkpoint**(`checkpoint_dir`)、ChatModel **重试**、会话 **输出键** 及 Deep 调参。20+ 领域示例仍可绑定角色
|
||||
- 📱 **机器人**:个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord、QQ 机器人,在手机或 IM 中与 CyberStrikeAI 对话(详见 [机器人使用说明](docs/zh-CN/robot.md))
|
||||
- 🧑⚖️ **人机协同(HITL)**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml` 的 `hitl.tool_whitelist`;审计 Agent 可通过 `hitl.audit_model` 使用独立小模型;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用。详见 [人机协同最佳实践](docs/zh-CN/hitl-best-practices.md)
|
||||
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
||||
- 📡 **内置 C2**:面向 AI 协同的轻量 **C2**——**多种监听器**(TCP 反向、HTTP/HTTPS Beacon、WebSocket)、**加密** Beacon 信道、**会话与任务**队列及持久化、**Payload** 辅助(一键命令 / 构建 / 下载)、**SSE** 实时事件、REST(`/api/c2/*`)及智能体侧 **一组 C2 MCP 工具**(如 `c2_listener`、`c2_session`、**`c2_task`**、`c2_task_manage`、`c2_payload`、`c2_event`、`c2_profile`、`c2_file`);敏感操作可对接 **人机协同(HITL)**,并支持 OPSEC 类规则(如命令拒绝正则)。**仅限授权测试。**
|
||||
### 智能体与编排
|
||||
|
||||
- 🤖 **智能体执行层**:将自然语言意图转化为受控、可审计的安全行动。
|
||||
- 🧩 **Eino 编排**:支持单智能体及 Deep、Plan-Execute、Supervisor 多智能体模式。
|
||||
- 🔀 **工作流**:通过 Agent、工具、条件、审批和输出节点构建可复用流程。
|
||||
- 🎭 **角色化测试**:为常见安全场景提供聚焦的提示词和工具策略。
|
||||
|
||||
### 工具与知识扩展
|
||||
|
||||
- 🧰 **安全工具**:提供 100+ 精选 YAML 工具配方,支持自定义扩展和按角色控制。
|
||||
- 🔌 **MCP 集成**:支持 HTTP、stdio、SSE、外部 MCP 联邦和动态工具发现。
|
||||
- ⏱️ **弹性工具执行**:阻塞型 MCP/工具调用交给 worker 执行,Agent 只有限等待;支持 `execution_id` 多轮等待、主动取消、单 server 熔断、并发限制和统一输出兜底。
|
||||
- 🎯 **Agent Skills**:遵循标准 Skill 目录结构,支持渐进式按需加载。
|
||||
- 📚 **知识库**:组合查询改写、向量检索、精排和结果后处理能力。
|
||||
- 🖼️ **视觉分析**:使用独立视觉模型分析截图、验证码和 UI,对话中仅保留文字摘要。
|
||||
|
||||
### 安全治理与审计
|
||||
|
||||
- 🧑⚖️ **人机协同**:支持审批模式、工具白名单、审计 Agent 复核和决策追踪。
|
||||
- 🔐 **平台 RBAC**:支持多用户、系统及自定义角色、权限 Scope、资源归属和显式授权。
|
||||
- 🔒 **安全与审计**:提供登录保护、审计日志、SQLite 持久化和行动证据留存。
|
||||
- 📄 **结果治理**:数据库保存与 Agent 实际看到的同一份兜底后工具结果,恢复路径会再次防御历史超大输出,前端详情也有展示保护。详见[工具执行治理](docs/zh-CN/tool-execution-governance.md)。
|
||||
|
||||
### 安全运营管理
|
||||
|
||||
- 📁 **对话管理**:支持分组、置顶、重命名和批量管理。
|
||||
- 📂 **项目与攻击链**:关联跨会话事实、风险评分、图谱视图和步骤回放。
|
||||
- 🗂️ **资产管理**:统一归档和去重域名、IP、端口与服务,支持 XLSX/CSV 导入导出、高级筛选与保存视图、责任和业务属性、跨页批量维护、重复资产合并,并跟踪扫描覆盖、关联漏洞和风险状态。详见[资产管理指南](docs/zh-CN/asset-management.md)。
|
||||
- 🛡️ **漏洞管理**:支持严重程度分级、状态流转、过滤和统计看板。
|
||||
- 📋 **批量任务**:支持任务队列、编辑、状态跟踪和结果留存。
|
||||
- 📱 **机器人接入**:支持个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord 和 QQ。
|
||||
|
||||
### 授权安全操作
|
||||
|
||||
- 🐚 **WebShell 管理**:提供连接管理、虚拟终端、文件操作和 AI 辅助工作流。
|
||||
- 📡 **内置 C2**:提供监听器、加密 Beacon、会话、任务队列、Payload 辅助和实时事件。
|
||||
|
||||
> WebShell、C2 及其他高风险能力仅限自有系统或已获得明确授权的测试环境。使用前请阅读[安全模型](docs/zh-CN/security-model.md)和[安全加固指南](docs/zh-CN/security-hardening.md)。
|
||||
|
||||
## 插件(Plugins)
|
||||
|
||||
@@ -159,11 +162,14 @@ CyberStrikeAI 基于 Go 构建,为 AI 原生安全运营提供完整底座:1
|
||||
|
||||
系统预置 100+ 渗透/攻防工具,覆盖完整攻击链:
|
||||
|
||||
<details>
|
||||
<summary><strong>查看完整工具分类</strong></summary>
|
||||
|
||||
- **网络扫描**:nmap、masscan、rustscan、arp-scan、nbtscan
|
||||
- **Web 应用扫描**:sqlmap、nikto、dirb、gobuster、feroxbuster、ffuf、httpx
|
||||
- **漏洞扫描**:nuclei、wpscan、wafw00f、dalfox、xsser
|
||||
- **子域名枚举**:subfinder、amass、findomain、dnsenum、fierce
|
||||
- **网络空间搜索引擎**:fofa_search、zoomeye_search
|
||||
- **网络空间搜索引擎**:fofa_search、zoomeye_search、quake_search、shodan_search
|
||||
- **API 安全**:graphql-scanner、arjun、api-fuzzer、api-schema-analyzer
|
||||
- **容器安全**:trivy、clair、docker-bench-security、kube-bench、kube-hunter
|
||||
- **云安全**:prowler、scout-suite、cloudmapper、pacu、terrascan、checkov
|
||||
@@ -175,12 +181,16 @@ CyberStrikeAI 基于 Go 构建,为 AI 原生安全运营提供完整底座:1
|
||||
- **CTF 实用工具**:stegsolve、zsteg、hash-identifier、fcrackzip、pdfcrack、cyberchef
|
||||
- **系统辅助**:exec、create-file、delete-file、list-files、modify-file
|
||||
|
||||
</details>
|
||||
|
||||
工具定义、自定义方式与使用说明见 [tools/README.md](tools/README.md)。
|
||||
|
||||
## 基础使用
|
||||
|
||||
### 快速上手(一条命令部署)
|
||||
|
||||
**环境要求:**
|
||||
- Go 1.21+ ([下载安装](https://go.dev/dl/))
|
||||
- Go 1.25+([下载安装](https://go.dev/dl/),以 `go.mod` 为准)
|
||||
- Python 3.10+ ([下载安装](https://www.python.org/downloads/))
|
||||
|
||||
**一条命令部署:**
|
||||
@@ -198,19 +208,32 @@ chmod +x run.sh && ./run.sh
|
||||
- ✅ 编译构建项目
|
||||
- ✅ 启动服务器
|
||||
|
||||
**验证是否启动成功:**
|
||||
|
||||
1. 确认终端显示 `● ONLINE`,并在其后给出实际 Web UI 地址。
|
||||
2. 打开该地址;默认 HTTPS 使用本地自签证书,首次访问需接受一次浏览器证书提示。
|
||||
3. 全新安装时,妥善保存 `ADMIN SETUP REQUIRED` 下仅展示一次的 `admin` 密码,登录后立即修改。
|
||||
|
||||
**网络默认:** `run.sh` 会以 **`--https`** 并传入项目根 **`config.yaml`** 启动(本机自签证书,多路流式场景更稳)。只要明文 HTTP 用 **`./run.sh --http`**。生产环境在 **`config.yaml`** 的 **`server.tls_cert_path` / `server.tls_key_path`** 配正式证书(见文件内注释)。手动启动可加 **`--https`** 或环境变量 **`CYBERSTRIKE_HTTPS=1`**;`-config` 写错时程序会在终端提示正确写法。
|
||||
|
||||
**首次配置:**
|
||||
1. **配置 AI 模型 API**(首次使用前必填)
|
||||
1. **配置 AI 通道**(首次使用前必填)
|
||||
- 启动后在浏览器打开 **`https://127.0.0.1:8080/`**(或 **`https://localhost:8080/`**;端口以 `config.yaml` 中 **`server.port`** 为准,默认 8080),并按提示信任自签证书。若使用 **`./run.sh --http`**,则改用 **`http://`** 访问。
|
||||
- 进入 `设置` → 填写 API 配置信息:
|
||||
- 进入 `系统设置` → `基本设置` → `AI 通道配置`,新增或编辑通道,填写 API 提供商、Base URL、API Key、模型和 Token 上限,点击 **保存更改**。左侧通道列表支持设为默认、复制、删除和批量探活。
|
||||
```yaml
|
||||
openai:
|
||||
api_key: "sk-your-key"
|
||||
base_url: "https://api.openai.com/v1" # 或 https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # 或 deepseek-chat, claude-3-opus 等
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1" # 或 https://api.deepseek.com/v1
|
||||
model: "gpt-4o" # 或 deepseek-chat, qwen3-max 等
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
```
|
||||
- 或启动前直接编辑 `config.yaml` 文件
|
||||
- 或启动前直接编辑 `config.yaml` 文件。`ai.default_channel` 会作为新对话和未显式选择通道任务的默认模型;对话页也可以在会话设置里选择某个已保存通道。
|
||||
2. **登录系统** - 首次启动时控制台会显示自动生成的 `admin` 初始密码;也可在「平台权限 → 用户管理」中创建账号
|
||||
3. **安装安全工具(可选)** - 按需安装 `tools/` 目录中的工具;未安装的工具在执行时会自动跳过或改用替代方案。常用示例:
|
||||
|
||||
@@ -242,7 +265,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
|
||||
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
|
||||
|
||||
### CyberStrikeAI 版本更新(无兼容性问题)
|
||||
### 版本升级与兼容性
|
||||
|
||||
1. (首次使用)启用脚本:`chmod +x upgrade.sh`
|
||||
2. 一键升级:`./upgrade.sh`(可选参数:`--tag vX.Y.Z`、`--no-venv`、`--yes`)。本地的 `tools/`、`roles/`、`skills/` 会始终保留不被覆盖。
|
||||
@@ -258,401 +281,36 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
* 建议/需要 `rsync` 用于安全同步代码。
|
||||
* 如果遇到 GitHub API 限流,运行前设置 `export GITHUB_TOKEN="..."` 再执行 `./upgrade.sh`。
|
||||
|
||||
⚠️ **注意:** 仅适用于无兼容性变更的版本更新。若版本存在兼容性调整,此方法不适用。
|
||||
|
||||
**举例:** 无兼容性变更如 v1.3.1 → v1.3.2;有兼容性变更如 v1.3.1 → v1.4.0。项目采用语义化版本(SemVer):仅第三位(补丁号)变更时通常可安全按上述步骤升级;次版本号或主版本号变更时可能涉及配置、数据或接口调整,需查阅 release notes 再决定是否适用本方法。
|
||||
|
||||
### 常用流程
|
||||
- **对话测试**:自然语言触发多步工具编排,SSE 实时输出。
|
||||
- **单代理 / 多代理**:聊天可选 **Eino 单代理**(`/api/eino-agent/stream`)与 **多代理**(`/api/multi-agent/stream` + `orchestration`)。多代理需 `multi_agent.enabled: true`。MCP 工具桥接一致。
|
||||
- **角色化测试**:从预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试等)中选择,自定义 AI 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
|
||||
- **图编排**:在 **图编排** 页拖拽节点、连线并保存流程;在角色中绑定 `workflow_id` 后,该角色对话将按图执行(Agent、MCP 工具、条件分支等)。跨节点传参优先用 `{{outputs.变量名}}`。详见 [图编排使用说明](docs/zh-CN/workflow-graph.md)。
|
||||
- **工具监控**:查看任务队列、执行日志、大文件附件。
|
||||
- **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。
|
||||
- **对话分组**:将对话按项目或主题组织到不同分组,支持置顶、重命名、删除等操作,所有数据持久化存储。
|
||||
- **漏洞管理**:在测试过程中创建、更新和跟踪发现的漏洞。支持按严重程度(严重/高/中/低/信息)、状态(待确认/已确认/已修复/误报)和对话进行过滤,查看统计信息并导出发现。
|
||||
- **批量任务管理**:创建任务队列,批量添加多个任务,执行前可编辑或删除任务,然后依次顺序执行。每个任务会作为独立对话执行,支持完整的状态跟踪(待执行/执行中/已完成/失败/已取消)和执行历史。
|
||||
- **WebShell 管理**:添加并管理 WebShell 连接(PHP/ASP/ASPX/JSP 或自定义类型)。使用虚拟终端执行命令(带命令历史与快捷命令),使用文件管理浏览、读取、编辑、上传与删除目标文件,并支持按路径导航和名称过滤。连接信息持久化存储于 SQLite,支持 GET/POST 及可配置命令参数(兼容冰蝎/蚁剑等)。
|
||||
- **内置 C2**:在 Web 界面或 `/api/c2/*` 创建/启动 **监听器**、生成 **Payload**、查看 **会话**、下发 **任务** 并订阅 **事件(SSE)**。智能体与外部客户端通过 **C2 MCP 工具族**(含 **`c2_task`** 等)编排;开启人机协同时,高风险任务可走审批。**仅用于已获明确授权的目标。**
|
||||
- **可视化配置**:在界面中切换模型、启停工具、设置迭代次数等。
|
||||
- **人机协同(HITL)**:侧栏设置协同模式与免审批工具(逗号或换行);全局白名单见 `config.yaml` 的 `hitl.tool_whitelist`。审计 Agent 可通过 `hitl.audit_model` 单独配置低成本模型,适合人工审计压力较大时接管常规审批。点「**应用**」可写浏览器/服务端并合并新增工具进配置(**无需重启**)。**新对话**保留侧栏选择;导航 **人机协同** 处理待审批。从侧栏删掉工具不会自动从配置文件移除全局项,需手改 `config.yaml`。
|
||||
|
||||
### 默认安全措施
|
||||
- 设置面板内置必填校验,防止漏配 API Key/Base URL/模型。
|
||||
- 首次启动且无 RBAC 用户时,自动生成 24 位 `admin` 初始密码并在控制台输出(仅存于数据库,不再写入 `config.yaml`)。
|
||||
- 所有 API(除登录外)都需携带 Bearer Token,统一鉴权中间件拦截。
|
||||
- 每个工具执行都带有超时、日志和错误隔离。
|
||||
|
||||
## 进阶使用
|
||||
|
||||
### 角色化测试
|
||||
- **预设角色**:系统内置 12+ 个预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试、二进制分析、云安全审计等),位于 `roles/` 目录。
|
||||
- **自定义提示词**:每个角色可定义 `user_prompt`,会在用户消息前自动添加,引导 AI 采用特定的测试方法和关注重点。
|
||||
- **工具限制**:角色可指定 `tools` 列表,限制可用工具,实现聚焦的测试流程(如 CTF 角色限制为 CTF 专用工具)。
|
||||
- **Skills**:技能包位于 `skills_dir`;启用 **`multi_agent.eino_skills`** 后,**单代理与多代理**均可通过 Eino **`skill`** 工具按需加载。可选 **`eino_middleware`**(tool_search、plantask、reduction、checkpoint、Summarization 转录等)与本机 read_file/glob/grep 等见文档。
|
||||
- **轻松创建角色**:通过在 `roles/` 目录添加 YAML 文件即可创建自定义角色。每个角色定义 `name`、`description`、`user_prompt`、`icon`、`tools`、`enabled` 字段。
|
||||
- **Web 界面集成**:在聊天界面通过下拉菜单选择角色。角色选择会影响 AI 行为和可用工具建议。
|
||||
|
||||
**创建自定义角色示例:**
|
||||
1. 在 `roles/` 目录创建 YAML 文件(如 `roles/custom-role.yaml`):
|
||||
```yaml
|
||||
name: 自定义角色
|
||||
description: 专用测试场景
|
||||
user_prompt: 你是一个专注于 API 安全的专业安全测试人员...
|
||||
icon: "\U0001F4E1"
|
||||
tools:
|
||||
- api-fuzzer
|
||||
- arjun
|
||||
- graphql-scanner
|
||||
enabled: true
|
||||
```
|
||||
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
||||
|
||||
### 多代理模式(Eino:Deep / Plan-Execute / Supervisor)
|
||||
- **能力说明**:在 **Eino 单代理**(`/api/eino-agent*`)之外,多代理基于 CloudWeGo **Eino** `adk/prebuilt`:**`deep`**、**`plan_execute`**、**`supervisor`**;客户端 **`orchestration`** 选择(缺省 `deep`)。模式定位按 Eino ADK 最佳实践区分:**Deep** 适合复杂安全测试与 task 子代理协作;**Plan-Execute** 适合目标明确的规划 → 执行 → 重规划闭环;**Supervisor** 适合多个专业子代理动态分派的专家路由场景。
|
||||
- **Markdown 定义**(`agents_dir`,默认 `agents/`):
|
||||
- **Deep 主代理**:`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。
|
||||
- **Plan-Execute 主代理**:固定 **`orchestrator-plan-execute.md`**(另可配 `orchestrator_instruction_plan_execute`)。
|
||||
- **Supervisor 主代理**:固定 **`orchestrator-supervisor.md`**(另可配 `orchestrator_instruction_supervisor`);至少需一名子代理,只有一名子代理时会提示专家路由价值有限。
|
||||
- **子代理**(**deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
|
||||
- **界面管理**:**Agents → Agent 管理**;API `/api/multi-agent/markdown-agents`。
|
||||
- **配置项**:`multi_agent`:`enabled`、`robot_default_agent_mode`、`batch_use_multi_agent`、`max_iteration`、`plan_execute_loop_max_iterations`、各模式 orchestrator 指令字段、可选 YAML `sub_agents` 与目录合并(同 `id` → Markdown 优先)、**`eino_skills`**、**`eino_middleware`**。
|
||||
- **长任务与恢复**:`checkpoint_dir` 支持进程崩溃后 ADK **断点续跑**(与基于 trace 的「中断继续」不同)。`deep_model_retry_max_retries` 在同一次 LLM 调用内重试瞬时 API 失败。**Summarization** 触发压缩时会写入过滤后的 **transcript**,摘要消息中带路径,模型可用 `read_file` 找回扫描输出等压缩前细节。
|
||||
- **更多细节**:[docs/zh-CN/MULTI_AGENT_EINO.md](docs/zh-CN/MULTI_AGENT_EINO.md)(流式、机器人、批量、中间件差异)。
|
||||
|
||||
### Skills 技能系统(Agent Skills + Eino)
|
||||
- **目录规范**:与 [Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) 一致,**仅**需目录下的 **`SKILL.md`**:YAML 头只用官方的 **`name` 与 `description`**,正文为 Markdown。可选同目录其他文件(`FORMS.md`、`REFERENCE.md`、`scripts/*` 等)。**不使用 `SKILL.yaml`**(Claude / Eino 官方均无此文件);章节、`scripts/` 列表、渐进式行为由运行时从正文与磁盘 **自动推导**。
|
||||
- **运行侧重构**:**`skills_dir`** 为技能包唯一根目录;**多代理** 通过 Eino 官方 **`skill`** 中间件做 **渐进式披露**(模型按 **name** 调用 `skill`,而非一次性注入全文)。由 **`multi_agent.eino_skills`** 控制:`disable`、`filesystem_tools`(本机读写与 Shell)、`skill_tool_name`。
|
||||
- **Eino / 知识流水线**:技能包可切分为 `schema.Document`,供 `FilesystemSkillsRetriever`(`skills.AsEinoRetriever()`)在 **compose** 图(如索引/编排)中使用。
|
||||
- **HTTP 管理**:`/api/skills` 列表与 `depth=summary|full`、`section`、`resource_path` 等仍用于 Web 与运维;**模型侧** 多代理走 **`skill`** 工具,而非 MCP。
|
||||
- **可选 `eino_middleware`**:如 `tool_search`(动态工具列表)、`patch_tool_calls`、**`plantask`**(Eino `TaskCreate` / `TaskGet` / `TaskUpdate` / `TaskList`;JSON 存于 `skills_dir/.eino/plantask/<会话ID>/`;**全部**任务标为 completed 后 Eino 会清理任务文件)、`reduction`、**`checkpoint_dir`**(如 `data/eino-checkpoints/`)、**`deep_model_retry_max_retries`**、**`deep_output_key`**、task 描述前缀等,见 `config.yaml` 与 `internal/config/config.go`。
|
||||
- **自带示例**:`skills/cyberstrike-eino-demo/`;说明见 `skills/README.md`。
|
||||
|
||||
**新建技能:**
|
||||
1. 在 `skills/` 下创建 `<skill-id>/`,放入标准 `SKILL.md`(及任意可选文件),或直接解压开源技能包到该目录。
|
||||
2. 启用 **`multi_agent.eino_skills`** 并使用 **多代理** 会话,由模型通过 **`skill`** 工具按包 **name** 加载。
|
||||
|
||||
### 工具编排与扩展
|
||||
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
|
||||
- `security.tools_dir` 指向目录即可批量启用;仍支持在主配置里内联定义。
|
||||
- **大工具输出**:超过 `reduction_max_length_for_trunc` 时由 Eino reduction 摘要,完整内容落盘至 `tmp/reduction/`;按 `<persisted-output>` 中的路径用 `read_file` 读取。
|
||||
- **结果压缩/摘要**:多兆字节日志可先压缩或生成摘要再写入 SQLite,减小档案体积。
|
||||
|
||||
**自定义工具的一般步骤**
|
||||
1. 复制 `tools/` 下现有示例(如 `tools/nmap.yaml` 或 `tools/ffuf.yaml`)。
|
||||
2. 修改 `name`、`command`、`args`、`short_description` 等基础信息。
|
||||
3. 在 `parameters[]` 中声明位置参数或带 flag 的参数,方便智能体自动拼装命令。
|
||||
4. 视需要补充 `description` 或 `notes`,给 AI 额外上下文或结果解读提示。
|
||||
5. 重启服务或在界面中重新加载配置,新工具即可在 Settings 面板中启用/禁用。
|
||||
|
||||
### 攻击链分析
|
||||
- 智能体解析每次对话,抽取目标、工具、漏洞与因果关系。
|
||||
- Web 端可交互式查看链路节点、风险级别及时间轴,支持导出报告。
|
||||
|
||||
### WebShell 管理
|
||||
- **连接管理**:在 Web 界面进入 **WebShell 管理**,可添加、编辑或删除 WebShell 连接。每条连接包含:Shell 地址、密码/密钥、Shell 类型(PHP/ASP/ASPX/JSP/自定义)、请求方式(GET/POST)、命令参数名(默认 `cmd`)、备注等信息,并持久化存储在 SQLite,兼容冰蝎、蚁剑等常见客户端。
|
||||
- **虚拟终端**:选择连接后,在 **虚拟终端** 标签页中执行任意命令,支持命令历史与常用快捷命令(whoami/id/ls/pwd 等),输出在浏览器中实时显示,支持 Ctrl+L 清屏。
|
||||
- **文件管理**:在 **文件管理** 标签页中可列出目录、读取/编辑文件、删除文件、新建文件/目录、上传文件(大文件分片上传)、重命名路径以及下载勾选文件,并支持面包屑导航与名称过滤。
|
||||
- **AI 助手**:在 **AI 助手** 标签页中与智能体对话,由系统自动结合当前 WebShell 连接执行工具与命令,侧边栏展示该连接下的所有历史会话,支持多轮追踪与查看。
|
||||
- **连通性测试**:使用 **测试连通性** 可在执行命令前通过一次 `echo 1` 调用校验 Shell 地址、密码与命令参数是否正确。
|
||||
- **持久化**:所有 WebShell 连接与相关 AI 会话均保存在 SQLite(与对话共用数据库),服务重启后仍可继续使用。
|
||||
|
||||
### 内置 C2(Command & Control)
|
||||
- **定位**:平台内置的 **AI 原生** C2 能力栈——监听器接入植入体(Beacon),服务端以 SQLite 持久化 **会话** 与 **任务**,通过 **事件总线** 推送变更(含 **SSE**),并由鉴权后的 **REST** 与 MCP 统一对外。
|
||||
- **监听器与传输**:支持 `tcp_reverse`、`http_beacon`、`https_beacon`、`websocket`;按监听器独立密钥;数据库中标记为运行中的监听器可在 **服务重启后尝试恢复**。
|
||||
- **与智能体联动**:通过 **`c2_task` 等 C2 MCP 工具** 与现有对话/多代理工具链协同;在会话策略需要时,危险任务类型可走既有 **人机协同(HITL)** 审批流。
|
||||
- **安全提示**:**仅**在实验环境或 **已获完整书面授权** 的对抗演练中使用;结合网络隔离、强鉴权及 HITL/白名单等策略管控风险。
|
||||
|
||||
### MCP 全场景
|
||||
- **Web 模式**:自带 HTTP MCP 服务供前端调用。
|
||||
- **MCP stdio 模式**:`go run cmd/mcp-stdio/main.go` 可接入 Cursor/命令行。
|
||||
- **外部 MCP 联邦**:在设置中注册第三方 MCP(HTTP/stdio/SSE),按需启停并实时查看调用统计与健康度。
|
||||
- **可选 MCP 服务**:项目中的 [`mcp-servers/`](mcp-servers/README_CN.md) 目录提供独立 MCP(如反向 Shell),采用标准 MCP stdio,可在 CyberStrikeAI(设置 → 外部 MCP)、Cursor、VS Code 等任意支持 MCP 的客户端中使用。
|
||||
|
||||
#### MCP stdio 快速集成
|
||||
1. **编译可执行文件**(在项目根目录执行):
|
||||
```bash
|
||||
go build -o cyberstrike-ai-mcp cmd/mcp-stdio/main.go
|
||||
```
|
||||
2. **在 Cursor 中配置**
|
||||
打开 `Settings → Tools & MCP → Add Custom MCP`,选择 **Command**,指定编译后的程序与配置文件:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai": {
|
||||
"command": "/absolute/path/to/cyberstrike-ai-mcp",
|
||||
"args": [
|
||||
"--config",
|
||||
"/absolute/path/to/config.yaml"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
将路径替换成你本地的实际地址,Cursor 会自动启动 stdio 版本的 MCP。
|
||||
|
||||
#### MCP HTTP 快速集成(Cursor / Claude Code)
|
||||
HTTP MCP 服务在独立端口(默认 `8081`)运行,支持 **Header 鉴权**:仅携带正确 header 的客户端可调用工具。
|
||||
|
||||
1. **在配置中启用 MCP** – 在 `config.yaml` 中设置 `mcp.enabled: true`,并按需设置 `mcp.host` / `mcp.port`。若需鉴权(端口对外暴露时建议开启),可设置:
|
||||
- `mcp.auth_header`:鉴权用的 header 名(如 `X-MCP-Token`);
|
||||
- `mcp.auth_header_value`:鉴权密钥。**留空**时,首次启动会自动生成随机密钥并写回配置文件。
|
||||
2. **启动服务** – 执行 `./run.sh` 或 `go run cmd/server/main.go`。MCP 端点为 `http://<host>:<port>/mcp`(例如 `http://localhost:8081/mcp`)。
|
||||
3. **从终端复制 JSON** – 启用 MCP 后,启动时会在终端打印一段 **可直接复制的 JSON**。若 `auth_header_value` 留空,会自动生成并写入配置,打印内容中会包含 URL 与 headers。
|
||||
4. **在 Cursor 或 Claude Code 中使用**:
|
||||
- **Cursor**:将整段 JSON 粘贴到 `~/.cursor/mcp.json` 或项目下的 `.cursor/mcp.json` 的 `mcpServers` 中(或合并进现有 `mcpServers`)。
|
||||
- **Claude Code**:粘贴到 `.mcp.json` 或 `~/.claude.json` 的 `mcpServers` 中。
|
||||
|
||||
终端打印示例(开启鉴权时):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"cyberstrike-ai": {
|
||||
"url": "http://localhost:8081/mcp",
|
||||
"headers": {
|
||||
"X-MCP-Token": "<自动生成或你配置的值>"
|
||||
},
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
若不配置 `auth_header` / `auth_header_value`,则端点不鉴权(仅适合本机或可信网络)。
|
||||
|
||||
#### 外部 MCP 联邦(HTTP/stdio/SSE)
|
||||
CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
||||
- **HTTP 模式** – 通过 HTTP POST 进行传统的请求/响应通信
|
||||
- **stdio 模式** – 通过标准输入/输出进行进程间通信
|
||||
- **SSE 模式** – 通过 Server-Sent Events 实现实时流式通信
|
||||
|
||||
添加外部 MCP 服务器:
|
||||
1. 打开 Web 界面,进入 **设置 → 外部MCP**。
|
||||
2. 点击 **添加外部MCP**,以 JSON 格式提供配置:
|
||||
|
||||
**HTTP 模式示例:**
|
||||
```json
|
||||
{
|
||||
"my-http-mcp": {
|
||||
"transport": "http",
|
||||
"url": "http://127.0.0.1:8081/mcp",
|
||||
"description": "HTTP MCP 服务器",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**stdio 模式示例:**
|
||||
```json
|
||||
{
|
||||
"my-stdio-mcp": {
|
||||
"command": "python3",
|
||||
"args": ["/path/to/mcp-server.py"],
|
||||
"description": "stdio MCP 服务器",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SSE 模式示例:**
|
||||
```json
|
||||
{
|
||||
"my-sse-mcp": {
|
||||
"transport": "sse",
|
||||
"url": "http://127.0.0.1:8082/sse",
|
||||
"description": "SSE MCP 服务器",
|
||||
"timeout": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 点击 **保存**,然后点击 **启动** 连接服务器。
|
||||
4. 实时监控连接状态、工具数量和健康度。
|
||||
|
||||
**SSE 模式优势:**
|
||||
- 通过 Server-Sent Events 实现实时双向通信
|
||||
- 适用于需要持续数据流的场景
|
||||
- 对于基于推送的通知,延迟更低
|
||||
|
||||
可在 `cmd/test-sse-mcp-server/` 目录找到用于验证的测试 SSE MCP 服务器。
|
||||
⚠️ **升级前必读:** 请查看目标版本的 Release Notes,确认配置、数据库和 API 是否变化。即使只是补丁版本也应先备份,不能仅凭版本号判断兼容性。
|
||||
|
||||
|
||||
### 知识库功能
|
||||
- **向量检索**:AI 智能体在对话过程中可自动调用 `search_knowledge_base` 工具搜索知识库中的安全知识。
|
||||
- **RAG 管线(始终启用)**:**MultiQuery**(LLM 查询改写)→ 向量预取与融合 → **HTTP 精排**(DashScope `gte-rerank` 或 Cohere 兼容 `/v1/rerank`)→ 后处理(规范化去重、字符/token 预算、最终 top_k)。精排失败时自动降级为融合排序,检索仍可用。
|
||||
- **向量相似度**:基于嵌入余弦相似度与相似度阈值过滤(与 Eino `retriever.Retriever` 语义一致)。
|
||||
- **自动索引**:扫描 `knowledge_base/` 目录下的 Markdown 文件,自动构建向量嵌入索引(Eino Markdown 标题切分 + 递归分块)。
|
||||
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理;设置页可配置 MultiQuery / 精排 / 预取候选数。
|
||||
- **检索日志**:记录所有知识检索操作,便于审计与调试。
|
||||
## 配置
|
||||
|
||||
**知识库配置步骤:**
|
||||
1. **启用功能**:在 `config.yaml` 中设置 `knowledge.enabled: true`:
|
||||
```yaml
|
||||
knowledge:
|
||||
enabled: true
|
||||
base_path: knowledge_base
|
||||
embedding:
|
||||
provider: openai
|
||||
model: text-embedding-v4
|
||||
base_url: "https://api.openai.com/v1" # 或你的嵌入模型 API
|
||||
api_key: "sk-xxx"
|
||||
retrieval:
|
||||
top_k: 5
|
||||
similarity_threshold: 0.7
|
||||
multi_query:
|
||||
max_queries: 4 # LLM 改写变体上限(始终启用)
|
||||
rerank: # 精排始终启用;留空则继承 openai/embedding 凭据
|
||||
provider: "" # 空=按 base_url 推断 dashscope | cohere
|
||||
model: "" # 空=DashScope→gte-rerank,Cohere→rerank-multilingual-v3.0
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # 每条 MultiQuery 变体的向量候选数;0=max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
```
|
||||
2. **添加知识文件**:将 Markdown 文件放入 `knowledge_base/` 目录,按分类组织(如 `knowledge_base/SQL注入/README.md`)。
|
||||
3. **扫描索引**:在 Web 界面中点击"扫描知识库",系统会自动导入文件并构建向量索引。
|
||||
4. **对话中使用**:AI 智能体在需要安全知识时会自动调用知识检索工具。你也可以显式要求:"搜索知识库中关于 SQL 注入的技术"。
|
||||
|
||||
**知识库结构说明:**
|
||||
- 文件按分类组织(目录名作为分类)。
|
||||
- 每个 Markdown 文件自动切块并生成向量嵌入。
|
||||
- 支持增量更新,修改后的文件会自动重新索引。
|
||||
|
||||
|
||||
### 自动化与安全
|
||||
- **REST API**:认证、会话、任务、监控、漏洞管理、角色管理等接口全部开放,可与 CI/CD 集成。
|
||||
- **多代理 API**:`POST /api/multi-agent/stream`(SSE,需启用多代理)、`POST /api/multi-agent`(非流式);Markdown 子代理/主代理管理见 `/api/multi-agent/markdown-agents`(列表/读写/增删)。
|
||||
- **角色管理 API**:通过 `/api/roles` 端点管理安全测试角色:`GET /api/roles`(列表)、`GET /api/roles/:name`(获取角色)、`POST /api/roles`(创建角色)、`PUT /api/roles/:name`(更新角色)、`DELETE /api/roles/:name`(删除角色)。角色以 YAML 文件形式存储在 `roles/` 目录,支持热加载。
|
||||
- **漏洞管理 API**:通过 `/api/vulnerabilities` 端点管理漏洞:`GET /api/vulnerabilities`(列表,支持过滤)、`POST /api/vulnerabilities`(创建)、`GET /api/vulnerabilities/:id`(获取)、`PUT /api/vulnerabilities/:id`(更新)、`DELETE /api/vulnerabilities/:id`(删除)、`GET /api/vulnerabilities/stats`(统计)。
|
||||
- **批量任务 API**:通过 `/api/batch-tasks` 端点管理批量任务队列:`POST /api/batch-tasks`(创建队列)、`GET /api/batch-tasks`(列表)、`GET /api/batch-tasks/:queueId`(获取队列)、`POST /api/batch-tasks/:queueId/start`(开始执行)、`POST /api/batch-tasks/:queueId/cancel`(取消)、`DELETE /api/batch-tasks/:queueId`(删除队列)、`POST /api/batch-tasks/:queueId/tasks`(添加任务)、`PUT /api/batch-tasks/:queueId/tasks/:taskId`(更新任务)、`DELETE /api/batch-tasks/:queueId/tasks/:taskId`(删除任务)。任务依次顺序执行,每个任务创建独立对话,支持完整状态跟踪。
|
||||
- **WebShell API**:通过 `/api/webshell/connections`(GET 列表、POST 创建、PUT 更新、DELETE 删除)及 `/api/webshell/exec`(执行命令)、`/api/webshell/fileop`(列出/读取/写入/删除文件)管理 WebShell 连接与执行操作。
|
||||
- **C2 API**:在 `/api/c2/*` 管理监听器、会话、任务、Payload、文件与事件(如监听器增删改查/启停、会话休眠、任务创建/取消/等待、Payload 构建/下载、事件流等)。
|
||||
- **任务控制**:支持暂停/终止长任务、修改参数后重跑、流式获取日志。
|
||||
- **安全管理**:`/api/auth/change-password` 可即时轮换口令;建议在暴露 MCP 端口时配合网络层 ACL。
|
||||
|
||||
## 配置参考
|
||||
请以 [`config.example.yaml`](config.example.yaml) 作为权威配置模板,只复制当前环境需要的配置。最少需要配置服务监听地址和一个 AI 通道:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
session_duration_hours: 12
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1"
|
||||
port: 8080
|
||||
log:
|
||||
level: "info"
|
||||
output: "stdout"
|
||||
mcp:
|
||||
enabled: true
|
||||
host: "0.0.0.0"
|
||||
port: 8081
|
||||
auth_header: "X-MCP-Token" # 可选;留空则不鉴权
|
||||
auth_header_value: "" # 可选;留空则首次启动自动生成并写回
|
||||
openai:
|
||||
api_key: "sk-xxx"
|
||||
base_url: "https://api.deepseek.com/v1"
|
||||
model: "deepseek-chat"
|
||||
database:
|
||||
path: "data/conversations.db"
|
||||
knowledge_db_path: "data/knowledge.db" # 可选:知识库独立数据库
|
||||
security:
|
||||
tools_dir: "tools"
|
||||
knowledge:
|
||||
enabled: false # 是否启用知识库功能
|
||||
base_path: "knowledge_base" # 知识库目录路径
|
||||
embedding:
|
||||
provider: "openai" # 嵌入模型提供商(目前仅支持 openai)
|
||||
model: "text-embedding-v4" # 嵌入模型名称
|
||||
base_url: "" # 留空则使用 OpenAI 配置的 base_url
|
||||
api_key: "" # 留空则使用 OpenAI 配置的 api_key
|
||||
retrieval:
|
||||
top_k: 5 # 检索返回的 Top-K 结果数量
|
||||
similarity_threshold: 0.7 # 余弦相似度阈值(0-1),低于此值的结果将被过滤
|
||||
multi_query:
|
||||
max_queries: 4 # MultiQuery 改写变体上限(始终启用)
|
||||
rerank: # HTTP 精排(始终启用);留空则继承 openai/embedding 凭据
|
||||
provider: ""
|
||||
model: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # 每条 MultiQuery 变体;0=max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
roles_dir: "roles" # 角色配置文件目录(相对于配置文件所在目录)
|
||||
skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
|
||||
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
||||
multi_agent:
|
||||
enabled: false
|
||||
default_mode: "eino_single" # eino_single | multi(开启多代理时的界面默认模式)
|
||||
robot_default_agent_mode: eino_single
|
||||
batch_use_multi_agent: false
|
||||
orchestrator_instruction: "" # Deep;orchestrator.md 正文为空时使用
|
||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
||||
# eino_middleware: plantask_enable、checkpoint_dir、deep_model_retry_max_retries、deep_output_key 等
|
||||
project:
|
||||
enabled: true # 启用项目黑板与事实 MCP 工具
|
||||
fact_index_max_runes: 65000
|
||||
fact_summary_max_runes: 24000
|
||||
default_inject_deprecated: false
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
provider: openai_compatible
|
||||
api_key: "${OPENAI_API_KEY}"
|
||||
base_url: "https://api.openai.com/v1"
|
||||
model: "your-model"
|
||||
```
|
||||
|
||||
### 工具模版示例(`tools/nmap.yaml`)
|
||||
|
||||
```yaml
|
||||
name: "nmap"
|
||||
command: "nmap"
|
||||
args: ["-sT", "-sV", "-sC"]
|
||||
enabled: true
|
||||
short_description: "网络资产扫描与服务指纹识别"
|
||||
parameters:
|
||||
- name: "target"
|
||||
type: "string"
|
||||
description: "IP 或域名"
|
||||
required: true
|
||||
position: 0
|
||||
- name: "ports"
|
||||
type: "string"
|
||||
flag: "-p"
|
||||
description: "端口范围,如 1-1000"
|
||||
```
|
||||
|
||||
### 角色配置示例(`roles/渗透测试.yaml`)
|
||||
|
||||
```yaml
|
||||
name: 渗透测试
|
||||
description: 专业渗透测试专家,全面深入的漏洞检测
|
||||
user_prompt: 你是一个专业的网络安全渗透测试专家。请使用专业的渗透测试方法和工具,对目标进行全面的安全测试,包括但不限于SQL注入、XSS、CSRF、文件包含、命令执行等常见漏洞。
|
||||
icon: "\U0001F3AF"
|
||||
tools:
|
||||
- nmap
|
||||
- sqlmap
|
||||
- nuclei
|
||||
- burpsuite
|
||||
- metasploit
|
||||
- httpx
|
||||
- record_vulnerability
|
||||
- list_knowledge_risk_types
|
||||
- search_knowledge_base
|
||||
enabled: true
|
||||
```
|
||||
`openai` 是兼容旧版本的运行时字段,新配置优先维护 `ai.channels`。不要提交真实凭证。将服务暴露到 localhost 之外前,请阅读[配置参考](docs/zh-CN/configuration.md)、[推荐配置画像](docs/zh-CN/configuration-profiles.md)和[安全加固指南](docs/zh-CN/security-hardening.md)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [文档导航](docs/README.md):部署、配置、安全模型、API、知识库、C2、WebShell、MCP、开发、测试、排错等完整专题入口。
|
||||
- [部署指南](docs/zh-CN/deployment.md):源码/二进制运行、HTTPS、反向代理、systemd、备份、升级与回滚。
|
||||
- [运维 Runbooks](docs/zh-CN/runbooks.md):生产部署、外部 MCP、知识库、授权 Web 测试、C2 清理等可执行流程。
|
||||
- [安全加固指南](docs/zh-CN/security-hardening.md):上线前基线、HITL 白名单、反向代理、文件权限和周期巡检。
|
||||
- [API Recipes](docs/zh-CN/api-recipes.md):登录、Agent、流式、多代理、上传、漏洞、知识库和审计导出调用示例。
|
||||
- [配置参考](docs/zh-CN/configuration.md):`config.yaml` 各配置段、推荐值和修改建议。
|
||||
- [安全模型](docs/zh-CN/security-model.md):认证、工具执行、HITL、审计、C2/WebShell 和数据安全边界。
|
||||
- [RBAC 权限管理](docs/zh-CN/rbac.md):平台用户、系统/自定义角色、权限目录、逐权限 Scope、资源授权、Agent/MCP/机器人边界与 API 示例。
|
||||
- [API 参考](docs/zh-CN/api-reference.md):OpenAPI、认证、Agent、项目、知识库、C2、WebShell 等接口入口。
|
||||
- [多代理模式(Eino)](docs/zh-CN/MULTI_AGENT_EINO.md):**Deep**、**Plan-Execute**、**Supervisor**、`agents/*.md`、`eino_skills` / `eino_middleware`、接口与流式说明。
|
||||
- [图编排使用说明](docs/zh-CN/workflow-graph.md):可视化流程搭建、节点配置、`previous` / `outputs` 变量传参与角色绑定。
|
||||
- [机器人使用说明](docs/zh-CN/robot.md):各平台接入、RBAC 逐用户绑定/服务账号模式、发送者白名单、命令、验证与排查。
|
||||
- [人机协同最佳实践](docs/zh-CN/hitl-best-practices.md):审批方模式、白名单、审计 Agent 提示词策略与独立小模型配置。
|
||||
- **新用户:** [部署指南](docs/zh-CN/deployment.md) → [配置参考](docs/zh-CN/configuration.md) → [排错指南](docs/zh-CN/troubleshooting.md)
|
||||
- **运维人员:** [配置画像](docs/zh-CN/configuration-profiles.md) → [安全加固](docs/zh-CN/security-hardening.md) → [运维 Runbooks](docs/zh-CN/runbooks.md)
|
||||
- **集成开发:** [API 参考](docs/zh-CN/api-reference.md) → [API Recipes](docs/zh-CN/api-recipes.md) → [MCP 联邦](docs/zh-CN/mcp-federation.md)
|
||||
- **项目贡献:** [开发者指南](docs/zh-CN/developer-guide.md) → [测试指南](docs/zh-CN/testing.md) → [贡献规范](docs/zh-CN/contributing-guide.md)
|
||||
- **全部专题:** [中文文档](docs/zh-CN/README.md) · [双语文档索引](docs/README.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -667,6 +325,7 @@ CyberStrikeAI/
|
||||
├── agents/ # 多代理 Markdown(orchestrator.md + 子代理 *.md)
|
||||
├── docs/ # 专题文档(部署、配置、安全、API、知识库、C2、WebShell 等)
|
||||
├── images/ # 文档配图
|
||||
├── scripts/ # 仓库维护检查,包括文档校验
|
||||
├── config.yaml # 运行配置
|
||||
├── run.sh # 启动脚本
|
||||
└── README*.md
|
||||
@@ -706,6 +365,26 @@ CyberStrikeAI 现已加入 [404星链计划](https://github.com/knownsec/404Star
|
||||
|
||||
---
|
||||
|
||||
## 社区与支持
|
||||
|
||||
- 在 [Discord](https://discord.gg/8PjVCMu8Zw) 加入社区。
|
||||
|
||||
<details>
|
||||
<summary><strong>微信群</strong></summary>
|
||||
|
||||
<img src="./images/wechat-group-cyberstrikeai-qr.jpg" alt="CyberStrikeAI 微信群二维码" width="280">
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>通过微信支付或支付宝赞助</strong></summary>
|
||||
|
||||
<div align="center">
|
||||
<img src="./images/sponsor-wechat-alipay-qr.jpg" alt="微信与支付宝赞助二维码" width="480">
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
## 许可证
|
||||
|
||||
CyberStrikeAI 采用 **Apache License 2.0** 开源许可。
|
||||
|
||||
@@ -34,6 +34,7 @@ func main() {
|
||||
|
||||
// 注册工具
|
||||
executor.RegisterTools(mcpServer)
|
||||
mcp.RegisterExecutionControlTools(mcpServer, nil)
|
||||
|
||||
log.Logger.Info("MCP服务器(stdio模式)已启动,等待消息...")
|
||||
|
||||
|
||||
+98
-8
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"cyberstrike-ai/internal/app"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/termout"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -12,15 +14,24 @@ import (
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
||||
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
||||
var configPath = flag.String("config", "config.yaml", "Path to the configuration file")
|
||||
var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured")
|
||||
var httpBootstrap = flag.Bool("http", false, "Force plain HTTP for the main site, overriding TLS settings in the configuration file")
|
||||
var resetAdminPassword = flag.Bool("reset-admin-password", false, "Interactively reset the built-in admin password and exit")
|
||||
flag.Parse()
|
||||
|
||||
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
||||
if !*httpsBootstrap {
|
||||
if *httpsBootstrap && *httpBootstrap {
|
||||
fmt.Fprintln(os.Stderr, "--http and --https cannot be used together")
|
||||
os.Exit(2)
|
||||
}
|
||||
if !*httpsBootstrap && !*httpBootstrap {
|
||||
v := strings.TrimSpace(os.Getenv("CYBERSTRIKE_HTTPS"))
|
||||
if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") {
|
||||
*httpsBootstrap = true
|
||||
@@ -33,25 +44,35 @@ func main() {
|
||||
cp = "config.yaml"
|
||||
}
|
||||
if strings.HasPrefix(cp, "-") {
|
||||
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q。\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml(-config 后必须是 yaml 文件路径)。\n", cp)
|
||||
fmt.Fprintf(os.Stderr, "Invalid -config path %q.\nIf HTTPS is also needed, use: ./cyberstrike-ai --https -config config.yaml (-config must be followed by a yaml file path).\n", cp)
|
||||
os.Exit(2)
|
||||
}
|
||||
localConfig, err := config.EnsureLocalConfig(cp)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cp)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to load config: %v\n", err)
|
||||
return
|
||||
}
|
||||
if localConfig.Created {
|
||||
termout.PrintConfigCreated()
|
||||
}
|
||||
|
||||
if *httpsBootstrap {
|
||||
if *resetAdminPassword {
|
||||
if err := runResetAdminPassword(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to reset admin password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if *httpBootstrap {
|
||||
config.ApplyPlainHTTPBootstrap(cfg)
|
||||
} else if *httpsBootstrap {
|
||||
config.ApplyDevHTTPSBootstrap(cfg)
|
||||
}
|
||||
|
||||
@@ -72,7 +93,7 @@ func main() {
|
||||
|
||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
||||
fmt.Printf("Failed to configure MCP authentication: %v\n", err)
|
||||
return
|
||||
}
|
||||
if cfg.MCP.Enabled {
|
||||
@@ -114,3 +135,72 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runResetAdminPassword(cfg *config.Config) error {
|
||||
dbPath := strings.TrimSpace(cfg.Database.Path)
|
||||
if dbPath == "" {
|
||||
dbPath = "data/conversations.db"
|
||||
}
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("database does not exist: %s; start the service once to initialize it first", dbPath)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Reset built-in admin password")
|
||||
fmt.Println()
|
||||
|
||||
password, err := readHiddenPassword("New admin password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
password = strings.TrimSpace(password)
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("new password must be at least 8 characters")
|
||||
}
|
||||
confirm, err := readHiddenPassword("Confirm new password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if password != strings.TrimSpace(confirm) {
|
||||
return fmt.Errorf("passwords do not match")
|
||||
}
|
||||
|
||||
hash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
admin, err := db.GetRBACUserByUsername("admin")
|
||||
if err != nil {
|
||||
return fmt.Errorf("built-in admin account was not found; start the service once to initialize it first: %w", err)
|
||||
}
|
||||
if !admin.IsBuiltin {
|
||||
return fmt.Errorf("admin account is not built in; refusing to reset it")
|
||||
}
|
||||
if err := db.UpdateRBACAdminPassword(hash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Admin password has been reset.")
|
||||
fmt.Println("If the service is running, existing login sessions remain valid until the service restarts or the sessions expire.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func readHiddenPassword(prompt string) (string, error) {
|
||||
fmt.Fprint(os.Stderr, prompt)
|
||||
password, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(password), nil
|
||||
}
|
||||
|
||||
+54
-29
@@ -10,11 +10,14 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.2"
|
||||
version: "v1.7.9"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
port: 8080 # 服务端口;未启用 TLS 时为 http://localhost:8080
|
||||
# 其他可信 Web 集成的精确 Origin。Chromium 浏览器插件会自动识别,无需配置;不要使用通配符。
|
||||
# cors_allowed_origins:
|
||||
# - https://trusted-integration.example
|
||||
# --- 可选:HTTPS + HTTP/2(缓解浏览器对同源 HTTP/1.1 的并发连接数限制,多路 Deep 流式更稳)---
|
||||
# 启用 TLS 的条件(满足其一即可):tls_enabled: true,或 tls_auto_self_sign: true,或同时配置了 tls_cert_path + tls_key_path。
|
||||
# 启用后请用 https://127.0.0.1:<本端口>/ 访问;若仍用 http:// 访问同端口,将自动 308 跳转到 HTTPS(可用 tls_http_redirect: false 关闭)。
|
||||
@@ -46,34 +49,40 @@ monitor:
|
||||
# 对话相关配置
|
||||
# ============================================
|
||||
|
||||
# AI 模型配置(支持 OpenAI 兼容 API)
|
||||
# 必填项:api_key, base_url, model 必须填写才能正常运行
|
||||
# AI 通道配置(支持保存多个 OpenAI 兼容 / Claude 通道)
|
||||
# default_channel 指定新对话与未显式选择通道的任务使用哪个通道。
|
||||
# 每个 channels.<id> 必填:api_key, base_url, model。
|
||||
# 支持的 API 服务商:
|
||||
# - OpenAI: https://api.openai.com/v1
|
||||
# - DeepSeek: https://api.deepseek.com/v1
|
||||
# - 其他兼容 OpenAI 协议的 API
|
||||
# 常用模型: gpt-4, gpt-3.5-turbo, deepseek-chat, claude-3-opus 等
|
||||
# provider: 可选值 openai(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||
openai:
|
||||
provider: openai # API 提供商: openai(默认,兼容OpenAI协议) | claude(自动桥接到Anthropic Claude Messages API)
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 # API 基础 URL(必填)
|
||||
api_key: sk-xxxxxxx # API 密钥(必填)
|
||||
model: qwen3-max # 模型名称(必填)
|
||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||
# provider: 可选值 openai_compatible(默认) | claude(自动桥接到 Anthropic Claude Messages API)
|
||||
ai:
|
||||
default_channel: qwen-max
|
||||
channels:
|
||||
qwen-max:
|
||||
name: Qwen Max
|
||||
provider: openai_compatible
|
||||
base_url: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
api_key: sk-xxxxxxx
|
||||
model: qwen3-max
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 32768
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off:OpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
|
||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
|
||||
# 视觉分析(analyze_image MCP 工具;图片仅在单次 VL 调用中出现,Agent 上下文只保留文字摘要)
|
||||
vision:
|
||||
enabled: false # true 且 model 非空时注册 analyze_image
|
||||
model: qwen-vl # VL 模型名(enabled 时必填)
|
||||
api_key: "" # 留空则复用 openai.api_key
|
||||
base_url: "" # 留空则复用 openai.base_url
|
||||
provider: # 留空则复用 openai.provider(openai | claude)
|
||||
api_key: "" # 留空则复用默认 AI 通道 api_key
|
||||
base_url: "" # 留空则复用默认 AI 通道 base_url
|
||||
provider: # 留空则复用默认 AI 通道 provider(openai_compatible | claude)
|
||||
max_image_bytes: 5242880 # 原始文件上限(字节),默认 5MB
|
||||
max_dimension: 2048 # 长边缩放像素
|
||||
jpeg_quality: 82
|
||||
@@ -82,19 +91,32 @@ vision:
|
||||
detail: auto # low | high | auto(Eino ImageURLDetail)
|
||||
timeout_seconds: 60
|
||||
# ============================================
|
||||
# 信息收集(FOFA)配置(可选)
|
||||
# 资产管理(网络空间测绘搜索)配置(可选)
|
||||
# ============================================
|
||||
# 用于「信息收集」页面调用 FOFA API(后端代理,避免前端暴露 key)
|
||||
# 也可通过环境变量配置:FOFA_EMAIL / FOFA_API_KEY(优先级更高)
|
||||
# 用于「资产管理 → 信息收集」页面调用 FOFA / ZoomEye / Quake / Shodan API
|
||||
# 后端代理请求,避免前端暴露 key;环境变量优先级更高
|
||||
fofa:
|
||||
base_url: https://fofa.info/api/v1/search/all # 可选,留空则使用默认
|
||||
email: "" # FOFA 账号邮箱(可选,建议在系统设置中填写)
|
||||
api_key: "" # FOFA API Key(可选,建议在系统设置中填写)
|
||||
zoomeye:
|
||||
base_url: https://api.zoomeye.org/v2/search # 可选,留空则使用默认
|
||||
api_key: "" # ZoomEye API Key;也可通过 ZOOMEYE_API_KEY 环境变量配置
|
||||
quake:
|
||||
base_url: https://quake.360.cn/api/v3/search/quake_service # 可选,留空则使用默认
|
||||
api_key: "" # Quake API Token;也可通过 QUAKE_API_KEY 环境变量配置
|
||||
shodan:
|
||||
base_url: https://api.shodan.io # 可选,留空则使用默认
|
||||
api_key: "" # Shodan API Key;也可通过 SHODAN_API_KEY 环境变量配置
|
||||
# Agent 配置
|
||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||
agent:
|
||||
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
|
||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||
tool_wait_timeout_seconds: 300 # 工具本轮最多等待(秒);到时返回 execution_id,worker 继续后台执行,可用 wait_tool_execution 继续等待;0=等到完成
|
||||
external_mcp_max_concurrent_per_server: 5 # 单个外部 MCP server 同时运行的工具数;0=默认2;负数=不限制
|
||||
external_mcp_max_concurrent_total: 16 # 所有外部 MCP 工具全局并发上限;0=默认16;负数=不限制
|
||||
external_mcp_circuit_failure_threshold: 15 # 单个外部 MCP server 连续失败多少次后熔断;0=默认3;负数=关闭熔断
|
||||
external_mcp_circuit_cooldown_seconds: 60 # 熔断冷却秒数;0=默认60
|
||||
shell_no_output_timeout_seconds: 1200 # execute/exec 连续无新输出则终止(秒);通用防挂死;0=默认300;-1=关闭
|
||||
workspace_root_dir: "" # 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离;勿用系统 /tmp
|
||||
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||
@@ -195,17 +217,20 @@ multi_agent:
|
||||
# Eino ADK 中间件与 Deep/Supervisor/plan_execute Executor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig)
|
||||
# plan_execute:下列 patch/reduction/tool_search/plantask 等同样作用于 Executor(经 ExecPreMiddlewares);Planner/Replanner 不挂 MCP 前置中间件。
|
||||
eino_middleware:
|
||||
max_tool_arguments_bytes: 65536 # 单个工具 arguments 硬上限;超出时禁止执行并要求模型改写
|
||||
max_shell_command_bytes: 65536 # exec/execute.command 硬上限;与普通工具 arguments 上限一致
|
||||
model_output_repair_max_attempts: 1 # 非法/截断模型输出最多自动修复一次,避免循环
|
||||
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
|
||||
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
||||
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
|
||||
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
|
||||
tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test, exec] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
|
||||
tool_search_always_visible_tools: [read_file, glob, grep, analyze_image, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, get_tool_execution, wait_tool_execution, cancel_tool_execution, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test, exec] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
|
||||
plantask_enable: true # P0:主代理挂载 TaskCreate/Get/Update/List 结构化任务板;需 eino_skills 可用且 skills_dir 存在
|
||||
plantask_rel_dir: .eino/plantask # 任务文件相对 skills_dir,按会话分子目录:skills/.eino/plantask/<conversationId>/
|
||||
reduction_enable: true # true:大工具输出截断/落盘以控上下文;后端会独立创建,不依赖 eino_skills 是否启用
|
||||
reduction_max_length_for_trunc: 50000 # 单条工具结果超过该字符数(bytes)时截断并落盘(由 reduction 中间件处理)
|
||||
reduction_enable: true # true:大工具输出截断并落盘到本地文件(tmp/reduction/.../trunc/<id>),上下文只留 <persisted-output> 预览;不依赖 eino_skills
|
||||
reduction_max_length_for_trunc: 100000 # 单条工具结果超过该字节数时:全文写入本地 trunc 文件,返回带路径的截断预览(MCP/exec 与 reduction 共用此阈值)
|
||||
reduction_max_tokens_for_clear: 60000 # 历史工具结果清理阈值(tokens),应低于 max_total_tokens * summarization_trigger_ratio
|
||||
reduction_root_dir: "" # 非空:截断/清理内容落盘根路径;空:使用系统临时目录下按会话隔离的默认路径
|
||||
reduction_root_dir: "" # 非空:截断/清理内容落盘根路径;空:项目目录下 tmp/reduction(按会话/项目隔离)
|
||||
reduction_clear_exclude: [] # 不参与「清理阶段」的工具名额外列表(会与 task/transfer/exit 等内置排除项合并);需要时用 YAML 列表填写
|
||||
reduction_sub_agents: true # true:子代理也挂 reduction;false:仅编排主代理使用 reduction
|
||||
summarization_trigger_ratio: 0.8 # summarization 触发比例(max_total_tokens * ratio),建议 0.75~0.85
|
||||
@@ -221,7 +246,7 @@ multi_agent:
|
||||
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
|
||||
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
|
||||
checkpoint_dir: data/eino-checkpoints # P0:进程崩溃/OOM 后同会话自动 ADK Resume;正常结束会删 .ckpt;与「中断并继续」(last_react_*) 是两套机制
|
||||
run_retry_max_attempts: 0 # 429/5xx/网络抖动时可退避重试次数(run loop + summarization 共用 isEinoTransientRunError);0=默认 10
|
||||
run_retry_max_attempts: 0 # 408/409/425/429/5xx/网络抖动时可退避重试次数;0=默认 4(永久性 4xx 不重试)
|
||||
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
|
||||
empty_response_continue_max_attempts: 0 # Run 成功但未捕获助手正文(含流式中断)时 Handler 退避续跑次数;0=默认 5
|
||||
deep_output_key: final_answer # P0:Eino session 写入最终助手结论(框架内部;Deep/Supervisor 主/eino_single)
|
||||
|
||||
+70
-51
@@ -1,68 +1,87 @@
|
||||
# CyberStrikeAI Documentation
|
||||
|
||||
Documentation is split by language:
|
||||
[中文](#中文文档) | [English](#english-documentation)
|
||||
|
||||
- [中文文档](zh-CN/)
|
||||
- [English docs](en-US/)
|
||||
CyberStrikeAI documentation is organized by user journey. Start with deployment, then move to the topic that matches your task.
|
||||
|
||||
## 中文文档
|
||||
|
||||
- [部署指南](zh-CN/deployment.md)
|
||||
- [运维 Runbooks](zh-CN/runbooks.md)
|
||||
- [配置画像](zh-CN/configuration-profiles.md)
|
||||
- [安全加固指南](zh-CN/security-hardening.md)
|
||||
- [API Recipes](zh-CN/api-recipes.md)
|
||||
- [贡献规范](zh-CN/contributing-guide.md)
|
||||
- [配置参考](zh-CN/configuration.md)
|
||||
- [安全模型](zh-CN/security-model.md)
|
||||
- [RBAC 权限管理](zh-CN/rbac.md)
|
||||
### 按目标开始
|
||||
|
||||
- **快速体验**:[部署指南](zh-CN/deployment.md) → [配置参考](zh-CN/configuration.md) → [排错指南](zh-CN/troubleshooting.md)
|
||||
- **生产部署**:[配置画像](zh-CN/configuration-profiles.md) → [安全加固](zh-CN/security-hardening.md) → [运维 Runbooks](zh-CN/runbooks.md) → [审计与监控](zh-CN/audit-and-monitoring.md)
|
||||
- **接入与自动化**:[API 参考](zh-CN/api-reference.md) → [API Recipes](zh-CN/api-recipes.md) → [MCP 联邦](zh-CN/mcp-federation.md)
|
||||
- **参与开发**:[开发者指南](zh-CN/developer-guide.md) → [测试指南](zh-CN/testing.md) → [贡献规范](zh-CN/contributing-guide.md)
|
||||
|
||||
### 核心概念与编排
|
||||
|
||||
- [架构说明](zh-CN/architecture.md)
|
||||
- [API 参考](zh-CN/api-reference.md)
|
||||
- [排错指南](zh-CN/troubleshooting.md)
|
||||
- [审计与监控](zh-CN/audit-and-monitoring.md)
|
||||
- [知识库](zh-CN/knowledge-base.md)
|
||||
- [C2 使用说明](zh-CN/c2.md)
|
||||
- [WebShell 管理](zh-CN/webshell.md)
|
||||
- [MCP 联邦](zh-CN/mcp-federation.md)
|
||||
- [安全模型](zh-CN/security-model.md)
|
||||
- [Agent 与角色](zh-CN/agent-and-role-guide.md)
|
||||
- [Skills 指南](zh-CN/skills-guide.md)
|
||||
- [插件开发](zh-CN/plugin-development.md)
|
||||
- [发布流程](zh-CN/release-process.md)
|
||||
- [测试指南](zh-CN/testing.md)
|
||||
- [图编排使用说明](zh-CN/workflow-graph.md)
|
||||
- [Eino 多代理](zh-CN/MULTI_AGENT_EINO.md)
|
||||
- [工作流](zh-CN/workflow-graph.md)
|
||||
- [工具执行治理](zh-CN/tool-execution-governance.md)
|
||||
- [人机协同最佳实践](zh-CN/hitl-best-practices.md)
|
||||
- [机器人使用说明](zh-CN/robot.md)
|
||||
|
||||
### 功能指南
|
||||
|
||||
- [知识库](zh-CN/knowledge-base.md)
|
||||
- [RBAC 权限管理](zh-CN/rbac.md)
|
||||
- [机器人接入](zh-CN/robot.md)
|
||||
- [视觉分析](zh-CN/VISION.md)
|
||||
- [前端国际化方案](zh-CN/frontend-i18n.md)
|
||||
- [Eino 多代理改造说明](zh-CN/MULTI_AGENT_EINO.md)
|
||||
- [WebShell 管理](zh-CN/webshell.md)
|
||||
- [C2 使用说明](zh-CN/c2.md)
|
||||
|
||||
## English Docs
|
||||
### 开发与发布
|
||||
|
||||
- [开发者指南](zh-CN/developer-guide.md)
|
||||
- [插件开发](zh-CN/plugin-development.md)
|
||||
- [前端国际化](zh-CN/frontend-i18n.md)
|
||||
- [测试指南](zh-CN/testing.md)
|
||||
- [贡献规范](zh-CN/contributing-guide.md)
|
||||
- [发布流程](zh-CN/release-process.md)
|
||||
|
||||
## English Documentation
|
||||
|
||||
### Choose a path
|
||||
|
||||
- **Try locally**: [Deployment](en-US/deployment.md) → [Configuration](en-US/configuration.md) → [Troubleshooting](en-US/troubleshooting.md)
|
||||
- **Run in production**: [Configuration Profiles](en-US/configuration-profiles.md) → [Security Hardening](en-US/security-hardening.md) → [Runbooks](en-US/runbooks.md) → [Audit and Monitoring](en-US/audit-and-monitoring.md)
|
||||
- **Integrate and automate**: [API Reference](en-US/api-reference.md) → [API Recipes](en-US/api-recipes.md) → [MCP Federation](en-US/mcp-federation.md)
|
||||
- **Contribute code**: [Developer Guide](en-US/developer-guide.md) → [Testing](en-US/testing.md) → [Contributing](en-US/contributing-guide.md)
|
||||
|
||||
### Concepts and orchestration
|
||||
|
||||
- [Deployment Guide](en-US/deployment.md)
|
||||
- [Runbooks](en-US/runbooks.md)
|
||||
- [Configuration Profiles](en-US/configuration-profiles.md)
|
||||
- [Security Hardening](en-US/security-hardening.md)
|
||||
- [API Recipes](en-US/api-recipes.md)
|
||||
- [Contributing Guide](en-US/contributing-guide.md)
|
||||
- [Configuration Reference](en-US/configuration.md)
|
||||
- [Security Model](en-US/security-model.md)
|
||||
- [RBAC Administration](en-US/rbac.md)
|
||||
- [Architecture](en-US/architecture.md)
|
||||
- [API Reference](en-US/api-reference.md)
|
||||
- [Troubleshooting](en-US/troubleshooting.md)
|
||||
- [Audit and Monitoring](en-US/audit-and-monitoring.md)
|
||||
- [Knowledge Base](en-US/knowledge-base.md)
|
||||
- [C2 Guide](en-US/c2.md)
|
||||
- [WebShell Management](en-US/webshell.md)
|
||||
- [MCP Federation](en-US/mcp-federation.md)
|
||||
- [Agent and Role Guide](en-US/agent-and-role-guide.md)
|
||||
- [Skills Guide](en-US/skills-guide.md)
|
||||
- [Plugin Development](en-US/plugin-development.md)
|
||||
- [Release Process](en-US/release-process.md)
|
||||
- [Testing Guide](en-US/testing.md)
|
||||
- [Graph Orchestration Guide](en-US/workflow-graph.md)
|
||||
- [Security Model](en-US/security-model.md)
|
||||
- [Agents and Roles](en-US/agent-and-role-guide.md)
|
||||
- [Skills](en-US/skills-guide.md)
|
||||
- [Eino Multi-Agent](en-US/MULTI_AGENT_EINO.md)
|
||||
- [Workflows](en-US/workflow-graph.md)
|
||||
- [Tool Execution Governance](en-US/tool-execution-governance.md)
|
||||
- [HITL Best Practices](en-US/hitl-best-practices.md)
|
||||
- [Robot / Chatbot Guide](en-US/robot.md)
|
||||
|
||||
### Feature guides
|
||||
|
||||
- [Knowledge Base](en-US/knowledge-base.md)
|
||||
- [RBAC Administration](en-US/rbac.md)
|
||||
- [Robot / Chatbot](en-US/robot.md)
|
||||
- [Vision Analysis](en-US/VISION.md)
|
||||
- [WebShell Management](en-US/webshell.md)
|
||||
- [C2 Guide](en-US/c2.md)
|
||||
|
||||
### Development and release
|
||||
|
||||
- [Developer Guide](en-US/developer-guide.md)
|
||||
- [Plugin Development](en-US/plugin-development.md)
|
||||
- [Frontend i18n](en-US/frontend-i18n.md)
|
||||
- [Eino Multi-Agent Notes](en-US/MULTI_AGENT_EINO.md)
|
||||
- [Testing](en-US/testing.md)
|
||||
- [Contributing](en-US/contributing-guide.md)
|
||||
- [Release Process](en-US/release-process.md)
|
||||
|
||||
## Documentation conventions
|
||||
|
||||
- Commands assume the repository root unless stated otherwise.
|
||||
- Examples use placeholders; never commit real credentials or target systems without explicit authorization.
|
||||
- Runtime behavior and configuration defaults are authoritative in `config.example.yaml` and the source code. If a document differs, report it as documentation drift.
|
||||
|
||||
+31
-29
@@ -1,30 +1,32 @@
|
||||
# English Docs
|
||||
# English Documentation
|
||||
|
||||
- [Deployment Guide](deployment.md): deployment modes, HTTPS, reverse proxy, systemd, backup, upgrade, and acceptance checks.
|
||||
- [Runbooks](runbooks.md): operational steps for production setup, external MCP, KB, Web testing, C2 cleanup, and tool debugging.
|
||||
- [Configuration Profiles](configuration-profiles.md): recommended profiles for dev, internal team, knowledge-only, production, C2, and MCP automation.
|
||||
- [Security Hardening](security-hardening.md): pre-launch baseline, reverse proxy, HITL allowlist, file permissions, and periodic review.
|
||||
- [API Recipes](api-recipes.md): examples for login, Agent, streaming, multi-agent, uploads, vulnerabilities, KB, MCP, and audit export.
|
||||
- [Contributing Guide](contributing-guide.md): checklists for APIs, config, tools, frontend, DB, high-risk features, and docs.
|
||||
- [Configuration Reference](configuration.md): `config.yaml` fields, hot-apply boundaries, recommended values, and source anchors.
|
||||
- [Security Model](security-model.md): trust boundaries, HITL, tool execution, C2/WebShell, and data safety.
|
||||
- [RBAC Administration](rbac.md): platform users, system/custom roles, permission catalog, per-permission scopes, resource assignments, Agent/MCP/robot boundaries, and API examples.
|
||||
- [Architecture](architecture.md): request flow, module relationships, complexity hotspots, and design trade-offs.
|
||||
- [API Reference](api-reference.md): authentication, OpenAPI, SSE, stability tiers, and common endpoints.
|
||||
- [Troubleshooting](troubleshooting.md): diagnostic order, minimal commands, common misdiagnoses, and issue template.
|
||||
- [Audit and Monitoring](audit-and-monitoring.md): platform audit, tool monitoring, HITL logs, and retention.
|
||||
- [Knowledge Base](knowledge-base.md): indexing pipeline, retrieval tuning, log analysis, and content writing.
|
||||
- [C2 Guide](c2.md): lifecycle, task classification, event review, and safety guidance.
|
||||
- [WebShell Management](webshell.md): operation tiers, naming, AI guardrails, and troubleshooting.
|
||||
- [MCP Federation](mcp-federation.md): built-in MCP, external MCP, lifecycle, and tool naming.
|
||||
- [Agent and Role Guide](agent-and-role-guide.md): roles, sub-agents, Skills, orchestration modes, and tool visibility.
|
||||
- [Skills Guide](skills-guide.md): Skill structure, progressive disclosure, anti-patterns, and local-tool risk.
|
||||
- [Plugin Development](plugin-development.md): API plugins, MCP plugins, resource-pack plugins, and security boundaries.
|
||||
- [Release Process](release-process.md): release risk, config compatibility, DB migrations, and acceptance checks.
|
||||
- [Testing Guide](testing.md): test layers, regression focus, test data, and failure cases.
|
||||
- [Graph Orchestration Guide](workflow-graph.md)
|
||||
- [HITL Best Practices](hitl-best-practices.md)
|
||||
- [Robot / Chatbot Guide](robot.md)
|
||||
- [Vision Analysis](VISION.md)
|
||||
- [Frontend i18n](frontend-i18n.md)
|
||||
- [Eino Multi-Agent Notes](MULTI_AGENT_EINO.md)
|
||||
[Documentation home](../README.md) | [中文](../zh-CN/README.md)
|
||||
|
||||
## Choose a path
|
||||
|
||||
- **Try locally**: [Deployment](deployment.md) → [Configuration](configuration.md) → [Troubleshooting](troubleshooting.md)
|
||||
- **Run in production**: [Configuration Profiles](configuration-profiles.md) → [Security Hardening](security-hardening.md) → [Runbooks](runbooks.md) → [Audit and Monitoring](audit-and-monitoring.md)
|
||||
- **Integrate and automate**: [API Reference](api-reference.md) → [API Recipes](api-recipes.md) → [MCP Federation](mcp-federation.md)
|
||||
- **Contribute code**: [Developer Guide](developer-guide.md) → [Testing](testing.md) → [Contributing](contributing-guide.md)
|
||||
|
||||
## Concepts and orchestration
|
||||
|
||||
- [Architecture](architecture.md) · [Security Model](security-model.md) · [RBAC](rbac.md)
|
||||
- [Agents and Roles](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino Multi-Agent](MULTI_AGENT_EINO.md)
|
||||
- [Workflows](workflow-graph.md) · [Tool Execution Governance](tool-execution-governance.md) · [HITL Best Practices](hitl-best-practices.md)
|
||||
|
||||
## Feature guides
|
||||
|
||||
- [Asset Management](asset-management.md) · [Knowledge Base](knowledge-base.md) · [Robot / Chatbot](robot.md) · [Vision](VISION.md)
|
||||
- [WebShell](webshell.md) · [C2](c2.md) · [MCP Federation](mcp-federation.md)
|
||||
|
||||
## Operations and reference
|
||||
|
||||
- [Deployment](deployment.md) · [Configuration](configuration.md) · [Configuration Profiles](configuration-profiles.md)
|
||||
- [Security Hardening](security-hardening.md) · [Audit and Monitoring](audit-and-monitoring.md) · [Runbooks](runbooks.md)
|
||||
- [API Reference](api-reference.md) · [API Recipes](api-recipes.md) · [Troubleshooting](troubleshooting.md)
|
||||
|
||||
## Development and release
|
||||
|
||||
- [Developer Guide](developer-guide.md) · [Plugin Development](plugin-development.md) · [Frontend i18n](frontend-i18n.md)
|
||||
- [Testing](testing.md) · [Contributing](contributing-guide.md) · [Release Process](release-process.md)
|
||||
|
||||
@@ -21,7 +21,7 @@ vision:
|
||||
timeout_seconds: 60
|
||||
```
|
||||
|
||||
Empty `api_key`, `base_url`, or `provider` inherits from `openai`.
|
||||
Empty `api_key`, `base_url`, or `provider` inherits from the resolved default AI channel.
|
||||
|
||||
## Data Handling
|
||||
|
||||
|
||||
@@ -151,3 +151,52 @@ curl -k "https://127.0.0.1:8080/api/audit/logs/export" \
|
||||
```
|
||||
|
||||
Exported logs may contain sensitive operational data. Store encrypted.
|
||||
|
||||
## Recipe 11: Bulk Import Assets
|
||||
|
||||
Create `assets.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "api-import",
|
||||
"source_query": "cmdb-export-2026-07",
|
||||
"assets": [
|
||||
{
|
||||
"domain": "app.example.com",
|
||||
"port": 443,
|
||||
"protocol": "https",
|
||||
"tags": ["production", "internet"],
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"ip": "192.0.2.10",
|
||||
"port": 22,
|
||||
"protocol": "ssh",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Submit it:
|
||||
|
||||
```bash
|
||||
curl -k https://127.0.0.1:8080/api/assets/import \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @assets.json
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{"created":2,"updated":0,"skipped":0}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The caller needs `asset:write`.
|
||||
- Each asset requires at least one of `host`, `ip`, or `domain`.
|
||||
- One request supports up to 100,000 assets. For large payloads, use a file with `--data-binary` instead of embedding JSON in the command line.
|
||||
- An existing “target + port + protocol” is merged and counted in `updated`.
|
||||
- To work from XLSX/CSV, use **Asset Inventory → Bulk Import** in the Web UI. The API itself accepts JSON rather than multipart files.
|
||||
|
||||
@@ -45,6 +45,18 @@ Multi-agent:
|
||||
|
||||
`orchestration` may be `deep`, `plan_execute`, or `supervisor`.
|
||||
|
||||
Common request body fields:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `message` | User message, required. |
|
||||
| `conversationId` | Continue an existing conversation; empty creates a new one. |
|
||||
| `projectId` | Project for a new conversation; empty may follow `config.project.default_project_id`. |
|
||||
| `role` | Use a named role. |
|
||||
| `aiChannelId` | Select a channel from `ai.channels`; empty follows `ai.default_channel`. |
|
||||
| `reasoning` | Per-session reasoning override, controlled by the channel's `reasoning.allow_client_reasoning`. |
|
||||
| `hitl` | Per-session human-in-the-loop settings. |
|
||||
|
||||
## SSE Notes
|
||||
|
||||
Streaming endpoints are long-lived. Clients should:
|
||||
@@ -55,6 +67,151 @@ Streaming endpoints are long-lived. Clients should:
|
||||
- disable proxy buffering;
|
||||
- pass `conversationId` when continuing a conversation.
|
||||
|
||||
## File Management Sources
|
||||
|
||||
The file management page and `GET /api/chat-uploads` group conversation-related files by source. Directory names still use project IDs or conversation IDs for stability, while the UI prefers project names or conversation titles and keeps the full ID available in tooltips or copied paths.
|
||||
|
||||
| Source | `source` | Typical directory | Meaning | Mutability |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Workspace files | `workspace` | `tmp/workspace/projects/<projectId>/...`, `tmp/workspace/conversations/<conversationId>/...` | The Agent workspace for downloaded files, analysis scripts, intermediate results, and generated CSV/XLSX/Markdown files. If an AI-generated file is missing from the UI, check this source first. | Read-only listing; supports copy path, download, and export. |
|
||||
| Conversation artifacts | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | Conversation-scoped deliverables or archived artifacts such as summaries, reports, or middleware-generated artifacts. | Read-only listing; supports copy path, download, and export. |
|
||||
| Tool outputs | `reduction` | `tmp/reduction/projects/<projectId>/...`, `tmp/reduction/conversations/<conversationId>/...` | Persisted full tool outputs, scan raw data, or outputs saved before truncation. Useful for reviewing long command or scan results. | Read-only listing; supports copy path, download, and export. |
|
||||
| Chat uploads | `upload` | `chat_uploads/<date>/<conversationId>/...` | Files manually uploaded in chat or from the file management page. Copy the server absolute path into chat when the AI should reference a file. | Supports upload, mkdir, text edit, rename, delete, copy path, download, and export. |
|
||||
|
||||
Related endpoints:
|
||||
|
||||
- `GET /api/chat-uploads`: list files filtered by source, project, conversation, or filename.
|
||||
- `GET /api/chat-uploads/path`: resolve a file-management relative path or internal virtual path to a server absolute path for copy actions.
|
||||
- `GET /api/chat-uploads/download`: download a file.
|
||||
- `GET /api/chat-uploads/export`: export the current filtered result as a ZIP.
|
||||
- `POST /api/chat-uploads`: upload into the chat uploads directory.
|
||||
|
||||
## Asset Management and Bulk Import
|
||||
|
||||
Asset endpoints:
|
||||
|
||||
- `GET /api/assets`: list and filter assets;
|
||||
- `GET /api/assets/selection`: resolve cross-page selection from the current filters, up to 10,000 rows;
|
||||
- `GET /api/assets/stats`: retrieve statistics; `days` accepts only `7`, `30`, or `90`;
|
||||
- `POST /api/assets/import`: create or deduplicate and update up to 100,000 assets;
|
||||
- `POST /api/assets/scan-links`: record up to 10,000 scan links;
|
||||
- `PUT /api/assets/bulk`: atomically update up to 10,000 assets;
|
||||
- `PUT /api/assets/project-binding`: bind up to 10,000 asset IDs to a project;
|
||||
- `POST /api/assets/batch-delete`: atomically delete up to 10,000 assets;
|
||||
- `POST /api/assets/merge`: merge 2-100 duplicate assets with a shared identity;
|
||||
- `PUT /api/assets/:id`: update an asset;
|
||||
- `DELETE /api/assets/:id`: delete an asset.
|
||||
|
||||
`GET /api/assets` and `GET /api/assets/selection` share filters and sorting. `selection` ignores pagination and returns all matching rows, up to 10,000:
|
||||
|
||||
| Category | Parameters |
|
||||
| --- | --- |
|
||||
| Pagination (list only) | `page`, `page_size` (maximum: 100) |
|
||||
| Common | `q`, `status`, `project_id`, `risk_level`, `min_vulnerabilities`, `max_vulnerabilities` |
|
||||
| Target and source | `host`, `ip`, `domain`, `port`, `protocol`, `source`, `tag` |
|
||||
| Responsibility and business | `responsible_person`, `department`, `business_system`, `environment`, `criticality` |
|
||||
| Location | `country`, `province`, `city` |
|
||||
| Scan | `scan_state=never|scanned`, `scan_overdue_days`, `last_scan_before`, `last_scan_after` |
|
||||
| Discovery time | `first_seen_before`, `first_seen_after`, `last_seen_before`, `last_seen_after` |
|
||||
| Sort | `sort_by`, `sort_order=asc|desc` |
|
||||
|
||||
Time parameters accept RFC3339 or `YYYY-MM-DD`. Supported `sort_by` values are `last_seen_at`, `last_scan_at`, `first_seen_at`, `created_at`, `updated_at`, `host`, `port`, `risk_level`, and `vulnerability_count`.
|
||||
|
||||
`POST /api/assets/import` accepts JSON, not an XLSX/CSV upload. The Web UI parses the template in the browser, previews it, and converts valid rows to this request:
|
||||
|
||||
```http
|
||||
POST /api/assets/import
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "manual-import",
|
||||
"source_query": "asset-import-2026-07.xlsx",
|
||||
"assets": [
|
||||
{
|
||||
"host": "https://app.example.com:443",
|
||||
"domain": "app.example.com",
|
||||
"port": 443,
|
||||
"protocol": "https",
|
||||
"title": "Example App",
|
||||
"server": "nginx",
|
||||
"project_id": "<project-id>",
|
||||
"responsible_person": "Alice",
|
||||
"department": "Security",
|
||||
"business_system": "Customer Portal",
|
||||
"environment": "production",
|
||||
"criticality": "critical",
|
||||
"tags": ["production", "internet"],
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"ip": "192.0.2.10",
|
||||
"port": 22,
|
||||
"protocol": "ssh",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Request rules:
|
||||
|
||||
- `assets` must contain between 1 and 100,000 entries;
|
||||
- at least one of `host`, `ip`, or `domain` must be non-empty for each asset;
|
||||
- `port` must be between `0` and `65535`;
|
||||
- `status` must be `active` or `inactive`;
|
||||
- `environment` may be empty or `production`, `staging`, `testing`, `development`, or `other`;
|
||||
- `criticality` may be empty or `critical`, `high`, `medium`, or `low`;
|
||||
- an asset may have up to 30 tags, each no longer than 64 characters;
|
||||
- a non-empty `project_id` must reference a project accessible to the caller;
|
||||
- the caller needs `asset:write`;
|
||||
- the server deduplicates by “target + port + protocol” and processes the request in one transaction.
|
||||
|
||||
Successful response:
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 120,
|
||||
"updated": 8,
|
||||
"skipped": 2
|
||||
}
|
||||
```
|
||||
|
||||
`created` counts new records, `updated` counts deduplicated merges, and `skipped` counts empty or inaccessible existing records. Validation errors return `400` with the failing asset position in `error`; inaccessible projects return `403`. See [Asset Management](asset-management.md#import-from-a-spreadsheet) for the template and UI workflow.
|
||||
|
||||
Bulk edit example:
|
||||
|
||||
```http
|
||||
PUT /api/assets/bulk
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"asset_ids": ["<asset-id-1>", "<asset-id-2>"],
|
||||
"responsible_person": "Alice",
|
||||
"department": "Security",
|
||||
"environment": "production",
|
||||
"criticality": "high",
|
||||
"add_tags": ["internet-facing"],
|
||||
"remove_tags": ["untriaged"]
|
||||
}
|
||||
```
|
||||
|
||||
All patch fields are optional; omitted fields retain their current values. `add_tags` and `remove_tags` are deduplicated inside the transaction. Bulk edit, project binding, and batch deletion validate access to every requested asset first, so a missing or inaccessible ID fails the entire operation.
|
||||
|
||||
Duplicate merge example:
|
||||
|
||||
```http
|
||||
POST /api/assets/merge
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"asset_ids": ["<primary-id>", "<duplicate-id>"],
|
||||
"primary_id": "<primary-id>"
|
||||
}
|
||||
```
|
||||
|
||||
Every record being removed must share a domain, IP address, or Host with the primary asset. Existing primary values win, empty fields are filled from the other records, and tags are unioned. The caller needs permission to update the primary and delete the other assets.
|
||||
|
||||
## Stability Tiers
|
||||
|
||||
| API type | Stability | Recommendation |
|
||||
@@ -62,6 +219,7 @@ Streaming endpoints are long-lived. Clients should:
|
||||
| `/api/auth/*` | high | safe to integrate |
|
||||
| `/api/eino-agent*` | high | preferred chat entry |
|
||||
| `/api/openapi/spec` | high | client generation |
|
||||
| `/api/assets/*` | high | asset management and bulk import |
|
||||
| `/api/config*` | medium | admin automation only |
|
||||
| `/api/c2/*`, `/api/webshell/*` | medium | high-risk, restrict access |
|
||||
| frontend private calls | low | avoid plugin dependency |
|
||||
@@ -70,6 +228,7 @@ Streaming endpoints are long-lived. Clients should:
|
||||
|
||||
- Conversations: `/api/conversations`
|
||||
- Projects/facts: `/api/projects`
|
||||
- Assets and bulk import: `/api/assets`
|
||||
- Vulnerabilities: `/api/vulnerabilities`
|
||||
- Knowledge: `/api/knowledge/*`
|
||||
- Roles: `/api/roles`
|
||||
@@ -101,3 +260,5 @@ curl -k https://127.0.0.1:8080/api/eino-agent \
|
||||
- OpenAPI: `internal/handler/openapi.go`
|
||||
- Single-agent: `internal/handler/eino_single_agent.go`
|
||||
- Multi-agent: `internal/handler/multi_agent.go`
|
||||
- Asset endpoints: `internal/handler/asset.go`
|
||||
- Asset storage and deduplication: `internal/database/asset.go`
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# Asset Management
|
||||
|
||||
[中文](../zh-CN/asset-management.md)
|
||||
|
||||
Asset management consolidates domains, IP addresses, ports, and services discovered through manual entry, network-space search engines, HTTP APIs, and Agent tasks into a maintainable baseline. It answers three questions: what assets exist, which assets have been assessed, and where risk is concentrated.
|
||||
|
||||
> This feature is designed for security testing and attack-surface governance. It is not a replacement for a full enterprise CMDB. Add and scan only systems you own or are explicitly authorized to test.
|
||||
|
||||
## Overview
|
||||
|
||||
Asset management provides three main views:
|
||||
|
||||
- **Overview**: asset totals, IPs, domains, ports, recent changes, scan coverage, and protocol distribution.
|
||||
- **Asset inventory**: identity, service details, source, tags, project ownership, responsibility and business metadata, scan history, and risk state.
|
||||
- **Reconnaissance**: search FOFA, ZoomEye, Quake, or Shodan and save confirmed results individually or in batches.
|
||||
|
||||
Assets can launch single-target analysis or batch scans. After an Agent records findings and completes the scan callback, the inventory displays related vulnerability counts, risk level, and latest scan time.
|
||||
|
||||
The overview can show the last 7, 30, or 90 days. It includes added/inactive asset trends, vulnerability discovery trends (including critical/high findings), total and 30-day scan coverage, never-scanned and stale counts, and the top eight protocols. Every statistic is restricted to the current user's accessible assets.
|
||||
|
||||
## Asset fields
|
||||
|
||||
An asset can include:
|
||||
|
||||
- host, IP address, domain, port, and protocol;
|
||||
- page title and service or product fingerprint;
|
||||
- country/region, state/province, and city;
|
||||
- responsible person, department, business system, environment, and criticality;
|
||||
- source, source query, and tags;
|
||||
- active or inactive status;
|
||||
- project and owner;
|
||||
- first seen, last seen, created, and updated timestamps;
|
||||
- latest scan time and linked conversation, task queue, and subtask;
|
||||
- related vulnerability count and current risk level.
|
||||
|
||||
At least one of `host`, `ip`, or `domain` is required.
|
||||
|
||||
## Build an asset baseline
|
||||
|
||||
### Add an asset manually
|
||||
|
||||
Go to **Asset Management → Asset Inventory** and select **Add Asset**. Supported target forms include:
|
||||
|
||||
```text
|
||||
https://example.com:8443
|
||||
example.com
|
||||
192.0.2.10:443
|
||||
[2001:db8::1]:443
|
||||
```
|
||||
|
||||
The system attempts to identify the URL, domain, IP address, port, and protocol. You can then add a project, tags, title, service fingerprint, location, responsible person, department, business system, environment, criticality, and status.
|
||||
|
||||
### Import from a spreadsheet
|
||||
|
||||
Go to **Asset Management → Asset Inventory** and select **Bulk Import**:
|
||||
|
||||
1. Download the XLSX (recommended) or CSV template.
|
||||
2. Enter assets in the `Assets` sheet without changing the header row.
|
||||
3. Choose the completed file or drop it onto the upload area.
|
||||
4. Review row-level validation. Duplicates, invalid values, and inaccessible projects are marked as errors.
|
||||
5. Select **Import valid rows**. Invalid rows are not submitted. When the file contains more than 100 rows, the preview shows the first 100 while submission processes every valid row.
|
||||
6. Review the created, updated, and skipped counts.
|
||||
|
||||
Template columns:
|
||||
|
||||
| Column | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `target` | Conditional | URL, domain, IPv4, IPv6, or a target with a port; required when `host`, `ip`, and `domain` are all empty |
|
||||
| `project` | No | Exact name or ID of an existing project; leave blank for no project |
|
||||
| `tags` | No | Comma, semicolon, or pipe-separated; up to 30 tags and 64 characters per tag |
|
||||
| `host` | Conditional | Full URL or host; may supplement `target` |
|
||||
| `ip` | Conditional | Valid IPv4 or IPv6 address |
|
||||
| `domain` | Conditional | Valid domain; internationalized domains are normalized |
|
||||
| `port` | No | `0-65535`; may be inferred from `target` |
|
||||
| `protocol` | No | Such as `http`, `https`, or `ssh`; may be inferred from a URL or common port |
|
||||
| `title` | No | Page title, up to 500 characters |
|
||||
| `server` | No | Service or product fingerprint |
|
||||
| `country` / `province` / `city` | No | Location metadata |
|
||||
| `responsible_person` | No | Responsible person, up to 255 characters |
|
||||
| `department` | No | Responsible department, up to 255 characters |
|
||||
| `business_system` | No | Owning business system, up to 255 characters |
|
||||
| `environment` | No | `production`, `staging`, `testing`, `development`, or `other` |
|
||||
| `criticality` | No | `critical`, `high`, `medium`, or `low` |
|
||||
| `status` | No | `active` or `inactive`; defaults to `active` |
|
||||
|
||||
The parser recognizes the template's English headers and common Chinese aliases. Environment and criticality columns also accept their corresponding Chinese values. Automated exports should keep the English headers and enum values to avoid ambiguous mappings.
|
||||
|
||||
Limits and behavior:
|
||||
|
||||
- One XLSX/CSV file may contain up to 100,000 rows and be up to 100 MB.
|
||||
- One `/api/assets/import` request may contain up to 100,000 assets.
|
||||
- Later rows with the same “target + port + protocol” in one file are marked as duplicates and are not submitted.
|
||||
- The Web UI parses and previews the file; the server remains responsible for authorization, validation, normalization, deduplication, and transactional writes.
|
||||
- Existing assets receive non-empty incoming fields and a refreshed last-seen time instead of a duplicate record.
|
||||
- Bulk import requires `asset:write`. Referenced projects must also be accessible to the current user.
|
||||
- Do not remove the server-side row limit. Split larger datasets and import them during a low-traffic window.
|
||||
|
||||
### Import from network-space search
|
||||
|
||||
1. Configure the relevant API key in the configuration file or under **System Settings → Asset Management**. Environment variables are also supported: `FOFA_API_KEY`, `ZOOMEYE_API_KEY`, `QUAKE_API_KEY`, and `SHODAN_API_KEY`.
|
||||
2. Open **Asset Management → Reconnaissance**.
|
||||
3. Select the data source: FOFA, ZoomEye, Quake, or Shodan.
|
||||
4. Enter or generate a query for that source and confirm its scope.
|
||||
5. Run the query, select results whose ownership has been verified, and choose **Save Selected**.
|
||||
6. Review the created, updated, and skipped counts.
|
||||
|
||||
Internet search results are not automatically your assets. Narrow the query with organization domains, certificates, network ranges, or product fingerprints, then verify authorization before saving results.
|
||||
|
||||
## Normalization and deduplication
|
||||
|
||||
Different sources may describe the same target in different forms. The system:
|
||||
|
||||
- trims surrounding whitespace;
|
||||
- normalizes IP addresses, domains, and protocols to lowercase;
|
||||
- extracts hostname, protocol, and port from URL-like hosts;
|
||||
- fills default HTTP/HTTPS ports when omitted;
|
||||
- converts internationalized domains to ASCII/Punycode;
|
||||
- removes empty or duplicate tags;
|
||||
- supplies default source and status values.
|
||||
|
||||
Assets use “target + port + protocol” as the service-level deduplication key. The preferred target is the domain, followed by the IP address, then the host. As a result, `80/http` and `443/https` on the same host remain separate assets.
|
||||
|
||||
When an existing asset is imported again, non-empty incoming fields and the last-seen time are updated. Existing fields omitted by the new record and the original first-seen time are preserved.
|
||||
|
||||
## Search, filters, and views
|
||||
|
||||
Keyword search in the Web UI covers hosts, IP addresses, domains, titles, services, tags, responsible people, departments, and business systems. Status and project are the primary filters; advanced filters can combine:
|
||||
|
||||
- risk level and minimum vulnerability count;
|
||||
- protocol, port, source, and exact tag;
|
||||
- scanned, never scanned, or not scanned for 30/60/90 days;
|
||||
- country/region, state/province, city, responsible person, department, and business system;
|
||||
- environment, criticality, first-seen dates, and last-seen dates;
|
||||
- sorting by last seen, latest scan, risk, vulnerability count, first seen, target name, or port.
|
||||
|
||||
Sorting by latest scan time in ascending order places never-scanned assets first, making coverage gaps visible.
|
||||
|
||||
Frequently used combinations can be saved as filter views. Saved views use the current browser's `localStorage`; they are not synchronized to the server, other browsers, or other users.
|
||||
|
||||
The HTTP API and `query_assets` additionally support `max_vulnerabilities`, latest-scan time ranges, and allowlisted creation/update sort fields. HTTP lists allow up to 100 rows per page, while Agent queries allow up to 50.
|
||||
|
||||
## Bulk maintenance and export
|
||||
|
||||
After selecting assets, you can act on the current page or select every result matching the current filters. Cross-page selection resolves the filters again on the server and is limited to 10,000 assets; narrow the filters when the result exceeds that limit.
|
||||
|
||||
Available actions:
|
||||
|
||||
- **Bind project**: replace the project binding for all selected assets;
|
||||
- **Bulk edit**: change status, responsible person, department, business system, environment, and criticality, and add or remove tags;
|
||||
- **Create scan task / Send to chat**: apply one prompt template to the selected assets;
|
||||
- **Export CSV / XLSX**: export the currently selected rows in the browser, including ownership, risk, vulnerability count, and timestamp fields;
|
||||
- **Merge duplicates**: keep the first selected asset as primary, fill its empty fields from the other records, and union their tags;
|
||||
- **Batch delete**: permanently delete the selected assets.
|
||||
|
||||
Bulk edit, project binding, and batch delete are all-or-nothing transactions. If any requested asset is missing or outside the caller's scope, the entire operation fails without a partial update.
|
||||
|
||||
Merge is only allowed when every duplicate shares a domain, IP address, or Host with the primary asset, and accepts 2-100 selected records. Existing primary values win, tags are unioned subject to the 30-tag limit, and the other records are deleted. It requires both `asset:write` and `asset:delete`; confirm the primary record and the scan history you need to retain before merging.
|
||||
|
||||
## Scanning and risk updates
|
||||
|
||||
### Scan one asset
|
||||
|
||||
Select **Scan** from the asset inventory. The system:
|
||||
|
||||
1. creates a conversation containing the target and asset ID;
|
||||
2. links the conversation to the asset;
|
||||
3. prompts the Agent to inspect exposed services and authorized risks;
|
||||
4. stores confirmed findings with `record_vulnerability`;
|
||||
5. updates scan state with `complete_asset_scan`.
|
||||
|
||||
Scan prompts support `{{asset_id}}`, `{{target}}`, `{{host}}`, `{{ip}}`, `{{domain}}`, and `{{port}}`. Adjust scope, ports, test intensity, and validation methods to match the authorization before starting.
|
||||
|
||||
### Batch scans
|
||||
|
||||
Select multiple assets and choose **Create Scan Task**. The system creates one subtask per asset and links each asset to its queue and subtask.
|
||||
|
||||
The current defaults use manual scheduling, one concurrent task, and Eino single-Agent mode to limit load on targets and the local host. You must still confirm the test window, request rate, permitted validation methods, and approval requirements.
|
||||
|
||||
### Risk calculation
|
||||
|
||||
Asset risk is calculated dynamically from open vulnerabilities in the latest linked scan:
|
||||
|
||||
- `critical`, `high`, `medium`, `low`, or `info`: an open finding at that level exists;
|
||||
- `normal`: the asset was scanned and has no open risk;
|
||||
- `unassessed`: no scan has completed.
|
||||
|
||||
Resolved, false-positive, and ignored findings remain in historical counts but no longer increase the current risk level.
|
||||
|
||||
## Agent tools
|
||||
|
||||
Six built-in tools expose asset operations to Agents:
|
||||
|
||||
- `create_asset`: create or deduplicate and update an asset;
|
||||
- `get_asset`: retrieve full details by ID;
|
||||
- `query_assets`: filter, sort, and paginate assets;
|
||||
- `update_asset`: partially update an asset;
|
||||
- `delete_asset`: delete an asset;
|
||||
- `complete_asset_scan`: record scan completion.
|
||||
|
||||
`query_assets` returns 20 summaries by default and allows at most 50 per page. Use `get_asset` for full details so large inventories do not consume the model context.
|
||||
|
||||
Both `create_asset` and `update_asset` accept responsibility and business metadata, and `query_assets` can filter by those fields. Agent writes go through the same normalization, validation, deduplication, and authorization checks as the HTTP API.
|
||||
|
||||
## Access control
|
||||
|
||||
Asset permissions are separated into:
|
||||
|
||||
- `asset:read`: view assets and statistics;
|
||||
- `asset:write`: create, import, edit, and update scan state;
|
||||
- `asset:delete`: delete assets.
|
||||
|
||||
Server-side authorization considers the asset owner, explicit resource assignments, the linked project, and permission scope (`all`, `assigned`, or `own`). When a conversation is linked to a project, Agent asset queries are restricted to that project and tool arguments cannot widen the boundary.
|
||||
|
||||
Asset batch endpoint limits:
|
||||
|
||||
- `POST /api/assets/import`: up to 100,000 assets per request;
|
||||
- `GET /api/assets/selection`: resolve up to 10,000 matching assets;
|
||||
- `POST /api/assets/scan-links`: up to 10,000 links per request;
|
||||
- `PUT /api/assets/bulk`: up to 10,000 asset IDs per request;
|
||||
- `PUT /api/assets/project-binding`: up to 10,000 asset IDs per request;
|
||||
- `POST /api/assets/batch-delete`: up to 10,000 asset IDs per request;
|
||||
- `POST /api/assets/merge`: merge 2-100 asset IDs per request.
|
||||
|
||||
## Recommended workflow
|
||||
|
||||
1. Define an explicitly authorized set of domains, IP addresses, or network ranges.
|
||||
2. Add a few critical targets manually and verify normalization and deduplication.
|
||||
3. Use tags to separate production, testing, critical-business, and internet-facing scopes.
|
||||
4. Configure one or more network-space search engines, begin with narrow queries, and verify ownership.
|
||||
5. Test scanning and vulnerability callbacks on one low-risk target.
|
||||
6. Use never-scanned and over-30-day filters to identify coverage gaps.
|
||||
7. After validating the workflow, expand gradually with small batch tasks.
|
||||
|
||||
A small, verified baseline is usually more valuable than a large inventory with unclear ownership and inconsistent sources.
|
||||
@@ -11,13 +11,20 @@ server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
tls_enabled: true
|
||||
# Optional: other trusted Web integrations; Chromium extensions need no entry.
|
||||
# cors_allowed_origins:
|
||||
# - https://trusted-integration.example
|
||||
auth:
|
||||
session_duration_hours: 12
|
||||
openai:
|
||||
provider: openai
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
agent:
|
||||
max_iterations: 12000
|
||||
tool_timeout_minutes: 60
|
||||
@@ -25,13 +32,66 @@ agent:
|
||||
|
||||
Change the initial `admin` password from the Web UI after first login. Use HTTPS or a trusted reverse proxy in any shared environment.
|
||||
|
||||
Valid Chromium `chrome-extension://<32-character-extension-id>` origins are recognized automatically. The extension must still obtain host permission and authenticate with a password and Bearer token. `server.cors_allowed_origins` remains available as an exact allowlist for other trusted Web integrations; wildcards are not accepted, and changing it requires a restart.
|
||||
|
||||
## AI Channels
|
||||
|
||||
`ai` is the recommended model configuration entry. In the Web UI, use **System Settings → Basic Settings → AI Channel Configuration**. Saving that form writes `ai.default_channel` and `ai.channels`. The legacy `openai` field remains as a backward-compatible runtime field; on load, CyberStrikeAI ensures a default channel exists and synchronizes the resolved `ai.default_channel` into runtime `openai`.
|
||||
|
||||
```yaml
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
reasoning:
|
||||
mode: on
|
||||
effort: high
|
||||
allow_client_reasoning: true
|
||||
profile: openai_compat
|
||||
claude-main:
|
||||
name: Claude Main
|
||||
provider: claude
|
||||
base_url: https://api.anthropic.com/v1
|
||||
api_key: sk-ant-...
|
||||
model: claude-sonnet-4-5
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `ai.default_channel` | Default channel ID for new conversations and requests without an explicit channel. |
|
||||
| `ai.channels.<id>` | Channel config. IDs are normalized to lowercase letters, digits, and hyphens. |
|
||||
| `name` | Display name in the Web UI; falls back to the ID. |
|
||||
| `provider` | `openai_compatible` or `claude`. OpenAI-compatible channels map to runtime `openai`; Claude channels bridge to Anthropic Messages API. |
|
||||
| `base_url/api_key/model` | Required. Base URL usually includes a version path such as `/v1`. |
|
||||
| `max_total_tokens` | Shared context budget for compression, attack-chain generation, multi-agent summaries, and similar paths. |
|
||||
| `max_completion_tokens` | Per-response output cap; default is used when empty. |
|
||||
| `reasoning` | Default reasoning fields for the channel. Gateway support varies; try `mode: off` first when a provider rejects requests. |
|
||||
|
||||
The chat page reads saved channels into the “AI Channel” selector. A non-empty request `aiChannelId` selects a channel for that run/session without sending API credentials through the prompt path. Empty `aiChannelId` follows `ai.default_channel`.
|
||||
|
||||
Common Web UI operations:
|
||||
|
||||
- Add: click `+`, fill required fields, then save.
|
||||
- Set default: select a channel, click **Set as default**, then save/apply.
|
||||
- Copy: duplicate the current form, useful for the same provider with a different model.
|
||||
- Delete: keep at least one channel; the default channel is protected from bulk delete.
|
||||
- Probe: use **Test connection** or **Bulk probe** to validate API key, Base URL, and model.
|
||||
|
||||
## Hot-Apply Boundaries
|
||||
|
||||
`POST /api/config/apply` coordinates model config, tool description mode, MCP tool registration, knowledge components, robot restarts, and C2 runtime reconciliation. It does not make every field instantly effective.
|
||||
|
||||
| Section | Usually hot-applies | Extra action |
|
||||
| --- | --- | --- |
|
||||
| `openai` | new requests use new model settings | running streams keep their current state |
|
||||
| `ai.default_channel` / `ai.channels` | new requests use the resolved default or selected channel | running streams keep their current state; reload config for the frontend channel list |
|
||||
| `openai` | compatibility field, usually synchronized from the default AI channel | prefer maintaining new config in `ai.channels` |
|
||||
| `agent.max_iterations` | new tasks | existing tasks continue |
|
||||
| `hitl.tool_whitelist` | new approval checks | pending approvals are not re-decided |
|
||||
| `knowledge.enabled` | initializes/updates components | scan and index are still required |
|
||||
@@ -42,8 +102,8 @@ Change the initial `admin` password from the Web UI after first login. Use HTTPS
|
||||
|
||||
## Fallback Relationships
|
||||
|
||||
- `vision.api_key/base_url/provider` can inherit from `openai`.
|
||||
- `hitl.audit_model` can inherit from `openai`.
|
||||
- `vision.api_key/base_url/provider` can inherit from the resolved default AI channel.
|
||||
- `hitl.audit_model` can inherit from the resolved default AI channel.
|
||||
- `knowledge.embedding.base_url/api_key` can inherit from model settings.
|
||||
- rerank config can inherit from embedding/openai.
|
||||
- `database.knowledge_db_path` can be separate or reuse the main DB.
|
||||
|
||||
@@ -103,6 +103,16 @@ Update:
|
||||
- `docs/zh-CN/README.md`
|
||||
- `docs/en-US/README.md`
|
||||
|
||||
Before submitting documentation changes, run:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-docs.py
|
||||
```
|
||||
|
||||
The check verifies local links, fenced code blocks, bilingual filename parity, locale index coverage, and the Go version documented by the root READMEs. Keep versioned examples derived from authoritative files such as `go.mod` and `config.example.yaml` whenever possible.
|
||||
|
||||
The same command runs automatically in the `Documentation` GitHub Actions workflow when documentation, `go.mod`, or the checker itself changes.
|
||||
|
||||
## Review Focus
|
||||
|
||||
Prioritize:
|
||||
|
||||
@@ -10,7 +10,7 @@ CyberStrikeAI can run as a local testing tool, an internal team service, or a pr
|
||||
- Python for some MCP servers and tool scripts.
|
||||
- SQLite files under `data/`; no external DB is required by default.
|
||||
- Actual security tools installed in PATH. YAML files under `tools/` only describe commands.
|
||||
- An OpenAI-compatible model endpoint, or `openai.provider: claude` for the Claude bridge.
|
||||
- At least one `ai.channels` entry. Use `provider: openai_compatible` for OpenAI-compatible endpoints, or `provider: claude` for the Claude bridge.
|
||||
|
||||
Important persistent paths:
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ hitl:
|
||||
provider: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
model: "" # set a small model here; blank reuses openai.model
|
||||
model: "" # set a small model here; blank reuses the default AI channel model
|
||||
retention_days: 90
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
||||
```
|
||||
|
||||
`audit_model` supports partial configuration. Empty fields inherit from the main `openai` config, so the common setup is to fill only `model` and run approvals on a cheaper small model.
|
||||
`audit_model` supports partial configuration. Empty fields inherit from the resolved default AI channel, so the common setup is to fill only `model` and run approvals on a cheaper small model.
|
||||
|
||||
## Recommended Approval Strategy
|
||||
|
||||
|
||||
+6
-2
@@ -87,7 +87,7 @@ Permissions use `module:action`. Common actions are `read`, `write`, `delete`, a
|
||||
| Robots | `robot:read`, `robot:write` |
|
||||
| Files | `files:read`, `files:write`, `files:delete` |
|
||||
| Attack chain | `attackchain:read`, `attackchain:write` |
|
||||
| FOFA | `fofa:execute` |
|
||||
| Network-space search / Reconnaissance | `fofa:execute` |
|
||||
| OpenAPI | `openapi:read` |
|
||||
| Chat groups | `group:read`, `group:write`, `group:delete` |
|
||||
| Monitor | `monitor:read`, `monitor:write`, `monitor:delete` |
|
||||
@@ -98,6 +98,7 @@ Important distinctions:
|
||||
- `agent:local-execute` is the local execution fallback and should be limited to trusted operators.
|
||||
- `mcp:execute` protects the authenticated MCP HTTP entry point.
|
||||
- `mcp:external:execute` allows Agent calls to external MCP tools and currently also requires `all` scope.
|
||||
- `fofa:execute` is kept for backward compatibility, but it now protects the Reconnaissance page for FOFA, ZoomEye, Quake, and Shodan searches.
|
||||
- `mcp:write` manages external MCP configuration; it is separate from external tool execution.
|
||||
- `robot:write` manages robot configuration and the test endpoint. Chatbot conversations use the bound user or configured service account's business permissions.
|
||||
|
||||
@@ -360,6 +361,10 @@ Inspect the scope for that specific permission, not only the overall display sco
|
||||
|
||||
Role changes revoke sessions. Sign in again. Robots resolve again on the next message.
|
||||
|
||||
### The built-in `admin` password is lost
|
||||
|
||||
Prefer resetting it from another administrator account with `rbac:write`. If no administrator session is available, follow the [administrator password recovery procedure](troubleshooting.md#recover-a-forgotten-admin-password) on the server.
|
||||
|
||||
### A global mutation is denied despite `write`
|
||||
|
||||
Process-global definitions require the corresponding permission with `all` scope. Create a dedicated global administration role instead of widening unrelated permissions.
|
||||
@@ -371,4 +376,3 @@ Process-global definitions require the corresponding permission with `all` scope
|
||||
### External MCP requires global scope
|
||||
|
||||
The user needs `mcp:external:execute`, and that permission's scope must be `all`.
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Tool Execution Governance
|
||||
|
||||
[Back to English documentation](README.md)
|
||||
|
||||
This document describes how CyberStrikeAI governs long-running tools, blocking MCP calls, oversized outputs, cancellation, and context restore. The goal is to preserve standard Agent/Eino tool semantics while preventing tool hangs, context blowups, oversized database records, and unsafe resume behavior.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Keep the agent runner responsive**: tools may run for a long time, but the current runner waits only for a bounded interval.
|
||||
- **Allow long tasks to continue**: timeout returns an `execution_id`; later turns can call `wait_tool_execution`.
|
||||
- **Support cancellation**: users and agents can cancel a running execution.
|
||||
- **Keep DB and agent views identical**: the database stores the same canonical capped result returned to the agent.
|
||||
- **Protect resume paths**: resume uses model-facing traces and caps historical oversized tool traces.
|
||||
- **Isolate external MCP failures**: external MCP servers are protected by per-server concurrency limits, global concurrency limits, and circuit breakers.
|
||||
|
||||
## Execution Model
|
||||
|
||||
Tool calls still appear to Eino/Agent as standard tool invocations, but the blocking work runs in a worker:
|
||||
|
||||
```text
|
||||
Agent calls tool
|
||||
-> ExecutionService creates execution
|
||||
-> worker runs the real MCP/tool call
|
||||
-> Agent bounded wait
|
||||
-> completed: return tool result
|
||||
-> still running: return execution_id, worker continues in background
|
||||
```
|
||||
|
||||
This prevents MCP servers, `exec`, `sqlmap`, `nmap`, `nuclei`, and similar long-running tools from binding the current runner indefinitely.
|
||||
|
||||
## Execution Statuses
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| `queued` | Execution exists and is waiting for a worker or concurrency slot |
|
||||
| `running` | Worker is executing |
|
||||
| `background_running` | UI display state: agent stopped waiting, background worker continues |
|
||||
| `completed` | This tool call completed |
|
||||
| `failed` | The tool actually failed |
|
||||
| `cancelled` | User, agent, or session cleanup cancelled the execution |
|
||||
| `hard_timeout` | The tool exceeded its hard timeout |
|
||||
| `orphaned` | A persisted running execution no longer has a runtime worker |
|
||||
|
||||
Important: when `wait_tool_execution` reaches `timeout_seconds` and the target execution is still running, the wait call itself is a completed observation, not a failed tool execution.
|
||||
|
||||
## Control Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `get_tool_execution` | Read current execution state |
|
||||
| `wait_tool_execution` | Wait for a selected execution for a bounded interval |
|
||||
| `cancel_tool_execution` | Cancel a selected execution |
|
||||
|
||||
`get_tool_execution` and `wait_tool_execution` can include a live output preview:
|
||||
|
||||
- `include_partial_output`: whether to return partial output, default `true`.
|
||||
- `partial_output_max_bytes`: tail preview limit for this call, default `4096`, maximum `65536`.
|
||||
|
||||
Partial output is a bounded preview of output produced so far, not the final `result`. The canonical `result` is still written only when the tool finishes. Tools that do not support streaming output simply omit partial fields.
|
||||
|
||||
Typical flow:
|
||||
|
||||
```text
|
||||
1. Call a long-running tool such as exec/sqlmap/nmap
|
||||
2. After tool_wait_timeout_seconds, receive execution_id
|
||||
3. Agent can continue reasoning, use other tools, or call wait_tool_execution
|
||||
4. If still incomplete, continue waiting or call cancel_tool_execution
|
||||
```
|
||||
|
||||
`tool_wait_timeout_seconds` applies to internal MCP tools, external MCP tools, and Eino filesystem's streaming `execute`. Eino's non-streaming filesystem tools such as `ls/read_file/write_file/edit_file/glob/grep` are recorded in execution monitoring, but they are not converted into resumable background workers.
|
||||
|
||||
## Cancellation and Session Cleanup
|
||||
|
||||
- User stop cancels running tools for the current conversation.
|
||||
- Normal session end cancels remaining running tools for the current conversation.
|
||||
- Interrupt-and-continue style flows do not mass-cancel tools.
|
||||
- Conversation-scoped cancellation avoids killing tools from other conversations.
|
||||
|
||||
## External MCP Isolation
|
||||
|
||||
External MCP servers can hang, disconnect, or return failures. CyberStrikeAI uses three protections:
|
||||
|
||||
| Capability | Config | Description |
|
||||
|---|---|---|
|
||||
| Per-server concurrency | `external_mcp_max_concurrent_per_server` | Max simultaneous calls for one external MCP server |
|
||||
| Global concurrency | `external_mcp_max_concurrent_total` | Max simultaneous external MCP calls across all servers |
|
||||
| Circuit breaker | `external_mcp_circuit_failure_threshold` / `external_mcp_circuit_cooldown_seconds` | Temporarily fast-fails a server after repeated failures |
|
||||
|
||||
Recommended defaults:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
external_mcp_max_concurrent_per_server: 2
|
||||
external_mcp_max_concurrent_total: 16
|
||||
external_mcp_circuit_failure_threshold: 3
|
||||
external_mcp_circuit_cooldown_seconds: 60
|
||||
```
|
||||
|
||||
## Output Governance
|
||||
|
||||
CyberStrikeAI uses `multi_agent.eino_middleware.reduction_max_length_for_trunc` as the unified tool result cap. The example configuration uses 50000 bytes.
|
||||
|
||||
```yaml
|
||||
multi_agent:
|
||||
eino_middleware:
|
||||
reduction_enable: true
|
||||
reduction_max_length_for_trunc: 50000
|
||||
```
|
||||
|
||||
Coverage:
|
||||
|
||||
| Channel | Behavior |
|
||||
|---|---|
|
||||
| Agent-facing tool result | Uses the canonical capped result |
|
||||
| DB / monitor storage | Stores the same canonical result |
|
||||
| `get_tool_execution` / `wait_tool_execution` | Reads the same canonical result |
|
||||
| Eino `execute` / filesystem monitor records | Capped before completion is persisted |
|
||||
| Non-streaming `exec` stdout/stderr | Source-side bounded buffer |
|
||||
| Streaming `exec` stdout/stderr | Streamed UI output is also bounded |
|
||||
| PTY execution path | Uses the same output cap |
|
||||
| Frontend detail modal | Has an additional UI display cap |
|
||||
|
||||
When the cap is reached, the full output is written to a local trunc file and the agent-facing payload becomes a `<persisted-output>` notice (with absolute path) that fits inside the configured budget.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
<persisted-output>
|
||||
Output too large (200000). Full output saved to: /path/to/tmp/reduction/conversations/<id>/trunc/<execution_id>
|
||||
Use read_file with offset/limit to read parts of the file.
|
||||
Preview (first …):
|
||||
…
|
||||
|
||||
Preview (last …):
|
||||
…
|
||||
|
||||
</persisted-output>
|
||||
```
|
||||
|
||||
The current strategy is “spill full text to disk + bounded preview in context.” Agents can recover the original via `read_file`.
|
||||
|
||||
## Database and Resume Context
|
||||
|
||||
New results are written through this path:
|
||||
|
||||
```text
|
||||
tool completes
|
||||
-> NormalizeToolResultForStorage
|
||||
-> update in-memory execution
|
||||
-> persist to DB
|
||||
-> return to Agent
|
||||
```
|
||||
|
||||
So, under normal operation, the DB stores exactly the result returned to the agent.
|
||||
|
||||
Resume uses `LastAgentTraceInput`, which is the model-facing trace that actually reached ChatModel, not raw event accumulation. The restore path also caps historical tool content to prevent context blowups from:
|
||||
|
||||
- pre-upgrade DB records that contain raw large output,
|
||||
- manual imports or migrations,
|
||||
- lowering the configured cap from a larger value,
|
||||
- future bypasses that accidentally skip canonicalization.
|
||||
|
||||
## Recommended Configuration
|
||||
|
||||
For long-running security tasks:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
max_iterations: 800
|
||||
tool_timeout_minutes: 60
|
||||
tool_wait_timeout_seconds: 30
|
||||
external_mcp_max_concurrent_per_server: 2
|
||||
external_mcp_max_concurrent_total: 16
|
||||
external_mcp_circuit_failure_threshold: 3
|
||||
external_mcp_circuit_cooldown_seconds: 60
|
||||
shell_no_output_timeout_seconds: 1200
|
||||
|
||||
multi_agent:
|
||||
eino_middleware:
|
||||
reduction_enable: true
|
||||
reduction_max_length_for_trunc: 50000
|
||||
```
|
||||
|
||||
| Parameter | Recommended | Notes |
|
||||
|---|---:|---|
|
||||
| `max_iterations` | `300-1000` | Very large values weaken loop protection |
|
||||
| `tool_timeout_minutes` | `60` | Hard timeout for tools such as sqlmap |
|
||||
| `tool_wait_timeout_seconds` | `30-60` | Agent wait bound before returning `execution_id` |
|
||||
| `shell_no_output_timeout_seconds` | `600-1200` | Kills silent hangs |
|
||||
| `reduction_max_length_for_trunc` | `50000` | Unified tool result cap |
|
||||
|
||||
Do not make `tool_wait_timeout_seconds` very large by default. Long tasks should continue in workers and be observed by `execution_id`, rather than blocking one turn for several minutes.
|
||||
|
||||
## Testing
|
||||
|
||||
Long-task test prompt:
|
||||
|
||||
```text
|
||||
Call exec to run sleep 120. If it is not done after 10 seconds, do not keep waiting; report execution_id, call wait_tool_execution for 5 seconds, then cancel_tool_execution if still incomplete and report final status.
|
||||
```
|
||||
|
||||
Large-output test prompt:
|
||||
|
||||
```text
|
||||
Call exec to run: python3 - <<'PY'
|
||||
print("A" * 200000)
|
||||
PY
|
||||
Then show the tool result length and whether it contains the truncation marker.
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
- The initial long task returns an `execution_id` and status `running` or UI `background_running`.
|
||||
- `wait_tool_execution` timing out while the target is still running is not displayed as a tool failure.
|
||||
- Large output never exceeds `reduction_max_length_for_trunc`.
|
||||
- DB, monitor details, and agent continuation use the same capped result.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- CyberStrikeAI cannot control how a remote external MCP server collects output internally; it caps results after they enter CyberStrikeAI and protects calls with concurrency limits and circuit breakers.
|
||||
- Oversized tool output is spilled to local `tmp/reduction/.../trunc/<id>` (or `reduction_root_dir`) before truncation; the bounded result includes an absolute path for `read_file`.
|
||||
@@ -42,12 +42,33 @@ Login fails:
|
||||
- stale cookie;
|
||||
- audit throttling repeated failures.
|
||||
|
||||
### Recover a forgotten `admin` password
|
||||
|
||||
If another administrator with `rbac:write` is available, reset the password under **Platform permissions → User management**.
|
||||
|
||||
If no administrator session is available, the built-in `admin` account can be recovered on the server. Change to the project root and run:
|
||||
|
||||
```bash
|
||||
./run.sh --reset-admin-password
|
||||
```
|
||||
|
||||
Enter and confirm the new password when prompted. The script hides input and stores a bcrypt hash. If the service is running, restart it afterward to invalidate existing login sessions.
|
||||
|
||||
If `run.sh` is not available, run the command below manually. Enter and confirm the new password when prompted:
|
||||
|
||||
```bash
|
||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||
```
|
||||
|
||||
Output `1` means that the row was updated. The command requires `sqlite3` and `htpasswd`. If `database.path` in `config.yaml` is not the default, replace `data/conversations.db`. Password input is hidden and is not written to shell history.
|
||||
|
||||
Model fails:
|
||||
|
||||
- wrong `base_url` path;
|
||||
- selected AI channel does not exist; empty selection follows `ai.default_channel`;
|
||||
- wrong `ai.channels.<id>.base_url` path;
|
||||
- invalid API key;
|
||||
- model unavailable;
|
||||
- reasoning fields unsupported by gateway. Try `openai.reasoning.mode: off`.
|
||||
- reasoning fields unsupported by gateway. Try `ai.channels.<id>.reasoning.mode: off`.
|
||||
|
||||
Streaming stalls:
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# CyberStrikeAI Graph Orchestration Guide
|
||||
# CyberStrikeAI Workflows Guide
|
||||
|
||||
[中文](../zh-CN/workflow-graph.md)
|
||||
|
||||
This document explains how to use **Graph Orchestration**: building workflows on the canvas, configuring node types, passing data between nodes, and binding a graph to a role for automatic execution.
|
||||
This document explains how to use **Workflows**: building workflows on the canvas, configuring node types, passing data between nodes, and binding a workflow to a role for automatic execution.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where to find Graph Orchestration
|
||||
## 1. Where to find Workflows
|
||||
|
||||
1. Log in to the CyberStrikeAI web UI.
|
||||
2. Open **Graph Orchestration** in the left sidebar.
|
||||
2. Open **Workflows** in the left sidebar.
|
||||
3. Select an existing workflow from the list, or create a new one.
|
||||
4. Drag nodes, draw edges, and configure properties on the canvas.
|
||||
5. Fill in **ID**, **Name**, and **Description**, then click **Save**.
|
||||
|
||||
+30
-28
@@ -1,30 +1,32 @@
|
||||
# 中文文档
|
||||
|
||||
- [部署指南](deployment.md):部署形态、HTTPS、反向代理、systemd、备份、升级和验收。
|
||||
- [运维 Runbooks](runbooks.md):生产部署、外部 MCP、知识库、Web 测试、C2 清理和工具排障的操作步骤。
|
||||
- [配置画像](configuration-profiles.md):本地开发、内网团队、知识库、高审计生产、C2 演练等推荐配置。
|
||||
- [安全加固指南](security-hardening.md):上线前基线、反向代理、HITL 白名单、文件权限和周期巡检。
|
||||
- [API Recipes](api-recipes.md):登录、Agent、流式、多代理、上传、漏洞、知识库、MCP 和审计导出示例。
|
||||
- [贡献规范](contributing-guide.md):新增 API、配置、工具、前端、数据库、高风险能力和文档的 checklist。
|
||||
- [配置参考](configuration.md):`config.yaml` 字段、热应用边界、参数建议和源码锚点。
|
||||
- [安全模型](security-model.md):信任边界、HITL、工具执行、C2/WebShell 与数据安全。
|
||||
- [RBAC 权限管理](rbac.md):平台用户、系统/自定义角色、权限目录、逐权限 Scope、资源授权、Agent/MCP/机器人边界与 API 示例。
|
||||
- [架构说明](architecture.md):请求路径、模块关系、复杂度热点和设计取舍。
|
||||
- [API 参考](api-reference.md):认证、OpenAPI、SSE、稳定性分层和常用接口。
|
||||
- [排错指南](troubleshooting.md):诊断顺序、最小命令、常见误判和故障模板。
|
||||
- [审计与监控](audit-and-monitoring.md):平台审计、工具监控、HITL 日志和保留策略。
|
||||
- [知识库](knowledge-base.md):索引链路、检索调参、日志分析和内容写法。
|
||||
- [C2 使用说明](c2.md):生命周期、任务分级、事件复盘和安全建议。
|
||||
- [WebShell 管理](webshell.md):操作分层、连接命名、AI 约束和排错。
|
||||
- [MCP 联邦](mcp-federation.md):内置 MCP、外部 MCP、生命周期和工具命名。
|
||||
- [Agent 与角色](agent-and-role-guide.md):角色、子代理、Skill、编排模式和工具可见性。
|
||||
- [Skills 指南](skills-guide.md):Skill 结构、渐进式披露、反模式和本地工具风险。
|
||||
- [插件开发](plugin-development.md):API 插件、MCP 插件、资源包插件和安全边界。
|
||||
- [发布流程](release-process.md):发布风险、配置兼容、数据库迁移和验收。
|
||||
- [测试指南](testing.md):测试分层、回归重点、测试数据和失败用例。
|
||||
- [图编排使用说明](workflow-graph.md)
|
||||
- [人机协同最佳实践](hitl-best-practices.md)
|
||||
- [机器人使用说明](robot.md)
|
||||
- [视觉分析](VISION.md)
|
||||
- [前端国际化方案](frontend-i18n.md)
|
||||
- [Eino 多代理改造说明](MULTI_AGENT_EINO.md)
|
||||
[文档首页](../README.md) | [English](../en-US/README.md)
|
||||
|
||||
## 按目标开始
|
||||
|
||||
- **快速体验**:[部署指南](deployment.md) → [配置参考](configuration.md) → [排错指南](troubleshooting.md)
|
||||
- **生产部署**:[配置画像](configuration-profiles.md) → [安全加固](security-hardening.md) → [运维 Runbooks](runbooks.md) → [审计与监控](audit-and-monitoring.md)
|
||||
- **接入与自动化**:[API 参考](api-reference.md) → [API Recipes](api-recipes.md) → [MCP 联邦](mcp-federation.md)
|
||||
- **参与开发**:[开发者指南](developer-guide.md) → [测试指南](testing.md) → [贡献规范](contributing-guide.md)
|
||||
|
||||
## 核心概念与编排
|
||||
|
||||
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
||||
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md) · [Agent 最终回复治理](agent-finalization-best-practices.md)
|
||||
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
||||
|
||||
## 功能指南
|
||||
|
||||
- [资产管理](asset-management.md) · [知识库](knowledge-base.md) · [机器人接入](robot.md) · [视觉分析](VISION.md)
|
||||
- [WebShell](webshell.md) · [C2](c2.md) · [MCP 联邦](mcp-federation.md)
|
||||
|
||||
## 运维与参考
|
||||
|
||||
- [部署指南](deployment.md) · [配置参考](configuration.md) · [配置画像](configuration-profiles.md)
|
||||
- [安全加固](security-hardening.md) · [审计与监控](audit-and-monitoring.md) · [运维 Runbooks](runbooks.md)
|
||||
- [API 参考](api-reference.md) · [API Recipes](api-recipes.md) · [排错指南](troubleshooting.md)
|
||||
|
||||
## 开发与发布
|
||||
|
||||
- [开发者指南](developer-guide.md) · [插件开发](plugin-development.md) · [前端国际化](frontend-i18n.md)
|
||||
- [测试指南](testing.md) · [贡献规范](contributing-guide.md) · [发布流程](release-process.md)
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
vision:
|
||||
enabled: true
|
||||
model: qwen-vl-max # 必填
|
||||
api_key: # 留空 → openai.api_key
|
||||
base_url: # 留空 → openai.base_url
|
||||
provider: # 留空 → openai.provider
|
||||
api_key: # 留空 → 默认 AI 通道 api_key
|
||||
base_url: # 留空 → 默认 AI 通道 base_url
|
||||
provider: # 留空 → 默认 AI 通道 provider
|
||||
max_image_bytes: 5242880
|
||||
max_dimension: 2048
|
||||
jpeg_quality: 82
|
||||
@@ -28,7 +28,7 @@ vision:
|
||||
|
||||
## Web 设置
|
||||
|
||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用 OpenAI)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||
**系统设置 → 基本设置 → 视觉分析(analyze_image)** 可配置启用开关、视觉模型、API Key/Base URL(留空复用默认 AI 通道)、预处理参数;**保存并应用** 后写入 `config.yaml` 并重新注册 MCP 工具。
|
||||
|
||||
## 路径
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
# Agent 最终回复治理最佳实践
|
||||
|
||||
[返回中文文档](README.md)
|
||||
|
||||
调研日期:2026-07-28
|
||||
|
||||
本文聚焦一个具体问题:Agent 在工具调用、推理、计划或子代理协作尚未真正完成时,输出了一段“像结论”的自然语言,前端或编排层把它当作最终回复展示。结论先说清楚:成熟 Agent 系统不会用“最近一段 assistant 文本”判断任务完成,而是用运行时状态、工具状态、验证结果和显式终态事件共同决定是否 final。
|
||||
|
||||
## 一、核心结论
|
||||
|
||||
1. **最终回复是运行时事件,不是自然语言内容。**
|
||||
“已拿到”“下一步”“Huge breakthrough”这类文本只能作为候选观察或进展,不能作为完成信号。
|
||||
|
||||
2. **过程面和交付面必须隔离。**
|
||||
`thinking`、`reasoning_chain`、`planning`、`response_delta`、子代理回复、工具输出都属于过程面;只有通过 final gate 的 `response` / `final` 事件才能写入主消息气泡和 `messages.content`。
|
||||
|
||||
3. **复杂任务需要 verifier,而不是更长 prompt。**
|
||||
Prompt 可以提醒模型谨慎,但最终完成必须由代码层判断:是否仍有待执行工具、后台 execution、未完成计划步骤、未验证证据、未记录事实/漏洞、未清理或未说明不可清理。
|
||||
|
||||
4. **不同 agent 模式不同,但 final 治理原则一致。**
|
||||
单代理、Deep、Plan-Execute、Supervisor 都需要 final gate。区别只是 gate 的证据来源不同:单代理看工具轨迹,Deep 还要看子代理结果,Plan-Execute 要看 Replanner 的终止判断,Supervisor 要看 `exit` 与 supervisor 汇总。
|
||||
|
||||
## 二、成熟 Agent 的公开做法
|
||||
|
||||
| 系统 | 公开做法 | 对 final 治理的启发 |
|
||||
|---|---|---|
|
||||
| Codex | OpenAI 的 Codex prompting guide 建议不要在 prompt 中强行要求 upfront plan、preamble 或 status updates,因为这可能导致 rollout 未完成就停止。 | 不要把“模型自己说的阶段性计划/状态”当完成依据;agent harness 应负责执行循环和收尾。 |
|
||||
| Claude Code | Claude Code 提供 `PreToolUse`、`PostToolUse`、`Stop` 等 hooks;`PostToolUse` 明确发生在工具成功执行之后。 | 生命周期事件比自然语言可靠。验证、审计、阻断应挂在确定的阶段边界上。 |
|
||||
| Claude Code Subagents | 子代理有独立上下文、自定义系统提示、特定工具权限和独立权限;子代理适合隔离大量检索/日志/文件读取。 | 子代理输出是证据材料,不是主任务最终结论;主代理必须汇总、验收、再 final。 |
|
||||
| Claude Code Plan Mode | Plan mode 先读文件并产出计划,获得批准前不编辑。 | 计划与执行是不同状态;计划完成不等于任务完成。 |
|
||||
| Cursor Plan Mode | Cursor Plan Mode 会研究代码库、询问澄清问题、生成可审查计划,并等待用户确认后再构建。 | UI 层把 plan/review/build 拆开,用户不会把计划误认为最终交付。 |
|
||||
| OpenCode | OpenCode 把 Build、Plan、Review、Debug、Docs 等 agent 分成不同工具权限与用途,Plan agent 只分析规划不做修改。 | 用 agent 能力边界降低误触发:能规划的 agent 不等于能执行完成。 |
|
||||
| Eino ADK | Eino ADK 提供事件驱动输出、Runner 回调、中断、checkpoint,以及 Supervisor、Plan-Execute 等协作原语。Plan-Execute 由 Planner、Executor、Replanner 协作。 | 当前项目选型方向正确;需要把事件驱动能力进一步固化为 finalization contract。 |
|
||||
|
||||
主要参考:
|
||||
|
||||
- OpenAI Codex Prompting Guide: https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide
|
||||
- Claude Code Hooks: https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||
- Claude Code Subagents: https://docs.anthropic.com/en/docs/claude-code/sub-agents
|
||||
- Claude Code Common Workflows: https://docs.anthropic.com/en/docs/claude-code/common-workflows
|
||||
- Cursor Agent Best Practices: https://cursor.com/blog/agent-best-practices
|
||||
- OpenCode Agents: https://opencode.ai/docs/agents/
|
||||
- CloudWeGo Eino ADK: https://www.cloudwego.io/docs/eino/core_modules/eino_adk/
|
||||
- CloudWeGo Eino ADK Patterns: https://www.cloudwego.io/docs/eino/overview/eino_adk0_1/
|
||||
|
||||
## 三、通用最佳实践
|
||||
|
||||
### 1. 建立 Finalization Contract
|
||||
|
||||
所有执行入口统一产出一个结构化收尾对象,只有它允许触发最终回复。
|
||||
|
||||
```go
|
||||
type FinalizationDecision struct {
|
||||
Status string // in_progress | completed | blocked | failed | cancelled
|
||||
Finalizable bool
|
||||
CompletionReason string // verified | user_cancelled | timeout | blocked | failed
|
||||
FinalText string
|
||||
EvidenceVerified bool
|
||||
EvidenceRefs []string
|
||||
PendingToolRuns []string
|
||||
PendingPlanSteps []string
|
||||
PendingApprovals []string
|
||||
MissingChecks []string
|
||||
}
|
||||
```
|
||||
|
||||
硬规则:
|
||||
|
||||
- `Finalizable=false` 时禁止发送 `response` 终态事件。
|
||||
- `Status=in_progress` 时只能发 `progress`、`planning`、`tool_*`、`reasoning_chain` 等过程事件。
|
||||
- `FinalText` 不能为空,但非空不代表可以 final。
|
||||
- `PendingToolRuns`、`PendingPlanSteps`、`PendingApprovals` 任一非空时不能 `completed`。
|
||||
- `EvidenceVerified=false` 时不能把候选输出写成已验证结论。
|
||||
|
||||
### 2. 固定 SSE 事件语义
|
||||
|
||||
推荐事件分层:
|
||||
|
||||
| 事件 | 展示位置 | 可否写 `messages.content` | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `progress` | 任务状态/时间线 | 否 | 简短进度 |
|
||||
| `planning` | 执行详情 | 否 | 主代理计划、阶段性判断 |
|
||||
| `reasoning_chain` / `thinking` | 执行详情 | 否 | 推理/思考摘要 |
|
||||
| `tool_call` / `tool_result` | 执行详情 | 否 | 工具事件 |
|
||||
| `eino_agent_reply` | 执行详情 | 否 | 子代理返回材料 |
|
||||
| `finalization_check` | 执行详情 | 否 | verifier 结果 |
|
||||
| `finalization_auto_continue` | 执行详情 | 否 | verifier 触发的工程续跑,`contextInjection=false` |
|
||||
| `response` | 主消息气泡 | 是 | 只能在 `data.finalized=true` 时使用 |
|
||||
| `done` | 关闭流 | 否 | 仅表示流结束,不表示任务成功 |
|
||||
| `error` / `cancelled` | 主消息气泡或系统提示 | 是,终态失败类 | 必须带原因 |
|
||||
|
||||
### 3. 把“最终候选”与“最终回复”分开
|
||||
|
||||
模型可以输出候选结论,但候选结论必须先进入 `final_candidate` 或 `planning`,再由 verifier 决定是否提升:
|
||||
|
||||
```text
|
||||
assistant text
|
||||
-> candidate
|
||||
-> finalization gate
|
||||
-> response(finalized=true)
|
||||
```
|
||||
|
||||
不要这样做:
|
||||
|
||||
```text
|
||||
assistant text
|
||||
-> response
|
||||
```
|
||||
|
||||
### 4. Stop-time Verification
|
||||
|
||||
借鉴 Claude Code hook 思路,在 agent run 停止时做一次确定性检查:
|
||||
|
||||
- 所有工具调用都有对应 tool result。
|
||||
- 后台 execution 都处于 terminal 状态,或被明确登记为仍在运行且任务状态为 `in_progress` / `blocked`。
|
||||
- Plan-Execute 没有未执行的 required step。
|
||||
- Supervisor 没有未汇总的子代理结果。
|
||||
- 在 evidence-required 策略下,至少存在可查询到的 completed 工具执行证据。
|
||||
|
||||
### 5. 子代理输出只作证据
|
||||
|
||||
子代理返回不能直接成为用户最终回复。主代理必须完成:
|
||||
|
||||
- 去重和冲突合并。
|
||||
- 证据强度排序。
|
||||
- 不确定性标注。
|
||||
- 范围边界确认。
|
||||
- 用户可读交付。
|
||||
|
||||
### 6. Prompt 只做软约束,代码做硬约束
|
||||
|
||||
Prompt 中可以写:
|
||||
|
||||
```text
|
||||
Interim observations must be marked as progress, not final.
|
||||
Do not produce a final answer until verification is complete.
|
||||
```
|
||||
|
||||
但真正决定 final 的必须是后端字段和状态机。否则模型只要生成一段像最终结论的自然语言,UI 仍可能误判。
|
||||
|
||||
## 四、CyberStrikeAI 当前落地状态
|
||||
|
||||
当前项目已经具备一套显式 final gate:
|
||||
|
||||
- [internal/agentfinalizer/decision.go](../../internal/agentfinalizer/decision.go) 是唯一的最终回复决策契约。
|
||||
- [internal/handler/finalization_helpers.go](../../internal/handler/finalization_helpers.go) 负责把决策结果写入 `process_details`,并且只有 `Finalizable=true` 时才调用 `UpdateAssistantMessageFinalize`。
|
||||
- [internal/handler/eino_single_agent.go](../../internal/handler/eino_single_agent.go)、[internal/handler/multi_agent.go](../../internal/handler/multi_agent.go)、[internal/handler/workflow_integration.go](../../internal/handler/workflow_integration.go)、[internal/handler/batch_queue_executor.go](../../internal/handler/batch_queue_executor.go) 均已在收尾处接入 finalizer。
|
||||
- [web/static/js/monitor.js](../../web/static/js/monitor.js) 只把 `data.finalized === true` 的 `response` 当最终回复;未最终化文本会显示为最终回复检查未通过。
|
||||
- [web/static/js/webshell.js](../../web/static/js/webshell.js) 将流式正文标记为候选输出,只有 `response(finalized=true)` 才切换为完成态。
|
||||
- [internal/agentfinalizer/decision_test.go](../../internal/agentfinalizer/decision_test.go) 覆盖 pending tool、HITL、空输出、证据策略要求但缺执行证据、失败证据不能支撑最终化、完成态证据可 final 等回归场景。
|
||||
- [internal/handler/finalization_auto_continue.go](../../internal/handler/finalization_auto_continue.go) 在缺 completed 执行证据时最多自动续跑 2 段;续跑只恢复已有模型轨迹,不向 agent 注入新的 user/system 文案。
|
||||
|
||||
当前契约的核心规则:
|
||||
|
||||
1. **模型自然语言只是 candidate。**
|
||||
`RunResult.Response` 不能直接升级为最终回复,必须经过 `agentfinalizer.Decide`。
|
||||
|
||||
2. **所有 `response` 事件必须携带终态字段。**
|
||||
至少包含 `finalized`、`finalizable`、`status`、`completionReason`、`evidenceVerified`、`evidenceRefs`、`pendingExecutionIds`、`missingChecks`。
|
||||
|
||||
3. **未完成工具会阻断 final。**
|
||||
`queued/running` 工具执行仍存在时,决策结果为 `in_progress/pending_tool_executions`。
|
||||
|
||||
4. **执行证据必须由结构化策略声明。**
|
||||
后端不从用户自然语言、助手回复或 agent mode 名称中推断执行意图。聊天请求通过 `finalization.requireExecutionEvidence` 显式声明;WebShell、Workflow、批量、机器人等执行入口由调用点显式传入 policy。policy 要求证据时,至少需要一个可查询到的 `completed` 工具执行记录;只有 failed/cancelled 记录不能支撑最终化。
|
||||
|
||||
5. **缺执行证据先工程续跑,再阻断。**
|
||||
Eino 单代理和 Eino 多代理主链路在 `missing_execution_evidence` 时会先通过已有 trace 自动续跑,不注入额外上下文;达到续跑上限后仍缺证据才写入 blocked。
|
||||
|
||||
6. **HITL 和空输出不会 final。**
|
||||
workflow 等待人工确认、空 assistant 文本、Eino 空输出占位均会写入阻断文案,而不是成功总结。
|
||||
|
||||
## 五、贴合当前项目的推荐架构
|
||||
|
||||
当前采用的链路是:
|
||||
|
||||
```text
|
||||
Agent / Eino ADK events
|
||||
-> event normalizer
|
||||
-> process_details
|
||||
-> finalization verifier
|
||||
-> response(finalized=true)
|
||||
-> messages.content
|
||||
```
|
||||
|
||||
### 1. 后端统一 Finalizer
|
||||
|
||||
职责:
|
||||
|
||||
- 接收 `RunResult` / 候选文本、`mcpExecutionIds`、会话与助手消息 ID、HITL 状态、编排模式。
|
||||
- 通过数据库查询工具执行状态,识别 pending、completed、failed、cancelled 等证据状态。
|
||||
- 返回 `FinalizationDecision`。
|
||||
- 不调用高风险工具,只做状态和证据检查。
|
||||
|
||||
### 2. RunResult 终态字段
|
||||
|
||||
[internal/multiagent/runner.go](../../internal/multiagent/runner.go) 已扩展终态字段:
|
||||
|
||||
```go
|
||||
type RunResult struct {
|
||||
Response string
|
||||
MCPExecutionIDs []string
|
||||
LastAgentTraceInput string
|
||||
LastAgentTraceOutput string
|
||||
|
||||
Finalized bool
|
||||
Status string
|
||||
CompletionReason string
|
||||
EvidenceVerified bool
|
||||
EvidenceRefs []string
|
||||
PendingExecutionIDs []string
|
||||
MissingChecks []string
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 发送 `response` 的条件
|
||||
|
||||
在单代理、多代理、工作流、批处理收尾处统一执行:
|
||||
|
||||
```go
|
||||
decision := h.finalizeAgentRunForDelivery(...)
|
||||
if !decision.Finalizable {
|
||||
sendEvent("finalization_check", "任务尚未达到最终回复条件", decision)
|
||||
sendEvent("response", finalizationBlockedMessage(decision), finalizationResponsePayload(decision, extra))
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("response", decision.FinalText, finalizationResponsePayload(decision, extra))
|
||||
```
|
||||
|
||||
### 4. 前端只信 `finalized=true`
|
||||
|
||||
在 [web/static/js/monitor.js](../../web/static/js/monitor.js) 的 `case 'response'` 中执行硬判断:
|
||||
|
||||
```js
|
||||
const responseFinalized = isFinalizedResponseData(responseData);
|
||||
const bubbleText = responseFinalized
|
||||
? resolvedResponseText
|
||||
: (event.message || '任务尚未达到最终回复条件,暂不生成成功结论。');
|
||||
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||
```
|
||||
|
||||
WebShell 侧同理:`response_delta` 可以用于实时预览,但 UI 文案应标记为“执行中输出”,只有最终 `response(finalized=true)` 才显示为完成态。
|
||||
|
||||
### 5. 各模式 final gate
|
||||
|
||||
| 模式 | 谁可以产出最终候选 | 谁决定 final | 必须检查 |
|
||||
|---|---|---|---|
|
||||
| Eino 单代理 | 单代理最后助手文本 | Finalizer | 无 pending tool、证据引用完整、任务状态 terminal |
|
||||
| Deep | 主代理汇总文本 | Finalizer | 子代理结果已汇总;子代理文本不能直接 final;工具状态 terminal |
|
||||
| Plan-Execute | Replanner 结束后的汇总文本 | Replanner + Finalizer | Executor 单步输出不能 final;计划步骤完成或明确 blocked |
|
||||
| Supervisor | Supervisor 的 `exit` / 汇总文本 | Supervisor + Finalizer | transfer 已返回;无未处理专家结果;最终由 supervisor 统一口径 |
|
||||
|
||||
### 6. 安全测试场景的证据 gate
|
||||
|
||||
安全测试、WebShell、批量验证、Workflow 和多代理执行等 evidence-required 场景,最终回复必须至少满足:
|
||||
|
||||
- 有明确目标和授权范围标识。
|
||||
- 有可复核证据引用,例如工具 execution id、请求/响应摘要、截图路径、命令输出摘要、事实/漏洞记录 ID。
|
||||
- 有身份或影响验证结果,而不是只凭 marker 文本判断。
|
||||
- 已记录到项目黑板或漏洞库,或明确说明未绑定项目导致无法记录。
|
||||
- 高风险动作已清理、回滚、取消,或明确说明未执行清理的原因。
|
||||
- 仍在运行的扫描/命令/WebShell/C2 任务不能被隐式当作完成。
|
||||
|
||||
注意:这里的 gate 是治理规则,不要求最终报告暴露敏感利用细节;可以只给证据摘要和内部引用。
|
||||
|
||||
## 六、落地状态与后续增强
|
||||
|
||||
### P0:先修“误 final”(已落地)
|
||||
|
||||
1. 已引入 `FinalizationDecision`。
|
||||
2. 主要 agent SSE `response` 事件已携带 `data.finalized/finalizable/status/completionReason` 等字段。
|
||||
3. 前端 `monitor.js` 和 `webshell.js` 已按 `finalized=true` 区分候选输出和最终回复。
|
||||
4. `RunResult.Response` 仍保留兼容字段名,但语义已由 finalizer 统一提升;后续可再拆成 `CandidateResponse` / `FinalResponse`,减少误用空间。
|
||||
5. Plan-Execute / Deep / Supervisor / Eino Single 等模式均通过统一 handler 收尾 gate。
|
||||
|
||||
### P1:补证据链(部分落地)
|
||||
|
||||
1. 已用 `mcp_execution:<id>` 作为基础 evidence refs。
|
||||
2. `finalization_check` 事件已展示 pending execution 与 missing checks。
|
||||
3. 执行入口已启用显式 execution evidence policy;Eino 主链路在 policy 要求证据且缺少 completed 工具证据时先无注入续跑,达到上限后才阻断 final。
|
||||
4. 后续建议:为 `record_vulnerability`、`upsert_project_fact`、项目黑板记录建立更细粒度 evidence refs。
|
||||
5. 后续建议:最终报告模板固定包含“结论、证据、风险/不确定性、后续动作”。
|
||||
|
||||
### P2:体验和观测(后续增强)
|
||||
|
||||
1. 在任务卡片展示 `in_progress / verifying / finalizing / completed / blocked`。
|
||||
2. 为 finalizer 加日志和指标:误拦截率、缺失证据类型、pending tool 数量。
|
||||
3. 支持“继续验证”按钮,从 `FinalizationDecision.MissingChecks` 自动生成下一轮输入。
|
||||
|
||||
## 七、验收测试建议
|
||||
|
||||
至少加入这些回归测试:
|
||||
|
||||
1. **推理文本不 final**
|
||||
模拟 `reasoning_chain` 里出现看似完成的候选结论,但本轮没有 completed 工具执行证据;预期主消息气泡不显示成功结论,只显示执行中或阻断态。
|
||||
|
||||
2. **主代理阶段性输出不 final**
|
||||
模拟 `response_start/delta` 输出“下一步继续验证”;预期只进入 timeline `planning`。
|
||||
|
||||
3. **未完成后台工具不 final**
|
||||
工具返回 `execution_id` 且状态 `running`;即使模型给出总结,也只能 `in_progress`。
|
||||
|
||||
4. **Plan-Execute Executor 输出不 final**
|
||||
Executor 输出“突破成功”,但 Replanner 未结束;预期不触发 `messages.content` finalize。
|
||||
|
||||
5. **Supervisor 子代理输出不 final**
|
||||
子代理返回确定结论,Supervisor 未 `exit`;预期只进入 `eino_agent_reply`。
|
||||
|
||||
6. **最终事件必须带 finalized**
|
||||
前端收到旧格式 `response` 无 `finalized=true`;预期候选内容只进入详情/警告,主消息显示阻断态,不创建成功最终气泡。
|
||||
|
||||
7. **失败和取消可终态**
|
||||
`error` / `cancelled` 仍可更新助手消息,但 `completionReason` 必须是 `failed` / `user_cancelled`,不能伪装为成功完成。
|
||||
|
||||
## 八、推荐默认策略
|
||||
|
||||
对 CyberStrikeAI,建议默认策略是:
|
||||
|
||||
```text
|
||||
eino_single:轻量任务可用,但 final gate 必须开启
|
||||
deep:复杂安全测试默认推荐
|
||||
plan_execute:目标明确、需要严格“规划-执行-重规划”的任务推荐
|
||||
supervisor:多专家路由任务使用,不作为默认泛化模式
|
||||
```
|
||||
|
||||
最终治理一句话:
|
||||
|
||||
```text
|
||||
messages.content 只能来自 FinalizationDecision.FinalText;
|
||||
process_details 可以展示所有过程;
|
||||
前端只能把 response(finalized=true) 当最终回复。
|
||||
```
|
||||
@@ -151,3 +151,52 @@ curl -k "https://127.0.0.1:8080/api/audit/logs/export" \
|
||||
```
|
||||
|
||||
导出文件可能包含敏感操作信息,应加密保存。
|
||||
|
||||
## Recipe 11:批量导入资产
|
||||
|
||||
先准备 `assets.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "api-import",
|
||||
"source_query": "cmdb-export-2026-07",
|
||||
"assets": [
|
||||
{
|
||||
"domain": "app.example.com",
|
||||
"port": 443,
|
||||
"protocol": "https",
|
||||
"tags": ["production", "internet"],
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"ip": "192.0.2.10",
|
||||
"port": 22,
|
||||
"protocol": "ssh",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
提交:
|
||||
|
||||
```bash
|
||||
curl -k https://127.0.0.1:8080/api/assets/import \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @assets.json
|
||||
```
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{"created":2,"updated":0,"skipped":0}
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 调用者需要 `asset:write` 权限。
|
||||
- 每条资产至少填写 `host`、`ip` 或 `domain`。
|
||||
- 单次最多 100000 条;大批量请求建议使用文件配合 `--data-binary`,不要把 JSON 直接写进命令行。
|
||||
- 已存在的“目标 + 端口 + 协议”会合并更新并计入 `updated`。
|
||||
- 如需从 XLSX/CSV 操作,使用 Web 端 **资产库 → 批量导入**;接口本身接收 JSON,不接收 multipart 文件。
|
||||
|
||||
@@ -52,6 +52,18 @@ Content-Type: application/json
|
||||
- `plan_execute`
|
||||
- `supervisor`
|
||||
|
||||
常用请求体字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `message` | 用户消息,必填。 |
|
||||
| `conversationId` | 继续已有对话;为空时创建新对话。 |
|
||||
| `projectId` | 新对话绑定项目;为空时可跟随 `config.project.default_project_id`。 |
|
||||
| `role` | 使用指定角色。 |
|
||||
| `aiChannelId` | 选择 `ai.channels` 中的通道 ID;为空时使用 `ai.default_channel`。 |
|
||||
| `reasoning` | 会话级推理覆盖,受通道 `reasoning.allow_client_reasoning` 控制。 |
|
||||
| `hitl` | 会话级人机协同配置。 |
|
||||
|
||||
对话管理:
|
||||
|
||||
- `POST /api/conversations`
|
||||
@@ -62,6 +74,25 @@ Content-Type: application/json
|
||||
- `POST /api/conversations/:id/delete-turn`
|
||||
- `GET /api/messages/:id/process-details`
|
||||
|
||||
## 文件管理来源
|
||||
|
||||
文件管理页面和 `/api/chat-uploads` 列表接口会把对话相关文件按来源归类。底层目录仍使用项目 ID 或会话 ID 保持稳定,界面会优先显示项目名或对话标题,完整 ID 可在提示或路径中查看。
|
||||
|
||||
| 来源 | `source` | 典型目录 | 说明 | 可变更性 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 工作目录 | `workspace` | `tmp/workspace/projects/<projectId>/...`、`tmp/workspace/conversations/<conversationId>/...` | Agent 执行任务时保存下载文件、分析脚本、中间结果和生成的 CSV/XLSX/Markdown 等。用户反馈“AI 生成的文件找不到”时,通常先看这里。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 会话产物 | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | 系统按会话归档的交付物或会话级产物,例如总结、报告、模型中间件生成的归档内容。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 工具输出 | `reduction` | `tmp/reduction/projects/<projectId>/...`、`tmp/reduction/conversations/<conversationId>/...` | 超长工具输出、扫描原文或被截断前落盘的结果缓存。适合回看完整工具输出。 | 只读展示;支持复制路径、下载、导出。 |
|
||||
| 对话附件 | `upload` | `chat_uploads/<date>/<conversationId>/...` | 用户在对话或文件管理页手动上传的附件。需要让 AI 引用某文件时,可复制服务器绝对路径粘贴到对话中。 | 可上传、新建目录、编辑文本文件、重命名、删除、复制路径、下载、导出。 |
|
||||
|
||||
相关接口:
|
||||
|
||||
- `GET /api/chat-uploads`:按来源、项目、会话、文件名筛选文件。
|
||||
- `GET /api/chat-uploads/path`:把文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件或目录路径。
|
||||
- `GET /api/chat-uploads/download`:下载指定文件。
|
||||
- `GET /api/chat-uploads/export`:导出当前筛选结果为 ZIP。
|
||||
- `POST /api/chat-uploads`:上传到对话附件目录。
|
||||
|
||||
## 项目、漏洞、攻击链
|
||||
|
||||
项目:
|
||||
@@ -89,6 +120,136 @@ Content-Type: application/json
|
||||
- `GET /api/attack-chain/:conversationId`
|
||||
- `POST /api/attack-chain/:conversationId/regenerate`
|
||||
|
||||
## 资产管理与批量导入
|
||||
|
||||
资产接口:
|
||||
|
||||
- `GET /api/assets`:分页查询资产;
|
||||
- `GET /api/assets/selection`:按当前筛选条件解析跨页选择,最多返回 10000 条;
|
||||
- `GET /api/assets/stats`:获取资产统计,`days` 仅支持 `7`、`30` 或 `90`;
|
||||
- `POST /api/assets/import`:新增或去重更新资产,单次最多 100000 条;
|
||||
- `POST /api/assets/scan-links`:批量记录扫描关联,单次最多 10000 条;
|
||||
- `PUT /api/assets/bulk`:原子批量更新最多 10000 个资产;
|
||||
- `PUT /api/assets/project-binding`:批量绑定项目,单次最多 10000 个资产 ID;
|
||||
- `POST /api/assets/batch-delete`:原子批量删除最多 10000 个资产;
|
||||
- `POST /api/assets/merge`:合并 2-100 个具有共同身份的重复资产;
|
||||
- `PUT /api/assets/:id`:更新资产;
|
||||
- `DELETE /api/assets/:id`:删除资产。
|
||||
|
||||
`GET /api/assets` 和 `GET /api/assets/selection` 使用相同的筛选与排序参数;`selection` 会忽略分页参数并返回全部匹配项(最多 10000 条):
|
||||
|
||||
| 类别 | 参数 |
|
||||
| --- | --- |
|
||||
| 分页(仅列表) | `page`、`page_size`(最大 100) |
|
||||
| 常用 | `q`、`status`、`project_id`、`risk_level`、`min_vulnerabilities`、`max_vulnerabilities` |
|
||||
| 目标与来源 | `host`、`ip`、`domain`、`port`、`protocol`、`source`、`tag` |
|
||||
| 责任与业务 | `responsible_person`、`department`、`business_system`、`environment`、`criticality` |
|
||||
| 地理 | `country`、`province`、`city` |
|
||||
| 扫描 | `scan_state=never|scanned`、`scan_overdue_days`、`last_scan_before`、`last_scan_after` |
|
||||
| 发现时间 | `first_seen_before`、`first_seen_after`、`last_seen_before`、`last_seen_after` |
|
||||
| 排序 | `sort_by`、`sort_order=asc|desc` |
|
||||
|
||||
时间参数接受 RFC3339 或 `YYYY-MM-DD`。`sort_by` 支持 `last_seen_at`、`last_scan_at`、`first_seen_at`、`created_at`、`updated_at`、`host`、`port`、`risk_level` 和 `vulnerability_count`。
|
||||
|
||||
`POST /api/assets/import` 接收 JSON,而不是 XLSX/CSV 文件。Web 端会在浏览器中解析模板、预览并转换为该请求格式:
|
||||
|
||||
```http
|
||||
POST /api/assets/import
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "manual-import",
|
||||
"source_query": "asset-import-2026-07.xlsx",
|
||||
"assets": [
|
||||
{
|
||||
"host": "https://app.example.com:443",
|
||||
"domain": "app.example.com",
|
||||
"port": 443,
|
||||
"protocol": "https",
|
||||
"title": "Example App",
|
||||
"server": "nginx",
|
||||
"project_id": "<project-id>",
|
||||
"responsible_person": "Alice",
|
||||
"department": "Security",
|
||||
"business_system": "Customer Portal",
|
||||
"environment": "production",
|
||||
"criticality": "critical",
|
||||
"tags": ["production", "internet"],
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"ip": "192.0.2.10",
|
||||
"port": 22,
|
||||
"protocol": "ssh",
|
||||
"status": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
请求规则:
|
||||
|
||||
- `assets` 必须包含 `1-100000` 条;
|
||||
- 每条资产的 `host`、`ip`、`domain` 至少一项非空;
|
||||
- `port` 范围为 `0-65535`;
|
||||
- `status` 仅支持 `active` 或 `inactive`;
|
||||
- `environment` 支持空值或 `production`、`staging`、`testing`、`development`、`other`;
|
||||
- `criticality` 支持空值或 `critical`、`high`、`medium`、`low`;
|
||||
- 标签最多 30 个,单个最多 64 个字符;
|
||||
- `project_id` 非空时,调用者必须有权访问该项目;
|
||||
- 需要 `asset:write` 权限;
|
||||
- 服务端按“目标 + 端口 + 协议”去重,并在同一事务中处理本次请求。
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 120,
|
||||
"updated": 8,
|
||||
"skipped": 2
|
||||
}
|
||||
```
|
||||
|
||||
- `created`:新建数量;
|
||||
- `updated`:命中去重键并合并更新的数量;
|
||||
- `skipped`:空记录或因资源归属不可更新而跳过的数量。
|
||||
|
||||
字段校验失败返回 `400`,且响应 `error` 会包含出错资产的顺序。项目无权访问返回 `403`。批量导入的模板字段和 UI 操作见[资产管理指南](asset-management.md#从表格批量导入)。
|
||||
|
||||
批量编辑示例:
|
||||
|
||||
```http
|
||||
PUT /api/assets/bulk
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"asset_ids": ["<asset-id-1>", "<asset-id-2>"],
|
||||
"responsible_person": "Alice",
|
||||
"department": "Security",
|
||||
"environment": "production",
|
||||
"criticality": "high",
|
||||
"add_tags": ["internet-facing"],
|
||||
"remove_tags": ["untriaged"]
|
||||
}
|
||||
```
|
||||
|
||||
批量字段均为可选;未提供的字段保持原值。`add_tags` 和 `remove_tags` 会在事务内去重处理。批量编辑、项目绑定和批量删除会先验证全部资产的可访问性,任一 ID 不存在或无权访问时整批失败。
|
||||
|
||||
重复资产合并示例:
|
||||
|
||||
```http
|
||||
POST /api/assets/merge
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"asset_ids": ["<primary-id>", "<duplicate-id>"],
|
||||
"primary_id": "<primary-id>"
|
||||
}
|
||||
```
|
||||
|
||||
每个待删除记录必须与主资产共享域名、IP 或 Host。主资产已有字段优先,空字段从其他记录补齐,标签取并集;调用者需要更新主资产和删除其余资产的权限。
|
||||
|
||||
## 工具、MCP、配置
|
||||
|
||||
配置:
|
||||
@@ -208,6 +369,7 @@ C2:
|
||||
| `/api/auth/*` | 高 | 可直接集成 |
|
||||
| `/api/eino-agent*` | 高 | 推荐外部对话入口 |
|
||||
| `/api/openapi/spec` | 高 | 用于生成客户端 |
|
||||
| `/api/assets/*` | 高 | 资产管理与批量导入 |
|
||||
| `/api/config*` | 中 | 管理工具使用,谨慎自动化 |
|
||||
| `/api/c2/*`、`/api/webshell/*` | 中 | 高风险,必须加权限边界 |
|
||||
| 前端私有调用细节 | 低 | 不建议插件依赖 |
|
||||
@@ -237,3 +399,5 @@ curl -k https://127.0.0.1:8080/api/eino-agent \
|
||||
- OpenAPI:`internal/handler/openapi.go`
|
||||
- 单代理:`internal/handler/eino_single_agent.go`
|
||||
- 多代理:`internal/handler/multi_agent.go`
|
||||
- 资产接口:`internal/handler/asset.go`
|
||||
- 资产存储与去重:`internal/database/asset.go`
|
||||
|
||||
@@ -14,7 +14,7 @@ flowchart LR
|
||||
M --> T["内置工具 / YAML 工具 / Skills FS"]
|
||||
M --> EM["外部 MCP"]
|
||||
A --> K["知识库检索"]
|
||||
H --> W["Workflow 图编排"]
|
||||
H --> W["Workflow 工作流"]
|
||||
H --> C2["内置 C2"]
|
||||
H --> WS["WebShell"]
|
||||
H --> AU["Audit / Monitor"]
|
||||
@@ -73,9 +73,9 @@ MCP 相关:
|
||||
|
||||
## Workflow
|
||||
|
||||
图编排在 `internal/workflow/`,HTTP 入口在 `internal/handler/workflow*.go`。它支持 start、agent、tool、condition、hitl、output、end 等节点。
|
||||
工作流引擎在 `internal/workflow/`,HTTP 入口在 `internal/handler/workflow*.go`。它支持 start、agent、tool、condition、hitl、output、end 等节点。
|
||||
|
||||
详细使用见 [图编排使用说明](workflow-graph.md)。
|
||||
详细使用见 [工作流使用说明](workflow-graph.md)。
|
||||
|
||||
## 知识库
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# 资产管理
|
||||
|
||||
[English](../en-US/asset-management.md)
|
||||
|
||||
资产管理用于将手工录入、网络空间测绘搜索、HTTP API 和 Agent 任务中发现的域名、IP、端口与服务统一归档,形成可持续维护的资产基线。它关注三个问题:当前拥有哪些资产、哪些资产已经检查、风险集中在哪里。
|
||||
|
||||
> 资产管理面向安全测试和攻击面治理,不替代完整的企业 CMDB。仅可录入和扫描自有系统或已获得明确授权的目标。
|
||||
|
||||
## 功能概览
|
||||
|
||||
资产管理包含三个主要入口:
|
||||
|
||||
- **资产概览**:统计资产总量、IP、域名、端口、近期变化、扫描覆盖率和协议分布。
|
||||
- **资产库**:维护资产身份、服务信息、来源、标签、项目归属、责任与业务属性、扫描记录和风险状态。
|
||||
- **信息收集**:查询 FOFA、ZoomEye、Quake 或 Shodan,并将确认归属的结果单条或批量写入资产库。
|
||||
|
||||
资产可以进一步发起单目标分析或批量扫描。Agent 保存漏洞并完成扫描回写后,资产列表会同步展示相关漏洞数量、风险等级和最近扫描时间。
|
||||
|
||||
概览支持切换最近 7、30 或 90 天,展示新增/停用资产趋势、漏洞发现趋势(含严重和高危)、扫描覆盖率、30 天内覆盖率、从未扫描与过期资产数量,以及协议 Top 8 分布。所有统计均受当前用户的资产访问范围约束。
|
||||
|
||||
## 资产字段
|
||||
|
||||
每条资产可记录:
|
||||
|
||||
- Host、IP、域名、端口和协议;
|
||||
- 页面标题、服务或产品指纹;
|
||||
- 国家/地区、省份/州和城市;
|
||||
- 负责人、部门、业务系统、环境和重要性;
|
||||
- 来源、来源查询条件和标签;
|
||||
- 活跃或停用状态;
|
||||
- 所属项目和所有者;
|
||||
- 首次发现、最近发现、创建和更新时间;
|
||||
- 最近扫描时间及关联的对话、任务队列和子任务;
|
||||
- 相关漏洞数量和当前风险等级。
|
||||
|
||||
至少需要提供 `host`、`ip` 或 `domain` 中的一项。
|
||||
|
||||
## 建立资产基线
|
||||
|
||||
### 手工新增
|
||||
|
||||
进入 **资产管理 → 资产库**,点击 **新增资产**。常见目标格式包括:
|
||||
|
||||
```text
|
||||
https://example.com:8443
|
||||
example.com
|
||||
192.0.2.10:443
|
||||
[2001:db8::1]:443
|
||||
```
|
||||
|
||||
系统会尽量识别 URL、域名、IP、端口和协议。保存前可继续补充项目、标签、标题、服务指纹、地理位置、负责人、部门、业务系统、环境、重要性和状态。
|
||||
|
||||
### 从表格批量导入
|
||||
|
||||
进入 **资产管理 → 资产库**,点击 **批量导入**:
|
||||
|
||||
1. 下载 XLSX(推荐)或 CSV 模板。
|
||||
2. 在 `Assets` 工作表中填写资产;不要修改表头。
|
||||
3. 上传文件,或将文件拖入上传区域。
|
||||
4. 查看逐行校验结果。文件内重复、格式错误和无权访问的项目会标为错误。
|
||||
5. 点击 **导入有效数据**。错误行不会提交,预览超过 100 行时只展示前 100 行,但提交会处理全部有效行。
|
||||
6. 根据提示核对新增、更新和跳过数量。
|
||||
|
||||
模板字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `target` | 条件必填 | URL、域名、IPv4、IPv6 或带端口目标;当 `host`、`ip`、`domain` 均为空时必填 |
|
||||
| `project` | 否 | 已有项目的精确名称或项目 ID;留空表示不绑定 |
|
||||
| `tags` | 否 | 逗号、中文逗号、分号或竖线分隔;最多 30 个,单个最多 64 个字符 |
|
||||
| `host` | 条件必填 | 完整 URL 或 Host;可与 `target` 配合补充 |
|
||||
| `ip` | 条件必填 | 合法 IPv4 或 IPv6 |
|
||||
| `domain` | 条件必填 | 合法域名,国际化域名会规范化 |
|
||||
| `port` | 否 | `0-65535`;留空时可从 `target` 推断 |
|
||||
| `protocol` | 否 | 如 `http`、`https`、`ssh`;留空时可从 URL 或常用端口推断 |
|
||||
| `title` | 否 | 页面标题,最多 500 个字符 |
|
||||
| `server` | 否 | 服务或产品指纹 |
|
||||
| `country` / `province` / `city` | 否 | 地理归属信息 |
|
||||
| `responsible_person` | 否 | 负责人,最多 255 个字符 |
|
||||
| `department` | 否 | 责任部门,最多 255 个字符 |
|
||||
| `business_system` | 否 | 所属业务系统,最多 255 个字符 |
|
||||
| `environment` | 否 | `production`、`staging`、`testing`、`development` 或 `other` |
|
||||
| `criticality` | 否 | `critical`、`high`、`medium` 或 `low` |
|
||||
| `status` | 否 | `active` 或 `inactive`,也接受“活跃”“停用”;默认 `active` |
|
||||
|
||||
表头同时识别模板中的英文字段和常用中文别名;环境和重要性也接受模板列中对应的中文值。为避免不同系统导出的列名产生歧义,自动化流程仍建议使用模板中的英文表头和枚举值。
|
||||
|
||||
限制与处理规则:
|
||||
|
||||
- 单个 XLSX/CSV 文件最多 100000 行、100 MB。
|
||||
- `/api/assets/import` 单次请求最多 100000 条资产。
|
||||
- 文件内相同“目标 + 端口 + 协议”的后续行会标记为重复,不会提交。
|
||||
- Web 端负责文件解析、预览和即时格式提示;服务端仍会执行权限检查、字段校验、规范化、去重和事务写入。
|
||||
- 已存在的资产会合并本次提供的非空字段并更新最近发现时间;不会创建重复记录。
|
||||
- 批量导入需要 `asset:write` 权限。填写项目时,当前用户还必须有权访问该项目。
|
||||
- 不建议取消服务端条数上限。更大规模的数据应拆分文件并在低峰期导入。
|
||||
|
||||
### 从网络空间测绘搜索导入
|
||||
|
||||
1. 在配置文件或 **系统设置 → 资产管理** 中填写对应 API Key,也可使用环境变量:`FOFA_API_KEY`、`ZOOMEYE_API_KEY`、`QUAKE_API_KEY`、`SHODAN_API_KEY`。
|
||||
2. 进入 **资产管理 → 信息收集**。
|
||||
3. 选择数据源:FOFA、ZoomEye、Quake 或 Shodan。
|
||||
4. 输入或生成该数据源的查询语句,并确认查询范围。
|
||||
5. 执行查询,选择确认归属的结果后点击 **入库所选**。
|
||||
6. 根据返回的新增、更新和跳过数量检查导入结果。
|
||||
|
||||
外部搜索结果不等同于自有资产。建议先通过组织域名、证书、网段或产品指纹缩小范围,确认授权后再入库。
|
||||
|
||||
## 规范化与去重
|
||||
|
||||
不同入口可能使用不同形式描述同一目标。系统会执行以下处理:
|
||||
|
||||
- 去除字段首尾空白;
|
||||
- IP、域名和协议转为小写;
|
||||
- 从 URL 型 Host 提取 hostname、协议和端口;
|
||||
- 为未显式指定端口的 HTTP/HTTPS 补充默认端口;
|
||||
- 将国际化域名转换为 ASCII/Punycode;
|
||||
- 清理并去重标签;
|
||||
- 为缺失的状态和来源补充默认值。
|
||||
|
||||
资产以“目标 + 端口 + 协议”作为服务级去重依据。目标优先采用域名,其次为 IP,最后为 Host。因此,同一主机的 `80/http` 和 `443/https` 会被视为不同资产。
|
||||
|
||||
重复资产再次入库时,系统会更新本次提供的非空字段和最近发现时间,保留未提供的原有信息,不会重置首次发现时间。
|
||||
|
||||
## 查询、筛选与视图
|
||||
|
||||
Web 端的关键字搜索覆盖 Host、IP、域名、标题、服务、标签、负责人、部门和业务系统。常用筛选包括状态和项目;展开高级筛选后还可以组合:
|
||||
|
||||
- 风险等级和最少漏洞数;
|
||||
- 协议、端口、来源和精确标签;
|
||||
- 已扫描、从未扫描,以及 30/60/90 天未扫描;
|
||||
- 国家/地区、省份/州、城市、负责人、部门和业务系统;
|
||||
- 环境、重要性、首次发现和最近发现日期范围;
|
||||
- 最近发现、最近扫描、风险、漏洞数、首次发现、目标名称或端口排序。
|
||||
|
||||
按最近扫描时间升序排列时,从未扫描的资产优先显示,便于识别覆盖盲区。
|
||||
|
||||
常用筛选组合可以保存为筛选视图。筛选视图保存在当前浏览器的 `localStorage` 中,不会同步到服务端、其他浏览器或其他用户。
|
||||
|
||||
HTTP API 和 `query_assets` 还支持 `max_vulnerabilities`、最近扫描时间范围,以及创建/更新时间等白名单排序字段。HTTP 列表每页最多 100 条,Agent 查询每页最多 50 条。
|
||||
|
||||
## 批量维护与导出
|
||||
|
||||
选择资产后,可以对当前页执行操作,也可以选择当前筛选条件命中的全部结果。跨页选择由服务端重新解析当前筛选条件,最多返回 10000 条;超过上限时需要进一步缩小范围。
|
||||
|
||||
可用操作:
|
||||
|
||||
- **绑定项目**:为所选资产统一替换项目归属;
|
||||
- **批量编辑**:修改状态、负责人、部门、业务系统、环境和重要性,并增删标签;
|
||||
- **创建扫描任务 / 发送到对话**:复用同一提示词模板处理所选资产;
|
||||
- **导出 CSV / XLSX**:在浏览器中导出当前已选择的资产,包含责任属性、风险、漏洞数量和时间字段;
|
||||
- **合并重复资产**:保留第一个所选资产为主资产,以其他记录的非空字段补齐主资产并合并标签;
|
||||
- **批量删除**:永久删除所选资产。
|
||||
|
||||
批量编辑、项目绑定和批量删除采用全有或全无的事务:只要其中一个资产不存在或超出调用者权限,整批操作就会失败,不会留下部分更新。
|
||||
|
||||
合并仅适用于具有共同域名、IP 或 Host 的记录,每次可选择 2-100 条。主资产已有值优先,标签取并集且仍受 30 个标签限制,其余记录会被删除。该操作同时需要 `asset:write` 和 `asset:delete`;合并前应确认主资产选择以及需要保留的扫描历史。
|
||||
|
||||
## 扫描与风险回写
|
||||
|
||||
### 单资产扫描
|
||||
|
||||
在资产列表中点击 **扫描**。系统会:
|
||||
|
||||
1. 创建新对话并注入资产目标和资产 ID;
|
||||
2. 将对话与资产关联;
|
||||
3. 引导 Agent 检查暴露服务和授权范围内的安全风险;
|
||||
4. 使用 `record_vulnerability` 保存确认的问题;
|
||||
5. 使用 `complete_asset_scan` 回写扫描状态。
|
||||
|
||||
扫描提示词支持 `{{asset_id}}`、`{{target}}`、`{{host}}`、`{{ip}}`、`{{domain}}` 和 `{{port}}` 变量。发起前应按授权范围调整测试强度、端口和验证方式。
|
||||
|
||||
### 批量扫描
|
||||
|
||||
在资产库中选择多个目标后,点击 **创建扫描任务**。系统会为每个资产创建独立子任务,并建立资产、队列和子任务之间的关联。
|
||||
|
||||
当前默认使用手动调度、单并发和 Eino 单 Agent 模式,以减少对目标和本机资源的瞬时压力。执行前仍需确认测试窗口、请求频率、允许的验证方式和审批要求。
|
||||
|
||||
### 风险计算
|
||||
|
||||
资产风险根据最近一次关联扫描中的未关闭漏洞动态计算:
|
||||
|
||||
- `critical`、`high`、`medium`、`low`、`info`:存在对应等级的未关闭漏洞;
|
||||
- `normal`:已扫描且没有开放风险;
|
||||
- `unassessed`:尚未完成扫描。
|
||||
|
||||
已修复、误报或忽略的漏洞仍保留在历史数量中,但不会继续提高当前风险等级。
|
||||
|
||||
## Agent 工具
|
||||
|
||||
系统向 Agent 提供六个内置资产工具:
|
||||
|
||||
- `create_asset`:新增资产或去重更新;
|
||||
- `get_asset`:按 ID 获取完整详情;
|
||||
- `query_assets`:分页、筛选和排序;
|
||||
- `update_asset`:局部更新;
|
||||
- `delete_asset`:删除资产;
|
||||
- `complete_asset_scan`:扫描完成后回写状态。
|
||||
|
||||
`query_assets` 默认每页返回 20 条、最多 50 条摘要。需要完整信息时使用 `get_asset`,避免大量资产数据占用模型上下文。
|
||||
|
||||
`create_asset` 和 `update_asset` 均支持责任与业务属性;`query_assets` 也可以按这些字段筛选。Agent 写入仍经过与 HTTP API 相同的规范化、字段校验、去重和权限检查。
|
||||
|
||||
## 权限控制
|
||||
|
||||
资产权限分为:
|
||||
|
||||
- `asset:read`:查看资产和统计数据;
|
||||
- `asset:write`:创建、导入、修改和回写扫描;
|
||||
- `asset:delete`:删除资产。
|
||||
|
||||
服务端会同时检查资产所有者、显式资源授权、所属项目及权限 Scope(`all`、`assigned`、`own`)。当对话绑定项目后,Agent 的资产查询会被限制在该项目内,工具参数不能扩大访问范围。
|
||||
|
||||
资产批量接口限制:
|
||||
|
||||
- `POST /api/assets/import`:单次最多 100000 条;
|
||||
- `GET /api/assets/selection`:最多解析 10000 条匹配资产;
|
||||
- `POST /api/assets/scan-links`:单次最多 10000 条;
|
||||
- `PUT /api/assets/bulk`:单次最多 10000 个资产 ID;
|
||||
- `PUT /api/assets/project-binding`:单次最多 10000 个资产 ID;
|
||||
- `POST /api/assets/batch-delete`:单次最多 10000 个资产 ID;
|
||||
- `POST /api/assets/merge`:单次合并 2-100 个资产 ID。
|
||||
|
||||
## 推荐使用流程
|
||||
|
||||
1. 划定一组明确授权的域名、IP 或网段。
|
||||
2. 手工加入少量核心目标,验证识别和去重结果。
|
||||
3. 使用标签区分生产、测试、核心业务和外网范围。
|
||||
4. 配置一个或多个网络空间测绘搜索引擎,从窄查询开始并确认资产归属。
|
||||
5. 对单个低风险目标测试扫描和漏洞回写流程。
|
||||
6. 使用“从未扫描”和“超过 30 天未扫描”筛选覆盖盲区。
|
||||
7. 确认流程稳定后,再逐步创建小规模批量任务。
|
||||
|
||||
经过确认的小规模资产基线通常比来源混乱的全量清单更有价值。
|
||||
+56
-20
@@ -5,12 +5,15 @@ CyberStrikeAI 的主配置文件是 `config.yaml`。大多数配置也可以在
|
||||
## 基础配置
|
||||
|
||||
```yaml
|
||||
version: "v1.6.51"
|
||||
version: "vX.Y.Z" # 占位符;请使用 config.example.yaml 中当前发布版本的值
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
tls_enabled: true
|
||||
tls_auto_self_sign: true
|
||||
# 可选:其他可信 Web 集成;Chromium 浏览器插件无需配置
|
||||
# cors_allowed_origins:
|
||||
# - https://trusted-integration.example
|
||||
auth:
|
||||
session_duration_hours: 12
|
||||
log:
|
||||
@@ -21,30 +24,62 @@ log:
|
||||
- `version`:前端展示版本。
|
||||
- `server.host/port`:Web 服务监听地址和端口。
|
||||
- `server.tls_*`:HTTPS 配置。生产环境建议使用 `tls_cert_path` 和 `tls_key_path`。
|
||||
- Chromium 浏览器插件的合法 `chrome-extension://<32位插件ID>` Origin 会被自动识别,无需配置。插件仍需按域授权,并使用密码登录与 Bearer Token 调用 API。
|
||||
- `server.cors_allowed_origins`:仅供其他可信 Web 集成使用的额外 Origin 精确白名单;不支持 `*`,修改后需重启服务。
|
||||
- `auth.session_duration_hours`:登录会话有效期(小时)。登录密码由 RBAC 用户管理,首次启动时在控制台输出 `admin` 初始密码。
|
||||
- `auth.session_duration_hours`:登录会话有效期。
|
||||
- `log.output`:可以是 `stdout`、`stderr` 或文件路径。
|
||||
|
||||
## 模型配置
|
||||
## AI 通道与模型配置
|
||||
|
||||
```yaml
|
||||
openai:
|
||||
provider: openai
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
max_total_tokens: 120000
|
||||
reasoning:
|
||||
mode: on
|
||||
effort: high
|
||||
allow_client_reasoning: true
|
||||
profile: openai_compat
|
||||
ai:
|
||||
default_channel: openai-main
|
||||
channels:
|
||||
openai-main:
|
||||
name: OpenAI Main
|
||||
provider: openai_compatible
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: sk-...
|
||||
model: gpt-4.1
|
||||
max_total_tokens: 120000
|
||||
max_completion_tokens: 16384
|
||||
reasoning:
|
||||
mode: on
|
||||
effort: high
|
||||
allow_client_reasoning: true
|
||||
profile: openai_compat
|
||||
claude-main:
|
||||
name: Claude Main
|
||||
provider: claude
|
||||
base_url: https://api.anthropic.com/v1
|
||||
api_key: sk-ant-...
|
||||
model: claude-sonnet-4-5
|
||||
```
|
||||
|
||||
- `provider`:`openai` 表示 OpenAI 兼容接口;`claude` 会桥接到 Anthropic Claude Messages API。
|
||||
- `base_url/api_key/model`:主模型配置。
|
||||
- `max_total_tokens`:上下文压缩、攻击链构建、多代理摘要等共用的总预算。
|
||||
- `reasoning`:控制推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。
|
||||
`ai` 是推荐的模型配置入口。系统设置页对应路径是 **系统设置 → 基本设置 → AI 通道配置**,保存后写入 `ai.default_channel` 和 `ai.channels`。旧版 `openai` 字段仍保留为兼容运行时字段;加载配置时会确保至少有一个默认通道,并把 `ai.default_channel` 解析后的配置同步到运行时 `openai`。
|
||||
|
||||
通道字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `ai.default_channel` | 默认通道 ID。新对话、机器人、批量任务和未显式选择通道的请求使用它。 |
|
||||
| `ai.channels.<id>` | 通道配置。ID 会归一化为小写、数字和短横线,例如 `Qwen_Max` 会变成 `qwen-max`。 |
|
||||
| `name` | Web UI 展示名。留空时使用通道 ID。 |
|
||||
| `provider` | `openai_compatible` 或 `claude`。`openai_compatible` 会在运行时映射为 `openai`;`claude` 会桥接到 Anthropic Messages API。 |
|
||||
| `base_url/api_key/model` | 必填。Base URL 通常需要包含版本路径,如 OpenAI/兼容网关的 `/v1`。 |
|
||||
| `max_total_tokens` | 上下文压缩、攻击链构建、多代理摘要等共用的总预算。 |
|
||||
| `max_completion_tokens` | 单次模型输出上限;未填时使用默认值。 |
|
||||
| `reasoning` | 该通道的默认推理扩展字段。不同网关支持差异较大,异常时先尝试 `mode: off`。 |
|
||||
|
||||
对话页的“AI 通道”下拉框会读取已保存通道。请求体中的 `aiChannelId` 非空时仅对本次/本会话运行配置生效,不会把 API Key 发送给模型;为空时跟随 `ai.default_channel`。
|
||||
|
||||
常用操作:
|
||||
|
||||
- 新增:点击左侧 `+`,填写必填字段后保存。
|
||||
- 设默认:选中通道后点击“设为默认”,保存并应用后新请求生效。
|
||||
- 复制:以当前表单内容创建副本,适合为同一服务商配置不同模型。
|
||||
- 删除:默认通道不能作为批量删除目标;删除后需保留至少一个通道。
|
||||
- 探活:单通道“测试连接”或左侧“批量探活”会调用模型测试接口,适合验证 Key、Base URL 和模型名。
|
||||
|
||||
## Agent
|
||||
|
||||
@@ -209,7 +244,8 @@ project:
|
||||
|
||||
| 配置段 | 应用后通常立即生效 | 需要额外动作 |
|
||||
| --- | --- | --- |
|
||||
| `openai` | 新请求使用新模型配置 | 旧的流式请求不会被强制切换 |
|
||||
| `ai.default_channel` / `ai.channels` | 新请求使用解析后的默认或选定通道 | 旧的流式请求不会被强制切换;前端通道列表需要重新读取配置 |
|
||||
| `openai` | 兼容字段;通常由默认 AI 通道同步 | 新配置优先维护 `ai.channels` |
|
||||
| `agent.max_iterations` | 新 Agent 任务生效 | 已运行任务按启动时状态继续 |
|
||||
| `security.tool_description_mode` | 工具重新暴露时生效 | 模型已有上下文不会回滚 |
|
||||
| `hitl.tool_whitelist` | 新工具调用审批判断生效 | 已挂起审批不自动重判 |
|
||||
@@ -224,7 +260,7 @@ project:
|
||||
几个字段有“留空复用”的关系:
|
||||
|
||||
- `vision.api_key/base_url/provider` 留空时复用 `openai`。
|
||||
- `hitl.audit_model` 留空时复用 `openai`。
|
||||
- `hitl.audit_model` 留空时复用默认 AI 通道解析后的 `openai`。
|
||||
- `knowledge.embedding.base_url/api_key` 留空时复用主模型或 embedding 默认配置。
|
||||
- `knowledge.retrieval.rerank.base_url/api_key` 留空时复用 embedding/openai。
|
||||
- `database.knowledge_db_path` 留空时可以复用主会话数据库,但独立文件更利于备份。
|
||||
|
||||
@@ -103,6 +103,16 @@ docs/en-US/
|
||||
- `docs/zh-CN/README.md`
|
||||
- `docs/en-US/README.md`
|
||||
|
||||
提交文档变更前运行:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-docs.py
|
||||
```
|
||||
|
||||
该检查会验证本地链接、代码块闭合、中英文文件名对齐、语言导航覆盖率,以及根 README 中的 Go 版本是否与 `go.mod` 一致。版本化示例应尽量从 `go.mod`、`config.example.yaml` 等权威文件派生,避免手工同步。
|
||||
|
||||
当文档、`go.mod` 或检查脚本变更时,GitHub Actions 中的 `Documentation` 工作流会自动运行同一条命令。
|
||||
|
||||
## Review 关注点
|
||||
|
||||
代码评审优先看:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- Python:部分 MCP 服务或工具脚本需要 Python 运行环境。
|
||||
- SQLite:默认使用文件型数据库,无需单独服务。
|
||||
- 安全工具:`tools/` 中的 YAML 只是工具定义,实际命令如 `nmap`、`sqlmap`、`nuclei` 仍需安装到系统 PATH。
|
||||
- 模型服务:需要 OpenAI 兼容 API,或配置 `openai.provider: claude` 走 Claude 桥接。
|
||||
- 模型服务:至少配置一个 `ai.channels` 通道;`provider: openai_compatible` 适用于 OpenAI 兼容 API,`provider: claude` 会走 Claude 桥接。
|
||||
|
||||
建议目录:
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ internal/database/ SQLite 数据访问
|
||||
internal/security/ 认证、限流、Shell 执行
|
||||
internal/mcp/ MCP Server、外部 MCP 管理
|
||||
internal/multiagent/ Eino 单代理、多代理、中间件
|
||||
internal/workflow/ 图编排运行时
|
||||
internal/workflow/ 工作流运行时
|
||||
internal/knowledge/ 知识库索引与检索
|
||||
internal/c2/ 内置 C2
|
||||
internal/project/ 项目事实黑板
|
||||
|
||||
@@ -23,12 +23,12 @@ hitl:
|
||||
provider: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
model: "" # 可填小模型;留空复用 openai.model
|
||||
model: "" # 可填小模型;留空复用默认 AI 通道的模型
|
||||
retention_days: 90
|
||||
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
|
||||
```
|
||||
|
||||
`audit_model` 的字段可以只填一部分。空字段会自动继承主 `openai` 配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
|
||||
`audit_model` 的字段可以只填一部分。空字段会自动继承默认 AI 通道解析后的模型配置,因此常见做法是只填 `model`,让审计 Agent 使用更便宜的小模型。
|
||||
|
||||
## 推荐审批策略
|
||||
|
||||
|
||||
+7
-2
@@ -94,13 +94,15 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角
|
||||
| 机器人 | `robot:read`、`robot:write` |
|
||||
| 文件 | `files:read`、`files:write`、`files:delete` |
|
||||
| 攻击链 | `attackchain:read`、`attackchain:write` |
|
||||
| FOFA | `fofa:execute` |
|
||||
| 网络空间测绘 / 信息收集 | `fofa:execute` |
|
||||
| OpenAPI | `openapi:read` |
|
||||
| 对话分组 | `group:read`、`group:write`、`group:delete` |
|
||||
| 执行监控 | `monitor:read`、`monitor:write`、`monitor:delete` |
|
||||
|
||||
特殊权限说明:
|
||||
|
||||
- `fofa:execute` 为兼容旧版本保留权限名,现在保护 **信息收集** 页中的 FOFA、ZoomEye、Quake、Shodan 查询。
|
||||
|
||||
- `agent:execute` 允许运行 Agent,但不自动允许本地文件系统、Shell 或任意配置命令。
|
||||
- `agent:local-execute` 是本地执行兜底权限,应仅授予可信操作员。
|
||||
- `mcp:execute` 用于访问认证后的 MCP HTTP 入口。
|
||||
@@ -372,6 +374,10 @@ curl -X POST http://localhost:8080/api/rbac/resource-assignments \
|
||||
|
||||
角色变更会撤销会话。让用户重新登录;机器人下一条消息会重新解析权限。
|
||||
|
||||
### 忘记了内置 `admin` 密码
|
||||
|
||||
优先使用其他具备 `rbac:write` 权限的管理员账号重置。若没有可用的管理员会话,请按[排错指南中的管理员密码恢复流程](troubleshooting.md#忘记-admin-密码)在服务器上紧急重置。
|
||||
|
||||
### `write` 权限存在但全局配置仍被拒绝
|
||||
|
||||
全局对象写操作要求对应权限的 Scope 为 `all`。创建一个 `all` Scope 的专用管理角色,而不是扩大无关权限。
|
||||
@@ -383,4 +389,3 @@ curl -X POST http://localhost:8080/api/rbac/resource-assignments \
|
||||
### 外部 MCP 提示需要 global scope
|
||||
|
||||
除 `mcp:external:execute` 外,该权限的 Scope 还必须为 `all`。外部 MCP 的数据边界不由本地资源授权自动保护。
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# 工具执行治理
|
||||
|
||||
[返回中文文档](README.md)
|
||||
|
||||
本文说明 CyberStrikeAI 对长时间工具、MCP 阻塞、大输出、取消和恢复上下文的治理策略。目标是让 Agent 保持标准工具语义,同时避免工具卡死、上下文爆炸、数据库膨胀或恢复时重新注入历史大输出。
|
||||
|
||||
## 设计目标
|
||||
|
||||
- **Agent 不被工具绑死**:工具调用可以很慢,但当前 runner 只等待有限时间。
|
||||
- **长任务可继续观察**:超时返回 `execution_id`,后续可用 `wait_tool_execution` 多轮等待。
|
||||
- **用户和 Agent 都能取消**:当前会话结束或用户停止任务时,会取消仍在运行的工具。
|
||||
- **数据库与 Agent 视图一致**:DB 保存的是 Agent 实际拿到的兜底后结果,不再保存另一份原始大输出。
|
||||
- **恢复不会撑爆上下文**:续跑使用 model-facing trace;历史异常大 tool trace 恢复时也会再次裁剪。
|
||||
- **外部 MCP 有隔离保护**:按 server 限并发、按全局限并发,并对连续失败的 server 熔断。
|
||||
|
||||
## 执行模型
|
||||
|
||||
普通工具调用仍然对 Eino/Agent 表现为一次标准 tool call,但底层执行分为两段:
|
||||
|
||||
```text
|
||||
Agent 调用工具
|
||||
-> ExecutionService 创建 execution
|
||||
-> worker 执行真实 MCP/工具调用
|
||||
-> Agent bounded wait
|
||||
-> 完成:返回工具结果
|
||||
-> 未完成:返回 execution_id,worker 继续后台运行
|
||||
```
|
||||
|
||||
这解决了 MCP server、`exec`、`sqlmap`、`nmap`、`nuclei` 等长任务阻塞当前 runner 的问题。
|
||||
|
||||
## 工具状态语义
|
||||
|
||||
| 状态 | 含义 |
|
||||
|---|---|
|
||||
| `queued` | execution 已创建,等待 worker 或并发槽位 |
|
||||
| `running` | worker 正在执行 |
|
||||
| `background_running` | 前端展示状态,表示本轮 Agent 已停止等待,但后台仍在跑 |
|
||||
| `completed` | 本次 tool call 本身已完成 |
|
||||
| `failed` | 工具真实失败 |
|
||||
| `cancelled` | 用户、Agent 或会话清理主动取消 |
|
||||
| `hard_timeout` | 超过硬超时,被系统终止 |
|
||||
| `orphaned` | 重启/异常后发现 DB 中仍是 running,但运行时已无对应 worker |
|
||||
|
||||
注意:`wait_tool_execution` 到达 `timeout_seconds` 时,如果目标 execution 仍在运行,**这次 wait 调用本身是完成的观察动作**,不是工具执行失败。返回体会说明目标仍为 `running`,前端不应显示为红色失败。
|
||||
|
||||
## 控制工具
|
||||
|
||||
| 工具 | 用途 |
|
||||
|---|---|
|
||||
| `get_tool_execution` | 读取 execution 当前状态 |
|
||||
| `wait_tool_execution` | 等待指定 execution 一段时间 |
|
||||
| `cancel_tool_execution` | 主动取消指定 execution |
|
||||
|
||||
`get_tool_execution` 与 `wait_tool_execution` 支持返回运行中输出预览:
|
||||
|
||||
- `include_partial_output`:是否返回 partial output,默认 `true`。
|
||||
- `partial_output_max_bytes`:本次返回的尾部预览上限,默认 `4096`,最大 `65536`。
|
||||
|
||||
partial output 是“已产生输出的有界预览”,不等同于最终 `result`。最终 `result` 仍只在工具结束时写入 canonical execution 记录;不支持流式输出的工具不会返回 partial 字段。
|
||||
|
||||
典型流程:
|
||||
|
||||
```text
|
||||
1. 调用 exec/sqlmap/nmap 等长任务
|
||||
2. 超过 tool_wait_timeout_seconds 后拿到 execution_id
|
||||
3. Agent 可继续推理、改用其他工具,或调用 wait_tool_execution
|
||||
4. 仍未完成时可继续等待,或调用 cancel_tool_execution
|
||||
```
|
||||
|
||||
`tool_wait_timeout_seconds` 适用于内部 MCP、外部 MCP,以及 Eino filesystem 的流式 `execute`。Eino 的 `ls/read_file/write_file/edit_file/glob/grep` 等非流式 filesystem 工具会写入 execution 监控记录,但不作为后台 worker 做软等待续跑。
|
||||
|
||||
## 取消和会话清理
|
||||
|
||||
- 用户点击“停止任务”时,会取消当前会话仍在运行的工具。
|
||||
- 会话正常结束后,会批量取消当前会话仍 `running` 的工具。
|
||||
- “中断并继续”类流程不会做会话级批量取消,以免误杀后续需要等待的 worker。
|
||||
- 取消只针对当前 conversation 绑定的 execution,不会误杀其他会话的工具。
|
||||
|
||||
## 外部 MCP 隔离
|
||||
|
||||
外部 MCP 可能因为远端 server 卡住、断连或返回异常而拖慢 Agent。系统提供三层保护:
|
||||
|
||||
| 能力 | 配置 | 说明 |
|
||||
|---|---|---|
|
||||
| 单 server 并发限制 | `external_mcp_max_concurrent_per_server` | 同一个外部 MCP server 同时运行的工具数 |
|
||||
| 全局并发限制 | `external_mcp_max_concurrent_total` | 所有外部 MCP 工具总并发 |
|
||||
| 熔断 | `external_mcp_circuit_failure_threshold` / `external_mcp_circuit_cooldown_seconds` | 单 server 连续失败后短期快速失败,避免反复打坏 server |
|
||||
|
||||
推荐默认:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
external_mcp_max_concurrent_per_server: 2
|
||||
external_mcp_max_concurrent_total: 16
|
||||
external_mcp_circuit_failure_threshold: 3
|
||||
external_mcp_circuit_cooldown_seconds: 60
|
||||
```
|
||||
|
||||
## 输出兜底
|
||||
|
||||
系统使用 `multi_agent.eino_middleware.reduction_max_length_for_trunc` 作为统一工具结果上限。当前示例配置为 50000 bytes。
|
||||
|
||||
```yaml
|
||||
multi_agent:
|
||||
eino_middleware:
|
||||
reduction_enable: true
|
||||
reduction_max_length_for_trunc: 50000
|
||||
```
|
||||
|
||||
兜底覆盖:
|
||||
|
||||
| 渠道 | 行为 |
|
||||
|---|---|
|
||||
| Agent 实际拿到的工具结果 | 使用兜底后的 canonical result |
|
||||
| DB/监控存储 | 保存同一份 canonical result |
|
||||
| `get_tool_execution` / `wait_tool_execution` | 读取同一份 canonical result |
|
||||
| Eino `execute` / filesystem 监控记录 | 完成记录前统一兜底 |
|
||||
| 非流式 `exec` stdout/stderr | 源头 bounded buffer |
|
||||
| 流式 `exec` stdout/stderr | 推送给前端的累计输出也受上限控制 |
|
||||
| PTY 执行路径 | 同样受上限控制 |
|
||||
| 前端详情弹窗 | 额外有 UI 展示截断保护 |
|
||||
|
||||
触发上限后,完整输出先写入本地 trunc 文件,Agent 侧只保留计入预算的 `<persisted-output>` 预览(含绝对路径)。因此阈值为 50000 时,上下文文本不会超过该上限。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
<persisted-output>
|
||||
Output too large (200000). Full output saved to: /path/to/tmp/reduction/conversations/<id>/trunc/<execution_id>
|
||||
Use read_file with offset/limit to read parts of the file.
|
||||
Preview (first …):
|
||||
…
|
||||
|
||||
Preview (last …):
|
||||
…
|
||||
|
||||
</persisted-output>
|
||||
```
|
||||
|
||||
当前策略是「全文落盘 + 上下文预览」:超过 `reduction_max_length_for_trunc` 时,完整输出写入本地文件(默认 `tmp/reduction/conversations/<会话ID>/trunc/<execution_id>`),Agent/DB/监控拿到的是带绝对路径的 `<persisted-output>` 预览;可用 `read_file` 按 offset/limit 回读全文。
|
||||
|
||||
## DB 与恢复上下文
|
||||
|
||||
新执行结果的写入路径如下:
|
||||
|
||||
```text
|
||||
工具完成
|
||||
-> NormalizeToolResultForStorage
|
||||
-> 写入内存 execution
|
||||
-> 写入 DB
|
||||
-> 返回给 Agent
|
||||
```
|
||||
|
||||
因此正常情况下,DB 中保存的就是 Agent 拿到的结果。
|
||||
|
||||
续跑恢复时,系统使用 `LastAgentTraceInput` 中的 model-facing trace,也就是实际送入 ChatModel 的消息快照,而不是原始事件流累计。恢复入口还会对历史 tool 内容再次应用上限,防止以下情况撑爆上下文:
|
||||
|
||||
- 升级前 DB 已经存过原始大输出。
|
||||
- 手工迁移或导入的数据绕过了当前写入路径。
|
||||
- 配置从更大阈值改成 50000。
|
||||
- 未来某条旁路写入漏掉 canonicalize。
|
||||
|
||||
## 关键配置建议
|
||||
|
||||
长任务场景推荐:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
max_iterations: 800
|
||||
tool_timeout_minutes: 60
|
||||
tool_wait_timeout_seconds: 30
|
||||
external_mcp_max_concurrent_per_server: 2
|
||||
external_mcp_max_concurrent_total: 16
|
||||
external_mcp_circuit_failure_threshold: 3
|
||||
external_mcp_circuit_cooldown_seconds: 60
|
||||
shell_no_output_timeout_seconds: 1200
|
||||
|
||||
multi_agent:
|
||||
eino_middleware:
|
||||
reduction_enable: true
|
||||
reduction_max_length_for_trunc: 50000
|
||||
```
|
||||
|
||||
参数说明:
|
||||
|
||||
| 参数 | 建议 | 说明 |
|
||||
|---|---:|---|
|
||||
| `max_iterations` | `300-1000` | 太大等于放弃循环保护 |
|
||||
| `tool_timeout_minutes` | `60` | 单次工具硬超时,适合 sqlmap 等长任务 |
|
||||
| `tool_wait_timeout_seconds` | `30-60` | Agent 本轮等待上限,到时返回 `execution_id` |
|
||||
| `shell_no_output_timeout_seconds` | `600-1200` | 连续无输出时终止,防止静默挂死 |
|
||||
| `reduction_max_length_for_trunc` | `50000` | 工具结果统一上限 |
|
||||
|
||||
不建议把 `tool_wait_timeout_seconds` 设置得很大。长任务应由 worker 后台跑,Agent 通过 `execution_id` 继续观察,而不是一轮等待数分钟。
|
||||
|
||||
## 测试建议
|
||||
|
||||
可以用以下对话测试长任务语义:
|
||||
|
||||
```text
|
||||
调用 exec 执行 sleep 120;如果超过 10 秒还没完成,不要一直等,告诉我 execution_id,然后调用 wait_tool_execution 等 5 秒;如果仍未完成,再调用 cancel_tool_execution,最后说明状态。
|
||||
```
|
||||
|
||||
可以用以下命令测试大输出兜底:
|
||||
|
||||
```text
|
||||
调用 exec 执行:python3 - <<'PY'
|
||||
print("A" * 200000)
|
||||
PY
|
||||
然后展示工具结果长度和是否包含截断提示。
|
||||
```
|
||||
|
||||
预期:
|
||||
|
||||
- 初始长任务会返回 `execution_id`,状态为 `running` 或前端展示 `background_running`。
|
||||
- `wait_tool_execution` 等待到上限但目标未完成时,本次 wait 调用不应显示为执行失败。
|
||||
- 大输出结果不会超过 `reduction_max_length_for_trunc`。
|
||||
- DB、监控详情、Agent 继续推理看到的是同一份兜底结果。
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 外部 MCP 的远端 server 内部如何采集输出不由 CyberStrikeAI 控制;CyberStrikeAI 会在结果进入本系统后统一兜底、限并发和熔断。
|
||||
- 超长工具输出会在截断前写入本地 `tmp/reduction/.../trunc/<id>`(或 `reduction_root_dir`),bounded result 中包含可 `read_file` 的绝对路径。
|
||||
@@ -28,23 +28,44 @@ https://127.0.0.1:8080/
|
||||
- 浏览器 Cookie 是否异常,可尝试无痕窗口。
|
||||
- 审计日志中是否有登录失败节流。
|
||||
|
||||
生产环境忘记密码时,需在服务器上通过 RBAC 用户管理重置,或直接更新数据库中的用户密码哈希。
|
||||
### 忘记 `admin` 密码
|
||||
|
||||
如果仍有其他具备 `rbac:write` 权限的管理员账号,优先在 **平台权限 → 用户管理** 中重置密码。
|
||||
|
||||
如果没有可用的管理员会话,可在服务器上紧急重置内置 `admin` 账号。在项目根目录执行:
|
||||
|
||||
```bash
|
||||
./run.sh --reset-admin-password
|
||||
```
|
||||
|
||||
按提示输入并确认新密码。脚本会隐藏输入并写入 bcrypt 哈希。如果服务正在运行,完成后重新启动服务,使原有登录会话失效。
|
||||
|
||||
如果无法使用 `run.sh`,也可以手动执行以下命令,按提示输入并确认新密码:
|
||||
|
||||
```bash
|
||||
HASH=$(htpasswd -nBC 10 '' | cut -d: -f2 | tr -d '\n') && sqlite3 data/conversations.db "UPDATE rbac_users SET password_hash='$HASH', updated_at=CURRENT_TIMESTAMP WHERE id='admin' AND username='admin' AND is_builtin=1; SELECT changes();"
|
||||
```
|
||||
|
||||
输出 `1` 表示修改成功。该命令需要 `sqlite3` 和 `htpasswd`;如果 `config.yaml` 中的 `database.path` 不是默认值,请替换 `data/conversations.db`。密码输入不会显示,也不会写入 Shell 历史。
|
||||
|
||||
## 模型无响应
|
||||
|
||||
检查:
|
||||
|
||||
- `openai.base_url` 是否包含正确路径,如 `/v1`。
|
||||
- `openai.api_key` 是否有效。
|
||||
- `openai.model` 是否存在。
|
||||
- 服务商是否支持当前 `reasoning` 字段。
|
||||
- 当前对话选择的 AI 通道是否存在;为空时会使用 `ai.default_channel`。
|
||||
- `ai.channels.<id>.base_url` 是否包含正确路径,如 `/v1`。
|
||||
- `ai.channels.<id>.api_key` 是否有效。
|
||||
- `ai.channels.<id>.model` 是否存在。
|
||||
- 服务商是否支持当前通道的 `reasoning` 字段。
|
||||
|
||||
可在系统设置中使用模型测试。若网关报 400,先尝试:
|
||||
|
||||
```yaml
|
||||
openai:
|
||||
reasoning:
|
||||
mode: off
|
||||
ai:
|
||||
channels:
|
||||
your-channel:
|
||||
reasoning:
|
||||
mode: off
|
||||
```
|
||||
|
||||
## 流式输出中断
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# CyberStrikeAI 图编排使用说明
|
||||
# CyberStrikeAI 工作流使用说明
|
||||
|
||||
[English](../en-US/workflow-graph.md)
|
||||
|
||||
本文档说明 **图编排(Graph Orchestration)** 的完整使用方式:如何在画布上搭建流程、配置各类型节点、在节点之间传递数据,以及如何将流程绑定到角色并自动运行。
|
||||
本文档说明 **工作流(Workflow)** 的完整使用方式:如何在画布上搭建流程、配置各类型节点、在节点之间传递数据,以及如何将流程绑定到角色并自动运行。
|
||||
|
||||
---
|
||||
|
||||
## 一、在哪里使用图编排
|
||||
## 一、在哪里使用工作流
|
||||
|
||||
1. 登录 CyberStrikeAI Web 端
|
||||
2. 左侧导航进入 **图编排**
|
||||
2. 左侧导航进入 **工作流**
|
||||
3. 在左侧列表选择已有流程,或新建流程
|
||||
4. 在中央画布拖拽、连线、配置节点
|
||||
5. 填写流程 **ID**、**名称**、**描述** 后点击 **保存**
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
## 三、执行模型(先理解再配置)
|
||||
|
||||
图编排按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
||||
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
||||
|
||||
每次运行会维护一份内部状态,模板变量 `{{...}}` 从这里取值:
|
||||
|
||||
@@ -389,7 +389,7 @@ HITL 等待信息会记录:
|
||||
### 8.1 在角色管理中绑定
|
||||
|
||||
1. 进入 **角色管理**,编辑或新建角色
|
||||
2. 选择 **工作流 / 图编排** 绑定的流程 ID
|
||||
2. 选择绑定的 **工作流** ID
|
||||
3. 策略设为 `auto`(默认:有 `workflow_id` 时自动执行)
|
||||
4. 保存角色
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.34.0
|
||||
go.uber.org/zap v1.26.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/text v0.26.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 88 KiB |
+88
-49
@@ -24,18 +24,17 @@ import (
|
||||
|
||||
// Agent AI代理
|
||||
type Agent struct {
|
||||
openAIClient *openai.Client
|
||||
config *config.OpenAIConfig
|
||||
agentConfig *config.AgentConfig
|
||||
mcpServer *mcp.Server
|
||||
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
||||
logger *zap.Logger
|
||||
maxIterations int
|
||||
mu sync.RWMutex // 添加互斥锁以支持并发更新
|
||||
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
|
||||
currentConversationID string // 当前对话ID(用于自动传递给工具)
|
||||
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录)
|
||||
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
|
||||
openAIClient *openai.Client
|
||||
config *config.OpenAIConfig
|
||||
agentConfig *config.AgentConfig
|
||||
mcpServer *mcp.Server
|
||||
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
||||
logger *zap.Logger
|
||||
maxIterations int
|
||||
mu sync.RWMutex // 添加互斥锁以支持并发更新
|
||||
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
|
||||
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录)
|
||||
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
|
||||
}
|
||||
|
||||
type agentConversationIDKey struct{}
|
||||
@@ -515,6 +514,14 @@ type ToolExecutionResult struct {
|
||||
IsError bool
|
||||
}
|
||||
|
||||
func buildToolFailureMessage(toolName, detail string, err error) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "工具调用失败\n\n")
|
||||
fmt.Fprintf(&b, "工具名称: %s\n", toolName)
|
||||
fmt.Fprintf(&b, "错误详情: %s", detail)
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// executeToolViaMCP 通过MCP执行工具
|
||||
// 即使工具执行失败,也返回结果而不是错误,让AI能够处理错误情况
|
||||
func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
||||
@@ -526,12 +533,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
// 如果是record_vulnerability工具,自动添加conversation_id
|
||||
if toolName == builtin.ToolRecordVulnerability {
|
||||
conversationID := agentConversationIDFromContext(ctx)
|
||||
if conversationID == "" {
|
||||
a.mu.RLock()
|
||||
conversationID = a.currentConversationID
|
||||
a.mu.RUnlock()
|
||||
}
|
||||
|
||||
if conversationID != "" {
|
||||
args["conversation_id"] = conversationID
|
||||
a.logger.Debug("自动添加conversation_id到record_vulnerability工具",
|
||||
@@ -580,32 +581,16 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
// 如果调用失败(如工具不存在、超时),返回友好的错误信息而不是抛出异常
|
||||
if err != nil {
|
||||
detail := err.Error()
|
||||
timeoutMinutes := 10
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
timeoutMinutes = a.agentConfig.ToolTimeoutMinutes
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
detail = "工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。"
|
||||
} else if errors.Is(err, context.DeadlineExceeded) {
|
||||
min := 10
|
||||
if a.agentConfig != nil && a.agentConfig.ToolTimeoutMinutes > 0 {
|
||||
min = a.agentConfig.ToolTimeoutMinutes
|
||||
}
|
||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", min)
|
||||
detail = fmt.Sprintf("工具执行超过 %d 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)", timeoutMinutes)
|
||||
}
|
||||
errorMsg := fmt.Sprintf(`工具调用失败
|
||||
|
||||
工具名称: %s
|
||||
错误类型: 系统错误
|
||||
错误详情: %s
|
||||
|
||||
可能的原因:
|
||||
- 工具 "%s" 不存在或未启用
|
||||
- 单次执行超时(agent.tool_timeout_minutes)
|
||||
- 系统配置问题
|
||||
- 网络或权限问题
|
||||
|
||||
建议:
|
||||
- 检查工具名称是否正确
|
||||
- 若需更长执行时间,可适当增大 agent.tool_timeout_minutes
|
||||
- 尝试使用其他替代工具
|
||||
- 如果这是必需的工具,请向用户说明情况`, toolName, detail, toolName)
|
||||
errorMsg := buildToolFailureMessage(toolName, detail, err)
|
||||
|
||||
return &ToolExecutionResult{
|
||||
Result: errorMsg,
|
||||
@@ -769,16 +754,8 @@ func (a *Agent) ToolsForRole(roleTools []string) []Tool {
|
||||
|
||||
// ExecuteMCPToolForConversation 在指定会话上下文中执行 MCP 工具(行为与主 Agent 循环中的工具调用一致,如自动注入 conversation_id)。
|
||||
func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationID, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
|
||||
a.mu.Lock()
|
||||
prev := a.currentConversationID
|
||||
a.currentConversationID = conversationID
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
a.currentConversationID = prev
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
ctx = withAgentConversationID(ctx, conversationID)
|
||||
ctx = mcp.WithMCPConversationID(ctx, conversationID)
|
||||
return a.executeToolViaMCP(ctx, toolName, args)
|
||||
}
|
||||
|
||||
@@ -798,6 +775,28 @@ func (a *Agent) FinishLocalToolExecution(ctx context.Context, executionID, toolN
|
||||
return a.mcpServer.FinishToolExecution(ctx, executionID, toolName, args, resultText, invokeErr)
|
||||
}
|
||||
|
||||
// AppendLocalToolExecutionPartialOutput records a bounded live-output preview for a running local tool.
|
||||
func (a *Agent) AppendLocalToolExecutionPartialOutput(executionID, chunk string) {
|
||||
if a == nil || a.mcpServer == nil {
|
||||
return
|
||||
}
|
||||
a.mcpServer.AppendToolExecutionPartialOutput(executionID, chunk)
|
||||
}
|
||||
|
||||
func (a *Agent) RegisterLocalToolExecutionCancel(executionID string, cancel context.CancelFunc) {
|
||||
if a == nil || a.mcpServer == nil {
|
||||
return
|
||||
}
|
||||
a.mcpServer.RegisterToolExecutionCancel(executionID, cancel)
|
||||
}
|
||||
|
||||
func (a *Agent) UnregisterLocalToolExecutionCancel(executionID string) {
|
||||
if a == nil || a.mcpServer == nil {
|
||||
return
|
||||
}
|
||||
a.mcpServer.UnregisterToolExecutionCancel(executionID)
|
||||
}
|
||||
|
||||
// RecordLocalToolExecution 将非 CallTool 路径完成的工具调用写入 MCP 监控库(与 CallTool 落库一致),返回 executionId。
|
||||
// 用于 Eino filesystem execute 等场景,使助手气泡「渗透测试详情」与常规 MCP 一致可点进监控。
|
||||
func (a *Agent) RecordLocalToolExecution(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
|
||||
@@ -837,6 +836,46 @@ func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CancelRunningMCPToolsForConversation cancels all currently running internal/external MCP executions
|
||||
// owned by the conversation. It is used when a session ends or the user stops a task.
|
||||
func (a *Agent) CancelRunningMCPToolsForConversation(conversationID, note string) int {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if a == nil || conversationID == "" {
|
||||
return 0
|
||||
}
|
||||
note = strings.TrimSpace(note)
|
||||
seen := make(map[string]struct{})
|
||||
cancelled := 0
|
||||
cancelIfConversationMatches := func(execID string, get func(string) (*mcp.ToolExecution, bool), cancel func(string, string) bool) {
|
||||
execID = strings.TrimSpace(execID)
|
||||
if execID == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[execID]; ok {
|
||||
return
|
||||
}
|
||||
seen[execID] = struct{}{}
|
||||
exec, ok := get(execID)
|
||||
if !ok || exec == nil || strings.TrimSpace(exec.ConversationID) != conversationID {
|
||||
return
|
||||
}
|
||||
if cancel(execID, note) {
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
if a.mcpServer != nil {
|
||||
for execID := range a.mcpServer.ActiveRunningExecutionIDs() {
|
||||
cancelIfConversationMatches(execID, a.mcpServer.GetExecution, a.mcpServer.CancelToolExecutionWithNote)
|
||||
}
|
||||
}
|
||||
if a.externalMCPMgr != nil {
|
||||
for execID := range a.externalMCPMgr.ActiveRunningExecutionIDs() {
|
||||
cancelIfConversationMatches(execID, a.externalMCPMgr.GetExecution, a.externalMCPMgr.CancelToolExecutionWithNote)
|
||||
}
|
||||
}
|
||||
return cancelled
|
||||
}
|
||||
|
||||
// extractQuotedToolName 尝试从错误信息中提取被引用的工具名称
|
||||
func extractQuotedToolName(errMsg string) string {
|
||||
start := strings.Index(errMsg, "\"")
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -65,3 +71,215 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
||||
t.Errorf("迭代次数不匹配。期望: 15, 实际: %d", agent.maxIterations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageAuthorizationDenied(t *testing.T) {
|
||||
msg := buildToolFailureMessage(
|
||||
"list_project_facts",
|
||||
"tool authorization denied: no access to project",
|
||||
errors.New("tool authorization denied: no access to project"),
|
||||
)
|
||||
for _, want := range []string{
|
||||
"工具名称: list_project_facts",
|
||||
"错误详情: tool authorization denied: no access to project",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
for _, notWant := range []string{
|
||||
"可能的原因",
|
||||
"建议",
|
||||
"错误类型",
|
||||
"工具 \"list_project_facts\" 不存在或未启用",
|
||||
"单次执行超时",
|
||||
} {
|
||||
if strings.Contains(msg, notWant) {
|
||||
t.Fatalf("message should not include generic hint %q:\n%s", notWant, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageCanceled(t *testing.T) {
|
||||
msg := buildToolFailureMessage(
|
||||
"long_running_tool",
|
||||
"工具调用已被手动终止(MCP 监控页)。智能体将携带此结果继续后续步骤,整条任务不会因此被停止。",
|
||||
context.Canceled,
|
||||
)
|
||||
|
||||
for _, want := range []string{
|
||||
"工具名称: long_running_tool",
|
||||
"错误详情: 工具调用已被手动终止",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageDeadlineExceeded(t *testing.T) {
|
||||
msg := buildToolFailureMessage(
|
||||
"nmap",
|
||||
"工具执行超过 15 分钟被自动终止(可在 config.yaml 的 agent.tool_timeout_minutes 中调整)",
|
||||
context.DeadlineExceeded,
|
||||
)
|
||||
|
||||
for _, want := range []string{
|
||||
"工具名称: nmap",
|
||||
"错误详情: 工具执行超过 15 分钟被自动终止",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolFailureMessageUnknownKeepsGenericFallback(t *testing.T) {
|
||||
msg := buildToolFailureMessage("custom_tool", "dial tcp: connection reset by peer", errors.New("dial tcp: connection reset by peer"))
|
||||
|
||||
for _, want := range []string{
|
||||
"工具名称: custom_tool",
|
||||
"错误详情: dial tcp: connection reset by peer",
|
||||
} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("message missing %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
|
||||
ag := setupTestAgent(t)
|
||||
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
|
||||
ag.mcpServer.RegisterTool(mcp.Tool{Name: "block", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
})
|
||||
|
||||
ctx1 := mcp.WithMCPConversationID(context.Background(), "conv-1")
|
||||
result1, execID1, err := ag.mcpServer.CallTool(ctx1, "block", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool conv-1: %v", err)
|
||||
}
|
||||
if result1 == nil || !result1.IsError || execID1 == "" {
|
||||
t.Fatalf("expected bounded wait for conv-1, result=%#v id=%q", result1, execID1)
|
||||
}
|
||||
|
||||
ctx2 := mcp.WithMCPConversationID(context.Background(), "conv-2")
|
||||
result2, execID2, err := ag.mcpServer.CallTool(ctx2, "block", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CallTool conv-2: %v", err)
|
||||
}
|
||||
if result2 == nil || !result2.IsError || execID2 == "" {
|
||||
t.Fatalf("expected bounded wait for conv-2, result=%#v id=%q", result2, execID2)
|
||||
}
|
||||
|
||||
if got := ag.CancelRunningMCPToolsForConversation("conv-1", "session ended"); got != 1 {
|
||||
t.Fatalf("cancelled count = %d, want 1", got)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
exec1, _ := ag.mcpServer.GetExecution(execID1)
|
||||
exec2, _ := ag.mcpServer.GetExecution(execID2)
|
||||
if exec1 != nil && exec1.Status == mcp.ToolExecutionStatusCancelled {
|
||||
if exec2 == nil || exec2.Status != mcp.ToolExecutionStatusRunning {
|
||||
t.Fatalf("conv-2 execution should remain running, got %#v", exec2)
|
||||
}
|
||||
if !strings.Contains(exec1.Error, "session ended") && (exec1.Result == nil || !strings.Contains(mcp.ToolResultPlainText(exec1.Result), "session ended")) {
|
||||
t.Fatalf("cancel note missing from conv-1 execution: %#v", exec1)
|
||||
}
|
||||
_ = ag.CancelRunningMCPToolsForConversation("conv-2", "")
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("conv-1 execution did not become cancelled")
|
||||
}
|
||||
|
||||
func TestExecuteMCPToolForConversationInjectsConversationID(t *testing.T) {
|
||||
ag := setupTestAgent(t)
|
||||
gotArgs := make(chan map[string]interface{}, 1)
|
||||
ag.mcpServer.RegisterTool(mcp.Tool{Name: builtin.ToolRecordVulnerability, InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
gotArgs <- args
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "ok"}}}, nil
|
||||
})
|
||||
|
||||
result, err := ag.ExecuteMCPToolForConversation(context.Background(), "conv-record", builtin.ToolRecordVulnerability, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteMCPToolForConversation: %v", err)
|
||||
}
|
||||
if result == nil || result.IsError {
|
||||
t.Fatalf("expected successful result, got %#v", result)
|
||||
}
|
||||
|
||||
select {
|
||||
case args := <-gotArgs:
|
||||
if got := args["conversation_id"]; got != "conv-record" {
|
||||
t.Fatalf("conversation_id = %#v, want conv-record", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("tool was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMCPToolForConversationBindsExecutionConversation(t *testing.T) {
|
||||
ag := setupTestAgent(t)
|
||||
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
|
||||
release := make(chan struct{})
|
||||
ag.mcpServer.RegisterTool(mcp.Tool{Name: "slow-bind", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
select {
|
||||
case <-release:
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "done"}}}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
})
|
||||
|
||||
result, err := ag.ExecuteMCPToolForConversation(context.Background(), "conv-bound", "slow-bind", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteMCPToolForConversation: %v", err)
|
||||
}
|
||||
if result == nil || !result.IsError || result.ExecutionID == "" {
|
||||
t.Fatalf("expected bounded wait result with execution id, result=%#v", result)
|
||||
}
|
||||
|
||||
exec, ok := ag.mcpServer.GetExecution(result.ExecutionID)
|
||||
if !ok || exec == nil {
|
||||
t.Fatalf("missing execution %q", result.ExecutionID)
|
||||
}
|
||||
if exec.ConversationID != "conv-bound" {
|
||||
t.Fatalf("execution conversation = %q, want conv-bound", exec.ConversationID)
|
||||
}
|
||||
close(release)
|
||||
}
|
||||
|
||||
func TestExecuteMCPToolForConversationConcurrentRecordIsolation(t *testing.T) {
|
||||
ag := setupTestAgent(t)
|
||||
seen := make(chan string, 2)
|
||||
ag.mcpServer.RegisterTool(mcp.Tool{Name: builtin.ToolRecordVulnerability, InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
if conv, _ := args["conversation_id"].(string); conv != "" {
|
||||
seen <- conv
|
||||
}
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "ok"}}}, nil
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, conv := range []string{"conv-a", "conv-b"} {
|
||||
conv := conv
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := ag.ExecuteMCPToolForConversation(context.Background(), conv, builtin.ToolRecordVulnerability, map[string]interface{}{}); err != nil {
|
||||
t.Errorf("ExecuteMCPToolForConversation %s: %v", conv, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(seen)
|
||||
|
||||
got := map[string]int{}
|
||||
for conv := range seen {
|
||||
got[conv]++
|
||||
}
|
||||
if got["conv-a"] != 1 || got["conv-b"] != 1 {
|
||||
t.Fatalf("conversation ids = %#v, want one call for conv-a and conv-b", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package agentfinalizer
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusCompleted = "completed"
|
||||
StatusInProgress = "in_progress"
|
||||
StatusBlocked = "blocked"
|
||||
StatusFailed = "failed"
|
||||
StatusCancelled = "cancelled"
|
||||
StatusAwaitingHITL = "awaiting_hitl"
|
||||
|
||||
ReasonVerified = "verified"
|
||||
ReasonPendingTools = "pending_tool_executions"
|
||||
ReasonEmptyResponse = "empty_response"
|
||||
ReasonAwaitingHITL = "awaiting_hitl"
|
||||
ReasonFailed = "failed"
|
||||
ReasonCancelled = "cancelled"
|
||||
ReasonMissingEvidence = "missing_execution_evidence"
|
||||
)
|
||||
|
||||
// Decision is the single contract that may promote an agent run to a final
|
||||
// user-facing answer. Natural-language assistant text is only a candidate until
|
||||
// this object says Finalizable.
|
||||
type Decision struct {
|
||||
Status string `json:"status"`
|
||||
Finalizable bool `json:"finalizable"`
|
||||
Finalized bool `json:"finalized"`
|
||||
CompletionReason string `json:"completionReason"`
|
||||
FinalText string `json:"finalText,omitempty"`
|
||||
EvidenceVerified bool `json:"evidenceVerified"`
|
||||
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||
PendingToolRuns []string `json:"pendingToolRuns,omitempty"`
|
||||
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||
AgentMode string `json:"agentMode,omitempty"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
AssistantMessageID string `json:"messageId,omitempty"`
|
||||
CandidateResponseLen int `json:"candidateResponseLen,omitempty"`
|
||||
}
|
||||
|
||||
type Input struct {
|
||||
Response string
|
||||
MCPExecutionIDs []string
|
||||
ConversationID string
|
||||
AssistantMessageID string
|
||||
AgentMode string
|
||||
Status string
|
||||
CompletionReason string
|
||||
AwaitingHITL bool
|
||||
RequireExecutionEvidence bool
|
||||
}
|
||||
|
||||
func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision {
|
||||
if result != nil {
|
||||
if strings.TrimSpace(in.Response) == "" {
|
||||
in.Response = result.Response
|
||||
}
|
||||
if len(in.MCPExecutionIDs) == 0 {
|
||||
in.MCPExecutionIDs = result.MCPExecutionIDs
|
||||
}
|
||||
if strings.TrimSpace(in.Status) == "" {
|
||||
in.Status = result.Status
|
||||
}
|
||||
if strings.TrimSpace(in.CompletionReason) == "" {
|
||||
in.CompletionReason = result.CompletionReason
|
||||
}
|
||||
}
|
||||
d := Decide(db, in)
|
||||
if result != nil {
|
||||
result.Finalized = d.Finalized
|
||||
result.Status = d.Status
|
||||
result.CompletionReason = d.CompletionReason
|
||||
result.EvidenceVerified = d.EvidenceVerified
|
||||
result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...)
|
||||
result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...)
|
||||
result.MissingChecks = append([]string(nil), d.MissingChecks...)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func Decide(db *database.DB, in Input) Decision {
|
||||
text := strings.TrimSpace(in.Response)
|
||||
status := strings.TrimSpace(in.Status)
|
||||
if status == "" {
|
||||
status = StatusCompleted
|
||||
}
|
||||
reason := strings.TrimSpace(in.CompletionReason)
|
||||
if reason == "" {
|
||||
reason = ReasonVerified
|
||||
}
|
||||
d := Decision{
|
||||
Status: status,
|
||||
CompletionReason: reason,
|
||||
FinalText: text,
|
||||
EvidenceVerified: true,
|
||||
EvidenceRefs: evidenceRefs(in.MCPExecutionIDs),
|
||||
AgentMode: strings.TrimSpace(in.AgentMode),
|
||||
ConversationID: strings.TrimSpace(in.ConversationID),
|
||||
AssistantMessageID: strings.TrimSpace(in.AssistantMessageID),
|
||||
CandidateResponseLen: len([]rune(text)),
|
||||
}
|
||||
|
||||
if in.AwaitingHITL {
|
||||
d.Status = StatusAwaitingHITL
|
||||
d.CompletionReason = ReasonAwaitingHITL
|
||||
d.EvidenceVerified = false
|
||||
d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval")
|
||||
return d
|
||||
}
|
||||
if isEmptyCandidate(text) {
|
||||
d.Status = StatusBlocked
|
||||
d.CompletionReason = ReasonEmptyResponse
|
||||
d.EvidenceVerified = false
|
||||
d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder")
|
||||
return d
|
||||
}
|
||||
switch status {
|
||||
case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL:
|
||||
d.Status = status
|
||||
d.EvidenceVerified = false
|
||||
if d.CompletionReason == ReasonVerified {
|
||||
d.CompletionReason = status
|
||||
}
|
||||
d.MissingChecks = append(d.MissingChecks, "agent run status is "+status)
|
||||
return d
|
||||
}
|
||||
|
||||
pending := pendingExecutions(db, in.MCPExecutionIDs)
|
||||
if len(pending) > 0 {
|
||||
d.Status = StatusInProgress
|
||||
d.CompletionReason = ReasonPendingTools
|
||||
d.EvidenceVerified = false
|
||||
d.PendingExecutionIDs = pending
|
||||
d.PendingToolRuns = append([]string(nil), pending...)
|
||||
d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running")
|
||||
return d
|
||||
}
|
||||
|
||||
if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) {
|
||||
d.Status = StatusBlocked
|
||||
d.CompletionReason = ReasonMissingEvidence
|
||||
d.EvidenceVerified = false
|
||||
d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded")
|
||||
return d
|
||||
}
|
||||
|
||||
d.Finalizable = true
|
||||
d.Finalized = true
|
||||
d.Status = StatusCompleted
|
||||
if d.CompletionReason == "" {
|
||||
d.CompletionReason = ReasonVerified
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"finalized": d.Finalized,
|
||||
"finalizable": d.Finalizable,
|
||||
"status": d.Status,
|
||||
"completionReason": d.CompletionReason,
|
||||
"evidenceVerified": d.EvidenceVerified,
|
||||
"evidenceRefs": d.EvidenceRefs,
|
||||
"pendingExecutionIds": d.PendingExecutionIDs,
|
||||
"pendingToolRuns": d.PendingToolRuns,
|
||||
"missingChecks": d.MissingChecks,
|
||||
}
|
||||
if d.ConversationID != "" {
|
||||
out["conversationId"] = d.ConversationID
|
||||
}
|
||||
if d.AssistantMessageID != "" {
|
||||
out["messageId"] = d.AssistantMessageID
|
||||
}
|
||||
if d.AgentMode != "" {
|
||||
out["agentMode"] = d.AgentMode
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isEmptyCandidate(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(s, "no assistant text was captured") ||
|
||||
strings.Contains(s, "未捕获到助手文本输出")
|
||||
}
|
||||
|
||||
func evidenceRefs(ids []string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, "mcp_execution:"+id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pendingExecutions(db *database.DB, ids []string) []string {
|
||||
if db == nil || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0)
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
exec, err := db.GetToolExecution(id)
|
||||
if err != nil || exec == nil {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(exec.Status) {
|
||||
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasCompletedEvidence(db *database.DB, ids []string) bool {
|
||||
if db == nil || len(ids) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
exec, err := db.GetToolExecution(id)
|
||||
if err != nil || exec == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package agentfinalizer
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func newDecisionTestDB(t *testing.T) *database.DB {
|
||||
t.Helper()
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) {
|
||||
t.Helper()
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: id,
|
||||
ToolName: "test::tool",
|
||||
Arguments: map[string]interface{}{"input": id},
|
||||
Status: status,
|
||||
StartTime: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveToolExecution(%s): %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideBlocksPendingToolExecutions(t *testing.T) {
|
||||
db := newDecisionTestDB(t)
|
||||
saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued)
|
||||
saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning)
|
||||
saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted)
|
||||
|
||||
d := Decide(db, Input{
|
||||
Response: "工具还没全部结束时,这只是一段候选输出。",
|
||||
MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"},
|
||||
})
|
||||
|
||||
if d.Finalizable || d.Finalized {
|
||||
t.Fatalf("pending tools should not be finalizable: %+v", d)
|
||||
}
|
||||
if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools {
|
||||
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools)
|
||||
}
|
||||
if got, want := len(d.PendingExecutionIDs), 2; got != want {
|
||||
t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) {
|
||||
hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true})
|
||||
if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL {
|
||||
t.Fatalf("HITL decision mismatch: %+v", hitl)
|
||||
}
|
||||
|
||||
empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"})
|
||||
if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse {
|
||||
t.Fatalf("empty candidate decision mismatch: %+v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) {
|
||||
d := Decide(nil, Input{
|
||||
Response: "任务已处理完成。",
|
||||
RequireExecutionEvidence: true,
|
||||
})
|
||||
if d.Finalizable || d.Finalized {
|
||||
t.Fatalf("missing required execution evidence should not finalize: %+v", d)
|
||||
}
|
||||
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||
}
|
||||
if d.EvidenceVerified {
|
||||
t.Fatalf("missing required execution evidence should be marked unverified: %+v", d)
|
||||
}
|
||||
if len(d.MissingChecks) == 0 {
|
||||
t.Fatalf("missing checks should explain the evidence gap: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) {
|
||||
db := newDecisionTestDB(t)
|
||||
saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed)
|
||||
saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled)
|
||||
|
||||
d := Decide(db, Input{
|
||||
Response: "任务已处理完成。",
|
||||
MCPExecutionIDs: []string{"run-failed", "run-cancelled"},
|
||||
RequireExecutionEvidence: true,
|
||||
})
|
||||
|
||||
if d.Finalizable || d.Finalized {
|
||||
t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d)
|
||||
}
|
||||
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideFinalizesCompletedEvidence(t *testing.T) {
|
||||
db := newDecisionTestDB(t)
|
||||
saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted)
|
||||
|
||||
d := Decide(db, Input{
|
||||
Response: "任务已处理完成,见工具执行记录。",
|
||||
MCPExecutionIDs: []string{"run-ok"},
|
||||
RequireExecutionEvidence: true,
|
||||
})
|
||||
|
||||
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||
t.Fatalf("completed execution should finalize: %+v", d)
|
||||
}
|
||||
if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 {
|
||||
t.Fatalf("evidence refs mismatch: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) {
|
||||
d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"})
|
||||
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
||||
}
|
||||
}
|
||||
+201
-16
@@ -66,6 +66,7 @@ type App struct {
|
||||
slackCancel context.CancelFunc // Slack Socket Mode 取消函数
|
||||
discordCancel context.CancelFunc // Discord Gateway 取消函数
|
||||
qqCancel context.CancelFunc // QQ WebSocket 取消函数
|
||||
alertCancel context.CancelFunc // 漏洞提醒持久化投递 worker
|
||||
c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil)
|
||||
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
|
||||
c2WatchdogCancel context.CancelFunc // 看门狗取消函数
|
||||
@@ -83,7 +84,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
router := gin.Default()
|
||||
|
||||
// CORS中间件
|
||||
router.Use(corsMiddleware())
|
||||
router.Use(corsMiddleware(cfg.Server.CORSAllowedOrigins))
|
||||
|
||||
// 初始化数据库
|
||||
dbPath := cfg.Database.Path
|
||||
@@ -119,6 +120,18 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
audit.RegisterConversationCreateHook(auditSvc)
|
||||
auditSvc.PurgeExpired()
|
||||
audit.StartRetentionLoop(auditSvc, log.Logger)
|
||||
if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil {
|
||||
log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err))
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := db.PurgeWorkflowPackageLifecycle(time.Now().UTC()); err != nil {
|
||||
log.Logger.Warn("清理过期工作流包记录失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
monitorRetention := monitor.NewService(db, cfg, log.Logger)
|
||||
monitorRetention.PurgeExpired()
|
||||
@@ -135,22 +148,38 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
mcpServer := mcp.NewServerWithStorage(log.Logger, db)
|
||||
mcpServer.SetToolAuthorizer(mcpToolAuthorizer(db))
|
||||
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
|
||||
mcpServer.ConfigureToolWaitTimeoutSeconds(cfg.Agent.ToolWaitTimeoutSeconds)
|
||||
mcpServer.ConfigureToolResultMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
mcpServer.ConfigureToolResultSpillRoot(cfg.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
|
||||
// 创建安全工具执行器
|
||||
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
|
||||
executor.SetShellNoOutputTimeoutSeconds(cfg.Agent.ShellNoOutputTimeoutSeconds)
|
||||
executor.SetToolOutputMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
executor.SetToolOutputSpillRoot(cfg.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
|
||||
// 注册工具
|
||||
executor.RegisterTools(mcpServer)
|
||||
|
||||
// 注册漏洞记录工具
|
||||
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
||||
registerAssetTools(mcpServer, db, log.Logger)
|
||||
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
||||
registerVisionTools(mcpServer, cfg, log.Logger)
|
||||
|
||||
// 创建外部MCP管理器(使用与内部MCP服务器相同的存储)
|
||||
externalMCPMgr := mcp.NewExternalMCPManagerWithStorage(log.Logger, db)
|
||||
externalMCPMgr.SetToolAuthorizer(externalMCPToolAuthorizer())
|
||||
externalMCPMgr.ConfigureToolWaitTimeoutSeconds(cfg.Agent.ToolWaitTimeoutSeconds)
|
||||
externalMCPMgr.ConfigureToolResultMaxBytes(cfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
externalMCPMgr.ConfigureToolResultSpillRoot(cfg.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
externalMCPMgr.ConfigureResilience(mcp.ExternalMCPResilienceConfig{
|
||||
MaxConcurrentPerServer: cfg.Agent.ExternalMCPMaxConcurrentPerServer,
|
||||
MaxConcurrentTotal: cfg.Agent.ExternalMCPMaxConcurrentTotal,
|
||||
CircuitFailureThreshold: cfg.Agent.ExternalMCPCircuitFailureThreshold,
|
||||
CircuitCooldown: time.Duration(cfg.Agent.ExternalMCPCircuitCooldownSeconds) * time.Second,
|
||||
})
|
||||
mcp.RegisterExecutionControlTools(mcpServer, externalMCPMgr)
|
||||
if cfg.ExternalMCP.Servers != nil {
|
||||
externalMCPMgr.LoadConfigs(&cfg.ExternalMCP)
|
||||
// 启动所有启用的外部MCP客户端
|
||||
@@ -367,6 +396,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
authHandler.SetAudit(auditSvc)
|
||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
||||
assetHandler := handler.NewAssetHandler(db, log.Logger)
|
||||
projectHandler := handler.NewProjectHandler(db, log.Logger)
|
||||
rbacHandler := handler.NewRBACHandler(db, log.Logger)
|
||||
rbacHandler.SetAudit(auditSvc)
|
||||
@@ -416,6 +446,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
||||
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
||||
robotHandler.SetAudit(auditSvc)
|
||||
db.SetVulnerabilityCreatedHook(robotHandler.NotifyNewVulnerability)
|
||||
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, conversationHandler, agentHandler)
|
||||
|
||||
// 创建 App 实例(部分字段稍后填充)
|
||||
@@ -444,10 +475,14 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
}
|
||||
// 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启
|
||||
app.startRobotConnections()
|
||||
alertCtx, alertCancel := context.WithCancel(context.Background())
|
||||
app.alertCancel = alertCancel
|
||||
go robotHandler.RunVulnerabilityAlertWorker(alertCtx)
|
||||
|
||||
// 设置漏洞工具注册器(内置工具,必须设置)
|
||||
vulnerabilityRegistrar := func() error {
|
||||
registerVulnerabilityTools(mcpServer, db, log.Logger)
|
||||
registerAssetTools(mcpServer, db, log.Logger)
|
||||
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
|
||||
registerVisionTools(mcpServer, cfg, log.Logger)
|
||||
return nil
|
||||
@@ -537,6 +572,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
attackChainHandler,
|
||||
app, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler,
|
||||
assetHandler,
|
||||
projectHandler,
|
||||
workflowHandler,
|
||||
webshellHandler,
|
||||
@@ -705,6 +741,10 @@ func (a *App) Shutdown() {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_ = einoobserve.ShutdownOtel(shutdownCtx)
|
||||
shutdownCancel()
|
||||
if a.alertCancel != nil {
|
||||
a.alertCancel()
|
||||
a.alertCancel = nil
|
||||
}
|
||||
|
||||
// 停止钉钉/飞书长连接
|
||||
a.robotMu.Lock()
|
||||
@@ -835,6 +875,7 @@ func setupRoutes(
|
||||
attackChainHandler *handler.AttackChainHandler,
|
||||
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler *handler.VulnerabilityHandler,
|
||||
assetHandler *handler.AssetHandler,
|
||||
projectHandler *handler.ProjectHandler,
|
||||
workflowHandler *handler.WorkflowHandler,
|
||||
webshellHandler *handler.WebShellHandler,
|
||||
@@ -955,6 +996,19 @@ func setupRoutes(
|
||||
// 信息收集 - 自然语言解析为 FOFA 语法(需人工确认后再查询)
|
||||
protected.POST("/fofa/parse", fofaHandler.ParseNaturalLanguage)
|
||||
|
||||
// 资产管理
|
||||
protected.GET("/assets", assetHandler.List)
|
||||
protected.GET("/assets/selection", assetHandler.Selection)
|
||||
protected.GET("/assets/stats", assetHandler.Stats)
|
||||
protected.POST("/assets/import", assetHandler.Import)
|
||||
protected.POST("/assets/scan-links", assetHandler.RecordScans)
|
||||
protected.PUT("/assets/bulk", assetHandler.BulkUpdate)
|
||||
protected.PUT("/assets/project-binding", assetHandler.UpdateProjectBinding)
|
||||
protected.POST("/assets/batch-delete", assetHandler.BatchDelete)
|
||||
protected.POST("/assets/merge", security.RequirePermission("asset:write"), assetHandler.Merge)
|
||||
protected.PUT("/assets/:id", assetHandler.Update)
|
||||
protected.DELETE("/assets/:id", assetHandler.Delete)
|
||||
|
||||
// 批量任务管理
|
||||
protected.POST("/batch-tasks", agentHandler.CreateBatchQueue)
|
||||
protected.GET("/batch-tasks", agentHandler.ListBatchQueues)
|
||||
@@ -1194,6 +1248,8 @@ func setupRoutes(
|
||||
protected.DELETE("/vulnerabilities/batch", vulnerabilityHandler.BatchDeleteVulnerabilities)
|
||||
protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions)
|
||||
protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats)
|
||||
protected.GET("/vulnerability-alerts/subscription", vulnerabilityHandler.GetMyAlertSubscription)
|
||||
protected.PUT("/vulnerability-alerts/subscription", vulnerabilityHandler.UpdateMyAlertSubscription)
|
||||
protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability)
|
||||
protected.POST("/vulnerabilities", vulnerabilityHandler.CreateVulnerability)
|
||||
protected.PUT("/vulnerabilities/:id", vulnerabilityHandler.UpdateVulnerability)
|
||||
@@ -1257,6 +1313,7 @@ func setupRoutes(
|
||||
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
||||
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
||||
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
||||
c2Routes.PUT("/sessions/:id/note", c2Handler.SetSessionNote)
|
||||
c2Routes.GET("/tasks", c2Handler.ListTasks)
|
||||
c2Routes.DELETE("/tasks", c2Handler.DeleteTasks)
|
||||
c2Routes.GET("/tasks/:id", c2Handler.GetTask)
|
||||
@@ -1281,7 +1338,9 @@ func setupRoutes(
|
||||
|
||||
// 对话附件(chat_uploads)管理
|
||||
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
||||
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
|
||||
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
||||
protected.GET("/chat-uploads/path", chatUploadsHandler.ResolvePath)
|
||||
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
||||
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
||||
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
|
||||
@@ -1296,13 +1355,18 @@ func setupRoutes(
|
||||
protected.PUT("/roles/:name", roleHandler.UpdateRole)
|
||||
protected.DELETE("/roles/:name", roleHandler.DeleteRole)
|
||||
|
||||
// 图编排 / 工作流定义(图结构固定,业务字段保存在 graph_json 中)
|
||||
// 工作流定义(图结构固定,业务字段保存在 graph_json 中)
|
||||
protected.GET("/workflows/runs/pending", workflowHandler.ListPendingRuns)
|
||||
protected.GET("/workflows/runs/:runId/replay", workflowHandler.ReplayRun)
|
||||
protected.GET("/workflows/runs/:runId", workflowHandler.GetRun)
|
||||
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
||||
protected.POST("/workflows/validate", workflowHandler.Validate)
|
||||
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
||||
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
||||
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
||||
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
||||
protected.POST("/workflow-package-imports", workflowHandler.ApplyPackageImport)
|
||||
protected.GET("/workflow-package-imports/:importId", workflowHandler.GetPackageImport)
|
||||
protected.GET("/workflows", workflowHandler.List)
|
||||
protected.GET("/workflows/:id", workflowHandler.Get)
|
||||
protected.POST("/workflows", workflowHandler.Create)
|
||||
@@ -1512,22 +1576,62 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
logger.Warn("跳过 WebShell 管理工具注册:db 为空")
|
||||
return
|
||||
}
|
||||
projectIDFromToolArgs := func(ctx context.Context, args map[string]interface{}) string {
|
||||
projectID, _ := args["project_id"].(string)
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
projectID = strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx))
|
||||
}
|
||||
return projectID
|
||||
}
|
||||
explicitProjectIDFromToolArgs := func(args map[string]interface{}) string {
|
||||
projectID, _ := args["project_id"].(string)
|
||||
return strings.TrimSpace(projectID)
|
||||
}
|
||||
authorizeWebshellToolProject := func(principal authctx.Principal, permission, projectID string) *mcp.ToolResult {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return nil
|
||||
}
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
return nil
|
||||
}
|
||||
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{{Type: "text", Text: "无权访问项目: " + projectID}},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// manage_webshell_list - 列出所有 webshell 连接
|
||||
listTool := mcp.Tool{
|
||||
Name: builtin.ToolManageWebshellList,
|
||||
Description: "列出所有已保存的 WebShell 连接,返回连接ID、URL、类型、备注等信息。",
|
||||
Description: "列出已保存的 WebShell 连接,返回连接ID、URL、类型、所属项目、备注等信息。默认按当前对话项目边界过滤:项目对话看本项目,未绑定项目的对话看未绑定连接;显式传 project_id 时按指定项目过滤。",
|
||||
ShortDescription: "列出所有 WebShell 连接",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"project_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "项目 ID;不填时在项目会话中默认使用当前项目。",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
connections := []database.WebShellConnection{}
|
||||
var err error
|
||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
||||
connections, err = db.ListWebshellConnectionsForAccess(principal.UserID, principal.ScopeFor("webshell:read"))
|
||||
projectID := explicitProjectIDFromToolArgs(args)
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, db)
|
||||
}
|
||||
if result := authorizeWebshellToolProject(principal, "webshell:read", projectID); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
connections, err = db.ListWebshellConnectionsForAccess(principal.UserID, principal.ScopeFor("webshell:read"), projectID)
|
||||
} else {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
||||
}
|
||||
@@ -1551,6 +1655,11 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
sb.WriteString(fmt.Sprintf(" 类型: %s\n", conn.Type))
|
||||
sb.WriteString(fmt.Sprintf(" 请求方式: %s\n", conn.Method))
|
||||
sb.WriteString(fmt.Sprintf(" 命令参数: %s\n", conn.CmdParam))
|
||||
if conn.ProjectID != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 项目ID: %s\n", conn.ProjectID))
|
||||
} else {
|
||||
sb.WriteString(" 项目: 未绑定\n")
|
||||
}
|
||||
if conn.Remark != "" {
|
||||
sb.WriteString(fmt.Sprintf(" 备注: %s\n", conn.Remark))
|
||||
}
|
||||
@@ -1625,6 +1734,14 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
cmdParam = "cmd"
|
||||
}
|
||||
remark, _ := args["remark"].(string)
|
||||
principal, ok := authctx.PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
||||
}
|
||||
projectID := projectIDFromToolArgs(ctx, args)
|
||||
if result := authorizeWebshellToolProject(principal, "webshell:write", projectID); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 生成连接ID
|
||||
connID := "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
|
||||
@@ -1636,6 +1753,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
Method: strings.ToLower(method),
|
||||
CmdParam: cmdParam,
|
||||
Remark: remark,
|
||||
ProjectID: projectID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -1645,15 +1763,17 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
if principal, ok := authctx.PrincipalFromContext(ctx); ok {
|
||||
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
|
||||
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
|
||||
_ = db.SetResourceOwner("webshell", conn.ID, principal.UserID)
|
||||
_ = db.AssignResourceToUser(principal.UserID, "webshell", conn.ID)
|
||||
projectLine := "项目: 未绑定"
|
||||
if conn.ProjectID != "" {
|
||||
projectLine = "项目ID: " + conn.ProjectID
|
||||
}
|
||||
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam),
|
||||
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n%s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam, projectLine),
|
||||
}},
|
||||
IsError: false,
|
||||
}, nil
|
||||
@@ -1698,6 +1818,10 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
"type": "string",
|
||||
"description": "新的备注",
|
||||
},
|
||||
"project_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "新的所属项目 ID;传空字符串可取消绑定。",
|
||||
},
|
||||
},
|
||||
"required": []string{"connection_id"},
|
||||
},
|
||||
@@ -1739,6 +1863,19 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
if remark, ok := args["remark"].(string); ok {
|
||||
existing.Remark = remark
|
||||
}
|
||||
if projectID, ok := args["project_id"].(string); ok {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID != "" {
|
||||
principal, ok := authctx.PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "缺少认证身份"}}, IsError: true}, nil
|
||||
}
|
||||
if result := authorizeWebshellToolProject(principal, "webshell:write", projectID); result != nil {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
existing.ProjectID = projectID
|
||||
}
|
||||
|
||||
if err := db.UpdateWebshellConnection(existing); err != nil {
|
||||
return &mcp.ToolResult{
|
||||
@@ -1750,7 +1887,7 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.Remark),
|
||||
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n项目ID: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.ProjectID, existing.Remark),
|
||||
}},
|
||||
IsError: false,
|
||||
}, nil
|
||||
@@ -2041,22 +2178,36 @@ func initializeKnowledge(
|
||||
return knowledgeHandler, nil
|
||||
}
|
||||
|
||||
// corsMiddleware CORS中间件
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
// corsMiddleware allows same-origin requests, valid Chromium extension
|
||||
// origins, and exact origins explicitly configured by the operator. CORS is
|
||||
// not an authentication boundary; API access still requires a valid session.
|
||||
func corsMiddleware(configuredOrigins []string) gin.HandlerFunc {
|
||||
allowedOrigins := make(map[string]struct{}, len(configuredOrigins))
|
||||
for _, origin := range configuredOrigins {
|
||||
if normalized, ok := normalizeCORSOrigin(origin); ok {
|
||||
allowedOrigins[normalized] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin != "" {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Host == "" || !strings.EqualFold(parsed.Host, c.Request.Host) {
|
||||
c.Writer.Header().Add("Vary", "Origin")
|
||||
normalized, valid := normalizeCORSOrigin(origin)
|
||||
_, explicitlyAllowed := allowedOrigins[normalized]
|
||||
parsed, _ := url.Parse(origin)
|
||||
sameHost := valid && strings.EqualFold(parsed.Host, c.Request.Host)
|
||||
browserExtension := valid && isChromiumExtensionOrigin(parsed)
|
||||
if !sameHost && !browserExtension && !explicitlyAllowed {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "cross-origin request denied"})
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Writer.Header().Add("Vary", "Origin")
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||
c.Writer.Header().Set("Access-Control-Max-Age", "600")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
@@ -2066,3 +2217,37 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isChromiumExtensionOrigin accepts only Chrome's canonical 32-character
|
||||
// extension IDs (letters a-p). It does not allow arbitrary custom schemes or
|
||||
// web origins, and the extension must separately obtain host permission.
|
||||
func isChromiumExtensionOrigin(origin *url.URL) bool {
|
||||
if origin == nil || !strings.EqualFold(origin.Scheme, "chrome-extension") || origin.Port() != "" {
|
||||
return false
|
||||
}
|
||||
id := strings.ToLower(origin.Hostname())
|
||||
if len(id) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, ch := range id {
|
||||
if ch < 'a' || ch > 'p' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeCORSOrigin validates and canonicalizes a serialized origin. CORS
|
||||
// origins never contain credentials, paths, query strings, or fragments.
|
||||
func normalizeCORSOrigin(raw string) (string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "*" || strings.EqualFold(raw, "null") {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil ||
|
||||
(parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", false
|
||||
}
|
||||
return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const agentAssetPageSizeMax = 50
|
||||
|
||||
func registerAssetTools(server *mcp.Server, db *database.DB, logger *zap.Logger) {
|
||||
if server == nil || db == nil {
|
||||
return
|
||||
}
|
||||
properties := assetMutationProperties()
|
||||
|
||||
server.RegisterTool(mcp.Tool{
|
||||
Name: builtin.ToolCreateAsset, ShortDescription: "新增或去重更新资产",
|
||||
Description: "向资产库新增资产。按目标+端口+协议去重;若资产已存在则更新非空字段。至少提供 host、ip、domain 之一。",
|
||||
// Bedrock rejects tool schemas with top-level oneOf/allOf/anyOf. The
|
||||
// host/ip/domain requirement is enforced by assetFromCreateArgs below.
|
||||
InputSchema: map[string]interface{}{"type": "object", "properties": properties},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
asset, err := assetFromCreateArgs(args)
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
access, owner, global := assetAccessFromToolContext(ctx, "asset:write")
|
||||
result, err := db.UpsertAssets([]*database.Asset{asset}, owner, global)
|
||||
if err != nil {
|
||||
logger.Error("Agent 保存资产失败", zap.Error(err))
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
if result.Skipped > 0 || asset.ID == "" {
|
||||
return textResult("资产未保存:同一资产已存在但当前用户无权更新,或目标字段为空", true), nil
|
||||
}
|
||||
saved, err := db.GetAsset(asset.ID, access)
|
||||
if err != nil {
|
||||
return textResult("资产已保存,但无法读取结果: "+err.Error(), true), nil
|
||||
}
|
||||
action := "created"
|
||||
if result.Updated > 0 {
|
||||
action = "updated"
|
||||
}
|
||||
return assetJSONResult(map[string]interface{}{"action": action, "asset": assetToolDetail(saved)})
|
||||
})
|
||||
|
||||
server.RegisterTool(mcp.Tool{
|
||||
Name: builtin.ToolGetAsset, ShortDescription: "按 ID 查看资产详情", Description: "按资产 ID 返回完整资产详情。查询列表时先用 query_assets,避免一次拉取过多详情。",
|
||||
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"id": map[string]interface{}{"type": "string", "description": "资产 ID"}}, "required": []string{"id"}},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
projectID, projectScoped, err := agentAssetProjectScope(db, ctx)
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
asset, err := db.GetAsset(strings.TrimSpace(strArg(args, "id")), assetAccessOnly(ctx, "asset:read"))
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return textResult("错误: 资产不存在或无权查看", true), nil
|
||||
}
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
if projectScoped && strings.TrimSpace(asset.ProjectID) != projectID {
|
||||
return textResult("错误: 资产不存在或不属于当前对话绑定的项目", true), nil
|
||||
}
|
||||
return assetJSONResult(assetToolDetail(asset))
|
||||
})
|
||||
|
||||
server.RegisterTool(mcp.Tool{
|
||||
Name: builtin.ToolQueryAssets, ShortDescription: "灵活分页查询资产",
|
||||
Description: "分页查询资产。支持精确字段、时间范围、扫描状态和白名单排序。查最久未扫描资产请使用 sort_by=last_scan_at、sort_order=asc;从未扫描资产会排在最前。默认每页 20 条,最大 50 条,返回精简摘要;使用 get_asset 获取单条详情。",
|
||||
InputSchema: assetQuerySchema(),
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
filter, page, pageSize, err := assetFilterFromToolArgs(args)
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
projectID, projectScoped, err := agentAssetProjectScope(db, ctx)
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
if projectScoped {
|
||||
// 对话绑定项目后,项目范围是服务端强制边界;不能通过工具参数扩大或切换范围。
|
||||
filter.ProjectID = projectID
|
||||
}
|
||||
items, total, err := db.ListAssets(pageSize, (page-1)*pageSize, filter, assetAccessOnly(ctx, "asset:read"))
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("资产查询:第 %d/%d 页,本页 %d 条,共 %d 条,page_size=%d\n", page, totalPages, len(items), total, pageSize))
|
||||
for _, asset := range items {
|
||||
b.WriteString(formatAssetListItem(asset))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if page < totalPages {
|
||||
b.WriteString(fmt.Sprintf("下一页:保持筛选条件并设置 page=%d。", page+1))
|
||||
}
|
||||
return textResult(b.String(), false), nil
|
||||
})
|
||||
|
||||
updateProperties := assetMutationProperties()
|
||||
updateProperties["id"] = map[string]interface{}{"type": "string", "description": "资产 ID"}
|
||||
server.RegisterTool(mcp.Tool{
|
||||
Name: builtin.ToolUpdateAsset, ShortDescription: "局部更新资产",
|
||||
Description: "按 ID 局部更新资产,只修改显式传入的字段;可传空 project_id 清除项目绑定,可传空 tags 清空标签。",
|
||||
InputSchema: map[string]interface{}{"type": "object", "properties": updateProperties, "required": []string{"id"}},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
id := strings.TrimSpace(strArg(args, "id"))
|
||||
access := assetAccessOnly(ctx, "asset:write")
|
||||
asset, err := db.GetAsset(id, access)
|
||||
if err != nil {
|
||||
return textResult("错误: 资产不存在或无权更新", true), nil
|
||||
}
|
||||
if err := applyAssetPatch(asset, args); err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
if err := db.UpdateAsset(id, asset, access); err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
updated, err := db.GetAsset(id, access)
|
||||
if err != nil {
|
||||
return textResult("资产已更新,但无法读取结果: "+err.Error(), true), nil
|
||||
}
|
||||
return assetJSONResult(map[string]interface{}{"action": "updated", "asset": assetToolDetail(updated)})
|
||||
})
|
||||
|
||||
server.RegisterTool(mcp.Tool{
|
||||
Name: builtin.ToolDeleteAsset, ShortDescription: "删除资产", Description: "按 ID 永久删除资产记录。仅在用户明确要求删除时调用。",
|
||||
InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"id": map[string]interface{}{"type": "string", "description": "资产 ID"}}, "required": []string{"id"}},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
id := strings.TrimSpace(strArg(args, "id"))
|
||||
if err := db.DeleteAsset(id, assetAccessOnly(ctx, "asset:delete")); err != nil {
|
||||
return textResult("错误: 资产不存在或无权删除", true), nil
|
||||
}
|
||||
return textResult("资产已删除: "+id, false), nil
|
||||
})
|
||||
|
||||
server.RegisterTool(mcp.Tool{
|
||||
Name: builtin.ToolCompleteAssetScan,
|
||||
ShortDescription: "完成资产扫描并回写结果",
|
||||
Description: "目标扫描完成后调用:把资产的上次扫描时间更新为当前时间,并关联当前对话。相关漏洞数量不手填,而是自动统计当前扫描对话中通过 record_vulnerability 保存的漏洞。应在漏洞均已落库后调用;一个扫描对话建议只对应一个资产。",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"id": map[string]interface{}{"type": "string", "description": "已完成扫描的资产 ID"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
id := strings.TrimSpace(strArg(args, "id"))
|
||||
conversationID := conversationIDFromToolCtx(ctx)
|
||||
if conversationID == "" {
|
||||
return textResult("错误: 无法确定当前扫描对话", true), nil
|
||||
}
|
||||
access := assetAccessOnly(ctx, "asset:write")
|
||||
if err := db.CompleteAssetScan(id, conversationID, access); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return textResult("错误: 资产不存在或无权回写扫描结果", true), nil
|
||||
}
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
updated, err := db.GetAsset(id, access)
|
||||
if err != nil {
|
||||
return textResult("扫描结果已回写,但无法读取资产: "+err.Error(), true), nil
|
||||
}
|
||||
return assetJSONResult(map[string]interface{}{
|
||||
"action": "scan_completed",
|
||||
"message": "上次扫描时间已更新;相关漏洞数由当前扫描对话中已保存的漏洞自动计算",
|
||||
"asset": assetToolDetail(updated),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func assetMutationProperties() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"project_id": map[string]interface{}{"type": "string"}, "host": map[string]interface{}{"type": "string"},
|
||||
"ip": map[string]interface{}{"type": "string"}, "port": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 65535},
|
||||
"domain": map[string]interface{}{"type": "string"}, "protocol": map[string]interface{}{"type": "string"},
|
||||
"title": map[string]interface{}{"type": "string"}, "server": map[string]interface{}{"type": "string"},
|
||||
"country": map[string]interface{}{"type": "string"}, "province": map[string]interface{}{"type": "string"}, "city": map[string]interface{}{"type": "string"},
|
||||
"responsible_person": map[string]interface{}{"type": "string"}, "department": map[string]interface{}{"type": "string"},
|
||||
"business_system": map[string]interface{}{"type": "string"},
|
||||
"environment": map[string]interface{}{"type": "string", "enum": []string{"production", "staging", "testing", "development", "other"}},
|
||||
"criticality": map[string]interface{}{"type": "string", "enum": []string{"critical", "high", "medium", "low"}},
|
||||
"source": map[string]interface{}{"type": "string"}, "source_query": map[string]interface{}{"type": "string"},
|
||||
"status": map[string]interface{}{"type": "string", "enum": []string{"active", "inactive"}},
|
||||
"tags": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "maxItems": 50},
|
||||
}
|
||||
}
|
||||
|
||||
func assetQuerySchema() map[string]interface{} {
|
||||
properties := map[string]interface{}{
|
||||
"q": map[string]interface{}{"type": "string", "description": "模糊搜索 host、IP、域名、标题、服务和标签"},
|
||||
"project_id": map[string]interface{}{"type": "string"}, "status": map[string]interface{}{"type": "string", "enum": []string{"active", "inactive"}},
|
||||
"protocol": map[string]interface{}{"type": "string"}, "source": map[string]interface{}{"type": "string"}, "tag": map[string]interface{}{"type": "string"},
|
||||
"host": map[string]interface{}{"type": "string"}, "ip": map[string]interface{}{"type": "string"}, "domain": map[string]interface{}{"type": "string"},
|
||||
"port": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 65535},
|
||||
"risk_level": map[string]interface{}{"type": "string", "enum": []string{"unassessed", "critical", "high", "medium", "low", "info", "normal"}},
|
||||
"min_vulnerabilities": map[string]interface{}{"type": "integer", "minimum": 0},
|
||||
"max_vulnerabilities": map[string]interface{}{"type": "integer", "minimum": 0},
|
||||
"country": map[string]interface{}{"type": "string"}, "province": map[string]interface{}{"type": "string"}, "city": map[string]interface{}{"type": "string"},
|
||||
"responsible_person": map[string]interface{}{"type": "string"}, "department": map[string]interface{}{"type": "string"},
|
||||
"business_system": map[string]interface{}{"type": "string"}, "environment": map[string]interface{}{"type": "string"}, "criticality": map[string]interface{}{"type": "string"},
|
||||
"scan_state": map[string]interface{}{"type": "string", "enum": []string{"never", "scanned"}, "description": "never=从未扫描,scanned=扫描过"},
|
||||
"scan_overdue_days": map[string]interface{}{"type": "integer", "minimum": 1},
|
||||
"last_scan_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_scan_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"first_seen_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"first_seen_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_seen_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_seen_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"sort_by": map[string]interface{}{"type": "string", "enum": []string{"last_seen_at", "last_scan_at", "first_seen_at", "created_at", "updated_at", "host", "port", "risk_level", "vulnerability_count"}},
|
||||
"sort_order": map[string]interface{}{"type": "string", "enum": []string{"asc", "desc"}},
|
||||
"page": map[string]interface{}{"type": "integer", "minimum": 1},
|
||||
"page_size": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": agentAssetPageSizeMax},
|
||||
}
|
||||
return map[string]interface{}{"type": "object", "properties": properties}
|
||||
}
|
||||
|
||||
func assetFromCreateArgs(args map[string]interface{}) (*database.Asset, error) {
|
||||
asset := &database.Asset{}
|
||||
if err := applyAssetPatch(asset, args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(asset.Host) == "" && strings.TrimSpace(asset.IP) == "" && strings.TrimSpace(asset.Domain) == "" {
|
||||
return nil, fmt.Errorf("host、ip、domain 至少需要一个")
|
||||
}
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func applyAssetPatch(asset *database.Asset, args map[string]interface{}) error {
|
||||
setString := func(key string, dst *string) {
|
||||
if _, ok := args[key]; ok {
|
||||
*dst = strings.TrimSpace(strArg(args, key))
|
||||
}
|
||||
}
|
||||
setString("project_id", &asset.ProjectID)
|
||||
setString("host", &asset.Host)
|
||||
setString("ip", &asset.IP)
|
||||
setString("domain", &asset.Domain)
|
||||
setString("protocol", &asset.Protocol)
|
||||
setString("title", &asset.Title)
|
||||
setString("server", &asset.Server)
|
||||
setString("country", &asset.Country)
|
||||
setString("province", &asset.Province)
|
||||
setString("city", &asset.City)
|
||||
setString("responsible_person", &asset.ResponsiblePerson)
|
||||
setString("department", &asset.Department)
|
||||
setString("business_system", &asset.BusinessSystem)
|
||||
setString("environment", &asset.Environment)
|
||||
setString("criticality", &asset.Criticality)
|
||||
setString("source", &asset.Source)
|
||||
setString("source_query", &asset.SourceQuery)
|
||||
setString("status", &asset.Status)
|
||||
if _, ok := args["port"]; ok {
|
||||
port := intArg(args, "port", -1)
|
||||
if port < 0 || port > 65535 {
|
||||
return fmt.Errorf("port 必须在 0-65535 之间")
|
||||
}
|
||||
asset.Port = port
|
||||
}
|
||||
if raw, ok := args["tags"]; ok {
|
||||
tags, err := stringSliceArg(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tags: %w", err)
|
||||
}
|
||||
asset.Tags = tags
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFilter, int, int, error) {
|
||||
filter := database.AssetListFilter{
|
||||
Search: strings.TrimSpace(strArg(args, "q")), ProjectID: strings.TrimSpace(strArg(args, "project_id")), Status: strings.ToLower(strings.TrimSpace(strArg(args, "status"))),
|
||||
Protocol: strings.ToLower(strings.TrimSpace(strArg(args, "protocol"))), Source: strings.TrimSpace(strArg(args, "source")), Tag: strings.TrimSpace(strArg(args, "tag")),
|
||||
Host: strings.TrimSpace(strArg(args, "host")), IP: strings.TrimSpace(strArg(args, "ip")), Domain: strings.TrimSpace(strArg(args, "domain")),
|
||||
ScanState: strings.ToLower(strings.TrimSpace(strArg(args, "scan_state"))), SortBy: strings.ToLower(strings.TrimSpace(strArg(args, "sort_by"))),
|
||||
SortOrder: strings.ToLower(strings.TrimSpace(strArg(args, "sort_order"))),
|
||||
RiskLevel: strings.ToLower(strings.TrimSpace(strArg(args, "risk_level"))),
|
||||
Country: strings.TrimSpace(strArg(args, "country")), Province: strings.TrimSpace(strArg(args, "province")), City: strings.TrimSpace(strArg(args, "city")),
|
||||
ResponsiblePerson: strings.TrimSpace(strArg(args, "responsible_person")), Department: strings.TrimSpace(strArg(args, "department")),
|
||||
BusinessSystem: strings.TrimSpace(strArg(args, "business_system")), Environment: strings.ToLower(strings.TrimSpace(strArg(args, "environment"))),
|
||||
Criticality: strings.ToLower(strings.TrimSpace(strArg(args, "criticality"))),
|
||||
}
|
||||
if !oneOfOrEmpty(filter.Status, "active", "inactive") {
|
||||
return filter, 0, 0, fmt.Errorf("status 仅支持 active 或 inactive")
|
||||
}
|
||||
if !oneOfOrEmpty(filter.ScanState, "never", "scanned") {
|
||||
return filter, 0, 0, fmt.Errorf("scan_state 仅支持 never 或 scanned")
|
||||
}
|
||||
if !oneOfOrEmpty(filter.SortBy, "last_seen_at", "last_scan_at", "first_seen_at", "created_at", "updated_at", "host", "port", "risk_level", "vulnerability_count") {
|
||||
return filter, 0, 0, fmt.Errorf("sort_by 不受支持")
|
||||
}
|
||||
if !oneOfOrEmpty(filter.SortOrder, "asc", "desc") {
|
||||
return filter, 0, 0, fmt.Errorf("sort_order 仅支持 asc 或 desc")
|
||||
}
|
||||
if _, ok := args["port"]; ok {
|
||||
port := intArg(args, "port", -1)
|
||||
if port < 0 || port > 65535 {
|
||||
return filter, 0, 0, fmt.Errorf("port 必须在 0-65535 之间")
|
||||
}
|
||||
filter.Port = &port
|
||||
}
|
||||
if _, ok := args["min_vulnerabilities"]; ok {
|
||||
value := intArg(args, "min_vulnerabilities", -1)
|
||||
if value < 0 {
|
||||
return filter, 0, 0, fmt.Errorf("min_vulnerabilities 不能小于 0")
|
||||
}
|
||||
filter.MinVulnerabilities = &value
|
||||
}
|
||||
if _, ok := args["max_vulnerabilities"]; ok {
|
||||
value := intArg(args, "max_vulnerabilities", -1)
|
||||
if value < 0 {
|
||||
return filter, 0, 0, fmt.Errorf("max_vulnerabilities 不能小于 0")
|
||||
}
|
||||
filter.MaxVulnerabilities = &value
|
||||
}
|
||||
if _, ok := args["scan_overdue_days"]; ok {
|
||||
value := intArg(args, "scan_overdue_days", 0)
|
||||
if value < 1 {
|
||||
return filter, 0, 0, fmt.Errorf("scan_overdue_days 必须大于 0")
|
||||
}
|
||||
filter.ScanOverdueDays = &value
|
||||
}
|
||||
var err error
|
||||
if filter.LastScanBefore, err = parseAssetToolTime("last_scan_before", strArg(args, "last_scan_before")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.LastScanAfter, err = parseAssetToolTime("last_scan_after", strArg(args, "last_scan_after")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.FirstSeenBefore, err = parseAssetToolTime("first_seen_before", strArg(args, "first_seen_before")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.FirstSeenAfter, err = parseAssetToolTime("first_seen_after", strArg(args, "first_seen_after")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.LastSeenBefore, err = parseAssetToolTime("last_seen_before", strArg(args, "last_seen_before")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.LastSeenAfter, err = parseAssetToolTime("last_seen_after", strArg(args, "last_seen_after")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
page := intArg(args, "page", 1)
|
||||
pageSize := intArg(args, "page_size", 20)
|
||||
if page < 1 || page > 1_000_000 {
|
||||
return filter, 0, 0, fmt.Errorf("page 必须在 1-1000000 之间")
|
||||
}
|
||||
if pageSize < 1 || pageSize > agentAssetPageSizeMax {
|
||||
return filter, 0, 0, fmt.Errorf("page_size 必须在 1-%d 之间", agentAssetPageSizeMax)
|
||||
}
|
||||
return filter, page, pageSize, nil
|
||||
}
|
||||
|
||||
func oneOfOrEmpty(value string, allowed ...string) bool {
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseAssetToolTime(field, value string) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return &parsed, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s 必须是 RFC3339 时间或 YYYY-MM-DD", field)
|
||||
}
|
||||
|
||||
func stringSliceArg(raw interface{}) ([]string, error) {
|
||||
values := []string{}
|
||||
switch typed := raw.(type) {
|
||||
case []string:
|
||||
values = append(values, typed...)
|
||||
case []interface{}:
|
||||
for _, item := range typed {
|
||||
value, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("必须是字符串数组")
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("必须是字符串数组")
|
||||
}
|
||||
if len(values) > 50 {
|
||||
return nil, fmt.Errorf("最多 50 个标签")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func assetAccessOnly(ctx context.Context, permission string) database.RBACListAccess {
|
||||
principal, ok := authctx.PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return database.RBACListAccess{}
|
||||
}
|
||||
return database.RBACListAccess{UserID: principal.UserID, Scope: principal.ScopeFor(permission)}
|
||||
}
|
||||
|
||||
func assetAccessFromToolContext(ctx context.Context, permission string) (database.RBACListAccess, string, bool) {
|
||||
principal, ok := authctx.PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return database.RBACListAccess{}, "", false
|
||||
}
|
||||
access := database.RBACListAccess{UserID: principal.UserID, Scope: principal.ScopeFor(permission)}
|
||||
return access, principal.UserID, access.Scope == database.RBACScopeAll
|
||||
}
|
||||
|
||||
// agentAssetProjectScope returns the hard asset-read boundary implied by the
|
||||
// current conversation. An unbound conversation (or a tool call outside a
|
||||
// conversation) keeps the existing all-accessible-assets behavior. A bound
|
||||
// conversation can only read assets assigned to that exact project.
|
||||
func agentAssetProjectScope(db *database.DB, ctx context.Context) (projectID string, scoped bool, err error) {
|
||||
conversationID := conversationIDFromToolCtx(ctx)
|
||||
if conversationID == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
projectID, err = db.GetConversationProjectID(conversationID)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("无法确定当前对话的项目范围")
|
||||
}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
return projectID, projectID != "", nil
|
||||
}
|
||||
|
||||
func formatAssetListItem(asset *database.Asset) string {
|
||||
target := asset.Domain
|
||||
if target == "" {
|
||||
target = asset.IP
|
||||
}
|
||||
if target == "" {
|
||||
target = asset.Host
|
||||
}
|
||||
if asset.Port > 0 {
|
||||
target = fmt.Sprintf("%s:%d", target, asset.Port)
|
||||
}
|
||||
lastScan := "never"
|
||||
if asset.LastScanAt != nil {
|
||||
lastScan = asset.LastScanAt.Format(time.RFC3339)
|
||||
}
|
||||
return fmt.Sprintf("- id=%s | target=%s | protocol=%s | status=%s | last_scan_at=%s | risk=%s | vulnerabilities=%d", asset.ID, truncateRunes(target, 120), truncateRunes(asset.Protocol, 30), truncateRunes(asset.Status, 30), lastScan, asset.RiskLevel, asset.VulnerabilityCount)
|
||||
}
|
||||
|
||||
// assetToolDetail keeps even a single unusually large imported record from
|
||||
// consuming the model context. The database and HTTP API retain full values.
|
||||
func assetToolDetail(asset *database.Asset) map[string]interface{} {
|
||||
if asset == nil {
|
||||
return nil
|
||||
}
|
||||
tags := make([]string, 0, len(asset.Tags))
|
||||
for i, tag := range asset.Tags {
|
||||
if i >= 50 {
|
||||
break
|
||||
}
|
||||
tags = append(tags, truncateRunes(tag, 100))
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"id": asset.ID, "project_id": asset.ProjectID, "project_name": truncateRunes(asset.ProjectName, 200),
|
||||
"host": truncateRunes(asset.Host, 500), "ip": truncateRunes(asset.IP, 100), "port": asset.Port,
|
||||
"domain": truncateRunes(asset.Domain, 255), "protocol": truncateRunes(asset.Protocol, 50),
|
||||
"title": truncateRunes(asset.Title, 500), "server": truncateRunes(asset.Server, 500),
|
||||
"country": truncateRunes(asset.Country, 100), "province": truncateRunes(asset.Province, 100), "city": truncateRunes(asset.City, 100),
|
||||
"responsible_person": truncateRunes(asset.ResponsiblePerson, 255), "department": truncateRunes(asset.Department, 255),
|
||||
"business_system": truncateRunes(asset.BusinessSystem, 255), "environment": asset.Environment, "criticality": asset.Criticality,
|
||||
"source": truncateRunes(asset.Source, 100), "source_query": truncateRunes(asset.SourceQuery, 2000),
|
||||
"status": truncateRunes(asset.Status, 50), "tags": tags,
|
||||
"first_seen_at": asset.FirstSeenAt, "last_seen_at": asset.LastSeenAt, "created_at": asset.CreatedAt, "updated_at": asset.UpdatedAt,
|
||||
"last_scan_conversation_id": asset.LastScanConversationID, "last_scan_queue_id": asset.LastScanQueueID, "last_scan_task_id": asset.LastScanTaskID,
|
||||
"vulnerability_count": asset.VulnerabilityCount, "risk_level": asset.RiskLevel,
|
||||
}
|
||||
if asset.LastScanAt != nil {
|
||||
detail["last_scan_at"] = asset.LastScanAt
|
||||
}
|
||||
if len(asset.Tags) > len(tags) {
|
||||
detail["tags_truncated"] = true
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
func assetJSONResult(value interface{}) (*mcp.ToolResult, error) {
|
||||
encoded, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
return textResult(string(encoded), false), nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAssetToolsCRUDQueryAndPageLimit(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "asset-tools.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
user, err := db.CreateRBACUser("asset-agent", "Asset Agent", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
|
||||
"asset:read": true, "asset:write": true, "asset:delete": true,
|
||||
})
|
||||
ctx := authctx.WithPrincipal(context.Background(), principal)
|
||||
server := mcp.NewServer(zap.NewNop())
|
||||
server.SetToolAuthorizer(mcpToolAuthorizer(db))
|
||||
registerAssetTools(server, db, zap.NewNop())
|
||||
|
||||
wantTools := map[string]bool{
|
||||
builtin.ToolCreateAsset: false, builtin.ToolGetAsset: false, builtin.ToolQueryAssets: false,
|
||||
builtin.ToolUpdateAsset: false, builtin.ToolDeleteAsset: false, builtin.ToolCompleteAssetScan: false,
|
||||
}
|
||||
for _, tool := range server.GetAllTools() {
|
||||
if _, ok := wantTools[tool.Name]; ok {
|
||||
wantTools[tool.Name] = true
|
||||
}
|
||||
}
|
||||
for name, found := range wantTools {
|
||||
if !found {
|
||||
t.Fatalf("asset tool not registered: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tool := range server.GetAllTools() {
|
||||
if tool.Name != builtin.ToolCreateAsset {
|
||||
continue
|
||||
}
|
||||
for _, keyword := range []string{"oneOf", "allOf", "anyOf"} {
|
||||
if _, exists := tool.InputSchema[keyword]; exists {
|
||||
t.Fatalf("create asset schema contains Bedrock-incompatible top-level %s", keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, _, err := server.CallTool(ctx, builtin.ToolCreateAsset, map[string]interface{}{"title": "Missing target"})
|
||||
if err != nil || result == nil || !result.IsError {
|
||||
t.Fatalf("create asset accepted missing host/ip/domain: result=%#v err=%v", result, err)
|
||||
}
|
||||
|
||||
result, _, err = server.CallTool(ctx, builtin.ToolCreateAsset, map[string]interface{}{
|
||||
"ip": "192.0.2.42", "port": 443, "protocol": "https", "title": "Before", "tags": []interface{}{"prod"},
|
||||
})
|
||||
if err != nil || result == nil || result.IsError {
|
||||
t.Fatalf("create asset result=%#v err=%v", result, err)
|
||||
}
|
||||
assets, total, err := db.ListAssets(20, 0, database.AssetListFilter{}, database.RBACListAccess{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
if err != nil || total != 1 || len(assets) != 1 {
|
||||
t.Fatalf("saved assets total=%d len=%d err=%v", total, len(assets), err)
|
||||
}
|
||||
id := assets[0].ID
|
||||
|
||||
result, _, err = server.CallTool(ctx, builtin.ToolUpdateAsset, map[string]interface{}{"id": id, "title": "After"})
|
||||
if err != nil || result == nil || result.IsError {
|
||||
t.Fatalf("update asset result=%#v err=%v", result, err)
|
||||
}
|
||||
updated, err := db.GetAsset(id, database.RBACListAccess{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
if err != nil || updated.Title != "After" || updated.IP != "192.0.2.42" {
|
||||
t.Fatalf("partial update lost fields: %#v err=%v", updated, err)
|
||||
}
|
||||
|
||||
result, _, err = server.CallTool(ctx, builtin.ToolQueryAssets, map[string]interface{}{
|
||||
"sort_by": "last_scan_at", "sort_order": "asc", "page": 1, "page_size": 1,
|
||||
})
|
||||
if err != nil || result == nil || result.IsError || !strings.Contains(toolResultText(result), "第 1/1 页") || !strings.Contains(toolResultText(result), "last_scan_at=never") {
|
||||
t.Fatalf("query asset result=%#v err=%v", result, err)
|
||||
}
|
||||
result, _, err = server.CallTool(ctx, builtin.ToolQueryAssets, map[string]interface{}{"page_size": agentAssetPageSizeMax + 1})
|
||||
if err != nil || result == nil || !result.IsError {
|
||||
t.Fatalf("oversized page was accepted: result=%#v err=%v", result, err)
|
||||
}
|
||||
|
||||
conversation, err := db.CreateConversation("asset scan", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateVulnerability(&database.Vulnerability{ConversationID: conversation.ID, Title: "finding", Severity: "high", Target: "192.0.2.42"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scanCtx := mcp.WithMCPConversationID(ctx, conversation.ID)
|
||||
result, _, err = server.CallTool(scanCtx, builtin.ToolCompleteAssetScan, map[string]interface{}{"id": id})
|
||||
if err != nil || result == nil || result.IsError {
|
||||
t.Fatalf("complete scan result=%#v err=%v", result, err)
|
||||
}
|
||||
scanned, err := db.GetAsset(id, database.RBACListAccess{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
if err != nil || scanned.LastScanAt == nil || scanned.LastScanConversationID != conversation.ID || scanned.VulnerabilityCount != 1 {
|
||||
t.Fatalf("scan fields not updated: %#v err=%v", scanned, err)
|
||||
}
|
||||
|
||||
result, _, err = server.CallTool(ctx, builtin.ToolDeleteAsset, map[string]interface{}{"id": id})
|
||||
if err != nil || result == nil || result.IsError {
|
||||
t.Fatalf("delete asset result=%#v err=%v", result, err)
|
||||
}
|
||||
if _, err := db.GetAsset(id, database.RBACListAccess{Scope: database.RBACScopeAll}); err == nil {
|
||||
t.Fatal("asset still exists after delete")
|
||||
}
|
||||
}
|
||||
|
||||
func toolResultText(result *mcp.ToolResult) string {
|
||||
var b strings.Builder
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
for _, content := range result.Content {
|
||||
b.WriteString(content.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestAssetReadToolsRespectConversationProjectScope(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "asset-project-scope.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
projectA, err := db.CreateProject(&database.Project{Name: "Project A"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectB, err := db.CreateProject(&database.Project{Name: "Project B"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets := []*database.Asset{
|
||||
{ProjectID: projectA.ID, IP: "192.0.2.10", Protocol: "https"},
|
||||
{ProjectID: projectB.ID, IP: "192.0.2.20", Protocol: "https"},
|
||||
{IP: "192.0.2.30", Protocol: "https"},
|
||||
}
|
||||
if result, err := db.UpsertAssets(assets, "", true); err != nil || result.Created != len(assets) {
|
||||
t.Fatalf("seed assets result=%#v err=%v", result, err)
|
||||
}
|
||||
|
||||
bound, err := db.CreateConversation("bound", database.ConversationCreateMeta{ProjectID: projectA.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unbound, err := db.CreateConversation("unbound", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
principal := authctx.NewPrincipal("admin", "admin", database.RBACScopeAll, map[string]bool{"asset:read": true})
|
||||
ctx := authctx.WithPrincipal(context.Background(), principal)
|
||||
server := mcp.NewServer(zap.NewNop())
|
||||
server.SetToolAuthorizer(mcpToolAuthorizer(db))
|
||||
registerAssetTools(server, db, zap.NewNop())
|
||||
|
||||
boundCtx := mcp.WithMCPConversationID(ctx, bound.ID)
|
||||
result, _, err := server.CallTool(boundCtx, builtin.ToolQueryAssets, map[string]interface{}{})
|
||||
text := toolResultText(result)
|
||||
if err != nil || result == nil || result.IsError || !strings.Contains(text, assets[0].ID) || strings.Contains(text, assets[1].ID) || strings.Contains(text, assets[2].ID) {
|
||||
t.Fatalf("bound query escaped project scope: result=%#v text=%q err=%v", result, text, err)
|
||||
}
|
||||
|
||||
// Even an explicit foreign project_id cannot override the conversation boundary.
|
||||
result, _, err = server.CallTool(boundCtx, builtin.ToolQueryAssets, map[string]interface{}{"project_id": projectB.ID})
|
||||
text = toolResultText(result)
|
||||
if err != nil || result == nil || result.IsError || !strings.Contains(text, assets[0].ID) || strings.Contains(text, assets[1].ID) {
|
||||
t.Fatalf("project_id overrode conversation scope: result=%#v text=%q err=%v", result, text, err)
|
||||
}
|
||||
|
||||
result, _, err = server.CallTool(boundCtx, builtin.ToolGetAsset, map[string]interface{}{"id": assets[1].ID})
|
||||
if err != nil || result == nil || !result.IsError {
|
||||
t.Fatalf("bound get read a foreign-project asset: result=%#v err=%v", result, err)
|
||||
}
|
||||
|
||||
unboundCtx := mcp.WithMCPConversationID(ctx, unbound.ID)
|
||||
result, _, err = server.CallTool(unboundCtx, builtin.ToolQueryAssets, map[string]interface{}{"page_size": 10})
|
||||
text = toolResultText(result)
|
||||
if err != nil || result == nil || result.IsError || !strings.Contains(text, assets[0].ID) || !strings.Contains(text, assets[1].ID) || !strings.Contains(text, assets[2].ID) {
|
||||
t.Fatalf("unbound query did not retain all-assets behavior: result=%#v text=%q err=%v", result, text, err)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
|
||||
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
|
||||
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
||||
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID。create 省略时默认使用当前对话绑定项目;未绑定项目的对话则创建未绑定监听器"},
|
||||
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
||||
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
||||
@@ -87,7 +88,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx))
|
||||
listeners, err := m.DB().ListC2ListenersForAccess(c2ToolAccess(ctx), mcpEffectiveProjectFilter(ctx, m.DB()))
|
||||
if err != nil {
|
||||
return makeC2Result(nil, err)
|
||||
}
|
||||
@@ -116,6 +117,13 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
cfg = &c2.ListenerConfig{}
|
||||
_ = json.Unmarshal(cfgBytes, cfg)
|
||||
}
|
||||
projectID := strings.TrimSpace(getString(params, "project_id"))
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, m.DB())
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
projectID = ""
|
||||
}
|
||||
}
|
||||
input := c2.CreateListenerInput{
|
||||
Name: getString(params, "name"),
|
||||
Type: getString(params, "type"),
|
||||
@@ -123,6 +131,7 @@ tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登
|
||||
BindPort: int(getFloat64(params, "bind_port")),
|
||||
ProfileID: getString(params, "profile_id"),
|
||||
Remark: getString(params, "remark"),
|
||||
ProjectID: projectID,
|
||||
Config: cfg,
|
||||
CallbackHost: getString(params, "callback_host"),
|
||||
}
|
||||
@@ -260,6 +269,7 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
case "list":
|
||||
filter := database.ListC2SessionsFilter{
|
||||
ListenerID: getString(params, "listener_id"),
|
||||
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||
Status: getString(params, "status"),
|
||||
OS: getString(params, "os"),
|
||||
Search: getString(params, "search"),
|
||||
@@ -495,6 +505,7 @@ func registerC2TaskManageTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
case "list":
|
||||
filter := database.ListC2TasksFilter{
|
||||
SessionID: getString(params, "session_id"),
|
||||
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||
Status: getString(params, "status"),
|
||||
}
|
||||
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
||||
@@ -645,6 +656,7 @@ func registerC2EventTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
filter := database.ListC2EventsFilter{
|
||||
Level: getString(params, "level"),
|
||||
Category: getString(params, "category"),
|
||||
ProjectID: mcpEffectiveProjectFilter(ctx, m.DB()),
|
||||
SessionID: getString(params, "session_id"),
|
||||
TaskID: getString(params, "task_id"),
|
||||
Limit: int(getFloat64(params, "limit")),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/c2"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestC2ListenerCreateInheritsConversationProject(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "c2-tools.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
user, err := db.CreateRBACUser("c2-agent", "C2 Agent", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&database.Project{Name: "engagement"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("project chat", database.ConversationCreateMeta{ProjectID: project.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
|
||||
"c2:read": true, "c2:write": true,
|
||||
})
|
||||
ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), conversation.ID), principal)
|
||||
server := mcp.NewServer(zap.NewNop())
|
||||
server.SetToolAuthorizer(mcpToolAuthorizer(db))
|
||||
registerC2Tools(server, c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop(), 8080)
|
||||
|
||||
result, _, err := server.CallTool(ctx, builtin.ToolC2Listener, map[string]interface{}{
|
||||
"action": "create",
|
||||
"name": "tcp-reverse-2222",
|
||||
"type": "tcp_reverse",
|
||||
"bind_host": "0.0.0.0",
|
||||
"bind_port": 2222,
|
||||
})
|
||||
if err != nil || result == nil || result.IsError {
|
||||
t.Fatalf("create listener result=%#v err=%v text=%q", result, err, toolResultText(result))
|
||||
}
|
||||
|
||||
listeners, err := db.ListC2Listeners()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 {
|
||||
t.Fatalf("listener count=%d, want 1", len(listeners))
|
||||
}
|
||||
if listeners[0].ProjectID != project.ID {
|
||||
t.Fatalf("listener project_id=%q, want %q", listeners[0].ProjectID, project.ID)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestCORSMiddlewareAllowsSameOriginAndRejectsForeignOrigin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware())
|
||||
router.Use(corsMiddleware(nil))
|
||||
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
same := httptest.NewRequest(http.MethodGet, "http://app.example/test", nil)
|
||||
@@ -32,3 +32,74 @@ func TestCORSMiddlewareAllowsSameOriginAndRejectsForeignOrigin(t *testing.T) {
|
||||
t.Fatalf("foreign-origin response = %d, want %d", foreignW.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddlewareAllowsBrowserExtensionWithoutConfiguration(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware(nil))
|
||||
router.POST("/api/auth/login", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "https://server.example/api/auth/login", nil)
|
||||
req.Host = "server.example"
|
||||
req.Header.Set("Origin", "chrome-extension://abcdefghijklmnopabcdefghijklmnop")
|
||||
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight response = %d, want %d", w.Code, http.StatusNoContent)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "chrome-extension://abcdefghijklmnopabcdefghijklmnop" {
|
||||
t.Fatalf("allow-origin = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddlewareRejectsInvalidExtensionOrigins(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, origin := range []string{
|
||||
"chrome-extension://too-short",
|
||||
"chrome-extension://qrstuvwxyzabcdefqrstuvwxyzabcdef",
|
||||
"chrome-extension://abcdefghijklmnopabcdefghijklmnop:8443",
|
||||
"moz-extension://abcdefghijklmnopabcdefghijklmnop",
|
||||
} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware(nil))
|
||||
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://server.example/test", nil)
|
||||
req.Host = "server.example"
|
||||
req.Header.Set("Origin", origin)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("response = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCORSMiddlewareRejectsUnsafeConfiguredEntries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, configured := range []string{
|
||||
"*",
|
||||
"null",
|
||||
"https://trusted.example/extra",
|
||||
"https://trusted.example?trusted=true",
|
||||
} {
|
||||
t.Run(configured, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
router.Use(corsMiddleware([]string{configured}))
|
||||
router.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://server.example/test", nil)
|
||||
req.Host = "server.example"
|
||||
req.Header.Set("Origin", "https://trusted.example")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("response = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,19 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
|
||||
if id == "" || db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
|
||||
return fmt.Errorf("no access to %s %s", resourceType, id)
|
||||
}
|
||||
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
toolExecutionResource := func(permission string) error {
|
||||
if err := require(permission); err != nil {
|
||||
return err
|
||||
}
|
||||
id := mcpAuthorizationString(args, "execution_id")
|
||||
if id == "" || db == nil || !db.UserCanAccessToolExecution(principal.UserID, principal.ScopeFor(permission), id) {
|
||||
return fmt.Errorf("no access to tool execution %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -71,6 +84,35 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
|
||||
return nil
|
||||
case builtin.ToolGetVulnerability:
|
||||
return resource("vulnerability:read", "vulnerability", "id")
|
||||
case builtin.ToolQueryAssets:
|
||||
return require("asset:read")
|
||||
case builtin.ToolGetAsset:
|
||||
return resource("asset:read", "asset", "id")
|
||||
case builtin.ToolCreateAsset:
|
||||
if err := require("asset:write"); err != nil {
|
||||
return err
|
||||
}
|
||||
if projectID := mcpAuthorizationString(args, "project_id"); projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor("asset:write"), "project", projectID)) {
|
||||
return fmt.Errorf("no access to project %s", projectID)
|
||||
}
|
||||
return nil
|
||||
case builtin.ToolUpdateAsset, builtin.ToolCompleteAssetScan:
|
||||
if err := resource("asset:write", "asset", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if toolName == builtin.ToolCompleteAssetScan {
|
||||
conversationID := mcpAuthorizationConversationID(ctx)
|
||||
if conversationID == "" || db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor("asset:write"), "conversation", conversationID) {
|
||||
return fmt.Errorf("no access to conversation %s", conversationID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if projectID := mcpAuthorizationString(args, "project_id"); projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor("asset:write"), "project", projectID)) {
|
||||
return fmt.Errorf("no access to project %s", projectID)
|
||||
}
|
||||
return nil
|
||||
case builtin.ToolDeleteAsset:
|
||||
return resource("asset:delete", "asset", "id")
|
||||
case builtin.ToolUpsertProjectFact, builtin.ToolDeprecateProjectFact, builtin.ToolRestoreProjectFact:
|
||||
return authorizeProjectTool(ctx, principal, db, "project:write")
|
||||
case builtin.ToolGetProjectFact, builtin.ToolListProjectFacts, builtin.ToolSearchProjectFacts:
|
||||
@@ -79,6 +121,10 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
|
||||
return require("knowledge:read")
|
||||
case builtin.ToolAnalyzeImage:
|
||||
return require("agent:execute")
|
||||
case builtin.ToolGetToolExecution, builtin.ToolWaitToolExecution:
|
||||
return toolExecutionResource("monitor:read")
|
||||
case builtin.ToolCancelToolExecution:
|
||||
return toolExecutionResource("monitor:write")
|
||||
case builtin.ToolBatchTaskList:
|
||||
return require("tasks:read")
|
||||
case builtin.ToolBatchTaskGet:
|
||||
@@ -98,20 +144,26 @@ func mcpToolAuthorizer(db *database.DB) func(context.Context, string, map[string
|
||||
builtin.ToolBatchTaskScheduleEnabled, builtin.ToolBatchTaskAdd, builtin.ToolBatchTaskUpdate:
|
||||
return resource("tasks:write", "batch_task", "queue_id")
|
||||
case builtin.ToolC2Listener:
|
||||
return authorizeC2Action(principal, db, args, "c2_listener", "listener_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_listener", "listener_id")
|
||||
case builtin.ToolC2Session, builtin.ToolC2Task, builtin.ToolC2File:
|
||||
if toolName == builtin.ToolC2File && mcpAuthorizationString(args, "action") == "get_result" {
|
||||
return authorizeC2Action(principal, db, args, "c2_task", "task_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_task", "task_id")
|
||||
}
|
||||
return authorizeC2Action(principal, db, args, "c2_session", "session_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_session", "session_id")
|
||||
case builtin.ToolC2TaskManage:
|
||||
return authorizeC2Action(principal, db, args, "c2_task", "task_id")
|
||||
return authorizeC2Action(ctx, principal, db, args, "c2_task", "task_id")
|
||||
case builtin.ToolC2Payload:
|
||||
return resource("c2:write", "c2_listener", "listener_id")
|
||||
case builtin.ToolC2Event:
|
||||
if id := mcpAuthorizationString(args, "session_id"); id != "" {
|
||||
return resource("c2:read", "c2_session", "session_id")
|
||||
}
|
||||
if id := mcpAuthorizationString(args, "task_id"); id != "" {
|
||||
return resource("c2:read", "c2_task", "task_id")
|
||||
}
|
||||
if filter := mcpEffectiveProjectFilter(ctx, db); filter != "" {
|
||||
return require("c2:read")
|
||||
}
|
||||
if principal.ScopeFor("c2:read") != database.RBACScopeAll {
|
||||
return fmt.Errorf("unfiltered C2 event list requires global scope")
|
||||
}
|
||||
@@ -164,7 +216,7 @@ func externalMCPToolAuthorizer() func(context.Context, string, map[string]interf
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[string]interface{}, resourceType, argument string) error {
|
||||
func authorizeC2Action(ctx context.Context, principal authctx.Principal, db *database.DB, args map[string]interface{}, resourceType, argument string) error {
|
||||
action := mcpAuthorizationString(args, "action")
|
||||
permission := "c2:write"
|
||||
if action == "list" || action == "get" || action == "get_result" || action == "wait" {
|
||||
@@ -185,11 +237,27 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
|
||||
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, candidate) {
|
||||
return fmt.Errorf("no access to %s %s", resourceType, candidate)
|
||||
}
|
||||
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if id == "" {
|
||||
if action == "create" || action == "list" {
|
||||
if action == "create" {
|
||||
projectID := mcpAuthorizationString(args, "project_id")
|
||||
if projectID == "" {
|
||||
projectID = mcpEffectiveProjectFilter(ctx, db)
|
||||
if projectID == database.ProjectFilterUnbound {
|
||||
projectID = ""
|
||||
}
|
||||
}
|
||||
if projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID)) {
|
||||
return fmt.Errorf("no access to project %s", projectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if action == "list" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("missing resource identifier %s", argument)
|
||||
@@ -197,9 +265,93 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
|
||||
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) {
|
||||
return fmt.Errorf("no access to %s %s", resourceType, id)
|
||||
}
|
||||
if err := authorizeMCPProjectResourceBoundary(ctx, db, resourceType, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authorizeMCPProjectResourceBoundary(ctx context.Context, db *database.DB, resourceType, resourceID string) error {
|
||||
filter := mcpEffectiveProjectFilter(ctx, db)
|
||||
if filter == "" || db == nil {
|
||||
return nil
|
||||
}
|
||||
projectID, ok, err := mcpResourceProjectID(db, resourceType, resourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if filter == database.ProjectFilterUnbound {
|
||||
if projectID != "" {
|
||||
return fmt.Errorf("resource %s %s belongs to project %s, current conversation is unbound", resourceType, resourceID, projectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if projectID != filter {
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("resource %s %s is unbound, current conversation project is %s", resourceType, resourceID, filter)
|
||||
}
|
||||
return fmt.Errorf("resource %s %s belongs to project %s, current conversation project is %s", resourceType, resourceID, projectID, filter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mcpResourceProjectID(db *database.DB, resourceType, resourceID string) (string, bool, error) {
|
||||
switch resourceType {
|
||||
case "webshell":
|
||||
conn, err := db.GetWebshellConnection(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if conn == nil {
|
||||
return "", true, fmt.Errorf("webshell not found")
|
||||
}
|
||||
return strings.TrimSpace(conn.ProjectID), true, nil
|
||||
case "c2_listener":
|
||||
listener, err := db.GetC2Listener(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if listener == nil {
|
||||
return "", true, fmt.Errorf("listener not found")
|
||||
}
|
||||
return strings.TrimSpace(listener.ProjectID), true, nil
|
||||
case "c2_session":
|
||||
session, err := db.GetC2Session(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if session == nil {
|
||||
return "", true, fmt.Errorf("session not found")
|
||||
}
|
||||
return mcpResourceProjectID(db, "c2_listener", session.ListenerID)
|
||||
case "c2_task":
|
||||
task, err := db.GetC2Task(resourceID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if task == nil {
|
||||
return "", true, fmt.Errorf("task not found")
|
||||
}
|
||||
return mcpResourceProjectIDFromC2Session(db, task.SessionID)
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func mcpResourceProjectIDFromC2Session(db *database.DB, sessionID string) (string, bool, error) {
|
||||
session, err := db.GetC2Session(sessionID)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if session == nil {
|
||||
return "", true, fmt.Errorf("session not found")
|
||||
}
|
||||
return mcpResourceProjectID(db, "c2_listener", session.ListenerID)
|
||||
}
|
||||
|
||||
func mcpAuthorizationStrings(args map[string]interface{}, key string) []string {
|
||||
values := []string{}
|
||||
switch raw := args[key].(type) {
|
||||
@@ -230,7 +382,13 @@ func authorizeProjectTool(ctx context.Context, principal authctx.Principal, db *
|
||||
return fmt.Errorf("no access to conversation %s", conversationID)
|
||||
}
|
||||
projectID, err := db.GetConversationProjectID(conversationID)
|
||||
if err != nil || strings.TrimSpace(projectID) == "" || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("no access to project: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
return fmt.Errorf("当前对话未绑定项目,无法使用项目黑板工具,请先在对话中选择项目或创建带项目的对话")
|
||||
}
|
||||
if !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID) {
|
||||
return fmt.Errorf("no access to project %s", projectID)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
@@ -48,6 +49,91 @@ func TestMCPToolAuthorizerEnforcesPermissionAndResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolAuthorizerEnforcesConversationProjectBoundary(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-project-boundary.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
user, err := db.CreateRBACUser("boundary-user", "Boundary User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&database.Project{Name: "Project 123"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectConv, err := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: project.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unboundConv, err := db.CreateConversation("unbound conversation", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wsProject := database.WebShellConnection{ID: "ws_project", ProjectID: project.ID, URL: "http://127.0.0.1/project.php", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
wsUnbound := database.WebShellConnection{ID: "ws_unbound", URL: "http://127.0.0.1/unbound.php", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
if err := db.CreateWebshellConnection(&wsProject); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&wsUnbound); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{wsProject.ID, wsUnbound.ID} {
|
||||
if err := db.AssignResourceToUser(user.ID, "webshell", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
listener := &database.C2Listener{ID: "l_project", ProjectID: project.ID, Name: "project listener", Type: "tcp_reverse", BindHost: "127.0.0.1", BindPort: 5555, OwnerUserID: user.ID, CreatedAt: now}
|
||||
if err := db.CreateC2Listener(listener); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "c2_listener", listener.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := &database.C2Session{ID: "s_project", ListenerID: listener.ID, ImplantUUID: "implant-project", Status: "active", FirstSeenAt: now, LastCheckIn: now}
|
||||
if err := db.UpsertC2Session(session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
|
||||
"webshell:read": true, "webshell:write": true,
|
||||
"c2:read": true, "c2:write": true,
|
||||
})
|
||||
authorize := mcpToolAuthorizer(db)
|
||||
unboundCtx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), unboundConv.ID), principal)
|
||||
projectCtx := authctx.WithPrincipal(mcp.WithMCPProjectID(mcp.WithMCPConversationID(context.Background(), projectConv.ID), project.ID), principal)
|
||||
projectCtxFromConversationOnly := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), projectConv.ID), principal)
|
||||
|
||||
if err := authorize(unboundCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err == nil {
|
||||
t.Fatal("unbound conversation was allowed to use project-bound webshell")
|
||||
}
|
||||
if err := authorize(unboundCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsUnbound.ID}); err != nil {
|
||||
t.Fatalf("unbound webshell denied in unbound conversation: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err != nil {
|
||||
t.Fatalf("project webshell denied in project conversation: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtxFromConversationOnly, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsProject.ID}); err != nil {
|
||||
t.Fatalf("project webshell denied when only conversation id is present: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtx, builtin.ToolWebshellExec, map[string]interface{}{"connection_id": wsUnbound.ID}); err == nil {
|
||||
t.Fatal("project conversation was allowed to use unbound webshell by id")
|
||||
}
|
||||
if err := authorize(unboundCtx, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err == nil {
|
||||
t.Fatal("unbound conversation was allowed to use project-bound c2 session")
|
||||
}
|
||||
if err := authorize(projectCtx, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err != nil {
|
||||
t.Fatalf("project c2 session denied in project conversation: %v", err)
|
||||
}
|
||||
if err := authorize(projectCtxFromConversationOnly, builtin.ToolC2Session, map[string]interface{}{"action": "get", "session_id": session.ID}); err != nil {
|
||||
t.Fatalf("project c2 session denied when only conversation id is present: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-policy-inventory.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
@@ -62,7 +148,7 @@ func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
|
||||
authorize := mcpToolAuthorizer(db)
|
||||
args := map[string]interface{}{
|
||||
"action": "get", "connection_id": "x", "queue_id": "x", "listener_id": "x",
|
||||
"session_id": "x", "task_id": "x", "id": "x", "conversation_id": "x",
|
||||
"session_id": "x", "task_id": "x", "id": "x", "conversation_id": "x", "execution_id": "x",
|
||||
}
|
||||
for _, toolName := range builtin.GetAllBuiltinTools() {
|
||||
err := authorize(ctx, toolName, args)
|
||||
@@ -72,6 +158,84 @@ func TestEveryBuiltinMCPToolHasExplicitAuthorizationPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPExecutionControlAuthorizationUsesExecutionScope(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-exec-authz.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
user, err := db.CreateRBACUser("exec-user", "Exec User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: "exec-owned",
|
||||
ToolName: "lab::slow",
|
||||
Status: "running",
|
||||
StartTime: time.Now(),
|
||||
OwnerUserID: user.ID,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: "exec-hidden",
|
||||
ToolName: "lab::slow",
|
||||
Status: "running",
|
||||
StartTime: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{"monitor:read": true, "monitor:write": true})
|
||||
ctx := authctx.WithPrincipal(context.Background(), principal)
|
||||
authorize := mcpToolAuthorizer(db)
|
||||
if err := authorize(ctx, builtin.ToolWaitToolExecution, map[string]interface{}{"execution_id": "exec-owned"}); err != nil {
|
||||
t.Fatalf("owned execution denied: %v", err)
|
||||
}
|
||||
if err := authorize(ctx, builtin.ToolCancelToolExecution, map[string]interface{}{"execution_id": "exec-hidden"}); err == nil {
|
||||
t.Fatal("foreign execution was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPAssetToolAuthorizationUsesAssetPermissionsAndScope(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "mcp-asset-authz.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
user, err := db.CreateRBACUser("asset-user", "Asset User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned := &database.Asset{IP: "192.0.2.10", Port: 443, Protocol: "https"}
|
||||
hidden := &database.Asset{IP: "192.0.2.20", Port: 443, Protocol: "https"}
|
||||
if _, err := db.UpsertAssets([]*database.Asset{owned}, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertAssets([]*database.Asset{hidden}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
permissions := map[string]bool{"asset:read": true, "asset:write": true}
|
||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, permissions))
|
||||
authorize := mcpToolAuthorizer(db)
|
||||
if err := authorize(ctx, builtin.ToolQueryAssets, nil); err != nil {
|
||||
t.Fatalf("asset query denied: %v", err)
|
||||
}
|
||||
if err := authorize(ctx, builtin.ToolGetAsset, map[string]interface{}{"id": owned.ID}); err != nil {
|
||||
t.Fatalf("owned asset denied: %v", err)
|
||||
}
|
||||
if err := authorize(ctx, builtin.ToolGetAsset, map[string]interface{}{"id": hidden.ID}); err == nil {
|
||||
t.Fatal("unassigned asset was readable")
|
||||
}
|
||||
if err := authorize(ctx, builtin.ToolUpdateAsset, map[string]interface{}{"id": owned.ID}); err != nil {
|
||||
t.Fatalf("owned asset update denied: %v", err)
|
||||
}
|
||||
if err := authorize(ctx, builtin.ToolDeleteAsset, map[string]interface{}{"id": owned.ID}); err == nil {
|
||||
t.Fatal("asset delete without asset:delete was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalMCPRequiresDedicatedPermission(t *testing.T) {
|
||||
authorize := externalMCPToolAuthorizer()
|
||||
ctx := authctx.WithPrincipal(context.Background(), authctx.NewPrincipal("u1", "user", database.RBACScopeAssigned, map[string]bool{"agent:execute": true}))
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
)
|
||||
|
||||
func mcpEffectiveProjectFilter(ctx context.Context, db *database.DB) string {
|
||||
if projectID := strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx)); projectID != "" {
|
||||
return projectID
|
||||
}
|
||||
if conversationID := mcpAuthorizationConversationID(ctx); conversationID != "" {
|
||||
if db != nil {
|
||||
if projectID, err := db.GetConversationProjectID(conversationID); err == nil {
|
||||
if projectID = strings.TrimSpace(projectID); projectID != "" {
|
||||
return projectID
|
||||
}
|
||||
}
|
||||
}
|
||||
return database.ProjectFilterUnbound
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -201,7 +201,7 @@ func registerVulnerabilityTools(mcpServer *mcp.Server, db *database.DB, logger *
|
||||
func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
|
||||
tool := mcp.Tool{
|
||||
Name: builtin.ToolRecordVulnerability,
|
||||
Description: "记录发现的漏洞详情到漏洞管理系统。必须按“仅看本记录即可复现”的标准填写:目标、触发点、前置条件、复现步骤、证据/POC、实际影响、修复建议和复测方式。边渗透边记录:每验证出一条可复现漏洞后立即调用,勿等会话结束。记录前可先 list_vulnerabilities 避免重复。",
|
||||
Description: "记录发现的漏洞详情到漏洞管理系统。必须按“仅看本记录即可复现”的标准填写:目标、漏洞类型、触发点、复现步骤、证据/POC、实际影响和修复建议;前置条件与复测方式为推荐填写项。边渗透边记录:每验证出一条可复现漏洞后立即调用,勿等会话结束。记录前可先 list_vulnerabilities 避免重复。",
|
||||
ShortDescription: "记录可复现的漏洞详情到漏洞管理系统",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -229,7 +229,7 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
},
|
||||
"preconditions": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "前置条件:登录状态、权限、账号、Header/Cookie、特定数据、网络位置、环境/版本等;无前置条件写“无”。",
|
||||
"description": "前置条件(推荐填写):登录状态、权限、账号、Header/Cookie、特定数据、网络位置、环境/版本等;无前置条件可写“无”。",
|
||||
},
|
||||
"reproduction_steps": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -249,7 +249,7 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
},
|
||||
"retest_notes": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "复测方式:修复后如何验证漏洞已关闭,包括应返回的状态码、错误信息或访问控制结果。",
|
||||
"description": "复测方式(推荐填写):修复后如何验证漏洞已关闭,包括应返回的状态码、错误信息或访问控制结果。",
|
||||
},
|
||||
},
|
||||
"required": []string{"title", "description", "severity", "vulnerability_type", "target", "reproduction_steps", "evidence", "impact", "recommendation"},
|
||||
@@ -282,7 +282,7 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
return textResult(fmt.Sprintf("错误: severity 必须是 critical、high、medium、low 或 info 之一,当前值: %s", severity), true), nil
|
||||
}
|
||||
if missing := missingVulnerabilityReproFields(args); len(missing) > 0 {
|
||||
return textResult("错误: 漏洞记录缺少复现所需信息,请补充后再记录:\n- "+strings.Join(missing, "\n- ")+"\n\n最佳实践:漏洞管理中的单条记录应独立包含目标、前置条件、复现步骤、证据/POC、影响和修复/复测方式。", true), nil
|
||||
return textResult("错误: 漏洞记录缺少必填信息,请补充后再记录:\n- "+strings.Join(missing, "\n- ")+"\n\n必填项用于确保单条记录可独立复现;前置条件和复测方式为推荐填写项。", true), nil
|
||||
}
|
||||
|
||||
projectID := ""
|
||||
@@ -318,6 +318,7 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
_ = db.SetResourceOwner("vulnerability", created.ID, principal.UserID)
|
||||
_ = db.AssignResourceToUser(principal.UserID, "vulnerability", created.ID)
|
||||
}
|
||||
db.NotifyVulnerabilityCreated(created)
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("漏洞记录成功",
|
||||
|
||||
@@ -34,9 +34,9 @@ type Manager struct {
|
||||
runningListeners map[string]Listener // listener_id → 已 Start 的 listener 实例
|
||||
storageDir string // 大结果(截图/下载)落盘根目录
|
||||
|
||||
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
|
||||
hitlBridge HITLBridge // 危险任务在 EnqueueTask 时调它发起审批(nil 表示不接 HITL)
|
||||
hitlDangerousGate func(conversationID, mcpToolName string) bool // 与人机协同一致:为 nil 或返回 false 时不走桥
|
||||
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
|
||||
hooks Hooks // 扩展挂钩:会话上线 / 任务完成 时通知漏洞库与攻击链
|
||||
}
|
||||
|
||||
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
|
||||
@@ -63,7 +63,7 @@ type HITLApprovalRequest struct {
|
||||
|
||||
// Hooks 给上层(漏洞管理 / 攻击链)注入回调
|
||||
type Hooks struct {
|
||||
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
|
||||
OnSessionFirstSeen func(session *database.C2Session) // 新会话首次上线
|
||||
OnTaskCompleted func(task *database.C2Task, sessionID string) // 任务完成(success/failed)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ func (m *Manager) Close() {
|
||||
// CreateListenerInput Web/MCP 创建监听器的入参(已校验 + 已 trim)
|
||||
type CreateListenerInput struct {
|
||||
Name string
|
||||
ProjectID string
|
||||
Type string
|
||||
BindHost string
|
||||
BindPort int
|
||||
@@ -195,6 +196,7 @@ func (m *Manager) CreateListener(in CreateListenerInput) (*database.C2Listener,
|
||||
|
||||
listener := &database.C2Listener{
|
||||
ID: "l_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14],
|
||||
ProjectID: strings.TrimSpace(in.ProjectID),
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Type: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
BindHost: bindHost,
|
||||
|
||||
+241
-20
@@ -21,8 +21,12 @@ type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
MCP MCPConfig `yaml:"mcp"`
|
||||
OpenAI OpenAIConfig `yaml:"openai"`
|
||||
AI AIConfig `yaml:"ai,omitempty" json:"ai,omitempty"`
|
||||
OpenAI OpenAIConfig `yaml:"openai,omitempty" json:"openai,omitempty"`
|
||||
FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,omitempty"`
|
||||
ZoomEye SpaceSearchConfig `yaml:"zoomeye,omitempty" json:"zoomeye,omitempty"`
|
||||
Quake SpaceSearchConfig `yaml:"quake,omitempty" json:"quake,omitempty"`
|
||||
Shodan SpaceSearchConfig `yaml:"shodan,omitempty" json:"shodan,omitempty"`
|
||||
Agent AgentConfig `yaml:"agent"`
|
||||
Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
@@ -49,6 +53,10 @@ type EnsureLocalConfigResult struct {
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultMaxCompletionTokens = 16384
|
||||
DefaultMaxToolArgumentsBytes = 65536
|
||||
DefaultMaxShellCommandBytes = 65536
|
||||
DefaultModelOutputRepairMaxAttempts = 1
|
||||
DefaultSummarizationUserIntentLedgerMaxRunes = 96000
|
||||
DefaultSummarizationUserIntentLedgerEntryMaxRunes = 16000
|
||||
DefaultLatestUserMessageMaxRunes = 48000
|
||||
@@ -248,6 +256,12 @@ func (c MultiAgentEinoCallbacksConfig) EinoCallbacksMaxOutputSummaryRunes() int
|
||||
|
||||
// MultiAgentEinoMiddlewareConfig optional Eino ADK middleware and Deep / supervisor tuning.
|
||||
type MultiAgentEinoMiddlewareConfig struct {
|
||||
// MaxToolArgumentsBytes hard-rejects oversized model-generated tool arguments before execution.
|
||||
MaxToolArgumentsBytes int `yaml:"max_tool_arguments_bytes,omitempty" json:"max_tool_arguments_bytes,omitempty"`
|
||||
// MaxShellCommandBytes applies a stricter limit to exec/execute command strings.
|
||||
MaxShellCommandBytes int `yaml:"max_shell_command_bytes,omitempty" json:"max_shell_command_bytes,omitempty"`
|
||||
// ModelOutputRepairMaxAttempts limits consecutive model-output repair attempts.
|
||||
ModelOutputRepairMaxAttempts int `yaml:"model_output_repair_max_attempts,omitempty" json:"model_output_repair_max_attempts,omitempty"`
|
||||
// PatchToolCalls inserts placeholder tool results for dangling assistant tool_calls (nil = enabled).
|
||||
PatchToolCalls *bool `yaml:"patch_tool_calls,omitempty" json:"patch_tool_calls,omitempty"`
|
||||
// ToolSearch enables dynamictool/toolsearch: hide tail tools until model calls tool_search (reduces prompt tools).
|
||||
@@ -299,7 +313,7 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
|
||||
// DeepModelRetryMaxRetries 已废弃:临时错误统一由 run loop 内 isEinoTransientRunError + run_retry_max_attempts 处理。
|
||||
DeepModelRetryMaxRetries int `yaml:"deep_model_retry_max_retries,omitempty" json:"deep_model_retry_max_retries,omitempty"`
|
||||
// RunRetryMaxAttempts > 0:429/5xx/网络抖动时可退避重试次数(run loop 与 summarization 共用);0=默认 10。
|
||||
// RunRetryMaxAttempts > 0:408/409/425/429/5xx/网络抖动时可退避重试次数(run loop 与 summarization 共用);0=默认 4。
|
||||
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
|
||||
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
|
||||
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
|
||||
@@ -309,6 +323,27 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
|
||||
}
|
||||
|
||||
func (c MultiAgentEinoMiddlewareConfig) MaxToolArgumentsBytesEffective() int {
|
||||
if c.MaxToolArgumentsBytes > 0 {
|
||||
return c.MaxToolArgumentsBytes
|
||||
}
|
||||
return DefaultMaxToolArgumentsBytes
|
||||
}
|
||||
|
||||
func (c MultiAgentEinoMiddlewareConfig) MaxShellCommandBytesEffective() int {
|
||||
if c.MaxShellCommandBytes > 0 {
|
||||
return c.MaxShellCommandBytes
|
||||
}
|
||||
return DefaultMaxShellCommandBytes
|
||||
}
|
||||
|
||||
func (c MultiAgentEinoMiddlewareConfig) ModelOutputRepairMaxAttemptsEffective() int {
|
||||
if c.ModelOutputRepairMaxAttempts > 0 {
|
||||
return c.ModelOutputRepairMaxAttempts
|
||||
}
|
||||
return DefaultModelOutputRepairMaxAttempts
|
||||
}
|
||||
|
||||
func (c MultiAgentEinoMiddlewareConfig) SummarizationTriggerRatioEffective() float64 {
|
||||
v := c.SummarizationTriggerRatio
|
||||
if v <= 0 {
|
||||
@@ -767,6 +802,9 @@ func (c RobotsConfig) ServiceAccountUserIDs() map[string]string {
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host" json:"host"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
// CORSAllowedOrigins contains additional, exact origins that may call the API.
|
||||
// Same-origin browser requests are always allowed. Wildcards are intentionally unsupported.
|
||||
CORSAllowedOrigins []string `yaml:"cors_allowed_origins,omitempty" json:"cors_allowed_origins,omitempty"`
|
||||
// TLSEnabled 为 true 时主 Web UI 使用 HTTPS;现代浏览器在同源下会协商 HTTP/2,缓解 HTTP/1.1 每源并发连接数限制。
|
||||
TLSEnabled bool `yaml:"tls_enabled,omitempty" json:"tls_enabled,omitempty"`
|
||||
// TLSCertPath / TLSKeyPath 非空时从 PEM 文件加载证书(生产环境推荐)。
|
||||
@@ -793,18 +831,151 @@ type MCPConfig struct {
|
||||
}
|
||||
|
||||
type OpenAIConfig struct {
|
||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude",claude 时自动桥接为 Anthropic Messages API
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` // API 提供商: "openai"(默认) 或 "claude",claude 时自动桥接为 Anthropic Messages API
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||
MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"`
|
||||
// Reasoning 控制 Eino ChatModel 的 thinking / reasoning_effort / output_config 等(Eino 单/多代理路径生效)。
|
||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
// AIConfig stores first-class model channels. Runtime callers resolve a channel
|
||||
// into OpenAIConfig at the edge instead of moving API credentials through chat requests.
|
||||
type AIConfig struct {
|
||||
DefaultChannel string `yaml:"default_channel,omitempty" json:"default_channel,omitempty"`
|
||||
Channels map[string]AIChannelConfig `yaml:"channels,omitempty" json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
type AIChannelConfig struct {
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||
APIKey string `yaml:"api_key" json:"api_key"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Model string `yaml:"model" json:"model"`
|
||||
MaxTotalTokens int `yaml:"max_total_tokens,omitempty" json:"max_total_tokens,omitempty"`
|
||||
MaxCompletionTokens int `yaml:"max_completion_tokens,omitempty" json:"max_completion_tokens,omitempty"`
|
||||
Reasoning OpenAIReasoningConfig `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
func (c AIChannelConfig) ToOpenAIConfig() OpenAIConfig {
|
||||
provider := strings.TrimSpace(c.Provider)
|
||||
if provider == "" || provider == "openai_compatible" {
|
||||
provider = "openai"
|
||||
}
|
||||
return OpenAIConfig{
|
||||
Provider: provider,
|
||||
APIKey: c.APIKey,
|
||||
BaseURL: c.BaseURL,
|
||||
Model: c.Model,
|
||||
MaxTotalTokens: c.MaxTotalTokens,
|
||||
MaxCompletionTokens: c.MaxCompletionTokens,
|
||||
Reasoning: c.Reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
func AIChannelFromOpenAI(id, name string, oa OpenAIConfig) AIChannelConfig {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = id
|
||||
}
|
||||
return AIChannelConfig{
|
||||
Name: name,
|
||||
Provider: oa.Provider,
|
||||
APIKey: oa.APIKey,
|
||||
BaseURL: oa.BaseURL,
|
||||
Model: oa.Model,
|
||||
MaxTotalTokens: oa.MaxTotalTokens,
|
||||
MaxCompletionTokens: oa.MaxCompletionTokens,
|
||||
Reasoning: oa.Reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeAIChannelID(s string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(s))
|
||||
id = strings.ReplaceAll(id, "_", "-")
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range id {
|
||||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if ok {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" {
|
||||
return "default"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *AIConfig) EnsureDefaultFromOpenAI(openAI OpenAIConfig) {
|
||||
if c.Channels == nil {
|
||||
c.Channels = make(map[string]AIChannelConfig)
|
||||
}
|
||||
def := NormalizeAIChannelID(c.DefaultChannel)
|
||||
if def == "default" && strings.TrimSpace(c.DefaultChannel) == "" {
|
||||
def = "default"
|
||||
}
|
||||
c.DefaultChannel = def
|
||||
if _, ok := c.Channels[def]; !ok {
|
||||
c.Channels[def] = AIChannelFromOpenAI(def, "Default", openAI)
|
||||
}
|
||||
}
|
||||
|
||||
func (c AIConfig) ResolveChannel(channelID string) (OpenAIConfig, string, bool) {
|
||||
id := NormalizeAIChannelID(channelID)
|
||||
if strings.TrimSpace(channelID) == "" {
|
||||
id = NormalizeAIChannelID(c.DefaultChannel)
|
||||
}
|
||||
if id == "" {
|
||||
id = "default"
|
||||
}
|
||||
if c.Channels != nil {
|
||||
if ch, ok := c.Channels[id]; ok {
|
||||
return ch.ToOpenAIConfig(), id, true
|
||||
}
|
||||
}
|
||||
return OpenAIConfig{}, id, false
|
||||
}
|
||||
|
||||
func (c *Config) ResolveAIChannel(channelID string) (OpenAIConfig, string, bool) {
|
||||
if c == nil {
|
||||
return OpenAIConfig{}, "", false
|
||||
}
|
||||
if oa, id, ok := c.AI.ResolveChannel(channelID); ok {
|
||||
return oa, id, true
|
||||
}
|
||||
return c.OpenAI, NormalizeAIChannelID(channelID), strings.TrimSpace(c.OpenAI.Model) != "" || strings.TrimSpace(c.OpenAI.BaseURL) != ""
|
||||
}
|
||||
|
||||
func (c *Config) ApplyDefaultAIChannel() {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.AI.EnsureDefaultFromOpenAI(c.OpenAI)
|
||||
if oa, _, ok := c.AI.ResolveChannel(c.AI.DefaultChannel); ok {
|
||||
c.OpenAI = oa
|
||||
}
|
||||
}
|
||||
|
||||
func (c OpenAIConfig) MaxCompletionTokensEffective() int {
|
||||
if c.MaxCompletionTokens > 0 {
|
||||
return c.MaxCompletionTokens
|
||||
}
|
||||
return DefaultMaxCompletionTokens
|
||||
}
|
||||
|
||||
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
|
||||
type OpenAIReasoningConfig struct {
|
||||
// Mode: auto(默认)| on | off | default(与 auto 相同)。off 时不向模型附加推理扩展字段。
|
||||
// Mode: auto(默认)| on | off | default(与 auto 相同)。
|
||||
// off 在 OpenAI/Claude profile 下省略推理字段;DeepSeek profile 下发送 thinking.type=disabled(其默认开启思考)。
|
||||
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
|
||||
// Effort: low | medium | high | max | xhigh;max/xhigh 为不同网关最高档命名,原样下发、不互转。空表示不单独指定强度。
|
||||
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
|
||||
@@ -813,6 +984,7 @@ type OpenAIReasoningConfig struct {
|
||||
// Profile: auto | deepseek_compat | openai_compat | output_config_effort
|
||||
Profile string `yaml:"profile,omitempty" json:"profile,omitempty"`
|
||||
// ExtraRequestFields 合并进 Chat Completions 根 JSON(管理员用;与自动字段同名时后者覆盖)。
|
||||
// Mode=off 时会移除其中的推理控制字段,但保留其他扩展字段;DeepSeek profile 随后补充显式关闭开关。
|
||||
ExtraRequestFields map[string]interface{} `yaml:"extra_request_fields,omitempty" json:"extra_request_fields,omitempty"`
|
||||
}
|
||||
|
||||
@@ -843,12 +1015,16 @@ func (c OpenAIReasoningConfig) AllowClientReasoningEffective() bool {
|
||||
}
|
||||
|
||||
type FofaConfig struct {
|
||||
// Email 为 FOFA 账号邮箱;APIKey 为 FOFA API Key(建议使用只读权限的 Key)
|
||||
Email string `yaml:"email,omitempty" json:"email,omitempty"`
|
||||
// APIKey 为 FOFA API Key(建议使用只读权限的 Key)
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://fofa.info/api/v1/search/all
|
||||
}
|
||||
|
||||
type SpaceSearchConfig struct {
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
Tools []ToolConfig `yaml:"tools,omitempty"` // 向后兼容:支持在主配置文件中定义工具
|
||||
ToolsDir string `yaml:"tools_dir,omitempty"` // 工具配置文件目录(新方式)
|
||||
@@ -861,8 +1037,13 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||
ToolWaitTimeoutSeconds int `yaml:"tool_wait_timeout_seconds" json:"tool_wait_timeout_seconds"` // 工具本轮等待秒数;到时返回 execution_id,worker 继续后台执行;0 表示等到完成
|
||||
ExternalMCPMaxConcurrentPerServer int `yaml:"external_mcp_max_concurrent_per_server" json:"external_mcp_max_concurrent_per_server"` // 单个外部 MCP server 同时运行的工具数;0 表示默认 2
|
||||
ExternalMCPMaxConcurrentTotal int `yaml:"external_mcp_max_concurrent_total" json:"external_mcp_max_concurrent_total"` // 所有外部 MCP 工具全局并发;0 表示默认 16
|
||||
ExternalMCPCircuitFailureThreshold int `yaml:"external_mcp_circuit_failure_threshold" json:"external_mcp_circuit_failure_threshold"` // 单个 MCP server 连续失败多少次后打开熔断;0 表示默认 3;负数关闭
|
||||
ExternalMCPCircuitCooldownSeconds int `yaml:"external_mcp_circuit_cooldown_seconds" json:"external_mcp_circuit_cooldown_seconds"` // 熔断后冷却秒数;0 表示默认 60
|
||||
// ShellNoOutputTimeoutSeconds execute/exec 无任何 stdout/stderr 时的空闲终止秒数(通用防挂死,不维护命令黑名单);0=默认 300(5 分钟);-1=关闭。
|
||||
ShellNoOutputTimeoutSeconds int `yaml:"shell_no_output_timeout_seconds" json:"shell_no_output_timeout_seconds"`
|
||||
// WorkspaceRootDir 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离。
|
||||
@@ -929,6 +1110,9 @@ func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig {
|
||||
if am.MaxTotalTokens > 0 {
|
||||
out.MaxTotalTokens = am.MaxTotalTokens
|
||||
}
|
||||
if am.MaxCompletionTokens > 0 {
|
||||
out.MaxCompletionTokens = am.MaxCompletionTokens
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1165,6 +1349,10 @@ func Load(path string) (*Config, error) {
|
||||
if cfg.Audit.MaxDetailBytes <= 0 {
|
||||
cfg.Audit.MaxDetailBytes = 8192
|
||||
}
|
||||
cfg.ApplyDefaultAIChannel()
|
||||
if err := validateModelOutputLimits(cfg.OpenAI, cfg.MultiAgent.EinoMiddleware); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 如果配置了工具目录,从目录加载工具配置
|
||||
if cfg.Security.ToolsDir != "" {
|
||||
inlineTools := append([]ToolConfig(nil), cfg.Security.Tools...)
|
||||
@@ -1227,6 +1415,25 @@ func Load(path string) (*Config, error) {
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func validateModelOutputLimits(openAI OpenAIConfig, mw MultiAgentEinoMiddlewareConfig) error {
|
||||
if openAI.MaxCompletionTokens < 0 {
|
||||
return fmt.Errorf("openai.max_completion_tokens 必须为正数")
|
||||
}
|
||||
if mw.MaxToolArgumentsBytes < 0 {
|
||||
return fmt.Errorf("multi_agent.eino_middleware.max_tool_arguments_bytes 必须为正数")
|
||||
}
|
||||
if mw.MaxShellCommandBytes < 0 {
|
||||
return fmt.Errorf("multi_agent.eino_middleware.max_shell_command_bytes 必须为正数")
|
||||
}
|
||||
if mw.ModelOutputRepairMaxAttempts < 0 {
|
||||
return fmt.Errorf("multi_agent.eino_middleware.model_output_repair_max_attempts 必须为正数")
|
||||
}
|
||||
if mw.MaxShellCommandBytesEffective() > mw.MaxToolArgumentsBytesEffective() {
|
||||
return fmt.Errorf("multi_agent.eino_middleware.max_shell_command_bytes 不能大于 max_tool_arguments_bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureLocalConfig(path string) (EnsureLocalConfigResult, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
@@ -1639,15 +1846,29 @@ func Default() *Config {
|
||||
Host: "127.0.0.1",
|
||||
Port: 8081,
|
||||
},
|
||||
OpenAI: OpenAIConfig{
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4",
|
||||
MaxTotalTokens: 120000,
|
||||
AI: AIConfig{
|
||||
DefaultChannel: "default",
|
||||
Channels: map[string]AIChannelConfig{
|
||||
"default": {
|
||||
Name: "Default",
|
||||
Provider: "openai_compatible",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
Model: "gpt-4",
|
||||
MaxTotalTokens: 120000,
|
||||
MaxCompletionTokens: DefaultMaxCompletionTokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
OpenAI: OpenAIConfig{},
|
||||
Agent: AgentConfig{
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
ToolWaitTimeoutSeconds: 60, // 外部 MCP 工具单轮最多等待 60 秒,超时后返回 execution_id 可继续等待
|
||||
ExternalMCPMaxConcurrentPerServer: 2, // 单个外部 MCP server 默认最多 2 个工具同时执行
|
||||
ExternalMCPMaxConcurrentTotal: 16, // 外部 MCP 工具全局默认最多 16 个同时执行
|
||||
ExternalMCPCircuitFailureThreshold: 3, // 单个 server 连续 3 次失败后临时熔断
|
||||
ExternalMCPCircuitCooldownSeconds: 60, // 熔断默认冷却 60 秒
|
||||
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
|
||||
@@ -1869,7 +2090,7 @@ type RoleConfig struct {
|
||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
|
||||
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName")
|
||||
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
|
||||
WorkflowID string `yaml:"workflow_id,omitempty" json:"workflow_id,omitempty"` // 可选:绑定图编排流程 ID
|
||||
WorkflowID string `yaml:"workflow_id,omitempty" json:"workflow_id,omitempty"` // 可选:绑定工作流 ID
|
||||
WorkflowVersion string `yaml:"workflow_version,omitempty" json:"workflow_version,omitempty"` // latest 或具体版本号;空等同 latest
|
||||
WorkflowPolicy string `yaml:"workflow_policy,omitempty" json:"workflow_policy,omitempty"` // auto | off;空且 workflow_id 非空时按 auto
|
||||
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
|
||||
|
||||
@@ -95,6 +95,48 @@ func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
initial := strings.Join([]string{
|
||||
"ai:",
|
||||
" default_channel: deepseek",
|
||||
" channels:",
|
||||
" qwen:",
|
||||
" name: Qwen",
|
||||
" provider: openai_compatible",
|
||||
" base_url: https://dashscope.example/v1",
|
||||
" api_key: qwen-key",
|
||||
" model: qwen-max",
|
||||
" deepseek:",
|
||||
" name: DeepSeek",
|
||||
" provider: openai_compatible",
|
||||
" base_url: https://deepseek.example/v1",
|
||||
" api_key: deepseek-key",
|
||||
" model: deepseek-chat",
|
||||
" max_total_tokens: 64000",
|
||||
"server:",
|
||||
" host: 127.0.0.1",
|
||||
" port: 8080",
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.OpenAI.Model != "deepseek-chat" || cfg.OpenAI.APIKey != "deepseek-key" || cfg.OpenAI.MaxTotalTokens != 64000 {
|
||||
t.Fatalf("runtime OpenAI config did not follow ai.default_channel: %+v", cfg.OpenAI)
|
||||
}
|
||||
oa, id, ok := cfg.ResolveAIChannel("qwen")
|
||||
if !ok || id != "qwen" || oa.Model != "qwen-max" || oa.APIKey != "qwen-key" {
|
||||
t.Fatalf("ResolveAIChannel(qwen) = (%+v, %q, %v)", oa, id, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) {
|
||||
var zero MultiAgentEinoMiddlewareConfig
|
||||
if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes {
|
||||
@@ -127,6 +169,22 @@ func TestSummarizationOutputReserveTokensEffective(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelOutputLimitDefaultsAndValidation(t *testing.T) {
|
||||
if got := (OpenAIConfig{}).MaxCompletionTokensEffective(); got != DefaultMaxCompletionTokens {
|
||||
t.Fatalf("max completion default=%d", got)
|
||||
}
|
||||
mw := MultiAgentEinoMiddlewareConfig{}
|
||||
if mw.MaxToolArgumentsBytesEffective() != 65536 || mw.MaxShellCommandBytesEffective() != 65536 || mw.ModelOutputRepairMaxAttemptsEffective() != 1 {
|
||||
t.Fatalf("unexpected guard defaults: %+v", mw)
|
||||
}
|
||||
if err := validateModelOutputLimits(OpenAIConfig{}, MultiAgentEinoMiddlewareConfig{MaxShellCommandBytes: 100, MaxToolArgumentsBytes: 99}); err == nil {
|
||||
t.Fatal("shell limit greater than generic limit must fail")
|
||||
}
|
||||
if err := validateModelOutputLimits(OpenAIConfig{MaxCompletionTokens: -1}, MultiAgentEinoMiddlewareConfig{}); err == nil {
|
||||
t.Fatal("negative completion limit must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestUserMessageRunesEffective(t *testing.T) {
|
||||
var zero MultiAgentEinoMiddlewareConfig
|
||||
if got := zero.LatestUserMessageMaxRunesEffective(); got != DefaultLatestUserMessageMaxRunes {
|
||||
|
||||
@@ -44,3 +44,17 @@ func ApplyDevHTTPSBootstrap(cfg *Config) {
|
||||
}
|
||||
cfg.Server.TLSAutoSelfSign = true
|
||||
}
|
||||
|
||||
// ApplyPlainHTTPBootstrap 供 --http / 一键脚本使用:强制主站使用明文 HTTP。
|
||||
// 它会覆盖配置文件中的 TLS 开关、自签证书以及证书路径,避免 --http 仍被配置中的 HTTPS 选项重新启用。
|
||||
func ApplyPlainHTTPBootstrap(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.Server.TLSEnabled = false
|
||||
cfg.Server.TLSAutoSelfSign = false
|
||||
cfg.Server.TLSCertPath = ""
|
||||
cfg.Server.TLSKeyPath = ""
|
||||
disabled := false
|
||||
cfg.Server.TLSHTTPRedirect = &disabled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyPlainHTTPBootstrapDisablesConfiguredTLS(t *testing.T) {
|
||||
enabled := true
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{
|
||||
TLSEnabled: true,
|
||||
TLSAutoSelfSign: true,
|
||||
TLSCertPath: "/tmp/server.crt",
|
||||
TLSKeyPath: "/tmp/server.key",
|
||||
TLSHTTPRedirect: &enabled,
|
||||
},
|
||||
}
|
||||
|
||||
ApplyPlainHTTPBootstrap(cfg)
|
||||
|
||||
if MainWebUIUsesHTTPS(&cfg.Server) {
|
||||
t.Fatal("expected --http bootstrap to disable main web UI HTTPS")
|
||||
}
|
||||
if ServerHTTPRedirectEnabled(&cfg.Server) {
|
||||
t.Fatal("expected --http bootstrap to disable HTTP to HTTPS redirect")
|
||||
}
|
||||
if cfg.Server.TLSCertPath != "" || cfg.Server.TLSKeyPath != "" {
|
||||
t.Fatalf("expected TLS cert paths to be cleared, got cert=%q key=%q", cfg.Server.TLSCertPath, cfg.Server.TLSKeyPath)
|
||||
}
|
||||
if cfg.Server.TLSHTTPRedirect == nil || *cfg.Server.TLSHTTPRedirect {
|
||||
t.Fatal("expected TLSHTTPRedirect to be explicitly disabled")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAssetURLNormalizationAndValidation(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-validation.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
asset := &Asset{Host: "https://例子.测试/path", Tags: []string{" prod ", "prod"}}
|
||||
result, err := db.UpsertAssets([]*Asset{asset}, "")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("URL asset was not created: result=%#v err=%v", result, err)
|
||||
}
|
||||
if asset.Domain != "xn--fsqu00a.xn--0zwm56d" || asset.Protocol != "https" || asset.Port != 443 {
|
||||
t.Fatalf("URL fields were not normalized: %#v", asset)
|
||||
}
|
||||
if len(asset.Tags) != 1 || asset.Tags[0] != "prod" {
|
||||
t.Fatalf("tags were not normalized: %#v", asset.Tags)
|
||||
}
|
||||
|
||||
invalid := []*Asset{
|
||||
{IP: "999.1.1.1", Status: "active"},
|
||||
{Domain: "bad_domain.example", Status: "active"},
|
||||
{Domain: "example.com", Port: 70000, Status: "active"},
|
||||
{Domain: "example.com", Protocol: "HTTP 1.1", Status: "active"},
|
||||
{Domain: "example.com", Status: "deleted"},
|
||||
}
|
||||
for _, candidate := range invalid {
|
||||
if _, err := db.UpsertAssets([]*Asset{candidate}, ""); err == nil {
|
||||
t.Fatalf("invalid asset unexpectedly accepted: %#v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range []string{"123", "not a formal target", "https://", "https://user:password@example.com"} {
|
||||
result, err := db.UpsertAssets([]*Asset{{Host: host}}, "")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("opaque asset address %q was not accepted: result=%#v err=%v", host, result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetValidationRejectsOversizedTags(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-tag-validation.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.UpsertAssets([]*Asset{{Domain: "example.com", Tags: []string{strings.Repeat("x", 65)}}}, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "标签") {
|
||||
t.Fatalf("expected tag validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFofaAssetIgnoresInvalidOptionalStructuredFields(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "fofa-asset-validation.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
asset := &Asset{
|
||||
Host: "https://203.0.113.59:8443",
|
||||
IP: "203.0.113.59",
|
||||
Domain: "provider_specific_invalid_domain_59",
|
||||
Port: 8443,
|
||||
Protocol: "https",
|
||||
Source: "fofa",
|
||||
}
|
||||
result, err := db.UpsertAssets([]*Asset{asset}, "")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("FOFA asset with dirty optional domain was not created: result=%#v err=%v", result, err)
|
||||
}
|
||||
if asset.Domain != "" || asset.IP != "203.0.113.59" {
|
||||
t.Fatalf("FOFA structured fields were not sanitized: %#v", asset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetUpsertDeduplicatesAndUpdates(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "assets.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
first := &Asset{Host: "https://example.com", Domain: "Example.COM", Port: 443, Protocol: "HTTPS", Title: "Old", Source: "fofa"}
|
||||
result, err := db.UpsertAssets([]*Asset{first}, "user-a")
|
||||
if err != nil || result.Created != 1 || result.Updated != 0 {
|
||||
t.Fatalf("first upsert = %#v, %v", result, err)
|
||||
}
|
||||
second := &Asset{Domain: "example.com", Port: 443, Protocol: "https", Title: "New", Server: "nginx", Source: "fofa"}
|
||||
result, err = db.UpsertAssets([]*Asset{second}, "user-a")
|
||||
if err != nil || result.Created != 0 || result.Updated != 1 {
|
||||
t.Fatalf("second upsert = %#v, %v", result, err)
|
||||
}
|
||||
assets, total, err := db.ListAssets(20, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(assets) != 1 {
|
||||
t.Fatalf("list assets total=%d len=%d err=%v", total, len(assets), err)
|
||||
}
|
||||
if assets[0].Title != "New" || assets[0].Server != "nginx" || assets[0].Protocol != "https" {
|
||||
t.Fatalf("asset not refreshed: %#v", assets[0])
|
||||
}
|
||||
stats, err := db.GetAssetStats(RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || stats["total"] != 1 {
|
||||
t.Fatalf("stats=%#v err=%v", stats, err)
|
||||
}
|
||||
coverage, ok := stats["coverage"].(map[string]interface{})
|
||||
if !ok || coverage["never_scanned"] != 1 || coverage["rate"] != 0 {
|
||||
t.Fatalf("coverage=%#v", stats["coverage"])
|
||||
}
|
||||
assetTrend, ok := stats["asset_trend"].([]map[string]interface{})
|
||||
if !ok || len(assetTrend) != 30 {
|
||||
t.Fatalf("asset trend=%#v", stats["asset_trend"])
|
||||
}
|
||||
riskTrend, ok := stats["risk_trend"].([]map[string]interface{})
|
||||
if !ok || len(riskTrend) != 30 {
|
||||
t.Fatalf("risk trend=%#v", stats["risk_trend"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetAccessFiltersOwners(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "assets-access.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now()
|
||||
if _, err := db.Exec(`INSERT INTO rbac_users (id,username,display_name,password_hash,enabled,is_builtin,created_at,updated_at) VALUES ('user-a','user-a','User A','hash',1,0,?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertAssets([]*Asset{{IP: "10.0.0.1", Port: 80, Protocol: "http"}}, "user-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, total, err := db.ListAssets(20, 0, AssetListFilter{}, RBACListAccess{UserID: "user-b", Scope: RBACScopeAssigned})
|
||||
if err != nil || total != 0 {
|
||||
t.Fatalf("unexpected cross-user assets: total=%d err=%v", total, err)
|
||||
}
|
||||
_, total, err = db.ListAssets(20, 0, AssetListFilter{}, RBACListAccess{UserID: "user-a", Scope: RBACScopeOwn})
|
||||
if err != nil || total != 1 {
|
||||
t.Fatalf("owner cannot list asset: total=%d err=%v", total, err)
|
||||
}
|
||||
assets, _, err := db.ListAssets(1, 0, AssetListFilter{}, RBACListAccess{UserID: "user-a", Scope: RBACScopeAssigned})
|
||||
if err != nil || len(assets) != 1 || !db.UserCanAccessResource("user-a", RBACScopeAssigned, "asset", assets[0].ID) {
|
||||
t.Fatalf("creator assignment missing: assets=%d err=%v", len(assets), err)
|
||||
}
|
||||
options, err := db.ListAssignableRBACResources("asset", "10.0.0.1", 10)
|
||||
if err != nil || len(options) != 1 {
|
||||
t.Fatalf("asset resource picker: options=%#v err=%v", options, err)
|
||||
}
|
||||
project, err := db.CreateProject(&Project{Name: "Alpha", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetResourceOwner("project", project.ID, "user-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset := assets[0]
|
||||
asset.ProjectID = project.ID
|
||||
if err := db.UpdateAsset(asset.ID, asset, RBACListAccess{Scope: RBACScopeAll}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectAssets, total, err := db.ListAssets(20, 0, AssetListFilter{ProjectID: project.ID}, RBACListAccess{UserID: "user-b", Scope: RBACScopeOwn})
|
||||
if err != nil || total != 1 || len(projectAssets) != 1 || projectAssets[0].ProjectName != "Alpha" {
|
||||
t.Fatalf("project-bound asset access failed: total=%d assets=%#v err=%v", total, projectAssets, err)
|
||||
}
|
||||
if !db.UserCanAccessResource("user-b", RBACScopeOwn, "asset", asset.ID) {
|
||||
t.Fatal("project owner cannot access bound asset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAssetsProjectIsAtomicAndScoped(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-batch-project.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
project, err := db.CreateProject(&Project{Name: "Batch Project", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertAssets([]*Asset{
|
||||
{IP: "192.0.2.1", Port: 80, Protocol: "http"},
|
||||
{IP: "192.0.2.2", Port: 443, Protocol: "https"},
|
||||
}, "owner-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets, _, err := db.ListAssets(10, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || len(assets) != 2 {
|
||||
t.Fatalf("list assets: len=%d err=%v", len(assets), err)
|
||||
}
|
||||
ids := []string{assets[0].ID, assets[1].ID}
|
||||
updated, err := db.UpdateAssetsProject(ids, project.ID, RBACListAccess{UserID: "owner-a", Scope: RBACScopeOwn})
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("batch bind: updated=%d err=%v", updated, err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
asset, err := db.GetAsset(id, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || asset.ProjectID != project.ID {
|
||||
t.Fatalf("asset %s was not bound: asset=%#v err=%v", id, asset, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.UpdateAssetsProject([]string{ids[0], "missing"}, "", RBACListAccess{Scope: RBACScopeAll}); err == nil {
|
||||
t.Fatal("partial batch update unexpectedly succeeded")
|
||||
}
|
||||
asset, err := db.GetAsset(ids[0], RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || asset.ProjectID != project.ID {
|
||||
t.Fatalf("failed batch changed an asset: asset=%#v err=%v", asset, err)
|
||||
}
|
||||
|
||||
updated, err = db.UpdateAssetsProject(ids, "", RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("batch unbind: updated=%d err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetAdvancedFiltersAndBulkMetadata(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-advanced.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
project, err := db.CreateProject(&Project{Name: "Production", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := []*Asset{
|
||||
{ProjectID: project.ID, Domain: "critical.example.com", Port: 443, Protocol: "https", Country: "CN", ResponsiblePerson: "Alice", Department: "Security", BusinessSystem: "Portal", Environment: "production", Criticality: "critical", Tags: []string{"internet"}},
|
||||
{ProjectID: project.ID, Domain: "dev.example.com", Port: 8080, Protocol: "http", Country: "US", Environment: "development", Criticality: "low"},
|
||||
}
|
||||
if result, err := db.UpsertAssets(input, "", true); err != nil || result.Created != 2 {
|
||||
t.Fatalf("create assets: result=%#v err=%v", result, err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("critical scan", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkAssetScanned(input[0].ID, conversation.ID, "", "", RBACListAccess{Scope: RBACScopeAll}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateVulnerability(&Vulnerability{ConversationID: conversation.ID, Title: "critical finding", Severity: "critical", Target: input[0].Domain}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
minVulns := 1
|
||||
items, total, err := db.ListAssets(20, 0, AssetListFilter{
|
||||
Status: "active", RiskLevel: "critical", MinVulnerabilities: &minVulns,
|
||||
Country: "cn", Environment: "production", Criticality: "critical",
|
||||
SortBy: "vulnerability_count", SortOrder: "desc",
|
||||
}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(items) != 1 {
|
||||
t.Fatalf("advanced query: total=%d items=%#v err=%v", total, items, err)
|
||||
}
|
||||
if items[0].ResponsiblePerson != "Alice" || items[0].BusinessSystem != "Portal" || items[0].VulnerabilityCount != 1 {
|
||||
t.Fatalf("metadata did not round-trip: %#v", items[0])
|
||||
}
|
||||
|
||||
status := "inactive"
|
||||
owner := "Bob"
|
||||
environment := "staging"
|
||||
updated, err := db.UpdateAssetsBulk([]string{input[0].ID, input[1].ID}, AssetBulkPatch{
|
||||
Status: &status, ResponsiblePerson: &owner, Environment: &environment,
|
||||
AddTags: []string{"review"}, RemoveTags: []string{"internet"},
|
||||
}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("bulk update: updated=%d err=%v", updated, err)
|
||||
}
|
||||
for _, id := range []string{input[0].ID, input[1].ID} {
|
||||
item, err := db.GetAsset(id, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Status != "inactive" || item.ResponsiblePerson != "Bob" || item.Environment != "staging" || len(item.Tags) != 1 || item.Tags[0] != "review" {
|
||||
t.Fatalf("unexpected bulk metadata: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAssetsForOperationAndBatchDelete(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-selection.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for i := 1; i <= 3; i++ {
|
||||
if _, err := db.UpsertAssets([]*Asset{{IP: "198.51.100." + strconv.Itoa(i), Port: 443, Protocol: "https", Tags: []string{"selected"}}}, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, total, err := db.ListAssetsForOperation(10, AssetListFilter{Tag: "selected"}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 3 || len(items) != 3 {
|
||||
t.Fatalf("selection: total=%d len=%d err=%v", total, len(items), err)
|
||||
}
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
deleted, err := db.DeleteAssets(ids, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || deleted != 3 {
|
||||
t.Fatalf("batch delete: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAssetsIsAtomic(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-merge.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
input := []*Asset{
|
||||
{Domain: "merge.example.com", Port: 80, Protocol: "http", Title: "Primary", Tags: []string{"one"}},
|
||||
{Domain: "merge.example.com", Port: 443, Protocol: "https", ResponsiblePerson: "Alice", Tags: []string{"two"}},
|
||||
}
|
||||
if _, err := db.UpsertAssets(input, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primary, err := db.GetAsset(input[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primary.ResponsiblePerson = "Alice"
|
||||
primary.Tags = []string{"one", "two"}
|
||||
merged, err := db.MergeAssets(primary, []string{input[1].ID}, RBACListAccess{Scope: RBACScopeAll}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || merged != 1 {
|
||||
t.Fatalf("merge: merged=%d err=%v", merged, err)
|
||||
}
|
||||
items, total, err := db.ListAssets(10, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(items) != 1 || items[0].ResponsiblePerson != "Alice" || len(items[0].Tags) != 2 {
|
||||
t.Fatalf("unexpected merged asset: total=%d items=%#v err=%v", total, items, err)
|
||||
}
|
||||
|
||||
before := items[0].Title
|
||||
items[0].Title = "Must roll back"
|
||||
if _, err := db.MergeAssets(items[0], []string{"missing"}, RBACListAccess{Scope: RBACScopeAll}, RBACListAccess{Scope: RBACScopeAll}); err == nil {
|
||||
t.Fatal("merge with missing duplicate unexpectedly succeeded")
|
||||
}
|
||||
after, err := db.GetAsset(items[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || after.Title != before {
|
||||
t.Fatalf("failed merge was not atomic: asset=%#v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-scan.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.UpsertAssets([]*Asset{{IP: "192.0.2.10", Port: 443, Protocol: "https"}}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets, _, err := db.ListAssets(10, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || len(assets) != 1 {
|
||||
t.Fatalf("list assets: len=%d err=%v", len(assets), err)
|
||||
}
|
||||
conv, err := db.CreateConversation("asset scan", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkAssetScanned(assets[0].ID, conv.ID, "", "", RBACListAccess{Scope: RBACScopeAll}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateVulnerability(&Vulnerability{ConversationID: conv.ID, Title: "finding", Severity: "high", Target: "192.0.2.10"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
||||
t.Fatalf("unexpected scan metadata: %#v", linked)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE vulnerabilities SET status='fixed' WHERE conversation_id=?`, conv.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.VulnerabilityCount != 1 || resolved.RiskLevel != "normal" {
|
||||
t.Fatalf("resolved finding should remain in history without raising current risk: %#v", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetListFlexibleFiltersAndOldestScanPagination(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-query.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
assets := []*Asset{
|
||||
{IP: "192.0.2.1", Port: 443, Protocol: "https", Source: "fofa", Tags: []string{"prod"}},
|
||||
{IP: "192.0.2.2", Port: 80, Protocol: "http", Source: "manual", Tags: []string{"prod", "legacy"}},
|
||||
{Domain: "never.example.com", Port: 443, Protocol: "https", Source: "manual", Tags: []string{"prod"}},
|
||||
}
|
||||
if _, err := db.UpsertAssets(assets, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-90 * 24 * time.Hour).UTC()
|
||||
recent := time.Now().Add(-24 * time.Hour).UTC()
|
||||
if _, err := db.Exec(`UPDATE assets SET last_scan_at=? WHERE id=?`, old, assets[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE assets SET last_scan_at=? WHERE id=?`, recent, assets[1].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
access := RBACListAccess{Scope: RBACScopeAll}
|
||||
firstPage, total, err := db.ListAssets(2, 0, AssetListFilter{Tag: "prod", SortBy: "last_scan_at", SortOrder: "asc"}, access)
|
||||
if err != nil || total != 3 || len(firstPage) != 2 {
|
||||
t.Fatalf("oldest scan page: total=%d len=%d err=%v", total, len(firstPage), err)
|
||||
}
|
||||
if firstPage[0].ID != assets[2].ID || firstPage[0].LastScanAt != nil || firstPage[1].ID != assets[0].ID {
|
||||
t.Fatalf("expected never-scanned then oldest scanned asset, got %#v", firstPage)
|
||||
}
|
||||
secondPage, _, err := db.ListAssets(2, 2, AssetListFilter{Tag: "prod", SortBy: "last_scan_at", SortOrder: "asc"}, access)
|
||||
if err != nil || len(secondPage) != 1 || secondPage[0].ID != assets[1].ID {
|
||||
t.Fatalf("unexpected second page: %#v err=%v", secondPage, err)
|
||||
}
|
||||
|
||||
never, total, err := db.ListAssets(20, 0, AssetListFilter{ScanState: "never"}, access)
|
||||
if err != nil || total != 1 || len(never) != 1 || never[0].ID != assets[2].ID {
|
||||
t.Fatalf("never-scanned filter: total=%d assets=%#v err=%v", total, never, err)
|
||||
}
|
||||
port := 443
|
||||
filtered, total, err := db.ListAssets(20, 0, AssetListFilter{Source: "fofa", Port: &port, LastScanBefore: &recent}, access)
|
||||
if err != nil || total != 1 || len(filtered) != 1 || filtered[0].ID != assets[0].ID {
|
||||
t.Fatalf("structured filters: total=%d assets=%#v err=%v", total, filtered, err)
|
||||
}
|
||||
}
|
||||
+17
-12
@@ -28,18 +28,19 @@ type AuditLog struct {
|
||||
|
||||
// ListAuditLogsFilter query parameters.
|
||||
type ListAuditLogsFilter struct {
|
||||
Actor string
|
||||
Level string
|
||||
Category string
|
||||
Action string
|
||||
Result string
|
||||
Query string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
Actor string
|
||||
Level string
|
||||
Category string
|
||||
Action string
|
||||
Result string
|
||||
Query string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
RelatedUserID string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
|
||||
@@ -73,6 +74,10 @@ func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
|
||||
conditions = append(conditions, "resource_id = ?")
|
||||
args = append(args, filter.ResourceID)
|
||||
}
|
||||
if relatedUserID := strings.TrimSpace(filter.RelatedUserID); relatedUserID != "" {
|
||||
conditions = append(conditions, `(resource_id = ? OR detail_json LIKE ? OR detail_json LIKE ?)`)
|
||||
args = append(args, relatedUserID, `%"user_id":"`+relatedUserID+`"%`, `%"userId":"`+relatedUserID+`"%`)
|
||||
}
|
||||
if filter.Since != nil {
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||
|
||||
@@ -31,6 +31,19 @@ func TestBuildAuditLogsWhere_timeFilterSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditLogsWhere_relatedUserID(t *testing.T) {
|
||||
where, args := buildAuditLogsWhere(ListAuditLogsFilter{Category: "rbac", RelatedUserID: "user-123"})
|
||||
if !strings.Contains(where, "resource_id = ?") || !strings.Contains(where, "detail_json LIKE ?") {
|
||||
t.Fatalf("expected related-user predicates, got %q", where)
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("expected category plus 3 related-user args, got %#v", args)
|
||||
}
|
||||
if args[1] != "user-123" || args[2] != `%"user_id":"user-123"%` || args[3] != `%"userId":"user-123"%` {
|
||||
t.Fatalf("unexpected related-user args: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditLogs_timeFilterMixedStorageFormats(t *testing.T) {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
|
||||
+185
-18
@@ -46,6 +46,7 @@ func validC2TextIDForDelete(id string) bool {
|
||||
// C2Listener 监听器实体
|
||||
type C2Listener struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // tcp_reverse|http_beacon|https_beacon|websocket|dns
|
||||
BindHost string `json:"bindHost"` // 默认 127.0.0.1
|
||||
@@ -165,12 +166,12 @@ func (db *DB) CreateC2Listener(l *C2Listener) error {
|
||||
l.ConfigJSON = "{}"
|
||||
}
|
||||
query := `
|
||||
INSERT INTO c2_listeners (id, name, type, bind_host, bind_port, profile_id, encryption_key,
|
||||
INSERT INTO c2_listeners (id, project_id, name, type, bind_host, bind_port, profile_id, encryption_key,
|
||||
implant_token, status, config_json, remark, owner_user_id, created_at, started_at, last_error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query,
|
||||
l.ID, l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
l.ID, strings.TrimSpace(l.ProjectID), l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.CreatedAt, l.StartedAt, l.LastError,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -190,12 +191,12 @@ func (db *DB) UpdateC2Listener(l *C2Listener) error {
|
||||
}
|
||||
query := `
|
||||
UPDATE c2_listeners SET
|
||||
name = ?, type = ?, bind_host = ?, bind_port = ?, profile_id = ?, encryption_key = ?,
|
||||
project_id = ?, name = ?, type = ?, bind_host = ?, bind_port = ?, profile_id = ?, encryption_key = ?,
|
||||
implant_token = ?, status = ?, config_json = ?, remark = ?, owner_user_id = ?, started_at = ?, last_error = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
res, err := db.Exec(query,
|
||||
l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
strings.TrimSpace(l.ProjectID), l.Name, l.Type, l.BindHost, l.BindPort, l.ProfileID, l.EncryptionKey,
|
||||
l.ImplantToken, l.Status, l.ConfigJSON, l.Remark, l.OwnerUserID, l.StartedAt, l.LastError, l.ID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -229,7 +230,7 @@ func (db *DB) SetC2ListenerStatus(id, status, lastError string, startedAt *time.
|
||||
// GetC2Listener 单条查询
|
||||
func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
||||
query := `
|
||||
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
||||
@@ -238,7 +239,7 @@ func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
||||
var l C2Listener
|
||||
var startedAt sql.NullTime
|
||||
err := db.QueryRow(query, id).Scan(
|
||||
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||
&l.ConfigJSON, &l.Remark,
|
||||
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
|
||||
@@ -259,7 +260,7 @@ func (db *DB) GetC2Listener(id string) (*C2Listener, error) {
|
||||
// ListC2Listeners 全量列表,按创建时间倒序
|
||||
func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
||||
query := `
|
||||
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
||||
@@ -275,7 +276,7 @@ func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
||||
var l C2Listener
|
||||
var startedAt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||
&l.ConfigJSON, &l.Remark,
|
||||
&l.OwnerUserID, &l.CreatedAt, &startedAt, &l.LastError,
|
||||
@@ -293,12 +294,18 @@ func (db *DB) ListC2Listeners() ([]*C2Listener, error) {
|
||||
}
|
||||
|
||||
// ListC2ListenersForAccess lists listeners visible to the resolved RBAC scope.
|
||||
func (db *DB) ListC2ListenersForAccess(access RBACListAccess) ([]*C2Listener, error) {
|
||||
func (db *DB) ListC2ListenersForAccess(access RBACListAccess, projectID string) ([]*C2Listener, error) {
|
||||
conditions := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
if projectID = strings.TrimSpace(projectID); projectID == ProjectFilterUnbound {
|
||||
conditions = append(conditions, "COALESCE(project_id, '') = ''")
|
||||
} else if projectID != "" {
|
||||
conditions = append(conditions, "COALESCE(project_id, '') = ?")
|
||||
args = append(args, projectID)
|
||||
}
|
||||
appendC2ListenerAccessFilter(&conditions, &args, access)
|
||||
query := `
|
||||
SELECT id, name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
SELECT id, COALESCE(project_id, ''), name, type, bind_host, bind_port, COALESCE(profile_id, ''),
|
||||
COALESCE(encryption_key, ''), COALESCE(implant_token, ''), status,
|
||||
COALESCE(config_json, '{}'), COALESCE(remark, ''),
|
||||
COALESCE(owner_user_id, ''), created_at, started_at, COALESCE(last_error, '')
|
||||
@@ -316,7 +323,7 @@ func (db *DB) ListC2ListenersForAccess(access RBACListAccess) ([]*C2Listener, er
|
||||
var l C2Listener
|
||||
var startedAt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.ID, &l.ProjectID, &l.Name, &l.Type, &l.BindHost, &l.BindPort, &l.ProfileID,
|
||||
&l.EncryptionKey, &l.ImplantToken, &l.Status,
|
||||
&l.ConfigJSON, &l.Remark, &l.OwnerUserID,
|
||||
&l.CreatedAt, &startedAt, &l.LastError,
|
||||
@@ -535,6 +542,7 @@ func (db *DB) queryC2SessionWhere(whereClause string, args ...interface{}) (*C2S
|
||||
// ListC2SessionsFilter 列表过滤参数
|
||||
type ListC2SessionsFilter struct {
|
||||
ListenerID string
|
||||
ProjectID string
|
||||
Status string // active|sleeping|dead|killed;空表示全部
|
||||
OS string
|
||||
Search string // 模糊匹配 hostname/username/internal_ip
|
||||
@@ -550,6 +558,18 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
|
||||
conditions = append(conditions, "listener_id = ?")
|
||||
args = append(args, filter.ListenerID)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ''
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ?
|
||||
)`)
|
||||
args = append(args, strings.TrimSpace(filter.ProjectID))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
@@ -645,6 +665,18 @@ func buildC2SessionsWhere(filter ListC2SessionsFilter) ([]string, []interface{})
|
||||
conditions = append(conditions, "listener_id = ?")
|
||||
args = append(args, filter.ListenerID)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ''
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE l.id = c2_sessions.listener_id AND COALESCE(l.project_id, '') = ?
|
||||
)`)
|
||||
args = append(args, strings.TrimSpace(filter.ProjectID))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
@@ -947,7 +979,10 @@ func (db *DB) GetC2Task(id string) (*C2Task, error) {
|
||||
// ListC2TasksFilter 任务过滤
|
||||
type ListC2TasksFilter struct {
|
||||
SessionID string
|
||||
ProjectID string
|
||||
Status string
|
||||
TaskType string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -959,10 +994,32 @@ func buildC2TasksWhere(filter ListC2TasksFilter) (where string, args []interface
|
||||
conditions = append(conditions, "session_id = ?")
|
||||
args = append(args, filter.SessionID)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_tasks.session_id AND COALESCE(l.project_id, '') = ''
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_tasks.session_id AND COALESCE(l.project_id, '') = ?
|
||||
)`)
|
||||
args = append(args, strings.TrimSpace(filter.ProjectID))
|
||||
}
|
||||
if filter.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if strings.TrimSpace(filter.TaskType) != "" {
|
||||
conditions = append(conditions, "task_type = ?")
|
||||
args = append(args, strings.TrimSpace(filter.TaskType))
|
||||
}
|
||||
if filter.Since != nil {
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||
}
|
||||
return strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
@@ -1016,6 +1073,43 @@ func (db *DB) CountC2TasksForAccess(filter ListC2TasksFilter, access RBACListAcc
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountC2TasksByStatusForAccess 与 ListC2Tasks 相同过滤条件下按状态统计
|
||||
func (db *DB) CountC2TasksByStatusForAccess(filter ListC2TasksFilter, access RBACListAccess) (map[string]int64, error) {
|
||||
where, args := buildC2TasksWhereForAccess(filter, access)
|
||||
query := `SELECT status, COUNT(*) FROM c2_tasks WHERE ` + where + ` GROUP BY status`
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
counts := map[string]int64{
|
||||
"queued": 0,
|
||||
"sent": 0,
|
||||
"running": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"cancelled": 0,
|
||||
"pending": 0,
|
||||
}
|
||||
var legacyPending int64
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var n int64
|
||||
if err := rows.Scan(&status, &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if status == "pending" {
|
||||
legacyPending = n
|
||||
continue
|
||||
}
|
||||
if _, ok := counts[status]; ok {
|
||||
counts[status] = n
|
||||
}
|
||||
}
|
||||
counts["pending"] = counts["queued"] + counts["sent"] + counts["running"] + legacyPending
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// CountC2TasksQueuedOrPending 统计 queued/pending 状态任务数(仪表盘「待审任务」)
|
||||
func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
|
||||
conditions := []string{"status IN ('queued', 'pending')"}
|
||||
@@ -1030,8 +1124,8 @@ func (db *DB) CountC2TasksQueuedOrPending(sessionID string) (int64, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID string, access RBACListAccess) (int64, error) {
|
||||
filter := ListC2TasksFilter{SessionID: sessionID}
|
||||
func (db *DB) CountC2TasksQueuedOrPendingForAccess(sessionID, projectID string, access RBACListAccess) (int64, error) {
|
||||
filter := ListC2TasksFilter{SessionID: sessionID, ProjectID: projectID}
|
||||
where, args := buildC2TasksWhereForAccess(filter, access)
|
||||
query := `SELECT COUNT(*) FROM c2_tasks WHERE status IN ('queued', 'pending') AND ` + where
|
||||
var n int64
|
||||
@@ -1387,7 +1481,9 @@ func (db *DB) AppendC2Event(e *C2Event) error {
|
||||
return errors.New("event id is required")
|
||||
}
|
||||
if e.CreatedAt.IsZero() {
|
||||
e.CreatedAt = time.Now()
|
||||
e.CreatedAt = time.Now().UTC()
|
||||
} else {
|
||||
e.CreatedAt = e.CreatedAt.UTC()
|
||||
}
|
||||
if strings.TrimSpace(e.Level) == "" {
|
||||
e.Level = "info"
|
||||
@@ -1402,7 +1498,7 @@ func (db *DB) AppendC2Event(e *C2Event) error {
|
||||
INSERT INTO c2_events (id, level, category, session_id, task_id, message, data_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query, e.ID, e.Level, e.Category, e.SessionID, e.TaskID, e.Message, dataJSON, e.CreatedAt)
|
||||
_, err := db.Exec(query, e.ID, e.Level, e.Category, e.SessionID, e.TaskID, e.Message, dataJSON, formatSQLiteUTC(e.CreatedAt))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1410,6 +1506,7 @@ func (db *DB) AppendC2Event(e *C2Event) error {
|
||||
type ListC2EventsFilter struct {
|
||||
Level string
|
||||
Category string
|
||||
ProjectID string
|
||||
SessionID string
|
||||
TaskID string
|
||||
Since *time.Time
|
||||
@@ -1428,6 +1525,49 @@ func buildC2EventsWhere(filter ListC2EventsFilter) (where string, args []interfa
|
||||
conditions = append(conditions, "category = ?")
|
||||
args = append(args, filter.Category)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProjectID) == ProjectFilterUnbound {
|
||||
conditions = append(conditions, `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_events.session_id AND COALESCE(l.project_id, '') = ''
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_tasks t
|
||||
JOIN c2_sessions s ON s.id = t.session_id
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE t.id = c2_events.task_id AND COALESCE(l.project_id, '') = ''
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE json_valid(c2_events.data_json)
|
||||
AND l.id = json_extract(c2_events.data_json, '$.listener_id')
|
||||
AND COALESCE(l.project_id, '') = ''
|
||||
)
|
||||
)`)
|
||||
} else if strings.TrimSpace(filter.ProjectID) != "" {
|
||||
conditions = append(conditions, `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM c2_sessions s
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE s.id = c2_events.session_id AND COALESCE(l.project_id, '') = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_tasks t
|
||||
JOIN c2_sessions s ON s.id = t.session_id
|
||||
JOIN c2_listeners l ON l.id = s.listener_id
|
||||
WHERE t.id = c2_events.task_id AND COALESCE(l.project_id, '') = ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM c2_listeners l
|
||||
WHERE json_valid(c2_events.data_json)
|
||||
AND l.id = json_extract(c2_events.data_json, '$.listener_id')
|
||||
AND COALESCE(l.project_id, '') = ?
|
||||
)
|
||||
)`)
|
||||
pid := strings.TrimSpace(filter.ProjectID)
|
||||
args = append(args, pid, pid, pid)
|
||||
}
|
||||
if filter.SessionID != "" {
|
||||
conditions = append(conditions, "session_id = ?")
|
||||
args = append(args, filter.SessionID)
|
||||
@@ -1437,8 +1577,8 @@ func buildC2EventsWhere(filter ListC2EventsFilter) (where string, args []interfa
|
||||
args = append(args, filter.TaskID)
|
||||
}
|
||||
if filter.Since != nil {
|
||||
conditions = append(conditions, "created_at >= ?")
|
||||
args = append(args, *filter.Since)
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||
}
|
||||
return strings.Join(conditions, " AND "), args
|
||||
}
|
||||
@@ -1510,6 +1650,33 @@ func (db *DB) CountC2EventsForAccess(filter ListC2EventsFilter, access RBACListA
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CountC2EventsByLevelForAccess 与 ListC2Events 相同过滤条件下按级别统计
|
||||
func (db *DB) CountC2EventsByLevelForAccess(filter ListC2EventsFilter, access RBACListAccess) (map[string]int64, error) {
|
||||
where, args := buildC2EventsWhereForAccess(filter, access)
|
||||
query := `SELECT level, COUNT(*) FROM c2_events WHERE ` + where + ` GROUP BY level`
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
counts := map[string]int64{
|
||||
"info": 0,
|
||||
"warn": 0,
|
||||
"critical": 0,
|
||||
}
|
||||
for rows.Next() {
|
||||
var level string
|
||||
var n int64
|
||||
if err := rows.Scan(&level, &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := counts[level]; ok {
|
||||
counts[level] = n
|
||||
}
|
||||
}
|
||||
return counts, rows.Err()
|
||||
}
|
||||
|
||||
// ListC2Events 事件查询,按创建时间倒序
|
||||
func (db *DB) ListC2Events(filter ListC2EventsFilter) ([]*C2Event, error) {
|
||||
where, args := buildC2EventsWhere(filter)
|
||||
|
||||
@@ -22,6 +22,8 @@ type Conversation struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
RoleName string `json:"roleName,omitempty"`
|
||||
AgentMode string `json:"agentMode,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
@@ -57,29 +59,31 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
roleName := normalizeConversationRoleName(meta.RoleName)
|
||||
agentMode := normalizeConversationAgentMode(meta.AgentMode)
|
||||
|
||||
var err error
|
||||
wsID := strings.TrimSpace(webshellConnectionID)
|
||||
switch {
|
||||
case wsID != "" && projectID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, projectID,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, projectID, roleName, agentMode,
|
||||
)
|
||||
case wsID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, roleName, agentMode,
|
||||
)
|
||||
case projectID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, now, now, projectID,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, projectID, roleName, agentMode,
|
||||
)
|
||||
default:
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
id, title, now, now,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, roleName, agentMode,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +94,8 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
||||
ID: id,
|
||||
Title: title,
|
||||
ProjectID: projectID,
|
||||
RoleName: roleName,
|
||||
AgentMode: agentMode,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -236,10 +242,12 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
var pinned int
|
||||
|
||||
var projectID sql.NullString
|
||||
var roleName sql.NullString
|
||||
var agentMode sql.NullString
|
||||
err := db.QueryRow(
|
||||
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?",
|
||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||
id,
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
@@ -249,6 +257,12 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
if agentMode.Valid {
|
||||
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
@@ -322,10 +336,12 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
var pinned int
|
||||
|
||||
var projectID sql.NullString
|
||||
var roleName sql.NullString
|
||||
var agentMode sql.NullString
|
||||
err := db.QueryRow(
|
||||
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?",
|
||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||
id,
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
@@ -335,6 +351,12 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
if agentMode.Valid {
|
||||
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
@@ -365,6 +387,49 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
func normalizeConversationRoleName(roleName string) string {
|
||||
roleName = strings.TrimSpace(roleName)
|
||||
if roleName == "" {
|
||||
return "默认"
|
||||
}
|
||||
return roleName
|
||||
}
|
||||
|
||||
func normalizeConversationAgentMode(agentMode string) string {
|
||||
agentMode = strings.ToLower(strings.TrimSpace(agentMode))
|
||||
agentMode = strings.ReplaceAll(agentMode, "-", "_")
|
||||
switch agentMode {
|
||||
case "deep", "plan_execute", "supervisor":
|
||||
return agentMode
|
||||
default:
|
||||
return "eino_single"
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
||||
roleName = normalizeConversationRoleName(roleName)
|
||||
_, err := db.Exec(
|
||||
"UPDATE conversations SET role_name = ?, updated_at = ? WHERE id = ?",
|
||||
roleName, time.Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新对话角色失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SetConversationAgentMode(id, agentMode string) error {
|
||||
agentMode = normalizeConversationAgentMode(agentMode)
|
||||
_, err := db.Exec(
|
||||
"UPDATE conversations SET agent_mode = ? WHERE id = ?",
|
||||
agentMode, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新对话模式失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func conversationProjectIDColumn(alias string) string {
|
||||
if alias != "" {
|
||||
return alias + ".project_id"
|
||||
@@ -489,7 +554,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
args = append(args, limit, offset)
|
||||
rows, err = db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||
FROM conversations c`+where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -505,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
rows, err = db.Query(
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||
args...,
|
||||
)
|
||||
}
|
||||
@@ -514,45 +579,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
||||
return nil, fmt.Errorf("查询对话列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []*Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var projectID sql.NullString
|
||||
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
|
||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||
}
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
}
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
}
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
|
||||
conv.Pinned = pinned != 0
|
||||
|
||||
conversations = append(conversations, &conv)
|
||||
}
|
||||
|
||||
return conversations, nil
|
||||
return scanConversationRows(rows)
|
||||
}
|
||||
|
||||
func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, projectID, userID, scope string) ([]*Conversation, error) {
|
||||
@@ -571,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||
args = append(args, limit, offset)
|
||||
rows, err = db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||
FROM conversations c`+where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`, args...)
|
||||
@@ -586,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
rows, err = db.Query(
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||
args...)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -603,12 +630,20 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var projectID sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
|
||||
var roleName sql.NullString
|
||||
var agentMode sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode); err != nil {
|
||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||
}
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
if agentMode.Valid {
|
||||
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||
}
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
@@ -668,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
args = append(args, limit, offset)
|
||||
rows, err := db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||
where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -678,43 +713,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
||||
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []*Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var projectID sql.NullString
|
||||
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
|
||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||
}
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
}
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
}
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
|
||||
conv.Pinned = pinned != 0
|
||||
conversations = append(conversations, &conv)
|
||||
}
|
||||
|
||||
return conversations, rows.Err()
|
||||
return scanConversationRows(rows)
|
||||
}
|
||||
|
||||
func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, projectID, userID, scope string) ([]*Conversation, error) {
|
||||
@@ -728,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
|
||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||
args = append(args, limit, offset)
|
||||
rows, err := db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||
where+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -861,6 +860,24 @@ func (db *DB) einoReductionBaseDir() string {
|
||||
return filepath.Join("tmp", "reduction")
|
||||
}
|
||||
|
||||
// EinoReductionBaseDir returns the configured reduction cache root.
|
||||
func (db *DB) EinoReductionBaseDir() string {
|
||||
return db.einoReductionBaseDir()
|
||||
}
|
||||
|
||||
// ConversationArtifactsBaseDir returns the conversation-scoped artifacts root.
|
||||
func (db *DB) ConversationArtifactsBaseDir() string {
|
||||
if db == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(db.conversationArtifactsDir)
|
||||
}
|
||||
|
||||
// EinoWorkspaceBaseDir returns the configured agent workspace root.
|
||||
func (db *DB) EinoWorkspaceBaseDir() string {
|
||||
return db.einoWorkspaceBaseDir()
|
||||
}
|
||||
|
||||
func (db *DB) einoWorkspaceBaseDir() string {
|
||||
if db == nil {
|
||||
return ""
|
||||
@@ -1445,7 +1462,8 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
seenExecIDs := make(map[string]bool)
|
||||
// A provider may reuse a fallback toolCallId across streaming rounds. Keep a
|
||||
// FIFO per ID instead of a single index so every persisted call gets at most
|
||||
// one result. Results without an ID fall back to the oldest unmatched call.
|
||||
// one result. Results without a stable ID are kept separate instead of being
|
||||
// guessed by order; showing no link is safer than linking to the wrong tool.
|
||||
toolIndexesByCallID := make(map[string][]int)
|
||||
lastMatchedToolIndexByCallID := make(map[string]int)
|
||||
matchedToolIndexes := make([]bool, 0)
|
||||
@@ -1520,7 +1538,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
if idx < 0 && toolCallID != "" {
|
||||
for nextUnmatchedToolIdx < len(matchedToolIndexes) && matchedToolIndexes[nextUnmatchedToolIdx] {
|
||||
nextUnmatchedToolIdx++
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ type ConversationCreateMeta struct {
|
||||
Source string
|
||||
WebShellConnectionID string
|
||||
ProjectID string
|
||||
RoleName string
|
||||
AgentMode string
|
||||
ClientIP string
|
||||
SessionHint string
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ type DB struct {
|
||||
checkpointDone chan struct{}
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
vulnerabilityCreatedHook func(*Vulnerability)
|
||||
}
|
||||
|
||||
// startPassiveCheckpointLoop 启动后台 PASSIVE checkpoint 循环。
|
||||
@@ -182,6 +183,8 @@ func (db *DB) initTables() error {
|
||||
title TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
role_name TEXT NOT NULL DEFAULT '默认',
|
||||
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||
last_react_input TEXT,
|
||||
last_react_output TEXT
|
||||
);`
|
||||
@@ -225,6 +228,10 @@ func (db *DB) initTables() error {
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME,
|
||||
duration_ms INTEGER,
|
||||
partial_output TEXT,
|
||||
partial_output_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
partial_output_truncated INTEGER NOT NULL DEFAULT 0,
|
||||
partial_output_updated_at DATETIME,
|
||||
owner_user_id TEXT,
|
||||
conversation_id TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -404,6 +411,48 @@ func (db *DB) initTables() error {
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
|
||||
);`
|
||||
|
||||
createAssetsTable := `
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
dedup_key TEXT NOT NULL UNIQUE, project_id TEXT,
|
||||
host TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', port INTEGER NOT NULL DEFAULT 0,
|
||||
domain TEXT NOT NULL DEFAULT '', protocol TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
|
||||
server TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT '', province TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '',
|
||||
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
|
||||
environment TEXT NOT NULL DEFAULT '', criticality TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'manual', source_query TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||
);`
|
||||
|
||||
createVulnerabilityAlertSubscriptionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS vulnerability_alert_subscriptions (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
min_severity TEXT NOT NULL DEFAULT 'high',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES rbac_users(id) ON DELETE CASCADE
|
||||
);`
|
||||
createVulnerabilityAlertDeliveriesTable := `
|
||||
CREATE TABLE IF NOT EXISTS vulnerability_alert_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vulnerability_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
external_user_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(vulnerability_id, platform, external_user_id),
|
||||
FOREIGN KEY (vulnerability_id) REFERENCES vulnerabilities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES rbac_users(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
// 创建批量任务队列表
|
||||
createBatchTaskQueuesTable := `
|
||||
CREATE TABLE IF NOT EXISTS batch_task_queues (
|
||||
@@ -446,6 +495,7 @@ func (db *DB) initTables() error {
|
||||
createWebshellConnectionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS webshell_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
url TEXT NOT NULL,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
type TEXT NOT NULL DEFAULT 'php',
|
||||
@@ -472,6 +522,7 @@ func (db *DB) initTables() error {
|
||||
createC2ListenersTable := `
|
||||
CREATE TABLE IF NOT EXISTS c2_listeners (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
bind_host TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||
@@ -639,6 +690,28 @@ func (db *DB) initTables() error {
|
||||
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
createWorkflowPackageInspectionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_package_inspections (
|
||||
id TEXT PRIMARY KEY, package_hash TEXT NOT NULL, manifest_json TEXT NOT NULL,
|
||||
workflow_payload_json TEXT NOT NULL, inspection_json TEXT NOT NULL,
|
||||
source_workflow_id TEXT NOT NULL, source_revision INTEGER NOT NULL,
|
||||
source_content_hash TEXT NOT NULL, source_graph_hash TEXT NOT NULL,
|
||||
local_conflict_state TEXT NOT NULL CHECK (local_conflict_state IN ('none','identical','id_conflict')),
|
||||
local_workflow_id TEXT, local_content_hash TEXT, local_graph_hash TEXT,
|
||||
created_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ready' CHECK (status IN ('ready','consumed','expired')),
|
||||
created_at DATETIME NOT NULL, expires_at DATETIME NOT NULL, consumed_at DATETIME
|
||||
);`
|
||||
createWorkflowPackageImportsTable := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_package_imports (
|
||||
id TEXT PRIMARY KEY, inspection_id TEXT NOT NULL, request_hash TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL, actor_user_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('create','keep_existing','overwrite','rename')),
|
||||
source_workflow_id TEXT NOT NULL, target_workflow_id TEXT NOT NULL, resulting_workflow_id TEXT,
|
||||
result TEXT NOT NULL CHECK (result IN ('created','overwritten','renamed','kept_existing','skipped_identical','failed')),
|
||||
error_code TEXT, error_message TEXT, created_at DATETIME NOT NULL, applied_at DATETIME,
|
||||
FOREIGN KEY (inspection_id) REFERENCES workflow_package_inspections(id)
|
||||
);`
|
||||
|
||||
// 创建索引
|
||||
createIndexes := `
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||
@@ -665,6 +738,13 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity);
|
||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_last_seen ON assets(last_seen_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_last_scan ON assets(last_scan_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_ip ON assets(ip);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_domain ON assets(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||
@@ -679,8 +759,10 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_webshell_connections_created_at ON webshell_connections(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_webshell_connections_project_id ON webshell_connections(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webshell_connection_states_updated_at ON webshell_connection_states(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_listeners_created_at ON c2_listeners(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_listeners_project_id ON c2_listeners(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_listeners_status ON c2_listeners(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_sessions_listener ON c2_sessions(listener_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_c2_sessions_status ON c2_sessions(status);
|
||||
@@ -703,6 +785,9 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON workflow_runs(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run ON workflow_node_runs(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_package_inspections_creator_expiry ON workflow_package_inspections(created_by, expires_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_actor_key ON workflow_package_imports(actor_user_id, idempotency_key);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_workflow_package_imports_inspection_success ON workflow_package_imports(inspection_id) WHERE result IN ('created','overwritten','renamed','kept_existing','skipped_identical');
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createConversationsTable); err != nil {
|
||||
@@ -770,6 +855,12 @@ func (db *DB) initTables() error {
|
||||
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(createAssetsTable); err != nil {
|
||||
return fmt.Errorf("创建assets表失败: %w", err)
|
||||
}
|
||||
if err := db.migrateAssetsTable(); err != nil {
|
||||
return fmt.Errorf("迁移assets表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createBatchTaskQueuesTable); err != nil {
|
||||
return fmt.Errorf("创建batch_task_queues表失败: %w", err)
|
||||
@@ -794,11 +885,19 @@ func (db *DB) initTables() error {
|
||||
if err := db.initRBACTables(); err != nil {
|
||||
return fmt.Errorf("创建RBAC表失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(createVulnerabilityAlertSubscriptionsTable); err != nil {
|
||||
return fmt.Errorf("创建漏洞提醒订阅表失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(createVulnerabilityAlertDeliveriesTable); err != nil {
|
||||
return fmt.Errorf("创建漏洞提醒投递表失败: %w", err)
|
||||
}
|
||||
|
||||
for tableName, ddl := range map[string]string{
|
||||
"workflow_definitions": createWorkflowDefinitionsTable,
|
||||
"workflow_runs": createWorkflowRunsTable,
|
||||
"workflow_node_runs": createWorkflowNodeRunsTable,
|
||||
"workflow_definitions": createWorkflowDefinitionsTable,
|
||||
"workflow_runs": createWorkflowRunsTable,
|
||||
"workflow_node_runs": createWorkflowNodeRunsTable,
|
||||
"workflow_package_inspections": createWorkflowPackageInspectionsTable,
|
||||
"workflow_package_imports": createWorkflowPackageImportsTable,
|
||||
} {
|
||||
if _, err := db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("创建%s表失败: %w", tableName, err)
|
||||
@@ -862,9 +961,15 @@ func (db *DB) initTables() error {
|
||||
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
|
||||
// 不返回错误,允许继续运行
|
||||
}
|
||||
if err := db.migrateC2ListenersTable(); err != nil {
|
||||
db.logger.Warn("迁移c2_listeners表失败", zap.Error(err))
|
||||
}
|
||||
if err := db.migrateWorkflowRunsTable(); err != nil {
|
||||
db.logger.Warn("迁移workflow_runs表失败", zap.Error(err))
|
||||
}
|
||||
if err := db.migrateToolExecutionsPartialOutputColumns(); err != nil {
|
||||
db.logger.Warn("迁移tool_executions partial output字段失败", zap.Error(err))
|
||||
}
|
||||
if err := db.migrateRBACOwnershipColumns(); err != nil {
|
||||
db.logger.Warn("迁移RBAC资源归属字段失败", zap.Error(err))
|
||||
}
|
||||
@@ -889,6 +994,54 @@ func (db *DB) migrateRobotUserSessionsTable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) migrateToolExecutionsPartialOutputColumns() error {
|
||||
for _, col := range []struct {
|
||||
name string
|
||||
stmt string
|
||||
}{
|
||||
{"partial_output", "ALTER TABLE tool_executions ADD COLUMN partial_output TEXT"},
|
||||
{"partial_output_bytes", "ALTER TABLE tool_executions ADD COLUMN partial_output_bytes INTEGER NOT NULL DEFAULT 0"},
|
||||
{"partial_output_truncated", "ALTER TABLE tool_executions ADD COLUMN partial_output_truncated INTEGER NOT NULL DEFAULT 0"},
|
||||
{"partial_output_updated_at", "ALTER TABLE tool_executions ADD COLUMN partial_output_updated_at DATETIME"},
|
||||
} {
|
||||
if err := db.addColumnIfMissing("tool_executions", col.name, col.stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateAssetsTable keeps databases created by the first asset-management release compatible.
|
||||
func (db *DB) migrateAssetsTable() error {
|
||||
columns := []struct {
|
||||
name string
|
||||
ddl string
|
||||
}{
|
||||
{"project_id", "ALTER TABLE assets ADD COLUMN project_id TEXT"},
|
||||
{"last_scan_at", "ALTER TABLE assets ADD COLUMN last_scan_at DATETIME"},
|
||||
{"last_scan_conversation_id", "ALTER TABLE assets ADD COLUMN last_scan_conversation_id TEXT NOT NULL DEFAULT ''"},
|
||||
{"last_scan_queue_id", "ALTER TABLE assets ADD COLUMN last_scan_queue_id TEXT NOT NULL DEFAULT ''"},
|
||||
{"last_scan_task_id", "ALTER TABLE assets ADD COLUMN last_scan_task_id TEXT NOT NULL DEFAULT ''"},
|
||||
{"responsible_person", "ALTER TABLE assets ADD COLUMN responsible_person TEXT NOT NULL DEFAULT ''"},
|
||||
{"department", "ALTER TABLE assets ADD COLUMN department TEXT NOT NULL DEFAULT ''"},
|
||||
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
||||
{"environment", "ALTER TABLE assets ADD COLUMN environment TEXT NOT NULL DEFAULT ''"},
|
||||
{"criticality", "ALTER TABLE assets ADD COLUMN criticality TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('assets') WHERE name=?", column.name).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
if _, err := db.Exec(column.ddl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateMessagesTable 迁移 messages 表,补充 updated_at 字段。
|
||||
// 语义:updated_at 表示该条消息最后一次被写入/更新的时间(例如助手占位消息在任务结束时更新正文)。
|
||||
func (db *DB) migrateMessagesTable() error {
|
||||
@@ -1007,6 +1160,36 @@ func (db *DB) migrateConversationsTable() error {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 role_name 字段是否存在(对话绑定的业务角色,用于历史任务切换时恢复角色上下文)
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='role_name'").Scan(&count)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN role_name TEXT NOT NULL DEFAULT '默认'"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加role_name字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if count == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN role_name TEXT NOT NULL DEFAULT '默认'"); err != nil {
|
||||
db.logger.Warn("添加role_name字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 agent_mode 字段是否存在(对话绑定的执行模式,用于历史任务切换时恢复对话模式)
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='agent_mode'").Scan(&count)
|
||||
if err != nil {
|
||||
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
|
||||
errMsg := strings.ToLower(addErr.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||
}
|
||||
}
|
||||
} else if count == 0 {
|
||||
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
|
||||
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1450,6 +1633,7 @@ func (db *DB) migrateWebshellConnectionsTable() error {
|
||||
name string
|
||||
stmt string
|
||||
}{
|
||||
{name: "project_id", stmt: "ALTER TABLE webshell_connections ADD COLUMN project_id TEXT"},
|
||||
{name: "encoding", stmt: "ALTER TABLE webshell_connections ADD COLUMN encoding TEXT NOT NULL DEFAULT ''"},
|
||||
{name: "os", stmt: "ALTER TABLE webshell_connections ADD COLUMN os TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
@@ -1475,6 +1659,10 @@ func (db *DB) migrateWebshellConnectionsTable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) migrateC2ListenersTable() error {
|
||||
return db.addColumnIfMissing("c2_listeners", "project_id", "ALTER TABLE c2_listeners ADD COLUMN project_id TEXT")
|
||||
}
|
||||
|
||||
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
|
||||
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
|
||||
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
|
||||
|
||||
@@ -43,11 +43,19 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
|
||||
if exec.Duration > 0 {
|
||||
durationMs = sql.NullInt64{Int64: exec.Duration.Milliseconds(), Valid: true}
|
||||
}
|
||||
var partialUpdatedAt sql.NullTime
|
||||
if exec.PartialOutputUpdatedAt != nil {
|
||||
partialUpdatedAt = sql.NullTime{Time: *exec.PartialOutputUpdatedAt, Valid: true}
|
||||
}
|
||||
partialTruncated := 0
|
||||
if exec.PartialOutputTruncated {
|
||||
partialTruncated = 1
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT OR REPLACE INTO tool_executions
|
||||
(id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, owner_user_id, conversation_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, partial_output, partial_output_bytes, partial_output_truncated, partial_output_updated_at, owner_user_id, conversation_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err = db.Exec(query,
|
||||
@@ -60,6 +68,10 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
|
||||
exec.StartTime,
|
||||
endTime,
|
||||
durationMs,
|
||||
sqlNullString(exec.PartialOutput),
|
||||
exec.PartialOutputBytes,
|
||||
partialTruncated,
|
||||
partialUpdatedAt,
|
||||
strings.TrimSpace(exec.OwnerUserID),
|
||||
strings.TrimSpace(exec.ConversationID),
|
||||
time.Now(),
|
||||
@@ -90,6 +102,13 @@ func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error
|
||||
return err
|
||||
}
|
||||
|
||||
func sqlNullString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// CountToolExecutions 统计工具执行记录总数
|
||||
func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
|
||||
return db.CountToolExecutionsForAccess(status, toolName, RBACListAccess{Scope: RBACScopeAll})
|
||||
@@ -267,7 +286,8 @@ type ToolStatsSummaryResult struct {
|
||||
TopTools []*mcp.ToolStats
|
||||
}
|
||||
|
||||
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)
|
||||
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)。
|
||||
// 监控页的失败口径只包含真实失败/异常终止;用户主动取消的 cancelled 保留在总调用中,不计入失败。
|
||||
func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
if topN <= 0 {
|
||||
topN = 6
|
||||
@@ -282,19 +302,19 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
|
||||
summaryQuery := `
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(total_calls), 0),
|
||||
COALESCE(SUM(success_calls), 0),
|
||||
COALESCE(SUM(failed_calls), 0),
|
||||
MAX(last_call_time)
|
||||
FROM tool_stats
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
|
||||
MAX(start_time),
|
||||
COUNT(DISTINCT tool_name)
|
||||
FROM tool_executions
|
||||
`
|
||||
var lastCallRaw sql.NullString
|
||||
err := db.QueryRow(summaryQuery).Scan(
|
||||
&result.Summary.ToolCount,
|
||||
&result.Summary.TotalCalls,
|
||||
&result.Summary.SuccessCalls,
|
||||
&result.Summary.FailedCalls,
|
||||
&lastCallRaw,
|
||||
&result.Summary.ToolCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -310,9 +330,13 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
}
|
||||
|
||||
topQuery := `
|
||||
SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time
|
||||
FROM tool_stats
|
||||
WHERE total_calls > 0
|
||||
SELECT tool_name,
|
||||
COUNT(*) AS total_calls,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS success_calls,
|
||||
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed_calls,
|
||||
MAX(start_time) AS last_call_time
|
||||
FROM tool_executions
|
||||
GROUP BY tool_name
|
||||
ORDER BY total_calls DESC, tool_name ASC
|
||||
LIMIT ?
|
||||
`
|
||||
@@ -324,7 +348,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
|
||||
for rows.Next() {
|
||||
var stat mcp.ToolStats
|
||||
var lastCallTime sql.NullTime
|
||||
var lastCallTime sql.NullString
|
||||
if err := rows.Scan(
|
||||
&stat.ToolName,
|
||||
&stat.TotalCalls,
|
||||
@@ -336,7 +360,8 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
continue
|
||||
}
|
||||
if lastCallTime.Valid {
|
||||
stat.LastCallTime = &lastCallTime.Time
|
||||
parsed := parseDBTime(lastCallTime.String)
|
||||
stat.LastCallTime = &parsed
|
||||
}
|
||||
result.TopTools = append(result.TopTools, &stat)
|
||||
}
|
||||
@@ -359,7 +384,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
|
||||
var lastCall sql.NullString
|
||||
err := db.QueryRow(`SELECT COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
|
||||
MAX(start_time), COUNT(DISTINCT tool_name)`+fromSQL, args...).Scan(
|
||||
&result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls,
|
||||
&lastCall, &result.Summary.ToolCount,
|
||||
@@ -373,7 +398,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
|
||||
}
|
||||
rows, err := db.Query(`SELECT tool_name, COUNT(*),
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END), MAX(start_time)`+
|
||||
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), MAX(start_time)`+
|
||||
fromSQL+` GROUP BY tool_name ORDER BY COUNT(*) DESC, tool_name ASC LIMIT ?`, append(args, topN)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -482,7 +507,9 @@ func appendToolExecutionAccessSQL(query string, args []interface{}, access RBACL
|
||||
// GetToolExecution 根据ID获取单条工具执行记录
|
||||
func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
query := `
|
||||
SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, COALESCE(owner_user_id, ''), COALESCE(conversation_id, '')
|
||||
SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms,
|
||||
COALESCE(partial_output, ''), COALESCE(partial_output_bytes, 0), COALESCE(partial_output_truncated, 0), partial_output_updated_at,
|
||||
COALESCE(owner_user_id, ''), COALESCE(conversation_id, '')
|
||||
FROM tool_executions
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -495,6 +522,8 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
var errorText sql.NullString
|
||||
var endTime sql.NullTime
|
||||
var durationMs sql.NullInt64
|
||||
var partialTruncated int
|
||||
var partialUpdatedAt sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&exec.ID,
|
||||
@@ -506,6 +535,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
&exec.StartTime,
|
||||
&endTime,
|
||||
&durationMs,
|
||||
&exec.PartialOutput,
|
||||
&exec.PartialOutputBytes,
|
||||
&partialTruncated,
|
||||
&partialUpdatedAt,
|
||||
&exec.OwnerUserID,
|
||||
&exec.ConversationID,
|
||||
)
|
||||
@@ -538,6 +571,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
if durationMs.Valid {
|
||||
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
|
||||
}
|
||||
exec.PartialOutputTruncated = partialTruncated != 0
|
||||
if partialUpdatedAt.Valid {
|
||||
exec.PartialOutputUpdatedAt = &partialUpdatedAt.Time
|
||||
}
|
||||
|
||||
return &exec, nil
|
||||
}
|
||||
@@ -565,7 +602,7 @@ func (db *DB) UserCanAccessToolExecution(userID, scope, executionID string) bool
|
||||
return conversation != "" && db.UserCanAccessResource(userID, scope, "conversation", conversation)
|
||||
}
|
||||
|
||||
// CancelOrphanedRunningToolExecutions 将仍为 running 的记录批量标记为 cancelled(如进程重启后无对应执行协程)。
|
||||
// CancelOrphanedRunningToolExecutions 将仍为 running 的记录批量标记为 orphaned(如进程重启后无对应执行协程)。
|
||||
func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg string) (int64, error) {
|
||||
errMsg = strings.TrimSpace(errMsg)
|
||||
if errMsg == "" {
|
||||
@@ -573,7 +610,7 @@ func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg stri
|
||||
}
|
||||
query := `
|
||||
UPDATE tool_executions
|
||||
SET status = 'cancelled',
|
||||
SET status = 'orphaned',
|
||||
error = ?,
|
||||
end_time = ?,
|
||||
duration_ms = MAX(0, CAST((julianday(?) - julianday(start_time)) * 86400000 AS INTEGER))
|
||||
@@ -586,7 +623,7 @@ func (db *DB) CancelOrphanedRunningToolExecutions(endTime time.Time, errMsg stri
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// FinalizeStaleRunningToolExecutions 将「非活跃且超过 minAge」的 running 记录标记为 cancelled。
|
||||
// FinalizeStaleRunningToolExecutions 将「非活跃且超过 minAge」的 running 记录标记为 orphaned。
|
||||
// activeIDs 为当前进程内仍登记 cancel 的 executionId;不在集合内且已超时的视为孤儿记录。
|
||||
func (db *DB) FinalizeStaleRunningToolExecutions(endTime time.Time, minAge time.Duration, activeIDs map[string]struct{}, errMsg string) (int64, error) {
|
||||
errMsg = strings.TrimSpace(errMsg)
|
||||
@@ -639,7 +676,7 @@ func (db *DB) FinalizeStaleRunningToolExecutions(endTime time.Time, minAge time.
|
||||
}
|
||||
res, err := db.Exec(`
|
||||
UPDATE tool_executions
|
||||
SET status = 'cancelled', error = ?, end_time = ?, duration_ms = ?
|
||||
SET status = 'orphaned', error = ?, end_time = ?, duration_ms = ?
|
||||
WHERE id = ? AND status = 'running'
|
||||
`, errMsg, endTime, durationMs, row.id)
|
||||
if err != nil {
|
||||
@@ -815,7 +852,7 @@ func (db *DB) PurgeToolExecutionsBefore(cutoff time.Time) (int64, error) {
|
||||
}
|
||||
delta.totalCalls += count
|
||||
switch status {
|
||||
case "failed", "cancelled":
|
||||
case "failed", "hard_timeout", "orphaned":
|
||||
delta.failedCalls += count
|
||||
case "completed":
|
||||
delta.successCalls += count
|
||||
@@ -971,7 +1008,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
|
||||
query = `
|
||||
SELECT date(start_time, 'localtime') AS bucket,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
|
||||
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
|
||||
FROM tool_executions
|
||||
WHERE start_time >= ?
|
||||
GROUP BY bucket
|
||||
@@ -981,7 +1018,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
|
||||
query = `
|
||||
SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN status IN ('failed', 'cancelled') THEN 1 ELSE 0 END) AS failed
|
||||
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
|
||||
FROM tool_executions
|
||||
WHERE start_time >= ?
|
||||
GROUP BY bucket
|
||||
|
||||
@@ -43,8 +43,8 @@ func TestCancelOrphanedRunningToolExecutions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution: %v", err)
|
||||
}
|
||||
if got.Status != "cancelled" {
|
||||
t.Fatalf("expected cancelled, got %s", got.Status)
|
||||
if got.Status != "orphaned" {
|
||||
t.Fatalf("expected orphaned, got %s", got.Status)
|
||||
}
|
||||
if got.EndTime == nil {
|
||||
t.Fatal("expected end_time to be set")
|
||||
@@ -88,8 +88,8 @@ func TestFinalizeStaleRunningToolExecutions_skipsActive(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution stale: %v", err)
|
||||
}
|
||||
if stale.Status != "cancelled" {
|
||||
t.Fatalf("stale expected cancelled, got %s", stale.Status)
|
||||
if stale.Status != "orphaned" {
|
||||
t.Fatalf("stale expected orphaned, got %s", stale.Status)
|
||||
}
|
||||
|
||||
activeExec, err := db.GetToolExecution("active")
|
||||
|
||||
@@ -84,3 +84,49 @@ func TestLoadToolStatsSummaryAndListPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadToolStatsSummaryDoesNotCountCancelledAsFailed(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor-cancelled-summary.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now()
|
||||
for i, status := range []string{"completed", "cancelled", "failed"} {
|
||||
exec := &mcp.ToolExecution{
|
||||
ID: fmt.Sprintf("exec-%d", i),
|
||||
ToolName: "exec",
|
||||
Arguments: map[string]interface{}{},
|
||||
Status: status,
|
||||
StartTime: now.Add(time.Duration(i) * time.Second),
|
||||
}
|
||||
end := exec.StartTime.Add(time.Second)
|
||||
exec.EndTime = &end
|
||||
exec.Duration = time.Second
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution(%s): %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := db.LoadToolStatsSummary(1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToolStatsSummary: %v", err)
|
||||
}
|
||||
if summary.Summary.TotalCalls != 3 {
|
||||
t.Fatalf("totalCalls = %d, want 3", summary.Summary.TotalCalls)
|
||||
}
|
||||
if summary.Summary.SuccessCalls != 1 {
|
||||
t.Fatalf("successCalls = %d, want 1", summary.Summary.SuccessCalls)
|
||||
}
|
||||
if summary.Summary.FailedCalls != 1 {
|
||||
t.Fatalf("failedCalls = %d, want 1", summary.Summary.FailedCalls)
|
||||
}
|
||||
if len(summary.TopTools) != 1 {
|
||||
t.Fatalf("top tools = %d, want 1", len(summary.TopTools))
|
||||
}
|
||||
if summary.TopTools[0].FailedCalls != 1 {
|
||||
t.Fatalf("top tool failedCalls = %d, want 1", summary.TopTools[0].FailedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T) {
|
||||
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
@@ -32,14 +32,24 @@ func TestProcessDetailsSummaryPairsMixedIdentifiedAndIDLessResults(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 4 {
|
||||
t.Fatalf("tool executions = %d, want 4", len(summary.ToolExecutions))
|
||||
if len(summary.ToolExecutions) != 6 {
|
||||
t.Fatalf("tool executions = %d, want 6", len(summary.ToolExecutions))
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions {
|
||||
for i, execution := range summary.ToolExecutions[:2] {
|
||||
if execution.Status != "completed" {
|
||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
||||
}
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[2:4] {
|
||||
if execution.Status != "result_missing" {
|
||||
t.Fatalf("unmatched call %d status = %q, want result_missing", i, execution.Status)
|
||||
}
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[4:] {
|
||||
if execution.Status != "completed" || execution.ToolCallID != "" {
|
||||
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var factKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*$`)
|
||||
var factKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
|
||||
|
||||
// ValidateFactKey 校验事实 key(项目内唯一标识)。
|
||||
func ValidateFactKey(key string) error {
|
||||
@@ -22,7 +22,7 @@ func ValidateFactKey(key string) error {
|
||||
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
||||
}
|
||||
if !factKeyPattern.MatchString(key) {
|
||||
return fmt.Errorf("fact_key 格式无效,仅允许小写字母、数字及 . _ / -,且须以小写字母或数字开头")
|
||||
return fmt.Errorf("fact_key 格式无效,仅允许字母、数字及 . _ / -,且须以字母或数字开头(支持驼峰命名)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -111,6 +111,19 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetProjectName returns a project display name without loading the full record.
|
||||
func (db *DB) GetProjectName(id string) (string, error) {
|
||||
var name string
|
||||
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("项目不存在")
|
||||
}
|
||||
return "", fmt.Errorf("获取项目名称失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(name), nil
|
||||
}
|
||||
|
||||
func projectListSearchPattern(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
@@ -263,11 +276,20 @@ func (db *DB) UpdateProject(p *Project) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;漏洞 project_id 置空)。
|
||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;其他资源 project_id 置空)。
|
||||
func (db *DB) DeleteProject(id string) error {
|
||||
if _, err := db.Exec(`UPDATE vulnerabilities SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除漏洞项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE assets SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除资产项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE webshell_connections SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除 WebShell 项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE c2_listeners SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除 C2 监听器项目关联失败: %w", err)
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除项目失败: %w", err)
|
||||
|
||||
@@ -84,7 +84,7 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
|
||||
limit = 100
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id
|
||||
`SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name
|
||||
FROM conversations WHERE project_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
|
||||
projectID, limit, offset,
|
||||
)
|
||||
@@ -99,12 +99,16 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pid sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid); err != nil {
|
||||
var roleName sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid, &roleName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pid.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(pid.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
conv.CreatedAt = parseDBTime(createdAt)
|
||||
conv.UpdatedAt = parseDBTime(updatedAt)
|
||||
conv.Pinned = pinned != 0
|
||||
|
||||
+189
-7
@@ -27,6 +27,7 @@ var rbacAssignableResourceTables = map[string]string{
|
||||
"project": "projects",
|
||||
"conversation": "conversations",
|
||||
"vulnerability": "vulnerabilities",
|
||||
"asset": "assets",
|
||||
"webshell": "webshell_connections",
|
||||
"batch_task": "batch_task_queues",
|
||||
"c2_listener": "c2_listeners",
|
||||
@@ -528,6 +529,9 @@ func (db *DB) UserCanAccessResource(userID, scope, resourceType, resourceID stri
|
||||
if resourceType == "vulnerability" {
|
||||
return db.userCanAccessVulnerabilityViaParent(userID, scope, resourceID)
|
||||
}
|
||||
if resourceType == "asset" {
|
||||
return db.userCanAccessAssetViaParent(userID, scope, resourceID)
|
||||
}
|
||||
if resourceType == "conversation" {
|
||||
return db.userCanAccessConversationViaParent(userID, scope, resourceID)
|
||||
}
|
||||
@@ -537,6 +541,15 @@ func (db *DB) UserCanAccessResource(userID, scope, resourceType, resourceID stri
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *DB) userCanAccessAssetViaParent(userID, scope, assetID string) bool {
|
||||
var projectID sql.NullString
|
||||
if err := db.QueryRow(`SELECT project_id FROM assets WHERE id = ?`, assetID).Scan(&projectID); err != nil {
|
||||
return false
|
||||
}
|
||||
return projectID.Valid && strings.TrimSpace(projectID.String) != "" &&
|
||||
db.UserCanAccessResource(userID, scope, "project", strings.TrimSpace(projectID.String))
|
||||
}
|
||||
|
||||
func (db *DB) userCanAccessConversationViaParent(userID, scope, conversationID string) bool {
|
||||
var projectID sql.NullString
|
||||
if err := db.QueryRow(`SELECT project_id FROM conversations WHERE id = ?`, conversationID).Scan(&projectID); err != nil {
|
||||
@@ -623,6 +636,8 @@ func (db *DB) userOwnsResource(userID, resourceType, resourceID string) bool {
|
||||
table = "conversations"
|
||||
case "vulnerability":
|
||||
table = "vulnerabilities"
|
||||
case "asset":
|
||||
table = "assets"
|
||||
case "webshell":
|
||||
table = "webshell_connections"
|
||||
case "batch_task":
|
||||
@@ -650,6 +665,8 @@ func (db *DB) SetResourceOwner(resourceType, resourceID, userID string) error {
|
||||
table = "conversations"
|
||||
case "vulnerability":
|
||||
table = "vulnerabilities"
|
||||
case "asset":
|
||||
table = "assets"
|
||||
case "webshell":
|
||||
table = "webshell_connections"
|
||||
case "batch_task":
|
||||
@@ -672,6 +689,8 @@ func (db *DB) GetResourceOwner(resourceType, resourceID string) string {
|
||||
table = "conversations"
|
||||
case "vulnerability":
|
||||
table = "vulnerabilities"
|
||||
case "asset":
|
||||
table = "assets"
|
||||
case "webshell":
|
||||
table = "webshell_connections"
|
||||
case "batch_task":
|
||||
@@ -733,6 +752,10 @@ func (db *DB) ListAssignableRBACResourcesPage(resourceType, search string, limit
|
||||
query = `SELECT id, title, severity FROM vulnerabilities
|
||||
WHERE LOWER(title) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'
|
||||
ORDER BY updated_at DESC LIMIT ? OFFSET ?`
|
||||
case "asset":
|
||||
query = `SELECT id, COALESCE(NULLIF(host,''),NULLIF(domain,''),NULLIF(ip,''),id), protocol || CASE WHEN port>0 THEN ':' || port ELSE '' END FROM assets
|
||||
WHERE LOWER(host) LIKE ? ESCAPE '\' OR LOWER(domain) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\'
|
||||
ORDER BY updated_at DESC LIMIT ? OFFSET ?`
|
||||
case "webshell":
|
||||
query = `SELECT id, COALESCE(NULLIF(remark, ''), url), type FROM webshell_connections
|
||||
WHERE LOWER(COALESCE(NULLIF(remark, ''), url)) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'
|
||||
@@ -747,7 +770,12 @@ func (db *DB) ListAssignableRBACResourcesPage(resourceType, search string, limit
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||
}
|
||||
|
||||
rows, err := db.Query(query, pattern, pattern, limit, offset)
|
||||
queryArgs := []interface{}{pattern, pattern}
|
||||
if resourceType == "asset" {
|
||||
queryArgs = append(queryArgs, pattern)
|
||||
}
|
||||
queryArgs = append(queryArgs, limit, offset)
|
||||
rows, err := db.Query(query, queryArgs...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -764,6 +792,43 @@ func (db *DB) ListAssignableRBACResourcesPage(resourceType, search string, limit
|
||||
return options, rows.Err()
|
||||
}
|
||||
|
||||
// CountAssignableRBACResources returns the total rows matching the resource picker filter.
|
||||
func (db *DB) CountAssignableRBACResources(resourceType, search string) (int, error) {
|
||||
resourceType = strings.TrimSpace(resourceType)
|
||||
if _, ok := rbacAssignableResourceTables[resourceType]; !ok {
|
||||
return 0, fmt.Errorf("不支持的资源类型: %s", resourceType)
|
||||
}
|
||||
pattern := "%" + strings.ToLower(strings.NewReplacer(
|
||||
`\`, `\\`, `%`, `\%`, `_`, `\_`,
|
||||
).Replace(strings.TrimSpace(search))) + "%"
|
||||
var query string
|
||||
switch resourceType {
|
||||
case "project":
|
||||
query = `SELECT COUNT(*) FROM projects WHERE LOWER(name) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'`
|
||||
case "conversation":
|
||||
query = `SELECT COUNT(*) FROM conversations WHERE LOWER(COALESCE(NULLIF(TRIM(title), ''), id)) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'`
|
||||
case "vulnerability":
|
||||
query = `SELECT COUNT(*) FROM vulnerabilities WHERE LOWER(title) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'`
|
||||
case "asset":
|
||||
query = `SELECT COUNT(*) FROM assets WHERE LOWER(host) LIKE ? ESCAPE '\' OR LOWER(domain) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\'`
|
||||
case "webshell":
|
||||
query = `SELECT COUNT(*) FROM webshell_connections WHERE LOWER(COALESCE(NULLIF(remark, ''), url)) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'`
|
||||
case "batch_task":
|
||||
query = `SELECT COUNT(*) FROM batch_task_queues WHERE LOWER(COALESCE(NULLIF(title, ''), id)) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'`
|
||||
case "c2_listener":
|
||||
query = `SELECT COUNT(*) FROM c2_listeners WHERE LOWER(name) LIKE ? ESCAPE '\' OR LOWER(id) LIKE ? ESCAPE '\'`
|
||||
}
|
||||
var total int
|
||||
queryArgs := []interface{}{pattern, pattern}
|
||||
if resourceType == "asset" {
|
||||
queryArgs = append(queryArgs, pattern)
|
||||
}
|
||||
if err := db.QueryRow(query, queryArgs...).Scan(&total); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func normalizeRBACResourceLabel(label, id string) string {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
@@ -838,6 +903,8 @@ func (db *DB) lookupRBACResourceOptionsByIDs(resourceType string, ids []string)
|
||||
query = `SELECT id, COALESCE(NULLIF(TRIM(title), ''), '未命名对话'), COALESCE(project_id, '') FROM conversations WHERE id IN (` + placeholders + `)`
|
||||
case "vulnerability":
|
||||
query = `SELECT id, title, severity FROM vulnerabilities WHERE id IN (` + placeholders + `)`
|
||||
case "asset":
|
||||
query = `SELECT id, COALESCE(NULLIF(host,''),NULLIF(domain,''),NULLIF(ip,''),id), protocol || CASE WHEN port>0 THEN ':' || port ELSE '' END FROM assets WHERE id IN (` + placeholders + `)`
|
||||
case "webshell":
|
||||
query = `SELECT id, COALESCE(NULLIF(remark, ''), url), type FROM webshell_connections WHERE id IN (` + placeholders + `)`
|
||||
case "batch_task":
|
||||
@@ -968,6 +1035,87 @@ func (db *DB) AssignResourcesToUser(userID, resourceType string, resourceIDs []s
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// AssignResourcesToUserAuto detects each resource's actual type before writing.
|
||||
// The whole batch is validated first and committed atomically.
|
||||
func (db *DB) AssignResourcesToUserAuto(userID string, resourceIDs []string) (int64, map[string]string, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" || len(resourceIDs) == 0 {
|
||||
return 0, nil, errors.New("user_id and resource_ids are required")
|
||||
}
|
||||
if len(resourceIDs) > RBACMaxBatchResourceAssignments {
|
||||
return 0, nil, fmt.Errorf("一次最多授权 %d 个资源", RBACMaxBatchResourceAssignments)
|
||||
}
|
||||
uniqueIDs := make([]string, 0, len(resourceIDs))
|
||||
seen := make(map[string]struct{}, len(resourceIDs))
|
||||
for _, rawID := range resourceIDs {
|
||||
id := strings.TrimSpace(rawID)
|
||||
if id == "" {
|
||||
return 0, nil, errors.New("资源 ID 不能为空")
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
uniqueIDs = append(uniqueIDs, id)
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var userExists int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM rbac_users WHERE id = ?`, userID).Scan(&userExists); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if userExists == 0 {
|
||||
return 0, nil, errors.New("用户不存在")
|
||||
}
|
||||
|
||||
typeTablePairs := []struct{ resourceType, table string }{
|
||||
{"project", "projects"}, {"conversation", "conversations"},
|
||||
{"vulnerability", "vulnerabilities"}, {"webshell", "webshell_connections"},
|
||||
{"asset", "assets"},
|
||||
{"batch_task", "batch_task_queues"}, {"c2_listener", "c2_listeners"},
|
||||
}
|
||||
detected := make(map[string]string, len(uniqueIDs))
|
||||
for _, resourceID := range uniqueIDs {
|
||||
for _, pair := range typeTablePairs {
|
||||
var exists int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM `+pair.table+` WHERE id = ?`, resourceID).Scan(&exists); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if exists > 0 {
|
||||
if previous := detected[resourceID]; previous != "" {
|
||||
return 0, nil, fmt.Errorf("资源 ID 同时匹配多个类型: %s (%s, %s)", resourceID, previous, pair.resourceType)
|
||||
}
|
||||
detected[resourceID] = pair.resourceType
|
||||
}
|
||||
}
|
||||
if detected[resourceID] == "" {
|
||||
return 0, nil, fmt.Errorf("资源不存在: %s", resourceID)
|
||||
}
|
||||
}
|
||||
|
||||
var created int64
|
||||
for _, resourceID := range uniqueIDs {
|
||||
result, err := tx.Exec(`
|
||||
INSERT OR IGNORE INTO rbac_resource_assignments (id, user_id, resource_type, resource_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, uuid.NewString(), userID, detected[resourceID], resourceID, time.Now())
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if n, err := result.RowsAffected(); err == nil {
|
||||
created += n
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return created, detected, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListRBACUsers() ([]RBACUser, error) {
|
||||
rows, err := db.Query(`SELECT id, username, display_name, password_hash, enabled, is_builtin, created_at, updated_at FROM rbac_users ORDER BY username ASC`)
|
||||
if err != nil {
|
||||
@@ -1257,16 +1405,50 @@ func (db *DB) ListRBACResourceAssignments(userID string) ([]RBACResourceAssignme
|
||||
}
|
||||
|
||||
func (db *DB) DeleteRBACResourceAssignment(id string) error {
|
||||
_, err := db.DeleteRBACResourceAssignmentWithDetails(id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteRBACResourceAssignmentWithDetails atomically removes an assignment and
|
||||
// returns the deleted row so callers can write a complete, attributable audit
|
||||
// event without racing a separate lookup against another delete.
|
||||
func (db *DB) DeleteRBACResourceAssignmentWithDetails(id string) (*RBACResourceAssignment, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return errors.New("assignment id is required")
|
||||
return nil, errors.New("assignment id is required")
|
||||
}
|
||||
result, err := db.Exec(`DELETE FROM rbac_resource_assignments WHERE id = ?`, id)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||
return errors.New("资源授权不存在或已撤销")
|
||||
defer tx.Rollback()
|
||||
|
||||
var row RBACResourceAssignment
|
||||
var createdAt string
|
||||
err = tx.QueryRow(`
|
||||
SELECT id, user_id, resource_type, resource_id, created_at
|
||||
FROM rbac_resource_assignments
|
||||
WHERE id = ?
|
||||
`, id).Scan(&row.ID, &row.UserID, &row.ResourceType, &row.ResourceID, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.New("资源授权不存在或已撤销")
|
||||
}
|
||||
return nil
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.CreatedAt = parseDBTime(createdAt)
|
||||
|
||||
result, err := tx.Exec(`DELETE FROM rbac_resource_assignments WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, rowsErr := result.RowsAffected(); rowsErr != nil {
|
||||
return nil, rowsErr
|
||||
} else if affected != 1 {
|
||||
return nil, errors.New("资源授权不存在或已撤销")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
@@ -373,22 +373,46 @@ func TestRBACBatchResourceAssignmentValidationAndAtomicity(t *testing.T) {
|
||||
|
||||
func TestRBACWebshellAndBatchListAccess(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
ws1 := WebShellConnection{ID: "ws_visible", URL: "http://a", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws2 := WebShellConnection{ID: "ws_hidden", URL: "http://b", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws1 := WebShellConnection{ID: "ws_visible", ProjectID: "p1", URL: "http://a", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws2 := WebShellConnection{ID: "ws_hidden", ProjectID: "p2", URL: "http://b", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws3 := WebShellConnection{ID: "ws_other_project", ProjectID: "p2", URL: "http://c", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws4 := WebShellConnection{ID: "ws_unbound", URL: "http://d", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
if err := db.CreateWebshellConnection(&ws1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.SetResourceOwner("webshell", ws1.ID, "u1")
|
||||
_ = db.SetResourceOwner("webshell", ws2.ID, "u2")
|
||||
webshells, err := db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn)
|
||||
_ = db.SetResourceOwner("webshell", ws3.ID, "u1")
|
||||
_ = db.SetResourceOwner("webshell", ws4.ID, "u1")
|
||||
webshells, err := db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 3 {
|
||||
t.Fatalf("webshells = %#v, want 3 owned webshells including unbound", webshells)
|
||||
}
|
||||
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "p1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 1 || webshells[0].ID != ws1.ID {
|
||||
t.Fatalf("webshells = %#v, want only %s", webshells, ws1.ID)
|
||||
t.Fatalf("webshells scoped to p1 = %#v, want only %s", webshells, ws1.ID)
|
||||
}
|
||||
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, ProjectFilterUnbound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 1 || webshells[0].ID != ws4.ID {
|
||||
t.Fatalf("unbound webshells = %#v, want only %s", webshells, ws4.ID)
|
||||
}
|
||||
|
||||
if err := db.CreateBatchQueue("q_visible", "visible", "", "eino_single", "manual", "", nil, "", 1, []map[string]interface{}{{"id": "t1", "message": "a"}}); err != nil {
|
||||
@@ -411,61 +435,143 @@ func TestRBACWebshellAndBatchListAccess(t *testing.T) {
|
||||
func TestRBACC2AccessInheritsListener(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
now := time.Now()
|
||||
l1 := &C2Listener{ID: "l_visible", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", CreatedAt: now}
|
||||
l2 := &C2Listener{ID: "l_hidden", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", CreatedAt: now}
|
||||
l1 := &C2Listener{ID: "l_visible", ProjectID: "p1", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", CreatedAt: now}
|
||||
l2 := &C2Listener{ID: "l_hidden", ProjectID: "p2", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", CreatedAt: now}
|
||||
l3 := &C2Listener{ID: "l_other_project", ProjectID: "p2", Name: "other project", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9003, OwnerUserID: "u1", CreatedAt: now}
|
||||
l4 := &C2Listener{ID: "l_unbound", Name: "unbound", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9004, OwnerUserID: "u1", CreatedAt: now}
|
||||
if err := db.CreateC2Listener(l1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_visible", ListenerID: l1.ID, ImplantUUID: "implant-visible", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_hidden", ListenerID: l2.ID, ImplantUUID: "implant-hidden", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_other_project", ListenerID: l3.ID, ImplantUUID: "implant-other-project", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_unbound", ListenerID: l4.ID, ImplantUUID: "implant-unbound", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_visible", SessionID: "s_visible", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_hidden", SessionID: "s_hidden", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_other_project", SessionID: "s_other_project", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_unbound", SessionID: "s_unbound", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_visible", Level: "info", Category: "task", SessionID: "s_visible", TaskID: "t_visible", Message: "visible", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_hidden", Level: "info", Category: "task", SessionID: "s_hidden", TaskID: "t_hidden", Message: "hidden", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_other_project", Level: "info", Category: "task", SessionID: "s_other_project", TaskID: "t_other_project", Message: "other project", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_unbound", Level: "info", Category: "task", SessionID: "s_unbound", TaskID: "t_unbound", Message: "unbound", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
access := RBACListAccess{UserID: "u1", Scope: RBACScopeOwn}
|
||||
listeners, err := db.ListC2ListenersForAccess(access)
|
||||
listeners, err := db.ListC2ListenersForAccess(access, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 3 {
|
||||
t.Fatalf("listeners = %#v, want 3 owned listeners including unbound", listeners)
|
||||
}
|
||||
listeners, err = db.ListC2ListenersForAccess(access, "p1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 || listeners[0].ID != l1.ID {
|
||||
t.Fatalf("listeners = %#v, want only %s", listeners, l1.ID)
|
||||
t.Fatalf("listeners scoped to p1 = %#v, want only %s", listeners, l1.ID)
|
||||
}
|
||||
listeners, err = db.ListC2ListenersForAccess(access, ProjectFilterUnbound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 || listeners[0].ID != l4.ID {
|
||||
t.Fatalf("unbound listeners = %#v, want only %s", listeners, l4.ID)
|
||||
}
|
||||
sessions, err := db.ListC2SessionsForAccess(ListC2SessionsFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 3 {
|
||||
t.Fatalf("sessions = %#v, want 3 owned sessions including unbound", sessions)
|
||||
}
|
||||
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].ID != "s_visible" {
|
||||
t.Fatalf("sessions = %#v, want only s_visible", sessions)
|
||||
t.Fatalf("sessions scoped to p1 = %#v, want only s_visible", sessions)
|
||||
}
|
||||
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].ID != "s_unbound" {
|
||||
t.Fatalf("unbound sessions = %#v, want only s_unbound", sessions)
|
||||
}
|
||||
tasks, err := db.ListC2TasksForAccess(ListC2TasksFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("tasks = %#v, want 3 owned tasks including unbound", tasks)
|
||||
}
|
||||
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "t_visible" {
|
||||
t.Fatalf("tasks = %#v, want only t_visible", tasks)
|
||||
t.Fatalf("tasks scoped to p1 = %#v, want only t_visible", tasks)
|
||||
}
|
||||
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "t_unbound" {
|
||||
t.Fatalf("unbound tasks = %#v, want only t_unbound", tasks)
|
||||
}
|
||||
events, err := db.ListC2EventsForAccess(ListC2EventsFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("events = %#v, want 3 owned events including unbound", events)
|
||||
}
|
||||
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].ID != "e_visible" {
|
||||
t.Fatalf("events = %#v, want only e_visible", events)
|
||||
t.Fatalf("events scoped to p1 = %#v, want only e_visible", events)
|
||||
}
|
||||
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].ID != "e_unbound" {
|
||||
t.Fatalf("unbound events = %#v, want only e_unbound", events)
|
||||
}
|
||||
if !db.UserCanAccessResource("u1", RBACScopeOwn, "c2_task", "t_visible") {
|
||||
t.Fatalf("expected listener ownership to allow task detail")
|
||||
@@ -579,3 +685,43 @@ func TestRBACAssignmentLabelsAndWeakTitles(t *testing.T) {
|
||||
t.Fatalf("assignment label = %q, want Alpha Project", rows[0].ResourceLabel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRBACResourceAssignmentWithDetails(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
user, err := db.CreateRBACUser("revoke-member", "Revoke Member", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&Project{Name: "Revoked Project"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.AssignResourcesToUser(user.ID, "project", []string{project.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := db.ListRBACResourceAssignments(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("assignments = %#v, want 1", rows)
|
||||
}
|
||||
|
||||
deleted, err := db.DeleteRBACResourceAssignmentWithDetails(rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted.ID != rows[0].ID || deleted.UserID != user.ID || deleted.ResourceType != "project" || deleted.ResourceID != project.ID {
|
||||
t.Fatalf("deleted assignment = %#v", deleted)
|
||||
}
|
||||
remaining, err := db.ListRBACResourceAssignments(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("remaining assignments = %#v, want none", remaining)
|
||||
}
|
||||
if _, err := db.DeleteRBACResourceAssignmentWithDetails(rows[0].ID); err == nil {
|
||||
t.Fatal("second delete unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,6 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
||||
}
|
||||
|
||||
return vuln, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VulnerabilityAlertSubscription is the single source of truth shared by Web
|
||||
// settings and robot commands. Alerts are opt-in and user scoped.
|
||||
type VulnerabilityAlertSubscription struct {
|
||||
UserID string `json:"user_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type VulnerabilityAlertRecipient struct {
|
||||
UserID string
|
||||
Platform string
|
||||
ExternalUserID string
|
||||
}
|
||||
|
||||
type VulnerabilityAlertDelivery struct {
|
||||
ID int64
|
||||
Vulnerability *Vulnerability
|
||||
UserID string
|
||||
Platform string
|
||||
ExternalUserID string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
var vulnerabilitySeverityRank = map[string]int{
|
||||
"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4,
|
||||
}
|
||||
|
||||
func NormalizeVulnerabilityAlertSeverity(value string) (string, error) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
value = "high"
|
||||
}
|
||||
if _, ok := vulnerabilitySeverityRank[value]; !ok {
|
||||
return "", fmt.Errorf("invalid minimum severity %q", value)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetVulnerabilityAlertSubscription(userID string) (*VulnerabilityAlertSubscription, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
var sub VulnerabilityAlertSubscription
|
||||
var enabled int
|
||||
var createdAt, updatedAt string
|
||||
err := db.QueryRow(`SELECT user_id, enabled, min_severity, created_at, updated_at
|
||||
FROM vulnerability_alert_subscriptions WHERE user_id = ?`, userID).
|
||||
Scan(&sub.UserID, &enabled, &sub.MinSeverity, &createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
now := time.Now()
|
||||
return &VulnerabilityAlertSubscription{UserID: userID, MinSeverity: "high", CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.Enabled = enabled != 0
|
||||
sub.CreatedAt = parseDBTime(createdAt)
|
||||
sub.UpdatedAt = parseDBTime(updatedAt)
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertVulnerabilityAlertSubscription(userID string, enabled bool, minSeverity string) (*VulnerabilityAlertSubscription, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("user id is required")
|
||||
}
|
||||
severity, err := NormalizeVulnerabilityAlertSeverity(minSeverity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = db.Exec(`INSERT INTO vulnerability_alert_subscriptions
|
||||
(user_id, enabled, min_severity, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET enabled = excluded.enabled,
|
||||
min_severity = excluded.min_severity, updated_at = excluded.updated_at`,
|
||||
userID, boolToInt(enabled), severity, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.GetVulnerabilityAlertSubscription(userID)
|
||||
}
|
||||
|
||||
// ListVulnerabilityAlertRecipients applies the same RBAC ownership/assignment
|
||||
// boundaries as the vulnerability list, then expands only enabled robot bindings.
|
||||
func (db *DB) ListVulnerabilityAlertRecipients(vuln *Vulnerability) ([]VulnerabilityAlertRecipient, error) {
|
||||
if vuln == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rank, ok := vulnerabilitySeverityRank[strings.ToLower(strings.TrimSpace(vuln.Severity))]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT DISTINCT s.user_id, b.platform, b.external_user_id, s.min_severity
|
||||
FROM vulnerability_alert_subscriptions s
|
||||
JOIN rbac_users u ON u.id = s.user_id AND u.enabled = 1
|
||||
JOIN robot_user_bindings b ON b.rbac_user_id = s.user_id AND b.enabled = 1
|
||||
WHERE s.enabled = 1 AND (
|
||||
EXISTS (SELECT 1 FROM vulnerabilities v WHERE v.id = ? AND v.owner_user_id = s.user_id)
|
||||
OR EXISTS (SELECT 1 FROM rbac_resource_assignments ra WHERE ra.user_id = s.user_id AND ra.resource_type = 'vulnerability' AND ra.resource_id = ?)
|
||||
OR (? <> '' AND (EXISTS (SELECT 1 FROM projects p WHERE p.id = ? AND p.owner_user_id = s.user_id)
|
||||
OR EXISTS (SELECT 1 FROM rbac_resource_assignments pra WHERE pra.user_id = s.user_id AND pra.resource_type = 'project' AND pra.resource_id = ?)))
|
||||
OR (? <> '' AND (EXISTS (SELECT 1 FROM conversations c WHERE c.id = ? AND c.owner_user_id = s.user_id)
|
||||
OR EXISTS (SELECT 1 FROM rbac_resource_assignments cra WHERE cra.user_id = s.user_id AND cra.resource_type = 'conversation' AND cra.resource_id = ?)))
|
||||
)`, vuln.ID, vuln.ID, vuln.ProjectID, vuln.ProjectID, vuln.ProjectID,
|
||||
vuln.ConversationID, vuln.ConversationID, vuln.ConversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]VulnerabilityAlertRecipient, 0)
|
||||
for rows.Next() {
|
||||
var recipient VulnerabilityAlertRecipient
|
||||
var minimum string
|
||||
if err := rows.Scan(&recipient.UserID, &recipient.Platform, &recipient.ExternalUserID, &minimum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rank >= vulnerabilitySeverityRank[minimum] {
|
||||
out = append(out, recipient)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) SetVulnerabilityCreatedHook(hook func(*Vulnerability)) {
|
||||
db.vulnerabilityCreatedHook = hook
|
||||
}
|
||||
|
||||
// NotifyVulnerabilityCreated must be called after resource ownership has been
|
||||
// committed. Delivery runs asynchronously and never delays the write path.
|
||||
func (db *DB) NotifyVulnerabilityCreated(vulnerability *Vulnerability) {
|
||||
if db == nil || vulnerability == nil || db.vulnerabilityCreatedHook == nil {
|
||||
return
|
||||
}
|
||||
created := *vulnerability
|
||||
go db.vulnerabilityCreatedHook(&created)
|
||||
}
|
||||
|
||||
func (db *DB) EnqueueVulnerabilityAlertDeliveries(vulnerabilityID string, recipients []VulnerabilityAlertRecipient) error {
|
||||
now := time.Now()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, r := range recipients {
|
||||
if _, err := tx.Exec(`INSERT INTO vulnerability_alert_deliveries
|
||||
(vulnerability_id, user_id, platform, external_user_id, status, attempts, next_attempt_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?, ?)
|
||||
ON CONFLICT(vulnerability_id, platform, external_user_id) DO NOTHING`,
|
||||
vulnerabilityID, r.UserID, r.Platform, r.ExternalUserID, now, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (db *DB) ListDueVulnerabilityAlertDeliveries(limit int) ([]VulnerabilityAlertDelivery, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := db.Query(`SELECT d.id, d.user_id, d.platform, d.external_user_id, d.attempts,
|
||||
v.id, COALESCE(v.conversation_id,''), COALESCE(v.project_id,''), v.title, COALESCE(v.description,''),
|
||||
v.severity, v.status, COALESCE(v.vulnerability_type,''), COALESCE(v.target,''),
|
||||
COALESCE(v.impact,''), COALESCE(v.recommendation,''), v.created_at, v.updated_at
|
||||
FROM vulnerability_alert_deliveries d JOIN vulnerabilities v ON v.id = d.vulnerability_id
|
||||
WHERE d.status IN ('pending','retry') AND d.next_attempt_at <= ?
|
||||
ORDER BY d.next_attempt_at, d.id LIMIT ?`, time.Now(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []VulnerabilityAlertDelivery
|
||||
for rows.Next() {
|
||||
var d VulnerabilityAlertDelivery
|
||||
v := &Vulnerability{}
|
||||
if err := rows.Scan(&d.ID, &d.UserID, &d.Platform, &d.ExternalUserID, &d.Attempts,
|
||||
&v.ID, &v.ConversationID, &v.ProjectID, &v.Title, &v.Description, &v.Severity, &v.Status,
|
||||
&v.Type, &v.Target, &v.Impact, &v.Recommendation, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Vulnerability = v
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) MarkVulnerabilityAlertDeliverySent(id int64) error {
|
||||
_, err := db.Exec(`UPDATE vulnerability_alert_deliveries SET status='sent', attempts=attempts+1, last_error='', updated_at=? WHERE id=?`, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) MarkVulnerabilityAlertDeliveryFailed(id int64, attempts int, sendErr error) error {
|
||||
status := "retry"
|
||||
if attempts >= 5 {
|
||||
status = "failed"
|
||||
}
|
||||
delay := time.Minute * time.Duration(1<<min(attempts, 6))
|
||||
message := ""
|
||||
if sendErr != nil {
|
||||
message = sendErr.Error()
|
||||
}
|
||||
_, err := db.Exec(`UPDATE vulnerability_alert_deliveries SET status=?, attempts=?, next_attempt_at=?, last_error=?, updated_at=? WHERE id=?`,
|
||||
status, attempts, time.Now().Add(delay), message, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestVulnerabilityAlertSubscriptionIsOptInAndRBACScoped(t *testing.T) {
|
||||
db, err := database.NewDB(t.TempDir()+"/alerts.db", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.BootstrapRBAC("hash", security.PermissionCatalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := db.CreateRBACUser("alert-owner", "Owner", "hash", true, []string{database.RBACSystemRoleOperator})
|
||||
other, _ := db.CreateRBACUser("alert-other", "Other", "hash", true, []string{database.RBACSystemRoleOperator})
|
||||
|
||||
for i, pair := range []struct {
|
||||
user *database.RBACUser
|
||||
external string
|
||||
}{{owner, "owner"}, {other, "other"}} {
|
||||
code := "code-" + string(rune('a'+i))
|
||||
if err := db.CreateRobotBindingCode(pair.user.ID, code, time.Now().Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ConsumeRobotBindingCode("wecom", pair.external, code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
defaultSub, err := db.GetVulnerabilityAlertSubscription(owner.ID)
|
||||
if err != nil || defaultSub.Enabled || defaultSub.MinSeverity != "high" {
|
||||
t.Fatalf("unsafe default: %#v %v", defaultSub, err)
|
||||
}
|
||||
if _, err := db.UpsertVulnerabilityAlertSubscription(owner.ID, true, "high"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertVulnerabilityAlertSubscription(other.ID, true, "low"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vuln, err := db.CreateVulnerability(&database.Vulnerability{Title: "owned", Severity: "high"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetResourceOwner("vulnerability", vuln.ID, owner.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipients, err := db.ListVulnerabilityAlertRecipients(vuln)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recipients) != 1 || recipients[0].UserID != owner.ID || recipients[0].ExternalUserID != "owner" {
|
||||
t.Fatalf("alert escaped RBAC boundary: %#v", recipients)
|
||||
}
|
||||
if err := db.EnqueueVulnerabilityAlertDeliveries(vuln.ID, recipients); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.EnqueueVulnerabilityAlertDeliveries(vuln.ID, recipients); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deliveries, err := db.ListDueVulnerabilityAlertDeliveries(10)
|
||||
if err != nil || len(deliveries) != 1 {
|
||||
t.Fatalf("outbox is not durable/deduplicated: %#v %v", deliveries, err)
|
||||
}
|
||||
if err := db.MarkVulnerabilityAlertDeliverySent(deliveries[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deliveries, _ = db.ListDueVulnerabilityAlertDeliveries(10)
|
||||
if len(deliveries) != 0 {
|
||||
t.Fatalf("sent delivery remained due: %#v", deliveries)
|
||||
}
|
||||
|
||||
vuln.Severity = "medium"
|
||||
recipients, err = db.ListVulnerabilityAlertRecipients(vuln)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recipients) != 0 {
|
||||
t.Fatalf("minimum severity was ignored: %#v", recipients)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// WebShellConnection WebShell 连接配置
|
||||
type WebShellConnection struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
@@ -60,17 +61,24 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
|
||||
|
||||
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
|
||||
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
||||
return db.ListWebshellConnectionsForAccess("", "")
|
||||
return db.ListWebshellConnectionsForAccess("", "", "")
|
||||
}
|
||||
|
||||
func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShellConnection, error) {
|
||||
func (db *DB) ListWebshellConnectionsForAccess(userID, scope, projectID string) ([]WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, url, password, type, method, cmd_param, remark,
|
||||
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
|
||||
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||
FROM webshell_connections
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == ProjectFilterUnbound {
|
||||
query += ` AND COALESCE(project_id, '') = ''`
|
||||
} else if projectID != "" {
|
||||
query += ` AND COALESCE(project_id, '') = ?`
|
||||
args = append(args, projectID)
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" && scope != RBACScopeAll {
|
||||
query += ` AND (
|
||||
@@ -93,7 +101,7 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
|
||||
var list []WebShellConnection
|
||||
for rows.Next() {
|
||||
var c WebShellConnection
|
||||
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
err := rows.Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
|
||||
continue
|
||||
@@ -106,12 +114,12 @@ func (db *DB) ListWebshellConnectionsForAccess(userID, scope string) ([]WebShell
|
||||
// GetWebshellConnection 根据 ID 获取一条连接
|
||||
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, url, password, type, method, cmd_param, remark,
|
||||
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
|
||||
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||
FROM webshell_connections WHERE id = ?
|
||||
`
|
||||
var c WebShellConnection
|
||||
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
err := db.QueryRow(query, id).Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -125,10 +133,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
// CreateWebshellConnection 创建 WebShell 连接
|
||||
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO webshell_connections (id, project_id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
|
||||
_, err := db.Exec(query, c.ID, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
@@ -140,10 +148,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
UPDATE webshell_connections
|
||||
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
|
||||
SET project_id = ?, url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
|
||||
result, err := db.Exec(query, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
|
||||
if err != nil {
|
||||
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type WorkflowPackageInspection struct {
|
||||
ID, PackageHash, ManifestJSON, WorkflowPayloadJSON, InspectionJSON string
|
||||
SourceWorkflowID, SourceContentHash, SourceGraphHash string
|
||||
SourceRevision int
|
||||
LocalConflictState, LocalWorkflowID, LocalContentHash, LocalGraphHash string
|
||||
CreatedBy, Status string
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
ConsumedAt *time.Time
|
||||
}
|
||||
|
||||
type WorkflowPackageImport struct {
|
||||
ID, InspectionID, RequestHash, IdempotencyKey, ActorUserID string
|
||||
Action, SourceWorkflowID, TargetWorkflowID, ResultingWorkflowID string
|
||||
Result, ErrorCode, ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
AppliedAt *time.Time
|
||||
}
|
||||
|
||||
type WorkflowPackageApplyRequest struct {
|
||||
InspectionID, RequestHash, IdempotencyKey, ActorUserID, Action, NewWorkflowID string
|
||||
ConfirmOverwrite bool
|
||||
}
|
||||
|
||||
type WorkflowPackageStoreError struct{ Code, Message string }
|
||||
|
||||
func (e *WorkflowPackageStoreError) Error() string { return e.Code + ": " + e.Message }
|
||||
func workflowPackageStoreError(code, message string) error {
|
||||
return &WorkflowPackageStoreError{code, message}
|
||||
}
|
||||
|
||||
func (db *DB) CreateWorkflowPackageInspection(v *WorkflowPackageInspection) error {
|
||||
if v == nil || strings.TrimSpace(v.ID) == "" || strings.TrimSpace(v.CreatedBy) == "" {
|
||||
return fmt.Errorf("workflow package inspection is incomplete")
|
||||
}
|
||||
if v.CreatedAt.IsZero() {
|
||||
v.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if v.ExpiresAt.IsZero() {
|
||||
v.ExpiresAt = v.CreatedAt.Add(30 * time.Minute)
|
||||
}
|
||||
_, err := db.Exec(`INSERT INTO workflow_package_inspections (id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,local_workflow_id,local_content_hash,local_graph_hash,created_by,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, v.ID, v.PackageHash, v.ManifestJSON, v.WorkflowPayloadJSON, v.InspectionJSON, v.SourceWorkflowID, v.SourceRevision, v.SourceContentHash, v.SourceGraphHash, v.LocalConflictState, nullString(v.LocalWorkflowID), nullString(v.LocalContentHash), nullString(v.LocalGraphHash), v.CreatedBy, "ready", v.CreatedAt.UTC(), v.ExpiresAt.UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowPackageInspection(id, actor string) (*WorkflowPackageInspection, error) {
|
||||
now := time.Now().UTC()
|
||||
_, _ = db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at <= ?`, now)
|
||||
row, err := scanWorkflowPackageInspection(db.QueryRow(`SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
func scanWorkflowPackageInspection(s interface{ Scan(...any) error }) (*WorkflowPackageInspection, error) {
|
||||
var v WorkflowPackageInspection
|
||||
var consumed sql.NullTime
|
||||
err := s.Scan(&v.ID, &v.PackageHash, &v.ManifestJSON, &v.WorkflowPayloadJSON, &v.InspectionJSON, &v.SourceWorkflowID, &v.SourceRevision, &v.SourceContentHash, &v.SourceGraphHash, &v.LocalConflictState, &v.LocalWorkflowID, &v.LocalContentHash, &v.LocalGraphHash, &v.CreatedBy, &v.Status, &v.CreatedAt, &v.ExpiresAt, &consumed)
|
||||
if consumed.Valid {
|
||||
t := consumed.Time
|
||||
v.ConsumedAt = &t
|
||||
}
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowPackageImport(id, actor string) (*WorkflowPackageImport, error) {
|
||||
v, err := scanWorkflowPackageImport(db.QueryRow(`SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE id=? AND actor_user_id=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
func scanWorkflowPackageImport(s interface{ Scan(...any) error }) (*WorkflowPackageImport, error) {
|
||||
var v WorkflowPackageImport
|
||||
var applied sql.NullTime
|
||||
err := s.Scan(&v.ID, &v.InspectionID, &v.RequestHash, &v.IdempotencyKey, &v.ActorUserID, &v.Action, &v.SourceWorkflowID, &v.TargetWorkflowID, &v.ResultingWorkflowID, &v.Result, &v.ErrorCode, &v.ErrorMessage, &v.CreatedAt, &applied)
|
||||
if applied.Valid {
|
||||
t := applied.Time
|
||||
v.AppliedAt = &t
|
||||
}
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (db *DB) ApplyWorkflowPackageImport(ctx context.Context, req WorkflowPackageApplyRequest) (*WorkflowPackageImport, bool, error) {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var existingHash string
|
||||
previous, prevErr := scanWorkflowPackageImport(tx.QueryRowContext(ctx, `SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE actor_user_id=? AND idempotency_key=?`, req.ActorUserID, req.IdempotencyKey))
|
||||
if prevErr == nil {
|
||||
existingHash = previous.RequestHash
|
||||
if existingHash != req.RequestHash {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_IDEMPOTENCY_KEY_REUSED", "幂等键已用于其他请求")
|
||||
}
|
||||
return previous, true, nil
|
||||
}
|
||||
if prevErr != sql.ErrNoRows {
|
||||
return nil, false, prevErr
|
||||
}
|
||||
inspection, err := scanWorkflowPackageInspection(tx.QueryRowContext(ctx, `SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, req.InspectionID, req.ActorUserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_NOT_FOUND", "预检不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !inspection.ExpiresAt.After(now) || inspection.Status == "expired" {
|
||||
_, _ = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='expired' WHERE id=?`, inspection.ID)
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_EXPIRED", "预检已过期")
|
||||
}
|
||||
if inspection.Status != "ready" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_CONSUMED", "预检已被使用")
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inspection.WorkflowPayloadJSON), &payload); err != nil {
|
||||
return nil, false, fmt.Errorf("decode inspection payload: %w", err)
|
||||
}
|
||||
targetID := inspection.SourceWorkflowID
|
||||
if req.Action == "rename" {
|
||||
targetID = strings.TrimSpace(req.NewWorkflowID)
|
||||
if !validWorkflowPackageID(targetID) {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INVALID_RENAME_ID", "新工作流 ID 无效")
|
||||
}
|
||||
}
|
||||
sourceCurrent, err := scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", inspection.SourceWorkflowID))
|
||||
if err == sql.ErrNoRows {
|
||||
sourceCurrent = nil
|
||||
} else if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := checkWorkflowPackageSnapshot(inspection, sourceCurrent, inspection.SourceWorkflowID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
current := sourceCurrent
|
||||
if targetID != inspection.SourceWorkflowID {
|
||||
current, err = scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", targetID))
|
||||
if err == sql.ErrNoRows {
|
||||
current = nil
|
||||
} else if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
result := ""
|
||||
resultingID := ""
|
||||
switch req.Action {
|
||||
case "create":
|
||||
if inspection.LocalConflictState != "none" || current != nil {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "目标工作流已存在")
|
||||
}
|
||||
result = "created"
|
||||
resultingID = targetID
|
||||
case "keep_existing":
|
||||
if inspection.LocalConflictState == "none" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许保留本地")
|
||||
}
|
||||
if inspection.LocalConflictState == "identical" {
|
||||
result = "skipped_identical"
|
||||
} else {
|
||||
result = "kept_existing"
|
||||
}
|
||||
resultingID = targetID
|
||||
case "overwrite":
|
||||
if inspection.LocalConflictState != "id_conflict" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许覆盖")
|
||||
}
|
||||
if !req.ConfirmOverwrite {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_OVERWRITE_CONFIRMATION_REQUIRED", "覆盖需要确认")
|
||||
}
|
||||
result = "overwritten"
|
||||
resultingID = targetID
|
||||
case "rename":
|
||||
if inspection.LocalConflictState != "id_conflict" || current != nil {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许另存")
|
||||
}
|
||||
result = "renamed"
|
||||
resultingID = targetID
|
||||
default:
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INVALID_ACTION", "导入动作无效")
|
||||
}
|
||||
if result == "created" || result == "renamed" {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_definitions (id,name,description,version,graph_json,enabled,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`, resultingID, payload.Name, payload.Description, 1, payload.GraphJSON, boolToInt(payload.Enabled), now, now)
|
||||
} else if result == "overwritten" {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE workflow_definitions SET name=?,description=?,version=version+1,graph_json=?,enabled=?,updated_at=? WHERE id=?`, payload.Name, payload.Description, payload.GraphJSON, boolToInt(payload.Enabled), now, resultingID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
imp := &WorkflowPackageImport{ID: "wpii_" + strings.ReplaceAll(uuid.NewString(), "-", ""), InspectionID: inspection.ID, RequestHash: req.RequestHash, IdempotencyKey: req.IdempotencyKey, ActorUserID: req.ActorUserID, Action: req.Action, SourceWorkflowID: inspection.SourceWorkflowID, TargetWorkflowID: targetID, ResultingWorkflowID: resultingID, Result: result, CreatedAt: now, AppliedAt: &now}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_package_imports (id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,resulting_workflow_id,result,created_at,applied_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, imp.ID, imp.InspectionID, imp.RequestHash, imp.IdempotencyKey, imp.ActorUserID, imp.Action, imp.SourceWorkflowID, imp.TargetWorkflowID, nullString(imp.ResultingWorkflowID), imp.Result, now, now)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='consumed',consumed_at=? WHERE id=? AND status='ready'`, now, inspection.ID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return imp, false, nil
|
||||
}
|
||||
|
||||
func checkWorkflowPackageSnapshot(i *WorkflowPackageInspection, current *WorkflowDefinition, targetID string) error {
|
||||
if i.LocalConflictState == "none" {
|
||||
if current != nil {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if current == nil || current.ID != i.LocalWorkflowID || current.ID != targetID {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
content, graph := workflowDefinitionPackageHashes(current)
|
||||
if content != i.LocalContentHash || graph != i.LocalGraphHash {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func workflowDefinitionPackageHashes(w *WorkflowDefinition) (string, string) {
|
||||
var g any
|
||||
dec := json.NewDecoder(strings.NewReader(w.GraphJSON))
|
||||
dec.UseNumber()
|
||||
_ = dec.Decode(&g)
|
||||
graph, _ := json.Marshal(g)
|
||||
payload := struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}{w.ID, w.Name, w.Description, w.Version, string(graph), w.Enabled}
|
||||
b, _ := json.Marshal(payload)
|
||||
return workflowPackageHash(b), workflowPackageHash(graph)
|
||||
}
|
||||
func workflowPackageHash(b []byte) string {
|
||||
s := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(s[:])
|
||||
}
|
||||
func validWorkflowPackageID(id string) bool {
|
||||
if len(id) < 1 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (db *DB) PurgeWorkflowPackageLifecycle(now time.Time) error {
|
||||
now = now.UTC()
|
||||
if _, err := db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at<=?`, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM workflow_package_inspections WHERE status='expired' AND expires_at<? AND NOT EXISTS (SELECT 1 FROM workflow_package_imports i WHERE i.inspection_id=workflow_package_inspections.id)`, now.Add(-24*time.Hour)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM workflow_package_imports WHERE created_at<?`, now.AddDate(0, 0, -90))
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestWorkflowPackageApplyOverwriteIsTransactionalAndIdempotent(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
current := &WorkflowDefinition{ID: "wf-1", Name: "Local", Version: 12, GraphJSON: `{"nodes":[]}`, Enabled: true}
|
||||
if err := db.UpsertWorkflowDefinition(current); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, _ = db.GetWorkflowDefinition("wf-1")
|
||||
content, graph := workflowDefinitionPackageHashes(current)
|
||||
payload, _ := json.Marshal(map[string]any{"id": "wf-1", "name": "Imported", "description": "new", "version": 18, "graph_json": `{"nodes":[]}`, "enabled": false})
|
||||
now := time.Now().UTC()
|
||||
inspection := &WorkflowPackageInspection{ID: "wpi_test", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-1", SourceRevision: 18, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-1", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
|
||||
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-1", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true}
|
||||
imp, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
|
||||
if err != nil || replayed || imp.Result != "overwritten" {
|
||||
t.Fatalf("apply = %#v replay=%v err=%v", imp, replayed, err)
|
||||
}
|
||||
updated, _ := db.GetWorkflowDefinition("wf-1")
|
||||
if updated.Version != 13 || updated.Name != "Imported" || updated.Enabled {
|
||||
t.Fatalf("updated workflow = %#v", updated)
|
||||
}
|
||||
replay, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
|
||||
if err != nil || !replayed || replay.ID != imp.ID {
|
||||
t.Fatalf("replay = %#v replay=%v err=%v", replay, replayed, err)
|
||||
}
|
||||
gotInspection, err := db.GetWorkflowPackageInspection(inspection.ID, "user-1")
|
||||
if err != nil || gotInspection.Status != "consumed" {
|
||||
t.Fatalf("inspection=%#v err=%v", gotInspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowPackageApplyRejectsChangedConflictSnapshot(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package-conflict.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Local", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local, _ := db.GetWorkflowDefinition("wf-2")
|
||||
content, graph := workflowDefinitionPackageHashes(local)
|
||||
payload, _ := json.Marshal(map[string]any{"id": "wf-2", "name": "Imported", "version": 2, "graph_json": `{"nodes":[]}`, "enabled": true})
|
||||
now := time.Now().UTC()
|
||||
inspection := &WorkflowPackageInspection{ID: "wpi_changed", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-2", SourceRevision: 2, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-2", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
|
||||
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Changed", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = db.ApplyWorkflowPackageImport(context.Background(), WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-2", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true})
|
||||
if e, ok := err.(*WorkflowPackageStoreError); !ok || e.Code != "WFPKG_CONFLICT_CHANGED" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
+60
-26
@@ -212,7 +212,7 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
||||
if h == nil || conversationID == "" || h.tasks == nil {
|
||||
return
|
||||
}
|
||||
h.cancelActiveMCPToolForConversation(conversationID)
|
||||
h.cancelRunningMCPToolsForConversation(conversationID)
|
||||
h.tasks.AbortActiveEinoExecute(conversationID, "")
|
||||
if ok, err := h.tasks.CancelTask(conversationID, ErrTaskCancelled); ok {
|
||||
h.logger.Info("已取消会话运行中任务", zap.String("conversationId", conversationID))
|
||||
@@ -221,12 +221,13 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AgentHandler) cancelActiveMCPToolForConversation(conversationID string) {
|
||||
if h == nil || h.tasks == nil || h.agent == nil {
|
||||
func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) {
|
||||
if h == nil || h.agent == nil {
|
||||
return
|
||||
}
|
||||
if execID := h.tasks.ActiveMCPExecutionID(conversationID); execID != "" {
|
||||
h.agent.CancelMCPToolExecutionWithNote(execID, "")
|
||||
n := h.agent.CancelRunningMCPToolsForConversation(conversationID, "会话已结束,自动终止仍在运行的工具")
|
||||
if n > 0 && h.logger != nil {
|
||||
h.logger.Info("已终止会话仍在运行的 MCP 工具", zap.String("conversationId", conversationID), zap.Int("count", n))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +267,7 @@ func NewAgentHandler(agent *agent.Agent, db *database.DB, cfg *config.Config, lo
|
||||
batchCronParser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor),
|
||||
auditLLM: openai.NewClient(llmCfg, llmHTTP, logger),
|
||||
}
|
||||
tm.SetToolCanceler(handler.cancelActiveMCPToolForConversation)
|
||||
tm.SetToolCanceler(handler.cancelRunningMCPToolsForConversation)
|
||||
if err := handler.hitlManager.EnsureSchema(); err != nil {
|
||||
logger.Warn("初始化 HITL 表失败", zap.Error(err))
|
||||
}
|
||||
@@ -332,20 +333,41 @@ type ChatReasoningRequest struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// ChatFinalizationRequest is a caller-provided delivery policy. The server does
|
||||
// not infer execution intent from natural-language user text.
|
||||
type ChatFinalizationRequest struct {
|
||||
RequireExecutionEvidence *bool `json:"requireExecutionEvidence,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest 聊天请求
|
||||
type ChatRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id)
|
||||
Role string `json:"role,omitempty"` // 角色名称
|
||||
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
|
||||
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||
Finalization ChatFinalizationRequest `json:"finalization,omitempty"`
|
||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||
Orchestration string `json:"orchestration,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) configForAIChannel(channelID string) (*config.Config, string, error) {
|
||||
if h == nil || h.config == nil {
|
||||
return nil, "", fmt.Errorf("服务器配置未加载")
|
||||
}
|
||||
oa, resolvedID, ok := h.config.ResolveAIChannel(channelID)
|
||||
if !ok {
|
||||
return nil, resolvedID, fmt.Errorf("AI 通道不存在: %s", resolvedID)
|
||||
}
|
||||
cfgCopy := *h.config
|
||||
cfgCopy.OpenAI = oa
|
||||
return &cfgCopy, resolvedID, nil
|
||||
}
|
||||
|
||||
func chatReasoningToClientIntent(r *ChatReasoningRequest) *reasoning.ClientIntent {
|
||||
if r == nil {
|
||||
return nil
|
||||
@@ -653,10 +675,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
|
||||
|
||||
// ChatResponse 聊天响应
|
||||
type ChatResponse struct {
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
Time time.Time `json:"time"`
|
||||
Response string `json:"response"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||
ConversationID string `json:"conversationId"` // 对话ID
|
||||
Time time.Time `json:"time"`
|
||||
Finalizable bool `json:"finalizable"`
|
||||
Finalized bool `json:"finalized"`
|
||||
Status string `json:"status,omitempty"`
|
||||
CompletionReason string `json:"completionReason,omitempty"`
|
||||
EvidenceVerified bool `json:"evidenceVerified"`
|
||||
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||
@@ -672,19 +702,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||
if assistantMessageID != "" {
|
||||
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
||||
}
|
||||
} else {
|
||||
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
if assistantMessageID == "" {
|
||||
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
|
||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
||||
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
||||
}
|
||||
return resultMA.Response, conversationID, nil
|
||||
return responseText, conversationID, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||
@@ -815,6 +846,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
|
||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||
|
||||
robotMode := config.NormalizeAgentMode(agentMode)
|
||||
if err := h.db.SetConversationAgentMode(conversationID, robotMode); err != nil {
|
||||
h.logger.Warn("机器人:更新对话模式失败", zap.String("conversationId", conversationID), zap.String("agentMode", robotMode), zap.Error(err))
|
||||
}
|
||||
switch robotMode {
|
||||
case "eino_single":
|
||||
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
||||
@@ -1553,7 +1587,7 @@ func (h *AgentHandler) CancelAgentLoop(c *gin.Context) {
|
||||
|
||||
var cause error = ErrTaskCancelled
|
||||
msg := "已提交取消请求,任务将在当前步骤完成后停止。"
|
||||
h.cancelActiveMCPToolForConversation(req.ConversationID)
|
||||
h.cancelRunningMCPToolsForConversation(req.ConversationID)
|
||||
h.tasks.AbortActiveEinoExecute(req.ConversationID, "")
|
||||
ok, err := h.tasks.CancelTask(req.ConversationID, cause)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AssetHandler struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
maxAssetImportBatch = 100000
|
||||
maxAssetOperationBatch = 10000
|
||||
)
|
||||
|
||||
func NewAssetHandler(db *database.DB, logger *zap.Logger) *AssetHandler {
|
||||
return &AssetHandler{db: db, logger: logger}
|
||||
}
|
||||
|
||||
func assetAccess(c *gin.Context) database.RBACListAccess {
|
||||
if session, ok := security.CurrentSession(c); ok {
|
||||
return database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
|
||||
}
|
||||
return database.RBACListAccess{}
|
||||
}
|
||||
|
||||
func assetAccessForPermission(c *gin.Context, permission string) database.RBACListAccess {
|
||||
if session, ok := security.CurrentSession(c); ok {
|
||||
return database.RBACListAccess{UserID: session.UserID, Scope: session.ScopeFor(permission)}
|
||||
}
|
||||
return database.RBACListAccess{}
|
||||
}
|
||||
|
||||
type importAssetsRequest struct {
|
||||
Assets []*database.Asset `json:"assets" binding:"required"`
|
||||
Source string `json:"source"`
|
||||
SourceQuery string `json:"source_query"`
|
||||
}
|
||||
|
||||
type assetScanLink struct {
|
||||
AssetID string `json:"asset_id" binding:"required"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
QueueID string `json:"queue_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
type recordAssetScansRequest struct {
|
||||
Scans []assetScanLink `json:"scans" binding:"required"`
|
||||
}
|
||||
|
||||
type updateAssetsProjectRequest struct {
|
||||
AssetIDs []string `json:"asset_ids" binding:"required"`
|
||||
ProjectID string `json:"project_id"`
|
||||
}
|
||||
|
||||
type bulkUpdateAssetsRequest struct {
|
||||
AssetIDs []string `json:"asset_ids" binding:"required"`
|
||||
Status *string `json:"status"`
|
||||
ResponsiblePerson *string `json:"responsible_person"`
|
||||
Department *string `json:"department"`
|
||||
BusinessSystem *string `json:"business_system"`
|
||||
Environment *string `json:"environment"`
|
||||
Criticality *string `json:"criticality"`
|
||||
AddTags []string `json:"add_tags"`
|
||||
RemoveTags []string `json:"remove_tags"`
|
||||
}
|
||||
|
||||
type assetIDsRequest struct {
|
||||
AssetIDs []string `json:"asset_ids" binding:"required"`
|
||||
}
|
||||
|
||||
type mergeAssetsRequest struct {
|
||||
AssetIDs []string `json:"asset_ids" binding:"required"`
|
||||
PrimaryID string `json:"primary_id"`
|
||||
}
|
||||
|
||||
func (h *AssetHandler) Import(c *gin.Context) {
|
||||
var req importAssetsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Assets) == 0 || len(req.Assets) > maxAssetImportBatch {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "assets 数量必须在 1-100000 之间"})
|
||||
return
|
||||
}
|
||||
owner := ""
|
||||
allowGlobal := false
|
||||
if session, ok := security.CurrentSession(c); ok {
|
||||
owner = session.UserID
|
||||
allowGlobal = session.Scope == database.RBACScopeAll
|
||||
}
|
||||
for _, asset := range req.Assets {
|
||||
if asset == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(asset.ProjectID) != "" {
|
||||
if session, ok := security.CurrentSession(c); ok && !h.db.UserCanAccessResource(session.UserID, session.Scope, "project", strings.TrimSpace(asset.ProjectID)) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权绑定该项目"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(asset.Source) == "" {
|
||||
asset.Source = strings.TrimSpace(req.Source)
|
||||
}
|
||||
if strings.TrimSpace(asset.SourceQuery) == "" {
|
||||
asset.SourceQuery = strings.TrimSpace(req.SourceQuery)
|
||||
}
|
||||
}
|
||||
result, err := h.db.UpsertAssets(req.Assets, owner, allowGlobal)
|
||||
if err != nil {
|
||||
var validationErr *database.AssetValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("导入资产失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *AssetHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
filter, err := assetListFilterFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
assets, total, err := h.db.ListAssets(pageSize, (page-1)*pageSize, filter, assetAccess(c))
|
||||
if err != nil {
|
||||
h.logger.Error("加载资产失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"assets": assets, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
|
||||
}
|
||||
|
||||
func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error) {
|
||||
filter := database.AssetListFilter{
|
||||
Search: strings.TrimSpace(c.Query("q")), Status: strings.ToLower(strings.TrimSpace(c.Query("status"))),
|
||||
Protocol: strings.ToLower(strings.TrimSpace(c.Query("protocol"))), ProjectID: strings.TrimSpace(c.Query("project_id")),
|
||||
Source: strings.TrimSpace(c.Query("source")), Tag: strings.TrimSpace(c.Query("tag")), Host: strings.TrimSpace(c.Query("host")),
|
||||
IP: strings.TrimSpace(c.Query("ip")), Domain: strings.TrimSpace(c.Query("domain")), ScanState: strings.ToLower(strings.TrimSpace(c.Query("scan_state"))),
|
||||
SortBy: strings.ToLower(strings.TrimSpace(c.Query("sort_by"))), SortOrder: strings.ToLower(strings.TrimSpace(c.Query("sort_order"))),
|
||||
RiskLevel: strings.ToLower(strings.TrimSpace(c.Query("risk_level"))),
|
||||
Country: strings.TrimSpace(c.Query("country")), Province: strings.TrimSpace(c.Query("province")), City: strings.TrimSpace(c.Query("city")),
|
||||
ResponsiblePerson: strings.TrimSpace(c.Query("responsible_person")), Department: strings.TrimSpace(c.Query("department")),
|
||||
BusinessSystem: strings.TrimSpace(c.Query("business_system")), Environment: strings.ToLower(strings.TrimSpace(c.Query("environment"))),
|
||||
Criticality: strings.ToLower(strings.TrimSpace(c.Query("criticality"))),
|
||||
}
|
||||
if raw := strings.TrimSpace(c.Query("port")); raw != "" {
|
||||
port, err := strconv.Atoi(raw)
|
||||
if err != nil || port < 0 || port > 65535 {
|
||||
return filter, &assetQueryError{field: "port", value: raw}
|
||||
}
|
||||
filter.Port = &port
|
||||
}
|
||||
for field, target := range map[string]**int{
|
||||
"min_vulnerabilities": &filter.MinVulnerabilities,
|
||||
"max_vulnerabilities": &filter.MaxVulnerabilities,
|
||||
"scan_overdue_days": &filter.ScanOverdueDays,
|
||||
} {
|
||||
raw := strings.TrimSpace(c.Query(field))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 || (field == "scan_overdue_days" && value == 0) {
|
||||
return filter, &assetQueryError{field: field, value: raw}
|
||||
}
|
||||
*target = &value
|
||||
}
|
||||
var err error
|
||||
if filter.LastScanBefore, err = parseAssetQueryTime("last_scan_before", c.Query("last_scan_before")); err != nil {
|
||||
return filter, err
|
||||
}
|
||||
if filter.LastScanAfter, err = parseAssetQueryTime("last_scan_after", c.Query("last_scan_after")); err != nil {
|
||||
return filter, err
|
||||
}
|
||||
if filter.FirstSeenBefore, err = parseAssetQueryTime("first_seen_before", c.Query("first_seen_before")); err != nil {
|
||||
return filter, err
|
||||
}
|
||||
if filter.FirstSeenAfter, err = parseAssetQueryTime("first_seen_after", c.Query("first_seen_after")); err != nil {
|
||||
return filter, err
|
||||
}
|
||||
if filter.LastSeenBefore, err = parseAssetQueryTime("last_seen_before", c.Query("last_seen_before")); err != nil {
|
||||
return filter, err
|
||||
}
|
||||
if filter.LastSeenAfter, err = parseAssetQueryTime("last_seen_after", c.Query("last_seen_after")); err != nil {
|
||||
return filter, err
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
|
||||
// Selection resolves all assets matching the current filter for cross-page actions.
|
||||
func (h *AssetHandler) Selection(c *gin.Context) {
|
||||
filter, err := assetListFilterFromQuery(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
assets, total, err := h.db.ListAssetsForOperation(maxAssetOperationBatch, filter, assetAccess(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "total": total})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"assets": assets, "total": total})
|
||||
}
|
||||
|
||||
type assetQueryError struct{ field, value string }
|
||||
|
||||
func (e *assetQueryError) Error() string {
|
||||
return e.field + " 参数无效: " + e.value
|
||||
}
|
||||
|
||||
func parseAssetQueryTime(field, value string) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return &parsed, nil
|
||||
}
|
||||
}
|
||||
return nil, &assetQueryError{field: field, value: value}
|
||||
}
|
||||
|
||||
func (h *AssetHandler) Stats(c *gin.Context) {
|
||||
days := 30
|
||||
if raw := strings.TrimSpace(c.Query("days")); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || (parsed != 7 && parsed != 30 && parsed != 90) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "days 仅支持 7、30 或 90"})
|
||||
return
|
||||
}
|
||||
days = parsed
|
||||
}
|
||||
stats, err := h.db.GetAssetStats(assetAccess(c), days)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// RecordScans stores the execution link created by the asset-library scan action.
|
||||
func (h *AssetHandler) RecordScans(c *gin.Context) {
|
||||
var req recordAssetScansRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Scans) == 0 || len(req.Scans) > maxAssetOperationBatch {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scans 数量必须在 1-10000 之间"})
|
||||
return
|
||||
}
|
||||
access := assetAccess(c)
|
||||
for _, scan := range req.Scans {
|
||||
conversationID := strings.TrimSpace(scan.ConversationID)
|
||||
queueID := strings.TrimSpace(scan.QueueID)
|
||||
taskID := strings.TrimSpace(scan.TaskID)
|
||||
if conversationID == "" && taskID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "conversation_id 或 task_id 至少需要一个"})
|
||||
return
|
||||
}
|
||||
if taskID != "" && (queueID == "" || !h.db.BatchTaskBelongsToQueue(taskID, queueID)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "任务不属于指定队列"})
|
||||
return
|
||||
}
|
||||
if _, err := h.db.GetAsset(strings.TrimSpace(scan.AssetID), access); err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "资产不存在或无权扫描"})
|
||||
return
|
||||
}
|
||||
if session, ok := security.CurrentSession(c); ok {
|
||||
if id := conversationID; id != "" && !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权关联该对话"})
|
||||
return
|
||||
}
|
||||
if id := queueID; id != "" && !h.db.UserCanAccessResource(session.UserID, session.Scope, "batch_task", id) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权关联该任务队列"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, scan := range req.Scans {
|
||||
if err := h.db.MarkAssetScanned(scan.AssetID, scan.ConversationID, scan.QueueID, scan.TaskID, access); err != nil {
|
||||
h.logger.Error("记录资产扫描失败", zap.String("asset_id", scan.AssetID), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": len(req.Scans)})
|
||||
}
|
||||
|
||||
func (h *AssetHandler) Update(c *gin.Context) {
|
||||
var asset database.Asset
|
||||
if err := c.ShouldBindJSON(&asset); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if asset.ProjectID != "" {
|
||||
if session, ok := security.CurrentSession(c); ok && !h.db.UserCanAccessResource(session.UserID, session.Scope, "project", asset.ProjectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权绑定该项目"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.db.UpdateAsset(c.Param("id"), &asset, assetAccess(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := h.db.GetAsset(c.Param("id"), assetAccess(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "资产不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// UpdateProjectBinding replaces the project binding for a selected asset set.
|
||||
func (h *AssetHandler) UpdateProjectBinding(c *gin.Context) {
|
||||
var req updateAssetsProjectRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > maxAssetOperationBatch {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-10000 之间"})
|
||||
return
|
||||
}
|
||||
req.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||
if req.ProjectID != "" {
|
||||
if _, err := h.db.GetProject(req.ProjectID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "项目不存在"})
|
||||
return
|
||||
}
|
||||
if session, ok := security.CurrentSession(c); ok && !h.db.UserCanAccessResource(session.UserID, session.Scope, "project", req.ProjectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权绑定该项目"})
|
||||
return
|
||||
}
|
||||
}
|
||||
updated, err := h.db.UpdateAssetsProject(req.AssetIDs, req.ProjectID, assetAccess(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated, "project_id": req.ProjectID})
|
||||
}
|
||||
|
||||
func (h *AssetHandler) BulkUpdate(c *gin.Context) {
|
||||
var req bulkUpdateAssetsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > maxAssetOperationBatch {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-10000 之间"})
|
||||
return
|
||||
}
|
||||
updated, err := h.db.UpdateAssetsBulk(req.AssetIDs, database.AssetBulkPatch{
|
||||
Status: req.Status, ResponsiblePerson: req.ResponsiblePerson, Department: req.Department,
|
||||
BusinessSystem: req.BusinessSystem, Environment: req.Environment, Criticality: req.Criticality,
|
||||
AddTags: req.AddTags, RemoveTags: req.RemoveTags,
|
||||
}, assetAccess(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||
}
|
||||
|
||||
func (h *AssetHandler) BatchDelete(c *gin.Context) {
|
||||
var req assetIDsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > maxAssetOperationBatch {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-10000 之间"})
|
||||
return
|
||||
}
|
||||
deleted, err := h.db.DeleteAssets(req.AssetIDs, assetAccess(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
|
||||
}
|
||||
|
||||
func assetIdentityKeys(asset *database.Asset) map[string]struct{} {
|
||||
keys := map[string]struct{}{}
|
||||
if value := strings.ToLower(strings.TrimSpace(asset.Domain)); value != "" {
|
||||
keys["domain:"+value] = struct{}{}
|
||||
}
|
||||
if value := strings.ToLower(strings.Trim(strings.TrimSpace(asset.IP), "[]")); value != "" {
|
||||
keys["ip:"+value] = struct{}{}
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(asset.Host)); value != "" {
|
||||
keys["host:"+value] = struct{}{}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func shareAssetIdentity(left, right *database.Asset) bool {
|
||||
for key := range assetIdentityKeys(left) {
|
||||
if _, ok := assetIdentityKeys(right)[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Merge keeps the selected primary asset and safely combines compatible duplicate metadata.
|
||||
func (h *AssetHandler) Merge(c *gin.Context) {
|
||||
var req mergeAssetsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.AssetIDs) < 2 || len(req.AssetIDs) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "合并资产数量必须在 2-100 之间"})
|
||||
return
|
||||
}
|
||||
writeAccess := assetAccessForPermission(c, "asset:write")
|
||||
deleteAccess := assetAccessForPermission(c, "asset:delete")
|
||||
primaryID := strings.TrimSpace(req.PrimaryID)
|
||||
if primaryID == "" {
|
||||
primaryID = strings.TrimSpace(req.AssetIDs[0])
|
||||
}
|
||||
primary, err := h.db.GetAsset(primaryID, writeAccess)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "主资产不存在或无权访问"})
|
||||
return
|
||||
}
|
||||
others := make([]*database.Asset, 0, len(req.AssetIDs)-1)
|
||||
seen := map[string]struct{}{primaryID: {}}
|
||||
for _, id := range req.AssetIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == primaryID {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
item, err := h.db.GetAsset(id, writeAccess)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "部分资产不存在或无权访问"})
|
||||
return
|
||||
}
|
||||
if !shareAssetIdentity(primary, item) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "所选资产没有共同域名、IP 或 Host,不能判定为重复资产"})
|
||||
return
|
||||
}
|
||||
others = append(others, item)
|
||||
}
|
||||
if len(others) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "至少需要两个不同资产"})
|
||||
return
|
||||
}
|
||||
mergeText := func(dst *string, src string) {
|
||||
if strings.TrimSpace(*dst) == "" && strings.TrimSpace(src) != "" {
|
||||
*dst = src
|
||||
}
|
||||
}
|
||||
tagSet := map[string]struct{}{}
|
||||
for _, tag := range primary.Tags {
|
||||
tagSet[tag] = struct{}{}
|
||||
}
|
||||
for _, item := range others {
|
||||
mergeText(&primary.ProjectID, item.ProjectID)
|
||||
mergeText(&primary.Host, item.Host)
|
||||
mergeText(&primary.IP, item.IP)
|
||||
mergeText(&primary.Domain, item.Domain)
|
||||
mergeText(&primary.Protocol, item.Protocol)
|
||||
mergeText(&primary.Title, item.Title)
|
||||
mergeText(&primary.Server, item.Server)
|
||||
mergeText(&primary.Country, item.Country)
|
||||
mergeText(&primary.Province, item.Province)
|
||||
mergeText(&primary.City, item.City)
|
||||
mergeText(&primary.ResponsiblePerson, item.ResponsiblePerson)
|
||||
mergeText(&primary.Department, item.Department)
|
||||
mergeText(&primary.BusinessSystem, item.BusinessSystem)
|
||||
mergeText(&primary.Environment, item.Environment)
|
||||
mergeText(&primary.Criticality, item.Criticality)
|
||||
for _, tag := range item.Tags {
|
||||
tagSet[tag] = struct{}{}
|
||||
}
|
||||
}
|
||||
primary.Tags = primary.Tags[:0]
|
||||
for tag := range tagSet {
|
||||
primary.Tags = append(primary.Tags, tag)
|
||||
}
|
||||
if len(primary.Tags) > 30 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "合并后标签超过 30 个"})
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(others))
|
||||
for _, item := range others {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
merged, err := h.db.MergeAssets(primary, ids, writeAccess, deleteAccess)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, _ := h.db.GetAsset(primary.ID, writeAccess)
|
||||
c.JSON(http.StatusOK, gin.H{"merged": merged, "asset": updated})
|
||||
}
|
||||
|
||||
func (h *AssetHandler) Delete(c *gin.Context) {
|
||||
if err := h.db.DeleteAsset(c.Param("id"), assetAccess(c)); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "资产不存在或无权删除"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAssetListPaginatesWithinProject(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "asset-list-pagination.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
project, err := db.CreateProject(&database.Project{Name: "Paged Project", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherProject, err := db.CreateProject(&database.Project{Name: "Other Project", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assets := make([]*database.Asset, 0, 8)
|
||||
for i := 1; i <= 7; i++ {
|
||||
assets = append(assets, &database.Asset{
|
||||
ProjectID: project.ID,
|
||||
IP: fmt.Sprintf("192.0.2.%d", i),
|
||||
Port: 80,
|
||||
Protocol: "http",
|
||||
})
|
||||
}
|
||||
assets = append(assets, &database.Asset{
|
||||
ProjectID: otherProject.ID,
|
||||
IP: "198.51.100.1",
|
||||
Port: 443,
|
||||
Protocol: "https",
|
||||
})
|
||||
if _, err := db.UpsertAssets(assets, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/api/assets", NewAssetHandler(db, zap.NewNop()).List)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/assets?project_id="+project.ID+"&page=2&page_size=3", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Assets []*database.Asset `json:"assets"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Total != 7 || payload.Page != 2 || payload.PageSize != 3 || payload.TotalPages != 3 {
|
||||
t.Fatalf("unexpected pagination: total=%d page=%d page_size=%d total_pages=%d",
|
||||
payload.Total, payload.Page, payload.PageSize, payload.TotalPages)
|
||||
}
|
||||
if len(payload.Assets) != 3 {
|
||||
t.Fatalf("expected 3 assets on page 2, got %d", len(payload.Assets))
|
||||
}
|
||||
for _, asset := range payload.Assets {
|
||||
if asset.ProjectID != project.ID {
|
||||
t.Fatalf("asset from another project leaked into page: %#v", asset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,15 @@ import (
|
||||
|
||||
func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter {
|
||||
filter := database.ListAuditLogsFilter{
|
||||
Level: c.Query("level"),
|
||||
Category: c.Query("category"),
|
||||
Action: c.Query("action"),
|
||||
Result: c.Query("result"),
|
||||
Query: c.Query("q"),
|
||||
ResourceType: c.Query("resource_type"),
|
||||
ResourceID: c.Query("resource_id"),
|
||||
Actor: c.Query("actor"),
|
||||
Level: c.Query("level"),
|
||||
Category: c.Query("category"),
|
||||
Action: c.Query("action"),
|
||||
Result: c.Query("result"),
|
||||
Query: c.Query("q"),
|
||||
ResourceType: c.Query("resource_type"),
|
||||
ResourceID: c.Query("resource_id"),
|
||||
RelatedUserID: c.Query("related_user_id"),
|
||||
}
|
||||
if since := c.Query("since"); since != "" {
|
||||
if t, err := database.ParseRFC3339Time(since); err == nil {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestAuditFilterFromQueryIncludesActorAndMemberFilters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = httptest.NewRequest("GET", "/api/audit/logs?actor=operator-user&action=assign_resource&resource_type=conversation&related_user_id=user-1", nil)
|
||||
|
||||
filter := auditFilterFromQuery(context)
|
||||
if filter.Actor != "operator-user" || filter.Action != "assign_resource" || filter.ResourceType != "conversation" || filter.RelatedUserID != "user-1" {
|
||||
t.Fatalf("filter = %#v", filter)
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,11 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
useBatchMulti = true
|
||||
batchOrch = "deep"
|
||||
}
|
||||
if useBatchMulti {
|
||||
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
|
||||
} else {
|
||||
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
|
||||
}
|
||||
|
||||
var resultMA *multiagent.RunResult
|
||||
var runErr error
|
||||
@@ -268,19 +273,38 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
|
||||
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||
|
||||
resText := resultMA.Response
|
||||
mcpIDs := resultMA.MCPExecutionIDs
|
||||
lastIn := resultMA.LastAgentTraceInput
|
||||
lastOut := resultMA.LastAgentTraceOutput
|
||||
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(lastIn)
|
||||
agentMode := "batch_eino_single"
|
||||
if useBatchMulti {
|
||||
agentMode = "batch_eino_" + batchOrch
|
||||
}
|
||||
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
||||
resText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
resText = finalizationBlockedMessage(decision)
|
||||
finishStatus = decision.Status
|
||||
sendEvent("finalization_check", resText, decision)
|
||||
}
|
||||
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": agentMode,
|
||||
"mcpExecutionIds": mcpIDs,
|
||||
"batchQueueId": queueID,
|
||||
"batchTaskId": task.ID,
|
||||
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||
}))
|
||||
|
||||
if assistantMessageID != "" {
|
||||
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
|
||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
||||
if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
||||
if assistantMessageID == "" {
|
||||
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||
} else if !decision.Finalizable {
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -290,6 +314,10 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
}
|
||||
}
|
||||
|
||||
if !decision.Finalizable {
|
||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusFailed, resText, finalizationCheckMessage(decision), conversationID)
|
||||
return
|
||||
}
|
||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
||||
}
|
||||
|
||||
|
||||
+96
-7
@@ -60,7 +60,7 @@ func (h *C2Handler) SetManager(m *c2.Manager) {
|
||||
|
||||
// ListListeners 获取监听器列表
|
||||
func (h *C2Handler) ListListeners(c *gin.Context) {
|
||||
listeners, err := h.mgr().DB().ListC2ListenersForAccess(c2AccessFromContext(c))
|
||||
listeners, err := h.mgr().DB().ListC2ListenersForAccess(c2AccessFromContext(c), c.Query("project_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -77,6 +77,7 @@ func (h *C2Handler) ListListeners(c *gin.Context) {
|
||||
func (h *C2Handler) CreateListener(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
BindHost string `json:"bind_host"`
|
||||
BindPort int `json:"bind_port"`
|
||||
@@ -92,6 +93,7 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
|
||||
|
||||
input := c2.CreateListenerInput{
|
||||
Name: req.Name,
|
||||
ProjectID: req.ProjectID,
|
||||
Type: req.Type,
|
||||
BindHost: req.BindHost,
|
||||
BindPort: req.BindPort,
|
||||
@@ -100,6 +102,10 @@ func (h *C2Handler) CreateListener(c *gin.Context) {
|
||||
Config: req.Config,
|
||||
CallbackHost: strings.TrimSpace(req.CallbackHost),
|
||||
}
|
||||
if !h.canAccessProject(c, input.ProjectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
listener, err := h.mgr().CreateListener(input)
|
||||
if err != nil {
|
||||
@@ -158,6 +164,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ProjectID string `json:"project_id"`
|
||||
BindHost string `json:"bind_host"`
|
||||
BindPort int `json:"bind_port"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
@@ -179,6 +186,7 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
||||
}
|
||||
|
||||
listener.Name = req.Name
|
||||
listener.ProjectID = strings.TrimSpace(req.ProjectID)
|
||||
listener.BindHost = req.BindHost
|
||||
listener.BindPort = req.BindPort
|
||||
listener.ProfileID = req.ProfileID
|
||||
@@ -187,6 +195,10 @@ func (h *C2Handler) UpdateListener(c *gin.Context) {
|
||||
cfgJSON, _ := json.Marshal(req.Config)
|
||||
listener.ConfigJSON = string(cfgJSON)
|
||||
}
|
||||
if !h.canAccessProject(c, listener.ProjectID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "project access denied"})
|
||||
return
|
||||
}
|
||||
if req.CallbackHost != nil {
|
||||
cfg := &c2.ListenerConfig{}
|
||||
raw := strings.TrimSpace(listener.ConfigJSON)
|
||||
@@ -275,6 +287,7 @@ func (h *C2Handler) StopListener(c *gin.Context) {
|
||||
func (h *C2Handler) ListSessions(c *gin.Context) {
|
||||
filter := database.ListC2SessionsFilter{
|
||||
ListenerID: c.Query("listener_id"),
|
||||
ProjectID: c.Query("project_id"),
|
||||
Status: c.Query("status"),
|
||||
OS: c.Query("os"),
|
||||
Search: c.Query("search"),
|
||||
@@ -404,6 +417,47 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// SetSessionNote 更新会话备注(仅服务端元数据,不下发植入体)
|
||||
func (h *C2Handler) SetSessionNote(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
note := strings.TrimSpace(req.Note)
|
||||
if len(note) > 2000 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "note too long (max 2000 characters)"})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.mgr().DB().GetC2Session(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if session == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.mgr().DB().SetC2SessionNote(id, note); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "c2", "session_note", "更新 C2 会话备注", "c2_session", id, map[string]interface{}{
|
||||
"note_len": len(note),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"updated": true,
|
||||
"note": note,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 任务 API
|
||||
// ============================================================================
|
||||
@@ -412,7 +466,14 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||
func (h *C2Handler) ListTasks(c *gin.Context) {
|
||||
filter := database.ListC2TasksFilter{
|
||||
SessionID: c.Query("session_id"),
|
||||
ProjectID: c.Query("project_id"),
|
||||
Status: c.Query("status"),
|
||||
TaskType: c.Query("task_type"),
|
||||
}
|
||||
if since := c.Query("since"); since != "" {
|
||||
if t, err := database.ParseRFC3339Time(since); err == nil {
|
||||
filter.Since = &t
|
||||
}
|
||||
}
|
||||
|
||||
paginated := false
|
||||
@@ -447,7 +508,7 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 仪表盘「待审任务」为全局 queued/pending 数量,与列表 session 过滤无关
|
||||
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", access)
|
||||
pendingN, _ := h.mgr().DB().CountC2TasksQueuedOrPendingForAccess("", filter.ProjectID, access)
|
||||
|
||||
if !paginated {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -462,9 +523,15 @@ func (h *C2Handler) ListTasks(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
statusCounts, err := h.mgr().DB().CountC2TasksByStatusForAccess(filter, access)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": tasks,
|
||||
"total": total,
|
||||
"status_counts": statusCounts,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"pending_queued_count": pendingN,
|
||||
@@ -784,11 +851,12 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
|
||||
filter := database.ListC2EventsFilter{
|
||||
Level: c.Query("level"),
|
||||
Category: c.Query("category"),
|
||||
ProjectID: c.Query("project_id"),
|
||||
SessionID: c.Query("session_id"),
|
||||
TaskID: c.Query("task_id"),
|
||||
}
|
||||
if since := c.Query("since"); since != "" {
|
||||
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
||||
if t, err := database.ParseRFC3339Time(since); err == nil {
|
||||
filter.Since = &t
|
||||
}
|
||||
}
|
||||
@@ -832,11 +900,17 @@ func (h *C2Handler) ListEvents(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
levelCounts, err := h.mgr().DB().CountC2EventsByLevelForAccess(filter, access)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"events": events,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"events": events,
|
||||
"total": total,
|
||||
"level_counts": levelCounts,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1115,6 +1189,21 @@ func c2AccessFromContext(c *gin.Context) database.RBACListAccess {
|
||||
return database.RBACListAccess{UserID: session.UserID, Scope: session.Scope}
|
||||
}
|
||||
|
||||
func (h *C2Handler) canAccessProject(c *gin.Context, projectID string) bool {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return true
|
||||
}
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if session.Scope == database.RBACScopeAll {
|
||||
return true
|
||||
}
|
||||
return h.mgr().DB().UserCanAccessResource(session.UserID, session.Scope, "project", projectID)
|
||||
}
|
||||
|
||||
func (h *C2Handler) c2ResourceAllowed(c *gin.Context, resourceType, resourceID string) bool {
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+208
-17
@@ -258,17 +258,21 @@ func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) err
|
||||
|
||||
// GetConfigResponse 获取配置响应
|
||||
type GetConfigResponse struct {
|
||||
OpenAI config.OpenAIConfig `json:"openai"`
|
||||
Vision config.VisionConfig `json:"vision"`
|
||||
FOFA config.FofaConfig `json:"fofa"`
|
||||
MCP config.MCPConfig `json:"mcp"`
|
||||
Tools []ToolConfigInfo `json:"tools"`
|
||||
Agent config.AgentConfig `json:"agent"`
|
||||
Hitl config.HitlConfig `json:"hitl,omitempty"`
|
||||
Knowledge config.KnowledgeConfig `json:"knowledge"`
|
||||
Robots config.RobotsConfig `json:"robots,omitempty"`
|
||||
MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"`
|
||||
C2 config.C2Public `json:"c2"`
|
||||
AI config.AIConfig `json:"ai"`
|
||||
OpenAI config.OpenAIConfig `json:"openai"`
|
||||
Vision config.VisionConfig `json:"vision"`
|
||||
FOFA config.FofaConfig `json:"fofa"`
|
||||
ZoomEye config.SpaceSearchConfig `json:"zoomeye"`
|
||||
Quake config.SpaceSearchConfig `json:"quake"`
|
||||
Shodan config.SpaceSearchConfig `json:"shodan"`
|
||||
MCP config.MCPConfig `json:"mcp"`
|
||||
Tools []ToolConfigInfo `json:"tools"`
|
||||
Agent config.AgentConfig `json:"agent"`
|
||||
Hitl config.HitlConfig `json:"hitl,omitempty"`
|
||||
Knowledge config.KnowledgeConfig `json:"knowledge"`
|
||||
Robots config.RobotsConfig `json:"robots,omitempty"`
|
||||
MultiAgent config.MultiAgentPublic `json:"multi_agent,omitempty"`
|
||||
C2 config.C2Public `json:"c2"`
|
||||
}
|
||||
|
||||
// ToolConfigInfo 工具配置信息
|
||||
@@ -360,9 +364,13 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, GetConfigResponse{
|
||||
AI: h.config.AI,
|
||||
OpenAI: h.config.OpenAI,
|
||||
Vision: h.config.Vision,
|
||||
FOFA: h.config.FOFA,
|
||||
ZoomEye: h.config.ZoomEye,
|
||||
Quake: h.config.Quake,
|
||||
Shodan: h.config.Shodan,
|
||||
MCP: h.config.MCP,
|
||||
Tools: tools,
|
||||
Agent: h.config.Agent,
|
||||
@@ -700,9 +708,13 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
|
||||
|
||||
// UpdateConfigRequest 更新配置请求
|
||||
type UpdateConfigRequest struct {
|
||||
AI *config.AIConfig `json:"ai,omitempty"`
|
||||
OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
|
||||
Vision *config.VisionConfig `json:"vision,omitempty"`
|
||||
FOFA *config.FofaConfig `json:"fofa,omitempty"`
|
||||
ZoomEye *config.SpaceSearchConfig `json:"zoomeye,omitempty"`
|
||||
Quake *config.SpaceSearchConfig `json:"quake,omitempty"`
|
||||
Shodan *config.SpaceSearchConfig `json:"shodan,omitempty"`
|
||||
MCP *config.MCPConfig `json:"mcp,omitempty"`
|
||||
Tools []ToolEnableStatus `json:"tools,omitempty"`
|
||||
Agent *AgentConfigUpdate `json:"agent,omitempty"`
|
||||
@@ -716,9 +728,14 @@ type UpdateConfigRequest struct {
|
||||
// AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。
|
||||
// 避免旧版「整包替换 *AgentConfig」时,未传的整型字段被反序列化为 0 误覆盖(例如 tool_timeout_minutes 变成 0)。
|
||||
type AgentConfigUpdate struct {
|
||||
MaxIterations *int `json:"max_iterations,omitempty"`
|
||||
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
|
||||
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
|
||||
MaxIterations *int `json:"max_iterations,omitempty"`
|
||||
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
|
||||
ToolWaitTimeoutSeconds *int `json:"tool_wait_timeout_seconds,omitempty"`
|
||||
ExternalMCPMaxConcurrentPerServer *int `json:"external_mcp_max_concurrent_per_server,omitempty"`
|
||||
ExternalMCPMaxConcurrentTotal *int `json:"external_mcp_max_concurrent_total,omitempty"`
|
||||
ExternalMCPCircuitFailureThreshold *int `json:"external_mcp_circuit_failure_threshold,omitempty"`
|
||||
ExternalMCPCircuitCooldownSeconds *int `json:"external_mcp_circuit_cooldown_seconds,omitempty"`
|
||||
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
|
||||
}
|
||||
|
||||
func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
|
||||
@@ -731,6 +748,21 @@ func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
|
||||
if src.ToolTimeoutMinutes != nil {
|
||||
dst.ToolTimeoutMinutes = *src.ToolTimeoutMinutes
|
||||
}
|
||||
if src.ToolWaitTimeoutSeconds != nil {
|
||||
dst.ToolWaitTimeoutSeconds = *src.ToolWaitTimeoutSeconds
|
||||
}
|
||||
if src.ExternalMCPMaxConcurrentPerServer != nil {
|
||||
dst.ExternalMCPMaxConcurrentPerServer = *src.ExternalMCPMaxConcurrentPerServer
|
||||
}
|
||||
if src.ExternalMCPMaxConcurrentTotal != nil {
|
||||
dst.ExternalMCPMaxConcurrentTotal = *src.ExternalMCPMaxConcurrentTotal
|
||||
}
|
||||
if src.ExternalMCPCircuitFailureThreshold != nil {
|
||||
dst.ExternalMCPCircuitFailureThreshold = *src.ExternalMCPCircuitFailureThreshold
|
||||
}
|
||||
if src.ExternalMCPCircuitCooldownSeconds != nil {
|
||||
dst.ExternalMCPCircuitCooldownSeconds = *src.ExternalMCPCircuitCooldownSeconds
|
||||
}
|
||||
if src.SystemPromptPath != nil {
|
||||
dst.SystemPromptPath = *src.SystemPromptPath
|
||||
}
|
||||
@@ -756,8 +788,20 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// 更新OpenAI配置
|
||||
if req.AI != nil {
|
||||
h.config.AI = *req.AI
|
||||
h.config.ApplyDefaultAIChannel()
|
||||
h.logger.Info("更新 AI 通道配置",
|
||||
zap.String("default_channel", h.config.AI.DefaultChannel),
|
||||
zap.Int("channels", len(h.config.AI.Channels)),
|
||||
)
|
||||
}
|
||||
if req.OpenAI != nil {
|
||||
h.config.OpenAI = *req.OpenAI
|
||||
h.config.AI.EnsureDefaultFromOpenAI(h.config.OpenAI)
|
||||
if def := config.NormalizeAIChannelID(h.config.AI.DefaultChannel); def != "" {
|
||||
h.config.AI.Channels[def] = config.AIChannelFromOpenAI(def, "Default", h.config.OpenAI)
|
||||
}
|
||||
h.logger.Info("更新OpenAI配置",
|
||||
zap.String("base_url", h.config.OpenAI.BaseURL),
|
||||
zap.String("model", h.config.OpenAI.Model),
|
||||
@@ -775,7 +819,19 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
// 更新FOFA配置
|
||||
if req.FOFA != nil {
|
||||
h.config.FOFA = *req.FOFA
|
||||
h.logger.Info("更新FOFA配置", zap.String("email", h.config.FOFA.Email))
|
||||
h.logger.Info("更新FOFA配置", zap.String("base_url", h.config.FOFA.BaseURL))
|
||||
}
|
||||
if req.ZoomEye != nil {
|
||||
h.config.ZoomEye = *req.ZoomEye
|
||||
h.logger.Info("更新ZoomEye配置", zap.String("base_url", h.config.ZoomEye.BaseURL))
|
||||
}
|
||||
if req.Quake != nil {
|
||||
h.config.Quake = *req.Quake
|
||||
h.logger.Info("更新Quake配置", zap.String("base_url", h.config.Quake.BaseURL))
|
||||
}
|
||||
if req.Shodan != nil {
|
||||
h.config.Shodan = *req.Shodan
|
||||
h.logger.Info("更新Shodan配置", zap.String("base_url", h.config.Shodan.BaseURL))
|
||||
}
|
||||
|
||||
// 更新MCP配置
|
||||
@@ -794,12 +850,35 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
h.logger.Info("更新Agent配置",
|
||||
zap.Int("max_iterations", h.config.Agent.MaxIterations),
|
||||
zap.Int("tool_timeout_minutes", h.config.Agent.ToolTimeoutMinutes),
|
||||
zap.Int("tool_wait_timeout_seconds", h.config.Agent.ToolWaitTimeoutSeconds),
|
||||
zap.Int("external_mcp_max_concurrent_per_server", h.config.Agent.ExternalMCPMaxConcurrentPerServer),
|
||||
zap.Int("external_mcp_max_concurrent_total", h.config.Agent.ExternalMCPMaxConcurrentTotal),
|
||||
zap.Int("external_mcp_circuit_failure_threshold", h.config.Agent.ExternalMCPCircuitFailureThreshold),
|
||||
zap.Int("external_mcp_circuit_cooldown_seconds", h.config.Agent.ExternalMCPCircuitCooldownSeconds),
|
||||
)
|
||||
if h.agent != nil && req.Agent.MaxIterations != nil {
|
||||
h.agent.UpdateMaxIterations(h.config.Agent.MaxIterations)
|
||||
}
|
||||
if h.executor != nil {
|
||||
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
}
|
||||
if h.mcpServer != nil {
|
||||
h.mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(h.config.Agent.ToolTimeoutMinutes)
|
||||
h.mcpServer.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
|
||||
h.mcpServer.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.mcpServer.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
}
|
||||
if h.externalMCPMgr != nil {
|
||||
h.externalMCPMgr.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
|
||||
h.externalMCPMgr.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.externalMCPMgr.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
h.externalMCPMgr.ConfigureResilience(mcp.ExternalMCPResilienceConfig{
|
||||
MaxConcurrentPerServer: h.config.Agent.ExternalMCPMaxConcurrentPerServer,
|
||||
MaxConcurrentTotal: h.config.Agent.ExternalMCPMaxConcurrentTotal,
|
||||
CircuitFailureThreshold: h.config.Agent.ExternalMCPCircuitFailureThreshold,
|
||||
CircuitCooldown: time.Duration(h.config.Agent.ExternalMCPCircuitCooldownSeconds) * time.Second,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1450,7 +1529,10 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
h.mcpServer.ClearTools()
|
||||
|
||||
// 重新注册安全工具
|
||||
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
h.executor.RegisterTools(h.mcpServer)
|
||||
mcp.RegisterExecutionControlTools(h.mcpServer, h.externalMCPMgr)
|
||||
|
||||
// 重新注册漏洞记录工具(内置工具,必须注册)
|
||||
if h.vulnerabilityToolRegistrar != nil {
|
||||
@@ -1521,6 +1603,24 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
}
|
||||
if h.mcpServer != nil {
|
||||
h.mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(h.config.Agent.ToolTimeoutMinutes)
|
||||
h.mcpServer.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
|
||||
h.mcpServer.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.mcpServer.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
}
|
||||
if h.executor != nil {
|
||||
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
}
|
||||
if h.externalMCPMgr != nil {
|
||||
h.externalMCPMgr.ConfigureToolWaitTimeoutSeconds(h.config.Agent.ToolWaitTimeoutSeconds)
|
||||
h.externalMCPMgr.ConfigureToolResultMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.externalMCPMgr.ConfigureToolResultSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
h.externalMCPMgr.ConfigureResilience(mcp.ExternalMCPResilienceConfig{
|
||||
MaxConcurrentPerServer: h.config.Agent.ExternalMCPMaxConcurrentPerServer,
|
||||
MaxConcurrentTotal: h.config.Agent.ExternalMCPMaxConcurrentTotal,
|
||||
CircuitFailureThreshold: h.config.Agent.ExternalMCPCircuitFailureThreshold,
|
||||
CircuitCooldown: time.Duration(h.config.Agent.ExternalMCPCircuitCooldownSeconds) * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
// 更新AttackChainHandler的OpenAI配置
|
||||
@@ -1598,9 +1698,13 @@ func (h *ConfigHandler) saveConfig() error {
|
||||
|
||||
updateAgentConfig(root, h.config.Agent)
|
||||
updateMCPConfig(root, h.config.MCP)
|
||||
updateOpenAIConfig(root, h.config.OpenAI)
|
||||
updateAIConfig(root, h.config.AI)
|
||||
removeKeyFromMap(root.Content[0], "openai")
|
||||
updateVisionConfig(root, h.config.Vision)
|
||||
updateFOFAConfig(root, h.config.FOFA)
|
||||
updateSpaceSearchConfig(root, "zoomeye", h.config.ZoomEye)
|
||||
updateSpaceSearchConfig(root, "quake", h.config.Quake)
|
||||
updateSpaceSearchConfig(root, "shodan", h.config.Shodan)
|
||||
updateKnowledgeConfig(root, h.config.Knowledge)
|
||||
updateC2Config(root, h.config.C2)
|
||||
updateRobotsConfig(root, h.config.Robots)
|
||||
@@ -1707,6 +1811,11 @@ func updateAgentConfig(doc *yaml.Node, agent config.AgentConfig) {
|
||||
agentNode := ensureMap(root, "agent")
|
||||
setIntInMap(agentNode, "max_iterations", agent.MaxIterations)
|
||||
setIntInMap(agentNode, "tool_timeout_minutes", agent.ToolTimeoutMinutes)
|
||||
setIntInMap(agentNode, "tool_wait_timeout_seconds", agent.ToolWaitTimeoutSeconds)
|
||||
setIntInMap(agentNode, "external_mcp_max_concurrent_per_server", agent.ExternalMCPMaxConcurrentPerServer)
|
||||
setIntInMap(agentNode, "external_mcp_max_concurrent_total", agent.ExternalMCPMaxConcurrentTotal)
|
||||
setIntInMap(agentNode, "external_mcp_circuit_failure_threshold", agent.ExternalMCPCircuitFailureThreshold)
|
||||
setIntInMap(agentNode, "external_mcp_circuit_cooldown_seconds", agent.ExternalMCPCircuitCooldownSeconds)
|
||||
setStringInMap(agentNode, "system_prompt_path", agent.SystemPromptPath)
|
||||
}
|
||||
|
||||
@@ -1784,14 +1893,84 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
func updateAIConfig(doc *yaml.Node, cfg config.AIConfig) {
|
||||
root := doc.Content[0]
|
||||
aiNode := ensureMap(root, "ai")
|
||||
if strings.TrimSpace(cfg.DefaultChannel) != "" {
|
||||
setStringInMap(aiNode, "default_channel", config.NormalizeAIChannelID(cfg.DefaultChannel))
|
||||
}
|
||||
channelsNode := ensureMap(aiNode, "channels")
|
||||
channelsNode.Content = nil
|
||||
normalized := make(map[string]config.AIChannelConfig, len(cfg.Channels))
|
||||
ids := make([]string, 0, len(cfg.Channels))
|
||||
for id, ch := range cfg.Channels {
|
||||
nid := config.NormalizeAIChannelID(id)
|
||||
if nid == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := normalized[nid]; !exists {
|
||||
ids = append(ids, nid)
|
||||
}
|
||||
normalized[nid] = ch
|
||||
}
|
||||
sort.Strings(ids)
|
||||
seen := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ch := normalized[id]
|
||||
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: id}
|
||||
channelNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
channelsNode.Content = append(channelsNode.Content, keyNode, channelNode)
|
||||
setStringInMap(channelNode, "name", ch.Name)
|
||||
if strings.TrimSpace(ch.Provider) != "" {
|
||||
setStringInMap(channelNode, "provider", ch.Provider)
|
||||
}
|
||||
setStringInMap(channelNode, "api_key", ch.APIKey)
|
||||
setStringInMap(channelNode, "base_url", ch.BaseURL)
|
||||
setStringInMap(channelNode, "model", ch.Model)
|
||||
if ch.MaxTotalTokens > 0 {
|
||||
setIntInMap(channelNode, "max_total_tokens", ch.MaxTotalTokens)
|
||||
}
|
||||
if ch.MaxCompletionTokens > 0 {
|
||||
setIntInMap(channelNode, "max_completion_tokens", ch.MaxCompletionTokens)
|
||||
}
|
||||
rn := ensureMap(channelNode, "reasoning")
|
||||
if strings.TrimSpace(ch.Reasoning.Mode) != "" {
|
||||
setStringInMap(rn, "mode", ch.Reasoning.Mode)
|
||||
}
|
||||
if strings.TrimSpace(ch.Reasoning.Effort) != "" {
|
||||
setStringInMap(rn, "effort", ch.Reasoning.Effort)
|
||||
}
|
||||
if ch.Reasoning.AllowClientReasoning != nil {
|
||||
setBoolInMap(rn, "allow_client_reasoning", *ch.Reasoning.AllowClientReasoning)
|
||||
}
|
||||
if strings.TrimSpace(ch.Reasoning.Profile) != "" {
|
||||
setStringInMap(rn, "profile", ch.Reasoning.Profile)
|
||||
}
|
||||
if len(rn.Content) == 0 {
|
||||
removeKeyFromMap(channelNode, "reasoning")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
|
||||
root := doc.Content[0]
|
||||
fofaNode := ensureMap(root, "fofa")
|
||||
setStringInMap(fofaNode, "base_url", cfg.BaseURL)
|
||||
setStringInMap(fofaNode, "email", cfg.Email)
|
||||
removeKeyFromMap(fofaNode, "email")
|
||||
setStringInMap(fofaNode, "api_key", cfg.APIKey)
|
||||
}
|
||||
|
||||
func updateSpaceSearchConfig(doc *yaml.Node, key string, cfg config.SpaceSearchConfig) {
|
||||
root := doc.Content[0]
|
||||
node := ensureMap(root, key)
|
||||
setStringInMap(node, "base_url", cfg.BaseURL)
|
||||
setStringInMap(node, "api_key", cfg.APIKey)
|
||||
}
|
||||
|
||||
func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
||||
root := doc.Content[0]
|
||||
knowledgeNode := ensureMap(root, "knowledge")
|
||||
@@ -2102,6 +2281,18 @@ func setStringInMap(mapNode *yaml.Node, key, value string) {
|
||||
valueNode.Value = value
|
||||
}
|
||||
|
||||
func removeKeyFromMap(mapNode *yaml.Node, key string) {
|
||||
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
for i := 0; i+1 < len(mapNode.Content); i += 2 {
|
||||
if mapNode.Content[i].Value == key {
|
||||
mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setStringSliceInMap(mapNode *yaml.Node, key string, values []string) {
|
||||
_, valueNode := ensureKeyValue(mapNode, key)
|
||||
valueNode.Kind = yaml.SequenceNode
|
||||
|
||||
@@ -373,6 +373,7 @@ func summarizeProcessDetailData(eventType string, data interface{}) interface{}
|
||||
"success": true, "isError": true, "executionId": true,
|
||||
"einoAgent": true, "einoRole": true, "einoScope": true, "orchestration": true,
|
||||
"agentFacing": true,
|
||||
"status": true, "modelFacingIsError": true, "resultPreview": true,
|
||||
}
|
||||
out := make(map[string]interface{}, len(allow)+1)
|
||||
for k, v := range m {
|
||||
|
||||
@@ -68,15 +68,15 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
|
||||
if progressCallback != nil {
|
||||
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"attempt": *attempt,
|
||||
"maxAttempts": maxAttempts,
|
||||
"contextSource": "empty_response_continue",
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"attempt": *attempt,
|
||||
"maxAttempts": maxAttempts,
|
||||
"contextSource": "empty_response_continue",
|
||||
"contextInjection": false,
|
||||
})
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
@@ -149,6 +150,14 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
runCfg, resolvedAIChannelID, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
taskStatus = "failed"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
sendEvent("error", err.Error(), nil)
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
@@ -181,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset int
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -222,8 +233,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtxLoop,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -236,6 +247,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.agentSessionContextBlock(conversationID),
|
||||
)
|
||||
_ = resolvedAIChannelID
|
||||
|
||||
if result != nil && len(result.MCPExecutionIDs) > 0 {
|
||||
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
|
||||
@@ -249,6 +261,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||
continue
|
||||
}
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||
continue
|
||||
}
|
||||
timeoutCancel()
|
||||
break
|
||||
}
|
||||
@@ -349,9 +368,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
|
||||
timeoutCancel()
|
||||
|
||||
if assistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
if decision.CompletionReason == "" {
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
|
||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||
@@ -359,12 +379,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
sendEvent("response", result.Response, map[string]interface{}{
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
sendEvent("finalization_check", responseText, decision)
|
||||
taskStatus = decision.Status
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
}
|
||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
})
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
|
||||
@@ -410,16 +437,24 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器配置未加载"})
|
||||
return
|
||||
}
|
||||
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
curHist := prep.History
|
||||
curMsg := prep.FinalMessage
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
var decision agentfinalizer.Decision
|
||||
for {
|
||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -432,28 +467,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.agentSessionContextBlock(prep.ConversationID),
|
||||
)
|
||||
if runErr == nil {
|
||||
break
|
||||
if runErr != nil {
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||
return
|
||||
}
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
mw := &h.config.MultiAgent.EinoMiddleware
|
||||
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||
return
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
}
|
||||
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
||||
}
|
||||
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"response": result.Response,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"response": responseText,
|
||||
"conversationId": prep.ConversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "eino_single",
|
||||
"finalized": decision.Finalized,
|
||||
"finalizable": decision.Finalizable,
|
||||
"status": decision.Status,
|
||||
"completionReason": decision.CompletionReason,
|
||||
"evidenceVerified": decision.EvidenceVerified,
|
||||
"evidenceRefs": decision.EvidenceRefs,
|
||||
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||
"missingChecks": decision.MissingChecks,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const finalizationAutoContinueMaxAttempts = 2
|
||||
|
||||
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||
if d.Finalizable || d.Finalized {
|
||||
return false
|
||||
}
|
||||
if attempt >= finalizationAutoContinueMaxAttempts {
|
||||
return false
|
||||
}
|
||||
return d.CompletionReason == agentfinalizer.ReasonMissingEvidence
|
||||
}
|
||||
|
||||
func (h *AgentHandler) tryAutoContinueAfterFinalization(
|
||||
taskCtx context.Context,
|
||||
conversationID string,
|
||||
result *multiagent.RunResult,
|
||||
decision agentfinalizer.Decision,
|
||||
attempt *int,
|
||||
curHistory *[]agent.ChatMessage,
|
||||
curFinalMessage *string,
|
||||
progressCallback func(eventType, message string, data interface{}),
|
||||
) bool {
|
||||
if !shouldAutoContinueAfterFinalization(decision, *attempt) || result == nil || !multiagent.HasEinoResumeTrace(result) {
|
||||
return false
|
||||
}
|
||||
*attempt++
|
||||
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||
*curHistory = hist
|
||||
} else if h.logger != nil {
|
||||
h.logger.Warn("finalization auto-continue could not restore trace",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
// Agent 无感续跑:不追加新的 user/system 文案,只使用上一段模型可见轨迹继续 Runner。
|
||||
*curFinalMessage = ""
|
||||
if progressCallback != nil {
|
||||
progressCallback("finalization_auto_continue", "最终回复检查尚未收敛,正在基于已有轨迹继续执行…", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "finalizer",
|
||||
"attempt": *attempt,
|
||||
"maxAttempts": finalizationAutoContinueMaxAttempts,
|
||||
"status": decision.Status,
|
||||
"completionReason": decision.CompletionReason,
|
||||
"missingChecks": decision.MissingChecks,
|
||||
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||
"contextInjection": false,
|
||||
})
|
||||
}
|
||||
select {
|
||||
case <-taskCtx.Done():
|
||||
return false
|
||||
case <-time.After(finalizationAutoContinueBackoff(*attempt)):
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
||||
if attempt <= 1 {
|
||||
return 500 * time.Millisecond
|
||||
}
|
||||
return time.Duration(attempt) * time.Second
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
)
|
||||
|
||||
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
||||
missingEvidence := agentfinalizer.Decision{
|
||||
Status: agentfinalizer.StatusBlocked,
|
||||
CompletionReason: agentfinalizer.ReasonMissingEvidence,
|
||||
}
|
||||
if !shouldAutoContinueAfterFinalization(missingEvidence, 0) {
|
||||
t.Fatal("missing execution evidence should trigger auto-continue")
|
||||
}
|
||||
if shouldAutoContinueAfterFinalization(missingEvidence, finalizationAutoContinueMaxAttempts) {
|
||||
t.Fatal("auto-continue should stop at max attempts")
|
||||
}
|
||||
|
||||
finalized := agentfinalizer.Decision{
|
||||
Status: agentfinalizer.StatusCompleted,
|
||||
CompletionReason: agentfinalizer.ReasonVerified,
|
||||
Finalizable: true,
|
||||
Finalized: true,
|
||||
}
|
||||
if shouldAutoContinueAfterFinalization(finalized, 0) {
|
||||
t.Fatal("finalized decision should not auto-continue")
|
||||
}
|
||||
|
||||
awaitingHITL := agentfinalizer.Decision{
|
||||
Status: agentfinalizer.StatusAwaitingHITL,
|
||||
CompletionReason: agentfinalizer.ReasonAwaitingHITL,
|
||||
}
|
||||
if shouldAutoContinueAfterFinalization(awaitingHITL, 0) {
|
||||
t.Fatal("awaiting HITL should not auto-continue without approval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
||||
if requestRequiresExecutionEvidence(nil) {
|
||||
t.Fatal("nil request should not require execution evidence")
|
||||
}
|
||||
if requestRequiresExecutionEvidence(&ChatRequest{}) {
|
||||
t.Fatal("missing finalization policy should not require execution evidence")
|
||||
}
|
||||
require := true
|
||||
if !requestRequiresExecutionEvidence(&ChatRequest{
|
||||
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||
}) {
|
||||
t.Fatal("explicit true policy should require execution evidence")
|
||||
}
|
||||
require = false
|
||||
if requestRequiresExecutionEvidence(&ChatRequest{
|
||||
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||
}) {
|
||||
t.Fatal("explicit false policy should not require execution evidence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AgentHandler) finalizeAgentRunForDelivery(
|
||||
conversationID string,
|
||||
assistantMessageID string,
|
||||
agentMode string,
|
||||
result *multiagent.RunResult,
|
||||
mcpExecutionIDs []string,
|
||||
reasoningContent string,
|
||||
) agentfinalizer.Decision {
|
||||
return h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, mcpExecutionIDs, reasoningContent, false)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeAgentRunForDeliveryWithPolicy(
|
||||
conversationID string,
|
||||
assistantMessageID string,
|
||||
agentMode string,
|
||||
result *multiagent.RunResult,
|
||||
mcpExecutionIDs []string,
|
||||
reasoningContent string,
|
||||
requireExecutionEvidence bool,
|
||||
) agentfinalizer.Decision {
|
||||
decision := agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||
ConversationID: conversationID,
|
||||
AssistantMessageID: assistantMessageID,
|
||||
AgentMode: agentMode,
|
||||
MCPExecutionIDs: mcpExecutionIDs,
|
||||
RequireExecutionEvidence: requireExecutionEvidence,
|
||||
})
|
||||
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpExecutionIDs, reasoningContent, decision)
|
||||
return decision
|
||||
}
|
||||
|
||||
func (h *AgentHandler) decideAgentRunForDeliveryWithPolicy(
|
||||
conversationID string,
|
||||
assistantMessageID string,
|
||||
agentMode string,
|
||||
result *multiagent.RunResult,
|
||||
mcpExecutionIDs []string,
|
||||
requireExecutionEvidence bool,
|
||||
) agentfinalizer.Decision {
|
||||
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||
ConversationID: conversationID,
|
||||
AssistantMessageID: assistantMessageID,
|
||||
AgentMode: agentMode,
|
||||
MCPExecutionIDs: mcpExecutionIDs,
|
||||
RequireExecutionEvidence: requireExecutionEvidence,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) decideAgentRunForDelivery(
|
||||
conversationID string,
|
||||
assistantMessageID string,
|
||||
agentMode string,
|
||||
result *multiagent.RunResult,
|
||||
mcpExecutionIDs []string,
|
||||
) agentfinalizer.Decision {
|
||||
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||
ConversationID: conversationID,
|
||||
AssistantMessageID: assistantMessageID,
|
||||
AgentMode: agentMode,
|
||||
MCPExecutionIDs: mcpExecutionIDs,
|
||||
RequireExecutionEvidence: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AgentHandler) persistFinalizationDecision(
|
||||
conversationID string,
|
||||
assistantMessageID string,
|
||||
agentMode string,
|
||||
mcpExecutionIDs []string,
|
||||
reasoningContent string,
|
||||
decision agentfinalizer.Decision,
|
||||
) {
|
||||
if assistantMessageID == "" || h.db == nil {
|
||||
return
|
||||
}
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||
if decision.Finalizable {
|
||||
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||
}
|
||||
return
|
||||
}
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeCandidateForDelivery(
|
||||
conversationID string,
|
||||
assistantMessageID string,
|
||||
agentMode string,
|
||||
response string,
|
||||
mcpExecutionIDs []string,
|
||||
awaitingHITL bool,
|
||||
reasoningContent string,
|
||||
) agentfinalizer.Decision {
|
||||
return h.finalizeCandidateForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, response, mcpExecutionIDs, awaitingHITL, reasoningContent, false)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) finalizeCandidateForDeliveryWithPolicy(
|
||||
conversationID string,
|
||||
assistantMessageID string,
|
||||
agentMode string,
|
||||
response string,
|
||||
mcpExecutionIDs []string,
|
||||
awaitingHITL bool,
|
||||
reasoningContent string,
|
||||
requireExecutionEvidence bool,
|
||||
) agentfinalizer.Decision {
|
||||
decision := agentfinalizer.Decide(h.db, agentfinalizer.Input{
|
||||
Response: response,
|
||||
ConversationID: conversationID,
|
||||
AssistantMessageID: assistantMessageID,
|
||||
AgentMode: agentMode,
|
||||
MCPExecutionIDs: mcpExecutionIDs,
|
||||
AwaitingHITL: awaitingHITL,
|
||||
RequireExecutionEvidence: requireExecutionEvidence,
|
||||
})
|
||||
if assistantMessageID == "" || h.db == nil {
|
||||
return decision
|
||||
}
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||
if decision.Finalizable {
|
||||
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||
}
|
||||
return decision
|
||||
}
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||
return decision
|
||||
}
|
||||
|
||||
func finalizationCheckMessage(d agentfinalizer.Decision) string {
|
||||
if d.Finalizable {
|
||||
return "最终回复检查通过。"
|
||||
}
|
||||
return finalizationBlockedMessage(d)
|
||||
}
|
||||
|
||||
func finalizationBlockedMessage(d agentfinalizer.Decision) string {
|
||||
parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"}
|
||||
if d.CompletionReason != "" {
|
||||
parts = append(parts, "原因: "+d.CompletionReason)
|
||||
}
|
||||
if len(d.PendingExecutionIDs) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("仍有 %d 个工具执行未结束: %s", len(d.PendingExecutionIDs), strings.Join(d.PendingExecutionIDs, ", ")))
|
||||
}
|
||||
if len(d.MissingChecks) > 0 {
|
||||
parts = append(parts, "缺失检查: "+strings.Join(d.MissingChecks, "; "))
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func finalizationResponsePayload(d agentfinalizer.Decision, extra map[string]interface{}) map[string]interface{} {
|
||||
return agentfinalizer.ResponsePayload(d, extra)
|
||||
}
|
||||
|
||||
func requestRequiresExecutionEvidence(req *ChatRequest) bool {
|
||||
return req != nil && req.Finalization.RequireExecutionEvidence != nil && *req.Finalization.RequireExecutionEvidence
|
||||
}
|
||||
+726
-164
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestFofaSearchUsesAPIKeyWithoutEmail(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FOFA_API_KEY", "")
|
||||
t.Setenv("FOFA_EMAIL", "legacy@example.com")
|
||||
|
||||
var receivedEmail string
|
||||
var receivedKey string
|
||||
fofaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedEmail = r.URL.Query().Get("email")
|
||||
receivedKey = r.URL.Query().Get("key")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"error":false,"size":1,"page":1,"results":[["https://example.com"]]}`))
|
||||
}))
|
||||
defer fofaServer.Close()
|
||||
|
||||
h := NewFofaHandler(&config.Config{
|
||||
FOFA: config.FofaConfig{
|
||||
BaseURL: fofaServer.URL,
|
||||
APIKey: "test-api-key",
|
||||
},
|
||||
}, zap.NewNop())
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
body := `{"query":"domain=\"example.com\"","fields":"host"}`
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Search(ctx)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("Search() status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if receivedEmail != "" {
|
||||
t.Fatalf("FOFA request unexpectedly included email = %q", receivedEmail)
|
||||
}
|
||||
if receivedKey != "test-api-key" {
|
||||
t.Fatalf("FOFA request key = %q, want %q", receivedKey, "test-api-key")
|
||||
}
|
||||
|
||||
var response fofaSearchResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.ResultsCount != 1 {
|
||||
t.Fatalf("results_count = %d, want 1", response.ResultsCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeFofaRequestErrorDoesNotExposeURLOrAPIKey(t *testing.T) {
|
||||
const secretURL = "https://fofa.info/api/v1/search/all?key=secret-api-key"
|
||||
err := &url.Error{
|
||||
Op: http.MethodGet,
|
||||
URL: secretURL,
|
||||
Err: context.DeadlineExceeded,
|
||||
}
|
||||
|
||||
status, message, timeout := safeFofaRequestError(err)
|
||||
|
||||
if status != http.StatusGatewayTimeout {
|
||||
t.Fatalf("status = %d, want %d", status, http.StatusGatewayTimeout)
|
||||
}
|
||||
if !timeout {
|
||||
t.Fatal("timeout = false, want true")
|
||||
}
|
||||
if strings.Contains(message, "secret-api-key") || strings.Contains(message, secretURL) {
|
||||
t.Fatalf("safe error exposed request URL or API key: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShodanSearchReportsShortfallWhenTotalExceedsMatches(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("SHODAN_API_KEY", "")
|
||||
|
||||
shodanServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/shodan/host/search" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("key"); got != "test-shodan-key" {
|
||||
t.Fatalf("Shodan key = %q, want test-shodan-key", got)
|
||||
}
|
||||
page := r.URL.Query().Get("page")
|
||||
count := 0
|
||||
switch page {
|
||||
case "1":
|
||||
count = 100
|
||||
case "2":
|
||||
count = 3
|
||||
default:
|
||||
count = 0
|
||||
}
|
||||
matches := make([]map[string]interface{}, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
matches = append(matches, map[string]interface{}{
|
||||
"ip_str": fmt.Sprintf("192.0.2.%d", i+1),
|
||||
"port": 80,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"total": 104,
|
||||
"matches": matches,
|
||||
})
|
||||
}))
|
||||
defer shodanServer.Close()
|
||||
|
||||
h := NewFofaHandler(&config.Config{
|
||||
Shodan: config.SpaceSearchConfig{
|
||||
BaseURL: shodanServer.URL,
|
||||
APIKey: "test-shodan-key",
|
||||
},
|
||||
}, zap.NewNop())
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
body := `{"provider":"shodan","query":"product:nginx","fields":"ip_str,port","size":1000,"page":1}`
|
||||
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
|
||||
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Search(ctx)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("Search() status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response fofaSearchResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Total != 104 || response.ResultsCount != 103 {
|
||||
t.Fatalf("counts: total=%d results_count=%d, want 104/103", response.Total, response.ResultsCount)
|
||||
}
|
||||
if response.ExpectedCount != 104 || response.Shortfall != 1 {
|
||||
t.Fatalf("shortfall: expected=%d shortfall=%d, want 104/1", response.ExpectedCount, response.Shortfall)
|
||||
}
|
||||
if response.Warning == "" {
|
||||
t.Fatal("warning should explain shortfall")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractInfoCollectJSONObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "plain json",
|
||||
in: `{"query":"title:\"CyberStrikeAI\"","warnings":[]}`,
|
||||
want: `{"query":"title:\"CyberStrikeAI\"","warnings":[]}`,
|
||||
},
|
||||
{
|
||||
name: "fenced json",
|
||||
in: "```json\n{\"query\":\"product:nginx\"}\n```",
|
||||
want: `{"query":"product:nginx"}`,
|
||||
},
|
||||
{
|
||||
name: "prefixed explanation",
|
||||
in: "解析结果如下:\n{\"query\":\"ssl.cert.subject.cn:example.com\",\"explanation\":\"ok\"}\n请确认。",
|
||||
want: `{"query":"ssl.cert.subject.cn:example.com","explanation":"ok"}`,
|
||||
},
|
||||
{
|
||||
name: "braces inside string",
|
||||
in: "结果:{\"query\":\"title:\\\"{admin}\\\"\",\"warnings\":[\"check\"]}",
|
||||
want: `{"query":"title:\"{admin}\"","warnings":["check"]}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := extractInfoCollectJSONObject(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("extractInfoCollectJSONObject() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("extractInfoCollectJSONObject() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,7 @@ func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int)
|
||||
stats[exec.ToolName] = stat
|
||||
}
|
||||
stat.TotalCalls++
|
||||
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||
if monitorStatusCountsAsFailed(exec.Status) {
|
||||
stat.FailedCalls++
|
||||
} else if exec.Status == "completed" {
|
||||
stat.SuccessCalls++
|
||||
@@ -180,6 +180,15 @@ func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int)
|
||||
return summarizeToolStats(stats, topN)
|
||||
}
|
||||
|
||||
func monitorStatusCountsAsFailed(status string) bool {
|
||||
switch strings.TrimSpace(strings.ToLower(status)) {
|
||||
case "failed", "hard_timeout", "orphaned":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *MonitorHandler) monitorRetentionDays() int {
|
||||
if h.monitorRetention != nil {
|
||||
return h.monitorRetention.RetentionDays()
|
||||
@@ -813,7 +822,7 @@ func (h *MonitorHandler) loadCallsTimeline(cfg callsTimelineConfig) []CallsTimel
|
||||
key := truncateToBucket(exec.StartTime, cfg.bucketSize, cfg.dailyBuckets)
|
||||
entry := bucketMap[key]
|
||||
entry.total++
|
||||
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||
if monitorStatusCountsAsFailed(exec.Status) {
|
||||
entry.failed++
|
||||
}
|
||||
bucketMap[key] = entry
|
||||
@@ -876,7 +885,7 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
|
||||
totalCalls := 1
|
||||
successCalls := 0
|
||||
failedCalls := 0
|
||||
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||
if monitorStatusCountsAsFailed(exec.Status) {
|
||||
failedCalls = 1
|
||||
} else if exec.Status == "completed" {
|
||||
successCalls = 1
|
||||
@@ -951,7 +960,7 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
|
||||
|
||||
stats := toolStats[exec.ToolName]
|
||||
stats.totalCalls++
|
||||
if exec.Status == "failed" || exec.Status == "cancelled" {
|
||||
if monitorStatusCountsAsFailed(exec.Status) {
|
||||
stats.failedCalls++
|
||||
} else if exec.Status == "completed" {
|
||||
stats.successCalls++
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agentfinalizer"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
@@ -158,6 +159,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
stopKeepalive := runSSEKeepalive(c, &sseWriteMu)
|
||||
defer stopKeepalive()
|
||||
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
sendEvent("error", err.Error(), nil)
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return
|
||||
}
|
||||
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
@@ -191,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||
var mainIterationOffset int
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||
}
|
||||
agentMode := "eino_" + effectiveOrch
|
||||
var decision agentfinalizer.Decision
|
||||
|
||||
for {
|
||||
segmentMainIterationMax := 0
|
||||
@@ -232,8 +246,8 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtxLoop,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -261,6 +275,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||
continue
|
||||
}
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||
mainIterationOffset += segmentMainIterationMax
|
||||
timeoutCancel()
|
||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||
continue
|
||||
}
|
||||
timeoutCancel()
|
||||
break
|
||||
}
|
||||
@@ -361,9 +382,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
|
||||
timeoutCancel()
|
||||
|
||||
if assistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
if decision.CompletionReason == "" {
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
}
|
||||
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
|
||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||
@@ -371,16 +393,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
sendEvent("finalization_check", responseText, decision)
|
||||
taskStatus = decision.Status
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
}
|
||||
sendEvent("response", result.Response, map[string]interface{}{
|
||||
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"agentMode": "eino_" + effectiveOrch,
|
||||
})
|
||||
"agentMode": agentMode,
|
||||
}))
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
}
|
||||
|
||||
@@ -421,16 +446,29 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
taskCtx = multiagent.WithHITLToolInterceptor(taskCtx, func(ctx context.Context, toolName, arguments string) (string, error) {
|
||||
return h.interceptHITLForEinoTool(ctx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil, toolName, arguments)
|
||||
})
|
||||
runCfg, _, err := h.configForAIChannel(req.AIChannelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
curHist := prep.History
|
||||
curMsg := prep.FinalMessage
|
||||
var result *multiagent.RunResult
|
||||
var runErr error
|
||||
var emptyResponseContinueAttempt int
|
||||
var finalizationAutoContinueAttempt int
|
||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||
}
|
||||
agentMode := "eino_" + effectiveOrch
|
||||
var decision agentfinalizer.Decision
|
||||
for {
|
||||
result, runErr = multiagent.RunDeepAgent(
|
||||
taskCtx,
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
runCfg,
|
||||
&runCfg.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
@@ -445,24 +483,30 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
chatReasoningToClientIntent(req.Reasoning),
|
||||
h.agentSessionContextBlock(prep.ConversationID),
|
||||
)
|
||||
if runErr == nil {
|
||||
break
|
||||
if runErr != nil {
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
}
|
||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||
errMsg := "执行失败: " + runErr.Error()
|
||||
if prep.AssistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||
return
|
||||
}
|
||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||
mw := &h.config.MultiAgent.EinoMiddleware
|
||||
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
|
||||
errMsg := "执行失败: " + runErr.Error()
|
||||
if prep.AssistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
|
||||
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||
continue
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||
return
|
||||
break
|
||||
}
|
||||
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
||||
}
|
||||
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||
|
||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||
@@ -470,11 +514,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
responseText := decision.FinalText
|
||||
if !decision.Finalizable {
|
||||
responseText = finalizationBlockedMessage(decision)
|
||||
}
|
||||
c.JSON(http.StatusOK, ChatResponse{
|
||||
Response: result.Response,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
Response: responseText,
|
||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||
ConversationID: prep.ConversationID,
|
||||
Time: time.Now(),
|
||||
Finalizable: decision.Finalizable,
|
||||
Finalized: decision.Finalized,
|
||||
Status: decision.Status,
|
||||
CompletionReason: decision.CompletionReason,
|
||||
EvidenceVerified: decision.EvidenceVerified,
|
||||
EvidenceRefs: decision.EvidenceRefs,
|
||||
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||
MissingChecks: decision.MissingChecks,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/audit"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/security"
|
||||
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
|
||||
UserMessageID string
|
||||
}
|
||||
|
||||
func chatRequestAgentMode(req *ChatRequest, source string) string {
|
||||
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
|
||||
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
|
||||
}
|
||||
return "eino_single"
|
||||
}
|
||||
|
||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
||||
if len(req.Attachments) > maxAttachments {
|
||||
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
||||
@@ -56,6 +64,8 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
||||
var err error
|
||||
meta := audit.ConversationCreateMetaFromGin(c, source)
|
||||
meta.ProjectID = projectID
|
||||
meta.RoleName = req.Role
|
||||
meta.AgentMode = chatRequestAgentMode(req, source)
|
||||
if webshellID != "" {
|
||||
meta.Source = source + "_webshell"
|
||||
meta.WebShellConnectionID = webshellID
|
||||
@@ -80,6 +90,12 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
||||
return nil, fmt.Errorf("无权访问该对话")
|
||||
}
|
||||
}
|
||||
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
||||
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
||||
}
|
||||
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
|
||||
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
|
||||
}
|
||||
|
||||
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
||||
if err != nil {
|
||||
|
||||
+283
-18
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
finalizationRequestSchema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "最终回复交付策略。后端不会从自然语言内容推断执行意图;执行入口应显式声明是否要求 completed 工具证据。",
|
||||
"properties": map[string]interface{}{
|
||||
"requireExecutionEvidence": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "为 true 时,缺少 completed 工具执行记录会触发无注入续跑或最终阻断;普通聊天可省略或设为 false。",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
spec := map[string]interface{}{
|
||||
"openapi": "3.0.0",
|
||||
"info": map[string]interface{}{
|
||||
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"required": []string{"projectId"},
|
||||
},
|
||||
"AgentChatResponse": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Agent 非流式响应。response 只是交付文本;是否为成功最终回复必须以 finalized/finalizable/status 为准。",
|
||||
"properties": map[string]interface{}{
|
||||
"response": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "交付给用户的文本。finalized=false 时为阻断/未完成说明,不是成功结论。",
|
||||
},
|
||||
"conversationId": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "对话 ID",
|
||||
},
|
||||
"assistantMessageId": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "助手消息 ID(部分接口返回)",
|
||||
},
|
||||
"mcpExecutionIds": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "本轮关联的 MCP 工具执行 ID",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"agentMode": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "agent 模式,例如 eino_single、eino_deep、workflow",
|
||||
},
|
||||
"finalized": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "是否已经通过最终回复检查。只有 true 才能当成功最终回复。",
|
||||
},
|
||||
"finalizable": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "候选输出是否可提升为最终回复。",
|
||||
},
|
||||
"status": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "最终化状态",
|
||||
"enum": []string{"completed", "in_progress", "blocked", "failed", "cancelled", "awaiting_hitl"},
|
||||
},
|
||||
"completionReason": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "最终化或阻断原因,例如 verified、pending_tool_executions、missing_execution_evidence",
|
||||
},
|
||||
"evidenceVerified": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "证据是否满足最终化要求",
|
||||
},
|
||||
"evidenceRefs": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "证据引用,例如 mcp_execution:<id>",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"pendingExecutionIds": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "仍处于 queued/running 的工具执行 ID",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"missingChecks": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "未通过最终化检查的原因列表",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": []string{"response", "conversationId", "finalized", "finalizable", "status", "evidenceVerified"},
|
||||
},
|
||||
"Conversation": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@@ -241,6 +316,58 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"AssetImportItem": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "待导入资产;host、ip、domain 至少一项非空",
|
||||
"properties": map[string]interface{}{
|
||||
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID;调用者必须有权访问"},
|
||||
"host": map[string]interface{}{"type": "string", "maxLength": 500, "example": "https://app.example.com:443"},
|
||||
"ip": map[string]interface{}{"type": "string", "example": "192.0.2.10"},
|
||||
"port": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 65535, "example": 443},
|
||||
"domain": map[string]interface{}{"type": "string", "example": "app.example.com"},
|
||||
"protocol": map[string]interface{}{"type": "string", "example": "https"},
|
||||
"title": map[string]interface{}{"type": "string", "maxLength": 500},
|
||||
"server": map[string]interface{}{"type": "string", "maxLength": 255, "example": "nginx"},
|
||||
"country": map[string]interface{}{"type": "string"},
|
||||
"province": map[string]interface{}{"type": "string"},
|
||||
"city": map[string]interface{}{"type": "string"},
|
||||
"responsible_person": map[string]interface{}{"type": "string", "maxLength": 255, "description": "资产负责人"},
|
||||
"department": map[string]interface{}{"type": "string", "maxLength": 255, "description": "所属部门"},
|
||||
"business_system": map[string]interface{}{"type": "string", "maxLength": 255, "description": "所属业务系统"},
|
||||
"environment": map[string]interface{}{"type": "string", "enum": []string{"production", "staging", "testing", "development", "other"}},
|
||||
"criticality": map[string]interface{}{"type": "string", "enum": []string{"critical", "high", "medium", "low"}},
|
||||
"source": map[string]interface{}{"type": "string"},
|
||||
"source_query": map[string]interface{}{"type": "string"},
|
||||
"status": map[string]interface{}{"type": "string", "enum": []string{"active", "inactive"}, "default": "active"},
|
||||
"tags": map[string]interface{}{
|
||||
"type": "array",
|
||||
"maxItems": 30,
|
||||
"items": map[string]interface{}{"type": "string", "maxLength": 64},
|
||||
},
|
||||
},
|
||||
},
|
||||
"AssetImportRequest": map[string]interface{}{
|
||||
"type": "object",
|
||||
"required": []string{"assets"},
|
||||
"properties": map[string]interface{}{
|
||||
"assets": map[string]interface{}{
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 100000,
|
||||
"items": map[string]interface{}{"$ref": "#/components/schemas/AssetImportItem"},
|
||||
},
|
||||
"source": map[string]interface{}{"type": "string", "description": "未在资产中填写来源时使用的默认来源"},
|
||||
"source_query": map[string]interface{}{"type": "string", "description": "默认来源查询或导入文件名"},
|
||||
},
|
||||
},
|
||||
"AssetImportResult": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"created": map[string]interface{}{"type": "integer", "description": "新建数量", "example": 120},
|
||||
"updated": map[string]interface{}{"type": "integer", "description": "去重合并数量", "example": 8},
|
||||
"skipped": map[string]interface{}{"type": "integer", "description": "跳过数量", "example": 2},
|
||||
},
|
||||
},
|
||||
"ExecutionResult": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@@ -255,7 +382,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"status": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "执行状态",
|
||||
"enum": []string{"success", "failed", "running"},
|
||||
"enum": []string{"queued", "running", "completed", "failed", "cancelled", "hard_timeout", "orphaned"},
|
||||
},
|
||||
"result": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -726,7 +853,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"status": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "执行状态",
|
||||
"enum": []string{"success", "failed", "running"},
|
||||
"enum": []string{"queued", "running", "completed", "failed", "cancelled", "hard_timeout", "orphaned"},
|
||||
},
|
||||
"createdAt": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -787,6 +914,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "object",
|
||||
"description": "配置信息(含 openai、vision、multi_agent 等)",
|
||||
"properties": map[string]interface{}{
|
||||
"agent": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/AgentConfig",
|
||||
},
|
||||
"vision": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/VisionConfig",
|
||||
},
|
||||
@@ -796,11 +926,30 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "object",
|
||||
"description": "更新配置请求",
|
||||
"properties": map[string]interface{}{
|
||||
"agent": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/AgentConfig",
|
||||
},
|
||||
"vision": map[string]interface{}{
|
||||
"$ref": "#/components/schemas/VisionConfig",
|
||||
},
|
||||
},
|
||||
},
|
||||
"AgentConfig": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "Agent 运行与外部 MCP 防卡死保护配置",
|
||||
"properties": map[string]interface{}{
|
||||
"max_iterations": map[string]interface{}{"type": "integer", "description": "最大迭代次数"},
|
||||
"tool_timeout_minutes": map[string]interface{}{"type": "integer", "description": "单次工具执行硬超时(分钟)"},
|
||||
"tool_wait_timeout_seconds": map[string]interface{}{"type": "integer", "description": "工具单轮等待秒数;到时返回 execution_id,worker 继续后台执行"},
|
||||
"external_mcp_max_concurrent_per_server": map[string]interface{}{"type": "integer", "description": "单个外部 MCP server 并发上限;0=默认2;负数=不限制"},
|
||||
"external_mcp_max_concurrent_total": map[string]interface{}{"type": "integer", "description": "外部 MCP 全局并发上限;0=默认16;负数=不限制"},
|
||||
"external_mcp_circuit_failure_threshold": map[string]interface{}{"type": "integer", "description": "连续失败熔断阈值;0=默认3;负数=关闭熔断"},
|
||||
"external_mcp_circuit_cooldown_seconds": map[string]interface{}{"type": "integer", "description": "熔断冷却秒数;0=默认60"},
|
||||
"shell_no_output_timeout_seconds": map[string]interface{}{"type": "integer", "description": "execute/exec 连续无输出终止秒数"},
|
||||
"workspace_root_dir": map[string]interface{}{"type": "string", "description": "会话工作目录根路径"},
|
||||
"system_prompt_path": map[string]interface{}{"type": "string", "description": "单代理系统提示文件路径"},
|
||||
},
|
||||
},
|
||||
"VisionConfig": map[string]interface{}{
|
||||
"type": "object",
|
||||
"description": "视觉分析(analyze_image MCP 工具);enabled 且 model 非空时注册工具",
|
||||
@@ -1507,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
"finalization": finalizationRequestSchema,
|
||||
},
|
||||
"required": []string{"message"},
|
||||
},
|
||||
@@ -1514,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
|
||||
"200": map[string]interface{}{
|
||||
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{"description": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"500": map[string]interface{}{"description": "执行失败"},
|
||||
@@ -1525,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
||||
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
|
||||
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。`data.finalized=false` 时 message 为未完成/阻断说明。**不依赖** `multi_agent.enabled`。",
|
||||
"operationId": "sendMessageEinoSingleAgentStream",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1538,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
"finalization": finalizationRequestSchema,
|
||||
},
|
||||
"required": []string{"message"},
|
||||
},
|
||||
@@ -1551,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"text/event-stream": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "SSE 流",
|
||||
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1589,6 +1747,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||
},
|
||||
"finalization": finalizationRequestSchema,
|
||||
"orchestration": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
||||
@@ -1602,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "成功,响应格式同 /api/eino-agent",
|
||||
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{"description": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
@@ -1615,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"对话交互"},
|
||||
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
||||
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||
"operationId": "sendMessageMultiAgentStream",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
@@ -1628,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"role": map[string]interface{}{"type": "string"},
|
||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||
"finalization": finalizationRequestSchema,
|
||||
"orchestration": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "deep | plan_execute | supervisor;缺省 deep",
|
||||
@@ -1646,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"text/event-stream": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "SSE 流",
|
||||
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2434,6 +2599,36 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/assets/import": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"资产管理"},
|
||||
"summary": "批量导入资产",
|
||||
"description": "新增或按“目标 + 端口 + 协议”去重更新资产。接收 JSON,不直接接收 XLSX/CSV 文件;单次最多 100000 条,需要 asset:write 权限。",
|
||||
"operationId": "importAssets",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/AssetImportRequest"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "导入完成",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"$ref": "#/components/schemas/AssetImportResult"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{"description": "数量或资产字段校验失败"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"403": map[string]interface{}{"description": "缺少 asset:write 权限或无权访问指定项目"},
|
||||
"500": map[string]interface{}{"description": "导入事务失败"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/projects": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"项目管理"},
|
||||
@@ -3401,7 +3596,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"description": "状态筛选",
|
||||
"schema": map[string]interface{}{
|
||||
"type": "string",
|
||||
"enum": []string{"success", "failed", "running"},
|
||||
"enum": []string{"queued", "running", "completed", "failed", "cancelled", "hard_timeout", "orphaned"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -5694,10 +5889,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
"summary": "列出附件",
|
||||
"description": "获取对话附件文件列表,可按对话ID过滤。",
|
||||
"description": "获取对话文件列表,包含手动上传附件、工具输出和会话产物,可按会话、项目、来源、文件名搜索和分页过滤。",
|
||||
"operationId": "listChatUploads",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
|
||||
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
@@ -5712,18 +5912,27 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"relativePath": map[string]interface{}{"type": "string"},
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"size": map[string]interface{}{"type": "integer"},
|
||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"subPath": map[string]interface{}{"type": "string"},
|
||||
"relativePath": map[string]interface{}{"type": "string"},
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"name": map[string]interface{}{"type": "string"},
|
||||
"size": map[string]interface{}{"type": "integer"},
|
||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"conversationTitle": map[string]interface{}{"type": "string"},
|
||||
"projectId": map[string]interface{}{"type": "string"},
|
||||
"projectName": map[string]interface{}{"type": "string"},
|
||||
"subPath": map[string]interface{}{"type": "string"},
|
||||
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"folders": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
"total": map[string]interface{}{"type": "integer"},
|
||||
"page": map[string]interface{}{"type": "integer"},
|
||||
"pageSize": map[string]interface{}{
|
||||
"type": "integer",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5798,6 +6007,31 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/export": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
"summary": "导出附件",
|
||||
"description": "按当前过滤条件导出对话文件 ZIP,包含 manifest.json。",
|
||||
"operationId": "exportChatUploads",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
|
||||
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "ZIP文件下载",
|
||||
"content": map[string]interface{}{
|
||||
"application/zip": map[string]interface{}{
|
||||
"schema": map[string]interface{}{"type": "string", "format": "binary"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/download": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
@@ -5821,6 +6055,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/path": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
"summary": "解析附件路径",
|
||||
"description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。",
|
||||
"operationId": "resolveChatUploadPath",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects/<id>/csv)", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "解析成功",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"absolutePath": map[string]interface{}{"type": "string"},
|
||||
"isDir": map[string]interface{}{"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
"403": map[string]interface{}{"description": "无权访问"},
|
||||
"404": map[string]interface{}{"description": "路径不存在"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/chat-uploads/content": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
|
||||
@@ -11,7 +11,7 @@ var apiDocI18nTagToKey = map[string]string{
|
||||
"知识库": "knowledgeBase", "MCP": "mcp",
|
||||
"FOFA信息收集": "fofaRecon", "终端": "terminal", "WebShell管理": "webshellManagement",
|
||||
"对话附件": "chatUploads", "机器人集成": "robotIntegration", "多代理Markdown": "markdownAgents",
|
||||
"项目管理": "projectManagement",
|
||||
"项目管理": "projectManagement", "资产管理": "assetManagement",
|
||||
}
|
||||
|
||||
var apiDocI18nSummaryToKey = map[string]string{
|
||||
@@ -61,7 +61,7 @@ var apiDocI18nSummaryToKey = map[string]string{
|
||||
"获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState",
|
||||
"获取AI对话历史": "getWebshellAIHistory", "列出AI对话": "listWebshellAIConversations",
|
||||
"执行WebShell命令": "webshellExec", "WebShell文件操作": "webshellFileOp",
|
||||
"列出附件": "listChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
|
||||
"列出附件": "listChatUploads", "导出附件": "exportChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
|
||||
"下载附件": "downloadChatUpload", "获取附件文本内容": "getChatUploadContent",
|
||||
"写入附件文本内容": "putChatUploadContent", "创建附件目录": "mkdirChatUpload", "重命名附件": "renameChatUpload",
|
||||
"企业微信回调验证": "wecomCallbackVerify", "企业微信消息回调": "wecomCallbackMessage",
|
||||
@@ -71,8 +71,9 @@ var apiDocI18nSummaryToKey = map[string]string{
|
||||
"列出技能包文件": "listSkillPackageFiles", "获取技能包文件内容": "getSkillPackageFile", "写入技能包文件": "putSkillPackageFile",
|
||||
"批量获取工具名称": "batchGetToolNames",
|
||||
"获取知识库统计": "getKnowledgeStats",
|
||||
"列出项目": "listProjects", "创建项目": "createProject", "获取项目": "getProject",
|
||||
"列出项目": "listProjects", "创建项目": "createProject", "获取项目": "getProject",
|
||||
"更新项目": "updateProject", "删除项目": "deleteProject",
|
||||
"批量导入资产": "importAssets",
|
||||
"列出或按 key 获取事实": "listProjectFacts", "创建/更新事实": "upsertProjectFact",
|
||||
"获取项目事实攻击路径图": "getProjectFactGraph", "列出项目全部事实边": "listProjectFactEdges",
|
||||
"添加事实边": "createProjectFactEdge", "删除事实边": "deleteProjectFactEdge",
|
||||
@@ -109,6 +110,9 @@ var apiDocI18nResponseDescToKey = map[string]string{
|
||||
"成功": "success", "nodes + edges": "factGraphNodesEdges",
|
||||
"边列表": "edgeList", "边已创建": "edgeCreated",
|
||||
"沉淀结果(facts/edges/graph)": "promoteAttackChainResult",
|
||||
"导入完成": "assetImportCompleted", "数量或资产字段校验失败": "assetImportValidationFailed",
|
||||
"缺少 asset:write 权限或无权访问指定项目": "assetImportForbidden",
|
||||
"导入事务失败": "assetImportTransactionFailed",
|
||||
}
|
||||
|
||||
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user