Compare commits

...
32 Commits
Author SHA1 Message Date
公明andGitHub b51d428704 Update config.example.yaml 2026-07-20 17:48:53 +08:00
公明andGitHub c50de7770b Add files via upload 2026-07-20 17:38:38 +08:00
公明andGitHub 9bafc2ab98 Add files via upload 2026-07-20 17:31:20 +08:00
公明andGitHub 437bae0b15 Add files via upload 2026-07-20 17:24:14 +08:00
公明andGitHub 083992f63e Add files via upload 2026-07-20 17:19:25 +08:00
公明andGitHub 47dc62ae57 Add files via upload 2026-07-20 17:14:23 +08:00
公明andGitHub c2e1d37058 Add files via upload 2026-07-20 17:05:52 +08:00
公明andGitHub c5f9a5494a Add files via upload 2026-07-20 16:59:31 +08:00
公明andGitHub ef2c3474fe Add files via upload 2026-07-20 16:54:50 +08:00
公明andGitHub c689122774 Add files via upload 2026-07-20 16:49:19 +08:00
公明andGitHub c1ab063b0d Add files via upload 2026-07-20 13:53:36 +08:00
公明andGitHub 75ec563bd3 Add files via upload 2026-07-20 11:47:05 +08:00
公明andGitHub ef8028cfa4 Add files via upload 2026-07-20 11:14:46 +08:00
公明andGitHub fa76ebce71 Add files via upload 2026-07-20 11:07:58 +08:00
公明andGitHub b9e1d7b7a8 Add files via upload 2026-07-20 10:52:00 +08:00
公明andGitHub 9a269ac0ec Add files via upload 2026-07-20 10:49:40 +08:00
公明andGitHub cb0d61a48d Add files via upload 2026-07-20 10:48:28 +08:00
公明andGitHub b7f9fa6173 Add files via upload 2026-07-17 18:04:27 +08:00
公明andGitHub 00283d5300 Add files via upload 2026-07-17 17:23:01 +08:00
公明andGitHub e00e53808b Add files via upload 2026-07-17 17:19:58 +08:00
公明andGitHub c915abf310 Add files via upload 2026-07-17 17:17:10 +08:00
公明andGitHub 5a282c8ed9 Add files via upload 2026-07-17 17:14:09 +08:00
公明andGitHub 1f3472955c Add files via upload 2026-07-17 17:11:16 +08:00
公明andGitHub 1923f7710a Add files via upload 2026-07-17 17:08:27 +08:00
公明andGitHub 833e8f13ce Add files via upload 2026-07-17 17:07:01 +08:00
公明andGitHub 79941207b9 Add files via upload 2026-07-17 17:03:30 +08:00
公明andGitHub 1858f4533a Add files via upload 2026-07-17 17:00:28 +08:00
公明andGitHub 3bb8efc892 Add files via upload 2026-07-17 16:56:22 +08:00
公明andGitHub b0d3ed8a87 Add files via upload 2026-07-17 16:53:06 +08:00
公明andGitHub 9f092388e1 Add files via upload 2026-07-17 16:51:35 +08:00
公明andGitHub 217f8f9648 Add files via upload 2026-07-17 10:53:24 +08:00
公明andGitHub 0b638791eb Add files via upload 2026-07-17 10:51:59 +08:00
49 changed files with 6767 additions and 924 deletions
+2 -2
View File
@@ -133,7 +133,7 @@ CyberStrikeAI connects planning, execution, human oversight, evidence, and repla
- 📁 **Conversation management** provides grouping, pinning, renaming, and batch organization. - 📁 **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. - 📂 **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 while tracking scan coverage, linked vulnerabilities, and risk state. See the [Asset Management guide](docs/en-US/asset-management.md). - 🗂️ **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. - 🛡️ **Vulnerability management** provides severity classification, lifecycle tracking, filtering, and statistics.
- 📋 **Batch tasks** provide queued execution, editing, status tracking, and retained results. - 📋 **Batch tasks** provide queued execution, editing, status tracking, and retained results.
- 📱 **Chatbots** connect Personal WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot. - 📱 **Chatbots** connect Personal WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot.
@@ -169,7 +169,7 @@ CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
- **Web & App Scanners** sqlmap, nikto, dirb, gobuster, feroxbuster, ffuf, httpx - **Web & App Scanners** sqlmap, nikto, dirb, gobuster, feroxbuster, ffuf, httpx
- **Vulnerability Scanners** nuclei, wpscan, wafw00f, dalfox, xsser - **Vulnerability Scanners** nuclei, wpscan, wafw00f, dalfox, xsser
- **Subdomain Enumeration** subfinder, amass, findomain, dnsenum, fierce - **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 - **API Security** graphql-scanner, arjun, api-fuzzer, api-schema-analyzer
- **Container Security** trivy, clair, docker-bench-security, kube-bench, kube-hunter - **Container Security** trivy, clair, docker-bench-security, kube-bench, kube-hunter
- **Cloud Security** prowler, scout-suite, cloudmapper, pacu, terrascan, checkov - **Cloud Security** prowler, scout-suite, cloudmapper, pacu, terrascan, checkov
+2 -2
View File
@@ -132,7 +132,7 @@ CyberStrikeAI 将规划、执行、人工监督、证据与复盘连接在同一
- 📁 **对话管理**:支持分组、置顶、重命名和批量管理。 - 📁 **对话管理**:支持分组、置顶、重命名和批量管理。
- 📂 **项目与攻击链**:关联跨会话事实、风险评分、图谱视图和步骤回放。 - 📂 **项目与攻击链**:关联跨会话事实、风险评分、图谱视图和步骤回放。
- 🗂️ **资产管理**:统一归档和去重域名、IP、端口与服务,跟踪扫描覆盖、关联漏洞和风险状态。详见[资产管理指南](docs/zh-CN/asset-management.md)。 - 🗂️ **资产管理**:统一归档和去重域名、IP、端口与服务,支持 XLSX/CSV 导入导出、高级筛选与保存视图、责任和业务属性、跨页批量维护、重复资产合并,并跟踪扫描覆盖、关联漏洞和风险状态。详见[资产管理指南](docs/zh-CN/asset-management.md)。
- 🛡️ **漏洞管理**:支持严重程度分级、状态流转、过滤和统计看板。 - 🛡️ **漏洞管理**:支持严重程度分级、状态流转、过滤和统计看板。
- 📋 **批量任务**:支持任务队列、编辑、状态跟踪和结果留存。 - 📋 **批量任务**:支持任务队列、编辑、状态跟踪和结果留存。
- 📱 **机器人接入**:支持个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord 和 QQ。 - 📱 **机器人接入**:支持个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord 和 QQ。
@@ -168,7 +168,7 @@ CyberStrikeAI 将规划、执行、人工监督、证据与复盘连接在同一
- **Web 应用扫描**sqlmap、nikto、dirb、gobuster、feroxbuster、ffuf、httpx - **Web 应用扫描**sqlmap、nikto、dirb、gobuster、feroxbuster、ffuf、httpx
- **漏洞扫描**nuclei、wpscan、wafw00f、dalfox、xsser - **漏洞扫描**nuclei、wpscan、wafw00f、dalfox、xsser
- **子域名枚举**subfinder、amass、findomain、dnsenum、fierce - **子域名枚举**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 - **API 安全**graphql-scanner、arjun、api-fuzzer、api-schema-analyzer
- **容器安全**trivy、clair、docker-bench-security、kube-bench、kube-hunter - **容器安全**trivy、clair、docker-bench-security、kube-bench、kube-hunter
- **云安全**prowler、scout-suite、cloudmapper、pacu、terrascan、checkov - **云安全**prowler、scout-suite、cloudmapper、pacu、terrascan、checkov
+9 -2
View File
@@ -17,10 +17,15 @@ import (
func main() { func main() {
var configPath = flag.String("config", "config.yaml", "配置文件路径") var configPath = flag.String("config", "config.yaml", "配置文件路径")
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致") var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
var httpBootstrap = flag.Bool("http", false, "强制主站使用明文 HTTP:覆盖配置文件中的 tls_enabled/tls_auto_self_sign/tls_cert_path/tls_key_path")
flag.Parse() flag.Parse()
// 环境变量兼容(便于 systemd/docker 等不传参场景) // 环境变量兼容(便于 systemd/docker 等不传参场景)
if !*httpsBootstrap { if *httpsBootstrap && *httpBootstrap {
fmt.Fprintln(os.Stderr, "--http 与 --https 不能同时使用")
os.Exit(2)
}
if !*httpsBootstrap && !*httpBootstrap {
v := strings.TrimSpace(os.Getenv("CYBERSTRIKE_HTTPS")) v := strings.TrimSpace(os.Getenv("CYBERSTRIKE_HTTPS"))
if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") { if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") {
*httpsBootstrap = true *httpsBootstrap = true
@@ -51,7 +56,9 @@ func main() {
termout.PrintConfigCreated() termout.PrintConfigCreated()
} }
if *httpsBootstrap { if *httpBootstrap {
config.ApplyPlainHTTPBootstrap(cfg)
} else if *httpsBootstrap {
config.ApplyDevHTTPSBootstrap(cfg) config.ApplyDevHTTPSBootstrap(cfg)
} }
+14 -6
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.4" version: "v1.7.5"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -66,7 +66,7 @@ openai:
max_completion_tokens: 16384 # 单次生成上限(含 reasoning 与可见输出),防止依赖网关隐式默认值 max_completion_tokens: 16384 # 单次生成上限(含 reasoning 与可见输出),防止依赖网关隐式默认值
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effortClaude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields # Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effortClaude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
reasoning: reasoning:
mode: on # auto | on | offoff 时不附加任何推理扩展字段 mode: on # auto | on | offoffOpenAI/Claude 不附加推理字段,DeepSeek 发送 thinking.type=disabled(其默认开启思考)
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定 effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准 allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
@@ -86,14 +86,22 @@ vision:
detail: auto # low | high | autoEino ImageURLDetail detail: auto # low | high | autoEino ImageURLDetail
timeout_seconds: 60 timeout_seconds: 60
# ============================================ # ============================================
# 信息收集(FOFA)配置(可选) # 资产管理(网络空间测绘搜索)配置(可选)
# ============================================ # ============================================
# 用于「信息收集」页面调用 FOFA API(后端代理,避免前端暴露 key) # 用于「资产管理 → 信息收集」页面调用 FOFA / ZoomEye / Quake / Shodan API
# 也可通过环境变量配置:FOFA_EMAIL / FOFA_API_KEY优先级更高 # 后端代理请求,避免前端暴露 key;环境变量优先级更高
fofa: fofa:
base_url: https://fofa.info/api/v1/search/all # 可选,留空则使用默认 base_url: https://fofa.info/api/v1/search/all # 可选,留空则使用默认
email: "" # FOFA 账号邮箱(可选,建议在系统设置中填写)
api_key: "" # FOFA API Key(可选,建议在系统设置中填写) 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 配置 # Agent 配置
# 达到最大迭代次数时,AI 会自动总结测试结果 # 达到最大迭代次数时,AI 会自动总结测试结果
agent: agent:
+49
View File
@@ -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. 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.
+130
View File
@@ -55,6 +55,132 @@ Streaming endpoints are long-lived. Clients should:
- disable proxy buffering; - disable proxy buffering;
- pass `conversationId` when continuing a conversation. - pass `conversationId` when continuing a conversation.
## 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 ## Stability Tiers
| API type | Stability | Recommendation | | API type | Stability | Recommendation |
@@ -62,6 +188,7 @@ Streaming endpoints are long-lived. Clients should:
| `/api/auth/*` | high | safe to integrate | | `/api/auth/*` | high | safe to integrate |
| `/api/eino-agent*` | high | preferred chat entry | | `/api/eino-agent*` | high | preferred chat entry |
| `/api/openapi/spec` | high | client generation | | `/api/openapi/spec` | high | client generation |
| `/api/assets/*` | high | asset management and bulk import |
| `/api/config*` | medium | admin automation only | | `/api/config*` | medium | admin automation only |
| `/api/c2/*`, `/api/webshell/*` | medium | high-risk, restrict access | | `/api/c2/*`, `/api/webshell/*` | medium | high-risk, restrict access |
| frontend private calls | low | avoid plugin dependency | | frontend private calls | low | avoid plugin dependency |
@@ -70,6 +197,7 @@ Streaming endpoints are long-lived. Clients should:
- Conversations: `/api/conversations` - Conversations: `/api/conversations`
- Projects/facts: `/api/projects` - Projects/facts: `/api/projects`
- Assets and bulk import: `/api/assets`
- Vulnerabilities: `/api/vulnerabilities` - Vulnerabilities: `/api/vulnerabilities`
- Knowledge: `/api/knowledge/*` - Knowledge: `/api/knowledge/*`
- Roles: `/api/roles` - Roles: `/api/roles`
@@ -101,3 +229,5 @@ curl -k https://127.0.0.1:8080/api/eino-agent \
- OpenAPI: `internal/handler/openapi.go` - OpenAPI: `internal/handler/openapi.go`
- Single-agent: `internal/handler/eino_single_agent.go` - Single-agent: `internal/handler/eino_single_agent.go`
- Multi-agent: `internal/handler/multi_agent.go` - Multi-agent: `internal/handler/multi_agent.go`
- Asset endpoints: `internal/handler/asset.go`
- Asset storage and deduplication: `internal/database/asset.go`
+100 -17
View File
@@ -2,7 +2,7 @@
[中文](../zh-CN/asset-management.md) [中文](../zh-CN/asset-management.md)
Asset management consolidates domains, IP addresses, ports, and services discovered through manual entry, FOFA, 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. 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. > 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.
@@ -11,11 +11,13 @@ Asset management consolidates domains, IP addresses, ports, and services discove
Asset management provides three main views: Asset management provides three main views:
- **Overview**: asset totals, IPs, domains, ports, recent changes, scan coverage, and protocol distribution. - **Overview**: asset totals, IPs, domains, ports, recent changes, scan coverage, and protocol distribution.
- **Asset inventory**: identity, service details, source, tags, project ownership, scan history, and risk state. - **Asset inventory**: identity, service details, source, tags, project ownership, responsibility and business metadata, scan history, and risk state.
- **Reconnaissance**: search FOFA and save confirmed results individually or in batches. - **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. 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 ## Asset fields
An asset can include: An asset can include:
@@ -23,6 +25,7 @@ An asset can include:
- host, IP address, domain, port, and protocol; - host, IP address, domain, port, and protocol;
- page title and service or product fingerprint; - page title and service or product fingerprint;
- country/region, state/province, and city; - country/region, state/province, and city;
- responsible person, department, business system, environment, and criticality;
- source, source query, and tags; - source, source query, and tags;
- active or inactive status; - active or inactive status;
- project and owner; - project and owner;
@@ -45,15 +48,61 @@ example.com
[2001:db8::1]: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, and status. 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 FOFA ### Import from a spreadsheet
1. Configure the FOFA email and API key in settings or the configuration file. You can also use the `FOFA_EMAIL` and `FOFA_API_KEY` environment variables. 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**. 2. Open **Asset Management → Reconnaissance**.
3. Enter or generate a FOFA query and confirm its scope. 3. Select the data source: FOFA, ZoomEye, Quake, or Shodan.
4. Run the query, select results whose ownership has been verified, and choose **Save Selected**. 4. Enter or generate a query for that source and confirm its scope.
5. Review the created, updated, and skipped counts. 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. 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.
@@ -73,18 +122,40 @@ Assets use “target + port + protocol” as the service-level deduplication key
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. 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 and filters ## Search, filters, and views
The Web UI searches hosts, IP addresses, domains, titles, services, and tags, with status and project filters. The backend and Agent tools additionally support: 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:
- source, tags, port, and protocol; - risk level and minimum vulnerability count;
- scanned and never-scanned states; - protocol, port, source, and exact tag;
- first-seen, last-seen, and latest-scan time ranges; - scanned, never scanned, or not scanned for 30/60/90 days;
- allowlisted sort fields such as latest scan time; - country/region, state/province, city, responsible person, department, and business system;
- paginated queries. - 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. 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 ## Scanning and risk updates
### Scan one asset ### Scan one asset
@@ -128,6 +199,8 @@ Six built-in tools expose asset operations to Agents:
`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. `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 ## Access control
Asset permissions are separated into: Asset permissions are separated into:
@@ -138,12 +211,22 @@ Asset permissions are separated into:
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. 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 ## Recommended workflow
1. Define an explicitly authorized set of domains, IP addresses, or network ranges. 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. 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. 3. Use tags to separate production, testing, critical-business, and internet-facing scopes.
4. Configure FOFA, begin with narrow queries, and verify ownership. 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. 5. Test scanning and vulnerability callbacks on one low-risk target.
6. Use never-scanned and over-30-day filters to identify coverage gaps. 6. Use never-scanned and over-30-day filters to identify coverage gaps.
7. After validating the workflow, expand gradually with small batch tasks. 7. After validating the workflow, expand gradually with small batch tasks.
+2 -1
View File
@@ -87,7 +87,7 @@ Permissions use `module:action`. Common actions are `read`, `write`, `delete`, a
| Robots | `robot:read`, `robot:write` | | Robots | `robot:read`, `robot:write` |
| Files | `files:read`, `files:write`, `files:delete` | | Files | `files:read`, `files:write`, `files:delete` |
| Attack chain | `attackchain:read`, `attackchain:write` | | Attack chain | `attackchain:read`, `attackchain:write` |
| FOFA | `fofa:execute` | | Network-space search / Reconnaissance | `fofa:execute` |
| OpenAPI | `openapi:read` | | OpenAPI | `openapi:read` |
| Chat groups | `group:read`, `group:write`, `group:delete` | | Chat groups | `group:read`, `group:write`, `group:delete` |
| Monitor | `monitor:read`, `monitor:write`, `monitor: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. - `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:execute` protects the authenticated MCP HTTP entry point.
- `mcp:external:execute` allows Agent calls to external MCP tools and currently also requires `all` scope. - `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. - `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. - `robot:write` manages robot configuration and the test endpoint. Chatbot conversations use the bound user or configured service account's business permissions.
+49
View File
@@ -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 文件。
+133
View File
@@ -89,6 +89,136 @@ Content-Type: application/json
- `GET /api/attack-chain/:conversationId` - `GET /api/attack-chain/:conversationId`
- `POST /api/attack-chain/:conversationId/regenerate` - `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、配置 ## 工具、MCP、配置
配置: 配置:
@@ -208,6 +338,7 @@ C2
| `/api/auth/*` | 高 | 可直接集成 | | `/api/auth/*` | 高 | 可直接集成 |
| `/api/eino-agent*` | 高 | 推荐外部对话入口 | | `/api/eino-agent*` | 高 | 推荐外部对话入口 |
| `/api/openapi/spec` | 高 | 用于生成客户端 | | `/api/openapi/spec` | 高 | 用于生成客户端 |
| `/api/assets/*` | 高 | 资产管理与批量导入 |
| `/api/config*` | 中 | 管理工具使用,谨慎自动化 | | `/api/config*` | 中 | 管理工具使用,谨慎自动化 |
| `/api/c2/*``/api/webshell/*` | 中 | 高风险,必须加权限边界 | | `/api/c2/*``/api/webshell/*` | 中 | 高风险,必须加权限边界 |
| 前端私有调用细节 | 低 | 不建议插件依赖 | | 前端私有调用细节 | 低 | 不建议插件依赖 |
@@ -237,3 +368,5 @@ curl -k https://127.0.0.1:8080/api/eino-agent \
- OpenAPI`internal/handler/openapi.go` - OpenAPI`internal/handler/openapi.go`
- 单代理:`internal/handler/eino_single_agent.go` - 单代理:`internal/handler/eino_single_agent.go`
- 多代理:`internal/handler/multi_agent.go` - 多代理:`internal/handler/multi_agent.go`
- 资产接口:`internal/handler/asset.go`
- 资产存储与去重:`internal/database/asset.go`
+100 -17
View File
@@ -2,7 +2,7 @@
[English](../en-US/asset-management.md) [English](../en-US/asset-management.md)
资产管理用于将手工录入、FOFA、HTTP API 和 Agent 任务中发现的域名、IP、端口与服务统一归档,形成可持续维护的资产基线。它关注三个问题:当前拥有哪些资产、哪些资产已经检查、风险集中在哪里。 资产管理用于将手工录入、网络空间测绘搜索、HTTP API 和 Agent 任务中发现的域名、IP、端口与服务统一归档,形成可持续维护的资产基线。它关注三个问题:当前拥有哪些资产、哪些资产已经检查、风险集中在哪里。
> 资产管理面向安全测试和攻击面治理,不替代完整的企业 CMDB。仅可录入和扫描自有系统或已获得明确授权的目标。 > 资产管理面向安全测试和攻击面治理,不替代完整的企业 CMDB。仅可录入和扫描自有系统或已获得明确授权的目标。
@@ -11,11 +11,13 @@
资产管理包含三个主要入口: 资产管理包含三个主要入口:
- **资产概览**:统计资产总量、IP、域名、端口、近期变化、扫描覆盖率和协议分布。 - **资产概览**:统计资产总量、IP、域名、端口、近期变化、扫描覆盖率和协议分布。
- **资产库**:维护资产身份、服务信息、来源、标签、项目归属、扫描记录和风险状态。 - **资产库**:维护资产身份、服务信息、来源、标签、项目归属、责任与业务属性、扫描记录和风险状态。
- **信息收集**:查询 FOFA,并将确认归属的结果单条或批量写入资产库。 - **信息收集**:查询 FOFA、ZoomEye、Quake 或 Shodan,并将确认归属的结果单条或批量写入资产库。
资产可以进一步发起单目标分析或批量扫描。Agent 保存漏洞并完成扫描回写后,资产列表会同步展示相关漏洞数量、风险等级和最近扫描时间。 资产可以进一步发起单目标分析或批量扫描。Agent 保存漏洞并完成扫描回写后,资产列表会同步展示相关漏洞数量、风险等级和最近扫描时间。
概览支持切换最近 7、30 或 90 天,展示新增/停用资产趋势、漏洞发现趋势(含严重和高危)、扫描覆盖率、30 天内覆盖率、从未扫描与过期资产数量,以及协议 Top 8 分布。所有统计均受当前用户的资产访问范围约束。
## 资产字段 ## 资产字段
每条资产可记录: 每条资产可记录:
@@ -23,6 +25,7 @@
- Host、IP、域名、端口和协议; - Host、IP、域名、端口和协议;
- 页面标题、服务或产品指纹; - 页面标题、服务或产品指纹;
- 国家/地区、省份/州和城市; - 国家/地区、省份/州和城市;
- 负责人、部门、业务系统、环境和重要性;
- 来源、来源查询条件和标签; - 来源、来源查询条件和标签;
- 活跃或停用状态; - 活跃或停用状态;
- 所属项目和所有者; - 所属项目和所有者;
@@ -45,15 +48,61 @@ example.com
[2001:db8::1]:443 [2001:db8::1]:443
``` ```
系统会尽量识别 URL、域名、IP、端口和协议。保存前可继续补充项目、标签、标题、服务指纹、地理位置和状态。 系统会尽量识别 URL、域名、IP、端口和协议。保存前可继续补充项目、标签、标题、服务指纹、地理位置、负责人、部门、业务系统、环境、重要性和状态。
### 从 FOFA 导入 ### 从表格批量导入
1. 在配置文件或系统设置中填写 FOFA 邮箱和 API Key,也可使用 `FOFA_EMAIL``FOFA_API_KEY` 环境变量。 进入 **资产管理 → 资产库**,点击 **批量导入**
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. 进入 **资产管理 → 信息收集** 2. 进入 **资产管理 → 信息收集**
3. 输入或生成 FOFA 查询语句,并确认查询范围 3. 选择数据源:FOFA、ZoomEye、Quake 或 Shodan
4. 执行查询,选择确认归属的结果后点击 **入库所选** 4. 输入或生成该数据源的查询语句,并确认查询范围
5. 根据返回的新增、更新和跳过数量检查导入结果 5. 执行查询,选择确认归属的结果后点击 **入库所选**
6. 根据返回的新增、更新和跳过数量检查导入结果。
外部搜索结果不等同于自有资产。建议先通过组织域名、证书、网段或产品指纹缩小范围,确认授权后再入库。 外部搜索结果不等同于自有资产。建议先通过组织域名、证书、网段或产品指纹缩小范围,确认授权后再入库。
@@ -73,18 +122,40 @@ example.com
重复资产再次入库时,系统会更新本次提供的非空字段和最近发现时间,保留未提供的原有信息,不会重置首次发现时间。 重复资产再次入库时,系统会更新本次提供的非空字段和最近发现时间,保留未提供的原有信息,不会重置首次发现时间。
## 查询筛选 ## 查询筛选与视图
Web 端支持搜索 Host、IP、域名、标题、服务标签,并按状态项目筛选。后端和 Agent 工具还支持 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`;合并前应确认主资产选择以及需要保留的扫描历史。
## 扫描与风险回写 ## 扫描与风险回写
### 单资产扫描 ### 单资产扫描
@@ -128,6 +199,8 @@ Web 端支持搜索 Host、IP、域名、标题、服务和标签,并按状态
`query_assets` 默认每页返回 20 条、最多 50 条摘要。需要完整信息时使用 `get_asset`,避免大量资产数据占用模型上下文。 `query_assets` 默认每页返回 20 条、最多 50 条摘要。需要完整信息时使用 `get_asset`,避免大量资产数据占用模型上下文。
`create_asset``update_asset` 均支持责任与业务属性;`query_assets` 也可以按这些字段筛选。Agent 写入仍经过与 HTTP API 相同的规范化、字段校验、去重和权限检查。
## 权限控制 ## 权限控制
资产权限分为: 资产权限分为:
@@ -138,12 +211,22 @@ Web 端支持搜索 Host、IP、域名、标题、服务和标签,并按状态
服务端会同时检查资产所有者、显式资源授权、所属项目及权限 Scope(`all``assigned``own`)。当对话绑定项目后,Agent 的资产查询会被限制在该项目内,工具参数不能扩大访问范围。 服务端会同时检查资产所有者、显式资源授权、所属项目及权限 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 或网段。 1. 划定一组明确授权的域名、IP 或网段。
2. 手工加入少量核心目标,验证识别和去重结果。 2. 手工加入少量核心目标,验证识别和去重结果。
3. 使用标签区分生产、测试、核心业务和外网范围。 3. 使用标签区分生产、测试、核心业务和外网范围。
4. 配置 FOFA,从窄查询开始并确认资产归属。 4. 配置一个或多个网络空间测绘搜索引擎,从窄查询开始并确认资产归属。
5. 对单个低风险目标测试扫描和漏洞回写流程。 5. 对单个低风险目标测试扫描和漏洞回写流程。
6. 使用“从未扫描”和“超过 30 天未扫描”筛选覆盖盲区。 6. 使用“从未扫描”和“超过 30 天未扫描”筛选覆盖盲区。
7. 确认流程稳定后,再逐步创建小规模批量任务。 7. 确认流程稳定后,再逐步创建小规模批量任务。
+3 -1
View File
@@ -94,13 +94,15 @@ AI 测试角色不是安全授权边界。即使选择了“渗透测试”角
| 机器人 | `robot:read``robot:write` | | 机器人 | `robot:read``robot:write` |
| 文件 | `files:read``files:write``files:delete` | | 文件 | `files:read``files:write``files:delete` |
| 攻击链 | `attackchain:read``attackchain:write` | | 攻击链 | `attackchain:read``attackchain:write` |
| FOFA | `fofa:execute` | | 网络空间测绘 / 信息收集 | `fofa:execute` |
| OpenAPI | `openapi:read` | | OpenAPI | `openapi:read` |
| 对话分组 | `group:read``group:write``group:delete` | | 对话分组 | `group:read``group:write``group:delete` |
| 执行监控 | `monitor:read``monitor:write``monitor:delete` | | 执行监控 | `monitor:read``monitor:write``monitor:delete` |
特殊权限说明: 特殊权限说明:
- `fofa:execute` 为兼容旧版本保留权限名,现在保护 **信息收集** 页中的 FOFA、ZoomEye、Quake、Shodan 查询。
- `agent:execute` 允许运行 Agent,但不自动允许本地文件系统、Shell 或任意配置命令。 - `agent:execute` 允许运行 Agent,但不自动允许本地文件系统、Shell 或任意配置命令。
- `agent:local-execute` 是本地执行兜底权限,应仅授予可信操作员。 - `agent:local-execute` 是本地执行兜底权限,应仅授予可信操作员。
- `mcp:execute` 用于访问认证后的 MCP HTTP 入口。 - `mcp:execute` 用于访问认证后的 MCP HTTP 入口。
+4
View File
@@ -983,10 +983,14 @@ func setupRoutes(
// 资产管理 // 资产管理
protected.GET("/assets", assetHandler.List) protected.GET("/assets", assetHandler.List)
protected.GET("/assets/selection", assetHandler.Selection)
protected.GET("/assets/stats", assetHandler.Stats) protected.GET("/assets/stats", assetHandler.Stats)
protected.POST("/assets/import", assetHandler.Import) protected.POST("/assets/import", assetHandler.Import)
protected.POST("/assets/scan-links", assetHandler.RecordScans) protected.POST("/assets/scan-links", assetHandler.RecordScans)
protected.PUT("/assets/bulk", assetHandler.BulkUpdate)
protected.PUT("/assets/project-binding", assetHandler.UpdateProjectBinding) 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.PUT("/assets/:id", assetHandler.Update)
protected.DELETE("/assets/:id", assetHandler.Delete) protected.DELETE("/assets/:id", assetHandler.Delete)
+54 -2
View File
@@ -193,6 +193,10 @@ func assetMutationProperties() map[string]interface{} {
"domain": map[string]interface{}{"type": "string"}, "protocol": map[string]interface{}{"type": "string"}, "domain": map[string]interface{}{"type": "string"}, "protocol": map[string]interface{}{"type": "string"},
"title": map[string]interface{}{"type": "string"}, "server": 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"}, "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"}, "source": map[string]interface{}{"type": "string"}, "source_query": map[string]interface{}{"type": "string"},
"status": map[string]interface{}{"type": "string", "enum": []string{"active", "inactive"}}, "status": map[string]interface{}{"type": "string", "enum": []string{"active", "inactive"}},
"tags": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "maxItems": 50}, "tags": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "maxItems": 50},
@@ -206,12 +210,21 @@ func assetQuerySchema() map[string]interface{} {
"protocol": map[string]interface{}{"type": "string"}, "source": map[string]interface{}{"type": "string"}, "tag": map[string]interface{}{"type": "string"}, "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"}, "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}, "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_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_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
"last_scan_after": 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_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
"last_seen_after": 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"}}, "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"}}, "sort_order": map[string]interface{}{"type": "string", "enum": []string{"asc", "desc"}},
"page": map[string]interface{}{"type": "integer", "minimum": 1}, "page": map[string]interface{}{"type": "integer", "minimum": 1},
"page_size": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": agentAssetPageSizeMax}, "page_size": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": agentAssetPageSizeMax},
@@ -246,6 +259,11 @@ func applyAssetPatch(asset *database.Asset, args map[string]interface{}) error {
setString("country", &asset.Country) setString("country", &asset.Country)
setString("province", &asset.Province) setString("province", &asset.Province)
setString("city", &asset.City) 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", &asset.Source)
setString("source_query", &asset.SourceQuery) setString("source_query", &asset.SourceQuery)
setString("status", &asset.Status) setString("status", &asset.Status)
@@ -273,6 +291,11 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
Host: strings.TrimSpace(strArg(args, "host")), IP: strings.TrimSpace(strArg(args, "ip")), Domain: strings.TrimSpace(strArg(args, "domain")), 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"))), 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"))), 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") { if !oneOfOrEmpty(filter.Status, "active", "inactive") {
return filter, 0, 0, fmt.Errorf("status 仅支持 active 或 inactive") return filter, 0, 0, fmt.Errorf("status 仅支持 active 或 inactive")
@@ -280,7 +303,7 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
if !oneOfOrEmpty(filter.ScanState, "never", "scanned") { if !oneOfOrEmpty(filter.ScanState, "never", "scanned") {
return filter, 0, 0, fmt.Errorf("scan_state 仅支持 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") { 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 不受支持") return filter, 0, 0, fmt.Errorf("sort_by 不受支持")
} }
if !oneOfOrEmpty(filter.SortOrder, "asc", "desc") { if !oneOfOrEmpty(filter.SortOrder, "asc", "desc") {
@@ -293,6 +316,27 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
} }
filter.Port = &port 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 var err error
if filter.LastScanBefore, err = parseAssetToolTime("last_scan_before", strArg(args, "last_scan_before")); err != nil { if filter.LastScanBefore, err = parseAssetToolTime("last_scan_before", strArg(args, "last_scan_before")); err != nil {
return filter, 0, 0, err return filter, 0, 0, err
@@ -300,6 +344,12 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
if filter.LastScanAfter, err = parseAssetToolTime("last_scan_after", strArg(args, "last_scan_after")); err != nil { if filter.LastScanAfter, err = parseAssetToolTime("last_scan_after", strArg(args, "last_scan_after")); err != nil {
return filter, 0, 0, err 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 { if filter.LastSeenBefore, err = parseAssetToolTime("last_seen_before", strArg(args, "last_seen_before")); err != nil {
return filter, 0, 0, err return filter, 0, 0, err
} }
@@ -435,6 +485,8 @@ func assetToolDetail(asset *database.Asset) map[string]interface{} {
"domain": truncateRunes(asset.Domain, 255), "protocol": truncateRunes(asset.Protocol, 50), "domain": truncateRunes(asset.Domain, 255), "protocol": truncateRunes(asset.Protocol, 50),
"title": truncateRunes(asset.Title, 500), "server": truncateRunes(asset.Server, 500), "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), "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), "source": truncateRunes(asset.Source, 100), "source_query": truncateRunes(asset.SourceQuery, 2000),
"status": truncateRunes(asset.Status, 50), "tags": tags, "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, "first_seen_at": asset.FirstSeenAt, "last_seen_at": asset.LastSeenAt, "created_at": asset.CreatedAt, "updated_at": asset.UpdatedAt,
+12 -3
View File
@@ -23,6 +23,9 @@ type Config struct {
MCP MCPConfig `yaml:"mcp"` MCP MCPConfig `yaml:"mcp"`
OpenAI OpenAIConfig `yaml:"openai"` OpenAI OpenAIConfig `yaml:"openai"`
FOFA FofaConfig `yaml:"fofa,omitempty" json:"fofa,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"` Agent AgentConfig `yaml:"agent"`
Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"` Hitl HitlConfig `yaml:"hitl,omitempty" json:"hitl,omitempty"`
Security SecurityConfig `yaml:"security"` Security SecurityConfig `yaml:"security"`
@@ -846,7 +849,8 @@ func (c OpenAIConfig) MaxCompletionTokensEffective() int {
// OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。 // OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。
type OpenAIReasoningConfig struct { 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"` Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Effort: low | medium | high | max | xhighmax/xhigh 为不同网关最高档命名,原样下发、不互转。空表示不单独指定强度。 // Effort: low | medium | high | max | xhighmax/xhigh 为不同网关最高档命名,原样下发、不互转。空表示不单独指定强度。
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"` Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
@@ -855,6 +859,7 @@ type OpenAIReasoningConfig struct {
// Profile: auto | deepseek_compat | openai_compat | output_config_effort // Profile: auto | deepseek_compat | openai_compat | output_config_effort
Profile string `yaml:"profile,omitempty" json:"profile,omitempty"` Profile string `yaml:"profile,omitempty" json:"profile,omitempty"`
// ExtraRequestFields 合并进 Chat Completions 根 JSON(管理员用;与自动字段同名时后者覆盖)。 // ExtraRequestFields 合并进 Chat Completions 根 JSON(管理员用;与自动字段同名时后者覆盖)。
// Mode=off 时会移除其中的推理控制字段,但保留其他扩展字段;DeepSeek profile 随后补充显式关闭开关。
ExtraRequestFields map[string]interface{} `yaml:"extra_request_fields,omitempty" json:"extra_request_fields,omitempty"` ExtraRequestFields map[string]interface{} `yaml:"extra_request_fields,omitempty" json:"extra_request_fields,omitempty"`
} }
@@ -885,12 +890,16 @@ func (c OpenAIReasoningConfig) AllowClientReasoningEffective() bool {
} }
type FofaConfig struct { type FofaConfig struct {
// Email 为 FOFA 账号邮箱;APIKey 为 FOFA API Key(建议使用只读权限的 Key) // APIKey 为 FOFA API Key(建议使用只读权限的 Key)
Email string `yaml:"email,omitempty" json:"email,omitempty"`
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` 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 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 { type SecurityConfig struct {
Tools []ToolConfig `yaml:"tools,omitempty"` // 向后兼容:支持在主配置文件中定义工具 Tools []ToolConfig `yaml:"tools,omitempty"` // 向后兼容:支持在主配置文件中定义工具
ToolsDir string `yaml:"tools_dir,omitempty"` // 工具配置文件目录(新方式) ToolsDir string `yaml:"tools_dir,omitempty"` // 工具配置文件目录(新方式)
+14
View File
@@ -44,3 +44,17 @@ func ApplyDevHTTPSBootstrap(cfg *Config) {
} }
cfg.Server.TLSAutoSelfSign = true 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")
}
}
+414 -26
View File
@@ -31,6 +31,11 @@ type Asset struct {
Country string `json:"country"` Country string `json:"country"`
Province string `json:"province"` Province string `json:"province"`
City string `json:"city"` City string `json:"city"`
ResponsiblePerson string `json:"responsible_person"`
Department string `json:"department"`
BusinessSystem string `json:"business_system"`
Environment string `json:"environment"`
Criticality string `json:"criticality"`
Source string `json:"source"` Source string `json:"source"`
SourceQuery string `json:"source_query"` SourceQuery string `json:"source_query"`
Status string `json:"status"` Status string `json:"status"`
@@ -59,9 +64,23 @@ type AssetListFilter struct {
IP string IP string
Domain string Domain string
Port *int Port *int
RiskLevel string
MinVulnerabilities *int
MaxVulnerabilities *int
Country string
Province string
City string
ResponsiblePerson string
Department string
BusinessSystem string
Environment string
Criticality string
ScanState string ScanState string
ScanOverdueDays *int
LastScanBefore *time.Time LastScanBefore *time.Time
LastScanAfter *time.Time LastScanAfter *time.Time
FirstSeenBefore *time.Time
FirstSeenAfter *time.Time
LastSeenBefore *time.Time LastSeenBefore *time.Time
LastSeenAfter *time.Time LastSeenAfter *time.Time
SortBy string SortBy string
@@ -84,6 +103,11 @@ func normalizeAsset(a *Asset) {
a.Country = strings.TrimSpace(a.Country) a.Country = strings.TrimSpace(a.Country)
a.Province = strings.TrimSpace(a.Province) a.Province = strings.TrimSpace(a.Province)
a.City = strings.TrimSpace(a.City) a.City = strings.TrimSpace(a.City)
a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson)
a.Department = strings.TrimSpace(a.Department)
a.BusinessSystem = strings.TrimSpace(a.BusinessSystem)
a.Environment = strings.ToLower(strings.TrimSpace(a.Environment))
a.Criticality = strings.ToLower(strings.TrimSpace(a.Criticality))
a.Source = strings.TrimSpace(a.Source) a.Source = strings.TrimSpace(a.Source)
a.SourceQuery = strings.TrimSpace(a.SourceQuery) a.SourceQuery = strings.TrimSpace(a.SourceQuery)
a.ProjectID = strings.TrimSpace(a.ProjectID) a.ProjectID = strings.TrimSpace(a.ProjectID)
@@ -192,6 +216,7 @@ func validateAsset(a *Asset) error {
for name, value := range map[string]string{ for name, value := range map[string]string{
"Host": a.Host, "域名": a.Domain, "协议": a.Protocol, "页面标题": a.Title, "Host": a.Host, "域名": a.Domain, "协议": a.Protocol, "页面标题": a.Title,
"服务指纹": a.Server, "国家/地区": a.Country, "省份/州": a.Province, "城市": a.City, "服务指纹": a.Server, "国家/地区": a.Country, "省份/州": a.Province, "城市": a.City,
"负责人": a.ResponsiblePerson, "部门": a.Department, "业务系统": a.BusinessSystem,
} { } {
limit := 255 limit := 255
if name == "Host" || name == "页面标题" { if name == "Host" || name == "页面标题" {
@@ -201,6 +226,12 @@ func validateAsset(a *Asset) error {
return assetValidationErrorf("%s不能超过 %d 个字符", name, limit) return assetValidationErrorf("%s不能超过 %d 个字符", name, limit)
} }
} }
if !oneOfAssetValue(a.Environment, "", "production", "staging", "testing", "development", "other") {
return assetValidationErrorf("环境必须为 production、staging、testing、development 或 other")
}
if !oneOfAssetValue(a.Criticality, "", "critical", "high", "medium", "low") {
return assetValidationErrorf("重要性必须为 critical、high、medium 或 low")
}
if len(a.Tags) > 30 { if len(a.Tags) > 30 {
return assetValidationErrorf("标签不能超过 30 个") return assetValidationErrorf("标签不能超过 30 个")
} }
@@ -212,6 +243,15 @@ func validateAsset(a *Asset) error {
return nil return nil
} }
func oneOfAssetValue(value string, allowed ...string) bool {
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
func validAssetDomain(domain string) bool { func validAssetDomain(domain string) bool {
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
if domain == "" || len(domain) > 253 || net.ParseIP(domain) != nil { if domain == "" || len(domain) > 253 || net.ParseIP(domain) != nil {
@@ -290,10 +330,12 @@ func (db *DB) UpsertAssets(assets []*Asset, ownerUserID string, allowGlobal ...b
asset.FirstSeenAt, asset.LastSeenAt, asset.CreatedAt, asset.UpdatedAt = now, now, now, now asset.FirstSeenAt, asset.LastSeenAt, asset.CreatedAt, asset.UpdatedAt = now, now, now, now
_, err = tx.Exec(`INSERT INTO assets ( _, err = tx.Exec(`INSERT INTO assets (
id,dedup_key,project_id,host,ip,port,domain,protocol,title,server,country,province,city,source,source_query,status,tags_json, id,dedup_key,project_id,host,ip,port,domain,protocol,title,server,country,province,city,source,source_query,status,tags_json,
responsible_person,department,business_system,environment,criticality,
first_seen_at,last_seen_at,created_at,updated_at,owner_user_id first_seen_at,last_seen_at,created_at,updated_at,owner_user_id
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
asset.ID, key, nullIfEmpty(asset.ProjectID), asset.Host, asset.IP, asset.Port, asset.Domain, asset.Protocol, asset.Title, asset.Server, asset.ID, key, nullIfEmpty(asset.ProjectID), asset.Host, asset.IP, asset.Port, asset.Domain, asset.Protocol, asset.Title, asset.Server,
asset.Country, asset.Province, asset.City, asset.Source, asset.SourceQuery, asset.Status, string(tagsJSON), asset.Country, asset.Province, asset.City, asset.Source, asset.SourceQuery, asset.Status, string(tagsJSON),
asset.ResponsiblePerson, asset.Department, asset.BusinessSystem, asset.Environment, asset.Criticality,
now, now, now, now, nullIfEmpty(ownerUserID)) now, now, now, now, nullIfEmpty(ownerUserID))
if err != nil { if err != nil {
return result, fmt.Errorf("创建资产失败: %w", err) return result, fmt.Errorf("创建资产失败: %w", err)
@@ -322,11 +364,18 @@ func (db *DB) UpsertAssets(assets []*Asset, ownerUserID string, allowGlobal ...b
country=CASE WHEN ?<>'' THEN ? ELSE country END, province=CASE WHEN ?<>'' THEN ? ELSE province END, country=CASE WHEN ?<>'' THEN ? ELSE country END, province=CASE WHEN ?<>'' THEN ? ELSE province END,
city=CASE WHEN ?<>'' THEN ? ELSE city END, source=CASE WHEN ?<>'' THEN ? ELSE source END, city=CASE WHEN ?<>'' THEN ? ELSE city END, source=CASE WHEN ?<>'' THEN ? ELSE source END,
source_query=CASE WHEN ?<>'' THEN ? ELSE source_query END, project_id=CASE WHEN ?<>'' THEN ? ELSE project_id END, source_query=CASE WHEN ?<>'' THEN ? ELSE source_query END, project_id=CASE WHEN ?<>'' THEN ? ELSE project_id END,
responsible_person=CASE WHEN ?<>'' THEN ? ELSE responsible_person END,
department=CASE WHEN ?<>'' THEN ? ELSE department END,
business_system=CASE WHEN ?<>'' THEN ? ELSE business_system END,
environment=CASE WHEN ?<>'' THEN ? ELSE environment END,
criticality=CASE WHEN ?<>'' THEN ? ELSE criticality END,
tags_json=CASE WHEN ?<>'[]' THEN ? ELSE tags_json END, tags_json=CASE WHEN ?<>'[]' THEN ? ELSE tags_json END,
last_seen_at=?, updated_at=? WHERE id=?`, last_seen_at=?, updated_at=? WHERE id=?`,
asset.Host, asset.Host, asset.IP, asset.IP, asset.Domain, asset.Domain, asset.Protocol, asset.Protocol, asset.Host, asset.Host, asset.IP, asset.IP, asset.Domain, asset.Domain, asset.Protocol, asset.Protocol,
asset.Title, asset.Title, asset.Server, asset.Server, asset.Country, asset.Country, asset.Province, asset.Province, asset.Title, asset.Title, asset.Server, asset.Server, asset.Country, asset.Country, asset.Province, asset.Province,
asset.City, asset.City, asset.Source, asset.Source, asset.SourceQuery, asset.SourceQuery, asset.ProjectID, nullIfEmpty(asset.ProjectID), string(tagsJSON), string(tagsJSON), asset.City, asset.City, asset.Source, asset.Source, asset.SourceQuery, asset.SourceQuery, asset.ProjectID, nullIfEmpty(asset.ProjectID),
asset.ResponsiblePerson, asset.ResponsiblePerson, asset.Department, asset.Department, asset.BusinessSystem, asset.BusinessSystem,
asset.Environment, asset.Environment, asset.Criticality, asset.Criticality, string(tagsJSON), string(tagsJSON),
now, now, existingID) now, now, existingID)
if err != nil { if err != nil {
return result, fmt.Errorf("更新资产失败: %w", err) return result, fmt.Errorf("更新资产失败: %w", err)
@@ -349,18 +398,19 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
args := []interface{}{} args := []interface{}{}
if q := strings.TrimSpace(filter.Search); q != "" { if q := strings.TrimSpace(filter.Search); q != "" {
pattern := "%" + escapeAssetLike(strings.ToLower(q)) + "%" pattern := "%" + escapeAssetLike(strings.ToLower(q)) + "%"
query += ` AND (LOWER(host) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\' OR LOWER(domain) LIKE ? ESCAPE '\' query += ` AND (LOWER(assets.host) LIKE ? ESCAPE '\' OR LOWER(assets.ip) LIKE ? ESCAPE '\' OR LOWER(assets.domain) LIKE ? ESCAPE '\'
OR LOWER(title) LIKE ? ESCAPE '\' OR LOWER(server) LIKE ? ESCAPE '\' OR LOWER(tags_json) LIKE ? ESCAPE '\')` OR LOWER(assets.title) LIKE ? ESCAPE '\' OR LOWER(assets.server) LIKE ? ESCAPE '\' OR LOWER(assets.tags_json) LIKE ? ESCAPE '\'
for i := 0; i < 6; i++ { OR LOWER(assets.responsible_person) LIKE ? ESCAPE '\' OR LOWER(assets.department) LIKE ? ESCAPE '\' OR LOWER(assets.business_system) LIKE ? ESCAPE '\')`
for i := 0; i < 9; i++ {
args = append(args, pattern) args = append(args, pattern)
} }
} }
if filter.Status != "" { if filter.Status != "" {
query += " AND status = ?" query += " AND assets.status = ?"
args = append(args, filter.Status) args = append(args, filter.Status)
} }
if filter.Protocol != "" { if filter.Protocol != "" {
query += " AND protocol = ?" query += " AND assets.protocol = ?"
args = append(args, filter.Protocol) args = append(args, filter.Protocol)
} }
if filter.ProjectID != "" { if filter.ProjectID != "" {
@@ -392,12 +442,41 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
query += " AND assets.port = ?" query += " AND assets.port = ?"
args = append(args, *filter.Port) args = append(args, *filter.Port)
} }
if filter.RiskLevel != "" {
query += " AND " + assetRiskLevelExpr + " = ?"
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
}
if filter.MinVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
args = append(args, *filter.MinVulnerabilities)
}
if filter.MaxVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
args = append(args, *filter.MaxVulnerabilities)
}
for _, item := range []struct {
column string
value string
}{
{"assets.country", filter.Country}, {"assets.province", filter.Province}, {"assets.city", filter.City},
{"assets.responsible_person", filter.ResponsiblePerson}, {"assets.department", filter.Department},
{"assets.business_system", filter.BusinessSystem}, {"assets.environment", filter.Environment}, {"assets.criticality", filter.Criticality},
} {
if strings.TrimSpace(item.value) != "" {
query += " AND LOWER(" + item.column + ") = LOWER(?)"
args = append(args, strings.TrimSpace(item.value))
}
}
switch strings.ToLower(strings.TrimSpace(filter.ScanState)) { switch strings.ToLower(strings.TrimSpace(filter.ScanState)) {
case "never": case "never":
query += " AND " + assetEffectiveLastScanExpr + " IS NULL" query += " AND " + assetEffectiveLastScanExpr + " IS NULL"
case "scanned": case "scanned":
query += " AND " + assetEffectiveLastScanExpr + " IS NOT NULL" query += " AND " + assetEffectiveLastScanExpr + " IS NOT NULL"
} }
if filter.ScanOverdueDays != nil {
query += " AND (" + assetEffectiveLastScanExpr + " IS NULL OR datetime(" + assetEffectiveLastScanExpr + ") < datetime('now', ?))"
args = append(args, fmt.Sprintf("-%d days", *filter.ScanOverdueDays))
}
if filter.LastScanBefore != nil { if filter.LastScanBefore != nil {
query += " AND " + assetEffectiveLastScanExpr + " < ?" query += " AND " + assetEffectiveLastScanExpr + " < ?"
args = append(args, *filter.LastScanBefore) args = append(args, *filter.LastScanBefore)
@@ -406,6 +485,14 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
query += " AND " + assetEffectiveLastScanExpr + " > ?" query += " AND " + assetEffectiveLastScanExpr + " > ?"
args = append(args, *filter.LastScanAfter) args = append(args, *filter.LastScanAfter)
} }
if filter.FirstSeenBefore != nil {
query += " AND assets.first_seen_at < ?"
args = append(args, *filter.FirstSeenBefore)
}
if filter.FirstSeenAfter != nil {
query += " AND assets.first_seen_at > ?"
args = append(args, *filter.FirstSeenAfter)
}
if filter.LastSeenBefore != nil { if filter.LastSeenBefore != nil {
query += " AND assets.last_seen_at < ?" query += " AND assets.last_seen_at < ?"
args = append(args, *filter.LastSeenBefore) args = append(args, *filter.LastSeenBefore)
@@ -428,7 +515,8 @@ func scanAsset(scanner interface{ Scan(...interface{}) error }) (*Asset, error)
var tags string var tags string
var lastScanAt interface{} var lastScanAt interface{}
err := scanner.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &a.Host, &a.IP, &a.Port, &a.Domain, &a.Protocol, &a.Title, &a.Server, &a.Country, err := scanner.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &a.Host, &a.IP, &a.Port, &a.Domain, &a.Protocol, &a.Title, &a.Server, &a.Country,
&a.Province, &a.City, &a.Source, &a.SourceQuery, &a.Status, &tags, &a.FirstSeenAt, &a.LastSeenAt, &a.CreatedAt, &a.UpdatedAt, &a.Province, &a.City, &a.ResponsiblePerson, &a.Department, &a.BusinessSystem, &a.Environment, &a.Criticality,
&a.Source, &a.SourceQuery, &a.Status, &tags, &a.FirstSeenAt, &a.LastSeenAt, &a.CreatedAt, &a.UpdatedAt,
&lastScanAt, &a.LastScanConversationID, &a.LastScanQueueID, &a.LastScanTaskID, &a.VulnerabilityCount, &a.RiskLevel) &lastScanAt, &a.LastScanConversationID, &a.LastScanQueueID, &a.LastScanTaskID, &a.VulnerabilityCount, &a.RiskLevel)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -476,20 +564,29 @@ const assetEffectiveLastScanExpr = `COALESCE(
assets.last_scan_at assets.last_scan_at
)` )`
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country, const assetVulnerabilityMatchExpr = `(
assets.province,assets.city,assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE
(COALESCE(assets.last_scan_conversation_id,'')<>'' AND v.conversation_id=assets.last_scan_conversation_id) (COALESCE(assets.last_scan_conversation_id,'')<>'' AND v.conversation_id=assets.last_scan_conversation_id)
OR (COALESCE(assets.last_scan_task_id,'')<>'' AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.id=assets.last_scan_task_id AND bt.conversation_id=v.conversation_id))), OR (COALESCE(assets.last_scan_task_id,'')<>'' AND EXISTS (
CASE WHEN assets.last_scan_at IS NULL THEN 'unassessed' ELSE CASE COALESCE(( SELECT 1 FROM batch_tasks bt WHERE bt.id=assets.last_scan_task_id AND bt.conversation_id=v.conversation_id
))
)`
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
const assetRiskScoreExpr = `COALESCE((
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END) SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
FROM vulnerabilities v WHERE FROM vulnerabilities v
LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ( WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
(COALESCE(assets.last_scan_conversation_id,'')<>'' AND v.conversation_id=assets.last_scan_conversation_id) ),0)`
OR (COALESCE(assets.last_scan_task_id,'')<>'' AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.id=assets.last_scan_task_id AND bt.conversation_id=v.conversation_id))
) const assetRiskLevelExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreExpr + `
),0) WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END` WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
` + assetVulnerabilityCountExpr + `,` + assetRiskLevelExpr
// MarkAssetScanned links an asset to the conversation or batch subtask created from it. // MarkAssetScanned links an asset to the conversation or batch subtask created from it.
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan. // The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
@@ -573,6 +670,36 @@ func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACL
return items, total, rows.Err() return items, total, rows.Err()
} }
// ListAssetsForOperation resolves the complete filtered selection used by
// cross-page bulk actions. The caller supplies a strict upper bound.
func (db *DB) ListAssetsForOperation(limit int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
if limit < 1 || limit > 10000 {
limit = 10000
}
where, args := assetWhere(filter, access)
var total int
if err := db.QueryRow("SELECT COUNT(*) FROM assets"+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
if total > limit {
return nil, total, fmt.Errorf("匹配资产超过 %d 条,请缩小筛选范围", limit)
}
rows, err := db.Query("SELECT "+assetSelectColumns+" FROM assets LEFT JOIN projects p ON p.id=assets.project_id"+where+" ORDER BY "+assetOrderBy(filter.SortBy, filter.SortOrder), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]*Asset, 0, total)
for rows.Next() {
item, err := scanAsset(rows)
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
}
func assetOrderBy(sortBy, sortOrder string) string { func assetOrderBy(sortBy, sortOrder string) string {
direction := "DESC" direction := "DESC"
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") { if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
@@ -598,6 +725,10 @@ func assetOrderBy(sortBy, sortOrder string) string {
expression = "LOWER(assets.host)" expression = "LOWER(assets.host)"
case "port": case "port":
expression = "assets.port" expression = "assets.port"
case "vulnerability_count":
expression = assetVulnerabilityCountExpr
case "risk_level":
expression = assetRiskScoreExpr
default: default:
expression = "assets.last_seen_at" expression = "assets.last_seen_at"
} }
@@ -620,8 +751,10 @@ func (db *DB) UpdateAsset(id string, a *Asset, access RBACListAccess) error {
} }
tags, _ := json.Marshal(a.Tags) tags, _ := json.Marshal(a.Tags)
where, args := appendAssetAccess(" WHERE id = ?", []interface{}{id}, access, "assets") where, args := appendAssetAccess(" WHERE id = ?", []interface{}{id}, access, "assets")
res, err := db.Exec(`UPDATE assets SET dedup_key=?,project_id=?,host=?,ip=?,port=?,domain=?,protocol=?,title=?,server=?,country=?,province=?,city=?,source=?,source_query=?,status=?,tags_json=?,updated_at=?`+where, res, err := db.Exec(`UPDATE assets SET dedup_key=?,project_id=?,host=?,ip=?,port=?,domain=?,protocol=?,title=?,server=?,country=?,province=?,city=?,
append([]interface{}{key, nullIfEmpty(a.ProjectID), a.Host, a.IP, a.Port, a.Domain, a.Protocol, a.Title, a.Server, a.Country, a.Province, a.City, a.Source, a.SourceQuery, a.Status, string(tags), time.Now()}, args...)...) responsible_person=?,department=?,business_system=?,environment=?,criticality=?,source=?,source_query=?,status=?,tags_json=?,updated_at=?`+where,
append([]interface{}{key, nullIfEmpty(a.ProjectID), a.Host, a.IP, a.Port, a.Domain, a.Protocol, a.Title, a.Server, a.Country, a.Province, a.City,
a.ResponsiblePerson, a.Department, a.BusinessSystem, a.Environment, a.Criticality, a.Source, a.SourceQuery, a.Status, string(tags), time.Now()}, args...)...)
if err != nil { if err != nil {
return err return err
} }
@@ -632,10 +765,18 @@ func (db *DB) UpdateAsset(id string, a *Asset, access RBACListAccess) error {
return nil return nil
} }
// UpdateAssetsProject atomically replaces the project binding for every asset. type AssetBulkPatch struct {
// It refuses the whole update when any requested asset is missing or outside Status *string
// the caller's access scope, so a bulk action can never partially succeed. ResponsiblePerson *string
func (db *DB) UpdateAssetsProject(ids []string, projectID string, access RBACListAccess) (int, error) { Department *string
BusinessSystem *string
Environment *string
Criticality *string
AddTags []string
RemoveTags []string
}
func normalizeAssetIDs(ids []string) []string {
unique := make([]string, 0, len(ids)) unique := make([]string, 0, len(ids))
seen := make(map[string]struct{}, len(ids)) seen := make(map[string]struct{}, len(ids))
for _, id := range ids { for _, id := range ids {
@@ -649,6 +790,253 @@ func (db *DB) UpdateAssetsProject(ids []string, projectID string, access RBACLis
seen[id] = struct{}{} seen[id] = struct{}{}
unique = append(unique, id) unique = append(unique, id)
} }
return unique
}
func normalizeBulkTags(tags []string) ([]string, error) {
seen := map[string]struct{}{}
result := make([]string, 0, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if utf8.RuneCountInString(tag) > 64 {
return nil, assetValidationErrorf("单个标签不能超过 64 个字符")
}
if _, exists := seen[tag]; exists {
continue
}
seen[tag] = struct{}{}
result = append(result, tag)
}
return result, nil
}
// UpdateAssetsBulk atomically applies operational metadata to a selected set.
func (db *DB) UpdateAssetsBulk(ids []string, patch AssetBulkPatch, access RBACListAccess) (int, error) {
unique := normalizeAssetIDs(ids)
if len(unique) == 0 {
return 0, fmt.Errorf("资产列表不能为空")
}
if patch.Status != nil {
value := strings.ToLower(strings.TrimSpace(*patch.Status))
if value != "active" && value != "inactive" {
return 0, assetValidationErrorf("资产状态必须为 active 或 inactive")
}
patch.Status = &value
}
if patch.Environment != nil {
value := strings.ToLower(strings.TrimSpace(*patch.Environment))
if !oneOfAssetValue(value, "", "production", "staging", "testing", "development", "other") {
return 0, assetValidationErrorf("环境值无效")
}
patch.Environment = &value
}
if patch.Criticality != nil {
value := strings.ToLower(strings.TrimSpace(*patch.Criticality))
if !oneOfAssetValue(value, "", "critical", "high", "medium", "low") {
return 0, assetValidationErrorf("重要性值无效")
}
patch.Criticality = &value
}
var err error
if patch.AddTags, err = normalizeBulkTags(patch.AddTags); err != nil {
return 0, err
}
if patch.RemoveTags, err = normalizeBulkTags(patch.RemoveTags); err != nil {
return 0, err
}
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(unique)), ",")
idArgs := make([]interface{}, len(unique))
for i, id := range unique {
idArgs[i] = id
}
countQuery, countArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id IN ("+placeholders+")", idArgs, access, "assets")
var accessible int
if err := tx.QueryRow(countQuery, countArgs...).Scan(&accessible); err != nil {
return 0, err
}
if accessible != len(unique) {
return 0, fmt.Errorf("部分资产不存在或无权更新")
}
for _, id := range unique {
var rawTags string
if err := tx.QueryRow("SELECT tags_json FROM assets WHERE id=?", id).Scan(&rawTags); err != nil {
return 0, err
}
tags := []string{}
_ = json.Unmarshal([]byte(rawTags), &tags)
remove := map[string]struct{}{}
for _, tag := range patch.RemoveTags {
remove[tag] = struct{}{}
}
merged := make([]string, 0, len(tags)+len(patch.AddTags))
seen := map[string]struct{}{}
for _, tag := range append(tags, patch.AddTags...) {
if _, removed := remove[tag]; removed {
continue
}
if _, exists := seen[tag]; exists {
continue
}
seen[tag] = struct{}{}
merged = append(merged, tag)
}
if len(merged) > 30 {
return 0, assetValidationErrorf("批量修改后标签不能超过 30 个")
}
tagsJSON, _ := json.Marshal(merged)
_, err := tx.Exec(`UPDATE assets SET
status=CASE WHEN ? THEN ? ELSE status END,
responsible_person=CASE WHEN ? THEN ? ELSE responsible_person END,
department=CASE WHEN ? THEN ? ELSE department END,
business_system=CASE WHEN ? THEN ? ELSE business_system END,
environment=CASE WHEN ? THEN ? ELSE environment END,
criticality=CASE WHEN ? THEN ? ELSE criticality END,
tags_json=?,updated_at=? WHERE id=?`,
patch.Status != nil, valueOrEmpty(patch.Status),
patch.ResponsiblePerson != nil, valueOrEmpty(patch.ResponsiblePerson),
patch.Department != nil, valueOrEmpty(patch.Department),
patch.BusinessSystem != nil, valueOrEmpty(patch.BusinessSystem),
patch.Environment != nil, valueOrEmpty(patch.Environment),
patch.Criticality != nil, valueOrEmpty(patch.Criticality),
string(tagsJSON), time.Now(), id)
if err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(unique), nil
}
func valueOrEmpty(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func (db *DB) DeleteAssets(ids []string, access RBACListAccess) (int, error) {
unique := normalizeAssetIDs(ids)
if len(unique) == 0 {
return 0, fmt.Errorf("资产列表不能为空")
}
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(unique)), ",")
args := make([]interface{}, len(unique))
for i, id := range unique {
args[i] = id
}
countQuery, countArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id IN ("+placeholders+")", args, access, "assets")
var accessible int
if err := tx.QueryRow(countQuery, countArgs...).Scan(&accessible); err != nil {
return 0, err
}
if accessible != len(unique) {
return 0, fmt.Errorf("部分资产不存在或无权删除")
}
deleteQuery, deleteArgs := appendAssetAccess("DELETE FROM assets WHERE id IN ("+placeholders+")", args, access, "assets")
result, err := tx.Exec(deleteQuery, deleteArgs...)
if err != nil {
return 0, err
}
deleted, err := result.RowsAffected()
if err != nil || int(deleted) != len(unique) {
return 0, fmt.Errorf("批量删除资产失败")
}
if err := tx.Commit(); err != nil {
return 0, err
}
return int(deleted), nil
}
// MergeAssets atomically updates the surviving asset and removes duplicates.
// Separate access scopes preserve permission-specific RBAC boundaries.
func (db *DB) MergeAssets(primary *Asset, duplicateIDs []string, writeAccess, deleteAccess RBACListAccess) (int, error) {
if primary == nil || strings.TrimSpace(primary.ID) == "" {
return 0, fmt.Errorf("主资产不能为空")
}
normalizeAsset(primary)
if err := validateAsset(primary); err != nil {
return 0, err
}
duplicates := normalizeAssetIDs(duplicateIDs)
filtered := duplicates[:0]
for _, id := range duplicates {
if id != primary.ID {
filtered = append(filtered, id)
}
}
duplicates = filtered
if len(duplicates) == 0 {
return 0, fmt.Errorf("重复资产列表不能为空")
}
key := assetDedupKey(primary)
tagsJSON, _ := json.Marshal(primary.Tags)
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
primaryQuery, primaryArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id=?", []interface{}{primary.ID}, writeAccess, "assets")
var primaryCount int
if err := tx.QueryRow(primaryQuery, primaryArgs...).Scan(&primaryCount); err != nil || primaryCount != 1 {
return 0, fmt.Errorf("主资产不存在或无权更新")
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(duplicates)), ",")
deleteArgs := make([]interface{}, len(duplicates))
for i, id := range duplicates {
deleteArgs[i] = id
}
countQuery, countArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id IN ("+placeholders+")", deleteArgs, deleteAccess, "assets")
var accessible int
if err := tx.QueryRow(countQuery, countArgs...).Scan(&accessible); err != nil || accessible != len(duplicates) {
return 0, fmt.Errorf("部分重复资产不存在或无权删除")
}
deleteQuery, scopedDeleteArgs := appendAssetAccess("DELETE FROM assets WHERE id IN ("+placeholders+")", deleteArgs, deleteAccess, "assets")
if result, err := tx.Exec(deleteQuery, scopedDeleteArgs...); err != nil {
return 0, err
} else if deleted, _ := result.RowsAffected(); int(deleted) != len(duplicates) {
return 0, fmt.Errorf("删除重复资产失败")
}
updateQuery, updateScopeArgs := appendAssetAccess(`UPDATE assets SET dedup_key=?,project_id=?,host=?,ip=?,port=?,domain=?,protocol=?,title=?,server=?,country=?,province=?,city=?,
responsible_person=?,department=?,business_system=?,environment=?,criticality=?,source=?,source_query=?,status=?,tags_json=?,updated_at=? WHERE id=?`,
[]interface{}{key, nullIfEmpty(primary.ProjectID), primary.Host, primary.IP, primary.Port, primary.Domain, primary.Protocol, primary.Title, primary.Server,
primary.Country, primary.Province, primary.City, primary.ResponsiblePerson, primary.Department, primary.BusinessSystem, primary.Environment,
primary.Criticality, primary.Source, primary.SourceQuery, primary.Status, string(tagsJSON), time.Now(), primary.ID}, writeAccess, "assets")
result, err := tx.Exec(updateQuery, updateScopeArgs...)
if err != nil {
return 0, err
}
if updated, _ := result.RowsAffected(); updated != 1 {
return 0, fmt.Errorf("更新主资产失败")
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(duplicates), nil
}
// UpdateAssetsProject atomically replaces the project binding for every asset.
// It refuses the whole update when any requested asset is missing or outside
// the caller's access scope, so a bulk action can never partially succeed.
func (db *DB) UpdateAssetsProject(ids []string, projectID string, access RBACListAccess) (int, error) {
unique := normalizeAssetIDs(ids)
if len(unique) == 0 { if len(unique) == 0 {
return 0, fmt.Errorf("资产列表不能为空") return 0, fmt.Errorf("资产列表不能为空")
} }
+128
View File
@@ -2,6 +2,7 @@ package database
import ( import (
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -224,6 +225,133 @@ func TestUpdateAssetsProjectIsAtomicAndScoped(t *testing.T) {
} }
} }
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) { func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "asset-scan.db"), zap.NewNop()) db, err := NewDB(filepath.Join(t.TempDir(), "asset-scan.db"), zap.NewNop())
if err != nil { if err != nil {
+7
View File
@@ -412,6 +412,8 @@ func (db *DB) initTables() error {
host TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', port INTEGER NOT NULL DEFAULT 0, 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 '', 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 '', 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', 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, 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, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
@@ -987,6 +989,11 @@ func (db *DB) migrateAssetsTable() error {
{"last_scan_conversation_id", "ALTER TABLE assets ADD COLUMN last_scan_conversation_id TEXT NOT NULL DEFAULT ''"}, {"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_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 ''"}, {"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 { for _, column := range columns {
var count int var count int
+241 -6
View File
@@ -18,6 +18,11 @@ type AssetHandler struct {
logger *zap.Logger logger *zap.Logger
} }
const (
maxAssetImportBatch = 100000
maxAssetOperationBatch = 10000
)
func NewAssetHandler(db *database.DB, logger *zap.Logger) *AssetHandler { func NewAssetHandler(db *database.DB, logger *zap.Logger) *AssetHandler {
return &AssetHandler{db: db, logger: logger} return &AssetHandler{db: db, logger: logger}
} }
@@ -29,6 +34,13 @@ func assetAccess(c *gin.Context) database.RBACListAccess {
return database.RBACListAccess{} 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 { type importAssetsRequest struct {
Assets []*database.Asset `json:"assets" binding:"required"` Assets []*database.Asset `json:"assets" binding:"required"`
Source string `json:"source"` Source string `json:"source"`
@@ -51,14 +63,35 @@ type updateAssetsProjectRequest struct {
ProjectID string `json:"project_id"` 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) { func (h *AssetHandler) Import(c *gin.Context) {
var req importAssetsRequest var req importAssetsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if len(req.Assets) == 0 || len(req.Assets) > 1000 { if len(req.Assets) == 0 || len(req.Assets) > maxAssetImportBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "assets 数量必须在 1-1000 之间"}) c.JSON(http.StatusBadRequest, gin.H{"error": "assets 数量必须在 1-100000 之间"})
return return
} }
owner := "" owner := ""
@@ -132,6 +165,11 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
Source: strings.TrimSpace(c.Query("source")), Tag: strings.TrimSpace(c.Query("tag")), Host: strings.TrimSpace(c.Query("host")), 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"))), 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"))), 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 != "" { if raw := strings.TrimSpace(c.Query("port")); raw != "" {
port, err := strconv.Atoi(raw) port, err := strconv.Atoi(raw)
@@ -140,6 +178,21 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
} }
filter.Port = &port 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 var err error
if filter.LastScanBefore, err = parseAssetQueryTime("last_scan_before", c.Query("last_scan_before")); err != nil { if filter.LastScanBefore, err = parseAssetQueryTime("last_scan_before", c.Query("last_scan_before")); err != nil {
return filter, err return filter, err
@@ -147,6 +200,12 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
if filter.LastScanAfter, err = parseAssetQueryTime("last_scan_after", c.Query("last_scan_after")); err != nil { if filter.LastScanAfter, err = parseAssetQueryTime("last_scan_after", c.Query("last_scan_after")); err != nil {
return filter, err 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 { if filter.LastSeenBefore, err = parseAssetQueryTime("last_seen_before", c.Query("last_seen_before")); err != nil {
return filter, err return filter, err
} }
@@ -156,6 +215,21 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
return filter, nil 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 } type assetQueryError struct{ field, value string }
func (e *assetQueryError) Error() string { func (e *assetQueryError) Error() string {
@@ -200,8 +274,8 @@ func (h *AssetHandler) RecordScans(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if len(req.Scans) == 0 || len(req.Scans) > 1000 { if len(req.Scans) == 0 || len(req.Scans) > maxAssetOperationBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "scans 数量必须在 1-1000 之间"}) c.JSON(http.StatusBadRequest, gin.H{"error": "scans 数量必须在 1-10000 之间"})
return return
} }
access := assetAccess(c) access := assetAccess(c)
@@ -273,8 +347,8 @@ func (h *AssetHandler) UpdateProjectBinding(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > 1000 { if len(req.AssetIDs) == 0 || len(req.AssetIDs) > maxAssetOperationBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-1000 之间"}) c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-10000 之间"})
return return
} }
req.ProjectID = strings.TrimSpace(req.ProjectID) req.ProjectID = strings.TrimSpace(req.ProjectID)
@@ -296,6 +370,167 @@ func (h *AssetHandler) UpdateProjectBinding(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"updated": updated, "project_id": req.ProjectID}) 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) { func (h *AssetHandler) Delete(c *gin.Context) {
if err := h.db.DeleteAsset(c.Param("id"), assetAccess(c)); err != nil { if err := h.db.DeleteAsset(c.Param("id"), assetAccess(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资产不存在或无权删除"}) c.JSON(http.StatusNotFound, gin.H{"error": "资产不存在或无权删除"})
+83
View File
@@ -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)
}
}
}
+45 -2
View File
@@ -261,6 +261,9 @@ type GetConfigResponse struct {
OpenAI config.OpenAIConfig `json:"openai"` OpenAI config.OpenAIConfig `json:"openai"`
Vision config.VisionConfig `json:"vision"` Vision config.VisionConfig `json:"vision"`
FOFA config.FofaConfig `json:"fofa"` 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"` MCP config.MCPConfig `json:"mcp"`
Tools []ToolConfigInfo `json:"tools"` Tools []ToolConfigInfo `json:"tools"`
Agent config.AgentConfig `json:"agent"` Agent config.AgentConfig `json:"agent"`
@@ -363,6 +366,9 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) {
OpenAI: h.config.OpenAI, OpenAI: h.config.OpenAI,
Vision: h.config.Vision, Vision: h.config.Vision,
FOFA: h.config.FOFA, FOFA: h.config.FOFA,
ZoomEye: h.config.ZoomEye,
Quake: h.config.Quake,
Shodan: h.config.Shodan,
MCP: h.config.MCP, MCP: h.config.MCP,
Tools: tools, Tools: tools,
Agent: h.config.Agent, Agent: h.config.Agent,
@@ -703,6 +709,9 @@ type UpdateConfigRequest struct {
OpenAI *config.OpenAIConfig `json:"openai,omitempty"` OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
Vision *config.VisionConfig `json:"vision,omitempty"` Vision *config.VisionConfig `json:"vision,omitempty"`
FOFA *config.FofaConfig `json:"fofa,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"` MCP *config.MCPConfig `json:"mcp,omitempty"`
Tools []ToolEnableStatus `json:"tools,omitempty"` Tools []ToolEnableStatus `json:"tools,omitempty"`
Agent *AgentConfigUpdate `json:"agent,omitempty"` Agent *AgentConfigUpdate `json:"agent,omitempty"`
@@ -775,7 +784,19 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
// 更新FOFA配置 // 更新FOFA配置
if req.FOFA != nil { if req.FOFA != nil {
h.config.FOFA = *req.FOFA 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配置 // 更新MCP配置
@@ -1601,6 +1622,9 @@ func (h *ConfigHandler) saveConfig() error {
updateOpenAIConfig(root, h.config.OpenAI) updateOpenAIConfig(root, h.config.OpenAI)
updateVisionConfig(root, h.config.Vision) updateVisionConfig(root, h.config.Vision)
updateFOFAConfig(root, h.config.FOFA) 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) updateKnowledgeConfig(root, h.config.Knowledge)
updateC2Config(root, h.config.C2) updateC2Config(root, h.config.C2)
updateRobotsConfig(root, h.config.Robots) updateRobotsConfig(root, h.config.Robots)
@@ -1788,10 +1812,17 @@ func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
root := doc.Content[0] root := doc.Content[0]
fofaNode := ensureMap(root, "fofa") fofaNode := ensureMap(root, "fofa")
setStringInMap(fofaNode, "base_url", cfg.BaseURL) setStringInMap(fofaNode, "base_url", cfg.BaseURL)
setStringInMap(fofaNode, "email", cfg.Email) removeKeyFromMap(fofaNode, "email")
setStringInMap(fofaNode, "api_key", cfg.APIKey) 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) { func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
root := doc.Content[0] root := doc.Content[0]
knowledgeNode := ensureMap(root, "knowledge") knowledgeNode := ensureMap(root, "knowledge")
@@ -2102,6 +2133,18 @@ func setStringInMap(mapNode *yaml.Node, key, value string) {
valueNode.Value = value 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) { func setStringSliceInMap(mapNode *yaml.Node, key string, values []string) {
_, valueNode := ensureKeyValue(mapNode, key) _, valueNode := ensureKeyValue(mapNode, key)
valueNode.Kind = yaml.SequenceNode valueNode.Kind = yaml.SequenceNode
+710 -148
View File
File diff suppressed because it is too large Load Diff
+199
View File
@@ -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)
}
})
}
}
+82
View File
@@ -241,6 +241,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{}{ "ExecutionResult": map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
@@ -2434,6 +2486,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{}{ "/api/projects": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"tags": []string{"项目管理"}, "tags": []string{"项目管理"},
+5 -1
View File
@@ -11,7 +11,7 @@ var apiDocI18nTagToKey = map[string]string{
"知识库": "knowledgeBase", "MCP": "mcp", "知识库": "knowledgeBase", "MCP": "mcp",
"FOFA信息收集": "fofaRecon", "终端": "terminal", "WebShell管理": "webshellManagement", "FOFA信息收集": "fofaRecon", "终端": "terminal", "WebShell管理": "webshellManagement",
"对话附件": "chatUploads", "机器人集成": "robotIntegration", "多代理Markdown": "markdownAgents", "对话附件": "chatUploads", "机器人集成": "robotIntegration", "多代理Markdown": "markdownAgents",
"项目管理": "projectManagement", "项目管理": "projectManagement", "资产管理": "assetManagement",
} }
var apiDocI18nSummaryToKey = map[string]string{ var apiDocI18nSummaryToKey = map[string]string{
@@ -73,6 +73,7 @@ var apiDocI18nSummaryToKey = map[string]string{
"获取知识库统计": "getKnowledgeStats", "获取知识库统计": "getKnowledgeStats",
"列出项目": "listProjects", "创建项目": "createProject", "获取项目": "getProject", "列出项目": "listProjects", "创建项目": "createProject", "获取项目": "getProject",
"更新项目": "updateProject", "删除项目": "deleteProject", "更新项目": "updateProject", "删除项目": "deleteProject",
"批量导入资产": "importAssets",
"列出或按 key 获取事实": "listProjectFacts", "创建/更新事实": "upsertProjectFact", "列出或按 key 获取事实": "listProjectFacts", "创建/更新事实": "upsertProjectFact",
"获取项目事实攻击路径图": "getProjectFactGraph", "列出项目全部事实边": "listProjectFactEdges", "获取项目事实攻击路径图": "getProjectFactGraph", "列出项目全部事实边": "listProjectFactEdges",
"添加事实边": "createProjectFactEdge", "删除事实边": "deleteProjectFactEdge", "添加事实边": "createProjectFactEdge", "删除事实边": "deleteProjectFactEdge",
@@ -109,6 +110,9 @@ var apiDocI18nResponseDescToKey = map[string]string{
"成功": "success", "nodes + edges": "factGraphNodesEdges", "成功": "success", "nodes + edges": "factGraphNodesEdges",
"边列表": "edgeList", "边已创建": "edgeCreated", "边列表": "edgeList", "边已创建": "edgeCreated",
"沉淀结果(facts/edges/graph": "promoteAttackChainResult", "沉淀结果(facts/edges/graph": "promoteAttackChainResult",
"导入完成": "assetImportCompleted", "数量或资产字段校验失败": "assetImportValidationFailed",
"缺少 asset:write 权限或无权访问指定项目": "assetImportForbidden",
"导入事务失败": "assetImportTransactionFailed",
} }
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary // enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary
+46
View File
@@ -0,0 +1,46 @@
package handler
import "testing"
func TestEnrichSpecWithI18nKeysForAssetImport(t *testing.T) {
responses := map[string]interface{}{
"200": map[string]interface{}{"description": "导入完成"},
"400": map[string]interface{}{"description": "数量或资产字段校验失败"},
"403": map[string]interface{}{"description": "缺少 asset:write 权限或无权访问指定项目"},
"500": map[string]interface{}{"description": "导入事务失败"},
}
operation := map[string]interface{}{
"tags": []string{"资产管理"},
"summary": "批量导入资产",
"responses": responses,
}
spec := map[string]interface{}{
"paths": map[string]interface{}{
"/api/assets/import": map[string]interface{}{
"post": operation,
},
},
}
enrichSpecWithI18nKeys(spec)
tagKeys, ok := operation["x-i18n-tags"].([]string)
if !ok || len(tagKeys) != 1 || tagKeys[0] != "assetManagement" {
t.Fatalf("unexpected asset tag i18n keys: %#v", operation["x-i18n-tags"])
}
if got := operation["x-i18n-summary"]; got != "importAssets" {
t.Fatalf("unexpected asset summary i18n key: %#v", got)
}
expectedResponseKeys := map[string]string{
"200": "assetImportCompleted",
"400": "assetImportValidationFailed",
"403": "assetImportForbidden",
"500": "assetImportTransactionFailed",
}
for status, want := range expectedResponseKeys {
response := responses[status].(map[string]interface{})
if got := response["x-i18n-description"]; got != want {
t.Errorf("unexpected asset response i18n key for %s: got %#v, want %q", status, got, want)
}
}
}
+9 -1
View File
@@ -588,11 +588,14 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
zap.Duration("backoff", backoff)) zap.Duration("backoff", backoff))
} }
if progress != nil { if progress != nil {
progress("eino_run_retry", fmt.Sprintf("遇到临时错误(限流或网络波动),%d 秒后第 %d/%d 次重试…", int(backoff.Seconds()), attemptNo, maxAttempts), map[string]interface{}{ errorKind, errorSummary := einoTransientRunErrorUserDetail(runErr)
progress("eino_run_retry", fmt.Sprintf("遇到临时错误,%d 秒后第 %d/%d 次重试。原因:%s", int(backoff.Seconds()), attemptNo, maxAttempts, errorSummary), map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"source": "eino", "source": "eino",
"orchestration": orchMode, "orchestration": orchMode,
"error": runErr.Error(), "error": runErr.Error(),
"errorKind": errorKind,
"errorSummary": errorSummary,
"attempt": attemptNo, "attempt": attemptNo,
"maxAttempts": maxAttempts, "maxAttempts": maxAttempts,
"backoffSec": int(backoff.Seconds()), "backoffSec": int(backoff.Seconds()),
@@ -601,7 +604,12 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"conversationId": conversationID, "conversationId": conversationID,
"source": "eino", "source": "eino",
"orchestration": orchMode, "orchestration": orchMode,
"error": runErr.Error(),
"errorKind": errorKind,
"errorSummary": errorSummary,
"attempt": attemptNo, "attempt": attemptNo,
"maxAttempts": maxAttempts,
"backoffSec": int(backoff.Seconds()),
"contextSource": string(ctxSource), "contextSource": string(ctxSource),
}) })
} }
@@ -94,6 +94,84 @@ func isRetryableHTTPStatus(status int) bool {
} }
} }
func einoTransientRunErrorUserDetail(err error) (kind, summary string) {
if err == nil {
return "", ""
}
msg := strings.TrimSpace(err.Error())
lower := strings.ToLower(msg)
if status := httpStatusFromErrorText(lower); status > 0 {
switch {
case status == 429:
kind = "rate_limit"
case status == 408 || status == 409 || status == 425:
kind = "retryable_http"
case status >= 500 && status <= 599:
kind = "upstream_server"
default:
kind = "http_error"
}
} else {
var apiErr *einoopenai.APIError
if errors.As(err, &apiErr) && apiErr.HTTPStatusCode > 0 {
switch {
case apiErr.HTTPStatusCode == 429:
kind = "rate_limit"
case apiErr.HTTPStatusCode == 408 || apiErr.HTTPStatusCode == 409 || apiErr.HTTPStatusCode == 425:
kind = "retryable_http"
case apiErr.HTTPStatusCode >= 500 && apiErr.HTTPStatusCode <= 599:
kind = "upstream_server"
default:
kind = "http_error"
}
}
}
if kind == "" {
switch {
case strings.Contains(lower, "too many requests") ||
strings.Contains(lower, "rate limit") ||
strings.Contains(lower, "rate_limit") ||
strings.Contains(lower, "ratelimit"):
kind = "rate_limit"
case strings.Contains(lower, "overloaded") ||
strings.Contains(lower, "capacity") ||
strings.Contains(lower, "temporarily unavailable") ||
strings.Contains(lower, "service unavailable"):
kind = "upstream_busy"
case strings.Contains(lower, "connection reset") ||
strings.Contains(lower, "connection refused") ||
strings.Contains(lower, "connection closed") ||
strings.Contains(lower, "i/o timeout") ||
strings.Contains(lower, "no such host") ||
strings.Contains(lower, "network is unreachable") ||
strings.Contains(lower, "broken pipe") ||
strings.Contains(lower, "read tcp") ||
strings.Contains(lower, "write tcp") ||
strings.Contains(lower, "dial tcp") ||
strings.Contains(lower, "tls handshake timeout") ||
strings.Contains(lower, "goaway") ||
strings.Contains(lower, "unexpected eof"):
kind = "network"
case strings.Contains(lower, "stream error") ||
strings.Contains(lower, "unexpected end of json"):
kind = "stream"
default:
kind = "transient"
}
}
return kind, einoTrimRetryErrorSummary(msg)
}
func einoTrimRetryErrorSummary(msg string) string {
msg = strings.Join(strings.Fields(strings.TrimSpace(msg)), " ")
const maxRunes = 500
runes := []rune(msg)
if len(runes) <= maxRunes {
return msg
}
return string(runes[:maxRunes]) + "..."
}
func httpStatusFromErrorText(msg string) int { func httpStatusFromErrorText(msg string) int {
match := httpStatusInErrorPattern.FindStringSubmatch(msg) match := httpStatusInErrorPattern.FindStringSubmatch(msg)
if len(match) != 2 { if len(match) != 2 {
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strings"
"testing" "testing"
"time" "time"
@@ -62,6 +63,45 @@ func TestEinoTransientRetryBackoff(t *testing.T) {
} }
} }
func TestEinoTransientRunErrorUserDetail(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
wantKind string
}{
{"rate limit", errors.New("HTTP 429 Too Many Requests"), "rate_limit"},
{"upstream", errors.New("upstream returned 503"), "upstream_server"},
{"network", errors.New("read tcp: connection reset by peer"), "network"},
{"stream", errors.New("unexpected end of JSON"), "stream"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
kind, summary := einoTransientRunErrorUserDetail(tc.err)
if kind != tc.wantKind {
t.Fatalf("kind=%q, want %q", kind, tc.wantKind)
}
if summary == "" {
t.Fatal("summary should not be empty")
}
})
}
}
func TestEinoTrimRetryErrorSummary(t *testing.T) {
t.Parallel()
raw := strings.Repeat("报错 ", 260)
got := einoTrimRetryErrorSummary(raw)
if len([]rune(got)) > 503 {
t.Fatalf("summary too long: %d runes", len([]rune(got)))
}
if !strings.HasSuffix(got, "...") {
t.Fatal("trimmed summary should end with ellipsis")
}
}
func TestEinoMessagesForRunRestart(t *testing.T) { func TestEinoMessagesForRunRestart(t *testing.T) {
t.Parallel() t.Parallel()
base := []adk.Message{schema.UserMessage("hi")} base := []adk.Message{schema.UserMessage("hi")}
+38 -35
View File
@@ -34,12 +34,13 @@ func ApplyPlanExecutePlannerModelConfig(cfg *einoopenai.ChatModelConfig, oa *con
if cfg == nil || oa == nil { if cfg == nil || oa == nil {
return return
} }
offOA := *oa mergeExtraRequestFields(cfg, oa.Reasoning.ExtraRequestFields)
offReasoning := oa.Reasoning
offReasoning.Mode = "off"
offOA.Reasoning = offReasoning
ApplyToEinoChatModelConfig(cfg, &offOA, nil)
clearReasoningFromChatModelConfig(cfg) clearReasoningFromChatModelConfig(cfg)
if resolveWireProfile(oa, &oa.Reasoning) == wireDeepseek {
// DeepSeek enables thinking by default, so omission would not actually
// disable it for the planner's forced tool-choice requests.
applyThinkingDisabled(cfg)
}
} }
func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) { func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) {
@@ -51,8 +52,22 @@ func clearReasoningFromChatModelConfig(cfg *einoopenai.ChatModelConfig) {
for _, key := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} { for _, key := range []string{"thinking", "reasoning_effort", "output_config", "reasoning"} {
delete(cfg.ExtraFields, key) delete(cfg.ExtraFields, key)
} }
if len(cfg.ExtraFields) == 0 {
cfg.ExtraFields = nil
}
}
}
func mergeExtraRequestFields(cfg *einoopenai.ChatModelConfig, fields map[string]interface{}) {
if cfg == nil || len(fields) == 0 {
return
}
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any, len(fields))
}
for k, v := range fields {
cfg.ExtraFields[k] = v
} }
applyThinkingDisabled(cfg)
} }
// ApplyToEinoChatModelConfig merges reasoning-related options into cfg. // ApplyToEinoChatModelConfig merges reasoning-related options into cfg.
@@ -65,42 +80,32 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
allowClient := sr.AllowClientReasoningEffective() allowClient := sr.AllowClientReasoningEffective()
mode := effectiveMode(sr, client, allowClient) mode := effectiveMode(sr, client, allowClient)
// Admin-defined root fields are independent of the selected reasoning wire
// profile. Merge them first so mode=off can remove only reasoning controls
// while preserving unrelated gateway options.
mergeExtraRequestFields(cfg, sr.ExtraRequestFields)
if mode == "off" {
clearReasoningFromChatModelConfig(cfg)
// Strict OpenAI endpoints reject unknown `thinking` fields, whereas the
// DeepSeek API enables thinking by default and requires an explicit
// thinking.type=disabled switch. Keep that wire difference profile-scoped.
if resolveWireProfile(oa, sr) == wireDeepseek {
applyThinkingDisabled(cfg)
}
return
}
// Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking` // Claude (Anthropic): merge admin extras first; optional extended thinking maps to top-level `thinking`
// (see internal/openai convertOpenAIToClaude). DeepSeek/OpenAI-style fields are not sent. // (see internal/openai convertOpenAIToClaude). DeepSeek/OpenAI-style fields are not sent.
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") || if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") ||
strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") { strings.EqualFold(strings.TrimSpace(oa.Provider), "anthropic") {
if len(sr.ExtraRequestFields) > 0 {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
for k, v := range sr.ExtraRequestFields {
cfg.ExtraFields[k] = v
}
}
if mode == "off" {
return
}
applyClaudeExtendedThinking(cfg, mode, effectiveEffort(sr, client, allowClient), oa.Model) applyClaudeExtendedThinking(cfg, mode, effectiveEffort(sr, client, allowClient), oa.Model)
return return
} }
if mode == "off" {
applyThinkingDisabled(cfg)
return
}
effort := effectiveEffort(sr, client, allowClient) effort := effectiveEffort(sr, client, allowClient)
prof := resolveWireProfile(oa, sr) prof := resolveWireProfile(oa, sr)
// Admin-defined extra root fields (merged first; automatic keys may follow).
if len(sr.ExtraRequestFields) > 0 {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
for k, v := range sr.ExtraRequestFields {
cfg.ExtraFields[k] = v
}
}
switch prof { switch prof {
case wireClaude, wireNone: case wireClaude, wireNone:
return return
@@ -222,7 +227,8 @@ func usesExtraFieldsReasoningEffort(e string) bool {
} }
func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile { func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile {
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") { provider := strings.TrimSpace(oa.Provider)
if strings.EqualFold(provider, "claude") || strings.EqualFold(provider, "anthropic") {
return wireClaude return wireClaude
} }
p := strings.ToLower(strings.TrimSpace(sr.ProfileEffective())) p := strings.ToLower(strings.TrimSpace(sr.ProfileEffective()))
@@ -252,9 +258,6 @@ func applyThinkingDisabled(cfg *einoopenai.ChatModelConfig) {
if cfg.ExtraFields == nil { if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any) cfg.ExtraFields = make(map[string]any)
} }
if _, exists := cfg.ExtraFields["thinking"]; exists {
return
}
cfg.ExtraFields["thinking"] = map[string]any{"type": "disabled"} cfg.ExtraFields["thinking"] = map[string]any{"type": "disabled"}
} }
+128 -16
View File
@@ -1,13 +1,33 @@
package reasoning package reasoning
import ( import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing" "testing"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai" einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/schema"
) )
var reasoningPayloadKeysForTest = []string{"thinking", "reasoning_effort", "output_config", "reasoning"}
func assertNoReasoningFields(t *testing.T, cfg *einoopenai.ChatModelConfig) {
t.Helper()
if cfg.ReasoningEffort != "" {
t.Fatalf("expected ReasoningEffort omitted, got %q", cfg.ReasoningEffort)
}
for _, key := range reasoningPayloadKeysForTest {
if _, ok := cfg.ExtraFields[key]; ok {
t.Fatalf("expected %q omitted, got %#v", key, cfg.ExtraFields)
}
}
}
func TestEffortStringForAPI_passthrough(t *testing.T) { func TestEffortStringForAPI_passthrough(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"max": "max", "max": "max",
@@ -50,7 +70,11 @@ func TestApplyOpenAICompat_xhighExtraField(t *testing.T) {
} }
func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testing.T) { func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{} cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
"thinking": map[string]any{"type": "enabled"},
"reasoning_effort": "high",
"vendor_option": true,
}}
oa := &config.OpenAIConfig{ oa := &config.OpenAIConfig{
BaseURL: "https://antchat.example.com/v1", BaseURL: "https://antchat.example.com/v1",
Model: "minimax-m3", Model: "minimax-m3",
@@ -61,31 +85,119 @@ func TestApplyPlanExecutePlannerModelConfig_stripsReasoningWhenGlobalOn(t *testi
}, },
} }
ApplyPlanExecutePlannerModelConfig(cfg, oa) ApplyPlanExecutePlannerModelConfig(cfg, oa)
if cfg.ReasoningEffort != "" { assertNoReasoningFields(t, cfg)
t.Fatalf("expected ReasoningEffort cleared, got %q", cfg.ReasoningEffort) if cfg.ExtraFields["vendor_option"] != true {
} t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || th["type"] != "disabled" {
t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields)
}
if _, ok := cfg.ExtraFields["reasoning_effort"]; ok {
t.Fatalf("expected reasoning_effort stripped, got %#v", cfg.ExtraFields)
} }
} }
func TestApplyReasoningOff_disablesThinking(t *testing.T) { func TestApplyReasoningOff_omitsAllReasoningFields(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{} cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
"thinking": map[string]any{"type": "enabled"},
"output_config": map[string]any{"effort": "high"},
}}
oa := &config.OpenAIConfig{ oa := &config.OpenAIConfig{
BaseURL: "https://api.openai.com/v1", BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o", Model: "gpt-4o-mini",
Reasoning: config.OpenAIReasoningConfig{ Reasoning: config.OpenAIReasoningConfig{
Mode: "off", Mode: "off",
Effort: "high",
Profile: "openai_compat",
ExtraRequestFields: map[string]interface{}{
"thinking": map[string]any{"type": "disabled"},
"reasoning": map[string]any{"effort": "high"},
"vendor_option": true,
},
}, },
} }
ApplyToEinoChatModelConfig(cfg, oa, nil) ApplyToEinoChatModelConfig(cfg, oa, nil)
th, ok := cfg.ExtraFields["thinking"].(map[string]any) assertNoReasoningFields(t, cfg)
if !ok || th["type"] != "disabled" { if cfg.ExtraFields["vendor_option"] != true {
t.Fatalf("expected thinking disabled, got %#v", cfg.ExtraFields) t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
}
}
func TestApplyReasoningOff_clientOverrideOmit(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{
Mode: "on", Effort: "high", Profile: "openai_compat",
}}
ApplyToEinoChatModelConfig(cfg, oa, &ClientIntent{Mode: "off", Effort: "high"})
assertNoReasoningFields(t, cfg)
}
func TestApplyReasoningOff_deepseekExplicitlyDisablesDefaultThinking(t *testing.T) {
for _, profile := range []string{"deepseek_compat", "auto"} {
t.Run(profile, func(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{ExtraFields: map[string]any{
"reasoning_effort": "high",
"vendor_option": true,
}}
oa := &config.OpenAIConfig{
BaseURL: "https://api.deepseek.com",
Model: "deepseek-v4-pro",
Reasoning: config.OpenAIReasoningConfig{
Mode: "off", Effort: "high", Profile: profile,
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
if cfg.ReasoningEffort != "" {
t.Fatalf("expected ReasoningEffort omitted, got %q", cfg.ReasoningEffort)
}
if _, ok := cfg.ExtraFields["reasoning_effort"]; ok {
t.Fatalf("expected reasoning_effort omitted, got %#v", cfg.ExtraFields)
}
thinking, ok := cfg.ExtraFields["thinking"].(map[string]any)
if !ok || thinking["type"] != "disabled" {
t.Fatalf("expected DeepSeek thinking disabled, got %#v", cfg.ExtraFields)
}
if cfg.ExtraFields["vendor_option"] != true {
t.Fatalf("expected unrelated extra field preserved, got %#v", cfg.ExtraFields)
}
})
}
}
func TestApplyReasoningOff_wirePayloadOmitsThinking(t *testing.T) {
var requestBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read request body: %v", err)
}
if err := json.Unmarshal(body, &requestBody); err != nil {
t.Errorf("decode request body: %v; body=%s", err, body)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)
}))
defer srv.Close()
cfg := &einoopenai.ChatModelConfig{
APIKey: "test-key",
BaseURL: srv.URL,
Model: "gpt-4o-mini",
}
oa := &config.OpenAIConfig{
BaseURL: "https://api.openai.com/v1",
Model: "gpt-4o-mini",
Reasoning: config.OpenAIReasoningConfig{
Mode: "off", Effort: "high", Profile: "openai_compat",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
model, err := einoopenai.NewChatModel(context.Background(), cfg)
if err != nil {
t.Fatalf("new chat model: %v", err)
}
if _, err := model.Generate(context.Background(), []*schema.Message{schema.UserMessage("hello")}); err != nil {
t.Fatalf("generate: %v", err)
}
for _, key := range reasoningPayloadKeysForTest {
if _, ok := requestBody[key]; ok {
t.Fatalf("wire payload unexpectedly contains %q: %#v", key, requestBody)
}
} }
} }
+2
View File
@@ -151,6 +151,8 @@ func permissionForRequest(method, fullPath string) string {
return crudPermission(method, "knowledge") return crudPermission(method, "knowledge")
case strings.HasPrefix(path, "/vulnerabilities"): case strings.HasPrefix(path, "/vulnerabilities"):
return crudPermission(method, "vulnerability") return crudPermission(method, "vulnerability")
case path == "/assets/batch-delete", path == "/assets/merge":
return "asset:delete"
case strings.HasPrefix(path, "/assets"): case strings.HasPrefix(path, "/assets"):
return crudPermission(method, "asset") return crudPermission(method, "asset")
case strings.HasPrefix(path, "/vulnerability-alerts"): case strings.HasPrefix(path, "/vulnerability-alerts"):
+7 -3
View File
@@ -348,7 +348,7 @@ need_rebuild() {
} }
# Main flow # Main flow
# Default: HTTPS (--https passed to binary); --http uses plain HTTP. # Default: HTTPS (--https passed to binary); --http forces plain HTTP even if config.yaml enables TLS.
main() { main() {
USE_HTTPS=1 USE_HTTPS=1
FORWARD_ARGS=() FORWARD_ARGS=()
@@ -357,6 +357,10 @@ main() {
USE_HTTPS=0 USE_HTTPS=0
continue continue
fi fi
if [ "$arg" = "--https" ]; then
USE_HTTPS=1
continue
fi
FORWARD_ARGS+=("$arg") FORWARD_ARGS+=("$arg")
done done
@@ -406,9 +410,9 @@ main() {
fi fi
else else
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
exec "./$BINARY_NAME" -config "$CONFIG_FILE" "${FORWARD_ARGS[@]}" exec "./$BINARY_NAME" -config "$CONFIG_FILE" --http "${FORWARD_ARGS[@]}"
else else
exec "./$BINARY_NAME" -config "$CONFIG_FILE" exec "./$BINARY_NAME" -config "$CONFIG_FILE" --http
fi fi
fi fi
} }
+5 -19
View File
@@ -10,10 +10,9 @@ args:
import os import os
# ==================== FOFA配置 ==================== # ==================== FOFA配置 ====================
# 请在此处配置您的FOFA账号信息 # 请在此处配置您的 FOFA API Key
# 您也可以在环境变量中设置:FOFA_EMAIL 和 FOFA_API_KEY # 您也可以在环境变量中设置:FOFA_API_KEY
# enable 默认为 false,需开启才能调用该MCP # enable 默认为 false,需开启才能调用该MCP
FOFA_EMAIL = "" # 请填写您的FOFA账号邮箱
FOFA_API_KEY = "" # 请填写您的FOFA API密钥 FOFA_API_KEY = "" # 请填写您的FOFA API密钥
# ================================================== # ==================================================
@@ -37,7 +36,7 @@ args:
pass pass
# 传统位置参数方式(向后兼容) # 传统位置参数方式(向后兼容)
# 注意:email 和 api_key 已从参数中移除,现在从配置中读取 # 注意:api_key 不作为调用参数传入,而是从配置或环境变量读取
# 参数位置:query=2, size=3, page=4, fields=5, full=6 # 参数位置:query=2, size=3, page=4, fields=5, full=6
# 但在 sys.argv 中,由于 python3 -c "code" 的格式,实际位置需要调整 # 但在 sys.argv 中,由于 python3 -c "code" 的格式,实际位置需要调整
# sys.argv[0] 是 '-c'sys.argv[1] 开始是实际参数 # sys.argv[0] 是 '-c'sys.argv[1] 开始是实际参数
@@ -77,26 +76,15 @@ args:
print(json.dumps(error_result, ensure_ascii=False, indent=2)) print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1) sys.exit(1)
# 从配置或环境变量获取email和api_key # 从配置或环境变量获取 api_key
email = os.getenv('FOFA_EMAIL', FOFA_EMAIL).strip()
api_key = os.getenv('FOFA_API_KEY', FOFA_API_KEY).strip() api_key = os.getenv('FOFA_API_KEY', FOFA_API_KEY).strip()
query = config.get('query', '').strip() query = config.get('query', '').strip()
if not email:
error_result = {
"status": "error",
"message": "缺少FOFA配置: emailFOFA账号邮箱)",
"required_config": ["email", "api_key"],
"note": "请在YAML文件的FOFA_EMAIL配置项中填写您的FOFA账号邮箱,或在环境变量FOFA_EMAIL中设置"
}
print(json.dumps(error_result, ensure_ascii=False, indent=2))
sys.exit(1)
if not api_key: if not api_key:
error_result = { error_result = {
"status": "error", "status": "error",
"message": "缺少FOFA配置: api_keyFOFA API密钥)", "message": "缺少FOFA配置: api_keyFOFA API密钥)",
"required_config": ["email", "api_key"], "required_config": ["api_key"],
"note": "请在YAML文件的FOFA_API_KEY配置项中填写您的API密钥,或在环境变量FOFA_API_KEY中设置。API密钥可在FOFA个人中心获取: https://fofa.info/userInfo" "note": "请在YAML文件的FOFA_API_KEY配置项中填写您的API密钥,或在环境变量FOFA_API_KEY中设置。API密钥可在FOFA个人中心获取: https://fofa.info/userInfo"
} }
print(json.dumps(error_result, ensure_ascii=False, indent=2)) print(json.dumps(error_result, ensure_ascii=False, indent=2))
@@ -122,7 +110,6 @@ args:
# 构建请求参数 # 构建请求参数
params = { params = {
'email': email,
'key': api_key, 'key': api_key,
'qbase64': base64.b64encode(query.encode('utf-8')).decode('utf-8') 'qbase64': base64.b64encode(query.encode('utf-8')).decode('utf-8')
} }
@@ -277,7 +264,6 @@ description: |
- 查询结果数量受账户权限限制 - 查询结果数量受账户权限限制
- full参数需要高级权限 - full参数需要高级权限
parameters: parameters:
- name: "query" - name: "query"
type: "string" type: "string"
description: | description: |
+1089 -174
View File
File diff suppressed because it is too large Load Diff
+130 -10
View File
@@ -440,6 +440,8 @@
"open": "Open", "open": "Open",
"unbindProjectTitle": "Unbind project", "unbindProjectTitle": "Unbind project",
"unbind": "Unbind", "unbind": "Unbind",
"unbindAssetConfirm": "Unbind “{{target}}” from this project? The asset will not be deleted.",
"unbindAssetDone": "Asset unbound from project",
"confirmUnbindConversation": "Unbind this conversation from current project?", "confirmUnbindConversation": "Unbind this conversation from current project?",
"unbindFailed": "Unbind failed", "unbindFailed": "Unbind failed",
"factMetaCategory": "Category: {{value}}", "factMetaCategory": "Category: {{value}}",
@@ -585,6 +587,9 @@
"penetrationTestDetail": "Task execution details", "penetrationTestDetail": "Task execution details",
"expandDetail": "Expand details", "expandDetail": "Expand details",
"expandDetailLazyHint": "Expand details (loads iteration details on click)", "expandDetailLazyHint": "Expand details (loads iteration details on click)",
"loadingEarlierDetails": "Loading earlier entries…",
"loadingLaterDetails": "Loading newer entries…",
"backToLatestProgress": "↓ Back to latest",
"viewToolDetail": "View details", "viewToolDetail": "View details",
"collapseToolDetail": "Collapse", "collapseToolDetail": "Collapse",
"liveTimelinePruned": "Collapsed the first {{count}} live process details. View the full record page by page after the task completes.", "liveTimelinePruned": "Collapsed the first {{count}} live process details. View the full record page by page after the task completes.",
@@ -643,7 +648,17 @@
"einoRunRetryTitle": "🔁 Transient error retry", "einoRunRetryTitle": "🔁 Transient error retry",
"einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)", "einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)",
"einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…", "einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…",
"einoRunRetryPlan": "Retry progress: attempt {{attempt}}/{{maxAttempts}}, waiting {{backoffSec}}s",
"einoRunRetryReasonKind": "Reason type",
"einoRunRetryErrorDetail": "Error detail", "einoRunRetryErrorDetail": "Error detail",
"einoRunRetryKind_rate_limit": "Rate limited / too many requests",
"einoRunRetryKind_retryable_http": "Retryable HTTP error",
"einoRunRetryKind_upstream_server": "Upstream server error",
"einoRunRetryKind_http_error": "HTTP error",
"einoRunRetryKind_upstream_busy": "Upstream busy",
"einoRunRetryKind_network": "Network connection issue",
"einoRunRetryKind_stream": "Streaming read issue",
"einoRunRetryKind_transient": "Transient issue",
"iterationLimitReachedTitle": "⛔ Iteration limit reached", "iterationLimitReachedTitle": "⛔ Iteration limit reached",
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.", "iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
"einoPendingOrphanedTitle": "🧹 Tool call reconciliation", "einoPendingOrphanedTitle": "🧹 Tool call reconciliation",
@@ -988,6 +1003,7 @@
"batchScanFailed": "Batch scan failed", "batchScanFailed": "Batch scan failed",
"batchQueueCreated": "Batch scan queue created", "batchQueueCreated": "Batch scan queue created",
"field": "Field", "field": "Field",
"cellValueLength": "{{count}} characters",
"parsePending": "AI parsing...", "parsePending": "AI parsing...",
"parsePendingClickCancel": "AI parsing... (click button to cancel)", "parsePendingClickCancel": "AI parsing... (click button to cancel)",
"parseSlow": "AI parse is taking a while, still processing…", "parseSlow": "AI parse is taking a while, still processing…",
@@ -1008,6 +1024,7 @@
"none": "None", "none": "None",
"truncated": "truncated", "truncated": "truncated",
"resultsMeta": "Total {{total}} · This page {{count}} · page={{page}} · size={{size}}", "resultsMeta": "Total {{total}} · This page {{count}} · page={{page}} · size={{size}}",
"providerReturnedFewer": "Provider returned {{count}}/{{expected}} rows",
"parseModalCancel": "Cancel", "parseModalCancel": "Cancel",
"parseModalApply": "Fill into query", "parseModalApply": "Fill into query",
"parseModalApplyRun": "Fill and query" "parseModalApplyRun": "Fill and query"
@@ -1063,6 +1080,47 @@
"coverageOfTotal": "{{percent}}% of all assets", "coverageOfTotal": "{{percent}}% of all assets",
"coverageMeta": "{{scanned}} / {{total}} covered", "coverageMeta": "{{scanned}} / {{total}} covered",
"addAsset": "+ Add asset", "addAsset": "+ Add asset",
"bulkImport": "Bulk import",
"bulkImportTitle": "Bulk import assets",
"bulkImportSubtitle": "Fill in a template, upload it, then review validation results before importing",
"downloadTemplate": "Download template",
"downloadTemplateHint": "XLSX is recommended; CSV is convenient for exports from other systems",
"downloadXlsx": "Download XLSX template",
"downloadCsv": "Download CSV template",
"uploadFile": "Upload file",
"uploadFileHint": "Supports .xlsx and .csv, up to 100,000 rows and 100 MB",
"chooseOrDropFile": "Choose a file or drop it here",
"fileNotSelected": "No file selected",
"dataPreview": "Data preview",
"rowNumber": "Row",
"validationResult": "Validation",
"validationPassed": "Valid",
"importValidRows": "Import valid rows",
"importValidRowsCount": "Import {{count}} valid rows",
"importPreviewSummary": "{{total}} rows: {{valid}} valid, {{invalid}} need attention",
"previewLimited": "Showing the first 100 rows; all {{count}} rows will be processed",
"fileTypeInvalid": "Only .xlsx and .csv files are supported",
"fileTooLarge": "The file must not exceed 100 MB",
"spreadsheetUnavailable": "The spreadsheet component failed to load; refresh and try again",
"fileParseFailed": "Could not parse the file; make sure it is valid and not corrupted",
"fileHasNoData": "The file has no rows to import",
"headerInvalid": "No template columns were recognized; use the downloaded template",
"tooManyRows": "Data must not exceed {{count}} rows",
"importTargetRequired": "Target address is missing",
"importStatusInvalid": "Status must be active or inactive",
"importProjectNotFound": "Project does not exist or is not accessible: {{project}}",
"importTagsInvalid": "Use at most 30 tags, with at most 64 characters per tag",
"importFieldTooLong": "Field {{field}} is too long",
"duplicateFileRow": "Duplicates row {{row}}",
"bulkImportDone": "Import complete: {{created}} created, {{updated}} updated, {{skipped}} skipped",
"templateGuideTitle": "Asset bulk import instructions",
"templateGuideRequired": "Required: target, or at least one of host / ip / domain",
"templateGuideTarget": "target examples: https://example.com:443, example.com, 1.1.1.1:22",
"templateGuideProject": "project accepts an existing project name or ID; leave blank for no project",
"templateGuideTags": "Separate tags with commas; at most 30 tags",
"templateGuideStatus": "status accepts active / inactive and defaults to active",
"templateGuideLimit": "Enter data in the Assets sheet; up to 100,000 rows",
"instructionsSheet": "Instructions",
"addAssetTitle": "Add asset", "addAssetTitle": "Add asset",
"editAssetTitle": "Edit asset", "editAssetTitle": "Edit asset",
"editorSubtitle": "Enter one target; other details are parsed automatically or can be added later", "editorSubtitle": "Enter one target; other details are parsed automatically or can be added later",
@@ -1138,6 +1196,8 @@
"selectAsset": "Select asset", "selectAsset": "Select asset",
"selectedCount": "{{count}} selected", "selectedCount": "{{count}} selected",
"clearSelection": "Clear selection", "clearSelection": "Clear selection",
"selectAllResults": "Select all {{count}}",
"allResultsSelected": "All {{count}} matching assets selected",
"bindProject": "Bind project", "bindProject": "Bind project",
"bindProjectTitle": "Bind project", "bindProjectTitle": "Bind project",
"chooseProject": "Choose a project", "chooseProject": "Choose a project",
@@ -1173,7 +1233,62 @@
"riskLow": "Low", "riskLow": "Low",
"riskInfo": "Info", "riskInfo": "Info",
"riskNormal": "Clear", "riskNormal": "Clear",
"riskUnassessed": "Unassessed" "riskUnassessed": "Unassessed",
"advancedFilters": "Advanced filters",
"resetFilters": "Reset",
"allRisks": "All risk levels",
"minVulnerabilityCount": "Minimum findings",
"scanState": "Scan status",
"allScanStates": "All",
"neverScannedFilter": "Never scanned",
"overdue30": "Not scanned for 30 days",
"overdue60": "Not scanned for 60 days",
"overdue90": "Not scanned for 90 days",
"scannedFilter": "Scanned",
"responsiblePerson": "Owner",
"department": "Department",
"ownership": "Ownership",
"unassigned": "Unassigned",
"businessSystem": "Business system",
"environment": "Environment",
"criticality": "Criticality",
"allEnvironments": "All environments",
"environmentProduction": "Production",
"environmentStaging": "Staging",
"environmentTesting": "Testing",
"environmentDevelopment": "Development",
"environmentOther": "Other",
"allCriticalities": "All criticalities",
"criticalityCritical": "Mission critical",
"criticalityHigh": "High",
"criticalityMedium": "Medium",
"criticalityLow": "Low",
"firstSeenStart": "First seen from",
"firstSeenEnd": "First seen to",
"lastSeenStart": "Last seen from",
"lastSeenEnd": "Last seen to",
"sort": "Sort",
"sortLastSeenDesc": "Last seen (newest)",
"sortLastScanAsc": "Most overdue scan",
"sortLastScanDesc": "Most recently scanned",
"sortRiskDesc": "Risk (high to low)",
"sortVulnerabilityDesc": "Findings (most first)",
"sortFirstSeenDesc": "First seen (newest)",
"sortHostAsc": "Target name",
"sortPortAsc": "Port",
"savedViews": "Saved filter views",
"saveCurrentView": "Save current view",
"deleteView": "Delete view",
"bulkEdit": "Bulk edit",
"moreActions": "More",
"export": "Export",
"exportCsv": "Export CSV",
"exportXlsx": "Export XLSX",
"mergeDuplicates": "Merge duplicates",
"mergeRequiresMultiple": "Select at least two duplicate assets",
"batchDelete": "Bulk delete…",
"responsibilityBusiness": "Ownership and business context",
"responsibilityBusinessHint": "Use ownership and business priority to drive operations and risk ranking"
}, },
"vulnerability": { "vulnerability": {
"title": "Vulnerability Management", "title": "Vulnerability Management",
@@ -1431,10 +1546,10 @@
"security": "Security", "security": "Security",
"rbac": "Platform permissions", "rbac": "Platform permissions",
"audit": "Audit logs", "audit": "Audit logs",
"infocollect": "Recon" "infocollect": "Asset management"
}, },
"infocollect": { "infocollect": {
"title": "Reconnaissance" "title": "Asset management"
}, },
"hitl": { "hitl": {
"title": "Human-in-the-loop", "title": "Human-in-the-loop",
@@ -1772,7 +1887,8 @@
"chatUploads": "Chat Uploads", "chatUploads": "Chat Uploads",
"robotIntegration": "Robot Integration", "robotIntegration": "Robot Integration",
"markdownAgents": "Markdown Agents", "markdownAgents": "Markdown Agents",
"projectManagement": "Project Management" "projectManagement": "Project Management",
"assetManagement": "Asset Management"
}, },
"summary": { "summary": {
"login": "User login", "login": "User login",
@@ -1930,7 +2046,8 @@
"listProjectFactEdges": "List all project fact edges", "listProjectFactEdges": "List all project fact edges",
"createProjectFactEdge": "Add fact edge", "createProjectFactEdge": "Add fact edge",
"deleteProjectFactEdge": "Delete fact edge", "deleteProjectFactEdge": "Delete fact edge",
"promoteAttackChainToProject": "Promote conversation attack chain to project fact graph" "promoteAttackChainToProject": "Promote conversation attack chain to project fact graph",
"importAssets": "Import assets in bulk"
}, },
"response": { "response": {
"getSuccess": "Success", "getSuccess": "Success",
@@ -1995,7 +2112,11 @@
"factGraphNodesEdges": "nodes + edges", "factGraphNodesEdges": "nodes + edges",
"edgeList": "Edge list", "edgeList": "Edge list",
"edgeCreated": "Edge created", "edgeCreated": "Edge created",
"promoteAttackChainResult": "Promotion result (facts/edges/graph)" "promoteAttackChainResult": "Promotion result (facts/edges/graph)",
"assetImportCompleted": "Import completed",
"assetImportValidationFailed": "Asset count or field validation failed",
"assetImportForbidden": "Missing asset:write permission or access to the specified project",
"assetImportTransactionFailed": "Import transaction failed"
} }
}, },
"chatGroup": { "chatGroup": {
@@ -2445,6 +2566,7 @@
"providerClaude": "Claude (Anthropic Messages API)", "providerClaude": "Claude (Anthropic Messages API)",
"visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)", "visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)",
"fofaConfig": "FOFA config", "fofaConfig": "FOFA config",
"fofaConfigHint": "Used for asset discovery and import; only an API key is required.",
"agentConfig": "Agent config", "agentConfig": "Agent config",
"knowledgeConfig": "Knowledge base config", "knowledgeConfig": "Knowledge base config",
"baseUrl": "Base URL", "baseUrl": "Base URL",
@@ -2463,14 +2585,12 @@
"maxTotalTokens": "Max Context Tokens", "maxTotalTokens": "Max Context Tokens",
"maxTotalTokensPlaceholder": "120000", "maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000", "maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
"openaiReasoningTitle": "Model reasoning (Eino)", "openaiReasoningTitle": "Model reasoning",
"openaiReasoningHint": "Applies to Eino single-agent and multi-agent only; works with chat-page reasoning controls.", "openaiReasoningHint": "Works with the reasoning controls on the chat page.",
"openaiReasoningProfile": "Wire profile", "openaiReasoningProfile": "Wire profile",
"openaiReasoningAllowClient": "Allow chat page to override reasoning options", "openaiReasoningAllowClient": "Allow chat page to override reasoning options",
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)", "fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)",
"fofaBaseUrlHint": "Leave empty for default.", "fofaBaseUrlHint": "Leave empty for default.",
"email": "Email",
"fofaEmailPlaceholder": "Enter FOFA email",
"fofaApiKeyPlaceholder": "Enter FOFA API Key", "fofaApiKeyPlaceholder": "Enter FOFA API Key",
"fofaApiKeyHint": "Stored in server config (config.yaml) only.", "fofaApiKeyHint": "Stored in server config (config.yaml) only.",
"maxIterations": "Max iterations", "maxIterations": "Max iterations",
+131 -11
View File
@@ -428,6 +428,8 @@
"open": "打开", "open": "打开",
"unbindProjectTitle": "解除项目绑定", "unbindProjectTitle": "解除项目绑定",
"unbind": "解绑", "unbind": "解绑",
"unbindAssetConfirm": "确定将“{{target}}”从当前项目解绑吗?资产不会被删除。",
"unbindAssetDone": "已从项目解绑资产",
"confirmUnbindConversation": "解除该对话与当前项目的绑定?", "confirmUnbindConversation": "解除该对话与当前项目的绑定?",
"unbindFailed": "解绑失败", "unbindFailed": "解绑失败",
"factMetaCategory": "分类: {{value}}", "factMetaCategory": "分类: {{value}}",
@@ -573,6 +575,9 @@
"penetrationTestDetail": "任务执行详情", "penetrationTestDetail": "任务执行详情",
"expandDetail": "展开详情", "expandDetail": "展开详情",
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)", "expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
"loadingEarlierDetails": "正在加载更早记录…",
"loadingLaterDetails": "正在加载更新记录…",
"backToLatestProgress": "↓ 回到最新进度",
"viewToolDetail": "查看详情", "viewToolDetail": "查看详情",
"collapseToolDetail": "收起", "collapseToolDetail": "收起",
"liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录", "liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录",
@@ -631,7 +636,17 @@
"einoRunRetryTitle": "🔁 临时错误重试", "einoRunRetryTitle": "🔁 临时错误重试",
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)", "einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…", "einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
"einoRunRetryPlan": "重试进度:第 {{attempt}}/{{maxAttempts}} 次,等待 {{backoffSec}} 秒",
"einoRunRetryReasonKind": "原因类型",
"einoRunRetryErrorDetail": "具体报错", "einoRunRetryErrorDetail": "具体报错",
"einoRunRetryKind_rate_limit": "限流 / 请求过多",
"einoRunRetryKind_retryable_http": "可重试 HTTP 错误",
"einoRunRetryKind_upstream_server": "上游服务错误",
"einoRunRetryKind_http_error": "HTTP 错误",
"einoRunRetryKind_upstream_busy": "上游繁忙",
"einoRunRetryKind_network": "网络连接异常",
"einoRunRetryKind_stream": "流式读取异常",
"einoRunRetryKind_transient": "临时异常",
"iterationLimitReachedTitle": "⛔ 达到迭代上限", "iterationLimitReachedTitle": "⛔ 达到迭代上限",
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。", "iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
"einoPendingOrphanedTitle": "🧹 工具调用收尾补偿", "einoPendingOrphanedTitle": "🧹 工具调用收尾补偿",
@@ -976,6 +991,7 @@
"batchScanFailed": "批量扫描失败", "batchScanFailed": "批量扫描失败",
"batchQueueCreated": "已创建批量扫描队列", "batchQueueCreated": "已创建批量扫描队列",
"field": "字段", "field": "字段",
"cellValueLength": "共 {{count}} 个字符",
"parsePending": "AI 解析中...", "parsePending": "AI 解析中...",
"parsePendingClickCancel": "AI 解析中...(点击按钮可取消)", "parsePendingClickCancel": "AI 解析中...(点击按钮可取消)",
"parseSlow": "AI 解析耗时较长,仍在处理中…", "parseSlow": "AI 解析耗时较长,仍在处理中…",
@@ -996,6 +1012,7 @@
"none": "无", "none": "无",
"truncated": "已截断", "truncated": "已截断",
"resultsMeta": "共 {{total}} 条 · 本页 {{count}} 条 · page={{page}} · size={{size}}", "resultsMeta": "共 {{total}} 条 · 本页 {{count}} 条 · page={{page}} · size={{size}}",
"providerReturnedFewer": "上游实际返回 {{count}}/{{expected}} 条",
"parseModalCancel": "取消", "parseModalCancel": "取消",
"parseModalApply": "填入查询框", "parseModalApply": "填入查询框",
"parseModalApplyRun": "填入并查询" "parseModalApplyRun": "填入并查询"
@@ -1051,6 +1068,47 @@
"coverageOfTotal": "占全部资产 {{percent}}%", "coverageOfTotal": "占全部资产 {{percent}}%",
"coverageMeta": "{{scanned}} / {{total}} 已覆盖", "coverageMeta": "{{scanned}} / {{total}} 已覆盖",
"addAsset": "+ 新增资产", "addAsset": "+ 新增资产",
"bulkImport": "批量导入",
"bulkImportTitle": "批量导入资产",
"bulkImportSubtitle": "下载模板填写后上传,提交前会先校验并预览数据",
"downloadTemplate": "下载模板",
"downloadTemplateHint": "推荐 XLSXCSV 适合从其他系统快速导出",
"downloadXlsx": "下载 XLSX 模板",
"downloadCsv": "下载 CSV 模板",
"uploadFile": "上传文件",
"uploadFileHint": "支持 .xlsx 和 .csv,最多 100000 行、100 MB",
"chooseOrDropFile": "选择文件,或拖拽到此处",
"fileNotSelected": "尚未选择文件",
"dataPreview": "数据预览",
"rowNumber": "行号",
"validationResult": "校验结果",
"validationPassed": "通过",
"importValidRows": "导入有效数据",
"importValidRowsCount": "导入 {{count}} 条有效数据",
"importPreviewSummary": "共 {{total}} 行,{{valid}} 行有效,{{invalid}} 行需修正",
"previewLimited": "仅展示前 100 行;提交时将处理全部 {{count}} 行",
"fileTypeInvalid": "仅支持 .xlsx 和 .csv 文件",
"fileTooLarge": "文件不能超过 100 MB",
"spreadsheetUnavailable": "表格组件加载失败,请刷新后重试",
"fileParseFailed": "无法解析文件,请确认文件未损坏且格式正确",
"fileHasNoData": "文件中没有可导入的数据",
"headerInvalid": "未识别到模板字段,请使用下载的模板填写",
"tooManyRows": "数据不能超过 {{count}} 行",
"importTargetRequired": "缺少目标地址",
"importStatusInvalid": "状态仅支持 active 或 inactive",
"importProjectNotFound": "项目不存在或无权访问:{{project}}",
"importTagsInvalid": "标签最多 30 个,单个标签最多 64 个字符",
"importFieldTooLong": "字段 {{field}} 内容过长",
"duplicateFileRow": "与第 {{row}} 行重复",
"bulkImportDone": "导入完成:新增 {{created}} 条,更新 {{updated}} 条,跳过 {{skipped}} 条",
"templateGuideTitle": "资产批量导入填写说明",
"templateGuideRequired": "必填:target,或 host / ip / domain 中至少一项",
"templateGuideTarget": "target 示例:https://example.com:443、example.com、1.1.1.1:22",
"templateGuideProject": "project 填写系统中已有的项目名称或项目 ID,留空表示不绑定",
"templateGuideTags": "tags 使用逗号分隔,最多 30 个",
"templateGuideStatus": "status 仅支持 active / inactive,留空默认为 active",
"templateGuideLimit": "请在“Assets”工作表中填写,最多 100000 行",
"instructionsSheet": "填写说明",
"addAssetTitle": "新增资产", "addAssetTitle": "新增资产",
"editAssetTitle": "编辑资产", "editAssetTitle": "编辑资产",
"editorSubtitle": "输入一个目标即可,其余信息可自动解析或稍后补充", "editorSubtitle": "输入一个目标即可,其余信息可自动解析或稍后补充",
@@ -1125,7 +1183,9 @@
"selectPage": "选择本页", "selectPage": "选择本页",
"selectAsset": "选择资产", "selectAsset": "选择资产",
"selectedCount": "已选择 {{count}} 项", "selectedCount": "已选择 {{count}} 项",
"clearSelection": "取消选择", "clearSelection": "清除选择",
"selectAllResults": "选择全部 {{count}} 项",
"allResultsSelected": "已选择当前筛选结果中的 {{count}} 项",
"bindProject": "绑定项目", "bindProject": "绑定项目",
"bindProjectTitle": "绑定项目", "bindProjectTitle": "绑定项目",
"chooseProject": "请选择项目", "chooseProject": "请选择项目",
@@ -1161,7 +1221,62 @@
"riskLow": "低危", "riskLow": "低危",
"riskInfo": "提示", "riskInfo": "提示",
"riskNormal": "正常", "riskNormal": "正常",
"riskUnassessed": "未评估" "riskUnassessed": "未评估",
"advancedFilters": "高级筛选",
"resetFilters": "重置",
"allRisks": "全部风险",
"minVulnerabilityCount": "漏洞数量至少",
"scanState": "扫描状态",
"allScanStates": "全部",
"neverScannedFilter": "从未扫描",
"overdue30": "30 天未扫描",
"overdue60": "60 天未扫描",
"overdue90": "90 天未扫描",
"scannedFilter": "已扫描",
"responsiblePerson": "负责人",
"department": "部门",
"ownership": "归属",
"unassigned": "未分配",
"businessSystem": "业务系统",
"environment": "环境",
"criticality": "重要性",
"allEnvironments": "全部环境",
"environmentProduction": "生产",
"environmentStaging": "预发布",
"environmentTesting": "测试",
"environmentDevelopment": "开发",
"environmentOther": "其他",
"allCriticalities": "全部级别",
"criticalityCritical": "核心",
"criticalityHigh": "重要",
"criticalityMedium": "一般",
"criticalityLow": "低",
"firstSeenStart": "首次发现开始",
"firstSeenEnd": "首次发现结束",
"lastSeenStart": "最近发现开始",
"lastSeenEnd": "最近发现结束",
"sort": "排序",
"sortLastSeenDesc": "最近发现(新到旧)",
"sortLastScanAsc": "最久未扫描优先",
"sortLastScanDesc": "最近扫描优先",
"sortRiskDesc": "风险从高到低",
"sortVulnerabilityDesc": "漏洞数量从多到少",
"sortFirstSeenDesc": "首次发现(新到旧)",
"sortHostAsc": "目标名称",
"sortPortAsc": "端口",
"savedViews": "保存的筛选视图",
"saveCurrentView": "保存当前视图",
"deleteView": "删除视图",
"bulkEdit": "批量编辑",
"moreActions": "更多",
"export": "导出",
"exportCsv": "导出 CSV",
"exportXlsx": "导出 XLSX",
"mergeDuplicates": "合并重复资产",
"mergeRequiresMultiple": "请至少选择两个重复资产",
"batchDelete": "批量删除…",
"responsibilityBusiness": "责任与业务属性",
"responsibilityBusinessHint": "用于资产归属、运营分级与风险排序"
}, },
"vulnerability": { "vulnerability": {
"title": "漏洞管理", "title": "漏洞管理",
@@ -1419,10 +1534,10 @@
"security": "安全设置", "security": "安全设置",
"rbac": "平台权限", "rbac": "平台权限",
"audit": "日志审计", "audit": "日志审计",
"infocollect": "信息收集" "infocollect": "资产管理"
}, },
"infocollect": { "infocollect": {
"title": "信息收集" "title": "资产管理"
}, },
"hitl": { "hitl": {
"title": "人机协同", "title": "人机协同",
@@ -1760,7 +1875,8 @@
"chatUploads": "对话附件", "chatUploads": "对话附件",
"robotIntegration": "机器人集成", "robotIntegration": "机器人集成",
"markdownAgents": "多代理Markdown", "markdownAgents": "多代理Markdown",
"projectManagement": "项目管理" "projectManagement": "项目管理",
"assetManagement": "资产管理"
}, },
"summary": { "summary": {
"login": "用户登录", "login": "用户登录",
@@ -1918,7 +2034,8 @@
"listProjectFactEdges": "列出项目全部事实边", "listProjectFactEdges": "列出项目全部事实边",
"createProjectFactEdge": "添加事实边", "createProjectFactEdge": "添加事实边",
"deleteProjectFactEdge": "删除事实边", "deleteProjectFactEdge": "删除事实边",
"promoteAttackChainToProject": "将对话攻击链沉淀到项目事实图" "promoteAttackChainToProject": "将对话攻击链沉淀到项目事实图",
"importAssets": "批量导入资产"
}, },
"response": { "response": {
"getSuccess": "获取成功", "getSuccess": "获取成功",
@@ -1983,7 +2100,11 @@
"factGraphNodesEdges": "nodes + edges", "factGraphNodesEdges": "nodes + edges",
"edgeList": "边列表", "edgeList": "边列表",
"edgeCreated": "边已创建", "edgeCreated": "边已创建",
"promoteAttackChainResult": "沉淀结果(facts/edges/graph" "promoteAttackChainResult": "沉淀结果(facts/edges/graph",
"assetImportCompleted": "导入完成",
"assetImportValidationFailed": "数量或资产字段校验失败",
"assetImportForbidden": "缺少 asset:write 权限或无权访问指定项目",
"assetImportTransactionFailed": "导入事务失败"
} }
}, },
"chatGroup": { "chatGroup": {
@@ -2433,6 +2554,7 @@
"providerClaude": "Claude (Anthropic Messages API)", "providerClaude": "Claude (Anthropic Messages API)",
"visionProviderReuseOpenAI": "OpenAI 配置(留空复用)", "visionProviderReuseOpenAI": "OpenAI 配置(留空复用)",
"fofaConfig": "FOFA 配置", "fofaConfig": "FOFA 配置",
"fofaConfigHint": "用于资产发现与导入,仅需配置 API Key。",
"agentConfig": "Agent 配置", "agentConfig": "Agent 配置",
"knowledgeConfig": "知识库配置", "knowledgeConfig": "知识库配置",
"baseUrl": "Base URL", "baseUrl": "Base URL",
@@ -2451,14 +2573,12 @@
"maxTotalTokens": "最大上下文 Token 数", "maxTotalTokens": "最大上下文 Token 数",
"maxTotalTokensPlaceholder": "120000", "maxTotalTokensPlaceholder": "120000",
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000", "maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
"openaiReasoningTitle": "模型推理Eino", "openaiReasoningTitle": "模型推理",
"openaiReasoningHint": "仅 Eino 单代理与多代理请求生效;与对话页「模型推理」下拉配合使用。", "openaiReasoningHint": "与对话页「模型推理」下拉配合使用。",
"openaiReasoningProfile": "线路 profile", "openaiReasoningProfile": "线路 profile",
"openaiReasoningAllowClient": "允许对话页覆盖推理选项", "openaiReasoningAllowClient": "允许对话页覆盖推理选项",
"fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)", "fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)",
"fofaBaseUrlHint": "留空则使用默认地址。", "fofaBaseUrlHint": "留空则使用默认地址。",
"email": "Email",
"fofaEmailPlaceholder": "输入 FOFA 账号邮箱",
"fofaApiKeyPlaceholder": "输入 FOFA API Key", "fofaApiKeyPlaceholder": "输入 FOFA API Key",
"fofaApiKeyHint": "仅保存在服务器配置中(`config.yaml`)。", "fofaApiKeyHint": "仅保存在服务器配置中(`config.yaml`)。",
"maxIterations": "最大迭代次数", "maxIterations": "最大迭代次数",
+4 -1
View File
@@ -41,7 +41,10 @@ function buildApiSpecTagToKey() {
function translateApiDocTag(tag) { function translateApiDocTag(tag) {
if (!tag) return tag; if (!tag) return tag;
var key = apiSpecTagToKey[tag]; var key = apiSpecTagToKey[tag];
return key ? _t('apiDocs.tags.' + key) : tag; if (!key) return tag;
var i18nKey = 'apiDocs.tags.' + key;
var translated = _t(i18nKey);
return translated === i18nKey ? tag : translated;
} }
function translateApiDocSummaryFromOp(op) { function translateApiDocSummaryFromOp(op) {
var key = op && op['x-i18n-summary']; var key = op && op['x-i18n-summary'];
+790 -69
View File
File diff suppressed because it is too large Load Diff
+144 -22
View File
@@ -56,6 +56,7 @@ const mentionState = {
// IME输入法状态跟踪 // IME输入法状态跟踪
let isComposing = false; let isComposing = false;
let compositionEndTimer = null;
// 输入框草稿保存相关 // 输入框草稿保存相关
const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft'; const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft';
@@ -1576,8 +1577,9 @@ function handleChatInputClick(event) {
function handleChatInputKeydown(event) { function handleChatInputKeydown(event) {
// 如果正在使用输入法输入(IME),回车键应该用于确认候选词,而不是发送消息 // 如果正在使用输入法输入(IME),回车键应该用于确认候选词,而不是发送消息
// 使用 event.isComposing 或 isComposing 标志来判断 // Safari 可能在确认候选词时先触发 compositionend,再触发 Enter keydown
if (event.isComposing || isComposing) { // 因此这里同时使用全局状态和 keyCode 229 兜底。
if (event.isComposing || isComposing || event.keyCode === 229) {
return; return;
} }
@@ -2377,12 +2379,20 @@ async function syncAssistantReasoningContentFromServer(backendMessageId, domAssi
const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId)); const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId));
if (!msg || !msg.reasoningContent) return; if (!msg || !msg.reasoningContent) return;
setMessageReasoningContent(domAssistantId, msg.reasoningContent); setMessageReasoningContent(domAssistantId, msg.reasoningContent);
const pdRes = await apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`); // 最终回复到达后同样必须完整恢复过程详情;无参数接口默认仅返回前 50 条,
// 否则这里会把 task-events 恢复出的完整时间线再次覆盖成第一页。
if (typeof window.loadProcessDetailsPaginated === 'function') {
await window.loadProcessDetailsPaginated(domAssistantId, String(backendMessageId));
} else {
const pdRes = await apiFetch(
`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details?full=1`
);
const pdJson = await pdRes.json().catch(() => ({})); const pdJson = await pdRes.json().catch(() => ({}));
const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : []; const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : [];
if (typeof renderProcessDetails === 'function') { if (typeof renderProcessDetails === 'function') {
renderProcessDetails(domAssistantId, details); renderProcessDetails(domAssistantId, details);
} }
}
} catch (e) { } catch (e) {
console.warn('syncAssistantReasoningContentFromServer failed', e); console.warn('syncAssistantReasoningContentFromServer failed', e);
} }
@@ -2509,6 +2519,13 @@ function renderProcessDetails(messageId, processDetails, options) {
if (!messageElement) { if (!messageElement) {
return; return;
} }
const isLazyRequest = (processDetails === null);
const reasoningFromMessage = getMessageReasoningContent(messageElement);
const backendId = messageElement.dataset ? String(messageElement.dataset.backendMessageId || '').trim() : '';
if (isLazyRequest && !reasoningFromMessage && !backendId && getMcpExecutionCount(messageElement) <= 0) {
pruneEmptyMcpCallSection(messageElement);
return;
}
// 查找或创建 MCP 区域(工具栏 + 工具列表 + 迭代时间线 分区) // 查找或创建 MCP 区域(工具栏 + 工具列表 + 迭代时间线 分区)
const chrome = ensureMcpCallSectionChrome(messageElement, messageId); const chrome = ensureMcpCallSectionChrome(messageElement, messageId);
@@ -2561,8 +2578,7 @@ function renderProcessDetails(messageId, processDetails, options) {
} }
// processDetails === null 表示“尚未加载(懒加载)”;messages.reasoningContent 可先展示 // processDetails === null 表示“尚未加载(懒加载)”;messages.reasoningContent 可先展示
const isLazyNotLoaded = (processDetails === null); const isLazyNotLoaded = isLazyRequest;
const reasoningFromMessage = getMessageReasoningContent(messageElement);
if (isLazyNotLoaded && !reasoningFromMessage) { if (isLazyNotLoaded && !reasoningFromMessage) {
detailsContainer.dataset.lazyNotLoaded = '1'; detailsContainer.dataset.lazyNotLoaded = '1';
detailsContainer.dataset.loaded = '0'; detailsContainer.dataset.loaded = '0';
@@ -2628,6 +2644,83 @@ function renderProcessDetails(messageId, processDetails, options) {
return s ? ('[' + s + '] ') : ''; return s ? ('[' + s + '] ') : '';
} }
function formatProcessDetailEinoRunRetryKind(kind) {
if (typeof window.formatEinoRunRetryKind === 'function') {
return window.formatEinoRunRetryKind(kind);
}
const key = String(kind || '').trim();
if (!key) return '';
const labels = {
rate_limit: '限流 / 请求过多',
retryable_http: '可重试 HTTP 错误',
upstream_server: '上游服务错误',
http_error: 'HTTP 错误',
upstream_busy: '上游繁忙',
network: '网络连接异常',
stream: '流式读取异常',
transient: '临时异常'
};
if (typeof window.t === 'function') {
const translated = window.t('chat.einoRunRetryKind_' + key);
if (translated && translated !== 'chat.einoRunRetryKind_' + key) return translated;
}
return labels[key] || key;
}
function formatProcessDetailEinoRunRetryTitle(data) {
if (typeof window.formatEinoRunRetryTitle === 'function') {
return window.formatEinoRunRetryTitle(data);
}
const d = data && typeof data === 'object' ? data : {};
const base = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
return base + '' + attempt + '/' + maxAttempts + '';
}
return base;
}
function formatProcessDetailEinoRunRetryMessage(message, data) {
if (typeof window.formatEinoRunRetryMessage === 'function') {
return window.formatEinoRunRetryMessage(message, data);
}
const d = data && typeof data === 'object' ? data : {};
const base = String(message || '').trim();
const errRaw = d.errorSummary != null && String(d.errorSummary).trim() !== ''
? String(d.errorSummary).trim()
: (d.error != null ? String(d.error).trim() : '');
const lines = [];
if (base) lines.push(base);
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
const backoffSec = Number(d.backoffSec || 0);
const kind = formatProcessDetailEinoRunRetryKind(d.errorKind);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
const retryPlan = typeof window.t === 'function'
? window.t('chat.einoRunRetryPlan', { attempt: attempt, maxAttempts: maxAttempts, backoffSec: Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-' })
: ('重试进度:第 ' + attempt + '/' + maxAttempts + ' 次,等待 ' + (Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-') + ' 秒');
if (!base || base.indexOf(String(attempt) + '/' + String(maxAttempts)) === -1) {
lines.push(retryPlan);
}
}
if (kind) {
const kindLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryReasonKind')
: '原因类型';
lines.push(kindLabel + '' + kind);
}
if (errRaw && (!base || base.indexOf(errRaw) === -1)) {
const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail')
: '错误详情';
lines.push(detailLabel + '' + errRaw);
}
return lines.join('\n');
}
function renderOneProcessDetail(detail) { function renderOneProcessDetail(detail) {
const eventType = detail.eventType || ''; const eventType = detail.eventType || '';
const title = detail.message || ''; const title = detail.message || '';
@@ -2729,19 +2822,8 @@ function renderProcessDetails(messageId, processDetails, options) {
? window.t('chat.einoEmptyResponseContinueTitle') ? window.t('chat.einoEmptyResponseContinueTitle')
: '🔁 自动续跑(无助手正文)'; : '🔁 自动续跑(无助手正文)';
} else if (eventType === 'eino_run_retry') { } else if (eventType === 'eino_run_retry') {
itemTitle = typeof window.t === 'function' itemTitle = formatProcessDetailEinoRunRetryTitle(data);
? window.t('chat.einoRunRetryTitle') detail.message = formatProcessDetailEinoRunRetryMessage(title, data);
: '🔁 临时错误重试';
const errRaw = data && data.error != null ? String(data.error).trim() : '';
if (errRaw) {
const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail')
: '错误详情';
if (!title || String(title).indexOf(errRaw) === -1) {
const merged = title ? (String(title) + '\n' + detailLabel + '' + errRaw) : (detailLabel + '' + errRaw);
detail.message = merged;
}
}
} else if (eventType === 'knowledge_retrieval') { } else if (eventType === 'knowledge_retrieval') {
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索'); itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
} else if (eventType === 'error') { } else if (eventType === 'error') {
@@ -2909,10 +2991,20 @@ if (chatInput) {
chatInput.addEventListener('focus', handleChatInputClick); chatInput.addEventListener('focus', handleChatInputClick);
// IME输入法事件监听,用于跟踪输入法状态 // IME输入法事件监听,用于跟踪输入法状态
chatInput.addEventListener('compositionstart', () => { chatInput.addEventListener('compositionstart', () => {
if (compositionEndTimer) {
clearTimeout(compositionEndTimer);
compositionEndTimer = null;
}
isComposing = true; isComposing = true;
}); });
chatInput.addEventListener('compositionend', () => { chatInput.addEventListener('compositionend', () => {
if (compositionEndTimer) {
clearTimeout(compositionEndTimer);
}
compositionEndTimer = setTimeout(() => {
isComposing = false; isComposing = false;
compositionEndTimer = null;
}, 0);
}); });
chatInput.addEventListener('blur', () => { chatInput.addEventListener('blur', () => {
setTimeout(() => { setTimeout(() => {
@@ -2977,6 +3069,30 @@ function getMcpExecutionCount(messageElement) {
return 0; return 0;
} }
function getExistingMcpCallSectionChrome(messageElement) {
if (!messageElement) return null;
const mcpSection = messageElement.querySelector('.mcp-call-section');
if (!mcpSection) return null;
return {
mcpSection: mcpSection,
toolbar: mcpSection.querySelector('.mcp-call-toolbar'),
toolList: mcpSection.querySelector('.mcp-tool-list')
};
}
function pruneEmptyMcpCallSection(messageElement) {
const chrome = getExistingMcpCallSectionChrome(messageElement);
if (!chrome || !chrome.mcpSection) return;
const hasDetails = !!chrome.mcpSection.querySelector('.process-details-container');
const hasToolButtons = !!(chrome.toolList && chrome.toolList.querySelector('.mcp-detail-btn'));
const hasPendingTools = getPendingMcpExecutionCount(messageElement) > 0 ||
getPendingToolExecutionSummaryCount(messageElement) > 0 ||
getMcpExecutionCount(messageElement) > 0;
if (!hasDetails && !hasToolButtons && !hasPendingTools) {
chrome.mcpSection.remove();
}
}
function collectMcpExecutionIdsFromProcessDetails(processDetails) { function collectMcpExecutionIdsFromProcessDetails(processDetails) {
if (!Array.isArray(processDetails)) return []; if (!Array.isArray(processDetails)) return [];
const seen = new Set(); const seen = new Set();
@@ -3204,13 +3320,19 @@ function ensureMcpCallSectionChrome(messageElement, messageId) {
function syncMcpToolsToggleButton(messageElement) { function syncMcpToolsToggleButton(messageElement) {
if (!messageElement) return; if (!messageElement) return;
const chrome = ensureMcpCallSectionChrome(messageElement, messageElement.id); const count = getMcpExecutionCount(messageElement);
let chrome = getExistingMcpCallSectionChrome(messageElement);
if (!chrome || (count > 0 && (!chrome.toolbar || !chrome.toolList))) {
if (count <= 0) return;
chrome = ensureMcpCallSectionChrome(messageElement, messageElement.id);
}
if (!chrome) return; if (!chrome) return;
const { toolbar, toolList } = chrome; const { toolbar, toolList } = chrome;
const count = getMcpExecutionCount(messageElement); if (!toolbar || !toolList) return;
let toolsToggle = toolbar.querySelector('.mcp-tools-toggle-btn'); let toolsToggle = toolbar.querySelector('.mcp-tools-toggle-btn');
if (count <= 0) { if (count <= 0) {
if (toolsToggle) toolsToggle.remove(); if (toolsToggle) toolsToggle.remove();
pruneEmptyMcpCallSection(messageElement);
return; return;
} }
if (!toolsToggle) { if (!toolsToggle) {
@@ -4151,7 +4273,7 @@ async function prefetchLastAssistantProcessDetails() {
await window.loadProcessDetailsPaginated(last.id, backendId); await window.loadProcessDetailsPaginated(last.id, backendId);
return; return;
} }
const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details'); const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details?full=1');
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
if (!res.ok || !Array.isArray(j.processDetails) || j.processDetails.length === 0) return; if (!res.ok || !Array.isArray(j.processDetails) || j.processDetails.length === 0) return;
if (typeof renderProcessDetails === 'function') { if (typeof renderProcessDetails === 'function') {
@@ -9374,7 +9496,7 @@ function renderBatchConversations(filtered = null) {
const checkbox = document.createElement('input'); const checkbox = document.createElement('input');
checkbox.type = 'checkbox'; checkbox.type = 'checkbox';
checkbox.className = 'batch-conversation-checkbox'; checkbox.className = 'batch-conversation-checkbox theme-checkbox';
checkbox.dataset.conversationId = conv.id; checkbox.dataset.conversationId = conv.id;
checkbox.addEventListener('change', syncSelectAllBatchCheckbox); checkbox.addEventListener('change', syncSelectAllBatchCheckbox);
+624 -33
View File
@@ -6,11 +6,149 @@ function _t(key, opts) {
const FOFA_FORM_STORAGE_KEY = 'info-collect-fofa-form'; const FOFA_FORM_STORAGE_KEY = 'info-collect-fofa-form';
const FOFA_HIDDEN_FIELDS_STORAGE_KEY = 'info-collect-fofa-hidden-fields'; const FOFA_HIDDEN_FIELDS_STORAGE_KEY = 'info-collect-fofa-hidden-fields';
const INFO_COLLECT_PROVIDERS = {
fofa: {
label: 'FOFA',
placeholder: '例如:app="Apache" && country="CN"',
nlPlaceholder: '例如:找美国 Missouri 的 Apache 站点,标题包含 Home',
hint: '查询语法参考 FOFA 文档,支持 && / || / () 等。',
parseHint: '解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'FOFA 返回数量上限与账号权限相关,前端最多允许 10000。',
fullOption: {
label: '完整模式',
hint: '向 FOFA 传 full=true,返回更完整/更实时的数据,可能消耗更多额度。'
},
fields: 'host,ip,port,domain,title,protocol,country,province,city,server',
presets: [
['Apache + 中国', 'app="Apache" && country="CN"'],
['登录页 + 中国', 'title="登录" && country="CN"'],
['指定域名', 'domain="example.com"'],
['指定 IP', 'ip="1.1.1.1"']
],
fieldPresets: [
['最小字段', 'host,ip,port,domain'],
['Web 常用', 'host,title,ip,port,domain,protocol,server,icp,country,province,city'],
['情报增强', 'host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner']
],
syntaxGuide: {
summary: 'FOFA 使用 field="value" 精确匹配,支持 &&、||、! 和括号组合;字符串建议用双引号包裹。',
docsUrl: 'https://en.fofa.info/api',
sections: [
['常用字段', ['app="Apache"', 'title="后台管理"', 'body="Powered by"', 'domain="example.com"', 'host="https://example.com"', 'ip="1.1.1.1"', 'port="443"', 'country="CN"', 'city="Hangzhou"', 'server="nginx"']],
['组合写法', ['app="nginx" && country="CN"', 'title="login" || title="登录"', '(app="Apache" || app="nginx") && port="443"', 'domain="example.com" && !title="404"']],
['场景示例', ['cert="example.com" && port="443"', 'header="JSESSIONID" && country="CN"', 'icon_hash="-247388890"', 'fid="sZyXkR9e" && domain="example.com"']]
]
}
},
zoomeye: {
label: 'ZoomEye',
placeholder: '例如:app="Apache" && country="CN"',
nlPlaceholder: '例如:找中国的 SSH 服务,排除蜜罐',
hint: 'ZoomEye 支持 app/title/domain/ip/port/country/city 等语法。',
parseHint: '解析后会弹窗展示 ZoomEye 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'ZoomEye pagesize 最高支持到 10000,实际额度以账号为准。',
fullOption: null,
fields: 'ip,port,domain,hostname,title,service,app,country,city',
presets: [
['Apache + 中国', 'app="Apache" && country="CN"'],
['SSH 服务', 'service="ssh"'],
['指定域名', 'domain="example.com"'],
['指定 IP', 'ip="1.1.1.1"']
],
fieldPresets: [
['最小字段', 'ip,port,domain,hostname'],
['Web 常用', 'ip,port,domain,hostname,title,service,app,country,city'],
['情报增强', 'ip,port,domain,hostname,title,service,app,country,city,org,isp,ssl']
],
syntaxGuide: {
summary: 'ZoomEye 支持字段检索、引号短语、AND/OR/NOT 与括号组合;字段名以官方控制台实际支持为准。',
docsUrl: 'https://www.zoomeye.ai/help',
sections: [
['常用字段', ['app="Apache"', 'service="ssh"', 'title="登录"', 'domain="example.com"', 'hostname="example.com"', 'ip="1.1.1.1"', 'port=443', 'country="CN"', 'city="Beijing"', 'org="Tencent"']],
['组合写法', ['app="nginx" AND country="CN"', 'service="http" AND (title="login" OR title="登录")', 'domain="example.com" AND NOT app="cloudflare"', 'port=443 AND country="US"']],
['场景示例', ['ssl.cert.fingerprint="SHA256值"', 'iconhash="-247388890"', 'service="rdp" AND country="CN"', 'app="Elasticsearch" AND port=9200']]
]
}
},
quake: {
label: 'Quake',
placeholder: '例如:service.name:"http" AND country_cn:"中国"',
nlPlaceholder: '例如:找中国的 HTTP 服务,标题包含登录',
hint: 'Quake 使用 DSL 语法,常见字段如 service.name、domain、ip、port、country_cn。',
parseHint: '解析后会弹窗展示 Quake DSL(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'Quake size 会消耗积分,建议按需控制返回数量。',
fullOption: {
label: '最新数据',
hint: '向 Quake 传 latest=true,优先查询最新数据。'
},
fields: 'ip,port,domain,service.name,service.http.title,location.country_cn,location.province_cn,location.city_cn',
presets: [
['HTTP + 中国', 'service.name:"http" AND country_cn:"中国"'],
['443 端口', 'port:443'],
['指定域名', 'domain:"example.com"'],
['指定 IP', 'ip:"1.1.1.1"']
],
fieldPresets: [
['最小字段', 'ip,port,domain'],
['Web 常用', 'ip,port,domain,service.name,service.http.title,location.country_cn,location.city_cn'],
['情报增强', 'ip,port,domain,service.name,service.http.title,service.http.server,location.country_cn,location.province_cn,location.city_cn,asn']
],
syntaxGuide: {
summary: 'Quake 使用 Lucene/DSL 风格查询,常见形式是 field:"value",逻辑运算符通常使用 AND、OR、NOT。',
docsUrl: 'https://quake.360.net/quake/#/help',
sections: [
['常用字段', ['service.name:"http"', 'service.http.title:"登录"', 'service.http.server:"nginx"', 'domain:"example.com"', 'ip:"1.1.1.1"', 'port:443', 'country_cn:"中国"', 'province_cn:"浙江"', 'city_cn:"杭州"']],
['组合写法', ['service.name:"http" AND country_cn:"中国"', '(service.name:"http" OR service.name:"https") AND port:443', 'domain:"example.com" AND NOT service.http.title:"404"', 'service.http.title:"login" AND port:443']],
['场景示例', ['service.http.favicon.hash:"-247388890"', 'service.http.response.header:"JSESSIONID"', 'service.name:"ssh" AND country_cn:"中国"', 'service.http.title:"Dashboard" AND NOT ip:"127.0.0.1"']]
]
}
},
shodan: {
label: 'Shodan',
placeholder: '例如:product:nginx country:CN',
nlPlaceholder: '例如:找中国的 nginx 资产,端口 443',
hint: 'Shodan 使用 filter:value 语法,常见字段如 product、port、country、org。',
parseHint: '解析后会弹窗展示 Shodan filter 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 1000,
sizeHint: 'Shodan 官方每页 100 条;后端会自动翻页聚合,单次最多 1000 条以控制额度消耗。',
fullOption: null,
fields: 'ip_str,port,hostnames,domains,org,isp,location.country_name,location.city,product,transport',
presets: [
['Nginx + 中国', 'product:nginx country:CN'],
['SSH 服务', 'port:22'],
['证书域名', 'ssl.cert.subject.cn:example.com'],
['Amazon 443', 'org:"Amazon" port:443']
],
fieldPresets: [
['最小字段', 'ip_str,port,hostnames,domains'],
['Web 常用', 'ip_str,port,hostnames,domains,product,org,location.country_name,location.city'],
['情报增强', 'ip_str,port,hostnames,domains,org,isp,asn,location.country_name,location.city,product,transport,ssl.cert.subject.cn']
],
syntaxGuide: {
summary: 'Shodan 默认搜索 banner data;精确条件使用 filter:value,值含空格时用双引号,多个过滤器并列表示收窄结果。',
docsUrl: 'https://help.shodan.io/the-basics/search-query-fundamentals',
sections: [
['常用过滤器', ['product:nginx', 'port:443', 'country:CN', 'city:Shanghai', 'org:"Amazon"', 'asn:AS15169', 'hostname:example.com', 'ssl.cert.subject.cn:example.com', 'http.title:"Dashboard"']],
['组合写法', ['product:nginx country:CN', 'apache port:443 country:DE', 'org:"Amazon" port:443', 'ssl.cert.subject.cn:example.com port:443']],
['场景示例', ['http.title:"login" country:CN', 'ssl:true port:443 hostname:example.com', 'vuln:CVE-2021-41773', 'has_screenshot:true product:nginx']]
]
}
}
};
const infoCollectState = { const infoCollectState = {
currentPayload: null, // { fields, results, query, total, page, size } currentPayload: null, // { fields, results, query, total, page, size }
hiddenFields: new Set(), hiddenFields: new Set(),
selectedRowIndexes: new Set(), selectedRowIndexes: new Set(),
tableBound: false tableBound: false,
providerSelectBound: false,
presetEventsBound: false,
syntaxGuideExpanded: false,
queryHeightFrame: null,
queryHeightResizeBound: false
}; };
// AI 解析(自然语言 -> FOFA)交互状态 // AI 解析(自然语言 -> FOFA)交互状态
@@ -31,6 +169,7 @@ if (typeof escapeHtml === 'undefined') {
function getFofaFormElements() { function getFofaFormElements() {
return { return {
query: document.getElementById('fofa-query'), query: document.getElementById('fofa-query'),
provider: document.getElementById('fofa-provider'),
nl: document.getElementById('fofa-nl'), nl: document.getElementById('fofa-nl'),
size: document.getElementById('fofa-size'), size: document.getElementById('fofa-size'),
page: document.getElementById('fofa-page'), page: document.getElementById('fofa-page'),
@@ -45,6 +184,25 @@ function getFofaFormElements() {
}; };
} }
function getInfoCollectProvider() {
const provider = (document.getElementById('fofa-provider')?.value || 'fofa').trim().toLowerCase();
return INFO_COLLECT_PROVIDERS[provider] ? provider : 'fofa';
}
function providerLabel(provider) {
return (INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa).label;
}
function getInfoCollectFullOption(provider) {
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
return cfg.fullOption || null;
}
function isInfoCollectFullEnabled(provider) {
const els = getFofaFormElements();
return !!(getInfoCollectFullOption(provider) && els.full && els.full.checked);
}
function loadHiddenFieldsFromStorage() { function loadHiddenFieldsFromStorage() {
try { try {
const raw = localStorage.getItem(FOFA_HIDDEN_FIELDS_STORAGE_KEY); const raw = localStorage.getItem(FOFA_HIDDEN_FIELDS_STORAGE_KEY);
@@ -94,13 +252,24 @@ function initInfoCollectPage() {
// 恢复上次输入 // 恢复上次输入
const saved = loadFofaFormFromStorage(); const saved = loadFofaFormFromStorage();
let shouldResetProviderFields = false;
if (saved) { if (saved) {
if (typeof saved.provider === 'string' && els.provider && INFO_COLLECT_PROVIDERS[saved.provider]) els.provider.value = saved.provider;
if (typeof saved.query === 'string') els.query.value = saved.query; if (typeof saved.query === 'string') els.query.value = saved.query;
if (typeof saved.size === 'number' || typeof saved.size === 'string') els.size.value = saved.size; if (typeof saved.size === 'number' || typeof saved.size === 'string') els.size.value = saved.size;
if (typeof saved.page === 'number' || typeof saved.page === 'string') els.page.value = saved.page; if (typeof saved.page === 'number' || typeof saved.page === 'string') els.page.value = saved.page;
if (typeof saved.fields === 'string') els.fields.value = saved.fields; if (typeof saved.fields === 'string') els.fields.value = saved.fields;
if (typeof saved.full === 'boolean') els.full.checked = saved.full; if (typeof saved.full === 'boolean') els.full.checked = saved.full;
const provider = getInfoCollectProvider();
const savedFields = String(saved.fields || '').trim();
shouldResetProviderFields = provider !== 'fofa' && (
savedFields === INFO_COLLECT_PROVIDERS.fofa.fields ||
savedFields === 'host,ip,port,domain'
);
} }
initInfoCollectProviderSelect();
bindInfoCollectPresetEvents();
refreshInfoCollectProviderUI(shouldResetProviderFields);
// 绑定 Enter 快捷查询(在 query 里用 Ctrl/Cmd+Enter // 绑定 Enter 快捷查询(在 query 里用 Ctrl/Cmd+Enter
els.query.addEventListener('keydown', (e) => { els.query.addEventListener('keydown', (e) => {
@@ -139,23 +308,391 @@ function initInfoCollectPage() {
autoGrowTextarea(els.query); autoGrowTextarea(els.query);
autoGrowTextarea(els.nl); autoGrowTextarea(els.nl);
}, 0); }, 0);
setInfoCollectQueryMode('syntax', { focus: false });
if (!infoCollectState.queryHeightResizeBound) {
infoCollectState.queryHeightResizeBound = true;
window.addEventListener('resize', scheduleInfoCollectQueryCardHeightStabilize);
}
// 绑定表格事件(事件委托,只绑定一次) // 绑定表格事件(事件委托,只绑定一次)
bindFofaTableEvents(); bindFofaTableEvents();
updateSelectedMeta(); updateSelectedMeta();
} }
function handleInfoCollectProviderChange() {
infoCollectState.syntaxGuideExpanded = false;
refreshInfoCollectProviderUI(true);
}
function setInfoCollectQueryMode(mode, options) {
const shouldFocus = options?.focus !== false;
const syntaxPanel = document.getElementById('info-collect-syntax-panel');
const naturalPanel = document.getElementById('info-collect-natural-panel');
if (syntaxPanel) {
syntaxPanel.hidden = false;
syntaxPanel.classList.add('is-active');
syntaxPanel.classList.add('is-generated-target');
}
if (naturalPanel) {
naturalPanel.hidden = false;
naturalPanel.classList.add('is-active');
}
const queryLabel = document.getElementById('info-collect-query-label');
const cfg = INFO_COLLECT_PROVIDERS[getInfoCollectProvider()] || INFO_COLLECT_PROVIDERS.fofa;
if (queryLabel) {
queryLabel.textContent = cfg.label + ' 查询语法(可编辑,可直接查询)';
}
const nlLabel = document.getElementById('info-collect-nl-label');
if (nlLabel) {
nlLabel.textContent = '自然语言(可选,AI 解析为 ' + cfg.label + ' 语法)';
}
if (shouldFocus) {
const focusTarget = mode === 'natural' ? document.getElementById('fofa-nl') : document.getElementById('fofa-query');
try { focusTarget?.focus(); } catch (e) { /* ignore */ }
}
scheduleInfoCollectQueryCardHeightStabilize();
}
function scheduleInfoCollectQueryCardHeightStabilize() {
if (infoCollectState.queryHeightFrame) {
cancelAnimationFrame(infoCollectState.queryHeightFrame);
}
infoCollectState.queryHeightFrame = requestAnimationFrame(() => {
infoCollectState.queryHeightFrame = null;
stabilizeInfoCollectQueryCardHeight();
});
}
function stabilizeInfoCollectQueryCardHeight() {
const card = document.querySelector('.info-collect-query-card');
if (!card) return;
const rect = card.getBoundingClientRect();
if (!rect.width) return;
const clone = card.cloneNode(true);
clone.style.position = 'absolute';
clone.style.visibility = 'hidden';
clone.style.pointerEvents = 'none';
clone.style.left = '-10000px';
clone.style.top = '0';
clone.style.width = rect.width + 'px';
clone.style.height = 'auto';
clone.style.minHeight = '0';
clone.style.maxHeight = 'none';
const naturalPanel = clone.querySelector('#info-collect-natural-panel');
if (naturalPanel) {
naturalPanel.hidden = false;
naturalPanel.classList.add('is-active');
}
const syntaxPanel = clone.querySelector('#info-collect-syntax-panel');
if (syntaxPanel) {
syntaxPanel.hidden = false;
syntaxPanel.classList.add('is-active', 'is-generated-target');
}
const queryLabel = clone.querySelector('#info-collect-query-label');
if (queryLabel) {
const cfg = INFO_COLLECT_PROVIDERS[getInfoCollectProvider()] || INFO_COLLECT_PROVIDERS.fofa;
queryLabel.textContent = cfg.label + ' 查询语法(可编辑,可直接查询)';
}
document.body.appendChild(clone);
const stableHeight = Math.ceil(clone.getBoundingClientRect().height);
clone.remove();
if (stableHeight > 0) {
card.style.minHeight = stableHeight + 'px';
}
}
function presetDataAttr(value) {
return escapeHtml(String(value == null ? '' : value))
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function bindInfoCollectPresetEvents() {
if (infoCollectState.presetEventsBound) return;
infoCollectState.presetEventsBound = true;
document.addEventListener('click', (event) => {
const queryBtn = event.target.closest?.('[data-info-query-preset]');
if (queryBtn) {
event.preventDefault();
applyFofaQueryPreset(queryBtn.getAttribute('data-info-query-preset') || '');
return;
}
const fieldsBtn = event.target.closest?.('[data-info-fields-preset]');
if (fieldsBtn) {
event.preventDefault();
applyFofaFieldsPreset(fieldsBtn.getAttribute('data-info-fields-preset') || '');
return;
}
const guideToggle = event.target.closest?.('[data-info-syntax-guide-toggle]');
if (guideToggle) {
event.preventDefault();
toggleInfoCollectSyntaxGuide();
}
});
}
function initInfoCollectProviderSelect() {
const els = getFofaFormElements();
const select = els.provider;
if (!select || infoCollectState.providerSelectBound) {
syncInfoCollectProviderSelect();
return;
}
infoCollectState.providerSelectBound = true;
select.classList.add('settings-native-select');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'settings-custom-select info-collect-provider-select';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'settings-custom-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const value = document.createElement('span');
value.className = 'settings-custom-select-value';
value.id = 'info-collect-provider-select-value';
const caret = document.createElement('span');
caret.className = 'settings-custom-select-caret';
caret.setAttribute('aria-hidden', 'true');
caret.textContent = '▾';
const menu = document.createElement('div');
menu.className = 'settings-custom-select-menu';
menu.id = 'info-collect-provider-select-menu';
menu.setAttribute('role', 'listbox');
trigger.appendChild(value);
trigger.appendChild(caret);
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(menu);
wrapper.appendChild(select);
trigger.addEventListener('click', (event) => {
event.stopPropagation();
const willOpen = !wrapper.classList.contains('open');
closeInfoCollectProviderSelect();
wrapper.classList.toggle('open', willOpen);
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
});
trigger.addEventListener('keydown', (event) => {
const options = Array.prototype.filter.call(select.options, (option) => !option.disabled);
if (!options.length) return;
const current = Math.max(0, options.indexOf(select.options[select.selectedIndex]));
let next = current;
if (event.key === 'ArrowDown') next = Math.min(options.length - 1, current + 1);
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1);
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = options.length - 1;
else if (event.key === 'Escape') {
closeInfoCollectProviderSelect();
return;
} else if (event.key === 'Enter' || event.key === ' ') {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
event.preventDefault();
return;
} else {
return;
}
event.preventDefault();
const nextOption = options[next];
if (nextOption && select.value !== nextOption.value) {
select.value = nextOption.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncInfoCollectProviderSelect();
});
menu.addEventListener('click', (event) => {
const item = event.target.closest('.settings-custom-select-option');
if (!item || item.disabled) return;
event.stopPropagation();
const option = select.options[Number(item.dataset.index)];
if (option && !option.disabled && select.value !== option.value) {
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncInfoCollectProviderSelect();
closeInfoCollectProviderSelect();
});
select.addEventListener('change', syncInfoCollectProviderSelect);
document.addEventListener('click', closeInfoCollectProviderSelect);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeInfoCollectProviderSelect();
});
syncInfoCollectProviderSelect();
}
function closeInfoCollectProviderSelect() {
const wrapper = document.querySelector('.info-collect-provider-select');
const trigger = wrapper?.querySelector('.settings-custom-select-trigger');
if (!wrapper) return;
wrapper.classList.remove('open');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
}
function syncInfoCollectProviderSelect() {
const select = document.getElementById('fofa-provider');
const wrapper = document.querySelector('.info-collect-provider-select');
if (!select || !wrapper) return;
const value = wrapper.querySelector('.settings-custom-select-value');
const menu = wrapper.querySelector('.settings-custom-select-menu');
const selected = select.options[select.selectedIndex];
if (value) value.textContent = selected ? selected.textContent : '';
if (!menu) return;
menu.innerHTML = '';
Array.prototype.forEach.call(select.options, (option, index) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'settings-custom-select-option';
item.setAttribute('role', 'option');
item.setAttribute('data-index', String(index));
item.setAttribute('aria-selected', option.selected ? 'true' : 'false');
item.classList.toggle('is-selected', option.selected);
item.disabled = !!option.disabled;
const check = document.createElement('span');
check.className = 'settings-custom-select-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'settings-custom-select-label';
label.textContent = option.textContent;
item.appendChild(check);
item.appendChild(label);
menu.appendChild(item);
});
}
function renderInfoCollectSyntaxGuide(cfg) {
const container = document.getElementById('info-collect-syntax-guide');
if (!container) return;
const guide = cfg.syntaxGuide;
if (!guide) {
container.innerHTML = '';
container.hidden = true;
return;
}
const docsLink = guide.docsUrl
? `<a class="info-collect-doc-link" href="${presetDataAttr(guide.docsUrl)}" target="_blank" rel="noopener noreferrer">官方文档</a>`
: '';
const expanded = !!infoCollectState.syntaxGuideExpanded;
const sections = (guide.sections || []).map(([title, examples]) => {
const chips = (examples || []).map(example => {
return `<button class="syntax-example-chip" type="button" data-info-query-preset="${presetDataAttr(example)}" title="填入查询框">${escapeHtml(example)}</button>`;
}).join('');
return `<div class="syntax-guide-section"><div class="syntax-guide-title">${escapeHtml(title)}</div><div class="syntax-guide-examples">${chips}</div></div>`;
}).join('');
container.hidden = false;
container.classList.toggle('is-expanded', expanded);
container.innerHTML = `
<div class="syntax-guide-header">
<div class="syntax-guide-summary">${escapeHtml(guide.summary || '')}</div>
<div class="syntax-guide-actions">
${docsLink}
<button class="syntax-guide-toggle" type="button" data-info-syntax-guide-toggle aria-expanded="${expanded ? 'true' : 'false'}">${expanded ? '收起示例' : '展开示例'}</button>
</div>
</div>
<div class="syntax-guide-body"${expanded ? '' : ' hidden'}>${sections}</div>
`;
}
function toggleInfoCollectSyntaxGuide() {
infoCollectState.syntaxGuideExpanded = !infoCollectState.syntaxGuideExpanded;
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
renderInfoCollectSyntaxGuide(cfg);
scheduleInfoCollectQueryCardHeightStabilize();
}
function refreshInfoCollectProviderUI(resetProviderFields) {
const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const queryLabel = document.getElementById('info-collect-query-label');
const nlLabel = document.getElementById('info-collect-nl-label');
const queryHint = document.getElementById('info-collect-query-hint');
const parseHint = document.getElementById('info-collect-parse-hint');
const sizeHint = document.getElementById('info-collect-size-hint');
const parseBtn = document.getElementById('fofa-nl-parse-btn');
const presets = document.getElementById('info-collect-query-presets');
const fieldPresets = document.getElementById('info-collect-fields-presets');
const fullOption = document.getElementById('info-collect-full-option');
const fullText = fullOption ? fullOption.querySelector('.checkbox-text') : null;
const fullConfig = getInfoCollectFullOption(provider);
if (queryLabel) queryLabel.textContent = cfg.label + ' 查询语法';
if (nlLabel) nlLabel.textContent = '自然语言(AI 解析为 ' + cfg.label + ' 语法)';
if (queryHint) queryHint.textContent = cfg.hint;
if (parseHint) parseHint.textContent = cfg.parseHint;
if (sizeHint) sizeHint.textContent = cfg.sizeHint;
if (parseBtn && parseBtn.dataset.loading !== '1') parseBtn.title = '将自然语言解析为 ' + cfg.label + ' 查询语法';
if (els.query) els.query.placeholder = cfg.placeholder;
if (els.nl) els.nl.placeholder = cfg.nlPlaceholder;
if (els.size) {
els.size.max = String(cfg.maxSize || 10000);
const currentSize = parseInt(els.size.value, 10) || 100;
if (cfg.maxSize && currentSize > cfg.maxSize) els.size.value = cfg.maxSize;
}
if (fullOption) {
if (fullConfig) {
fullOption.hidden = false;
fullOption.title = fullConfig.hint || '';
if (fullText) fullText.textContent = fullConfig.label || _t('infoCollectPage.fullLabel');
} else {
fullOption.hidden = true;
fullOption.title = '';
if (els.full) els.full.checked = false;
}
}
if (els.fields && (resetProviderFields || !els.fields.value.trim())) els.fields.value = cfg.fields;
if (presets) {
presets.innerHTML = cfg.presets.map(([label, query]) => {
return `<button class="preset-chip" type="button" data-info-query-preset="${presetDataAttr(query)}" title="填入示例">${escapeHtml(label)}</button>`;
}).join('');
}
if (fieldPresets) {
fieldPresets.innerHTML = cfg.fieldPresets.map(([label, fields]) => {
return `<button class="preset-chip" type="button" data-info-fields-preset="${presetDataAttr(fields)}" title="填入字段模板">${escapeHtml(label)}</button>`;
}).join('');
}
renderInfoCollectSyntaxGuide(cfg);
saveFofaFormToStorage({
provider,
query: (els.query?.value || '').trim(),
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '',
full: isInfoCollectFullEnabled(provider)
});
setInfoCollectQueryMode('syntax', { focus: false });
scheduleInfoCollectQueryCardHeightStabilize();
}
function applyFofaQueryPreset(preset) { function applyFofaQueryPreset(preset) {
const els = getFofaFormElements(); const els = getFofaFormElements();
if (!els.query) return; if (!els.query) return;
setInfoCollectQueryMode('syntax');
els.query.value = (preset || '').trim(); els.query.value = (preset || '').trim();
els.query.focus(); els.query.focus();
saveFofaFormToStorage({ saveFofaFormToStorage({
provider: getInfoCollectProvider(),
query: els.query.value, query: els.query.value,
size: parseInt(els.size?.value, 10) || 100, size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1, page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '', fields: els.fields?.value || '',
full: !!els.full?.checked full: isInfoCollectFullEnabled(getInfoCollectProvider())
}); });
} }
@@ -165,54 +702,68 @@ function applyFofaFieldsPreset(preset) {
els.fields.value = (preset || '').trim(); els.fields.value = (preset || '').trim();
els.fields.focus(); els.fields.focus();
saveFofaFormToStorage({ saveFofaFormToStorage({
provider: getInfoCollectProvider(),
query: (els.query?.value || '').trim(), query: (els.query?.value || '').trim(),
size: parseInt(els.size?.value, 10) || 100, size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1, page: parseInt(els.page?.value, 10) || 1,
fields: els.fields.value, fields: els.fields.value,
full: !!els.full?.checked full: isInfoCollectFullEnabled(getInfoCollectProvider())
}); });
} }
function resetFofaForm() { function resetFofaForm() {
const els = getFofaFormElements(); const els = getFofaFormElements();
if (!els.query) return; if (!els.query) return;
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
els.query.value = ''; els.query.value = '';
if (els.size) els.size.value = 100; if (els.size) els.size.value = 100;
if (els.page) els.page.value = 1; if (els.page) els.page.value = 1;
if (els.fields) els.fields.value = 'host,ip,port,domain,title,protocol,country,province,city,server'; if (els.fields) els.fields.value = cfg.fields;
if (els.full) els.full.checked = false; if (els.full) els.full.checked = false;
if (els.nl) els.nl.value = '';
setInfoCollectQueryMode('syntax');
saveFofaFormToStorage({ saveFofaFormToStorage({
provider,
query: els.query.value, query: els.query.value,
size: parseInt(els.size?.value, 10) || 100, size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1, page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '', fields: els.fields?.value || '',
full: !!els.full?.checked full: isInfoCollectFullEnabled(provider)
}); });
renderFofaResults({ query: '', fields: [], results: [], total: 0, page: 1, size: 0 }); renderFofaResults({ query: '', fields: [], results: [], total: 0, page: 1, size: 0 });
} }
async function submitFofaSearch() { async function submitFofaSearch() {
const els = getFofaFormElements(); const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const query = (els.query?.value || '').trim(); const query = (els.query?.value || '').trim();
const size = parseInt(els.size?.value, 10) || 100; const providerCfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const maxSize = providerCfg.maxSize || 10000;
let size = parseInt(els.size?.value, 10) || 100;
if (size > maxSize) {
size = maxSize;
if (els.size) els.size.value = String(maxSize);
showInlineToast(providerCfg.label + ' 单次最多返回 ' + maxSize + ' 条,已自动调整。');
}
const page = parseInt(els.page?.value, 10) || 1; const page = parseInt(els.page?.value, 10) || 1;
const fields = (els.fields?.value || '').trim(); const fields = (els.fields?.value || '').trim();
const full = !!els.full?.checked; const full = isInfoCollectFullEnabled(provider);
if (!query) { if (!query) {
alert(_t('infoCollect.enterFofaQuery')); alert(_t('infoCollect.enterFofaQuery'));
return; return;
} }
saveFofaFormToStorage({ query, size, page, fields, full }); saveFofaFormToStorage({ provider, query, size, page, fields, full });
setFofaMeta(_t('infoCollect.querying')); setFofaMeta(providerLabel(provider) + ' ' + _t('infoCollect.querying'));
setFofaLoading(true); setFofaLoading(true);
try { try {
const response = await apiFetch('/api/fofa/search', { const response = await apiFetch('/api/fofa/search', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, size, page, fields, full }) body: JSON.stringify({ provider, query, size, page, fields, full })
}); });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
@@ -221,9 +772,9 @@ async function submitFofaSearch() {
} }
renderFofaResults(result); renderFofaResults(result);
} catch (e) { } catch (e) {
console.error('FOFA 查询失败:', e); console.error(providerLabel(provider) + ' 查询失败:', e);
setFofaMeta(_t('infoCollect.queryFailed')); setFofaMeta(_t('infoCollect.queryFailed'));
renderFofaResults({ query, fields: [], results: [], total: 0, page: 1, size: 0 }); renderFofaResults({ provider, query, fields: [], results: [], total: 0, page: 1, size: 0 });
alert(_t('infoCollect.queryFailed') + ': ' + (e && e.message ? e.message : String(e))); alert(_t('infoCollect.queryFailed') + ': ' + (e && e.message ? e.message : String(e)));
} finally { } finally {
setFofaLoading(false); setFofaLoading(false);
@@ -232,6 +783,7 @@ async function submitFofaSearch() {
async function parseFofaNaturalLanguage() { async function parseFofaNaturalLanguage() {
const els = getFofaFormElements(); const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const text = (els.nl?.value || '').trim(); const text = (els.nl?.value || '').trim();
if (!text) { if (!text) {
alert(_t('infoCollect.enterNaturalLanguage')); alert(_t('infoCollect.enterNaturalLanguage'));
@@ -264,7 +816,7 @@ async function parseFofaNaturalLanguage() {
const resp = await apiFetch('/api/fofa/parse', { const resp = await apiFetch('/api/fofa/parse', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }), body: JSON.stringify({ provider, text }),
signal: fofaParseAbortController.signal signal: fofaParseAbortController.signal
}); });
const result = await resp.json().catch(() => ({})); const result = await resp.json().catch(() => ({}));
@@ -309,9 +861,11 @@ function setFofaParseLoading(loading, statusText) {
btn.setAttribute('aria-busy', 'true'); btn.setAttribute('aria-busy', 'true');
btn.disabled = false; btn.disabled = false;
} else { } else {
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
btn.classList.remove('btn-loading'); btn.classList.remove('btn-loading');
btn.textContent = btn.dataset.originalText || _t('infoCollectPage.parseBtn'); btn.textContent = btn.dataset.originalText || _t('infoCollectPage.parseBtn');
btn.title = _t('infoCollect.parseToFofa'); btn.title = '将自然语言解析为 ' + cfg.label + ' 查询语法';
btn.disabled = false; btn.disabled = false;
delete btn.dataset.loading; delete btn.dataset.loading;
btn.removeAttribute('aria-busy'); btn.removeAttribute('aria-busy');
@@ -333,6 +887,8 @@ function showFofaParseModal(nlText, parsed) {
const existing = document.getElementById('fofa-parse-modal'); const existing = document.getElementById('fofa-parse-modal');
if (existing) existing.remove(); if (existing) existing.remove();
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const safeNL = escapeHtml((nlText || '').trim()); const safeNL = escapeHtml((nlText || '').trim());
const warnings = Array.isArray(parsed?.warnings) ? parsed.warnings.filter(Boolean).map(x => String(x)) : []; const warnings = Array.isArray(parsed?.warnings) ? parsed.warnings.filter(Boolean).map(x => String(x)) : [];
const explanation = parsed?.explanation != null ? String(parsed.explanation) : ''; const explanation = parsed?.explanation != null ? String(parsed.explanation) : '';
@@ -360,8 +916,8 @@ function showFofaParseModal(nlText, parsed) {
</div> </div>
<div class="form-group info-collect-parse-form-group"> <div class="form-group info-collect-parse-form-group">
<label for="fofa-parse-query">${_t('infoCollect.fofaQueryEditable')}</label> <label for="fofa-parse-query">${escapeHtml(cfg.label)} 查询语法可编辑</label>
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${_t('infoCollect.queryPlaceholder')}"></textarea> <textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${escapeHtml(cfg.placeholder)}"></textarea>
<small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small> <small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small>
</div> </div>
@@ -420,7 +976,7 @@ function showFofaParseModal(nlText, parsed) {
size: parseInt(els.size?.value, 10) || 100, size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1, page: parseInt(els.page?.value, 10) || 1,
fields: (els.fields?.value || '').trim(), fields: (els.fields?.value || '').trim(),
full: !!els.full?.checked full: isInfoCollectFullEnabled(getInfoCollectProvider())
}); });
close(); close();
if (run) submitFofaSearch(); if (run) submitFofaSearch();
@@ -447,6 +1003,22 @@ function setFofaMeta(text) {
} }
} }
function buildInfoCollectResultsMeta(provider, total, count, page, size, expectedCount, shortfall) {
let text = providerLabel(provider) + ' · ' + _t('infoCollect.resultsMeta', { total, count, page, size });
if (provider === 'shodan') {
let expected = Number(expectedCount || 0);
if (!Number.isFinite(expected) || expected <= 0) {
const startOffset = Math.max(0, (Number(page) || 1) - 1) * 100;
expected = Math.min(Number(size) || 0, Math.max(0, (Number(total) || 0) - startOffset));
}
const missing = Number(shortfall || 0);
if (expected > 0 && (missing > 0 || count < expected)) {
text += ' · ' + _t('infoCollect.providerReturnedFewer', { expected, count });
}
}
return text;
}
function updateSelectedMeta() { function updateSelectedMeta() {
const els = getFofaFormElements(); const els = getFofaFormElements();
if (els.selectedMeta) { if (els.selectedMeta) {
@@ -473,6 +1045,7 @@ function renderFofaResults(payload) {
// 保存当前 payload 到 state // 保存当前 payload 到 state
infoCollectState.currentPayload = { infoCollectState.currentPayload = {
provider: payload.provider || getInfoCollectProvider(),
query: payload.query || '', query: payload.query || '',
total: typeof payload.total === 'number' ? payload.total : 0, total: typeof payload.total === 'number' ? payload.total : 0,
page: typeof payload.page === 'number' ? payload.page : 1, page: typeof payload.page === 'number' ? payload.page : 1,
@@ -496,7 +1069,15 @@ function renderFofaResults(payload) {
const size = typeof payload.size === 'number' ? payload.size : 0; const size = typeof payload.size === 'number' ? payload.size : 0;
const page = typeof payload.page === 'number' ? payload.page : 1; const page = typeof payload.page === 'number' ? payload.page : 1;
setFofaMeta(_t('infoCollect.resultsMeta', { total, count: results.length, page, size })); setFofaMeta(buildInfoCollectResultsMeta(
infoCollectState.currentPayload.provider,
total,
results.length,
page,
size,
typeof payload.expected_count === 'number' ? payload.expected_count : 0,
typeof payload.shortfall === 'number' ? payload.shortfall : 0
));
// 可见字段 // 可见字段
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f)); const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
@@ -506,7 +1087,7 @@ function renderFofaResults(payload) {
// 表头(左:勾选列;右:操作列固定) // 表头(左:勾选列;右:操作列固定)
const headerCells = [ const headerCells = [
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" title="' + escapeHtml(_t('infoCollect.selectAll')) + '"/></th>', '<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" class="theme-checkbox" title="' + escapeHtml(_t('infoCollect.selectAll')) + '"/></th>',
...visibleFields.map(f => `<th>${escapeHtml(String(f))}</th>`), ...visibleFields.map(f => `<th>${escapeHtml(String(f))}</th>`),
'<th class="info-collect-col-actions">' + escapeHtml(_t('infoCollect.actions')) + '</th>' '<th class="info-collect-col-actions">' + escapeHtml(_t('infoCollect.actions')) + '</th>'
].join(''); ].join('');
@@ -525,7 +1106,7 @@ function renderFofaResults(payload) {
const encoded = encodeURIComponent(JSON.stringify(safeRow)); const encoded = encodeURIComponent(JSON.stringify(safeRow));
const encodedTarget = encodeURIComponent(target || ''); const encodedTarget = encodeURIComponent(target || '');
const selectHtml = '<td class="info-collect-col-select"><input class="fofa-row-select" type="checkbox" data-index="' + idx + '" title="' + escapeHtml(_t('infoCollect.selectRow')) + '"/></td>'; const selectHtml = '<td class="info-collect-col-select"><input class="fofa-row-select theme-checkbox" type="checkbox" data-index="' + idx + '" title="' + escapeHtml(_t('infoCollect.selectRow')) + '"/></td>';
const cellsHtml = visibleFields.map(f => { const cellsHtml = visibleFields.map(f => {
const val = safeRow[f]; const val = safeRow[f];
@@ -768,7 +1349,8 @@ function buildScanMessage(target, row, options) {
const fields = Array.isArray(opts.fields) ? opts.fields : []; const fields = Array.isArray(opts.fields) ? opts.fields : [];
const summary = formatFofaRowSummary(row || {}, fields); const summary = formatFofaRowSummary(row || {}, fields);
return `对以下目标做信息收集与基础扫描:\n${target}\n\n要求:\n1) 识别服务/框架与关键指纹\n2) 枚举开放端口与常见管理入口\n3) 用 httpx/指纹/目录探测等方式快速确认可访问面\n4) 输出可复现的命令与结论\n\n已知信息(来自 FOFA 该行全部字段):\n${summary}`.trim(); const provider = providerLabel(infoCollectState.currentPayload?.provider || getInfoCollectProvider());
return `对以下目标做信息收集与基础扫描:\n${target}\n\n要求:\n1) 识别服务/框架与关键指纹\n2) 枚举开放端口与常见管理入口\n3) 用 httpx/指纹/目录探测等方式快速确认可访问面\n4) 输出可复现的命令与结论\n\n已知信息(来自 ${provider} 该行全部字段):\n${summary}`.trim();
} }
function bindFofaTableEvents() { function bindFofaTableEvents() {
@@ -926,12 +1508,14 @@ function exportFofaResults(format) {
const fields = p.fields || []; const fields = p.fields || [];
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f)); const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
const provider = p.provider || 'fofa';
const now = new Date(); const now = new Date();
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
if (format === 'json') { if (format === 'json') {
const payload = { const payload = {
provider,
query: p.query || '', query: p.query || '',
total: p.total || 0, total: p.total || 0,
page: p.page || 1, page: p.page || 1,
@@ -939,7 +1523,7 @@ function exportFofaResults(format) {
fields: fields, fields: fields,
results: p.results results: p.results
}; };
downloadBlob(JSON.stringify(payload, null, 2), `fofa_results_${ts}.json`, 'application/json;charset=utf-8'); downloadBlob(JSON.stringify(payload, null, 2), `${provider}_results_${ts}.json`, 'application/json;charset=utf-8');
return; return;
} }
@@ -956,7 +1540,7 @@ function exportFofaResults(format) {
const ws = XLSX.utils.aoa_to_sheet(aoa); const ws = XLSX.utils.aoa_to_sheet(aoa);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, _t('infoCollect.batchScanTitle')); XLSX.utils.book_append_sheet(wb, ws, _t('infoCollect.batchScanTitle'));
XLSX.writeFile(wb, `fofa_results_${ts}.xlsx`); XLSX.writeFile(wb, `${provider}_results_${ts}.xlsx`);
return; return;
} }
@@ -968,7 +1552,7 @@ function exportFofaResults(format) {
}); });
const csv = [header.map(csvEscape).join(','), ...rows.map(cols => cols.join(','))].join('\n'); const csv = [header.map(csvEscape).join(','), ...rows.map(cols => cols.join(','))].join('\n');
const csvWithBom = '\uFEFF' + csv; const csvWithBom = '\uFEFF' + csv;
downloadBlob(csvWithBom, `fofa_results_${ts}.csv`, 'text/csv;charset=utf-8'); downloadBlob(csvWithBom, `${provider}_results_${ts}.csv`, 'text/csv;charset=utf-8');
} }
function csvEscape(value) { function csvEscape(value) {
@@ -1074,13 +1658,19 @@ function showCellDetailModal(field, fullText) {
const existing = document.getElementById('info-collect-cell-modal'); const existing = document.getElementById('info-collect-cell-modal');
if (existing) existing.remove(); if (existing) existing.remove();
const text = fullText == null ? '' : String(fullText);
const fieldName = field || _t('infoCollect.field');
const charCountLabel = _t('infoCollect.cellValueLength', { count: Array.from(text).length });
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.id = 'info-collect-cell-modal'; modal.id = 'info-collect-cell-modal';
modal.className = 'info-collect-cell-modal'; modal.className = 'info-collect-cell-modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="info-collect-cell-modal-content" role="dialog" aria-modal="true"> <div class="info-collect-cell-modal-content" role="dialog" aria-modal="true">
<div class="info-collect-cell-modal-header"> <div class="info-collect-cell-modal-header">
<div class="info-collect-cell-modal-title">${escapeHtml(field || _t('infoCollect.field'))}</div> <div class="info-collect-cell-modal-heading">
<div class="info-collect-cell-modal-title">${escapeHtml(fieldName)}</div>
<div class="info-collect-cell-modal-subtitle">${escapeHtml(charCountLabel)}</div>
</div>
<button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="${_t('common.close')}"> <button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="${_t('common.close')}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@@ -1088,7 +1678,7 @@ function showCellDetailModal(field, fullText) {
</button> </button>
</div> </div>
<div class="info-collect-cell-modal-body"> <div class="info-collect-cell-modal-body">
<pre class="info-collect-cell-modal-pre">${escapeHtml(fullText || '')}</pre> <pre class="info-collect-cell-modal-pre">${escapeHtml(text)}</pre>
</div> </div>
<div class="info-collect-cell-modal-footer"> <div class="info-collect-cell-modal-footer">
<button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">${_t('common.copy')}</button> <button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">${_t('common.copy')}</button>
@@ -1100,7 +1690,13 @@ function showCellDetailModal(field, fullText) {
document.body.appendChild(modal); document.body.appendChild(modal);
openAppModal(modal); openAppModal(modal);
const onKey = (e) => {
if (e.key === 'Escape') {
close();
}
};
const close = function () { const close = function () {
document.removeEventListener('keydown', onKey);
closeAppModal(modal); closeAppModal(modal);
modal.remove(); modal.remove();
syncAppModalBodyLock(); syncAppModalBodyLock();
@@ -1111,16 +1707,10 @@ function showCellDetailModal(field, fullText) {
document.getElementById('info-collect-cell-modal-close')?.addEventListener('click', close); document.getElementById('info-collect-cell-modal-close')?.addEventListener('click', close);
document.getElementById('info-collect-cell-modal-ok')?.addEventListener('click', close); document.getElementById('info-collect-cell-modal-ok')?.addEventListener('click', close);
document.getElementById('info-collect-cell-modal-copy')?.addEventListener('click', () => { document.getElementById('info-collect-cell-modal-copy')?.addEventListener('click', () => {
navigator.clipboard.writeText(fullText || '').then(() => showInlineToast(_t('common.copied'))).catch(() => alert(_t('common.copyFailed'))); navigator.clipboard.writeText(text).then(() => showInlineToast(_t('common.copied'))).catch(() => alert(_t('common.copyFailed')));
}); });
// Esc 关闭 // Esc 关闭
const onKey = (e) => {
if (e.key === 'Escape') {
close();
document.removeEventListener('keydown', onKey);
}
};
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
} }
@@ -1129,6 +1719,7 @@ window.initInfoCollectPage = initInfoCollectPage;
window.resetFofaForm = resetFofaForm; window.resetFofaForm = resetFofaForm;
window.submitFofaSearch = submitFofaSearch; window.submitFofaSearch = submitFofaSearch;
window.parseFofaNaturalLanguage = parseFofaNaturalLanguage; window.parseFofaNaturalLanguage = parseFofaNaturalLanguage;
window.setInfoCollectQueryMode = setInfoCollectQueryMode;
window.scanFofaRow = scanFofaRow; window.scanFofaRow = scanFofaRow;
window.copyFofaTarget = copyFofaTarget; window.copyFofaTarget = copyFofaTarget;
window.copyFofaTargetEncoded = copyFofaTargetEncoded; window.copyFofaTargetEncoded = copyFofaTargetEncoded;
+311 -78
View File
@@ -1375,11 +1375,19 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
} }
const PROCESS_DETAILS_PAGE_SIZE = 50; const PROCESS_DETAILS_PAGE_SIZE = 50;
const processDetailsAutoLoadObservers = new WeakMap();
function getProcessDetailsLoadMoreLabel(hasMore) { function processDetailsContinuousLabel(kind) {
if (!hasMore) return ''; if (kind === 'older') {
return (typeof window.t === 'function' ? window.t('common.loadMore') : '加载更多') + ' · ' + return typeof window.t === 'function' ? window.t('chat.loadingEarlierDetails') : '正在加载更早记录…';
(typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '任务执行详情'); }
if (kind === 'newer') {
return typeof window.t === 'function' ? window.t('chat.loadingLaterDetails') : '正在加载更新记录…';
}
if (kind === 'retry') {
return typeof window.t === 'function' ? window.t('common.retry') : '加载失败,点击重试';
}
return '';
} }
function updateProcessDetailsLoadMoreButton(assistantMessageId, backendMessageId, hasMore) { function updateProcessDetailsLoadMoreButton(assistantMessageId, backendMessageId, hasMore) {
@@ -1389,79 +1397,171 @@ function updateProcessDetailsLoadMoreButton(assistantMessageId, backendMessageId
}); });
} }
function disconnectProcessDetailsAutoLoader(detailsContainer) {
if (!detailsContainer) return;
const old = processDetailsAutoLoadObservers.get(detailsContainer);
if (old) {
old.disconnect();
processDetailsAutoLoadObservers.delete(detailsContainer);
}
}
function scrollProcessDetailsToLatest(assistantMessageId, smooth) {
const detailsContainer = document.getElementById('process-details-' + assistantMessageId);
if (!detailsContainer) return;
const timeline = detailsContainer.querySelector('.progress-timeline');
if (!timeline) return;
const behavior = smooth === false ? 'auto' : 'smooth';
if (timeline.scrollHeight > timeline.clientHeight + 2) {
timeline.scrollTo({ top: timeline.scrollHeight, behavior: behavior });
return;
}
const items = timeline.querySelectorAll('.timeline-item');
if (!items.length) return;
const lastItem = items[items.length - 1];
lastItem.scrollIntoView({ behavior: behavior, block: 'nearest' });
}
function updateProcessDetailsJumpLatestVisibility(detailsContainer) {
if (!detailsContainer) return;
const btn = detailsContainer.querySelector('.process-details-jump-latest');
const timeline = detailsContainer.querySelector('.progress-timeline');
if (!btn || !timeline) return;
const hasUnloadedNewer = detailsContainer.dataset.hasNext === '1';
const awayFromTimelineBottom = timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop > 120;
btn.classList.toggle('visible', hasUnloadedNewer || awayFromTimelineBottom);
}
async function requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, direction, sentinel) {
const detailsContainer = document.getElementById('process-details-' + assistantMessageId);
if (!detailsContainer || !sentinel) return;
const loadingKey = direction === 'prev' ? 'loadingPrev' : 'loadingMore';
const hasKey = direction === 'prev' ? 'hasPrev' : 'hasNext';
if (detailsContainer.dataset[hasKey] !== '1' || detailsContainer.dataset[loadingKey] === '1') return;
detailsContainer.dataset[loadingKey] = '1';
sentinel.classList.add('is-loading');
sentinel.textContent = processDetailsContinuousLabel(direction === 'prev' ? 'older' : 'newer');
try {
await loadProcessDetailsPaginated(assistantMessageId, backendMessageId, {
prepend: direction === 'prev',
append: direction === 'next',
autoLoadAll: false
});
} catch (e) {
console.error('自动加载过程详情失败:', e);
sentinel.classList.remove('is-loading');
sentinel.classList.add('is-error');
sentinel.textContent = processDetailsContinuousLabel('retry');
sentinel.onclick = function () {
sentinel.onclick = null;
sentinel.classList.remove('is-error');
requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, direction, sentinel);
};
} finally {
detailsContainer.dataset[loadingKey] = '0';
}
}
function updateProcessDetailsPaginationButtons(assistantMessageId, backendMessageId, pageState) { function updateProcessDetailsPaginationButtons(assistantMessageId, backendMessageId, pageState) {
const detailsContainer = document.getElementById('process-details-' + assistantMessageId); const detailsContainer = document.getElementById('process-details-' + assistantMessageId);
if (!detailsContainer) return; if (!detailsContainer) return;
const timeline = detailsContainer.querySelector('.progress-timeline');
if (!timeline) return;
const state = pageState || {}; const state = pageState || {};
detailsContainer.dataset.hasPrev = state.hasPrev ? '1' : '0';
detailsContainer.dataset.hasNext = state.hasNext ? '1' : '0';
let prevBtn = detailsContainer.querySelector('.process-details-load-prev-btn'); detailsContainer.querySelectorAll('.process-details-load-prev-btn, .process-details-load-more-btn').forEach(function (el) {
if (!state.hasPrev) { el.remove();
if (prevBtn) prevBtn.remove();
} else {
if (!prevBtn) {
prevBtn = document.createElement('button');
prevBtn.type = 'button';
prevBtn.className = 'mcp-detail-btn process-details-load-prev-btn';
const content = detailsContainer.querySelector('.process-details-content');
if (content) {
detailsContainer.insertBefore(prevBtn, content);
} else {
detailsContainer.prepend(prevBtn);
}
}
const loadMoreText = typeof window.t === 'function' ? window.t('common.loadMore') : '加载更多';
let prevPageText = typeof window.t === 'function' ? window.t('chat.previousPage') : '上一页';
if (!prevPageText || prevPageText === 'chat.previousPage') prevPageText = '上一页';
prevBtn.textContent = loadMoreText + ' · ' + prevPageText;
prevBtn.disabled = false;
prevBtn.onclick = async () => {
if (detailsContainer.dataset.loadingPrev === '1') return;
detailsContainer.dataset.loadingPrev = '1';
prevBtn.disabled = true;
prevBtn.textContent = typeof window.t === 'function' ? window.t('common.loading') : '加载中…';
try {
await loadProcessDetailsPaginated(assistantMessageId, backendMessageId, {
prepend: true,
autoLoadAll: false
}); });
} finally { timeline.querySelectorAll('.process-details-auto-sentinel').forEach(function (el) {
detailsContainer.dataset.loadingPrev = '0'; el.remove();
});
disconnectProcessDetailsAutoLoader(detailsContainer);
let topSentinel = null;
let bottomSentinel = null;
if (state.hasPrev) {
topSentinel = document.createElement('button');
topSentinel.type = 'button';
topSentinel.className = 'process-details-auto-sentinel process-details-auto-sentinel--top';
topSentinel.setAttribute('aria-label', processDetailsContinuousLabel('older'));
topSentinel.textContent = processDetailsContinuousLabel('older');
timeline.prepend(topSentinel);
} }
}; if (state.hasNext) {
bottomSentinel = document.createElement('button');
bottomSentinel.type = 'button';
bottomSentinel.className = 'process-details-auto-sentinel process-details-auto-sentinel--bottom';
bottomSentinel.setAttribute('aria-label', processDetailsContinuousLabel('newer'));
bottomSentinel.textContent = processDetailsContinuousLabel('newer');
timeline.appendChild(bottomSentinel);
} }
let nextBtn = detailsContainer.querySelector('.process-details-load-more-btn'); let jumpBtn = detailsContainer.querySelector('.process-details-jump-latest');
if (!state.hasNext) { if (!jumpBtn) {
if (nextBtn) nextBtn.remove(); jumpBtn = document.createElement('button');
return; jumpBtn.type = 'button';
jumpBtn.className = 'process-details-jump-latest';
jumpBtn.textContent = typeof window.t === 'function' ? window.t('chat.backToLatestProgress') : '↓ 回到最新进度';
detailsContainer.appendChild(jumpBtn);
} }
if (!nextBtn) { jumpBtn.onclick = async function () {
nextBtn = document.createElement('button'); if (detailsContainer.dataset.hasNext === '1') {
nextBtn.type = 'button';
nextBtn.className = 'mcp-detail-btn process-details-load-more-btn';
detailsContainer.appendChild(nextBtn);
}
nextBtn.textContent = getProcessDetailsLoadMoreLabel(true);
nextBtn.disabled = false;
nextBtn.onclick = async () => {
if (detailsContainer.dataset.loadingMore === '1') return;
detailsContainer.dataset.loadingMore = '1';
nextBtn.disabled = true;
nextBtn.textContent = typeof window.t === 'function' ? window.t('common.loading') : '加载中…';
try {
await loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { await loadProcessDetailsPaginated(assistantMessageId, backendMessageId, {
append: true, autoLoadAll: false,
autoLoadAll: false initialLatest: true
}); });
} finally {
detailsContainer.dataset.loadingMore = '0';
} }
requestAnimationFrame(function () {
scrollProcessDetailsToLatest(assistantMessageId, true);
updateProcessDetailsJumpLatestVisibility(detailsContainer);
});
}; };
if (timeline.dataset.continuousScrollBound !== '1') {
timeline.dataset.continuousScrollBound = '1';
timeline.addEventListener('scroll', function () {
updateProcessDetailsJumpLatestVisibility(detailsContainer);
}, { passive: true });
}
if (typeof IntersectionObserver === 'function' && (topSentinel || bottomSentinel)) {
const root = document.getElementById('chat-messages') || null;
const observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
if (detailsContainer.dataset.autoLoadSuspended === '1') return;
if (entry.target === topSentinel) {
requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'prev', topSentinel);
} else if (entry.target === bottomSentinel) {
requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'next', bottomSentinel);
}
});
}, { root: root, rootMargin: '180px 0px', threshold: 0.01 });
if (topSentinel) observer.observe(topSentinel);
if (bottomSentinel) observer.observe(bottomSentinel);
processDetailsAutoLoadObservers.set(detailsContainer, observer);
} else {
if (topSentinel) {
topSentinel.textContent = typeof window.t === 'function' ? window.t('common.loadMore') : '加载更早记录';
topSentinel.onclick = function () {
requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'prev', topSentinel);
};
}
if (bottomSentinel) {
bottomSentinel.textContent = typeof window.t === 'function' ? window.t('common.loadMore') : '加载更新记录';
bottomSentinel.onclick = function () {
requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'next', bottomSentinel);
};
}
}
updateProcessDetailsJumpLatestVisibility(detailsContainer);
} }
/** /**
* 分页加载过程详情并增量渲染默认全量加载供恢复流程使用 * 分页加载过程详情并增量渲染默认全量加载供恢复流程使用
* 用户手动展开时 autoLoadAll=false只加载一页并展示加载更多 * 用户手动展开时由任务状态选择首个历史页或最新页滚动到边界后自动加载相邻页
*/ */
async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, options) { async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, options) {
if (!assistantMessageId || !backendMessageId || typeof apiFetch !== 'function' || typeof renderProcessDetails !== 'function') { if (!assistantMessageId || !backendMessageId || typeof apiFetch !== 'function' || typeof renderProcessDetails !== 'function') {
@@ -1481,6 +1581,21 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId,
? parseInt(detailsContainer.dataset.nextOffset, 10) || 0 ? parseInt(detailsContainer.dataset.nextOffset, 10) || 0
: 0; : 0;
const anchorId = opts.anchorId != null ? String(opts.anchorId).trim() : ''; const anchorId = opts.anchorId != null ? String(opts.anchorId).trim() : '';
if (opts.initialLatest && !prepend && !opts.append && !anchorId) {
if (detailsContainer) {
// 初页渲染完成前禁止顶部哨兵抢先触发;定位到底部后再开放自动加载。
detailsContainer.dataset.autoLoadSuspended = '1';
}
const summaryRes = await apiFetch(
'/api/messages/' + encodeURIComponent(String(backendMessageId)) + '/process-details?summary=1'
);
const summaryJSON = await summaryRes.json().catch(() => ({}));
if (!summaryRes.ok) {
throw new Error((summaryJSON && summaryJSON.error) ? summaryJSON.error : String(summaryRes.status));
}
const total = summaryJSON && summaryJSON.summary && Number(summaryJSON.summary.total);
offset = Number.isFinite(total) ? Math.max(0, total - PAGE) : 0;
}
let isFirst = !opts.append; let isFirst = !opts.append;
while (true) { while (true) {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -1507,6 +1622,9 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId,
markLoaded: autoLoadAll ? !hasMore : true, markLoaded: autoLoadAll ? !hasMore : true,
toolExecutions: toolExecutions toolExecutions: toolExecutions
}); });
// renderProcessDetails 对大页分帧渲染;等待一帧后再放置顶部/底部哨兵,
// 避免哨兵被后续批次插到时间线中间。
await new Promise((resolve) => requestAnimationFrame(resolve));
const responseOffset = j && typeof j.offset === 'number' ? j.offset : offset; const responseOffset = j && typeof j.offset === 'number' ? j.offset : offset;
const total = j && typeof j.total === 'number' ? j.total : responseOffset + details.length; const total = j && typeof j.total === 'number' ? j.total : responseOffset + details.length;
const nextOffset = prepend && existingNextOffset > 0 const nextOffset = prepend && existingNextOffset > 0
@@ -1519,6 +1637,7 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId,
detailsContainer.dataset.loaded = hasMore ? 'partial' : '1'; detailsContainer.dataset.loaded = hasMore ? 'partial' : '1';
detailsContainer.dataset.prevOffset = String(prevOffset); detailsContainer.dataset.prevOffset = String(prevOffset);
detailsContainer.dataset.nextOffset = String(nextOffset); detailsContainer.dataset.nextOffset = String(nextOffset);
detailsContainer.dataset.total = String(total);
} }
updateProcessDetailsPaginationButtons(assistantMessageId, backendMessageId, { updateProcessDetailsPaginationButtons(assistantMessageId, backendMessageId, {
hasPrev: !autoLoadAll && responseOffset > 0, hasPrev: !autoLoadAll && responseOffset > 0,
@@ -1530,10 +1649,38 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId,
isFirst = false; isFirst = false;
await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve));
} }
if (opts.initialLatest) {
requestAnimationFrame(function () {
scrollProcessDetailsToLatest(assistantMessageId, false);
if (detailsContainer) {
delete detailsContainer.dataset.autoLoadSuspended;
}
});
} else if (opts.initialStart) {
requestAnimationFrame(function () {
const container = document.getElementById('process-details-' + assistantMessageId);
const timeline = container && container.querySelector('.progress-timeline');
if (timeline) timeline.scrollTop = 0;
});
}
} }
window.loadProcessDetailsPaginated = loadProcessDetailsPaginated; window.loadProcessDetailsPaginated = loadProcessDetailsPaginated;
function shouldInitiallyOpenProcessDetailsAtLatest(assistantMessageId, detailsContainer) {
if (!detailsContainer) return false;
// task-events 恢复流会明确给当前详情容器打 is-streaming 标记。
if (detailsContainer.classList.contains('is-streaming')) return true;
try {
const replay = window.__csTaskEventStream;
if (replay && replay.active && String(replay.assistantDomId || '') === String(assistantMessageId || '')) {
return true;
}
} catch (e) { /* ignore */ }
// 其余情况按终态/历史详情处理,从第一条开始,便于顺序复盘。
return false;
}
function resolveEventBackendMessageId(eventData) { function resolveEventBackendMessageId(eventData) {
if (!eventData || typeof eventData !== 'object') return ''; if (!eventData || typeof eventData !== 'object') return '';
const raw = eventData.messageId != null ? eventData.messageId : eventData.assistantMessageId; const raw = eventData.messageId != null ? eventData.messageId : eventData.assistantMessageId;
@@ -1549,7 +1696,12 @@ function triggerLazyProcessDetailsLoad(assistantMessageId, backendMessageId, det
if (timeline) { if (timeline) {
timeline.innerHTML = '<div class="progress-timeline-empty">' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '</div>'; timeline.innerHTML = '<div class="progress-timeline-empty">' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '</div>';
} }
loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { autoLoadAll: false }) const openAtLatest = shouldInitiallyOpenProcessDetailsAtLatest(assistantMessageId, detailsContainer);
loadProcessDetailsPaginated(assistantMessageId, backendMessageId, {
autoLoadAll: false,
initialLatest: openAtLatest,
initialStart: !openAtLatest
})
.catch((e) => { .catch((e) => {
console.error('加载过程详情失败:', e); console.error('加载过程详情失败:', e);
const tl = detailsContainer.querySelector('.progress-timeline'); const tl = detailsContainer.querySelector('.progress-timeline');
@@ -1763,17 +1915,72 @@ function mergeMcpExecutionIDLists(prev, next) {
function formatEinoRunRetryMessage(message, data) { function formatEinoRunRetryMessage(message, data) {
const d = data && typeof data === 'object' ? data : {}; const d = data && typeof data === 'object' ? data : {};
const base = String(message || '').trim(); const base = String(message || '').trim();
const errRaw = d.error != null ? String(d.error).trim() : ''; const errRaw = d.errorSummary != null && String(d.errorSummary).trim() !== ''
? String(d.errorSummary).trim()
: (d.error != null ? String(d.error).trim() : '');
const lines = [];
if (base) lines.push(base);
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
const backoffSec = Number(d.backoffSec || 0);
const kind = formatEinoRunRetryKind(d.errorKind);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
const retryPlan = typeof window.t === 'function'
? window.t('chat.einoRunRetryPlan', { attempt: attempt, maxAttempts: maxAttempts, backoffSec: Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-' })
: ('重试进度:第 ' + attempt + '/' + maxAttempts + ' 次,等待 ' + (Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-') + ' 秒');
if (!base || base.indexOf(String(attempt) + '/' + String(maxAttempts)) === -1) {
lines.push(retryPlan);
}
}
if (kind) {
const kindLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryReasonKind')
: '原因类型';
lines.push(kindLabel + '' + kind);
}
if (!errRaw) { if (!errRaw) {
return base; return lines.join('\n');
} }
const detailLabel = typeof window.t === 'function' const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail') ? window.t('chat.einoRunRetryErrorDetail')
: '错误详情'; : '错误详情';
if (base && base.indexOf(errRaw) !== -1) { if (!base || base.indexOf(errRaw) === -1) {
return base; lines.push(detailLabel + '' + errRaw);
} }
return base ? (base + '\n' + detailLabel + '' + errRaw) : (detailLabel + '' + errRaw); return lines.join('\n');
}
function formatEinoRunRetryKind(kind) {
const key = String(kind || '').trim();
if (!key) return '';
const labels = {
rate_limit: '限流 / 请求过多',
retryable_http: '可重试 HTTP 错误',
upstream_server: '上游服务错误',
http_error: 'HTTP 错误',
upstream_busy: '上游繁忙',
network: '网络连接异常',
stream: '流式读取异常',
transient: '临时异常'
};
if (typeof window.t === 'function') {
const translated = window.t('chat.einoRunRetryKind_' + key);
if (translated && translated !== 'chat.einoRunRetryKind_' + key) return translated;
}
return labels[key] || key;
}
function formatEinoRunRetryTitle(data) {
const d = data && typeof data === 'object' ? data : {};
const base = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
return base + '' + attempt + '/' + maxAttempts + '';
}
return base;
} }
// 处理流式事件 // 处理流式事件
@@ -2273,12 +2480,9 @@ function handleStreamEvent(event, progressElement, progressId,
case 'eino_run_retry': { case 'eino_run_retry': {
const d = event.data || {}; const d = event.data || {};
const title = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const msg = formatEinoRunRetryMessage(event.message, d); const msg = formatEinoRunRetryMessage(event.message, d);
addTimelineItem(timeline, 'warning', { addTimelineItem(timeline, 'warning', {
title: title, title: formatEinoRunRetryTitle(d),
message: msg, message: msg,
data: d data: d
}); });
@@ -3146,7 +3350,7 @@ async function restoreWorkflowHitlInlineForConversation(conversationId) {
if (typeof loadProcessDetailsPaginated === 'function') { if (typeof loadProcessDetailsPaginated === 'function') {
await loadProcessDetailsPaginated(clientMsgId, backendMsgId); await loadProcessDetailsPaginated(clientMsgId, backendMsgId);
} else if (typeof apiFetch === 'function' && backendMsgId) { } else if (typeof apiFetch === 'function' && backendMsgId) {
const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details'); const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details?full=1');
const j = await res.json().catch(function () { return {}; }); const j = await res.json().catch(function () { return {}; });
if (res.ok && typeof renderProcessDetails === 'function') { if (res.ok && typeof renderProcessDetails === 'function') {
renderProcessDetails(clientMsgId, (j && Array.isArray(j.processDetails)) ? j.processDetails : []); renderProcessDetails(clientMsgId, (j && Array.isArray(j.processDetails)) ? j.processDetails : []);
@@ -3278,7 +3482,7 @@ async function restoreHitlInlineForConversation(conversationId) {
if (typeof loadProcessDetailsPaginated === 'function') { if (typeof loadProcessDetailsPaginated === 'function') {
await loadProcessDetailsPaginated(clientMsgId, backendMsgId); await loadProcessDetailsPaginated(clientMsgId, backendMsgId);
} else { } else {
const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details'); const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details?full=1');
const j = await res.json().catch(function () { return {}; }); const j = await res.json().catch(function () { return {}; });
if (!res.ok) throw new Error((j && j.error) ? j.error : String(res.status)); if (!res.ok) throw new Error((j && j.error) ? j.error : String(res.status));
const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : [];
@@ -3356,13 +3560,21 @@ async function refreshLastAssistantProcessDetails(conversationId) {
wasExpanded = !!(tl && tl.classList.contains('expanded')); wasExpanded = !!(tl && tl.classList.contains('expanded'));
} }
try { try {
const res = await apiFetch('/api/messages/' + encodeURIComponent(backendId) + '/process-details'); // 恢复流程必须遍历全部分页。直接请求无参数接口只会返回最早 50 条,
// 长任务刷新后会表现为“旧轮次 → 当前实时轮次”的中间历史缺失。
if (typeof loadProcessDetailsPaginated === 'function') {
await loadProcessDetailsPaginated(clientId, backendId);
} else {
const res = await apiFetch(
'/api/messages/' + encodeURIComponent(backendId) + '/process-details?full=1'
);
const j = await res.json().catch(function () { return {}; }); const j = await res.json().catch(function () { return {}; });
if (!res.ok) return; if (!res.ok) return;
const details = Array.isArray(j.processDetails) ? j.processDetails : []; const details = Array.isArray(j.processDetails) ? j.processDetails : [];
if (typeof renderProcessDetails === 'function') { if (typeof renderProcessDetails === 'function') {
renderProcessDetails(clientId, details); renderProcessDetails(clientId, details);
} }
}
if (wasExpanded) { if (wasExpanded) {
expandProcessDetailsTimeline(clientId); expandProcessDetailsTimeline(clientId);
} }
@@ -3407,16 +3619,23 @@ async function attachRunningTaskEventStream(conversationId) {
if (!asEl || !asEl.id) return false; if (!asEl || !asEl.id) return false;
const backendId = asEl.dataset && asEl.dataset.backendMessageId; const backendId = asEl.dataset && asEl.dataset.backendMessageId;
if (backendId && typeof renderProcessDetails === 'function') { if (backendId && typeof renderProcessDetails === 'function') {
const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details'); // 运行中会话可能远超默认 50 条;完整补齐数据库历史后再接实时事件。
if (typeof loadProcessDetailsPaginated === 'function') {
await loadProcessDetailsPaginated(asEl.id, String(backendId));
} else {
const res = await apiFetch(
'/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details?full=1'
);
const jd = await res.json().catch(function () { return {}; }); const jd = await res.json().catch(function () { return {}; });
if (res.ok && Array.isArray(jd.processDetails)) { if (res.ok && Array.isArray(jd.processDetails)) {
renderProcessDetails(asEl.id, jd.processDetails); renderProcessDetails(asEl.id, jd.processDetails);
// renderProcessDetails 会重建时间线节点,需重新挂载 HITL 审批入口 }
}
// 历史重绘会重建时间线节点,需重新挂载 HITL 审批入口。
if (typeof window.restoreHitlInlineForConversation === 'function') { if (typeof window.restoreHitlInlineForConversation === 'function') {
await window.restoreHitlInlineForConversation(conversationId); await window.restoreHitlInlineForConversation(conversationId);
} }
} }
}
expandProcessDetailsTimeline(asEl.id); expandProcessDetailsTimeline(asEl.id);
const progressId = taskReplayProgressId(conversationId); const progressId = taskReplayProgressId(conversationId);
@@ -3482,7 +3701,18 @@ async function attachRunningTaskEventStream(conversationId) {
} }
if (typeof loadActiveTasks === 'function') loadActiveTasks(); if (typeof loadActiveTasks === 'function') loadActiveTasks();
if (replaySawDone && typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) { if (replaySawDone && typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
const replayTimeline = document.getElementById('process-details-' + asEl.id + '-timeline');
const keepExpanded = !!(replayTimeline && replayTimeline.classList.contains('expanded'));
await window.loadConversation(conversationId); await window.loadConversation(conversationId);
// loadConversation 使用轻量消息接口,会把详情重新置为懒加载状态;
// 任务终态再从 DB 全量对账一次,补回订阅建立期间可能错过的事件。
await refreshLastAssistantProcessDetails(conversationId);
if (keepExpanded) {
const finalAssistant = findLastAssistantMessageElInChat();
if (finalAssistant && finalAssistant.id) {
expandProcessDetailsTimeline(finalAssistant.id);
}
}
} }
return true; return true;
} catch (e) { } catch (e) {
@@ -4048,6 +4278,9 @@ function addTimelineItem(timeline, type, options) {
const itemId = 'timeline-item-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); const itemId = 'timeline-item-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
item.id = itemId; item.id = itemId;
item.className = `timeline-item timeline-item-${type}`; item.className = `timeline-item timeline-item-${type}`;
if (type === 'eino_run_retry') {
item.classList.add('timeline-item-warning');
}
// 记录类型与参数,便于 languagechange 时刷新标题文案 // 记录类型与参数,便于 languagechange 时刷新标题文案
item.dataset.timelineType = type; item.dataset.timelineType = type;
if (type === 'iteration') { if (type === 'iteration') {
@@ -4240,7 +4473,7 @@ function addTimelineItem(timeline, type, options) {
${escapeHtml(options.message || taskCancelledLabel)} ${escapeHtml(options.message || taskCancelledLabel)}
</div> </div>
`; `;
} else if (type === 'warning' && options.message) { } else if ((type === 'warning' || type === 'eino_run_retry') && options.message) {
const streamBody = typeof formatTimelineStreamBody === 'function' const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data) ? formatTimelineStreamBody(options.message, options.data)
: options.message; : options.message;
@@ -6236,7 +6469,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
html: ` html: `
<tr data-execution-id="${executionId}"> <tr data-execution-id="${executionId}">
<td> <td>
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection('${jsExecId}', this.checked)" /> <input type="checkbox" class="monitor-execution-checkbox theme-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection('${jsExecId}', this.checked)" />
</td> </td>
<td>${toolName}</td> <td>${toolName}</td>
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td> <td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
@@ -6269,7 +6502,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const headerHtml = ` const headerHtml = `
<tr> <tr>
<th style="width: 40px;"> <th style="width: 40px;">
<input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" /> <input type="checkbox" id="monitor-select-all" class="theme-checkbox" onchange="toggleSelectAll(this)" />
</th> </th>
<th>${escapeHtml(colTool)}</th> <th>${escapeHtml(colTool)}</th>
<th>${escapeHtml(colStatus)}</th> <th>${escapeHtml(colStatus)}</th>
+99 -3
View File
@@ -8,6 +8,20 @@ let currentProjectId = null;
let currentProjectUpdatedAt = null; let currentProjectUpdatedAt = null;
let currentProjectTab = 'facts'; let currentProjectTab = 'facts';
let currentProjectAssets = []; let currentProjectAssets = [];
const PROJECT_ASSETS_PAGE_SIZE_KEY = 'cyberstrike.project_assets_page_size';
let projectAssetsPagination = {
page: 1,
pageSize: (() => {
try {
const size = Number(localStorage.getItem(PROJECT_ASSETS_PAGE_SIZE_KEY));
return [10, 20, 50, 100].includes(size) ? size : 20;
} catch (e) {
return 20;
}
})(),
total: 0,
totalPages: 1,
};
const projectNameById = {}; const projectNameById = {};
let _projectsListReady = false; let _projectsListReady = false;
let _projectsFetchPromise = null; let _projectsFetchPromise = null;
@@ -974,6 +988,7 @@ function updateProjectStats(stats) {
async function selectProject(id) { async function selectProject(id) {
currentProjectId = id; currentProjectId = id;
projectAssetsPagination.page = 1;
const searchEl = document.getElementById('project-facts-search'); const searchEl = document.getElementById('project-facts-search');
const catEl = document.getElementById('project-facts-filter-category'); const catEl = document.getElementById('project-facts-filter-category');
const confEl = document.getElementById('project-facts-filter-confidence'); const confEl = document.getElementById('project-facts-filter-confidence');
@@ -1032,24 +1047,40 @@ function switchProjectTab(tab) {
if (tab === 'vulns') loadProjectVulnerabilities(); if (tab === 'vulns') loadProjectVulnerabilities();
} }
async function loadProjectAssets() { async function loadProjectAssets(page) {
const tbody = document.getElementById('project-assets-tbody'); const tbody = document.getElementById('project-assets-tbody');
const countEl = document.getElementById('project-assets-count'); const countEl = document.getElementById('project-assets-count');
if (!tbody || !currentProjectId) return; if (!tbody || !currentProjectId) return;
const requestedPage = Math.max(1, Number(page || projectAssetsPagination.page || 1));
projectAssetsPagination.page = requestedPage;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loading', '加载中...'))}</td></tr>`; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loading', '加载中...'))}</td></tr>`;
const qs = new URLSearchParams({ project_id: currentProjectId, page: '1', page_size: '100' }); const qs = new URLSearchParams({
project_id: currentProjectId,
page: String(requestedPage),
page_size: String(projectAssetsPagination.pageSize),
});
const res = await apiFetch(`/api/assets?${qs.toString()}`); const res = await apiFetch(`/api/assets?${qs.toString()}`);
if (!res.ok) { if (!res.ok) {
currentProjectAssets = []; currentProjectAssets = [];
projectAssetsPagination.total = 0;
projectAssetsPagination.totalPages = 1;
if (countEl) countEl.textContent = '0'; if (countEl) countEl.textContent = '0';
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loadFailed', '加载失败'))}</td></tr>`; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loadFailed', '加载失败'))}</td></tr>`;
renderProjectAssetsPagination();
return; return;
} }
const data = await res.json(); const data = await res.json();
currentProjectAssets = data.assets || []; currentProjectAssets = data.assets || [];
projectAssetsPagination.page = Number(data.page || requestedPage);
projectAssetsPagination.total = Number(data.total || 0);
projectAssetsPagination.totalPages = Math.max(1, Number(data.total_pages || 1));
if (projectAssetsPagination.page > projectAssetsPagination.totalPages) {
return loadProjectAssets(projectAssetsPagination.totalPages);
}
if (countEl) countEl.textContent = tpFmt('projects.assetCount', `${data.total || 0} 个资产`, { count: data.total || 0 }); if (countEl) countEl.textContent = tpFmt('projects.assetCount', `${data.total || 0} 个资产`, { count: data.total || 0 });
if (!currentProjectAssets.length) { if (!currentProjectAssets.length) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('projects.noBoundAssets', '暂无绑定到此项目的资产'))}</td></tr>`; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('projects.noBoundAssets', '暂无绑定到此项目的资产'))}</td></tr>`;
renderProjectAssetsPagination();
return; return;
} }
tbody.innerHTML = currentProjectAssets.map((asset, index) => { tbody.innerHTML = currentProjectAssets.map((asset, index) => {
@@ -1065,9 +1096,50 @@ async function loadProjectAssets() {
<td>${escapeHtml(asset.source || '-')}</td> <td>${escapeHtml(asset.source || '-')}</td>
<td>${escapeHtml(updated)}</td> <td>${escapeHtml(updated)}</td>
<td><span class="asset-status asset-status--${escapeHtml(asset.status || 'active')}">${escapeHtml(status)}</span></td> <td><span class="asset-status asset-status--${escapeHtml(asset.status || 'active')}">${escapeHtml(status)}</span></td>
<td class="col-actions"><div class="projects-table-actions"><button type="button" class="projects-action-btn projects-action-btn--view" onclick="openProjectAssetDetail(${index})">${escapeHtml(tpFmt('common.view', '查看'))}</button></div></td> <td class="col-actions"><div class="projects-table-actions"><button type="button" class="projects-action-btn projects-action-btn--mute" data-require-permission="asset:write" onclick="unbindAssetFromProject(${index})" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button></div></td>
</tr>`; </tr>`;
}).join(''); }).join('');
renderProjectAssetsPagination();
const tableWrap = document.querySelector('#project-panel-assets .projects-table-wrap');
if (tableWrap) tableWrap.scrollTop = 0;
}
function renderProjectAssetsPagination() {
const root = document.getElementById('project-assets-pagination');
if (!root) return;
const { page, pageSize, total, totalPages } = projectAssetsPagination;
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
const end = total === 0 ? 0 : Math.min(page * pageSize, total);
const atFirst = page <= 1 || total === 0;
const atLast = page >= totalPages || total === 0;
root.innerHTML = `<div class="pagination">
<div class="pagination-info">
<span>${escapeHtml(tpFmt('projects.paginationShow', `显示 ${start}-${end} / 共 ${total}`, { start, end, total }))}</span>
<label class="pagination-page-size">${escapeHtml(tpFmt('projects.paginationPerPage', '每页显示'))}
<select id="project-assets-page-size" onchange="changeProjectAssetsPageSize(this.value)">
${[10, 20, 50, 100].map(size => `<option value="${size}" ${size === pageSize ? 'selected' : ''}>${size}</option>`).join('')}
</select>
</label>
</div>
<div class="pagination-controls">
<button type="button" class="btn-secondary" onclick="loadProjectAssets(1)" ${atFirst ? 'disabled' : ''}>${escapeHtml(tpFmt('skillsPage.firstPage', '首页'))}</button>
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${Math.max(1, page - 1)})" ${atFirst ? 'disabled' : ''}>${escapeHtml(tpFmt('projects.paginationPrev', '上一页'))}</button>
<span class="pagination-page">${escapeHtml(tpFmt('skillsPage.pageOf', `${page} / ${totalPages}`, { current: page, total: totalPages }))}</span>
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${Math.min(totalPages, page + 1)})" ${atLast ? 'disabled' : ''}>${escapeHtml(tpFmt('projects.paginationNext', '下一页'))}</button>
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${totalPages})" ${atLast ? 'disabled' : ''}>${escapeHtml(tpFmt('skillsPage.lastPage', '尾页'))}</button>
</div>
</div>`;
}
function changeProjectAssetsPageSize(value) {
const size = Number(value);
if (![10, 20, 50, 100].includes(size)) return;
projectAssetsPagination.pageSize = size;
projectAssetsPagination.page = 1;
try {
localStorage.setItem(PROJECT_ASSETS_PAGE_SIZE_KEY, String(size));
} catch (e) { /* ignore */ }
loadProjectAssets(1);
} }
function openProjectAssetDetail(index) { function openProjectAssetDetail(index) {
@@ -1075,6 +1147,29 @@ function openProjectAssetDetail(index) {
if (asset && typeof window.openAssetDetailRecord === 'function') window.openAssetDetailRecord(asset); if (asset && typeof window.openAssetDetailRecord === 'function') window.openAssetDetailRecord(asset);
} }
async function unbindAssetFromProject(index) {
const asset = currentProjectAssets[Number(index)];
if (!asset || !asset.id || !currentProjectId) return;
const target = asset.host || asset.domain || asset.ip || asset.id;
const message = tpFmt('projects.unbindAssetConfirm', `确定将“${target}”从当前项目解绑吗?资产不会被删除。`, { target });
if (!confirm(message)) return;
try {
const res = await apiFetch('/api/assets/project-binding', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_ids: [asset.id], project_id: '' })
});
if (!res.ok) throw new Error(await res.text());
if (typeof showInlineToast === 'function') {
showInlineToast(tpFmt('projects.unbindAssetDone', '已从项目解绑资产', { target }));
}
await loadProjectAssets(projectAssetsPagination.page);
await refreshProjectHeaderStats();
} catch (error) {
alert(`${tp('projects.unbindFailed')}: ${error.message || error}`);
}
}
let _selectedGraphFactKey = null; let _selectedGraphFactKey = null;
let _selectedGraphEdgeId = null; let _selectedGraphEdgeId = null;
let _currentGraphData = null; let _currentGraphData = null;
@@ -2807,6 +2902,7 @@ window.unbindConversationFromProject = unbindConversationFromProject;
window.loadProjectConversations = loadProjectConversations; window.loadProjectConversations = loadProjectConversations;
window.loadProjectAssets = loadProjectAssets; window.loadProjectAssets = loadProjectAssets;
window.openProjectAssetDetail = openProjectAssetDetail; window.openProjectAssetDetail = openProjectAssetDetail;
window.unbindAssetFromProject = unbindAssetFromProject;
window.loadProjectFactGraph = loadProjectFactGraph; window.loadProjectFactGraph = loadProjectFactGraph;
window.filterProjectFactGraph = filterProjectFactGraph; window.filterProjectFactGraph = filterProjectFactGraph;
window.centerProjectFactGraph = centerProjectFactGraph; window.centerProjectFactGraph = centerProjectFactGraph;
+2
View File
@@ -115,6 +115,8 @@
exportFofaResults: 'fofa:execute', exportFofaResults: 'fofa:execute',
importSelectedFofaAssets: 'asset:write', importSelectedFofaAssets: 'asset:write',
importFofaRowAsset: 'asset:write', importFofaRowAsset: 'asset:write',
openAssetImport: 'asset:write',
submitAssetImport: 'asset:write',
saveAsset: 'asset:write', saveAsset: 'asset:write',
deleteAsset: 'asset:delete', deleteAsset: 'asset:delete',
+14 -4
View File
@@ -1,5 +1,5 @@
// 页面路由管理 // 页面路由管理
let currentPage = 'dashboard'; let currentPage = null;
/** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id= */ /** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id= */
function buildHashForPage(pageId) { function buildHashForPage(pageId) {
@@ -96,6 +96,19 @@ function initRouter() {
// 切换页面 // 切换页面
function switchPage(pageId) { function switchPage(pageId) {
const targetPage = document.getElementById(`page-${pageId}`);
if (!targetPage) return;
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
// 同一页面已经激活时不再重复初始化,避免接口重复请求和页面二次重绘。
if (currentPage === pageId && targetPage.classList.contains('active')) {
const currentHash = buildHashForPage(pageId);
if (window.location.hash.slice(1) !== currentHash) {
window.location.hash = currentHash;
}
return;
}
if (typeof window.syncC2NavOnceFromServer === 'function') { if (typeof window.syncC2NavOnceFromServer === 'function') {
void window.syncC2NavOnceFromServer(); void window.syncC2NavOnceFromServer();
} }
@@ -105,8 +118,6 @@ function switchPage(pageId) {
}); });
// 显示目标页面 // 显示目标页面
const targetPage = document.getElementById(`page-${pageId}`);
if (targetPage) {
targetPage.classList.add('active'); targetPage.classList.add('active');
currentPage = pageId; currentPage = pageId;
@@ -125,7 +136,6 @@ function switchPage(pageId) {
applyRBACToUI(targetPage); applyRBACToUI(targetPage);
} }
} }
}
window.switchPage = switchPage; window.switchPage = switchPage;
// 更新导航状态 // 更新导航状态
+73 -20
View File
@@ -503,6 +503,8 @@ let toolsPagination = {
total: 0, total: 0,
totalPages: 0 totalPages: 0
}; };
let toolsLoadController = null;
let toolsLoadSequence = 0;
let c2NavSyncedOnce = false; let c2NavSyncedOnce = false;
@@ -705,12 +707,17 @@ async function loadConfig(loadTools = true, options = {}) {
// 填充FOFA配置 // 填充FOFA配置
const fofa = currentConfig.fofa || {}; const fofa = currentConfig.fofa || {};
const fofaEmailEl = document.getElementById('fofa-email');
const fofaKeyEl = document.getElementById('fofa-api-key'); const fofaKeyEl = document.getElementById('fofa-api-key');
const fofaBaseUrlEl = document.getElementById('fofa-base-url'); const fofaBaseUrlEl = document.getElementById('fofa-base-url');
if (fofaEmailEl) fofaEmailEl.value = fofa.email || '';
if (fofaKeyEl) fofaKeyEl.value = fofa.api_key || ''; if (fofaKeyEl) fofaKeyEl.value = fofa.api_key || '';
if (fofaBaseUrlEl) fofaBaseUrlEl.value = fofa.base_url || ''; if (fofaBaseUrlEl) fofaBaseUrlEl.value = fofa.base_url || '';
['zoomeye', 'quake', 'shodan'].forEach((name) => {
const cfg = currentConfig[name] || {};
const keyEl = document.getElementById(`${name}-api-key`);
const baseUrlEl = document.getElementById(`${name}-base-url`);
if (keyEl) keyEl.value = cfg.api_key || '';
if (baseUrlEl) baseUrlEl.value = cfg.base_url || '';
});
// 填充人机协同配置 // 填充人机协同配置
const hitl = currentConfig.hitl || {}; const hitl = currentConfig.hitl || {};
@@ -1080,17 +1087,28 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符 // 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
if (window.i18nReady) await window.i18nReady; if (window.i18nReady) await window.i18nReady;
const toolsList = document.getElementById('tools-list'); const toolsList = document.getElementById('tools-list');
const requestSequence = ++toolsLoadSequence;
// 显示加载状态 // 新请求接管列表,取消仍在进行的旧请求,避免连续筛选/切页时旧响应覆盖新结果。
if (toolsList) { if (toolsLoadController) {
// 清空整个容器,包括可能存在的分页控件 toolsLoadController.abort();
toolsList.innerHTML = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '</div></div>';
} }
const controller = new AbortController();
toolsLoadController = controller;
try { // 清理 DOM 之前先保留用户尚未保存的勾选状态。
// 在加载新页面之前,先保存当前页的状态到全局映射
saveCurrentPageToolStates(); saveCurrentPageToolStates();
// 首次加载才显示占位;后续刷新保留旧列表,避免整块内容闪烁和布局跳动。
if (toolsList) {
toolsList.setAttribute('aria-busy', 'true');
if (!toolsList.querySelector('.tool-item')) {
toolsList.innerHTML = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '</div></div>';
}
}
let timeoutId = null;
try {
const pageSize = toolsPagination.pageSize; const pageSize = toolsPagination.pageSize;
let url = `/api/config/tools?page=${page}&page_size=${pageSize}`; let url = `/api/config/tools?page=${page}&page_size=${pageSize}`;
if (searchKeyword) { if (searchKeyword) {
@@ -1107,13 +1125,11 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
} }
// 使用较短的超时时间(10秒),避免长时间等待 // 使用较短的超时时间(10秒),避免长时间等待
const controller = new AbortController(); timeoutId = setTimeout(() => controller.abort(), 10000);
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await apiFetch(url, { const response = await apiFetch(url, {
signal: controller.signal signal: controller.signal
}); });
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
if (typeof readApiError === 'function') { if (typeof readApiError === 'function') {
@@ -1123,6 +1139,8 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
} }
const result = await response.json(); const result = await response.json();
if (requestSequence !== toolsLoadSequence) return;
allTools = result.tools || []; allTools = result.tools || [];
toolsPagination = { toolsPagination = {
page: result.page || page, page: result.page || page,
@@ -1150,6 +1168,9 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
renderExternalMcpFilterChip(); renderExternalMcpFilterChip();
updateExternalMcpCardSelection(); updateExternalMcpCardSelection();
} catch (error) { } catch (error) {
// 被后续请求替代属于正常控制流,不显示错误,也不覆盖新请求的界面。
if (controller.signal.aborted && requestSequence !== toolsLoadSequence) return;
console.error('加载工具列表失败:', error); console.error('加载工具列表失败:', error);
if (toolsList) { if (toolsList) {
const isTimeout = error.name === 'AbortError' || error.message.includes('timeout'); const isTimeout = error.name === 'AbortError' || error.message.includes('timeout');
@@ -1158,6 +1179,12 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) {
: (typeof window.t === 'function' ? window.t('mcp.loadToolsFailed') : '加载工具列表失败') + ': ' + escapeHtml(error.message); : (typeof window.t === 'function' ? window.t('mcp.loadToolsFailed') : '加载工具列表失败') + ': ' + escapeHtml(error.message);
toolsList.innerHTML = `<div class="error" style="padding: 20px; text-align: center;">${errorMsg}</div>`; toolsList.innerHTML = `<div class="error" style="padding: 20px; text-align: center;">${errorMsg}</div>`;
} }
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
if (requestSequence === toolsLoadSequence) {
if (toolsList) toolsList.removeAttribute('aria-busy');
if (toolsLoadController === controller) toolsLoadController = null;
}
} }
} }
@@ -1287,13 +1314,13 @@ function renderToolsList() {
const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`; const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
toolItem.innerHTML = ` toolItem.innerHTML = `
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" /> <input type="checkbox" class="theme-checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
<div class="tool-item-info"> <div class="tool-item-info">
<div class="tool-item-name"> <div class="tool-item-name">
${escapeHtml(tool.name)} ${escapeHtml(tool.name)}
${externalBadge} ${externalBadge}
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()"> <label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" /> <input type="checkbox" class="theme-checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" />
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span> <span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
</label> </label>
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''} ${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
@@ -1883,10 +1910,21 @@ async function applySettings() {
}, },
vision: visionPayload, vision: visionPayload,
fofa: { fofa: {
email: document.getElementById('fofa-email')?.value.trim() || '',
api_key: document.getElementById('fofa-api-key')?.value.trim() || '', api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
base_url: document.getElementById('fofa-base-url')?.value.trim() || '' base_url: document.getElementById('fofa-base-url')?.value.trim() || ''
}, },
zoomeye: {
api_key: document.getElementById('zoomeye-api-key')?.value.trim() || '',
base_url: document.getElementById('zoomeye-base-url')?.value.trim() || ''
},
quake: {
api_key: document.getElementById('quake-api-key')?.value.trim() || '',
base_url: document.getElementById('quake-base-url')?.value.trim() || ''
},
shodan: {
api_key: document.getElementById('shodan-api-key')?.value.trim() || '',
base_url: document.getElementById('shodan-base-url')?.value.trim() || ''
},
hitl: { hitl: {
...prevHitl, ...prevHitl,
audit_model: { audit_model: {
@@ -3094,6 +3132,23 @@ async function fetchExternalMCPs() {
// MCP 管理页定时刷新外部 MCP 状态(感知后台断连/自动重连) // MCP 管理页定时刷新外部 MCP 状态(感知后台断连/自动重连)
let externalMcpPollTimer = null; let externalMcpPollTimer = null;
const EXTERNAL_MCP_POLL_INTERVAL_MS = 8000; const EXTERNAL_MCP_POLL_INTERVAL_MS = 8000;
let externalMcpRenderSignature = '';
function renderExternalMCPData(data, forceRender = false) {
const servers = data.servers || {};
const stats = data.stats || {};
const signature = JSON.stringify({ servers, stats });
if (!forceRender && signature === externalMcpRenderSignature) {
updateExternalMcpCardSelection();
return false;
}
externalMcpRenderSignature = signature;
renderExternalMCPList(servers);
renderExternalMCPStats(stats);
return true;
}
function startExternalMcpPoll() { function startExternalMcpPoll() {
stopExternalMcpPoll(); stopExternalMcpPoll();
@@ -3118,13 +3173,12 @@ function stopExternalMcpPoll() {
} }
// 加载外部MCP列表并渲染 // 加载外部MCP列表并渲染
async function loadExternalMCPs() { async function loadExternalMCPs(options = {}) {
try { try {
// 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符 // 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符
if (window.i18nReady) await window.i18nReady; if (window.i18nReady) await window.i18nReady;
const data = await fetchExternalMCPs(); const data = await fetchExternalMCPs();
renderExternalMCPList(data.servers || {}); renderExternalMCPData(data, options.forceRender === true);
renderExternalMCPStats(data.stats || {});
} catch (error) { } catch (error) {
console.error('加载外部MCP列表失败:', error); console.error('加载外部MCP列表失败:', error);
const list = document.getElementById('external-mcp-list'); const list = document.getElementById('external-mcp-list');
@@ -3150,8 +3204,7 @@ async function pollExternalMCPToolCount(name, maxAttempts = 10) {
await new Promise(r => setTimeout(r, pollIntervalMs)); await new Promise(r => setTimeout(r, pollIntervalMs));
try { try {
const data = await fetchExternalMCPs(); const data = await fetchExternalMCPs();
renderExternalMCPList(data.servers || {}); renderExternalMCPData(data);
renderExternalMCPStats(data.stats || {});
if (name != null) { if (name != null) {
const server = data.servers && data.servers[name]; const server = data.servers && data.servers[name];
if (server && server.tool_count > 0) break; if (server && server.tool_count > 0) break;
@@ -3742,7 +3795,7 @@ document.addEventListener('languagechange', function () {
const mcpPage = document.getElementById('page-mcp-management'); const mcpPage = document.getElementById('page-mcp-management');
if (mcpPage && mcpPage.classList.contains('active')) { if (mcpPage && mcpPage.classList.contains('active')) {
if (typeof loadExternalMCPs === 'function') { if (typeof loadExternalMCPs === 'function') {
loadExternalMCPs().catch(function () { /* ignore */ }); loadExternalMCPs({ forceRender: true }).catch(function () { /* ignore */ });
} }
if (typeof updateToolsStats === 'function') { if (typeof updateToolsStats === 'function') {
updateToolsStats().catch(function () { /* ignore */ }); updateToolsStats().catch(function () { /* ignore */ });
+264 -72
View File
@@ -23,7 +23,7 @@
} }
})(); })();
</script> </script>
<link rel="stylesheet" href="/static/css/style.css?v=20260716-9"> <link rel="stylesheet" href="/static/css/style.css?v=20260720-2">
<link rel="stylesheet" href="/static/css/c2.css"> <link rel="stylesheet" href="/static/css/c2.css">
<link rel="stylesheet" href="/static/vendor/xterm.css"> <link rel="stylesheet" href="/static/vendor/xterm.css">
<script src="/static/js/router.js"></script> <script src="/static/js/router.js"></script>
@@ -1703,6 +1703,8 @@
<div class="page-content asset-content"> <div class="page-content asset-content">
<div class="asset-overview-summary"> <div class="asset-overview-summary">
<section class="asset-total-card"> <section class="asset-total-card">
<div class="asset-total-card__layout">
<div class="asset-inventory-summary">
<div class="asset-total-card__heading"> <div class="asset-total-card__heading">
<div class="asset-overview-icon asset-overview-icon--blue" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2 8 4-8 4-8-4 8-4Z"/><path d="m4 10 8 4 8-4"/><path d="m4 14 8 4 8-4"/></svg></div> <div class="asset-overview-icon asset-overview-icon--blue" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 2 8 4-8 4-8-4 8-4Z"/><path d="m4 10 8 4 8-4"/><path d="m4 14 8 4 8-4"/></svg></div>
<div><span class="asset-eyebrow" data-i18n="assets.totalAssets">资产总数</span><p data-i18n="assets.totalAssetsHint">当前纳入持续监测的资产</p></div> <div><span class="asset-eyebrow" data-i18n="assets.totalAssets">资产总数</span><p data-i18n="assets.totalAssetsHint">当前纳入持续监测的资产</p></div>
@@ -1713,6 +1715,16 @@
<div class="asset-type-item asset-type-item--domain"><span class="asset-type-dot"></span><span data-i18n="assets.domainCount">域名数量</span><strong id="asset-stat-domains">0</strong></div> <div class="asset-type-item asset-type-item--domain"><span class="asset-type-dot"></span><span data-i18n="assets.domainCount">域名数量</span><strong id="asset-stat-domains">0</strong></div>
<div class="asset-type-item asset-type-item--port"><span class="asset-type-dot"></span><span data-i18n="assets.portCount">端口数量</span><strong id="asset-stat-ports">0</strong></div> <div class="asset-type-item asset-type-item--port"><span class="asset-type-dot"></span><span data-i18n="assets.portCount">端口数量</span><strong id="asset-stat-ports">0</strong></div>
</div> </div>
</div>
<div class="asset-protocol-compact">
<div class="asset-protocol-compact__header">
<div><h3 data-i18n="assets.protocolDistribution">协议分布</h3><p data-i18n="assets.protocolDistributionHint">按已识别服务查看资产暴露构成</p></div>
<span class="asset-panel-meta" id="asset-protocol-meta"></span>
</div>
<div class="asset-protocol-columns"><span data-i18n="assets.protocolName">协议</span><span data-i18n="assets.assetRatio">资产占比</span><span data-i18n="assets.assetAmount">数量</span><span data-i18n="assets.ratio">占比</span></div>
<div id="asset-protocol-chart" class="asset-bar-chart asset-bar-chart--compact" aria-live="polite"><div class="muted" data-i18n="common.noData">暂无数据</div></div>
</div>
</div>
</section> </section>
<section class="asset-recent-card"> <section class="asset-recent-card">
<div class="asset-recent-card__top"> <div class="asset-recent-card__top">
@@ -1745,29 +1757,14 @@
<section class="asset-panel asset-coverage-panel"> <section class="asset-panel asset-coverage-panel">
<div class="asset-panel-header"><div><h3 data-i18n="assets.scanCoverage">扫描覆盖</h3><p data-i18n="assets.scanCoverageHint">识别未扫描与长期未复查的资产缺口</p></div><span id="asset-coverage-status" class="asset-panel-meta"></span></div> <div class="asset-panel-header"><div><h3 data-i18n="assets.scanCoverage">扫描覆盖</h3><p data-i18n="assets.scanCoverageHint">识别未扫描与长期未复查的资产缺口</p></div><span id="asset-coverage-status" class="asset-panel-meta"></span></div>
<div class="asset-coverage-layout"> <div class="asset-coverage-layout">
<div class="asset-coverage-overview">
<div class="asset-coverage-gauge" id="asset-coverage-gauge"><div><strong id="asset-coverage-rate">0%</strong><span data-i18n="assets.overallCoverage">总体覆盖率</span></div></div> <div class="asset-coverage-gauge" id="asset-coverage-gauge"><div><strong id="asset-coverage-rate">0%</strong><span data-i18n="assets.overallCoverage">总体覆盖率</span></div></div>
</div>
<div class="asset-coverage-metrics"> <div class="asset-coverage-metrics">
<div><span data-i18n="assets.scannedAssets">已扫描资产</span><strong id="asset-coverage-scanned">0</strong><small data-i18n="assets.scannedAssetsHint">至少完成过一次扫描</small></div> <div class="asset-coverage-metric asset-coverage-metric--scanned"><span data-i18n="assets.scannedAssets">已扫描资产</span><strong id="asset-coverage-scanned">0</strong><small data-i18n="assets.scannedAssetsHint">至少完成过一次扫描</small></div>
<div><span data-i18n="assets.recentlyScanned">近 30 天覆盖</span><strong id="asset-coverage-recent">0</strong><small id="asset-coverage-recent-rate">0%</small></div> <div class="asset-coverage-metric asset-coverage-metric--recent"><span data-i18n="assets.recentlyScanned">近 30 天覆盖</span><strong id="asset-coverage-recent">0</strong><small id="asset-coverage-recent-rate">0%</small></div>
<div class="asset-coverage-gap"><span data-i18n="assets.neverScanned">从未扫描</span><strong id="asset-coverage-never">0</strong><small data-i18n="assets.neverScannedHint">建议优先纳入扫描</small></div> <div class="asset-coverage-metric asset-coverage-metric--never"><span data-i18n="assets.neverScanned">从未扫描</span><strong id="asset-coverage-never">0</strong><small data-i18n="assets.neverScannedHint">建议优先纳入扫描</small></div>
<div class="asset-coverage-gap"><span data-i18n="assets.staleScans">超过 30 天未扫描</span><strong id="asset-coverage-stale">0</strong><small data-i18n="assets.staleScansHint">扫描结果可能已过期</small></div> <div class="asset-coverage-metric asset-coverage-metric--stale"><span data-i18n="assets.staleScans">超过 30 天未扫描</span><strong id="asset-coverage-stale">0</strong><small data-i18n="assets.staleScansHint">扫描结果可能已过期</small></div>
</div>
</div>
</section>
<section class="asset-panel asset-protocol-panel">
<div class="asset-panel-header"><div><h3 data-i18n="assets.protocolDistribution">协议分布</h3><p data-i18n="assets.protocolDistributionHint">按已识别服务查看资产暴露构成</p></div><span class="asset-panel-meta" id="asset-protocol-meta"></span></div>
<div class="asset-protocol-layout">
<div id="asset-protocol-summary" class="asset-protocol-summary" aria-hidden="true">
<div class="asset-protocol-summary__label" data-i18n="assets.protocolComposition">协议构成</div>
<div class="asset-protocol-summary__main">
<div class="asset-protocol-donut"><div><strong>0%</strong><span data-i18n="assets.topProtocolShare">主要协议占比</span></div></div>
<div class="asset-protocol-lead"><span data-i18n="assets.topProtocol">主要协议</span><strong></strong><small></small></div>
</div>
<div id="asset-protocol-top-list" class="asset-protocol-top-list"></div>
</div>
<div class="asset-protocol-ranking">
<div class="asset-protocol-columns"><span data-i18n="assets.protocolRank">排名</span><span data-i18n="assets.protocolName">协议</span><span data-i18n="assets.assetRatio">资产占比</span><span data-i18n="assets.assetAmount">数量</span><span data-i18n="assets.ratio">占比</span></div>
<div id="asset-protocol-chart" class="asset-bar-chart"><div class="muted" data-i18n="common.noData">暂无数据</div></div>
</div> </div>
</div> </div>
</section> </section>
@@ -1780,29 +1777,81 @@
<div><h2 data-i18n="assets.libraryTitle">资产库</h2></div> <div><h2 data-i18n="assets.libraryTitle">资产库</h2></div>
<div class="page-header-actions"> <div class="page-header-actions">
<button class="btn-secondary" type="button" onclick="loadAssets()" data-i18n="common.refresh">刷新</button> <button class="btn-secondary" type="button" onclick="loadAssets()" data-i18n="common.refresh">刷新</button>
<button class="btn-secondary" data-require-permission="asset:write" type="button" onclick="openAssetImport()" data-i18n="assets.bulkImport">批量导入</button>
<button class="btn-primary" data-require-permission="asset:write" type="button" onclick="openAssetEditor()" data-i18n="assets.addAsset">+ 新增资产</button> <button class="btn-primary" data-require-permission="asset:write" type="button" onclick="openAssetEditor()" data-i18n="assets.addAsset">+ 新增资产</button>
</div> </div>
</div> </div>
<div class="page-content asset-content"> <div class="page-content asset-content">
<div class="asset-toolbar"> <div class="asset-toolbar">
<input id="asset-search" type="search" data-i18n="assets.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索主机、IP、域名、标题、服务或标签" onkeydown="if(event.key==='Enter')loadAssets(1)"> <div class="asset-toolbar-main">
<input id="asset-search" type="search" data-i18n="assets.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索主机、IP、域名、标题、服务、责任人或标签" onkeydown="if(event.key==='Enter')loadAssets(1)">
<select id="asset-status-filter" onchange="loadAssets(1)"><option value="" data-i18n="assets.allStatuses">全部状态</option><option value="active" data-i18n="assets.statusActive">活跃</option><option value="inactive" data-i18n="assets.statusInactive">停用</option></select> <select id="asset-status-filter" onchange="loadAssets(1)"><option value="" data-i18n="assets.allStatuses">全部状态</option><option value="active" data-i18n="assets.statusActive">活跃</option><option value="inactive" data-i18n="assets.statusInactive">停用</option></select>
<select id="asset-project-filter" onchange="loadAssets(1)"><option value="" data-i18n="assets.allProjects">全部项目</option></select> <select id="asset-project-filter" onchange="loadAssets(1)"><option value="" data-i18n="assets.allProjects">全部项目</option></select>
<button class="btn-secondary" type="button" onclick="loadAssets(1)" data-i18n="common.search">搜索</button> <button class="btn-secondary" type="button" onclick="toggleAssetAdvancedFilters()" aria-expanded="false" id="asset-advanced-toggle" data-i18n="assets.advancedFilters">高级筛选</button>
<button class="btn-primary" type="button" onclick="loadAssets(1)" data-i18n="common.search">搜索</button>
<button class="btn-link" type="button" onclick="resetAssetFilters()" data-i18n="assets.resetFilters">重置</button>
<span id="asset-list-meta" class="muted"></span> <span id="asset-list-meta" class="muted"></span>
</div> </div>
<div id="asset-advanced-filters" class="asset-advanced-filters" hidden>
<label><span data-i18n="assets.riskLevel">风险等级</span><select id="asset-risk-filter"><option value="" data-i18n="assets.allRisks">全部风险</option><option value="critical" data-i18n="assets.riskCritical">严重</option><option value="high" data-i18n="assets.riskHigh">高危</option><option value="medium" data-i18n="assets.riskMedium">中危</option><option value="low" data-i18n="assets.riskLow">低危</option><option value="info" data-i18n="assets.riskInfo">信息</option><option value="normal" data-i18n="assets.riskNormal">正常</option><option value="unassessed" data-i18n="assets.riskUnassessed">未评估</option></select></label>
<label><span data-i18n="assets.minVulnerabilityCount">漏洞数量至少</span><input id="asset-vuln-min-filter" type="number" min="0" placeholder="0"></label>
<label><span data-i18n="assets.protocol">协议</span><input id="asset-protocol-filter" type="text" placeholder="https"></label>
<label><span>端口</span><input id="asset-port-filter" type="number" min="0" max="65535" placeholder="443"></label>
<label><span data-i18n="assets.source">来源</span><input id="asset-source-filter" type="text" placeholder="fofa / manual"></label>
<label><span data-i18n="assets.tagsLabel">标签</span><input id="asset-tag-filter" type="text" placeholder="公网"></label>
<label><span data-i18n="assets.scanState">扫描状态</span><select id="asset-scan-filter"><option value="" data-i18n="assets.allScanStates">全部</option><option value="never" data-i18n="assets.neverScannedFilter">从未扫描</option><option value="30" data-i18n="assets.overdue30">30 天未扫描</option><option value="60" data-i18n="assets.overdue60">60 天未扫描</option><option value="90" data-i18n="assets.overdue90">90 天未扫描</option><option value="scanned" data-i18n="assets.scannedFilter">已扫描</option></select></label>
<label><span data-i18n="assets.country">国家/地区</span><input id="asset-country-filter" type="text" placeholder="CN"></label>
<label><span data-i18n="assets.province">省份/州</span><input id="asset-province-filter" type="text"></label>
<label><span data-i18n="assets.city">城市</span><input id="asset-city-filter" type="text"></label>
<label><span data-i18n="assets.responsiblePerson">负责人</span><input id="asset-responsible-filter" type="text"></label>
<label><span data-i18n="assets.department">部门</span><input id="asset-department-filter" type="text"></label>
<label><span data-i18n="assets.businessSystem">业务系统</span><input id="asset-business-filter" type="text"></label>
<label><span data-i18n="assets.environment">环境</span><select id="asset-environment-filter"><option value="" data-i18n="assets.allEnvironments">全部环境</option><option value="production" data-i18n="assets.environmentProduction">生产</option><option value="staging" data-i18n="assets.environmentStaging">预发布</option><option value="testing" data-i18n="assets.environmentTesting">测试</option><option value="development" data-i18n="assets.environmentDevelopment">开发</option><option value="other" data-i18n="assets.environmentOther">其他</option></select></label>
<label><span data-i18n="assets.criticality">重要性</span><select id="asset-criticality-filter"><option value="" data-i18n="assets.allCriticalities">全部级别</option><option value="critical" data-i18n="assets.criticalityCritical">核心</option><option value="high" data-i18n="assets.criticalityHigh">重要</option><option value="medium" data-i18n="assets.criticalityMedium">一般</option><option value="low" data-i18n="assets.criticalityLow"></option></select></label>
<label><span data-i18n="assets.firstSeenStart">首次发现开始</span><input id="asset-first-seen-after-filter" type="date"></label>
<label><span data-i18n="assets.firstSeenEnd">首次发现结束</span><input id="asset-first-seen-before-filter" type="date"></label>
<label><span data-i18n="assets.lastSeenStart">最近发现开始</span><input id="asset-last-seen-after-filter" type="date"></label>
<label><span data-i18n="assets.lastSeenEnd">最近发现结束</span><input id="asset-last-seen-before-filter" type="date"></label>
<label><span data-i18n="assets.sort">排序</span><select id="asset-sort-filter"><option value="last_seen_at:desc" data-i18n="assets.sortLastSeenDesc">最近发现(新到旧)</option><option value="last_scan_at:asc" data-i18n="assets.sortLastScanAsc">最久未扫描优先</option><option value="last_scan_at:desc" data-i18n="assets.sortLastScanDesc">最近扫描优先</option><option value="risk_level:desc" data-i18n="assets.sortRiskDesc">风险从高到低</option><option value="vulnerability_count:desc" data-i18n="assets.sortVulnerabilityDesc">漏洞数量从多到少</option><option value="first_seen_at:desc" data-i18n="assets.sortFirstSeenDesc">首次发现(新到旧)</option><option value="host:asc" data-i18n="assets.sortHostAsc">目标名称</option><option value="port:asc" data-i18n="assets.sortPortAsc">端口</option></select></label>
</div>
<div class="asset-saved-views">
<select id="asset-saved-view-select" onchange="applyAssetSavedView(this.value)"><option value="" data-i18n="assets.savedViews">保存的筛选视图</option></select>
<button class="btn-secondary" type="button" onclick="saveCurrentAssetView()" data-i18n="assets.saveCurrentView">保存当前视图</button>
<button class="btn-link" type="button" onclick="deleteCurrentAssetView()" data-i18n="assets.deleteView">删除视图</button>
</div>
</div>
<div id="asset-batch-actions" class="asset-batch-actions" hidden> <div id="asset-batch-actions" class="asset-batch-actions" hidden>
<span id="asset-selected-count"></span> <div class="asset-batch-selection" aria-live="polite">
<button class="btn-secondary" type="button" onclick="clearAssetSelection()" data-i18n="assets.clearSelection">取消选择</button> <span class="asset-batch-selection-mark" aria-hidden="true"></span>
<strong id="asset-selected-count"></strong>
<button class="asset-batch-text-action" type="button" onclick="clearAssetSelection()" data-i18n="assets.clearSelection">清除选择</button>
<button id="asset-select-all-results" class="asset-batch-text-action asset-batch-select-all" type="button" onclick="selectAllMatchingAssets()" hidden>选择全部结果</button>
<span id="asset-all-results-selected" class="asset-batch-all-selected" hidden></span>
</div>
<div class="asset-batch-primary-actions">
<button class="btn-secondary" data-require-permission="asset:write" type="button" onclick="openAssetProjectModal()" data-i18n="assets.bindProject">绑定项目</button> <button class="btn-secondary" data-require-permission="asset:write" type="button" onclick="openAssetProjectModal()" data-i18n="assets.bindProject">绑定项目</button>
<button class="btn-secondary" type="button" onclick="openAssetScanModal('chat')" data-i18n="assets.sendToChat">发送到对话</button> <button class="btn-secondary" data-require-permission="asset:write" type="button" onclick="openAssetBulkEdit()" data-i18n="assets.bulkEdit">批量编辑</button>
<button class="btn-primary" type="button" onclick="openAssetScanModal('task')" data-i18n="assets.createScanTask">创建扫描任务</button> <button class="btn-primary" type="button" onclick="openAssetScanModal('task')" data-i18n="assets.createScanTask">创建扫描任务</button>
<details id="asset-batch-more" class="asset-batch-more">
<summary class="btn-secondary" data-i18n="assets.moreActions">更多</summary>
<div class="asset-batch-menu" role="menu">
<button type="button" role="menuitem" onclick="closeAssetBatchMenu();openAssetScanModal('chat')" data-i18n="assets.sendToChat">发送到对话</button>
<div class="asset-batch-menu-separator" role="separator"></div>
<div class="asset-batch-menu-label" data-i18n="assets.export">导出</div>
<button type="button" role="menuitem" onclick="closeAssetBatchMenu();exportSelectedAssets('csv')" data-i18n="assets.exportCsv">导出 CSV</button>
<button type="button" role="menuitem" onclick="closeAssetBatchMenu();exportSelectedAssets('xlsx')" data-i18n="assets.exportXlsx">导出 XLSX</button>
<span class="asset-batch-permission-action" data-require-permission="asset:write"><button id="asset-merge-selected" type="button" role="menuitem" data-require-permission="asset:delete" onclick="closeAssetBatchMenu();mergeSelectedAssets()" data-i18n="assets.mergeDuplicates">合并重复资产</button></span>
<div class="asset-batch-menu-separator" role="separator"></div>
<button class="asset-batch-menu-danger" data-require-permission="asset:delete" type="button" role="menuitem" onclick="closeAssetBatchMenu();deleteSelectedAssets()" data-i18n="assets.batchDelete">批量删除…</button>
</div>
</details>
</div>
</div> </div>
<div class="asset-list-card"> <div class="asset-list-card">
<div class="asset-table-wrap"> <div class="asset-table-wrap">
<table class="asset-table"> <table class="asset-table">
<thead><tr><th class="asset-check-cell"><input id="asset-select-page" type="checkbox" onchange="toggleAssetPageSelection(this.checked)" data-i18n="assets.selectPage" data-i18n-attr="aria-label" aria-label="选择本页"></th><th data-i18n="assets.target">目标</th><th data-i18n="assets.service">服务</th><th data-i18n="assets.project">所属项目</th><th data-i18n="assets.lastScan">上次扫描</th><th data-i18n="assets.relatedVulnerabilities">相关漏洞</th><th data-i18n="assets.riskLevel">风险等级</th><th data-i18n="assets.status">状态</th><th data-i18n="infoCollect.actions">操作</th></tr></thead> <thead><tr><th class="asset-check-cell"><input id="asset-select-page" class="theme-checkbox" type="checkbox" onchange="toggleAssetPageSelection(this.checked)" data-i18n="assets.selectPage" data-i18n-attr="aria-label" aria-label="选择本页"></th><th data-i18n="assets.target">目标</th><th data-i18n="assets.service">服务</th><th data-i18n="assets.project">所属项目</th><th data-i18n="assets.ownership">归属</th><th data-i18n="assets.lastScan">上次扫描</th><th data-i18n="assets.relatedVulnerabilities">相关漏洞</th><th data-i18n="assets.riskLevel">风险等级</th><th data-i18n="assets.status">状态</th><th data-i18n="infoCollect.actions">操作</th></tr></thead>
<tbody id="asset-table-body"><tr><td colspan="9" class="muted" data-i18n="common.noData">暂无数据</td></tr></tbody> <tbody id="asset-table-body"><tr><td colspan="10" class="muted" data-i18n="common.noData">暂无数据</td></tr></tbody>
</table> </table>
</div> </div>
<div id="asset-pagination" class="pagination-container pagination-fixed"></div> <div id="asset-pagination" class="pagination-container pagination-fixed"></div>
@@ -1810,6 +1859,48 @@
</div> </div>
</div> </div>
<div id="asset-import-modal" class="modal asset-import-modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="asset-import-title" onclick="if(event.target===this)closeAssetImport()">
<div class="modal-content asset-import-content" onclick="event.stopPropagation()">
<div class="modal-header">
<div><h2 id="asset-import-title" data-i18n="assets.bulkImportTitle">批量导入资产</h2><p class="asset-page-subtitle" data-i18n="assets.bulkImportSubtitle">下载模板填写后上传,提交前会先校验并预览数据</p></div>
<button type="button" class="modal-close" onclick="closeAssetImport()" data-i18n="common.close" data-i18n-attr="aria-label" aria-label="关闭"></button>
</div>
<div class="modal-body asset-import-body">
<section class="asset-import-step">
<div class="asset-import-step-heading"><span>1</span><div><strong data-i18n="assets.downloadTemplate">下载模板</strong><small data-i18n="assets.downloadTemplateHint">推荐 XLSX;CSV 适合从其他系统快速导出</small></div></div>
<div class="asset-import-template-actions">
<button class="btn-secondary" type="button" onclick="downloadAssetTemplate('xlsx')" data-i18n="assets.downloadXlsx">下载 XLSX 模板</button>
<button class="btn-secondary" type="button" onclick="downloadAssetTemplate('csv')" data-i18n="assets.downloadCsv">下载 CSV 模板</button>
</div>
</section>
<section class="asset-import-step">
<div class="asset-import-step-heading"><span>2</span><div><strong data-i18n="assets.uploadFile">上传文件</strong><small data-i18n="assets.uploadFileHint">支持 .xlsx 和 .csv,最多 100000 行、100 MB</small></div></div>
<input id="asset-import-file" type="file" accept=".xlsx,.csv,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" hidden onchange="handleAssetImportFile(this.files && this.files[0])">
<button id="asset-import-dropzone" class="asset-import-dropzone" type="button" onclick="document.getElementById('asset-import-file').click()">
<span class="asset-import-dropzone-icon" aria-hidden="true"></span>
<strong id="asset-import-dropzone-title" data-i18n="assets.chooseOrDropFile">选择文件,或拖拽到此处</strong>
<small id="asset-import-file-name" data-i18n="assets.fileNotSelected">尚未选择文件</small>
</button>
</section>
<section id="asset-import-preview-section" class="asset-import-step asset-import-preview-section" hidden>
<div class="asset-import-step-heading"><span>3</span><div><strong data-i18n="assets.dataPreview">数据预览</strong><small id="asset-import-summary"></small></div></div>
<div class="asset-import-preview-wrap">
<table class="asset-import-preview-table">
<thead><tr><th data-i18n="assets.rowNumber">行号</th><th data-i18n="assets.target">目标</th><th data-i18n="assets.project">所属项目</th><th data-i18n="assets.status">状态</th><th data-i18n="assets.validationResult">校验结果</th></tr></thead>
<tbody id="asset-import-preview-body"></tbody>
</table>
</div>
<p id="asset-import-preview-note" class="form-hint"></p>
</section>
<div id="asset-import-error" class="asset-form-error" role="alert" hidden></div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick="closeAssetImport()" data-i18n="common.cancel">取消</button>
<button id="asset-import-submit" type="button" class="btn-primary" data-require-permission="asset:write" onclick="submitAssetImport()" disabled data-i18n="assets.importValidRows">导入有效数据</button>
</div>
</div>
</div>
<div id="asset-project-modal" class="modal asset-project-modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="asset-project-title" onclick="if(event.target===this)closeAssetProjectModal()"> <div id="asset-project-modal" class="modal asset-project-modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="asset-project-title" onclick="if(event.target===this)closeAssetProjectModal()">
<form class="modal-content asset-project-content" onsubmit="event.preventDefault();submitAssetProjectBinding()" onclick="event.stopPropagation()"> <form class="modal-content asset-project-content" onsubmit="event.preventDefault();submitAssetProjectBinding()" onclick="event.stopPropagation()">
<div class="modal-header"><div><h2 id="asset-project-title" data-i18n="assets.bindProjectTitle">绑定项目</h2><p id="asset-project-subtitle" class="asset-page-subtitle"></p></div><button type="button" class="modal-close" onclick="closeAssetProjectModal()" data-i18n="common.close" data-i18n-attr="aria-label" aria-label="关闭"></button></div> <div class="modal-header"><div><h2 id="asset-project-title" data-i18n="assets.bindProjectTitle">绑定项目</h2><p id="asset-project-subtitle" class="asset-page-subtitle"></p></div><button type="button" class="modal-close" onclick="closeAssetProjectModal()" data-i18n="common.close" data-i18n-attr="aria-label" aria-label="关闭"></button></div>
@@ -1821,6 +1912,23 @@
</form> </form>
</div> </div>
<div id="asset-bulk-edit-modal" class="modal asset-project-modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="asset-bulk-edit-title" onclick="if(event.target===this)closeAssetBulkEdit()">
<form class="modal-content asset-bulk-edit-content" onsubmit="event.preventDefault();submitAssetBulkEdit()" onclick="event.stopPropagation()">
<div class="modal-header"><div><h2 id="asset-bulk-edit-title">批量编辑资产</h2><p id="asset-bulk-edit-subtitle" class="asset-page-subtitle"></p></div><button type="button" class="modal-close" onclick="closeAssetBulkEdit()" aria-label="关闭"></button></div>
<div class="modal-body asset-editor-grid">
<label class="asset-editor-field"><span>状态(留空不修改)</span><select id="asset-bulk-status"><option value="">不修改</option><option value="active">活跃</option><option value="inactive">停用</option></select></label>
<label class="asset-editor-field"><span>负责人</span><input id="asset-bulk-responsible" type="text" placeholder="留空不修改"></label>
<label class="asset-editor-field"><span>部门</span><input id="asset-bulk-department" type="text" placeholder="留空不修改"></label>
<label class="asset-editor-field"><span>业务系统</span><input id="asset-bulk-business" type="text" placeholder="留空不修改"></label>
<label class="asset-editor-field"><span>环境(留空不修改)</span><select id="asset-bulk-environment"><option value="">不修改</option><option value="production">生产</option><option value="staging">预发布</option><option value="testing">测试</option><option value="development">开发</option><option value="other">其他</option></select></label>
<label class="asset-editor-field"><span>重要性(留空不修改)</span><select id="asset-bulk-criticality"><option value="">不修改</option><option value="critical">核心</option><option value="high">重要</option><option value="medium">一般</option><option value="low"></option></select></label>
<label class="asset-editor-field"><span>增加标签</span><input id="asset-bulk-add-tags" type="text" placeholder="逗号分隔"></label>
<label class="asset-editor-field"><span>移除标签</span><input id="asset-bulk-remove-tags" type="text" placeholder="逗号分隔"></label>
</div>
<div class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAssetBulkEdit()">取消</button><button id="asset-bulk-edit-submit" type="submit" class="btn-primary" data-require-permission="asset:write">应用修改</button></div>
</form>
</div>
<div id="asset-scan-modal" class="modal asset-scan-modal" style="display:none" role="dialog" aria-modal="true" onclick="if(event.target===this)closeAssetScanModal()"> <div id="asset-scan-modal" class="modal asset-scan-modal" style="display:none" role="dialog" aria-modal="true" onclick="if(event.target===this)closeAssetScanModal()">
<div class="modal-content asset-scan-content" onclick="event.stopPropagation()"> <div class="modal-content asset-scan-content" onclick="event.stopPropagation()">
<div class="modal-header"><div><h2 id="asset-scan-title" data-i18n="assets.scanTitle">扫描资产</h2><p id="asset-scan-subtitle" class="asset-page-subtitle"></p></div><span class="modal-close" onclick="closeAssetScanModal()">&times;</span></div> <div class="modal-header"><div><h2 id="asset-scan-title" data-i18n="assets.scanTitle">扫描资产</h2><p id="asset-scan-subtitle" class="asset-page-subtitle"></p></div><span class="modal-close" onclick="closeAssetScanModal()">&times;</span></div>
@@ -1879,6 +1987,16 @@
<label class="asset-editor-field"><span data-i18n="assets.status">状态</span><select id="asset-edit-status"><option value="active" data-i18n="assets.statusActive">活跃</option><option value="inactive" data-i18n="assets.statusInactive">停用</option></select></label> <label class="asset-editor-field"><span data-i18n="assets.status">状态</span><select id="asset-edit-status"><option value="active" data-i18n="assets.statusActive">活跃</option><option value="inactive" data-i18n="assets.statusInactive">停用</option></select></label>
</div> </div>
</div> </div>
<div class="asset-editor-section-group">
<div class="asset-editor-group-heading"><h3 data-i18n="assets.responsibilityBusiness">责任与业务属性</h3><p data-i18n="assets.responsibilityBusinessHint">用于资产归属、运营分级与风险排序</p></div>
<div class="asset-editor-grid">
<label class="asset-editor-field"><span data-i18n="assets.responsiblePerson">负责人</span><input id="asset-edit-responsible" type="text" maxlength="255"></label>
<label class="asset-editor-field"><span data-i18n="assets.department">部门</span><input id="asset-edit-department" type="text" maxlength="255"></label>
<label class="asset-editor-field"><span data-i18n="assets.businessSystem">业务系统</span><input id="asset-edit-business" type="text" maxlength="255"></label>
<label class="asset-editor-field"><span data-i18n="assets.environment">环境</span><select id="asset-edit-environment"><option value="">未设置</option><option value="production" data-i18n="assets.environmentProduction">生产</option><option value="staging" data-i18n="assets.environmentStaging">预发布</option><option value="testing" data-i18n="assets.environmentTesting">测试</option><option value="development" data-i18n="assets.environmentDevelopment">开发</option><option value="other" data-i18n="assets.environmentOther">其他</option></select></label>
<label class="asset-editor-field"><span data-i18n="assets.criticality">重要性</span><select id="asset-edit-criticality"><option value="">未设置</option><option value="critical" data-i18n="assets.criticalityCritical">核心</option><option value="high" data-i18n="assets.criticalityHigh">重要</option><option value="medium" data-i18n="assets.criticalityMedium">一般</option><option value="low" data-i18n="assets.criticalityLow"></option></select></label>
</div>
</div>
</section> </section>
<div id="asset-editor-form-error" class="asset-form-error" role="alert" hidden></div> <div id="asset-editor-form-error" class="asset-form-error" role="alert" hidden></div>
</div> </div>
@@ -1908,25 +2026,50 @@
<div class="page-content"> <div class="page-content">
<div class="info-collect-panel"> <div class="info-collect-panel">
<div class="info-collect-form"> <div class="info-collect-form">
<div class="form-group"> <div class="info-collect-form-top">
<label for="fofa-query" data-i18n="infoCollectPage.fofaQuerySyntax">FOFA 查询语法</label> <div class="info-collect-provider-card form-group">
<textarea id="fofa-query" class="info-collect-query-input" rows="1" data-i18n="infoCollect.queryPlaceholder" data-i18n-attr="placeholder" placeholder='例如:app="Apache" && country="CN"'></textarea> <div class="info-collect-step-title">
<small class="form-hint" data-i18n="infoCollectPage.formHint">查询语法参考 FOFA 文档,支持 && / || / () 等。</small> <span>01</span>
<div class="info-collect-presets" aria-label="FOFA 查询示例" data-i18n="infoCollectPage.queryPresetsAria" data-i18n-attr="aria-label" data-i18n-skip-text="true"> <label for="fofa-provider">数据源</label>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('app=&quot;Apache&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetApache" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">Apache + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('title=&quot;登录&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetLogin" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">登录页 + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('domain=&quot;example.com&quot;')" data-i18n="infoCollectPage.presetDomain" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">指定域名</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip=&quot;1.1.1.1&quot;')" data-i18n="infoCollectPage.presetIp" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">指定 IP</button>
</div> </div>
<select id="fofa-provider" onchange="handleInfoCollectProviderChange()">
<option value="fofa">FOFA</option>
<option value="zoomeye">ZoomEye</option>
<option value="quake">Quake</option>
<option value="shodan">Shodan</option>
</select>
<small class="form-hint">支持 FOFA、ZoomEye、Quake、ShodanAPI Key 可在系统设置或环境变量中配置。</small>
</div> </div>
<div class="form-group"> <div class="info-collect-query-card form-group">
<label for="fofa-nl" data-i18n="infoCollectPage.naturalLanguage">自然语言(AI 解析为 FOFA 语法)</label> <div class="info-collect-step-title">
<span>02</span>
<div>查询方式</div>
</div>
<div id="info-collect-natural-panel" class="info-collect-query-mode-panel info-collect-natural-panel">
<label for="fofa-nl" id="info-collect-nl-label" class="info-collect-inner-label">自然语言(可选,AI 解析为查询语法)</label>
<div class="info-collect-nl-row"> <div class="info-collect-nl-row">
<textarea id="fofa-nl" class="info-collect-query-input" rows="1" data-i18n="infoCollectPage.nlPlaceholder" data-i18n-attr="placeholder" placeholder="例如:找美国 Missouri 的 Apache 站点,标题包含 Home"></textarea> <textarea id="fofa-nl" class="info-collect-query-input" rows="1" placeholder="例如:找中国的 Apache 站点"></textarea>
<button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" data-i18n="infoCollectPage.parseBtn" data-i18n-attr="title" data-i18n-title="infoCollectPage.parseBtnTitle" title="将自然语言解析为 FOFA 查询语法">AI 解析</button> <button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" data-i18n="infoCollectPage.parseBtn" data-i18n-attr="title" data-i18n-title="infoCollectPage.parseBtnTitle" title="将自然语言解析为 FOFA 查询语法">AI 解析</button>
</div> </div>
<div id="fofa-nl-status" class="fofa-nl-status muted" style="display: none;" aria-live="polite"></div> <div id="fofa-nl-status" class="fofa-nl-status muted" style="display: none;" aria-live="polite"></div>
<small class="form-hint" data-i18n="infoCollectPage.parseHint">解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。</small> <small class="form-hint" id="info-collect-parse-hint">解析后会弹窗展示对应数据源语法(可编辑),确认无误后再填入查询框并执行查询。</small>
</div>
<div id="info-collect-syntax-panel" class="info-collect-query-mode-panel is-active">
<label for="fofa-query" id="info-collect-query-label" class="info-collect-inner-label">查询语法(可编辑,可直接查询)</label>
<div class="info-collect-query-row">
<textarea id="fofa-query" class="info-collect-query-input" rows="1" placeholder='例如:app="Apache" && country="CN"'></textarea>
<button id="fofa-query-submit-btn" class="btn-primary" data-require-permission="fofa:execute" type="button" onclick="submitFofaSearch()">查询</button>
</div>
<small class="form-hint" id="info-collect-query-hint">选择数据源后会显示对应查询语法提示。</small>
<div class="info-collect-presets" id="info-collect-query-presets" aria-label="查询示例"></div>
<div class="info-collect-syntax-guide" id="info-collect-syntax-guide" hidden></div>
</div>
</div>
</div>
<div class="info-collect-options-card">
<div class="info-collect-step-title">
<span>03</span>
<div>返回配置</div>
</div> </div>
<div class="info-collect-form-row"> <div class="info-collect-form-row">
<div class="form-group"> <div class="form-group">
@@ -1937,22 +2080,23 @@
<label for="fofa-page" data-i18n="infoCollectPage.pageNum">页码</label> <label for="fofa-page" data-i18n="infoCollectPage.pageNum">页码</label>
<input type="number" id="fofa-page" min="1" value="1" /> <input type="number" id="fofa-page" min="1" value="1" />
</div> </div>
<div class="form-group"> <div class="form-group" id="info-collect-full-option">
<label class="checkbox-label" style="margin-top: 24px;"> <label class="checkbox-label">
<input type="checkbox" id="fofa-full" class="modern-checkbox" /> <input type="checkbox" id="fofa-full" class="modern-checkbox" />
<span class="checkbox-custom"></span> <span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="infoCollectPage.fullLabel">full</span> <span class="checkbox-text" data-i18n="infoCollectPage.fullLabel">full</span>
</label> </label>
</div> </div>
<small class="form-hint info-collect-row-hint" id="info-collect-size-hint">不同数据源的单次返回上限可能不同。</small>
</div> </div>
<div class="form-group"> </div>
<div class="info-collect-fields-card form-group">
<div class="info-collect-step-title">
<span>04</span>
<label for="fofa-fields" data-i18n="infoCollectPage.returnFields">返回字段名(逗号分隔)</label> <label for="fofa-fields" data-i18n="infoCollectPage.returnFields">返回字段名(逗号分隔)</label>
<input type="text" id="fofa-fields" value="host,ip,port,domain,title,protocol,country,province,city,server" />
<div class="info-collect-presets" aria-label="FOFA 字段模板" data-i18n="infoCollectPage.fieldsPresetsAria" data-i18n-attr="aria-label" data-i18n-skip-text="true">
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain')" data-i18n="infoCollectPage.minFields" data-i18n-attr="title" data-i18n-title="infoCollectPage.minFieldsTitle" title="适合快速导出目标">最小字段</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,title,ip,port,domain,protocol,server,icp,country,province,city')" data-i18n="infoCollectPage.webCommon" data-i18n-attr="title" data-i18n-title="infoCollectPage.webCommonTitle" title="适合浏览和筛选">Web 常用</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner')" data-i18n="infoCollectPage.intelEnhanced" data-i18n-attr="title" data-i18n-title="infoCollectPage.intelEnhancedTitle" title="更偏指纹/情报">情报增强</button>
</div> </div>
<input type="text" id="fofa-fields" value="host,ip,port,domain,title,protocol,country,province,city,server" />
<div class="info-collect-presets" id="info-collect-fields-presets" aria-label="字段模板"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -2222,12 +2366,13 @@
</p> </p>
<span id="project-assets-count" class="projects-panel-count">0</span> <span id="project-assets-count" class="projects-panel-count">0</span>
</div> </div>
<div class="projects-table-wrap"> <div class="projects-table-wrap projects-table-wrap--with-pagination">
<table class="data-table data-table--projects"> <table class="data-table data-table--projects">
<thead><tr><th data-i18n="assets.target">目标</th><th data-i18n="assets.service">服务</th><th data-i18n="assets.title">标题/指纹</th><th data-i18n="assets.source">来源</th><th data-i18n="assets.lastSeen">最近发现</th><th data-i18n="assets.status">状态</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead> <thead><tr><th data-i18n="assets.target">目标</th><th data-i18n="assets.service">服务</th><th data-i18n="assets.title">标题/指纹</th><th data-i18n="assets.source">来源</th><th data-i18n="assets.lastSeen">最近发现</th><th data-i18n="assets.status">状态</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
<tbody id="project-assets-tbody"></tbody> <tbody id="project-assets-tbody"></tbody>
</table> </table>
</div> </div>
<div id="project-assets-pagination" class="pagination-container pagination-fixed projects-panel-pagination"></div>
</div> </div>
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden> <div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
<div class="projects-panel-toolbar projects-panel-toolbar--hint"> <div class="projects-panel-toolbar projects-panel-toolbar--hint">
@@ -3183,7 +3328,7 @@
<span data-i18n="settings.nav.hitl">人机协同</span> <span data-i18n="settings.nav.hitl">人机协同</span>
</div> </div>
<div class="settings-nav-item" data-section="infocollect" onclick="switchSettingsSection('infocollect')"> <div class="settings-nav-item" data-section="infocollect" onclick="switchSettingsSection('infocollect')">
<span data-i18n="settings.nav.infocollect">信息收集</span> <span data-i18n="settings.nav.infocollect">资产管理</span>
</div> </div>
<div class="settings-nav-item" data-section="knowledge" onclick="switchSettingsSection('knowledge')"> <div class="settings-nav-item" data-section="knowledge" onclick="switchSettingsSection('knowledge')">
<span data-i18n="settings.nav.knowledge">知识库</span> <span data-i18n="settings.nav.knowledge">知识库</span>
@@ -3251,17 +3396,17 @@
<small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small> <small style="color: var(--text-muted, #718096); font-size: 0.75rem;" data-i18n="settingsBasic.maxTotalTokensHint">内存压缩和攻击链构建共用此配置,默认 120000</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label data-i18n="settingsBasic.openaiReasoningTitle">模型推理Eino</label> <label data-i18n="settingsBasic.openaiReasoningTitle">模型推理</label>
<small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">仅影响 Eino 单代理与多代理;对话页可覆盖(见下方「允许对话覆盖」)</small> <small class="form-hint" data-i18n="settingsBasic.openaiReasoningHint">与对话页「模型推理」下拉配合使用</small>
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;"> <div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; align-items: center;">
<label for="openai-reasoning-mode" style="font-size: 0.8125rem;" data-i18n="chat.reasoningModeLabel">模式</label> <label for="openai-reasoning-mode" style="font-size: 0.8125rem;" data-i18n="chat.reasoningModeLabel">模式</label>
<select id="openai-reasoning-mode" style="min-width: 120px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);"> <select id="openai-reasoning-mode" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option> <option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
<option value="on" data-i18n="chat.reasoningModeOn">开启</option> <option value="on" data-i18n="chat.reasoningModeOn">开启</option>
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option> <option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
</select> </select>
<label for="openai-reasoning-effort" style="font-size: 0.8125rem;" data-i18n="chat.reasoningEffortLabel">强度</label> <label for="openai-reasoning-effort" style="font-size: 0.8125rem;" data-i18n="chat.reasoningEffortLabel">强度</label>
<select id="openai-reasoning-effort" style="min-width: 100px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);"> <select id="openai-reasoning-effort" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option> <option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
<option value="low">low</option> <option value="low">low</option>
<option value="medium">medium</option> <option value="medium">medium</option>
@@ -3270,7 +3415,7 @@
<option value="max">max</option> <option value="max">max</option>
</select> </select>
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label> <label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
<select id="openai-reasoning-profile" style="min-width: 140px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);"> <select id="openai-reasoning-profile" style="min-width: 220px; padding: 0.35rem 0.5rem; border-radius: 6px; border: 1px solid var(--border-color, #e2e8f0);">
<option value="auto">auto</option> <option value="auto">auto</option>
<option value="deepseek_compat">deepseek_compat</option> <option value="deepseek_compat">deepseek_compat</option>
<option value="openai_compat">openai_compat</option> <option value="openai_compat">openai_compat</option>
@@ -3440,7 +3585,6 @@
</details> </details>
</div> </div>
</div> </div>
<div class="settings-actions"> <div class="settings-actions">
<button class="btn-primary" onclick="applySettings()" data-require-permission="config:write" data-i18n="settings.apply.button">应用配置</button> <button class="btn-primary" onclick="applySettings()" data-require-permission="config:write" data-i18n="settings.apply.button">应用配置</button>
</div> </div>
@@ -3533,25 +3677,22 @@
</div> </div>
</div> </div>
<!-- 信息收集设置 --> <!-- 资产管理设置 -->
<div id="settings-section-infocollect" class="settings-section-content"> <div id="settings-section-infocollect" class="settings-section-content">
<div class="settings-section-header"> <div class="settings-section-header">
<h3 data-i18n="settings.infocollect.title">信息收集</h3> <h3 data-i18n="settings.infocollect.title">资产管理</h3>
</div> </div>
<!-- FOFA配置 --> <!-- FOFA配置 -->
<div class="settings-subsection"> <div class="settings-subsection">
<h4 data-i18n="settingsBasic.fofaConfig">FOFA 配置</h4> <h4 data-i18n="settingsBasic.fofaConfig">FOFA 配置</h4>
<p class="form-hint" data-i18n="settingsBasic.fofaConfigHint">用于资产发现与导入,仅需配置 API Key。</p>
<div class="settings-form"> <div class="settings-form">
<div class="form-group"> <div class="form-group">
<label for="fofa-base-url">Base URL</label> <label for="fofa-base-url">Base URL</label>
<input type="text" id="fofa-base-url" data-i18n="settingsBasic.fofaBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://fofa.info/api/v1/search/all(可选)" /> <input type="text" id="fofa-base-url" data-i18n="settingsBasic.fofaBaseUrlPlaceholder" data-i18n-attr="placeholder" placeholder="https://fofa.info/api/v1/search/all(可选)" />
<small class="form-hint" data-i18n="settingsBasic.fofaBaseUrlHint">留空则使用默认地址。</small> <small class="form-hint" data-i18n="settingsBasic.fofaBaseUrlHint">留空则使用默认地址。</small>
</div> </div>
<div class="form-group">
<label for="fofa-email" data-i18n="settingsBasic.email">Email</label>
<input type="text" id="fofa-email" data-i18n="settingsBasic.fofaEmailPlaceholder" data-i18n-attr="placeholder" placeholder="输入 FOFA 账号邮箱" autocomplete="off" />
</div>
<div class="form-group"> <div class="form-group">
<label for="fofa-api-key">API Key</label> <label for="fofa-api-key">API Key</label>
<input type="password" id="fofa-api-key" data-i18n="settingsBasic.fofaApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入 FOFA API Key" autocomplete="off" /> <input type="password" id="fofa-api-key" data-i18n="settingsBasic.fofaApiKeyPlaceholder" data-i18n-attr="placeholder" placeholder="输入 FOFA API Key" autocomplete="off" />
@@ -3560,6 +3701,57 @@
</div> </div>
</div> </div>
<div class="settings-subsection">
<h4>ZoomEye 配置</h4>
<p class="form-hint">用于信息收集页调用 ZoomEye API;也可设置环境变量 ZOOMEYE_API_KEY。</p>
<div class="settings-form">
<div class="form-group">
<label for="zoomeye-base-url">Base URL</label>
<input type="text" id="zoomeye-base-url" placeholder="https://api.zoomeye.org/v2/search(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
<label for="zoomeye-api-key">API Key</label>
<input type="password" id="zoomeye-api-key" placeholder="输入 ZoomEye API Key" autocomplete="off" />
<small class="form-hint">仅保存在服务器配置中(config.yaml)。</small>
</div>
</div>
</div>
<div class="settings-subsection">
<h4>Quake 配置</h4>
<p class="form-hint">用于信息收集页调用 360 Quake API;也可设置环境变量 QUAKE_API_KEY。</p>
<div class="settings-form">
<div class="form-group">
<label for="quake-base-url">Base URL</label>
<input type="text" id="quake-base-url" placeholder="https://quake.360.cn/api/v3/search/quake_service(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
<label for="quake-api-key">API Token</label>
<input type="password" id="quake-api-key" placeholder="输入 Quake API Token" autocomplete="off" />
<small class="form-hint">仅保存在服务器配置中(config.yaml)。</small>
</div>
</div>
</div>
<div class="settings-subsection">
<h4>Shodan 配置</h4>
<p class="form-hint">用于信息收集页调用 Shodan API;也可设置环境变量 SHODAN_API_KEY。</p>
<div class="settings-form">
<div class="form-group">
<label for="shodan-base-url">Base URL</label>
<input type="text" id="shodan-base-url" placeholder="https://api.shodan.io(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
<label for="shodan-api-key">API Key</label>
<input type="password" id="shodan-api-key" placeholder="输入 Shodan API Key" autocomplete="off" />
<small class="form-hint">仅保存在服务器配置中(config.yaml)。</small>
</div>
</div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<button type="button" class="btn-primary" onclick="applySettings()" data-require-permission="config:write" data-i18n="settings.apply.button">应用配置</button> <button type="button" class="btn-primary" onclick="applySettings()" data-require-permission="config:write" data-i18n="settings.apply.button">应用配置</button>
</div> </div>
@@ -5385,7 +5577,7 @@
<div class="batch-conversations-table"> <div class="batch-conversations-table">
<div class="batch-table-header"> <div class="batch-table-header">
<div class="batch-table-col-checkbox"> <div class="batch-table-col-checkbox">
<input type="checkbox" id="batch-select-all" onchange="toggleSelectAllBatch()" data-i18n="batchManageModal.selectAll" data-i18n-attr="title" title="全选" /> <input type="checkbox" id="batch-select-all" class="theme-checkbox" onchange="toggleSelectAllBatch()" data-i18n="batchManageModal.selectAll" data-i18n-attr="title" title="全选" />
</div> </div>
<div class="batch-table-col-name" data-i18n="batchManageModal.conversationName">对话名称</div> <div class="batch-table-col-name" data-i18n="batchManageModal.conversationName">对话名称</div>
<div class="batch-table-col-project" data-i18n="batchManageModal.project">项目</div> <div class="batch-table-col-project" data-i18n="batchManageModal.project">项目</div>
@@ -6284,15 +6476,15 @@
<script src="/static/js/auth.js"></script> <script src="/static/js/auth.js"></script>
<script src="/static/js/modal.js"></script> <script src="/static/js/modal.js"></script>
<script src="/static/js/notifications.js"></script> <script src="/static/js/notifications.js"></script>
<script src="/static/js/info-collect.js"></script> <script src="/static/js/info-collect.js?v=20260717-1"></script>
<script src="/static/js/assets.js?v=20260716-10"></script> <script src="/static/js/assets.js?v=20260717-7"></script>
<script src="/static/js/agents.js"></script> <script src="/static/js/agents.js"></script>
<script src="/static/js/dashboard.js"></script> <script src="/static/js/dashboard.js"></script>
<script src="/static/js/chat-scroll.js"></script> <script src="/static/js/chat-scroll.js"></script>
<script src="/static/js/monitor.js"></script> <script src="/static/js/monitor.js?v=20260717-1"></script>
<script src="/static/js/chat.js"></script> <script src="/static/js/chat.js?v=20260717-1"></script>
<script src="/static/js/hitl.js"></script> <script src="/static/js/hitl.js"></script>
<script src="/static/js/settings.js"></script> <script src="/static/js/settings.js?v=20260717-1"></script>
<script src="/static/js/audit-datetime-picker.js"></script> <script src="/static/js/audit-datetime-picker.js"></script>
<script src="/static/js/audit.js"></script> <script src="/static/js/audit.js"></script>
<script src="/static/js/wechat-robot.js"></script> <script src="/static/js/wechat-robot.js"></script>
@@ -6302,7 +6494,7 @@
<script src="/static/js/knowledge.js"></script> <script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script> <script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.js"></script> <script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js"></script> <script src="/static/js/projects.js?v=20260717-1"></script>
<script src="/static/js/vulnerability.js?v=14"></script> <script src="/static/js/vulnerability.js?v=14"></script>
<script src="/static/js/webshell.js"></script> <script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script> <script src="/static/js/chat-files.js"></script>
@@ -6312,6 +6504,6 @@
<script src="/static/js/roles.js"></script> <script src="/static/js/roles.js"></script>
<script src="/static/js/rbac.js"></script> <script src="/static/js/rbac.js"></script>
<script src="/static/js/c2.js"></script> <script src="/static/js/c2.js"></script>
<script src="/static/js/rbac-guards.js"></script> <script src="/static/js/rbac-guards.js?v=20260717-1"></script>
</body> </body>
</html> </html>