mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e54815e018 | |||
| 9baa99ea40 | |||
| 83a8c46db1 | |||
| 4b2619e1fe | |||
| 3fffee80f4 | |||
| 41d7afcf99 | |||
| 6431dcb240 |
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.3.7"
|
version: "v1.3.9"
|
||||||
|
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -511,6 +511,8 @@ func setupRoutes(
|
|||||||
|
|
||||||
// 信息收集 - FOFA 查询(后端代理)
|
// 信息收集 - FOFA 查询(后端代理)
|
||||||
protected.POST("/fofa/search", fofaHandler.Search)
|
protected.POST("/fofa/search", fofaHandler.Search)
|
||||||
|
// 信息收集 - 自然语言解析为 FOFA 语法(需人工确认后再查询)
|
||||||
|
protected.POST("/fofa/parse", fofaHandler.ParseNaturalLanguage)
|
||||||
|
|
||||||
// 批量任务管理
|
// 批量任务管理
|
||||||
protected.POST("/batch-tasks", agentHandler.CreateBatchQueue)
|
protected.POST("/batch-tasks", agentHandler.CreateBatchQueue)
|
||||||
|
|||||||
@@ -854,6 +854,7 @@ func (h *ConfigHandler) saveConfig() error {
|
|||||||
updateAgentConfig(root, h.config.Agent.MaxIterations)
|
updateAgentConfig(root, h.config.Agent.MaxIterations)
|
||||||
updateMCPConfig(root, h.config.MCP)
|
updateMCPConfig(root, h.config.MCP)
|
||||||
updateOpenAIConfig(root, h.config.OpenAI)
|
updateOpenAIConfig(root, h.config.OpenAI)
|
||||||
|
updateFOFAConfig(root, h.config.FOFA)
|
||||||
updateKnowledgeConfig(root, h.config.Knowledge)
|
updateKnowledgeConfig(root, h.config.Knowledge)
|
||||||
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
|
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
|
||||||
// 读取原始配置以保持向后兼容
|
// 读取原始配置以保持向后兼容
|
||||||
@@ -998,6 +999,14 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
|
|||||||
setStringInMap(openaiNode, "model", cfg.Model)
|
setStringInMap(openaiNode, "model", cfg.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
|
||||||
|
root := doc.Content[0]
|
||||||
|
fofaNode := ensureMap(root, "fofa")
|
||||||
|
setStringInMap(fofaNode, "base_url", cfg.BaseURL)
|
||||||
|
setStringInMap(fofaNode, "email", cfg.Email)
|
||||||
|
setStringInMap(fofaNode, "api_key", cfg.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
||||||
root := doc.Content[0]
|
root := doc.Content[0]
|
||||||
knowledgeNode := ensureMap(root, "knowledge")
|
knowledgeNode := ensureMap(root, "knowledge")
|
||||||
|
|||||||
+250
-6
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -11,22 +13,31 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
openaiClient "cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FofaHandler struct {
|
type FofaHandler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
openAIClient *openaiClient.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFofaHandler(cfg *config.Config, logger *zap.Logger) *FofaHandler {
|
func NewFofaHandler(cfg *config.Config, logger *zap.Logger) *FofaHandler {
|
||||||
|
// LLM 请求通常比 FOFA 查询更慢一点,单独给一个更宽松的超时。
|
||||||
|
llmHTTPClient := &http.Client{Timeout: 2 * time.Minute}
|
||||||
|
var llmCfg *config.OpenAIConfig
|
||||||
|
if cfg != nil {
|
||||||
|
llmCfg = &cfg.OpenAI
|
||||||
|
}
|
||||||
return &FofaHandler{
|
return &FofaHandler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
openAIClient: openaiClient.NewClient(llmCfg, llmHTTPClient, logger),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +49,16 @@ type fofaSearchRequest struct {
|
|||||||
Full bool `json:"full,omitempty"`
|
Full bool `json:"full,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fofaParseRequest struct {
|
||||||
|
Text string `json:"text" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type fofaParseResponse struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Explanation string `json:"explanation,omitempty"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type fofaAPIResponse struct {
|
type fofaAPIResponse struct {
|
||||||
Error bool `json:"error"`
|
Error bool `json:"error"`
|
||||||
ErrMsg string `json:"errmsg"`
|
ErrMsg string `json:"errmsg"`
|
||||||
@@ -86,6 +107,229 @@ func (h *FofaHandler) resolveBaseURL() string {
|
|||||||
return "https://fofa.info/api/v1/search/all"
|
return "https://fofa.info/api/v1/search/all"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseNaturalLanguage 将自然语言解析为 FOFA 查询语法(仅生成,不执行查询)
|
||||||
|
func (h *FofaHandler) ParseNaturalLanguage(c *gin.Context) {
|
||||||
|
var req fofaParseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Text = strings.TrimSpace(req.Text)
|
||||||
|
if req.Text == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "text 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.cfg == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "系统配置未初始化"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(h.cfg.OpenAI.APIKey) == "" || strings.TrimSpace(h.cfg.OpenAI.Model) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "未配置 AI 模型:请在系统设置中填写 openai.api_key 与 openai.model(支持 OpenAI 兼容 API,如 DeepSeek)",
|
||||||
|
"need": []string{"openai.api_key", "openai.model"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.openAIClient == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "AI 客户端未初始化"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt := strings.TrimSpace(`
|
||||||
|
你是“FOFA 查询语法生成器”。任务:把用户输入的自然语言搜索意图,转换成 FOFA 查询语法。
|
||||||
|
|
||||||
|
输出要求(非常重要):
|
||||||
|
1) 只输出 JSON(不要 markdown、不要代码块、不要额外解释文本)
|
||||||
|
2) JSON 结构必须是:
|
||||||
|
{
|
||||||
|
"query": "string,FOFA查询语法(可直接粘贴到 FOFA 或本系统查询框)",
|
||||||
|
"explanation": "string,可选,解释你如何映射字段/逻辑",
|
||||||
|
"warnings": ["string"...] 可选,列出歧义/风险/需要人工确认的点
|
||||||
|
}
|
||||||
|
3) 如果用户输入本身已经是 FOFA 查询语法(或非常接近 FOFA 语法的表达式),应当“原样返回”为 query:
|
||||||
|
- 不要擅自改写字段名、操作符、括号结构
|
||||||
|
- 不要改写任何字符串值(尤其是地理位置类值),不要做缩写/同义词替换/翻译/音译
|
||||||
|
|
||||||
|
查询语法要点(来自 FOFA 语法参考):
|
||||||
|
- 逻辑连接符:&&(与)、||(或),必要时用 () 包住子表达式以确认优先级(括号优先级最高)
|
||||||
|
- 当同一层级同时出现 && 与 ||(混用)时,用 () 明确优先级(避免歧义)
|
||||||
|
- 比较/匹配:
|
||||||
|
- = 匹配;当字段="" 时,可查询“不存在该字段”或“值为空”的情况
|
||||||
|
- == 完全匹配;当字段=="" 时,可查询“字段存在且值为空”的情况
|
||||||
|
- != 不匹配;当字段!="" 时,可查询“值不为空”的情况
|
||||||
|
- *= 模糊匹配;可使用 * 或 ? 进行搜索
|
||||||
|
- 直接输入关键词(不带字段)会在标题、HTML内容、HTTP头、URL字段中搜索;但当意图明确时优先用字段表达(更可控、更准确)
|
||||||
|
|
||||||
|
字段示例速查(来自用户提供的案例,可直接套用/拼接):
|
||||||
|
- 高级搜索操作符示例:
|
||||||
|
- title="beijing" (= 匹配)
|
||||||
|
- title=="" (== 完全匹配,字段存在且值为空)
|
||||||
|
- title="" (= 匹配,可能表示字段不存在或值为空)
|
||||||
|
- title!="" (!= 不匹配,可用于值不为空)
|
||||||
|
- title*="*Home*" (*= 模糊匹配,用 * 或 ?)
|
||||||
|
- (app="Apache" || app="Nginx") && country="CN" (混用 && / || 时用括号)
|
||||||
|
- 基础类(General):
|
||||||
|
- ip="1.1.1.1"
|
||||||
|
- ip="220.181.111.1/24"
|
||||||
|
- ip="2600:9000:202a:2600:18:4ab7:f600:93a1"
|
||||||
|
- port="6379"
|
||||||
|
- domain="qq.com"
|
||||||
|
- host=".fofa.info"
|
||||||
|
- os="centos"
|
||||||
|
- server="Microsoft-IIS/10"
|
||||||
|
- asn="19551"
|
||||||
|
- org="LLC Baxet"
|
||||||
|
- is_domain=true / is_domain=false
|
||||||
|
- is_ipv6=true / is_ipv6=false
|
||||||
|
- 标记类(Special Label):
|
||||||
|
- app="Microsoft-Exchange"
|
||||||
|
- fid="sSXXGNUO2FefBTcCLIT/2Q=="
|
||||||
|
- product="NGINX"
|
||||||
|
- product="Roundcube-Webmail" && product.version="1.6.10"
|
||||||
|
- category="服务"
|
||||||
|
- type="service" / type="subdomain"
|
||||||
|
- cloud_name="Aliyundun"
|
||||||
|
- is_cloud=true / is_cloud=false
|
||||||
|
- is_fraud=true / is_fraud=false
|
||||||
|
- is_honeypot=true / is_honeypot=false
|
||||||
|
- 协议类(type=service):
|
||||||
|
- protocol="quic"
|
||||||
|
- banner="users"
|
||||||
|
- banner_hash="7330105010150477363"
|
||||||
|
- banner_fid="zRpqmn0FXQRjZpH8MjMX55zpMy9SgsW8"
|
||||||
|
- base_protocol="udp" / base_protocol="tcp"
|
||||||
|
- 网站类(type=subdomain):
|
||||||
|
- title="beijing"
|
||||||
|
- header="elastic"
|
||||||
|
- header_hash="1258854265"
|
||||||
|
- body="网络空间测绘"
|
||||||
|
- body_hash="-2090962452"
|
||||||
|
- js_name="js/jquery.js"
|
||||||
|
- js_md5="82ac3f14327a8b7ba49baa208d4eaa15"
|
||||||
|
- cname="customers.spektrix.com"
|
||||||
|
- cname_domain="siteforce.com"
|
||||||
|
- icon_hash="-247388890"
|
||||||
|
- status_code="402"
|
||||||
|
- icp="京ICP证030173号"
|
||||||
|
- sdk_hash="Are3qNnP2Eqn7q5kAoUO3l+w3mgVIytO"
|
||||||
|
- 地理位置(Location):
|
||||||
|
- country="CN" 或 country="中国"
|
||||||
|
- region="Zhejiang" 或 region="浙江"(仅支持中国地区中文)
|
||||||
|
- city="Hangzhou"
|
||||||
|
- 证书类(Certificate):
|
||||||
|
- cert="baidu"
|
||||||
|
- cert.subject="Oracle Corporation"
|
||||||
|
- cert.issuer="DigiCert"
|
||||||
|
- cert.subject.org="Oracle Corporation"
|
||||||
|
- cert.subject.cn="baidu.com"
|
||||||
|
- cert.issuer.org="cPanel, Inc."
|
||||||
|
- cert.issuer.cn="Synology Inc. CA"
|
||||||
|
- cert.domain="huawei.com"
|
||||||
|
- cert.is_equal=true / cert.is_equal=false
|
||||||
|
- cert.is_valid=true / cert.is_valid=false
|
||||||
|
- cert.is_match=true / cert.is_match=false
|
||||||
|
- cert.is_expired=true / cert.is_expired=false
|
||||||
|
- jarm="2ad2ad0002ad2ad22c2ad2ad2ad2ad2eac92ec34bcc0cf7520e97547f83e81"
|
||||||
|
- tls.version="TLS 1.3"
|
||||||
|
- tls.ja3s="15af977ce25de452b96affa2addb1036"
|
||||||
|
- cert.sn="356078156165546797850343536942784588840297"
|
||||||
|
- cert.not_after.after="2025-03-01" / cert.not_after.before="2025-03-01"
|
||||||
|
- cert.not_before.after="2025-03-01" / cert.not_before.before="2025-03-01"
|
||||||
|
- 时间类(Last update time):
|
||||||
|
- after="2023-01-01"
|
||||||
|
- before="2023-12-01"
|
||||||
|
- after="2023-01-01" && before="2023-12-01"
|
||||||
|
- 独立IP语法(需配合 ip_filter / ip_exclude):
|
||||||
|
- ip_filter(banner="SSH-2.0-OpenSSH_6.7p2") && ip_filter(icon_hash="-1057022626")
|
||||||
|
- ip_filter(banner="SSH-2.0-OpenSSH_6.7p2" && asn="3462") && ip_exclude(title="EdgeOS")
|
||||||
|
- port_size="6" / port_size_gt="6" / port_size_lt="12"
|
||||||
|
- ip_ports="80,161"
|
||||||
|
- ip_country="CN"
|
||||||
|
- ip_region="Zhejiang"
|
||||||
|
- ip_city="Hangzhou"
|
||||||
|
- ip_after="2021-03-18"
|
||||||
|
- ip_before="2019-09-09"
|
||||||
|
|
||||||
|
生成约束与注意事项:
|
||||||
|
- 字符串值一律用英文双引号包裹,例如 title="登录"、country="CN"
|
||||||
|
- 字符串值保持字面一致:不要缩写(例如 city="beijing" 不要变成 city="BJ"),不要用别名(例如 Beijing/Peking),不要擅自翻译/音译/改写大小写
|
||||||
|
- 地理位置字段(country/region/city)更倾向于“按用户给定值输出”;不确定合法取值时,不要猜测,把备选写进 warnings
|
||||||
|
- 不要捏造不存在的 FOFA 字段;不确定时把不确定点写进 warnings,并输出一个保守的 query
|
||||||
|
- 当用户描述里有“多个与/或条件”,优先加 () 明确优先级,例如:(app="Apache" || app="Nginx") && country="CN"
|
||||||
|
- 当用户缺少关键条件导致范围过大或歧义(如地点/协议/端口/服务类型未说明),允许 query 为空字符串,并在 warnings 里明确需要补充的信息
|
||||||
|
`)
|
||||||
|
|
||||||
|
userPrompt := fmt.Sprintf("自然语言意图:%s", req.Text)
|
||||||
|
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"model": h.cfg.OpenAI.Model,
|
||||||
|
"messages": []map[string]interface{}{
|
||||||
|
{"role": "system", "content": systemPrompt},
|
||||||
|
{"role": "user", "content": userPrompt},
|
||||||
|
},
|
||||||
|
"temperature": 0.1,
|
||||||
|
"max_tokens": 1200,
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI 返回结构:只需要 choices[0].message.content
|
||||||
|
var apiResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := h.openAIClient.ChatCompletion(ctx, requestBody, &apiResponse); err != nil {
|
||||||
|
var apiErr *openaiClient.APIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
h.logger.Warn("FOFA自然语言解析:LLM返回错误", zap.Int("status", apiErr.StatusCode))
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败(上游返回非 200),请检查模型配置或稍后重试"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 解析失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "AI 未返回有效结果"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
|
||||||
|
// 兼容模型偶尔返回 ```json ... ``` 的情况
|
||||||
|
content = strings.TrimPrefix(content, "```json")
|
||||||
|
content = strings.TrimPrefix(content, "```")
|
||||||
|
content = strings.TrimSuffix(content, "```")
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
|
||||||
|
var parsed fofaParseResponse
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||||
|
// 直接回传一部分原文,方便排查,但避免太大
|
||||||
|
snippet := content
|
||||||
|
if len(snippet) > 1200 {
|
||||||
|
snippet = snippet[:1200]
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{
|
||||||
|
"error": "AI 返回内容无法解析为 JSON,请稍后重试或换个描述方式",
|
||||||
|
"snippet": snippet,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsed.Query = strings.TrimSpace(parsed.Query)
|
||||||
|
if parsed.Query == "" {
|
||||||
|
// query 允许为空(表示需求不明确),但前端需要明确提示
|
||||||
|
if len(parsed.Warnings) == 0 {
|
||||||
|
parsed.Warnings = []string{"需求信息不足,未能生成可用的 FOFA 查询语法,请补充关键条件(如国家/端口/产品/域名等)。"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
// Search FOFA 查询(后端代理,避免前端暴露 key)
|
// Search FOFA 查询(后端代理,避免前端暴露 key)
|
||||||
func (h *FofaHandler) Search(c *gin.Context) {
|
func (h *FofaHandler) Search(c *gin.Context) {
|
||||||
var req fofaSearchRequest
|
var req fofaSearchRequest
|
||||||
|
|||||||
+114
-5
@@ -2142,6 +2142,10 @@ header {
|
|||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
|
/* 允许标题在 flex 中收缩/换行,避免把右侧按钮挤压变形 */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
@@ -3854,6 +3858,36 @@ header {
|
|||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 通用按钮加载态(用于耗时请求:AI 解析等) */
|
||||||
|
.btn-loading {
|
||||||
|
opacity: 0.9;
|
||||||
|
cursor: progress;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loading::before {
|
||||||
|
content: '';
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.18);
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
animation: btnSpin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btnSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fofa-nl-status {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background: rgba(220, 53, 69, 0.08);
|
background: rgba(220, 53, 69, 0.08);
|
||||||
@@ -7808,6 +7842,27 @@ header {
|
|||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 批量队列详情弹窗:标题很长时避免挤压右侧按钮 */
|
||||||
|
#batch-queue-detail-modal .modal-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#batch-queue-detail-modal .modal-header > div {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#batch-queue-detail-modal .modal-header-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
width: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#batch-queue-detail-modal .modal-header-actions button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-queue-tasks-list {
|
.batch-queue-tasks-list {
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -7847,12 +7902,13 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.batch-task-header .btn-small {
|
.batch-task-header .btn-small {
|
||||||
margin-left: auto;
|
margin-left: 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-task-header .batch-task-edit-btn {
|
.batch-task-header .batch-task-edit-btn {
|
||||||
margin-left: 8px;
|
/* 仅把“编辑”(首个操作按钮)推到最右,避免多个按钮都 margin-left:auto 导致挤压/错位 */
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-task-header .batch-task-delete-btn {
|
.batch-task-header .batch-task-delete-btn {
|
||||||
@@ -9303,6 +9359,13 @@ header {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 批量队列详情弹窗:即使在窄屏也保持按钮不“变形” */
|
||||||
|
#batch-queue-detail-modal .modal-header-actions {
|
||||||
|
flex-direction: row;
|
||||||
|
width: auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.attack-chain-action-btn {
|
.attack-chain-action-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -10952,10 +11015,55 @@ header {
|
|||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表单整体增加纵向留白,避免“挤在一起” */
|
||||||
|
.info-collect-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 将每个表单块做成轻量卡片分组(只作用于 info-collect 顶层 form-group) */
|
||||||
|
.info-collect-form > .form-group,
|
||||||
|
.info-collect-form > .info-collect-form-row {
|
||||||
|
padding: 14px 14px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖全局 .form-group textarea(min-height:200px) 的默认大留白 */
|
||||||
|
.form-group textarea.info-collect-query-input {
|
||||||
|
min-height: 36px;
|
||||||
|
max-height: 96px;
|
||||||
|
overflow: hidden; /* 配合 JS 自动增高 */
|
||||||
|
resize: none;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-collect-nl-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-collect-nl-row .info-collect-query-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0; /* 允许在 flex 中收缩,避免撑破布局 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-collect-nl-row button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.info-collect-form-row {
|
.info-collect-form-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.info-collect-nl-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
.info-collect-col-actions {
|
.info-collect-col-actions {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
}
|
}
|
||||||
@@ -10964,7 +11072,8 @@ header {
|
|||||||
.info-collect-form-row {
|
.info-collect-form-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-collect-form-row .form-group {
|
.info-collect-form-row .form-group {
|
||||||
@@ -11106,7 +11215,7 @@ header {
|
|||||||
|
|
||||||
.info-collect-presets {
|
.info-collect-presets {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@@ -11123,7 +11232,7 @@ header {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 6px 10px;
|
padding: 7px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
|||||||
+327
-23
@@ -10,6 +10,11 @@ const infoCollectState = {
|
|||||||
tableBound: false
|
tableBound: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AI 解析(自然语言 -> FOFA)交互状态
|
||||||
|
let fofaParseAbortController = null;
|
||||||
|
let fofaParseSlowTimer = null;
|
||||||
|
let fofaParseToastHandle = null;
|
||||||
|
|
||||||
// HTML转义(如果未定义)
|
// HTML转义(如果未定义)
|
||||||
if (typeof escapeHtml === 'undefined') {
|
if (typeof escapeHtml === 'undefined') {
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -23,6 +28,7 @@ if (typeof escapeHtml === 'undefined') {
|
|||||||
function getFofaFormElements() {
|
function getFofaFormElements() {
|
||||||
return {
|
return {
|
||||||
query: document.getElementById('fofa-query'),
|
query: document.getElementById('fofa-query'),
|
||||||
|
nl: document.getElementById('fofa-nl'),
|
||||||
size: document.getElementById('fofa-size'),
|
size: document.getElementById('fofa-size'),
|
||||||
page: document.getElementById('fofa-page'),
|
page: document.getElementById('fofa-page'),
|
||||||
fields: document.getElementById('fofa-fields'),
|
fields: document.getElementById('fofa-fields'),
|
||||||
@@ -101,20 +107,35 @@ function initInfoCollectPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 单行输入:按内容自动增高(避免默认留空白行)
|
// 自然语言输入:Ctrl/Cmd+Enter 触发解析
|
||||||
const autoGrow = () => {
|
if (els.nl) {
|
||||||
|
els.nl.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
parseFofaNaturalLanguage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// textarea:按内容自动增高(避免默认留空白行)
|
||||||
|
const autoGrowTextarea = (el) => {
|
||||||
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
els.query.style.height = '40px';
|
el.style.height = '36px';
|
||||||
const max = 110;
|
const max = 96;
|
||||||
const h = Math.min(max, els.query.scrollHeight);
|
const h = Math.min(max, el.scrollHeight);
|
||||||
els.query.style.height = `${h}px`;
|
el.style.height = `${h}px`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
els.query.addEventListener('input', autoGrow);
|
els.query.addEventListener('input', () => autoGrowTextarea(els.query));
|
||||||
|
if (els.nl) els.nl.addEventListener('input', () => autoGrowTextarea(els.nl));
|
||||||
// 初始化时也执行一次
|
// 初始化时也执行一次
|
||||||
setTimeout(autoGrow, 0);
|
setTimeout(() => {
|
||||||
|
autoGrowTextarea(els.query);
|
||||||
|
autoGrowTextarea(els.nl);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
// 绑定表格事件(事件委托,只绑定一次)
|
// 绑定表格事件(事件委托,只绑定一次)
|
||||||
bindFofaTableEvents();
|
bindFofaTableEvents();
|
||||||
@@ -206,6 +227,213 @@ async function submitFofaSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function parseFofaNaturalLanguage() {
|
||||||
|
const els = getFofaFormElements();
|
||||||
|
const text = (els.nl?.value || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
alert('请输入自然语言描述');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次点击:取消进行中的解析(避免“以为卡死/失败”)
|
||||||
|
if (fofaParseAbortController) {
|
||||||
|
try { fofaParseAbortController.abort(); } catch (e) { /* ignore */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先创建 controller,避免极快的重复点击触发并发请求
|
||||||
|
fofaParseAbortController = new AbortController();
|
||||||
|
setFofaParseLoading(true, 'AI 解析中...');
|
||||||
|
|
||||||
|
// 持续提示:直到请求完成/取消/失败才消失
|
||||||
|
fofaParseToastHandle = showInlineToast('AI 解析中...(点击按钮可取消)', { duration: 0, id: 'fofa-parse-pending' });
|
||||||
|
|
||||||
|
// 如果超过一小段时间还没返回,再强调“仍在进行中”,降低误判为失败的概率
|
||||||
|
fofaParseSlowTimer = setTimeout(() => {
|
||||||
|
const status = document.getElementById('fofa-nl-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'AI 解析耗时较长,仍在处理中…';
|
||||||
|
status.style.display = 'block';
|
||||||
|
}
|
||||||
|
}, 1800);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiFetch('/api/fofa/parse', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
signal: fofaParseAbortController.signal
|
||||||
|
});
|
||||||
|
const result = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(result.error || `请求失败: ${resp.status}`);
|
||||||
|
}
|
||||||
|
showFofaParseModal(text, result);
|
||||||
|
showInlineToast('AI 解析完成');
|
||||||
|
} catch (e) {
|
||||||
|
// AbortController 取消:不视为失败
|
||||||
|
if (e && (e.name === 'AbortError' || String(e).includes('AbortError'))) {
|
||||||
|
showInlineToast('已取消 AI 解析');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('FOFA 自然语言解析失败:', e);
|
||||||
|
showInlineToast('AI 解析失败:' + (e && e.message ? e.message : String(e)), { duration: 2800 });
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
fofaParseAbortController = null;
|
||||||
|
if (fofaParseSlowTimer) {
|
||||||
|
clearTimeout(fofaParseSlowTimer);
|
||||||
|
fofaParseSlowTimer = null;
|
||||||
|
}
|
||||||
|
if (fofaParseToastHandle && typeof fofaParseToastHandle.remove === 'function') {
|
||||||
|
fofaParseToastHandle.remove();
|
||||||
|
}
|
||||||
|
fofaParseToastHandle = null;
|
||||||
|
setFofaParseLoading(false, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFofaParseLoading(loading, statusText) {
|
||||||
|
const btn = document.getElementById('fofa-nl-parse-btn');
|
||||||
|
const status = document.getElementById('fofa-nl-status');
|
||||||
|
if (btn) {
|
||||||
|
if (loading) {
|
||||||
|
if (!btn.dataset.originalText) btn.dataset.originalText = btn.textContent || 'AI 解析';
|
||||||
|
btn.classList.add('btn-loading');
|
||||||
|
btn.textContent = '取消解析';
|
||||||
|
btn.title = '点击取消 AI 解析';
|
||||||
|
btn.dataset.loading = '1';
|
||||||
|
btn.setAttribute('aria-busy', 'true');
|
||||||
|
btn.disabled = false;
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('btn-loading');
|
||||||
|
btn.textContent = btn.dataset.originalText || 'AI 解析';
|
||||||
|
btn.title = '将自然语言解析为 FOFA 查询语法';
|
||||||
|
btn.disabled = false;
|
||||||
|
delete btn.dataset.loading;
|
||||||
|
btn.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
const text = (statusText || '').trim();
|
||||||
|
if (loading && text) {
|
||||||
|
status.textContent = text;
|
||||||
|
status.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
status.textContent = '';
|
||||||
|
status.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFofaParseModal(nlText, parsed) {
|
||||||
|
const existing = document.getElementById('fofa-parse-modal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const safeNL = escapeHtml((nlText || '').trim());
|
||||||
|
const warnings = Array.isArray(parsed?.warnings) ? parsed.warnings.filter(Boolean).map(x => String(x)) : [];
|
||||||
|
const explanation = parsed?.explanation != null ? String(parsed.explanation) : '';
|
||||||
|
|
||||||
|
const warningsHtml = warnings.length
|
||||||
|
? `<ul style="margin: 8px 0 0 18px;">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
|
||||||
|
: `<div class="muted" style="margin-top: 8px;">无</div>`;
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.id = 'fofa-parse-modal';
|
||||||
|
modal.className = 'modal';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-content" style="max-width: 900px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>AI 解析结果</h2>
|
||||||
|
<span class="modal-close" id="fofa-parse-modal-close" title="关闭">×</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding: 18px 28px; overflow: auto;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>自然语言</label>
|
||||||
|
<div class="muted" style="margin-top: 6px; white-space: pre-wrap;">${safeNL || '-'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 14px;">
|
||||||
|
<label for="fofa-parse-query">FOFA 查询语法(可编辑)</label>
|
||||||
|
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder='例如:app="Apache" && country="CN"'></textarea>
|
||||||
|
<small class="form-hint">请人工确认语法与范围无误后再执行查询。</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 14px;">
|
||||||
|
<label>提醒</label>
|
||||||
|
<div style="background: #fff8e1; border: 1px solid #ffe8a3; border-radius: 10px; padding: 10px 12px;">
|
||||||
|
${warningsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${explanation ? `
|
||||||
|
<div class="form-group" style="margin-top: 14px;">
|
||||||
|
<label>解析说明</label>
|
||||||
|
<pre style="margin-top: 8px; white-space: pre-wrap; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px; font-size: 13px;">${escapeHtml(explanation)}</pre>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding: 18px 28px;">
|
||||||
|
<button class="btn-secondary" type="button" id="fofa-parse-cancel">取消</button>
|
||||||
|
<button class="btn-secondary" type="button" id="fofa-parse-apply">填入查询框</button>
|
||||||
|
<button class="btn-primary" type="button" id="fofa-parse-apply-run">填入并查询</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const queryTextarea = document.getElementById('fofa-parse-query');
|
||||||
|
if (queryTextarea) {
|
||||||
|
queryTextarea.value = (parsed?.query || '').trim();
|
||||||
|
setTimeout(() => {
|
||||||
|
try { queryTextarea.focus(); } catch (e) { /* ignore */ }
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => modal.remove();
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) close();
|
||||||
|
});
|
||||||
|
document.getElementById('fofa-parse-modal-close')?.addEventListener('click', close);
|
||||||
|
document.getElementById('fofa-parse-cancel')?.addEventListener('click', close);
|
||||||
|
|
||||||
|
const applyToQuery = (run) => {
|
||||||
|
const els = getFofaFormElements();
|
||||||
|
const q = (queryTextarea?.value || '').trim();
|
||||||
|
if (!q) {
|
||||||
|
showInlineToast('解析结果为空:请在弹窗中补充/修改 FOFA 查询语法', { duration: 2600 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (els.query) {
|
||||||
|
els.query.value = q;
|
||||||
|
try { els.query.focus(); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
// 写入表单缓存(与现有“直接查询”一致)
|
||||||
|
saveFofaFormToStorage({
|
||||||
|
query: q,
|
||||||
|
size: parseInt(els.size?.value, 10) || 100,
|
||||||
|
page: parseInt(els.page?.value, 10) || 1,
|
||||||
|
fields: (els.fields?.value || '').trim(),
|
||||||
|
full: !!els.full?.checked
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
if (run) submitFofaSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('fofa-parse-apply')?.addEventListener('click', () => applyToQuery(false));
|
||||||
|
document.getElementById('fofa-parse-apply-run')?.addEventListener('click', () => applyToQuery(true));
|
||||||
|
|
||||||
|
// Esc 关闭
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
close();
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
}
|
||||||
|
|
||||||
function setFofaMeta(text) {
|
function setFofaMeta(text) {
|
||||||
const els = getFofaFormElements();
|
const els = getFofaFormElements();
|
||||||
if (els.meta) {
|
if (els.meta) {
|
||||||
@@ -393,12 +621,79 @@ function copyFofaTargetEncoded(encodedTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showInlineToast(text) {
|
// showInlineToast('xxx');也支持 showInlineToast('xxx', { duration: 0, id: '...' })
|
||||||
|
function showInlineToast(text, options) {
|
||||||
|
const opts = options && typeof options === 'object' ? options : {};
|
||||||
|
const duration = typeof opts.duration === 'number' ? opts.duration : 1200;
|
||||||
|
const id = typeof opts.id === 'string' && opts.id.trim() ? opts.id.trim() : '';
|
||||||
|
const replace = opts.replace !== false;
|
||||||
|
|
||||||
|
if (id && replace) {
|
||||||
|
document.getElementById(id)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.textContent = text;
|
if (id) toast.id = id;
|
||||||
toast.style.cssText = 'position: fixed; top: 24px; right: 24px; background: rgba(0,0,0,0.85); color: #fff; padding: 10px 12px; border-radius: 8px; z-index: 10000; font-size: 13px;';
|
toast.textContent = String(text == null ? '' : text);
|
||||||
|
toast.style.cssText = 'position: fixed; top: 24px; right: 24px; background: rgba(0,0,0,0.85); color: #fff; padding: 10px 12px; border-radius: 8px; z-index: 10000; font-size: 13px; max-width: 420px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,0.22);';
|
||||||
document.body.appendChild(toast);
|
document.body.appendChild(toast);
|
||||||
setTimeout(() => toast.remove(), 1200);
|
|
||||||
|
let timer = null;
|
||||||
|
const remove = () => {
|
||||||
|
try { if (timer) clearTimeout(timer); } catch (e) { /* ignore */ }
|
||||||
|
timer = null;
|
||||||
|
try { toast.remove(); } catch (e) { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
timer = setTimeout(remove, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { el: toast, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateForPreview(value, maxLen) {
|
||||||
|
const s = value == null ? '' : String(value);
|
||||||
|
if (maxLen <= 0 || s.length <= maxLen) return s;
|
||||||
|
return s.slice(0, maxLen) + '...(已截断)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFofaRowSummary(row, fields) {
|
||||||
|
const r = row && typeof row === 'object' ? row : {};
|
||||||
|
const order = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const preferred = Array.isArray(fields) ? fields : [];
|
||||||
|
preferred.forEach(k => {
|
||||||
|
const key = String(k || '').trim();
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
order.push(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(r).sort().forEach(k => {
|
||||||
|
if (seen.has(k)) return;
|
||||||
|
seen.add(k);
|
||||||
|
order.push(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (order.length === 0) return '-';
|
||||||
|
|
||||||
|
const lines = order.map((k) => {
|
||||||
|
const v = r[k];
|
||||||
|
let text = '';
|
||||||
|
if (v === null) text = 'null';
|
||||||
|
else if (v === undefined) text = '';
|
||||||
|
else if (typeof v === 'string') text = v === '' ? '""' : v;
|
||||||
|
else if (typeof v === 'number' || typeof v === 'boolean') text = String(v);
|
||||||
|
else {
|
||||||
|
try { text = JSON.stringify(v); } catch (e) { text = String(v); }
|
||||||
|
}
|
||||||
|
text = truncateForPreview(text, 800);
|
||||||
|
return `- ${k}: ${text}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanFofaRow(encodedRowJson, clickEvent) {
|
function scanFofaRow(encodedRowJson, clickEvent) {
|
||||||
@@ -423,7 +718,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
|||||||
window.location.hash = 'chat';
|
window.location.hash = 'chat';
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = buildScanMessage(target, row);
|
const message = buildScanMessage(target, row, { fields });
|
||||||
const autoSend = !!(clickEvent && (clickEvent.ctrlKey || clickEvent.metaKey));
|
const autoSend = !!(clickEvent && (clickEvent.ctrlKey || clickEvent.metaKey));
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
@@ -458,15 +753,12 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
|||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScanMessage(target, row) {
|
function buildScanMessage(target, row, options) {
|
||||||
const title = row && row.title != null ? String(row.title).trim() : '';
|
const opts = options && typeof options === 'object' ? options : {};
|
||||||
const server = row && row.server != null ? String(row.server).trim() : '';
|
const fields = Array.isArray(opts.fields) ? opts.fields : [];
|
||||||
const hintParts = [];
|
|
||||||
if (title) hintParts.push(`title="${title}"`);
|
|
||||||
if (server) hintParts.push(`server="${server}"`);
|
|
||||||
const hint = hintParts.length ? `(FOFA提示:${hintParts.join(', ')})` : '';
|
|
||||||
|
|
||||||
return `对以下目标做信息收集与基础扫描:\n${target}\n\n要求:\n1) 识别服务/框架与关键指纹\n2) 枚举开放端口与常见管理入口\n3) 用 httpx/指纹/目录探测等方式快速确认可访问面\n4) 输出可复现的命令与结论\n\n${hint}`.trim();
|
const summary = formatFofaRowSummary(row || {}, fields);
|
||||||
|
return `对以下目标做信息收集与基础扫描:\n${target}\n\n要求:\n1) 识别服务/框架与关键指纹\n2) 枚举开放端口与常见管理入口\n3) 用 httpx/指纹/目录探测等方式快速确认可访问面\n4) 输出可复现的命令与结论\n\n已知信息(来自 FOFA 该行全部字段):\n${summary}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindFofaTableEvents() {
|
function bindFofaTableEvents() {
|
||||||
@@ -684,6 +976,7 @@ async function batchScanSelectedFofaRows() {
|
|||||||
const fields = p.fields || [];
|
const fields = p.fields || [];
|
||||||
const tasks = [];
|
const tasks = [];
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
|
|
||||||
selected.forEach(idx => {
|
selected.forEach(idx => {
|
||||||
const row = p.results[idx];
|
const row = p.results[idx];
|
||||||
const target = inferTargetFromRow(row || {}, fields);
|
const target = inferTargetFromRow(row || {}, fields);
|
||||||
@@ -691,7 +984,10 @@ async function batchScanSelectedFofaRows() {
|
|||||||
skipped.push(idx + 1);
|
skipped.push(idx + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tasks.push(buildScanMessage(target, row || {}));
|
// 批量任务:与单条一致,只带“该行全部字段”的摘要(避免重复与超长)
|
||||||
|
tasks.push(buildScanMessage(target, row || {}, {
|
||||||
|
fields
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
@@ -701,10 +997,17 @@ async function batchScanSelectedFofaRows() {
|
|||||||
|
|
||||||
const title = (p.query ? `FOFA 批量扫描:${p.query}` : 'FOFA 批量扫描').slice(0, 80);
|
const title = (p.query ? `FOFA 批量扫描:${p.query}` : 'FOFA 批量扫描').slice(0, 80);
|
||||||
try {
|
try {
|
||||||
|
// 不强制切换到“信息收集”角色:沿用当前已选角色;若为默认则传空字符串交给后端走默认逻辑
|
||||||
|
let role = '';
|
||||||
|
if (typeof getCurrentRole === 'function') {
|
||||||
|
try { role = getCurrentRole() || ''; } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (role === '默认') role = '';
|
||||||
|
|
||||||
const resp = await apiFetch('/api/batch-tasks', {
|
const resp = await apiFetch('/api/batch-tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title, tasks, role: '信息收集' })
|
body: JSON.stringify({ title, tasks, role })
|
||||||
});
|
});
|
||||||
const result = await resp.json().catch(() => ({}));
|
const result = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -787,6 +1090,7 @@ function showCellDetailModal(field, fullText) {
|
|||||||
window.initInfoCollectPage = initInfoCollectPage;
|
window.initInfoCollectPage = initInfoCollectPage;
|
||||||
window.resetFofaForm = resetFofaForm;
|
window.resetFofaForm = resetFofaForm;
|
||||||
window.submitFofaSearch = submitFofaSearch;
|
window.submitFofaSearch = submitFofaSearch;
|
||||||
|
window.parseFofaNaturalLanguage = parseFofaNaturalLanguage;
|
||||||
window.scanFofaRow = scanFofaRow;
|
window.scanFofaRow = scanFofaRow;
|
||||||
window.copyFofaTarget = copyFofaTarget;
|
window.copyFofaTarget = copyFofaTarget;
|
||||||
window.copyFofaTargetEncoded = copyFofaTargetEncoded;
|
window.copyFofaTargetEncoded = copyFofaTargetEncoded;
|
||||||
|
|||||||
@@ -1197,7 +1197,8 @@ async function showBatchQueueDetail(queueId) {
|
|||||||
batchQueuesState.currentQueueId = queueId;
|
batchQueuesState.currentQueueId = queueId;
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
title.textContent = queue.title ? `批量任务队列 - ${escapeHtml(queue.title)}` : '批量任务队列';
|
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &...(看起来像“变形/乱码”)
|
||||||
|
title.textContent = queue.title ? `批量任务队列 - ${String(queue.title)}` : '批量任务队列';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新按钮显示
|
// 更新按钮显示
|
||||||
|
|||||||
@@ -756,6 +756,15 @@
|
|||||||
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip="1.1.1.1"')" title="填入示例">指定 IP</button>
|
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip="1.1.1.1"')" title="填入示例">指定 IP</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fofa-nl">自然语言(AI 解析为 FOFA 语法)</label>
|
||||||
|
<div class="info-collect-nl-row">
|
||||||
|
<textarea id="fofa-nl" class="info-collect-query-input" rows="1" placeholder="例如:找美国 Missouri 的 Apache 站点,标题包含 Home"></textarea>
|
||||||
|
<button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" title="将自然语言解析为 FOFA 查询语法">AI 解析</button>
|
||||||
|
</div>
|
||||||
|
<div id="fofa-nl-status" class="fofa-nl-status muted" style="display: none;" aria-live="polite"></div>
|
||||||
|
<small class="form-hint">解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。</small>
|
||||||
|
</div>
|
||||||
<div class="info-collect-form-row">
|
<div class="info-collect-form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="fofa-size">返回数量</label>
|
<label for="fofa-size">返回数量</label>
|
||||||
@@ -1761,10 +1770,10 @@ version: 1.0.0<br>
|
|||||||
<h2 id="batch-queue-detail-title">批量任务队列详情</h2>
|
<h2 id="batch-queue-detail-title">批量任务队列详情</h2>
|
||||||
<div style="display: flex; align-items: center; gap: 12px;">
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
<div class="modal-header-actions">
|
<div class="modal-header-actions">
|
||||||
<button class="btn-secondary" id="batch-queue-add-task-btn" onclick="showAddBatchTaskModal()" style="display: none;">添加任务</button>
|
<button class="btn-secondary btn-small" id="batch-queue-add-task-btn" onclick="showAddBatchTaskModal()" style="display: none;">添加任务</button>
|
||||||
<button class="btn-primary" id="batch-queue-start-btn" onclick="startBatchQueue()" style="display: none;">开始执行</button>
|
<button class="btn-primary btn-small" id="batch-queue-start-btn" onclick="startBatchQueue()" style="display: none;">开始执行</button>
|
||||||
<button class="btn-secondary" id="batch-queue-pause-btn" onclick="pauseBatchQueue()" style="display: none;">暂停队列</button>
|
<button class="btn-secondary btn-small" id="batch-queue-pause-btn" onclick="pauseBatchQueue()" style="display: none;">暂停队列</button>
|
||||||
<button class="btn-secondary btn-danger" id="batch-queue-delete-btn" onclick="deleteBatchQueue()" style="display: none;">删除队列</button>
|
<button class="btn-secondary btn-small btn-danger" id="batch-queue-delete-btn" onclick="deleteBatchQueue()" style="display: none;">删除队列</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="modal-close" onclick="closeBatchQueueDetailModal()">×</span>
|
<span class="modal-close" onclick="closeBatchQueueDetailModal()">×</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user