Compare commits

..
13 Commits
Author SHA1 Message Date
公明andGitHub 5d1f5d2886 Add files via upload 2026-07-31 21:24:09 +08:00
公明andGitHub 0f92817261 Add files via upload 2026-07-31 21:21:47 +08:00
公明andGitHub 2ec5953416 Add files via upload 2026-07-31 21:19:50 +08:00
公明andGitHub b4fe2e795e Add files via upload 2026-07-31 21:18:21 +08:00
公明andGitHub 904d860797 Add files via upload 2026-07-31 21:15:42 +08:00
公明andGitHub dc08199af6 Add files via upload 2026-07-31 21:14:10 +08:00
公明andGitHub 84e99220ff Add files via upload 2026-07-31 21:12:01 +08:00
公明andGitHub 86f1d10a8b Add files via upload 2026-07-31 21:09:45 +08:00
公明andGitHub 5c643a1606 Add files via upload 2026-07-31 21:06:22 +08:00
公明andGitHub b9854192c6 Add files via upload 2026-07-31 21:04:11 +08:00
公明andGitHub 5a5762e1d1 Add files via upload 2026-07-29 17:50:38 +08:00
公明andGitHub 8882d70393 Add files via upload 2026-07-29 11:43:53 +08:00
公明andGitHub 5747ebc612 Add files via upload 2026-07-29 11:42:23 +08:00
47 changed files with 2545 additions and 72 deletions
+43 -11
View File
@@ -34,7 +34,39 @@ Saved workflows can be bound to a role under **Role Management**. When `workflow
---
## 3. Execution model (read this before configuring)
## 3. Natural-language Draft Generation
The Workflows page provides a **Create from natural language** entry point. After a user describes a security operations goal, CyberStrikeAI generates an editable draft and returns a structured audit result:
- Draft generation does not save, dry-run, or execute tools automatically.
- The server endpoint is `POST /api/workflows/generate-draft`, protected by `workflow:write`.
- The response includes `graph`, `meta`, `capabilities`, `audit`, and `stats`; after applying the draft to the canvas, normal save validation still runs.
- High-risk language such as executing scripts, isolating hosts, blocking, deleting, or exploitation is marked as `high_risk` and defaults to HITL approval or `requires_human_confirmation`.
- Tool capabilities are matched against the available tool list; unmatched capabilities fall back to Agent draft nodes and are surfaced in `audit.missing_fields` / `audit.assumptions`.
- If server generation is unavailable, the frontend uses a local deterministic fallback and still returns an editable draft with risk notes.
Example request:
```http
POST /api/workflows/generate-draft
Content-Type: application/json
{
"prompt": "Scan target assets for open ports, create a vulnerability task when critical ports are found, ask the owner for approval, and output a report",
"options": {
"include_objective": true,
"allow_schedule": false,
"allow_high_risk": false
},
"available_tools": [
{ "key": "nmap", "name": "nmap", "enabled": true }
]
}
```
---
## 4. Execution model (read this before configuring)
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
@@ -116,7 +148,7 @@ Agent B still receives Agent As output even when a condition node lies betwee
---
## 4. Template syntax
## 5. Template syntax
### 4.1 Basic format
@@ -198,7 +230,7 @@ Field bindings can read ordinary fields such as `output` or `message`, and also
---
## 5. Node types and configuration
## 6. Node types and configuration
### 5.1 Start
@@ -318,7 +350,7 @@ Optional node for an end summary template (less common in role-bound flows).
---
## 6. Edge configuration
## 7. Edge configuration
Select an **edge** to configure its **condition** in the right panel.
@@ -335,7 +367,7 @@ If no edge condition is set:
---
## 7. Full example: passing Agent output across a condition
## 8. Full example: passing Agent output across a condition
### 7.1 Graph structure
@@ -384,7 +416,7 @@ Start → Agent (initial value) → Condition → Agent (transform) → Output
---
## 8. Bind to a role and run
## 9. Bind to a role and run
### 8.1 Bind in Role Management
@@ -414,7 +446,7 @@ If no Output node is reached or no branch matches, `outputs` may be empty and th
---
## 9. Debugging, dry-run, and replay
## 10. Debugging, dry-run, and replay
### 9.1 Safe dry-run
@@ -487,7 +519,7 @@ Token and cost metrics depend on whether the underlying model/Agent events repor
---
## 10. Validation before save
## 11. Validation before save
On save, the system checks:
@@ -510,7 +542,7 @@ On save, the system checks:
---
## 11. Troubleshooting
## 12. Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
@@ -526,7 +558,7 @@ On save, the system checks:
---
## 12. Best practices
## 13. Best practices
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
@@ -539,7 +571,7 @@ On save, the system checks:
---
## 13. Code references (for developers)
## 14. Code references (for developers)
| Module | Path |
|--------|------|
+43 -11
View File
@@ -34,7 +34,39 @@
---
## 三、执行模型(先理解再配置)
## 三、自然语言生成草稿
工作流页面提供 **用自然语言创建** 入口。用户描述一句安全作业目标后,平台会生成一个可编辑草稿,并返回结构化审计结果:
- 只生成草稿,不会自动保存、试运行或真实执行工具。
- 服务端接口为 `POST /api/workflows/generate-draft`,权限为 `workflow:write`
- 生成结果包含 `graph``meta``capabilities``audit``stats`,前端应用到画布后仍走现有保存校验。
- 高风险语义(执行脚本、隔离、封禁、删除、利用等)会标记 `high_risk`,并默认插入 HITL 审批或 `requires_human_confirmation`
- 工具能力按已有工具列表匹配;未匹配到时降级为 Agent 草稿,并在 `audit.missing_fields` / `audit.assumptions` 中提示需要补配置。
- 如果服务端生成不可用,前端会使用本地确定性兜底生成器,继续给出可编辑草稿和风险提示。
示例请求:
```http
POST /api/workflows/generate-draft
Content-Type: application/json
{
"prompt": "",
"options": {
"include_objective": true,
"allow_schedule": false,
"allow_high_risk": false
},
"available_tools": [
{ "key": "nmap", "name": "nmap", "enabled": true }
]
}
```
---
## 四、执行模型(先理解再配置)
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
@@ -116,7 +148,7 @@ outputs["你填的变量名"] = 节点输出内容
---
## 、模板语法
## 、模板语法
### 4.1 基本格式
@@ -198,7 +230,7 @@ jq({{outputs.scan}}, ".severity") == "high"
---
## 、节点类型与配置
## 、节点类型与配置
### 5.1 开始(start
@@ -318,7 +350,7 @@ HITL 等待信息会记录:
---
## 、连线配置
## 、连线配置
选中 **连线** 后,右侧可配置 **连线条件**
@@ -335,7 +367,7 @@ HITL 等待信息会记录:
---
## 、完整示例:跨条件节点传递 Agent 输出
## 、完整示例:跨条件节点传递 Agent 输出
### 7.1 流程结构
@@ -384,7 +416,7 @@ HITL 等待信息会记录:
---
## 、绑定角色并运行
## 、绑定角色并运行
### 8.1 在角色管理中绑定
@@ -414,7 +446,7 @@ workflow_policy: auto
---
## 、调试、试运行与复盘
## 、调试、试运行与复盘
### 9.1 安全试运行(dry-run
@@ -487,7 +519,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、保存前校验规则
## 十、保存前校验规则
保存时系统会自动检查:
@@ -510,7 +542,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、排错指南
## 十、排错指南
| 现象 | 可能原因 | 处理建议 |
|------|----------|----------|
@@ -526,7 +558,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、最佳实践
## 十、最佳实践
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result``parsed_targets`,避免都叫 `agent_result`
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
@@ -539,7 +571,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、相关代码位置(开发者参考)
## 十、相关代码位置(开发者参考)
| 模块 | 路径 |
|------|------|
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+1
View File
@@ -1362,6 +1362,7 @@ func setupRoutes(
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
protected.POST("/workflows/validate", workflowHandler.Validate)
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
protected.POST("/workflows/generate-draft", workflowHandler.GenerateDraft)
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
+150 -8
View File
@@ -13,6 +13,7 @@ import (
"unicode/utf8"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/net/idna"
)
@@ -50,6 +51,7 @@ type Asset struct {
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
VulnerabilityCount int `json:"vulnerability_count"`
RiskLevel string `json:"risk_level"`
RiskScore int `json:"-"`
OwnerUserID string `json:"-"`
}
@@ -443,15 +445,15 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
args = append(args, *filter.Port)
}
if filter.RiskLevel != "" {
query += " AND " + assetRiskLevelExpr + " = ?"
query += " AND " + assetRiskLevelCachedExpr + " = ?"
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
}
if filter.MinVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
query += " AND " + assetVulnerabilityCountCachedExpr + " >= ?"
args = append(args, *filter.MinVulnerabilities)
}
if filter.MaxVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
query += " AND " + assetVulnerabilityCountCachedExpr + " <= ?"
args = append(args, *filter.MaxVulnerabilities)
}
for _, item := range []struct {
@@ -573,20 +575,24 @@ const assetVulnerabilityMatchExpr = `(
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
const assetRiskScoreExpr = `COALESCE((
const assetRiskScoreQueryExpr = `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)
FROM vulnerabilities v
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
),0)`
const assetRiskLevelExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreExpr + `
const assetRiskLevelQueryExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreQueryExpr + `
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 assetVulnerabilityCountCachedExpr = `COALESCE(assets.vulnerability_count,0)`
const assetRiskScoreCachedExpr = `COALESCE(assets.risk_score,0)`
const assetRiskLevelCachedExpr = `COALESCE(NULLIF(assets.risk_level,''),'unassessed')`
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
` + assetVulnerabilityCountCachedExpr + `,` + assetRiskLevelCachedExpr
// 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.
@@ -601,6 +607,9 @@ func (db *DB) MarkAssetScanned(id, conversationID, queueID, taskID string, acces
if n == 0 {
return sql.ErrNoRows
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
return nil
}
@@ -629,6 +638,9 @@ func (db *DB) CompleteAssetScan(id, conversationID string, access RBACListAccess
if n == 0 {
return sql.ErrNoRows
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
return nil
}
@@ -638,6 +650,136 @@ func (db *DB) BatchTaskBelongsToQueue(taskID, queueID string) bool {
return err == nil && count > 0
}
func assetRiskLevelFromScore(score int, scanned bool) string {
if !scanned {
return "unassessed"
}
switch score {
case 5:
return "critical"
case 4:
return "high"
case 3:
return "medium"
case 2:
return "low"
case 1:
return "info"
default:
return "normal"
}
}
// RefreshAssetRiskCache recalculates the denormalized fields used by the asset
// list. Keeping this in the database layer makes Web API and MCP writes share
// one consistency path.
func (db *DB) RefreshAssetRiskCache(assetID string) error {
assetID = strings.TrimSpace(assetID)
if assetID == "" {
return nil
}
var count int
if err := db.QueryRow("SELECT "+assetVulnerabilityCountExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&count); err != nil {
if err == sql.ErrNoRows {
return nil
}
return fmt.Errorf("刷新资产漏洞数量失败: %w", err)
}
var score int
if err := db.QueryRow("SELECT "+assetRiskScoreQueryExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&score); err != nil {
return fmt.Errorf("刷新资产风险分数失败: %w", err)
}
var lastScan interface{}
if err := db.QueryRow("SELECT "+assetEffectiveLastScanExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&lastScan); err != nil {
return fmt.Errorf("刷新资产扫描状态失败: %w", err)
}
level := assetRiskLevelFromScore(score, lastScan != nil)
if _, err := db.Exec(`UPDATE assets SET vulnerability_count=?, risk_score=?, risk_level=? WHERE id=?`, count, score, level, assetID); err != nil {
return fmt.Errorf("更新资产风险缓存失败: %w", err)
}
return nil
}
func (db *DB) RefreshAllAssetRiskCache() error {
rows, err := db.Query(`SELECT id FROM assets`)
if err != nil {
return fmt.Errorf("查询资产列表失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
}
return rows.Err()
}
func (db *DB) AssetIDsForVulnerabilityConversations(conversationIDs []string) ([]string, error) {
seen := map[string]struct{}{}
cleaned := make([]string, 0, len(conversationIDs))
for _, id := range conversationIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
cleaned = append(cleaned, id)
}
if len(cleaned) == 0 {
return nil, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(cleaned)), ",")
args := make([]interface{}, 0, len(cleaned)*2)
for _, id := range cleaned {
args = append(args, id)
}
for _, id := range cleaned {
args = append(args, id)
}
rows, err := db.Query(`SELECT DISTINCT assets.id FROM assets
WHERE assets.last_scan_conversation_id IN (`+placeholders+`)
OR assets.last_scan_task_id IN (SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id IN (`+placeholders+`))`, args...)
if err != nil {
return nil, fmt.Errorf("查询受影响资产失败: %w", err)
}
defer rows.Close()
assetIDs := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
assetIDs = append(assetIDs, id)
}
return assetIDs, rows.Err()
}
func (db *DB) RefreshAssetRiskCacheForConversations(conversationIDs ...string) error {
assetIDs, err := db.AssetIDsForVulnerabilityConversations(conversationIDs)
if err != nil {
return err
}
for _, id := range assetIDs {
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
}
return nil
}
func (db *DB) refreshAssetRiskCacheForConversationsBestEffort(conversationIDs ...string) {
if err := db.RefreshAssetRiskCacheForConversations(conversationIDs...); err != nil && db.logger != nil {
db.logger.Warn("刷新资产风险缓存失败", zap.Error(err))
}
}
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
if limit < 1 {
limit = 20
@@ -726,9 +868,9 @@ func assetOrderBy(sortBy, sortOrder string) string {
case "port":
expression = "assets.port"
case "vulnerability_count":
expression = assetVulnerabilityCountExpr
expression = assetVulnerabilityCountCachedExpr
case "risk_level":
expression = assetRiskScoreExpr
expression = assetRiskScoreCachedExpr
default:
expression = "assets.last_seen_at"
}
+6 -1
View File
@@ -383,7 +383,12 @@ func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
t.Fatalf("unexpected scan metadata: %#v", linked)
}
if _, err := db.Exec(`UPDATE vulnerabilities SET status='fixed' WHERE conversation_id=?`, conv.ID); err != nil {
vulns, err := db.ListVulnerabilities(10, 0, VulnerabilityListFilter{ConversationID: conv.ID})
if err != nil || len(vulns) != 1 {
t.Fatalf("list linked vulnerabilities: len=%d err=%v", len(vulns), err)
}
vulns[0].Status = "fixed"
if err := db.UpdateVulnerability(vulns[0].ID, vulns[0]); err != nil {
t.Fatal(err)
}
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
+2
View File
@@ -1424,6 +1424,7 @@ type ProcessDetailsSummary struct {
type ProcessDetailsToolExecution struct {
ProcessDetailID string `json:"processDetailId,omitempty"`
ResultDetailID string `json:"resultDetailId,omitempty"`
ToolName string `json:"toolName,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
ExecutionID string `json:"executionId,omitempty"`
@@ -1552,6 +1553,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
if toolCallID != "" {
lastMatchedToolIndexByCallID[toolCallID] = idx
}
summary.ToolExecutions[idx].ResultDetailID = strings.TrimSpace(detailID)
if summary.ToolExecutions[idx].ToolName == "" {
summary.ToolExecutions[idx].ToolName = toolName
}
+7 -1
View File
@@ -421,6 +421,7 @@ func (db *DB) initTables() error {
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',
vulnerability_count INTEGER NOT NULL DEFAULT 0, risk_score INTEGER NOT NULL DEFAULT 0, risk_level TEXT NOT NULL DEFAULT 'unassessed',
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
@@ -745,6 +746,9 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
CREATE INDEX IF NOT EXISTS idx_assets_vulnerability_count ON assets(vulnerability_count);
CREATE INDEX IF NOT EXISTS idx_assets_risk_score ON assets(risk_score);
CREATE INDEX IF NOT EXISTS idx_assets_risk_level ON assets(risk_level);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
@@ -977,7 +981,6 @@ func (db *DB) initTables() error {
if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
}
db.logger.Debug("数据库表初始化完成")
return nil
}
@@ -1027,6 +1030,9 @@ func (db *DB) migrateAssetsTable() error {
{"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 ''"},
{"vulnerability_count", "ALTER TABLE assets ADD COLUMN vulnerability_count INTEGER NOT NULL DEFAULT 0"},
{"risk_score", "ALTER TABLE assets ADD COLUMN risk_score INTEGER NOT NULL DEFAULT 0"},
{"risk_level", "ALTER TABLE assets ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'unassessed'"},
}
for _, column := range columns {
var count int
@@ -22,10 +22,13 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
{"toolName": "http-framework-test", "success": true},
{"toolName": "http-framework-test", "success": true},
}
var resultIDs []string
for _, result := range results {
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", result); err != nil {
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", result)
if err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
resultIDs = append(resultIDs, resultID)
}
summary, err := db.GetProcessDetailsSummary(messageID)
@@ -39,6 +42,9 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
if execution.Status != "completed" {
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
}
if execution.ResultDetailID != resultIDs[i] {
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
}
}
for i, execution := range summary.ToolExecutions[2:4] {
if execution.Status != "result_missing" {
+31
View File
@@ -191,6 +191,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
if err != nil {
return nil, fmt.Errorf("创建漏洞失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID)
return vuln, nil
}
@@ -299,6 +300,8 @@ func (db *DB) CountVulnerabilitiesForAccess(filter VulnerabilityListFilter, acce
// UpdateVulnerability 更新漏洞
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
vuln.UpdatedAt = time.Now()
var oldConversationID string
_ = db.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&oldConversationID)
query := `
UPDATE vulnerabilities
@@ -318,6 +321,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
return fmt.Errorf("更新漏洞失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID)
return nil
}
@@ -337,6 +341,10 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
args := []interface{}{}
where, args = filter.appendWhere(where, args)
where, args = appendVulnerabilityAccessFilter(where, args, access)
affectedConversations, err := collectVulnerabilityConversationIDs(tx, where, args)
if err != nil {
return 0, err
}
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
@@ -356,6 +364,7 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交事务失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(affectedConversations...)
return deleted, nil
}
@@ -366,6 +375,8 @@ func (db *DB) DeleteVulnerability(id string) error {
return fmt.Errorf("开启事务失败: %w", err)
}
defer func() { _ = tx.Rollback() }()
var conversationID string
_ = tx.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&conversationID)
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
@@ -377,9 +388,29 @@ func (db *DB) DeleteVulnerability(id string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(conversationID)
return nil
}
func collectVulnerabilityConversationIDs(tx *sql.Tx, where string, args []interface{}) ([]string, error) {
rows, err := tx.Query(`SELECT DISTINCT COALESCE(conversation_id,'') FROM vulnerabilities `+where, args...)
if err != nil {
return nil, fmt.Errorf("查询受影响漏洞会话失败: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
if strings.TrimSpace(id) != "" {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
+1 -1
View File
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
h.agentHandler = ah
}
const monitorPageTopTools = 3
const monitorPageTopTools = 6
// MonitorStatsSummary 工具调用汇总
type MonitorStatsSummary struct {
+45
View File
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
@@ -47,6 +48,12 @@ type workflowDryRunRequest struct {
Inputs map[string]interface{} `json:"inputs,omitempty"`
}
type workflowGenerateDraftRequest struct {
Prompt string `json:"prompt"`
Options workflowrunner.DraftOptions `json:"options"`
AvailableTools []workflowrunner.DraftTool `json:"available_tools,omitempty"`
}
func (h *WorkflowHandler) List(c *gin.Context) {
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
@@ -126,6 +133,44 @@ func (h *WorkflowHandler) DryRun(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": result})
}
func (h *WorkflowHandler) GenerateDraft(c *gin.Context) {
var req workflowGenerateDraftRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
draftReq := workflowrunner.DraftRequest{
Prompt: req.Prompt,
Options: req.Options,
AvailableTools: req.AvailableTools,
}
var result *workflowrunner.DraftResult
var llmErr error
if h.cfg != nil {
if llmCfg, _, ok := h.cfg.ResolveAIChannel(""); ok && strings.TrimSpace(llmCfg.APIKey) != "" && strings.TrimSpace(llmCfg.Model) != "" {
result, llmErr = workflowrunner.GenerateDraftFromLLM(c.Request.Context(), draftReq, llmCfg, h.logger)
} else {
llmErr = errors.New("AI 通道未配置 api_key 或 model")
}
} else {
llmErr = errors.New("工作流生成器未加载平台 AI 配置")
}
if llmErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "大模型生成失败: " + llmErr.Error()})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "workflow", "generate_draft", "自然语言生成工作流草稿", "", "", map[string]interface{}{
"generator": result.Generator,
"nodes": result.Stats["nodes"],
"edges": result.Stats["edges"],
"high_risk": result.Audit.HighRisk,
"savable": result.Audit.Savable,
})
}
c.JSON(http.StatusOK, gin.H{"result": result})
}
func (h *WorkflowHandler) Update(c *gin.Context) {
h.save(c, c.Param("id"))
}
+24
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
@@ -76,3 +77,26 @@ func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
t.Fatalf("saved=%#v", saved)
}
}
func TestWorkflowHandlerGenerateDraft(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewWorkflowHandler(nil, zap.NewNop())
body := bytes.NewBufferString(`{"prompt":"对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告","options":{"include_objective":true},"available_tools":[{"key":"nmap","name":"nmap","enabled":true}]}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflows/generate-draft", body)
c.Request.Header.Set("Content-Type", "application/json")
h.GenerateDraft(c)
if w.Code != http.StatusBadGateway {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Error string `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !strings.Contains(resp.Error, "大模型生成失败") {
t.Fatalf("unexpected error: %#v", resp.Error)
}
}
+3 -1
View File
@@ -172,6 +172,8 @@ func permissionForRequest(method, fullPath string) string {
return "workflow:read"
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
return "workflow:write"
case path == "/workflows/generate-draft":
return "workflow:write"
case strings.HasPrefix(path, "/workflows"):
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
return "workflow:execute"
@@ -265,7 +267,7 @@ func isProcessGlobalMutationPath(path string) bool {
}
if strings.HasPrefix(path, "/workflows") {
// Workflow runs inherit conversation access; definitions are global.
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run"
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" && path != "/workflows/generate-draft"
}
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
return true
@@ -157,9 +157,15 @@ func TestWorkflowRunPermissionIsSeparateFromDefinitionManagement(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
t.Fatalf("resume permission = %q, want workflow:execute", got)
}
if got := permissionForRequest(http.MethodPost, "/api/workflows/generate-draft"); got != "workflow:write" {
t.Fatalf("generate draft permission = %q, want workflow:write", got)
}
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
t.Fatalf("definition permission = %q, want workflow:write", got)
}
if isProcessGlobalMutationPath("/workflows/generate-draft") {
t.Fatalf("generate draft should not be treated as a process-global mutation")
}
}
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
+782
View File
@@ -0,0 +1,782 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"regexp"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/openai"
"go.uber.org/zap"
)
type DraftTool struct {
Key string `json:"key"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
}
type DraftOptions struct {
IncludeObjective bool `json:"include_objective"`
AllowSchedule bool `json:"allow_schedule"`
AllowHighRisk bool `json:"allow_high_risk"`
}
type DraftRequest struct {
Prompt string `json:"prompt"`
Options DraftOptions `json:"options"`
AvailableTools []DraftTool `json:"available_tools,omitempty"`
}
type DraftMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
type DraftCapability struct {
Label string `json:"label"`
ToolName string `json:"tool_name,omitempty"`
ToolCandidates []string `json:"tool_candidates,omitempty"`
}
type DraftAudit struct {
Savable bool `json:"savable"`
Validation []string `json:"validation,omitempty"`
MissingFields []string `json:"missing_fields,omitempty"`
RiskWarnings []string `json:"risk_warnings,omitempty"`
Assumptions []string `json:"assumptions,omitempty"`
HighRisk bool `json:"high_risk"`
NeedsHITL bool `json:"needs_hitl"`
}
type DraftResult struct {
Graph *graphDef `json:"graph"`
Meta DraftMeta `json:"meta"`
Generator string `json:"generator"`
Audit DraftAudit `json:"audit"`
Capabilities []DraftCapability `json:"capabilities,omitempty"`
Stats map[string]int `json:"stats"`
}
type llmDraftEnvelope struct {
Graph graphDef `json:"graph"`
Meta DraftMeta `json:"meta"`
Capabilities []DraftCapability `json:"capabilities,omitempty"`
Audit DraftAudit `json:"audit,omitempty"`
}
type draftToolHint struct {
Label string
Keywords []string
Tools []string
}
var draftToolHints = []draftToolHint{
{Label: "子域名发现", Keywords: []string{"子域名", "subdomain", "subfinder", "amass"}, Tools: []string{"subfinder", "amass"}},
{Label: "端口扫描", Keywords: []string{"端口", "port", "nmap", "rustscan", "masscan"}, Tools: []string{"nmap", "rustscan", "masscan"}},
{Label: "漏洞扫描", Keywords: []string{"漏洞", "vuln", "漏洞扫描", "nuclei", "nikto", "zap"}, Tools: []string{"nuclei", "nikto", "zap"}},
{Label: "暴露面探测", Keywords: []string{"目录", "路径", "暴露页面", "dir", "ffuf", "gobuster", "feroxbuster"}, Tools: []string{"ffuf", "gobuster", "feroxbuster", "dirsearch"}},
{Label: "证书与域名线索收集", Keywords: []string{"证书", "certificate", "crt"}, Tools: []string{"subfinder"}},
{Label: "云配置审计", Keywords: []string{"云", "cloud", "配置审计", "prowler", "scout"}, Tools: []string{"prowler", "scout-suite"}},
{Label: "容器安全检查", Keywords: []string{"容器", "镜像", "k8s", "kubernetes", "trivy", "kube"}, Tools: []string{"trivy", "kube-bench", "kube-hunter"}},
{Label: "威胁情报收集", Keywords: []string{"情报", "威胁情报", "threat", "ioc", "virustotal", "shodan", "fofa"}, Tools: []string{"virustotal_search", "shodan_search", "fofa_search"}},
}
var highRiskDraftRE = regexp.MustCompile(`(?i)(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)`)
func GenerateDraftFromNaturalLanguage(ctx context.Context, req DraftRequest) (*DraftResult, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, fmt.Errorf("工作流需求不能为空")
}
capabilities := detectDraftCapabilities(prompt, req.AvailableTools)
wantsApproval := containsAnyFold(prompt, "审批", "确认", "审核", "负责人", "人工", "review", "approve", "approval", "human")
wantsReport := containsAnyFold(prompt, "报告", "汇总", "输出", "通知", "任务", "工单", "report", "summary", "notify", "ticket")
wantsCondition := containsAnyFold(prompt, "如果", "发现", "存在", "高危", "新增", "失败", "通过", "否则", "if", "when", "high", "critical", "new", "fail")
highRisk := highRiskDraftRE.MatchString(prompt)
builder := &draftGraphBuilder{x: 120, y: 150}
assumptions := make([]string, 0)
riskWarnings := make([]string, 0)
missingFields := make([]string, 0)
start := builder.add("start", "开始", map[string]any{"input_keys": "message, conversationId, projectId, target"}, 0)
previous := start
for _, capability := range capabilities {
hasTool := strings.TrimSpace(capability.ToolName) != ""
var id string
if hasTool {
id = builder.add("tool", capability.Label, map[string]any{
"tool_name": capability.ToolName,
"arguments": `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`,
"timeout_seconds": "120",
"join_strategy": "all_merge",
}, 0)
} else {
id = builder.add("agent", capability.Label, map[string]any{
"agent_mode": "eino_single",
"input_binding": map[string]any{"from": "previous", "field": "output"},
"instruction": capability.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt,
"output_key": "agent_result",
"join_strategy": "all_merge",
"missing_tool_candidates": strings.Join(capability.ToolCandidates, ", "),
}, 0)
if len(capability.ToolCandidates) > 0 {
assumptions = append(assumptions, capability.Label+" 未匹配到已启用工具,已生成 Agent 草稿节点。")
missingFields = append(missingFields, capability.Label+": 选择或启用对应 MCP 工具")
}
}
builder.connect(previous, id, "", nil)
previous = id
}
openConditionID := ""
if wantsCondition {
expr := `{{previous.output}} != ""`
label := "是否满足触发条件"
if highRisk {
expr = `{{previous.output}} contains "高危"`
label = "是否需要高风险处置"
}
condition := builder.add("condition", label, map[string]any{"expression": expr, "join_strategy": "all_merge"}, 0)
builder.connect(previous, condition, "", nil)
openConditionID = condition
report := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
"output_key": "result",
"source_binding": map[string]any{"from": "previous", "field": "output"},
"static_value": "",
"join_strategy": "all_merge",
}, 130)
builder.connect(condition, report, "否", map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"})
previous = condition
}
insertedHITL := false
if highRisk {
if !req.Options.AllowHighRisk || wantsApproval {
approval := builder.add("hitl", "人工审批", map[string]any{
"prompt": "请确认是否允许继续执行高风险处置:" + prompt,
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
"reviewer": "human",
"join_strategy": "all_merge",
"risk_level": "high",
}, 0)
builder.connect(previous, approval, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
if previous == openConditionID {
openConditionID = ""
}
previous = approval
insertedHITL = true
}
action := builder.add("agent", "执行受控处置", map[string]any{
"agent_mode": "eino_single",
"input_binding": map[string]any{"from": "previous", "field": "output"},
"instruction": "仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:" + prompt,
"output_key": "remediation_plan",
"join_strategy": "all_merge",
"risk_level": "high",
"requires_human_confirmation": "true",
}, 0)
builder.connect(previous, action, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
if previous == openConditionID {
openConditionID = ""
}
previous = action
if insertedHITL {
riskWarnings = append(riskWarnings, "检测到高风险动作,已加入人工审批与 requires_human_confirmation 标记。")
} else {
riskWarnings = append(riskWarnings, "检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。")
}
} else if wantsApproval {
approval := builder.add("hitl", "人工审批", map[string]any{
"prompt": "请审核工作流阶段结果:" + prompt,
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
"reviewer": "human",
"join_strategy": "all_merge",
}, 0)
builder.connect(previous, approval, "", nil)
previous = approval
insertedHITL = true
}
output := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
"output_key": "result",
"source_binding": map[string]any{"from": "previous", "field": "output"},
"static_value": "",
"join_strategy": "all_merge",
}, 0)
builder.connect(previous, output, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
graph := &graphDef{Nodes: builder.nodes, Edges: builder.edges, Config: map[string]any{
"schema_version": 1,
"generated_by": "natural_language",
"source_prompt": prompt,
}}
if req.Options.IncludeObjective {
graph.Config["objective"] = prompt
}
if req.Options.AllowSchedule && containsAnyFold(prompt, "每天", "每周", "定时", "周期", "持续", "daily", "weekly", "schedule", "monitor") {
if containsAnyFold(prompt, "每天", "daily") {
graph.Config["trigger_suggestion"] = "daily"
} else {
graph.Config["trigger_suggestion"] = "scheduled"
}
assumptions = append(assumptions, "已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。")
}
raw, _ := json.Marshal(graph)
validation := make([]string, 0)
if err := ValidateGraphJSON(ctx, string(raw)); err != nil {
validation = append(validation, err.Error())
}
return &DraftResult{
Graph: graph,
Meta: DraftMeta{ID: draftSlug(prompt), Name: draftName(prompt), Description: prompt, Enabled: true},
Generator: "deterministic",
Audit: DraftAudit{
Savable: len(validation) == 0,
Validation: validation,
MissingFields: missingFields,
RiskWarnings: riskWarnings,
Assumptions: assumptions,
HighRisk: highRisk,
NeedsHITL: insertedHITL,
},
Capabilities: capabilities,
Stats: map[string]int{"nodes": len(graph.Nodes), "edges": len(graph.Edges)},
}, nil
}
func GenerateDraftFromLLM(ctx context.Context, req DraftRequest, oa config.OpenAIConfig, logger *zap.Logger) (*DraftResult, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, fmt.Errorf("工作流需求不能为空")
}
if strings.TrimSpace(oa.APIKey) == "" || strings.TrimSpace(oa.Model) == "" {
return nil, fmt.Errorf("AI 通道未配置 api_key 或 model")
}
if logger == nil {
logger = zap.NewNop()
}
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
toolJSON, _ := json.Marshal(req.AvailableTools)
systemPrompt := `你是 CyberStrikeAI 的工作流编排助手你必须把用户的一句话需求转换为可保存的工作流草稿 JSON
只返回 JSON 对象不要 Markdown不要解释JSON 必须符合
{
"meta": {"id":"kebab-case-id","name":"短名称","description":"用户需求","enabled":true},
"graph": {
"nodes": [{"id":"start-1","type":"start","label":"显示名","position":{"x":120,"y":150},"config":{}}],
"edges": [{"id":"edge-1","source":"start-1","target":"node-2","label":"","config":{}}],
"config": {"schema_version":1,"generated_by":"llm","source_prompt":"用户原文"}
},
"capabilities": [{"label":"能力名","tool_name":"已匹配工具名","tool_candidates":["候选工具"]}],
"audit": {"assumptions":[],"missing_fields":[],"risk_warnings":[]}
}
硬性规则
- 只能输出一个合法 JSON object不要输出 JSON Schema注释解释文字Markdown 代码块或多余前后缀
- 不要在 JSON 字符串值中使用竖线枚举写法type 字段一次只能填写一个节点类型字符串
- 至少 1 start 1 outputoutput/end 不能有出边
- 节点 type 只能从这些字符串中选择starttoolagentconditionhitloutputend
- 每个 agenttooloutput 节点都必须配置唯一的 output_keyoutput 节点默认使用 result
- agent 节点必须配置 instruction input_binding默认 input_binding {"from":"previous","field":"output"}
- output 节点必须配置 source_binding static_value默认 source_binding {"from":"previous","field":"output"}
- tool 节点必须配置 tool_nameargumentstimeout_secondsarguments 必须是合法 JSON 字符串
- 所有非 start 且可能有多个上游的节点必须配置 join_strategy:"all_merge"
- condition 最多 2 条出边必须用 branch true/false并用 label /
- tool 节点只有在 available_tools 中存在启用工具时才使用否则用 agent 节点并在 audit.missing_fields 写明缺失工具
- 高风险动作执行脚本隔离封禁删除利用payload命令执行等必须加入 hitl 审批或在高风险节点 config 中标记 requires_human_confirmation:"true"risk_level:"high"
- 不要生成会真实执行攻击的参数工具参数使用 {{inputs.target}}{{inputs.message}} 占位
- 所有节点 config generated_by:"llm" needs_review:"true"`
userPrompt := fmt.Sprintf("用户需求:%s\n\n选项:%+v\n\n可用工具 JSON%s", prompt, req.Options, string(toolJSON))
requestBody := map[string]interface{}{
"model": strings.TrimSpace(oa.Model),
"messages": []map[string]interface{}{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"temperature": 0,
"max_completion_tokens": 4096,
"response_format": map[string]interface{}{"type": "json_object"},
"thinking": map[string]interface{}{"type": "disabled"},
}
var apiResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
}
client := openai.NewClient(&oa, nil, logger)
if err := client.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
return nil, fmt.Errorf("调用大模型失败: %w", err)
}
if len(apiResponse.Choices) == 0 {
return nil, fmt.Errorf("大模型未返回候选结果")
}
raw := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
if raw == "" {
raw = strings.TrimSpace(apiResponse.Choices[0].Message.ReasoningContent)
}
env, err := parseLLMDraftEnvelope(raw)
if err != nil {
return nil, err
}
result := normalizeLLMDraft(prompt, req, env)
graphRaw, _ := json.Marshal(result.Graph)
validation := make([]string, 0)
if err := ValidateGraphJSON(ctx, string(graphRaw)); err != nil {
validation = append(validation, err.Error())
}
result.Audit.Validation = validation
result.Audit.Savable = len(validation) == 0
if !result.Audit.Savable {
return nil, fmt.Errorf("大模型生成的工作流未通过校验: %s", strings.Join(validation, ""))
}
return result, nil
}
type draftGraphBuilder struct {
nodes []graphNode
edges []graphEdge
x float64
y float64
nodeSeq int
edgeSeq int
}
func (b *draftGraphBuilder) add(nodeType, label string, config map[string]any, yOffset float64) string {
b.nodeSeq++
id := fmt.Sprintf("%s-%d", nodeType, b.nodeSeq)
if config == nil {
config = make(map[string]any)
}
config["generated_by"] = "natural_language"
config["needs_review"] = "true"
b.nodes = append(b.nodes, graphNode{
ID: id,
Type: nodeType,
Label: label,
Position: graphPosition{X: b.x, Y: b.y + yOffset},
Config: config,
})
b.x += 210
return id
}
func (b *draftGraphBuilder) connect(source, target, label string, config map[string]any) {
b.edgeSeq++
if config == nil {
config = make(map[string]any)
}
b.edges = append(b.edges, graphEdge{ID: fmt.Sprintf("edge-ai-%d", b.edgeSeq), Source: source, Target: target, Label: label, Config: config})
}
func parseLLMDraftEnvelope(raw string) (llmDraftEnvelope, error) {
var lastErr error
for _, candidate := range jsonObjectCandidates(raw) {
var env llmDraftEnvelope
if err := json.Unmarshal([]byte(candidate), &env); err == nil {
if len(env.Graph.Nodes) == 0 {
lastErr = fmt.Errorf("大模型 JSON 缺少 graph.nodes")
continue
}
return env, nil
} else {
lastErr = err
}
}
if lastErr == nil {
lastErr = fmt.Errorf("大模型响应为空")
}
return llmDraftEnvelope{}, fmt.Errorf("解析大模型工作流 JSON 失败: %w", lastErr)
}
func jsonObjectCandidates(raw string) []string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
candidates := []string{s}
if start := strings.Index(s, "{"); start >= 0 {
if end := strings.LastIndex(s, "}"); end > start {
candidates = append(candidates, s[start:end+1])
}
}
return candidates
}
func normalizeLLMDraft(prompt string, req DraftRequest, env llmDraftEnvelope) *DraftResult {
g := env.Graph
if g.Config == nil {
g.Config = make(map[string]any)
}
g.Config["schema_version"] = 1
g.Config["generated_by"] = "llm"
g.Config["source_prompt"] = prompt
if req.Options.IncludeObjective {
g.Config["objective"] = prompt
}
enabledTools := enabledDraftToolNames(req.AvailableTools)
usedOutputKeys := make(map[string]bool)
nodeTypes := make(map[string]string, len(g.Nodes))
for i := range g.Nodes {
if strings.TrimSpace(g.Nodes[i].ID) == "" {
g.Nodes[i].ID = fmt.Sprintf("%s-%d", firstNonEmpty(g.Nodes[i].Type, "node"), i+1)
}
if strings.TrimSpace(g.Nodes[i].Type) == "" {
g.Nodes[i].Type = "agent"
}
if strings.TrimSpace(g.Nodes[i].Label) == "" {
g.Nodes[i].Label = displayNodeType(g.Nodes[i].Type)
}
if g.Nodes[i].Position.X == 0 && g.Nodes[i].Position.Y == 0 {
g.Nodes[i].Position = graphPosition{X: 120 + float64(i)*210, Y: 150}
}
if g.Nodes[i].Config == nil {
g.Nodes[i].Config = make(map[string]any)
}
g.Nodes[i].Config["generated_by"] = "llm"
g.Nodes[i].Config["needs_review"] = "true"
normalizeLLMNodeConfig(prompt, &g.Nodes[i], enabledTools, usedOutputKeys)
nodeTypes[g.Nodes[i].ID] = strings.ToLower(strings.TrimSpace(g.Nodes[i].Type))
}
conditionBranchCounts := make(map[string]int)
for i := range g.Edges {
if strings.TrimSpace(g.Edges[i].ID) == "" {
g.Edges[i].ID = fmt.Sprintf("edge-llm-%d", i+1)
}
if g.Edges[i].Config == nil {
g.Edges[i].Config = make(map[string]any)
}
normalizeLLMEdgeConfig(&g.Edges[i], nodeTypes, conditionBranchCounts)
}
audit := env.Audit
highRisk := highRiskDraftRE.MatchString(prompt) || graphHasHighRisk(g)
audit.HighRisk = highRisk
audit.NeedsHITL = graphHasNodeType(g, "hitl")
if highRisk && !audit.NeedsHITL && !graphHasConfirmation(g) {
audit.RiskWarnings = append(audit.RiskWarnings, "大模型生成包含高风险语义,请补充人工审批或确认标记后再运行。")
}
if len(audit.RiskWarnings) == 0 && highRisk {
audit.RiskWarnings = append(audit.RiskWarnings, "检测到高风险动作,已标记为需要重点审计。")
}
meta := env.Meta
if strings.TrimSpace(meta.Description) == "" {
meta.Description = prompt
}
if strings.TrimSpace(meta.Name) == "" {
meta.Name = draftName(prompt)
}
if strings.TrimSpace(meta.ID) == "" {
meta.ID = draftSlug(prompt)
}
meta.Enabled = true
return &DraftResult{
Graph: &g,
Meta: meta,
Generator: "llm",
Audit: audit,
Capabilities: env.Capabilities,
Stats: map[string]int{"nodes": len(g.Nodes), "edges": len(g.Edges)},
}
}
func normalizeLLMEdgeConfig(edge *graphEdge, nodeTypes map[string]string, conditionBranchCounts map[string]int) {
if nodeTypes[strings.TrimSpace(edge.Source)] != "condition" {
return
}
if conditionBranchHint(*edge) != "" {
return
}
conditionBranchCounts[edge.Source]++
branch := "true"
label := "是"
if conditionBranchCounts[edge.Source] > 1 {
branch = "false"
label = "否"
}
edge.Label = label
edge.Config["branch"] = branch
}
func normalizeLLMNodeConfig(prompt string, node *graphNode, enabledTools map[string]bool, usedOutputKeys map[string]bool) {
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
switch nodeType {
case "start":
if cfgString(node.Config, "input_keys") == "" {
node.Config["input_keys"] = "message, conversationId, projectId, target"
}
case "tool":
toolName := cfgString(node.Config, "tool_name")
if toolName == "" || !enabledTools[strings.ToLower(toolName)] {
node.Type = "agent"
node.Config["missing_tool_name"] = toolName
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
return
}
if cfgString(node.Config, "arguments") == "" {
node.Config["arguments"] = `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`
}
if cfgString(node.Config, "timeout_seconds") == "" {
node.Config["timeout_seconds"] = "120"
}
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "tool_result"))
ensureJoinStrategy(node)
case "agent":
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
case "condition":
if cfgString(node.Config, "expression") == "" {
node.Config["expression"] = `{{previous.output}} != ""`
}
ensureJoinStrategy(node)
case "hitl":
if cfgString(node.Config, "prompt") == "" {
node.Config["prompt"] = "请审核工作流阶段结果:" + prompt
}
if cfgString(node.Config, "reviewer") == "" {
node.Config["reviewer"] = "human"
}
ensureJoinStrategy(node)
case "output":
ensureNodeOutputKey(node, usedOutputKeys, "result")
if cfgString(node.Config, "static_value") == "" {
if _, ok := parseFieldBinding(node.Config, "source_binding"); !ok {
node.Config["source_binding"] = map[string]any{"from": "previous", "field": "output"}
}
}
ensureJoinStrategy(node)
case "end":
ensureJoinStrategy(node)
}
}
func normalizeAgentDraftConfig(prompt string, node *graphNode, usedOutputKeys map[string]bool) {
if cfgString(node.Config, "agent_mode") == "" {
node.Config["agent_mode"] = "eino_single"
}
if cfgString(node.Config, "instruction") == "" {
node.Config["instruction"] = node.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt
}
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
node.Config["input_binding"] = map[string]any{"from": "previous", "field": "output"}
}
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "agent_result"))
ensureJoinStrategy(node)
}
func ensureJoinStrategy(node *graphNode) {
if cfgString(node.Config, "join_strategy") == "" {
node.Config["join_strategy"] = "all_merge"
}
}
func ensureNodeOutputKey(node *graphNode, used map[string]bool, fallback string) {
current := sanitizeOutputKey(cfgString(node.Config, "output_key"))
if current == "" {
current = sanitizeOutputKey(fallback)
}
if current == "" {
current = "result"
}
base := current
for i := 2; used[current]; i++ {
current = fmt.Sprintf("%s_%d", base, i)
}
node.Config["output_key"] = current
used[current] = true
}
func draftOutputKeyBase(node *graphNode, fallback string) string {
if name := cfgString(node.Config, "tool_name"); name != "" {
return name + "_result"
}
if node.ID != "" {
return node.ID + "_result"
}
return fallback
}
func sanitizeOutputKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastUnderscore := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastUnderscore = false
continue
}
if b.Len() > 0 && !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}
func enabledDraftToolNames(tools []DraftTool) map[string]bool {
names := make(map[string]bool, len(tools)*2)
for _, tool := range tools {
if !tool.Enabled {
continue
}
if key := strings.ToLower(strings.TrimSpace(tool.Key)); key != "" {
names[key] = true
}
if name := strings.ToLower(strings.TrimSpace(tool.Name)); name != "" {
names[name] = true
}
}
return names
}
func graphHasNodeType(g graphDef, nodeType string) bool {
for _, node := range g.Nodes {
if strings.EqualFold(node.Type, nodeType) {
return true
}
}
return false
}
func graphHasConfirmation(g graphDef) bool {
for _, node := range g.Nodes {
if cfgString(node.Config, "requires_human_confirmation") == "true" {
return true
}
}
return false
}
func graphHasHighRisk(g graphDef) bool {
for _, node := range g.Nodes {
if cfgString(node.Config, "risk_level") == "high" || cfgString(node.Config, "requires_human_confirmation") == "true" {
return true
}
if highRiskDraftRE.MatchString(node.Label) || highRiskDraftRE.MatchString(cfgString(node.Config, "instruction")) {
return true
}
}
return false
}
func detectDraftCapabilities(prompt string, tools []DraftTool) []DraftCapability {
capabilities := make([]DraftCapability, 0)
for _, hint := range draftToolHints {
if containsAnyFold(prompt, hint.Keywords...) {
capabilities = append(capabilities, DraftCapability{
Label: hint.Label,
ToolName: matchDraftTool(hint.Tools, tools),
ToolCandidates: append([]string(nil), hint.Tools...),
})
}
}
if len(capabilities) == 0 {
capabilities = append(capabilities, DraftCapability{Label: "节点能力", ToolCandidates: nil})
}
return capabilities
}
func matchDraftTool(candidates []string, tools []DraftTool) string {
if len(candidates) == 0 || len(tools) == 0 {
return ""
}
for _, enabledOnly := range []bool{true, false} {
for _, candidate := range candidates {
candidate = strings.ToLower(strings.TrimSpace(candidate))
for _, tool := range tools {
if enabledOnly && !tool.Enabled {
continue
}
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(tool.Key, tool.Name)))
if key != "" && strings.Contains(key, candidate) {
return firstNonEmpty(tool.Key, tool.Name)
}
}
}
}
return ""
}
func containsAnyFold(text string, needles ...string) bool {
lower := strings.ToLower(text)
for _, needle := range needles {
if strings.Contains(lower, strings.ToLower(needle)) {
return true
}
}
return false
}
func draftOutputLabel(wantsReport bool) string {
if wantsReport {
return "输出报告"
}
return "输出"
}
func branchLabel(source, conditionID string) string {
if source == conditionID && conditionID != "" {
return "是"
}
return ""
}
func branchConfig(source, conditionID string, yes bool) map[string]any {
if source != conditionID || conditionID == "" {
return nil
}
if yes {
return map[string]any{"condition": `{{previous.matched}} == "true"`, "branch": "true"}
}
return map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"}
}
func draftName(prompt string) string {
runes := []rune(strings.TrimSpace(prompt))
if len(runes) > 22 {
return string(runes[:22]) + "..."
}
return string(runes)
}
func draftSlug(prompt string) string {
lower := strings.ToLower(strings.TrimSpace(prompt))
var b strings.Builder
lastDash := false
for _, r := range lower {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
slug := strings.Trim(b.String(), "-")
if slug != "" {
if len(slug) > 48 {
return strings.Trim(slug[:48], "-")
}
return slug
}
h := fnv.New32a()
_, _ = h.Write([]byte(lower))
if !utf8.ValidString(lower) || lower == "" {
lower = "workflow"
}
return fmt.Sprintf("ai-workflow-%x", h.Sum32())
}
+213
View File
@@ -0,0 +1,213 @@
package workflow
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cyberstrike-ai/internal/config"
)
func TestGenerateDraftFromNaturalLanguageHighRiskAddsHITLAndValidGraph(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告",
Options: DraftOptions{
IncludeObjective: true,
AllowSchedule: false,
AllowHighRisk: false,
},
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
if !result.Audit.HighRisk || !result.Audit.NeedsHITL || len(result.Audit.RiskWarnings) == 0 {
t.Fatalf("audit did not flag high-risk HITL path: %#v", result.Audit)
}
var hasTool, hasHITL, hasConfirmation bool
for _, node := range result.Graph.Nodes {
if node.Type == "tool" && cfgString(node.Config, "tool_name") == "nmap" {
hasTool = true
}
if node.Type == "hitl" {
hasHITL = true
}
if cfgString(node.Config, "requires_human_confirmation") == "true" {
hasConfirmation = true
}
}
if !hasTool || !hasHITL || !hasConfirmation {
t.Fatalf("expected nmap tool, HITL, and confirmation marker; tool=%v hitl=%v confirmation=%v", hasTool, hasHITL, hasConfirmation)
}
}
func TestGenerateDraftAllowHighRiskStillLabelsConditionBranch(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "如果漏洞扫描发现高危漏洞,允许生成执行修复脚本的草稿并输出报告",
Options: DraftOptions{
AllowHighRisk: true,
},
AvailableTools: []DraftTool{{Key: "nuclei", Name: "nuclei", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if branch := cfgString(edge.Config, "branch"); branch != "" {
branches[branch] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("condition branches = %#v, want true and false", branches)
}
}
func TestGenerateDraftFromLLMUsesOpenAICompatibleEndpoint(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.URL.Path != "/chat/completions" {
t.Fatalf("path = %s, want /chat/completions", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("authorization = %q", got)
}
var payload struct {
Temperature float64 `json:"temperature"`
ResponseFormat struct {
Type string `json:"type"`
} `json:"response_format"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.Temperature != 0 || payload.ResponseFormat.Type != "json_object" {
t.Fatalf("unexpected structured output controls: temperature=%v response_format=%#v", payload.Temperature, payload.ResponseFormat)
}
if len(payload.Messages) == 0 || strings.Contains(payload.Messages[0].Content, "start|tool|agent") {
t.Fatalf("system prompt still contains pipe enum: %q", payload.Messages[0].Content)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"llm-port-scan\",\"name\":\"端口扫描\",\"description\":\"端口扫描\",\"enabled\":true},\"graph\":{\"nodes\":[{\"id\":\"start-1\",\"type\":\"start\",\"label\":\"开始\",\"position\":{\"x\":120,\"y\":150},\"config\":{\"input_keys\":\"message, target\"}},{\"id\":\"tool-2\",\"type\":\"tool\",\"label\":\"端口扫描\",\"position\":{\"x\":330,\"y\":150},\"config\":{\"tool_name\":\"nmap\",\"arguments\":\"{\\\"target\\\":\\\"{{inputs.target}}\\\"}\",\"timeout_seconds\":\"120\",\"join_strategy\":\"all_merge\"}},{\"id\":\"output-3\",\"type\":\"output\",\"label\":\"输出报告\",\"position\":{\"x\":540,\"y\":150},\"config\":{\"source_binding\":{\"from\":\"previous\",\"field\":\"output\"},\"join_strategy\":\"all_merge\"}}],\"edges\":[{\"id\":\"edge-1\",\"source\":\"start-1\",\"target\":\"tool-2\"},{\"id\":\"edge-2\",\"source\":\"tool-2\",\"target\":\"output-3\"}],\"config\":{\"schema_version\":1}},\"capabilities\":[{\"label\":\"端口扫描\",\"tool_name\":\"nmap\",\"tool_candidates\":[\"nmap\"]}],\"audit\":{\"assumptions\":[]}}"}}]}`))
}))
defer srv.Close()
result, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "对目标做端口扫描并输出报告",
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err != nil {
t.Fatalf("GenerateDraftFromLLM: %v", err)
}
if !called {
t.Fatal("expected LLM endpoint to be called")
}
if result.Generator != "llm" || !result.Audit.Savable || result.Meta.ID != "llm-port-scan" {
t.Fatalf("unexpected result: %#v", result)
}
for _, node := range result.Graph.Nodes {
if node.Type == "output" && cfgString(node.Config, "output_key") != "result" {
t.Fatalf("output_key = %q, want result", cfgString(node.Config, "output_key"))
}
}
}
func TestGenerateDraftFromLLMReturnsErrorOnMalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"bad\"},|\"graph\":{\"nodes\":[]}}"}}]}`))
}))
defer srv.Close()
_, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "随便生成一个工作流,要求所有节点都用到输出变量",
Options: DraftOptions{
IncludeObjective: true,
},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err == nil {
t.Fatal("expected malformed JSON error")
}
if !strings.Contains(err.Error(), "解析大模型工作流 JSON 失败") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNormalizeLLMDraftRepairsMissingRequiredConfig(t *testing.T) {
result := normalizeLLMDraft("随便生成一个工作流", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "agent-1", Type: "agent", Label: "分析", Config: map[string]any{}},
{ID: "out-1", Type: "output", Label: "输出结果", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "agent-1"},
{ID: "e2", Source: "agent-1", Target: "out-1"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
var agentKey, outputKey string
for _, node := range result.Graph.Nodes {
switch node.Type {
case "agent":
agentKey = cfgString(node.Config, "output_key")
case "output":
outputKey = cfgString(node.Config, "output_key")
}
}
if agentKey == "" || outputKey != "result" {
t.Fatalf("agentKey=%q outputKey=%q", agentKey, outputKey)
}
}
func TestNormalizeLLMDraftRepairsConditionBranches(t *testing.T) {
result := normalizeLLMDraft("如果发现异常则输出详情,否则输出正常", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "cond-1", Type: "condition", Label: "判断", Config: map[string]any{"expression": `{{inputs.message}} != ""`}},
{ID: "out-yes", Type: "output", Label: "异常", Config: map[string]any{}},
{ID: "out-no", Type: "output", Label: "正常", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "cond-1"},
{ID: "e2", Source: "cond-1", Target: "out-yes"},
{ID: "e3", Source: "cond-1", Target: "out-no"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if edge.Source == "cond-1" {
branches[cfgString(edge.Config, "branch")] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("branches = %#v, want true and false", branches)
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
name: active-directory-attack
description: >-
内网域攻击:BloodHound,Kerberoast,ADCS ESC1/ESC8,NTLM Relay,Coerce,DACL,DCSync,Zerologon/NoPac/PrintNightmare,mitm6,LLMNR,Linux内网。Use when attacking Active Directory, ADCS, NTLM relay, or internal domain.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 内网域攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: ai-llm-app-attack
description: >-
AI/LLM应用攻击:提示注入,Agent工具滥用RCE,RAG投毒,MCP供应链,torch.load pickle RCE。Use when testing LLM apps, agents, RAG, MCP plugins, or AI model file risks.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## AI / LLM 应用攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: attack-surface-recon
description: >-
侦察/攻击面测绘:被动whois/amass/crt.sh/FOFA/Shodan,主动subfinder/httpx/naabu/katana/nuclei,DNS地域/CDN/Nginx catch-all/宝塔/UniApp指纹。开局第一动作,认知写入项目黑板。Use when starting recon, asset mapping, fingerprinting, or CDN/DNS bypass discovery.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 侦察 / 攻击面测绘
+2 -1
View File
@@ -2,7 +2,8 @@
name: binary-mobile-reversing
description: >-
APK/EXE/二进制:UniApp/DCloud/Flutter逆向,证书固定绕过,导出组件,内存破坏exploit链,IoT固件。Use when reversing APK/EXE, UniApp/Flutter, native .so, or memory-corruption exploits.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## APK / EXE / 二进制逆向
+2 -1
View File
@@ -2,7 +2,8 @@
name: blockchain-contract-attack
description: >-
区块链/智能合约:Etherscan,slither/mythril,重入/访问控制/预言机/闪电贷,跨链桥,RPC暴露。Use when auditing smart contracts, DeFi, or blockchain attack surfaces.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 区块链 / 智能合约
+2 -1
View File
@@ -2,7 +2,8 @@
name: capability-primitive-search
description: >-
能力原语+状态空间搜索:read/write/exec/ssrf等原语凑RCE等式A-F,低危映射,正反向搜索,跨域兑现。Use when no single RCE, chaining low-severity vulns, or deriving novel attack chains.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 能力原语 + 状态空间搜索
+2 -1
View File
@@ -2,7 +2,8 @@
name: cloud-attack-methods
description: >-
云攻击:元数据API,S3/K8s,AWS/Azure/GCP身份提权,MinIO矩阵,阿里云FC,ChengZi SDK解密。Use when attacking cloud metadata, IAM, K8s, MinIO, Aliyun FC, or cloud post-ex.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 云攻击手法
+2 -1
View File
@@ -2,7 +2,8 @@
name: component-vuln-intel
description: >-
联网情报收集:识别组件后必做CVE/搜索引擎/中文社区/GitHub PoC/资产引擎/即时情报/依赖扩展+受阻换路。Use when a framework/component/version is identified and must search before exploit.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 联网情报收集(识别组件→立即全网搜;结果=线索/tentative,验证前不是 confirmed Fact
+2 -1
View File
@@ -2,7 +2,8 @@
name: initial-access-phishing
description: >-
初始访问/钓鱼/社工:凭据喷洒,AiTM,设备码,OAuth同意钓鱼,载荷,vishing。Use when needing initial access, phishing, AiTM, device code, or social engineering.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 初始访问 / 钓鱼 / 社工
+2 -1
View File
@@ -6,7 +6,8 @@ description: >-
(联网情报/Web/认证/服务端/源码/社工/后渗透/二进制/内网域/云/区块链/AI/无线/硬件)+0day+
组合拳+代理自举。核心:全网搜不到洞时现场推导独属于目标的攻击链。本文件为套件索引。
Use when starting a full-chain pentest engagement or needing the skill map for this suite.
tags: [渗透测试, penetration-testing, 红队, autonomous]
metadata:
tags: [渗透测试, penetration-testing, 红队, autonomous]
---
# 渗透测试Agent操作系统
+2 -1
View File
@@ -4,7 +4,8 @@ description: >-
CyberStrikeAI 项目黑板:跨会话 Fact 图(SQLite+ upsert_project_fact/record_vulnerability
边渗透边记录节奏、关系边 links、confidence、与多代理协调落库。Use when managing project
facts, blackboard index, writing evidence, or avoiding context-loss after compression.
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
metadata:
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
---
# 项目黑板(与本产品对齐)
+2 -1
View File
@@ -3,7 +3,8 @@ name: pentest-output-standards
description: >-
输出规范:中文分析,思维链,漏洞报告模板,负结果,黑板状态总览,改动台账,死锁突破。
Use when reporting findings, maintaining change ledger, or formatting pentest output.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 输出规范
+2 -1
View File
@@ -3,7 +3,8 @@ name: pentest-verification
description: >-
验证铁律:搜索≠漏洞,confirmed Fact须证据,tentative表线索,禁止空泛推测,负结果也落库,
想象力拉满+单步验证零容忍。Use when writing project facts, validating findings, or avoiding hallucination.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 验证铁律(全系统最高规则,违反即幻觉)
+2 -1
View File
@@ -2,7 +2,8 @@
name: post-exploitation
description: >-
后渗透/提权+凭据破解+密码学:反弹shell,Linux/Windows提权,横向,隧道,免杀,C2持久化,hashcat/Padding Oracle/hash长度扩展。Use when post-exploitation, privilege escalation, lateral movement, or cracking crypto.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 后渗透 / 提权 / 凭据破解 / 密码学
+2 -1
View File
@@ -2,7 +2,8 @@
name: proxy-tool-bootstrap
description: >-
自找代理+工具自举:SOCKS5/HTTP/Tor换路序列,工具Python自举,字典自生成,OOB基础设施。Use when blocked by 403/429/WAF/timeout, missing tools, or needing OOB confirmation.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 自找代理 + 工具自举(被拦换路,没工具自己写)
+2 -1
View File
@@ -2,7 +2,8 @@
name: redteam-opsec
description: >-
OPSEC隐蔽作战纪律:IP黑名单绕过,速率时序,流量混淆,最小足迹,反取证,渐进暴露。Use when maintaining stealth, bypassing IP bans, or planning covert red-team ops.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## OPSEC / 隐蔽作战纪律(免杀 / 稳定 / 隐蔽)
+2 -1
View File
@@ -2,7 +2,8 @@
name: source-code-hunting
description: >-
源码狩猎:.git泄露,危险函数grep,JS RC4解混淆,semgrep/CodeQL,trufflehog,patch diff,供应链/CI。Use when hunting source leaks, secrets, JS deobfuscation, or supply-chain issues.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 源码狩猎
+2 -1
View File
@@ -2,7 +2,8 @@
name: specialized-attack-playbooks
description: >-
专题实战利用:GoEdge私钥导出,灰产CDN取证,ARP MITM,CDN→S3 STS链,宝塔+UniApp,AI IDE API反代,OCS+MinIO;含references/scripts支持文件索引。Use when applying specialized playbooks for GoEdge, CDN, ARP MITM, BT Panel, OCS, MinIO.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 专题实战利用(全内联)
+2 -1
View File
@@ -2,7 +2,8 @@
name: unlimited-attack-scope
description: >-
不设限原则:搜索/验证/串联/记录一切,全领域适用,被拦换路,不达不休。Use when reminding scope is unlimited across Web/APK/cloud/AI/wireless/social eng.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 不设限原则
+2 -1
View File
@@ -2,7 +2,8 @@
name: web-attack-methods
description: >-
Web全栈攻击:SQLi/命令注入/SSTI/XSS/SSRF/NoSQL,认证JWT/OAuth/SAML,LFI/上传,Tomcat/WS/STOMP/XFF/PATH_INFO/CDN502/网宿JS挑战绕过。Use when testing Web injection, auth bypass, server-side, WAF/CDN bypass.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## Web 攻击手法(注入 / 认证 / 服务端 / 杂项 / CDN)
+2 -1
View File
@@ -2,7 +2,8 @@
name: wireless-hardware-attack
description: >-
无线/硬件:WiFi PMKID/WPS/Evil Twin,BLE,Zigbee,NFC,SDR,UART/JTAG/SPI,侧信道,故障注入。Use when attacking WiFi, BLE, RFID, SDR, or hardware interfaces.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 无线 / 硬件攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: zero-day-discovery
description: >-
0day自主发现引擎:变体分析/补丁间隙/差分/Fuzzing/污点推理/N-day武器化/猎人思维。Use when public vulns not found and need to discover 0day or weaponize N-day.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 0day 自主发现引擎(全网搜不到漏洞时自己挖)
+283
View File
@@ -30350,6 +30350,27 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
line-height: 1.45;
}
.skills-management-page .skill-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.skills-management-page .skill-card-tags span {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(15, 23, 42, 0.04);
color: var(--text-secondary);
font-size: 0.72rem;
line-height: 1.3;
}
.skills-management-page .skill-card-actions {
display: flex;
grid-column: auto;
@@ -40103,6 +40124,247 @@ html[data-theme="dark"] .workflow-status-toggle.is-disabled {
padding: 12px 18px;
}
.workflow-ai-modal-content {
width: min(760px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow: hidden;
}
.workflow-ai-modal-header {
align-items: flex-start;
}
.workflow-ai-modal-header h2 {
margin-bottom: 4px;
}
.workflow-ai-subtitle {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
}
.workflow-ai-modal-body {
display: grid;
gap: 14px;
max-height: min(66vh, 640px);
overflow-y: auto;
}
.workflow-ai-prompt-field textarea {
resize: vertical;
min-height: 116px;
}
.workflow-ai-examples,
.workflow-ai-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.workflow-ai-options label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--border-color);
border-radius: 7px;
color: var(--text-secondary);
font-size: 13px;
}
.workflow-ai-result {
display: grid;
gap: 10px;
}
.workflow-ai-result:empty {
display: none;
}
.workflow-ai-result-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.workflow-ai-progress {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.workflow-ai-progress[hidden] {
display: none;
}
.workflow-ai-progress span {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 9px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
background: var(--bg-secondary);
}
.workflow-ai-progress b {
display: inline-grid;
flex: 0 0 22px;
width: 22px;
height: 22px;
place-items: center;
border-radius: 50%;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
}
.workflow-ai-progress small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-ai-progress .is-active {
border-color: color-mix(in srgb, var(--accent-color) 45%, var(--border-color));
color: var(--text-primary);
}
.workflow-ai-progress .is-active b {
background: var(--accent-color);
color: white;
}
.workflow-ai-progress .is-complete b {
background: #16a34a;
color: white;
}
.workflow-ai-preview {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
.workflow-ai-preview-title {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.workflow-ai-preview-flow {
display: flex;
align-items: center;
gap: 7px;
overflow-x: auto;
padding-bottom: 2px;
}
.workflow-ai-preview-flow i {
flex: 0 0 auto;
color: var(--text-secondary);
font-style: normal;
}
.workflow-ai-preview-node {
display: grid;
flex: 0 0 auto;
gap: 2px;
width: 128px;
min-height: 48px;
padding: 7px 9px;
border: 1px solid color-mix(in srgb, var(--accent-color) 22%, var(--border-color));
border-radius: 8px;
background: var(--bg-primary);
}
.workflow-ai-preview-node small {
color: var(--text-secondary);
font-size: 11px;
}
.workflow-ai-preview-node strong {
overflow: hidden;
color: var(--text-primary);
font-size: 12px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-ai-preview-node.is-risky {
border-color: rgba(249, 115, 22, 0.62);
background: rgba(249, 115, 22, 0.08);
}
.workflow-ai-result-grid span,
.workflow-ai-result-section {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
.workflow-ai-result-grid span {
display: grid;
gap: 4px;
padding: 10px;
color: var(--text-secondary);
font-size: 12px;
}
.workflow-ai-result-grid strong {
color: var(--text-primary);
font-size: 16px;
}
.workflow-ai-result-section {
padding: 10px 12px;
font-size: 13px;
}
.workflow-ai-result-section p {
margin: 4px 0 0;
color: var(--text-secondary);
}
.workflow-ai-result-section ul {
margin: 8px 0 0;
padding-left: 18px;
color: var(--text-secondary);
}
.workflow-ai-result-section.is-warning {
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.08);
}
.workflow-ai-result-section.is-attention {
border-color: rgba(14, 165, 233, 0.42);
background: rgba(14, 165, 233, 0.08);
}
.workflow-ai-modal-footer {
gap: 8px;
}
@media (max-width: 640px) {
.workflow-ai-progress {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workflow-ai-result-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
html[data-theme="dark"] .workflow-dry-run-modal-icon {
color: #bfdbfe;
background: rgba(59, 130, 246, 0.14);
@@ -40343,6 +40605,27 @@ html[data-theme="dark"] .workflow-toolbar #workflow-connect-btn[aria-pressed="tr
font-weight: 600;
}
.workflow-ai-canvas-status {
position: absolute;
top: 14px;
left: 50%;
z-index: 2;
transform: translateX(-50%);
padding: 9px 14px;
border: 1px solid color-mix(in srgb, var(--accent-color) 35%, var(--border-color));
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 92%, var(--accent-color));
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
pointer-events: none;
}
.workflow-ai-canvas-status[hidden] {
display: none;
}
.workflow-properties {
display: flex;
flex-direction: column;
+76 -1
View File
@@ -3532,6 +3532,81 @@
"canvasTools": "Canvas tools",
"moreActions": "More",
"deleteWorkflow": "Delete workflow",
"ai": {
"open": "Create from natural language",
"title": "Create Workflow from Natural Language",
"subtitle": "AI generates an editable draft and will not save or run it automatically.",
"promptLabel": "Workflow request",
"promptPlaceholder": "Example: continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear",
"examplesLabel": "Example requests",
"exampleDomain": "Domain monitor",
"exampleReport": "Report approval",
"exampleTriage": "Asset triage",
"includeObjective": "Generate objective configuration too",
"allowSchedule": "Allow scheduled trigger suggestions",
"allowHighRisk": "Allow high-risk node drafts",
"allowFallback": "Allow deterministic fallback if AI fails",
"promptRequired": "Describe the workflow request first.",
"generateFailed": "Generation failed",
"generatedWithIssues": "The draft still has validation issues. Adjust the request and try again.",
"generate": "Generate draft",
"generating": "Generating…",
"apply": "Render to canvas",
"rendering": "Rendering…",
"applied": "Workflow draft generated. Review it before saving.",
"canvasRendering": "AI is composing the canvas…",
"generatorAI": "AI channel",
"generatorLLM": "Model generated",
"generatorServer": "Server draft generator",
"generatorFallback": "Deterministic fallback",
"generatorUnknown": "Unknown generator",
"llmTitle": "Generated with the platform default AI channel and structurally validated",
"serverTitle": "Generated on the server and structurally audited",
"fallbackTitle": "Deterministic fallback was used",
"serverFallbackNotice": "Model generation is unavailable: {{reason}}",
"preview": "Flow preview",
"resultNodes": "Nodes",
"resultEdges": "Edges",
"resultRepairs": "Repair rounds",
"resultCapabilities": "Node capabilities",
"resultTools": "Tool capabilities",
"resultRisk": "Risk",
"resultSaveState": "Savable",
"yes": "Yes",
"no": "No",
"missingFields": "Missing configuration",
"riskWarnings": "Risk warnings",
"assumptions": "Assumptions",
"capabilityTrace": "Capability toolchain",
"nodeGenerated": "AI generated",
"nodeNeedsInput": "Needs input",
"riskLow": "Low",
"riskMedium": "Medium",
"riskHigh": "High",
"steps": {
"understand": "Understand request",
"match": "Match tools",
"draft": "Generate nodes",
"audit": "Safety audit"
},
"examples": {
"domainMonitor": "Continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear",
"reportReview": "Collect threat intelligence every day, generate a report, and send it to the security owner for approval",
"assetTriage": "Extract high-risk assets from scan results, deduplicate them, and output remediation suggestions"
}
},
"audit": {
"title": "Draft Audit",
"ready": "No blocking items found in this draft",
"review": "Review before saving or running",
"aiNodes": "AI nodes",
"needsInput": "Needs input",
"highRisk": "High risk",
"validation": "Structure issues",
"nodeIssues": "Node notes",
"validationIssues": "Structure checks",
"saveConfirm": "This draft contains missing configuration or high-risk nodes. Save as draft anyway?"
},
"package": {
"importLocal": "Import local package",
"export": "Export",
@@ -3641,7 +3716,7 @@
"deleteSelected": "Delete selected",
"autoLayout": "Auto layout",
"dryRun": "Dry run",
"canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly",
"canvasEmpty": "Drag nodes from the left onto the canvas, or generate a workflow from natural language",
"properties": "Properties",
"nodeProperties": "Node properties",
"edgeProperties": "Edge properties",
+76 -1
View File
@@ -3520,6 +3520,81 @@
"canvasTools": "画布工具",
"moreActions": "更多",
"deleteWorkflow": "删除工作流",
"ai": {
"open": "用自然语言创建",
"title": "用自然语言创建工作流",
"subtitle": "AI 会生成可编辑草稿,不会自动保存或运行。",
"promptLabel": "工作流需求",
"promptPlaceholder": "例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告",
"examplesLabel": "示例需求",
"exampleDomain": "域名监控",
"exampleReport": "报告审批",
"exampleTriage": "资产研判",
"includeObjective": "同时生成 objective 配置",
"allowSchedule": "允许生成定时触发建议",
"allowHighRisk": "允许包含高风险节点草稿",
"allowFallback": "AI 失败时允许确定性兜底",
"promptRequired": "请先描述工作流需求。",
"generateFailed": "生成失败",
"generatedWithIssues": "草稿仍有校验问题,请调整需求后重试。",
"generate": "生成草稿",
"generating": "正在生成…",
"apply": "渲染到画布",
"rendering": "正在渲染…",
"applied": "已生成工作流草稿,请检查后保存。",
"canvasRendering": "AI 正在编排画布…",
"generatorAI": "AI 通道",
"generatorLLM": "大模型生成",
"generatorServer": "服务端草稿生成器",
"generatorFallback": "确定性兜底",
"generatorUnknown": "未知生成器",
"llmTitle": "已调用平台默认 AI 通道生成,并完成结构校验",
"serverTitle": "已通过服务端生成并完成结构审计",
"fallbackTitle": "已使用确定性兜底",
"serverFallbackNotice": "大模型生成不可用:{{reason}}",
"preview": "流程预览",
"resultNodes": "节点",
"resultEdges": "连线",
"resultRepairs": "修复轮次",
"resultCapabilities": "节点能力",
"resultTools": "工具能力",
"resultRisk": "风险",
"resultSaveState": "可保存",
"yes": "是",
"no": "否",
"missingFields": "缺失配置",
"riskWarnings": "风险提示",
"assumptions": "生成假设",
"capabilityTrace": "能力工具链",
"nodeGenerated": "AI 生成",
"nodeNeedsInput": "需补配置",
"riskLow": "低",
"riskMedium": "中",
"riskHigh": "高",
"steps": {
"understand": "理解需求",
"match": "匹配工具",
"draft": "生成节点",
"audit": "安全审计"
},
"examples": {
"domainMonitor": "持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告",
"reportReview": "每天收集威胁情报,生成报告后交给安全负责人审批",
"assetTriage": "从扫描结果中提取高风险资产,去重后输出处置建议"
}
},
"audit": {
"title": "草稿审计",
"ready": "当前草稿未发现阻断项",
"review": "保存或运行前建议检查",
"aiNodes": "AI 节点",
"needsInput": "需补配置",
"highRisk": "高风险",
"validation": "结构问题",
"nodeIssues": "节点提示",
"validationIssues": "结构校验",
"saveConfirm": "当前草稿包含需补配置或高风险节点,确认仅保存为草稿?"
},
"package": {
"importLocal": "导入本地包",
"export": "导出",
@@ -3629,7 +3704,7 @@
"deleteSelected": "删除选中",
"autoLayout": "自动布局",
"dryRun": "试运行",
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
"canvasEmpty": "从左侧拖拽节点到画布,或用自然语言生成工作流",
"properties": "属性",
"nodeProperties": "节点属性",
"edgeProperties": "连线属性",
+67 -7
View File
@@ -3584,13 +3584,14 @@ function setPendingMcpExecutionIds(messageElement, executionIds) {
function normalizeToolExecutionSummaryForButton(raw) {
const data = raw && typeof raw === 'object' ? raw : {};
return {
toolName: data.toolName || data.name || '',
status: data.status || '',
executionId: data.executionId || '',
toolCallId: data.toolCallId || '',
processDetailId: data.processDetailId || ''
};
return {
toolName: data.toolName || data.name || '',
status: data.status || '',
executionId: data.executionId || '',
toolCallId: data.toolCallId || '',
processDetailId: data.processDetailId || '',
resultDetailId: data.resultDetailId || ''
};
}
function cacheToolExecutionSummaries(messageElement, summaries) {
@@ -3924,6 +3925,15 @@ async function fetchProcessDetailDataForModal(detailId) {
return detail && detail.data ? detail.data : null;
}
async function fetchProcessDetailForModal(detailId) {
const id = detailId != null ? String(detailId).trim() : '';
if (!id || typeof apiFetch !== 'function') return null;
const res = await apiFetch('/api/process-details/' + encodeURIComponent(id));
const j = await res.json().catch(() => ({}));
if (!res.ok) return null;
return j && j.processDetail ? j.processDetail : null;
}
function processToolResultTextFromData(resultData) {
if (!resultData) return '';
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
@@ -3950,6 +3960,56 @@ function processToolResultToMCPResult(resultData, rawText) {
}
async function showMCPDetailFromProcessToolItem(messageElement, summary, index) {
const item = normalizeToolExecutionSummaryForButton(summary);
if (item.processDetailId) {
const callDetail = await fetchProcessDetailForModal(item.processDetailId);
const callData = callDetail && callDetail.data ? callDetail.data : null;
if (callData) {
const args = typeof window.parseToolCallArgsFromData === 'function'
? window.parseToolCallArgsFromData(callData)
: (callData.argumentsObj || {});
let resultData = callData._mergedResult || null;
let resultDetailId = item.resultDetailId || callData._mergedResultDetailId || '';
let rawText = resultData ? processToolResultTextFromData(resultData) : '';
const resultPayloadDeferred = resultData && resultData._payloadDeferred === true;
const resultOnlyHasPreview = resultData && resultData.result == null && resultData.error == null && resultData.resultPreview != null;
if (resultDetailId && (!resultData || resultPayloadDeferred || resultOnlyHasPreview)) {
const resultDetail = await fetchProcessDetailForModal(resultDetailId);
const fullResult = resultDetail && resultDetail.data ? resultDetail.data : null;
if (fullResult) {
resultData = fullResult;
rawText = processToolResultTextFromData(fullResult);
}
}
const displayState = resultData && typeof window.getToolResultDisplayState === 'function'
? window.getToolResultDisplayState(resultData, { rawText: rawText })
: null;
const backgroundRunning = (displayState && displayState.kind === 'background_running') || String(item.status || '').toLowerCase() === 'background_running';
const success = resultData
? !(displayState ? displayState.isError : (resultData.isError || resultData.success === false))
: String(item.status || '').toLowerCase() !== 'failed';
const status = backgroundRunning
? 'background_running'
: (resultData || item.status
? (success ? 'completed' : 'failed')
: 'running');
const exec = {
id: (resultData && resultData.executionId) || item.executionId || callData.executionId || '',
toolName: item.toolName || callData.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'),
status: status,
startTime: callDetail.createdAt || '',
arguments: args || {},
result: processToolResultToMCPResult(resultData, rawText),
error: resultData && resultData.error ? String(resultData.error) : ''
};
openAppModal('mcp-detail-modal', { focus: false });
deferModalContent(function () {
renderMCPDetailModal(exec);
});
return;
}
}
const target = await findToolExecutionTimelineItem(messageElement, summary, index);
if (!target) {
alert(typeof window.t === 'function'
+1 -1
View File
@@ -5681,7 +5681,7 @@ function updateMonitorTimelineSection() {
}
const MCP_STATS_TOP_N = 3;
const MCP_STATS_TOP_N = 6;
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
function getMcpMonitorTimelineRange() {
+7
View File
@@ -168,6 +168,12 @@ function renderSkillsList() {
? _t('skills.cardFiles', { count: skill.file_count })
: '';
const metaItems = [ver, fc, sc].filter(Boolean);
const tags = Array.isArray(skill.tags) ? skill.tags.filter(Boolean) : [];
const visibleTags = tags.slice(0, 4);
const hiddenTagCount = Math.max(0, tags.length - visibleTags.length);
const tagsHtml = visibleTags.length
? `<div class="skill-card-tags">${visibleTags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}${hiddenTagCount ? `<span>+${hiddenTagCount}</span>` : ''}</div>`
: '';
return `
<div class="skill-card">
<div class="skill-card-body">
@@ -177,6 +183,7 @@ function renderSkillsList() {
${metaItems.length ? `<div class="skill-card-meta">${metaItems.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>` : ''}
</div>
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
${tagsHtml}
</div>
<div class="skill-card-actions">
<button type="button" class="btn-secondary btn-small" data-skill-view="${escapeHtml(sid)}">${_t('common.view')}</button>
+586 -4
View File
@@ -37,6 +37,12 @@
requestSignature: '',
dragDepth: 0
};
const workflowAiState = {
draft: null,
result: null,
activeStep: '',
animating: false
};
const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id';
const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id';
@@ -67,6 +73,18 @@
const NODE_PLACEMENT_PADDING = 20;
const WORKFLOW_EDIT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
const WORKFLOW_AI_TOOL_HINTS = [
{ keywords: ['子域名', 'subdomain', 'subfinder', 'amass'], tools: ['subfinder', 'amass'], label: '子域名发现' },
{ keywords: ['端口', 'port', 'nmap', 'rustscan', 'masscan'], tools: ['nmap', 'rustscan', 'masscan'], label: '端口扫描' },
{ keywords: ['漏洞', 'vuln', '漏洞扫描', 'nuclei', 'nikto', 'zap'], tools: ['nuclei', 'nikto', 'zap'], label: '漏洞扫描' },
{ keywords: ['目录', '路径', '暴露页面', 'dir', 'ffuf', 'gobuster', 'feroxbuster'], tools: ['ffuf', 'gobuster', 'feroxbuster', 'dirsearch'], label: '暴露面探测' },
{ keywords: ['证书', 'certificate', 'crt'], tools: ['subfinder'], label: '证书与域名线索收集' },
{ keywords: ['云', 'cloud', '配置审计', 'prowler', 'scout'], tools: ['prowler', 'scout-suite'], label: '云配置审计' },
{ keywords: ['容器', '镜像', 'k8s', 'kubernetes', 'trivy', 'kube'], tools: ['trivy', 'kube-bench', 'kube-hunter'], label: '容器安全检查' },
{ keywords: ['情报', '威胁情报', 'threat', 'ioc', 'virustotal', 'shodan', 'fofa'], tools: ['virustotal_search', 'shodan_search', 'fofa_search'], label: '威胁情报收集' }
];
const WORKFLOW_AI_HIGH_RISK_RE = /(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)/i;
const WORKFLOW_AI_PROGRESS_STEPS = ['understand', 'match', 'draft', 'audit'];
function esc(text) {
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
@@ -166,6 +184,10 @@
function graphToElements(graph) {
const nodes = (graph.nodes || []).map((node, index) => ({
group: 'nodes',
classes: [
node.config && node.config.generated_by === 'natural_language' ? 'ai-generated' : '',
node.config && (node.config.risk_level === 'high' || node.config.requires_human_confirmation === 'true') ? 'high-risk' : ''
].filter(Boolean).join(' '),
data: {
id: node.id || `node-${index + 1}`,
label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }),
@@ -265,6 +287,8 @@
{ selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } },
{ selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } },
{ selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } },
{ selector: 'node.ai-generated', style: { 'border-width': 2, 'border-color': '#38bdf8' } },
{ selector: 'node.high-risk', style: { 'border-width': 3, 'border-color': '#f97316' } },
{
selector: 'edge',
style: {
@@ -1350,6 +1374,559 @@
}
}
function workflowAiOption(id) {
const el = document.getElementById(id);
return !!(el && el.checked);
}
function workflowAiSleep(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
function workflowAiStepLabel(step) {
return _t('workflows.ai.steps.' + step);
}
function workflowAiSetProgress(activeStep, done) {
workflowAiState.activeStep = activeStep || '';
const wrap = document.getElementById('workflow-ai-progress');
if (!wrap) return;
if (!activeStep && !done) {
wrap.hidden = true;
wrap.innerHTML = '';
return;
}
wrap.hidden = false;
const activeIndex = WORKFLOW_AI_PROGRESS_STEPS.indexOf(activeStep);
wrap.innerHTML = WORKFLOW_AI_PROGRESS_STEPS.map(function (step, index) {
const complete = done || (activeIndex >= 0 && index < activeIndex);
const active = !done && step === activeStep;
return `<span class="${complete ? 'is-complete' : ''} ${active ? 'is-active' : ''}">
<b>${complete ? '✓' : index + 1}</b>
<small>${esc(workflowAiStepLabel(step))}</small>
</span>`;
}).join('');
}
function workflowAiPreviewItems(graph) {
const nodes = (graph.nodes || []).slice().sort(function (a, b) {
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
return ax === bx ? ay - by : ax - bx;
});
return nodes.slice(0, 9);
}
function workflowAiPreviewHtml(graph) {
const items = workflowAiPreviewItems(graph);
if (!items.length) return '';
return `<div class="workflow-ai-preview" aria-label="${esc(_t('workflows.ai.preview'))}">
<div class="workflow-ai-preview-title">${esc(_t('workflows.ai.preview'))}</div>
<div class="workflow-ai-preview-flow">
${items.map(function (node, index) {
const type = node.type || 'tool';
const cfg = node.config || {};
const risky = cfg.risk_level === 'high' || cfg.requires_human_confirmation === 'true';
return `<span class="workflow-ai-preview-node is-${esc(type)} ${risky ? 'is-risky' : ''}">
<small>${esc(wfNodeLabel(type))}</small>
<strong>${esc(node.label || wfNodeLabel(type))}</strong>
</span>${index < items.length - 1 ? '<i aria-hidden="true">→</i>' : ''}`;
}).join('')}
</div>
</div>`;
}
function workflowAiSlug(text) {
const raw = String(text || '').trim().toLowerCase();
const ascii = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (ascii) return ascii.slice(0, 48);
let hash = 0;
for (let i = 0; i < raw.length; i++) hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
return 'ai-workflow-' + Math.abs(hash || Date.now()).toString(36);
}
function workflowAiMatchTool(toolNames) {
if (!workflowToolOptions.length) return '';
const enabled = workflowToolOptions.filter(tool => tool.enabled !== false);
const all = enabled.length ? enabled : workflowToolOptions;
for (const wanted of toolNames || []) {
const lower = String(wanted || '').toLowerCase();
const found = all.find(tool => String(tool.key || '').toLowerCase().includes(lower));
if (found) return found.key;
}
return '';
}
function workflowAiDetectCapabilities(prompt) {
const lower = String(prompt || '').toLowerCase();
const capabilities = [];
WORKFLOW_AI_TOOL_HINTS.forEach(function (hint) {
if (hint.keywords.some(keyword => lower.indexOf(String(keyword).toLowerCase()) !== -1)) {
capabilities.push({
label: hint.label,
tool_name: workflowAiMatchTool(hint.tools),
tool_candidates: hint.tools.slice()
});
}
});
if (!capabilities.length) {
capabilities.push({ label: _t('workflows.ai.resultCapabilities'), tool_name: '', tool_candidates: [] });
}
return capabilities;
}
function workflowAiNode(id, type, label, x, y, config) {
return {
id,
type,
label,
position: { x, y },
config: Object.assign(configWithDefaults(type, {}), config || {}, {
generated_by: 'natural_language',
needs_review: 'true'
})
};
}
function workflowAiEdge(id, source, target, label, config) {
return {
id,
source,
target,
label: label || '',
config: config || {}
};
}
function workflowAiBuildDraft(prompt, options) {
const capabilities = workflowAiDetectCapabilities(prompt);
const wantsApproval = /(审批|确认|审核|负责人|人工|review|approve|approval|human)/i.test(prompt);
const wantsReport = /(报告|汇总|输出|通知|任务|工单|report|summary|notify|ticket)/i.test(prompt);
const wantsCondition = /(如果|发现|存在|高危|新增|失败|通过|否则|if|when|high|critical|new|fail)/i.test(prompt);
const highRisk = WORKFLOW_AI_HIGH_RISK_RE.test(prompt);
const nodes = [];
const edges = [];
const assumptions = [];
const riskWarnings = [];
let x = 120;
const y = 150;
let nodeIndex = 1;
let edgeIndex = 1;
let openConditionId = '';
function nextId(prefix) {
const id = prefix + '-' + nodeIndex;
nodeIndex += 1;
return id;
}
function add(type, label, config, yy) {
const id = nextId(type);
nodes.push(workflowAiNode(id, type, label, x, yy || y, config));
x += 210;
return id;
}
function connect(source, target, label, config) {
edges.push(workflowAiEdge('edge-ai-' + edgeIndex, source, target, label, config));
edgeIndex += 1;
}
const start = add('start', wfNodeLabel('start'), { input_keys: 'message, conversationId, projectId, target' });
let previous = start;
capabilities.forEach(function (capability) {
const hasTool = !!capability.tool_name;
const id = add(hasTool ? 'tool' : 'agent', capability.label, hasTool ? {
tool_name: capability.tool_name,
arguments: '{"target":"{{inputs.target}}","message":"{{inputs.message}}"}',
timeout_seconds: '120',
join_strategy: 'all_merge'
} : {
agent_mode: 'eino_single',
input_binding: { from: 'previous', field: 'output' },
instruction: capability.label + '。根据用户需求执行安全流程步骤,并输出结构化结果:' + prompt,
output_key: 'agent_result',
join_strategy: 'all_merge',
missing_tool_candidates: capability.tool_candidates.join(', ')
});
connect(previous, id);
previous = id;
if (!hasTool && capability.tool_candidates.length) {
assumptions.push(capability.label + ' 未匹配到已启用工具,已生成 Agent 草稿节点。');
}
});
if (wantsCondition) {
const condition = add('condition', highRisk ? '是否需要高风险处置' : '是否满足触发条件', {
expression: highRisk ? '{{previous.output}} contains "高危"' : '{{previous.output}} != ""',
join_strategy: 'all_merge'
});
connect(previous, condition);
openConditionId = condition;
const report = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
output_key: 'result',
source_binding: { from: 'previous', field: 'output' },
static_value: '',
join_strategy: 'all_merge'
}, y + 130);
connect(condition, report, _t('workflows.edges.no'), { condition: '{{previous.matched}} == "false"', branch: 'false' });
previous = condition;
}
if (highRisk) {
let insertedApproval = false;
if (!options.allowHighRisk || wantsApproval) {
const approval = add('hitl', '人工审批', {
prompt: '请确认是否允许继续执行高风险处置:' + prompt,
prompt_binding: { from: 'previous', field: 'output' },
reviewer: 'human',
join_strategy: 'all_merge',
risk_level: 'high'
});
connect(previous, approval, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
if (previous === openConditionId) openConditionId = '';
previous = approval;
insertedApproval = true;
}
const action = add('agent', '执行受控处置', {
agent_mode: 'eino_single',
input_binding: { from: 'previous', field: 'output' },
instruction: '仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:' + prompt,
output_key: 'remediation_plan',
join_strategy: 'all_merge',
risk_level: 'high',
requires_human_confirmation: 'true'
});
connect(previous, action, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
if (previous === openConditionId) openConditionId = '';
previous = action;
riskWarnings.push(insertedApproval
? '检测到高风险动作,已加入人工确认与 requires_human_confirmation 标记。'
: '检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。');
} else if (wantsApproval && !nodes.some(node => node.type === 'hitl')) {
const approval = add('hitl', '人工审批', {
prompt: '请审核工作流阶段结果:' + prompt,
prompt_binding: { from: 'previous', field: 'output' },
reviewer: 'human',
join_strategy: 'all_merge'
});
connect(previous, approval);
previous = approval;
}
const output = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
output_key: 'result',
source_binding: { from: 'previous', field: 'output' },
static_value: '',
join_strategy: 'all_merge'
});
connect(previous, output, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
const graph = {
nodes,
edges,
config: {
schema_version: 1,
generated_by: 'natural_language',
source_prompt: prompt,
objective: options.includeObjective ? prompt : ''
}
};
if (options.allowSchedule && /(每天|每周|定时|周期|持续|daily|weekly|schedule|monitor)/i.test(prompt)) {
graph.config.trigger_suggestion = /(每天|daily)/i.test(prompt) ? 'daily' : 'scheduled';
assumptions.push('已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。');
}
return {
graph,
meta: {
id: workflowAiSlug(prompt),
name: prompt.length > 22 ? prompt.slice(0, 22) + '...' : prompt,
description: prompt,
enabled: true
},
generator: 'deterministic-fallback',
audit: {
risk_warnings: riskWarnings,
assumptions: assumptions,
high_risk: highRisk,
needs_hitl: nodes.some(node => node.type === 'hitl'),
savable: validateWorkflowGraph(graph).length === 0
},
assumptions,
riskWarnings,
capabilities,
stats: { nodes: nodes.length, edges: edges.length }
};
}
function workflowAiAvailableToolsPayload() {
return (workflowToolOptions || []).map(function (tool) {
return {
key: tool.key || tool.name || '',
name: tool.name || '',
enabled: tool.enabled !== false
};
}).filter(function (tool) {
return tool.key || tool.name;
});
}
async function workflowAiGenerateDraftOnServer(prompt, options) {
const response = await apiFetch('/api/workflows/generate-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
options: {
include_objective: !!options.includeObjective,
allow_schedule: !!options.allowSchedule,
allow_high_risk: !!options.allowHighRisk
},
available_tools: workflowAiAvailableToolsPayload()
})
});
const data = await response.json().catch(function () { return {}; });
if (!response.ok) {
throw new Error(data.error || _t('workflows.ai.generateFailed'));
}
const result = data.result || data;
if (!result || !result.graph) {
throw new Error(_t('workflows.ai.generateFailed'));
}
result.generator = result.generator || 'server';
return result;
}
function workflowAiRenderResult(result) {
const target = document.getElementById('workflow-ai-result');
const applyBtn = document.getElementById('workflow-ai-apply-btn');
if (!target) return;
if (!result) {
target.innerHTML = '';
if (applyBtn) applyBtn.disabled = true;
return;
}
const graph = result.graph || defaultGraph();
const errors = validateWorkflowGraph(graph);
const audit = result.audit || {};
const risks = audit.risk_warnings || result.riskWarnings || [];
const assumptions = audit.assumptions || result.assumptions || [];
const missing = audit.missing_fields || [];
const stats = result.stats || {};
const generator = String(result.generator || '');
const generatorKey = generator === 'llm'
? 'workflows.ai.generatorLLM'
: (generator === 'deterministic'
? 'workflows.ai.generatorServer'
: (generator.indexOf('fallback') !== -1 ? 'workflows.ai.generatorFallback' : 'workflows.ai.generatorUnknown'));
const capabilityText = (result.capabilities || []).map(function (cap) {
return cap.label + (cap.tool_name ? ' -> ' + cap.tool_name : (cap.toolName ? ' -> ' + cap.toolName : ''));
});
target.innerHTML = `
${workflowAiPreviewHtml(graph)}
<div class="workflow-ai-result-grid">
<span>${esc(_t('workflows.ai.resultNodes'))}<strong>${stats.nodes || (graph.nodes || []).length}</strong></span>
<span>${esc(_t('workflows.ai.resultEdges'))}<strong>${stats.edges || (graph.edges || []).length}</strong></span>
<span>${esc(_t('workflows.ai.resultRisk'))}<strong>${risks.length ? esc(_t('workflows.ai.riskHigh')) : esc(_t('workflows.ai.riskLow'))}</strong></span>
<span>${esc(_t('workflows.ai.resultSaveState'))}<strong>${errors.length ? esc(_t('workflows.ai.no')) : esc(_t('workflows.ai.yes'))}</strong></span>
</div>
<div class="workflow-ai-result-section"><strong>${esc(_t(generatorKey))}</strong><p>${esc(generator === 'llm' ? _t('workflows.ai.llmTitle') : (generator === 'deterministic' ? _t('workflows.ai.serverTitle') : _t('workflows.ai.fallbackTitle')))}</p></div>
${capabilityText.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.capabilityTrace')) + '</strong><ul>' + capabilityText.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${missing.length ? '<div class="workflow-ai-result-section is-attention"><strong>' + esc(_t('workflows.ai.missingFields')) + '</strong><ul>' + missing.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${assumptions.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.assumptions')) + '</strong><ul>' + assumptions.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${risks.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.ai.riskWarnings')) + '</strong><ul>' + risks.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${errors.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.audit.validationIssues')) + '</strong><ul>' + errors.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
`;
if (applyBtn) applyBtn.disabled = !!errors.length;
}
function workflowAiApplyGraph(result) {
if (!result || !result.graph) return;
fillWorkflowForm({
id: '',
name: result.meta.name,
description: result.meta.description,
enabled: true,
graph_json: result.graph
});
syncWorkflowMetaIdField(false, result.meta.id);
updateWorkflowCanvasTitle();
if (cy && cy.nodes().length) {
cy.fit(cy.elements(), 60);
const generated = cy.nodes('.ai-generated');
if (generated.length) {
generated.addClass('just-added');
const risky = generated.filter('.high-risk');
if (risky.length) risky.select();
setTimeout(function () {
if (cy) cy.nodes('.ai-generated').removeClass('just-added');
}, 1200);
}
}
}
function workflowAiSortedNodes(graph) {
return (graph.nodes || []).slice().sort(function (a, b) {
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
return ax === bx ? ay - by : ax - bx;
});
}
async function workflowAiAnimateGraph(result) {
if (!result || !result.graph || workflowAiState.animating) return;
workflowAiState.animating = true;
const status = document.getElementById('workflow-ai-canvas-status');
if (status) status.hidden = false;
try {
initCy();
if (!cy) {
workflowAiApplyGraph(result);
return;
}
const graph = parseGraph(result.graph);
syncWorkflowMetaForm({
id: '',
name: result.meta && result.meta.name ? result.meta.name : '',
description: result.meta && result.meta.description ? result.meta.description : '',
enabled: true
});
currentWorkflowId = '';
syncWorkflowMetaIdField(false, result.meta && result.meta.id ? result.meta.id : '');
updateWorkflowCanvasTitle();
resetSequences(graph);
cy.elements().remove();
updateEmptyState();
closeWorkflowDryRunPanel();
renderWorkflowList();
const nodeElements = graphToElements({ nodes: workflowAiSortedNodes(graph), edges: [] });
const edgeElements = graphToElements({ nodes: [], edges: graph.edges || [] });
for (const ele of nodeElements) {
const added = cy.add(ele);
selectWorkflowElement(added);
added.addClass('just-added');
cy.animate({ center: { eles: added }, duration: 180 });
await workflowAiSleep(120);
added.removeClass('just-added');
}
for (const edge of edgeElements) {
const added = cy.add(edge);
added.select();
await workflowAiSleep(80);
added.unselect();
}
if (cy.nodes().length) {
layoutWorkflowGraph(true);
await workflowAiSleep(320);
const risky = cy.nodes('.high-risk');
if (risky.length) {
selectWorkflowElement(risky.first());
} else {
selectWorkflowElement(null);
}
}
updateEmptyState();
} finally {
workflowAiState.animating = false;
if (status) status.hidden = true;
}
}
window.openWorkflowAiModal = function () {
if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return;
workflowAiState.draft = null;
workflowAiState.result = null;
workflowAiSetProgress('', false);
workflowAiRenderResult(null);
const prompt = document.getElementById('workflow-ai-prompt');
const generateBtn = document.getElementById('workflow-ai-generate-btn');
if (generateBtn) generateBtn.disabled = false;
if (typeof openAppModal === 'function') {
openAppModal('workflow-ai-modal', { focusEl: prompt });
}
};
window.closeWorkflowAiModal = function () {
if (typeof closeAppModal === 'function') closeAppModal('workflow-ai-modal');
};
window.useWorkflowAiExample = function (key) {
const prompt = document.getElementById('workflow-ai-prompt');
if (!prompt) return;
prompt.value = _t('workflows.ai.examples.' + key);
prompt.focus();
};
window.generateWorkflowFromNaturalLanguage = async function () {
const promptEl = document.getElementById('workflow-ai-prompt');
const generateBtn = document.getElementById('workflow-ai-generate-btn');
const prompt = promptEl ? promptEl.value.trim() : '';
if (!prompt) {
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.promptRequired'), 'warning');
return;
}
if (generateBtn) {
generateBtn.disabled = true;
generateBtn.textContent = _t('workflows.ai.generating');
}
try {
workflowAiRenderResult(null);
workflowAiSetProgress('understand');
await workflowAiSleep(140);
await loadWorkflowTools();
workflowAiSetProgress('match');
await workflowAiSleep(140);
const options = {
includeObjective: workflowAiOption('workflow-ai-include-objective'),
allowSchedule: workflowAiOption('workflow-ai-allow-schedule'),
allowHighRisk: workflowAiOption('workflow-ai-allow-high-risk')
};
workflowAiSetProgress('draft');
const result = await workflowAiGenerateDraftOnServer(prompt, options);
workflowAiSetProgress('audit');
await workflowAiSleep(120);
workflowAiState.draft = result.graph;
workflowAiState.result = result;
workflowAiRenderResult(result);
workflowAiSetProgress('', true);
if (validateWorkflowGraph(result.graph).length && typeof showNotification === 'function') {
showNotification(_t('workflows.ai.generatedWithIssues'), 'warning');
}
} catch (error) {
workflowAiState.draft = null;
workflowAiState.result = null;
workflowAiSetProgress('', false);
workflowAiRenderResult(null);
if (typeof showNotification === 'function') showNotification(error.message || _t('workflows.ai.generateFailed'), 'error');
} finally {
if (generateBtn) {
generateBtn.disabled = false;
generateBtn.textContent = _t('workflows.ai.generate');
}
}
};
window.applyWorkflowAiDraft = async function () {
if (!workflowAiState.result) return;
const applyBtn = document.getElementById('workflow-ai-apply-btn');
if (applyBtn) {
applyBtn.disabled = true;
applyBtn.textContent = _t('workflows.ai.rendering');
}
closeWorkflowAiModal();
try {
await workflowAiAnimateGraph(workflowAiState.result);
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.applied'), 'success');
} finally {
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.textContent = _t('workflows.ai.apply');
}
}
};
window.refreshWorkflows = async function () {
initCy();
const list = document.getElementById('workflow-list');
@@ -1818,15 +2395,20 @@
window.layoutWorkflowGraph = function (animate) {
if (!cy || !cy.nodes().length) return;
cy.layout({
const layout = cy.layout({
name: 'breadthfirst',
directed: true,
padding: 40,
spacingFactor: 1.25,
animate: animate !== false,
animationDuration: 250
}).run();
cy.fit(undefined, 40);
});
layout.one('layoutstop', function () {
if (cy && cy.elements().length) cy.fit(cy.elements(), 40);
});
layout.run();
if (animate === false && cy.elements().length) cy.fit(cy.elements(), 40);
return layout;
};
window.updateWorkflowSelectedProperty = function () {
@@ -2458,7 +3040,7 @@
if (page && typeof window.applyTranslations === 'function') {
window.applyTranslations(page);
}
['workflow-dry-run-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
['workflow-dry-run-modal', 'workflow-ai-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
const modal = document.getElementById(id);
if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal);
});
+41 -1
View File
@@ -3088,6 +3088,7 @@
<div class="page-header-actions">
<button class="btn-secondary" onclick="refreshWorkflows()" data-i18n="common.refresh">刷新</button>
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowPackageImportModal()" data-i18n="workflows.package.importLocal">导入本地包</button>
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()" data-i18n="workflows.ai.open">用自然语言创建</button>
<button class="btn-primary" onclick="newWorkflowDraft()" data-i18n="workflows.newGraph">新建工作流</button>
</div>
</div>
@@ -3139,6 +3140,10 @@
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.4v13.2a1 1 0 0 0 1.55.84l9.2-6.6a1 1 0 0 0 0-1.68l-9.2-6.6A1 1 0 0 0 8 5.4Z"/></svg>
<span class="workflow-toolbar-label" data-i18n="workflows.dryRun">试运行</span>
</button>
<button class="btn-secondary btn-small workflow-toolbar-button workflow-ai-button" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()">
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z"/><path d="M19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z"/></svg>
<span class="workflow-toolbar-label" data-i18n="workflows.ai.generate">生成草稿</span>
</button>
<details class="workflow-more-actions" id="workflow-more-actions" data-require-permission-any="workflow:read workflow:delete">
<summary class="btn-secondary btn-small workflow-more-actions-trigger" aria-haspopup="menu">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
@@ -3164,7 +3169,8 @@
</section>
<section class="workflow-canvas-wrap" ondragover="workflowCanvasDragOver(event)" ondrop="workflowCanvasDrop(event)">
<div id="workflow-canvas"></div>
<div id="workflow-canvas-empty" class="workflow-canvas-empty" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或点击节点按钮快速添加</div>
<div id="workflow-canvas-empty" class="workflow-canvas-empty" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或用自然语言生成工作流</div>
<div id="workflow-ai-canvas-status" class="workflow-ai-canvas-status" hidden data-i18n="workflows.ai.canvasRendering">AI 正在编排画布…</div>
</section>
</main>
<aside class="workflow-properties">
@@ -5568,6 +5574,40 @@
</div>
</form>
</div>
<div id="workflow-ai-modal" class="modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-ai-modal-title" onclick="if(event.target===this)closeWorkflowAiModal()" onkeydown="if(event.key==='Escape')closeWorkflowAiModal()">
<form class="modal-content workflow-ai-modal-content" onsubmit="event.preventDefault(); generateWorkflowFromNaturalLanguage()" onclick="event.stopPropagation()">
<div class="modal-header workflow-ai-modal-header">
<div>
<h2 id="workflow-ai-modal-title" data-i18n="workflows.ai.title">用自然语言创建工作流</h2>
<p class="workflow-ai-subtitle" data-i18n="workflows.ai.subtitle">AI 会生成可编辑草稿,不会自动保存或运行。</p>
</div>
<button type="button" class="modal-close" onclick="closeWorkflowAiModal()" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true" aria-label="关闭"></button>
</div>
<div class="modal-body workflow-ai-modal-body">
<div class="form-group workflow-ai-prompt-field">
<label for="workflow-ai-prompt" data-i18n="workflows.ai.promptLabel">工作流需求</label>
<textarea id="workflow-ai-prompt" class="form-input" rows="5" data-i18n="workflows.ai.promptPlaceholder" data-i18n-attr="placeholder" placeholder="例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告"></textarea>
</div>
<div class="workflow-ai-examples" aria-label="示例需求" data-i18n="workflows.ai.examplesLabel" data-i18n-attr="aria-label">
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('domainMonitor')" data-i18n="workflows.ai.exampleDomain">域名监控</button>
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('reportReview')" data-i18n="workflows.ai.exampleReport">报告审批</button>
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('assetTriage')" data-i18n="workflows.ai.exampleTriage">资产研判</button>
</div>
<div class="workflow-ai-options">
<label><input type="checkbox" id="workflow-ai-include-objective" checked> <span data-i18n="workflows.ai.includeObjective">同时生成 objective 配置</span></label>
<label><input type="checkbox" id="workflow-ai-allow-schedule"> <span data-i18n="workflows.ai.allowSchedule">允许生成定时触发建议</span></label>
<label><input type="checkbox" id="workflow-ai-allow-high-risk"> <span data-i18n="workflows.ai.allowHighRisk">允许包含高风险节点草稿</span></label>
</div>
<div id="workflow-ai-progress" class="workflow-ai-progress" hidden aria-live="polite"></div>
<div id="workflow-ai-result" class="workflow-ai-result" aria-live="polite"></div>
</div>
<div class="modal-footer workflow-ai-modal-footer">
<button type="button" class="btn-secondary btn-small" onclick="closeWorkflowAiModal()" data-i18n="common.cancel">取消</button>
<button id="workflow-ai-apply-btn" type="button" class="btn-secondary btn-small" onclick="applyWorkflowAiDraft()" disabled data-i18n="workflows.ai.apply">渲染到画布</button>
<button id="workflow-ai-generate-btn" type="submit" class="btn-primary btn-small" data-i18n="workflows.ai.generate">生成草稿</button>
</div>
</form>
</div>
<div id="workflow-package-import-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-import-title">
<div class="modal-content workflow-package-modal-content">
<div class="modal-header workflow-package-modal-header">