Compare commits

...

7 Commits

Author SHA1 Message Date
公明 e54815e018 Update config.yaml 2026-02-21 01:02:38 +08:00
公明 9baa99ea40 Add files via upload 2026-02-21 01:01:51 +08:00
公明 83a8c46db1 Add files via upload 2026-02-21 00:50:59 +08:00
公明 4b2619e1fe Update config.yaml 2026-02-20 18:48:07 +08:00
公明 3fffee80f4 Add files via upload 2026-02-20 18:43:50 +08:00
公明 41d7afcf99 Add files via upload 2026-02-20 18:10:29 +08:00
公明 6431dcb240 Add files via upload 2026-02-20 17:53:38 +08:00
8 changed files with 718 additions and 40 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.3.7"
version: "v1.3.9"
# 服务器配置
server:
+2
View File
@@ -511,6 +511,8 @@ func setupRoutes(
// 信息收集 - FOFA 查询(后端代理)
protected.POST("/fofa/search", fofaHandler.Search)
// 信息收集 - 自然语言解析为 FOFA 语法(需人工确认后再查询)
protected.POST("/fofa/parse", fofaHandler.ParseNaturalLanguage)
// 批量任务管理
protected.POST("/batch-tasks", agentHandler.CreateBatchQueue)
+9
View File
@@ -854,6 +854,7 @@ func (h *ConfigHandler) saveConfig() error {
updateAgentConfig(root, h.config.Agent.MaxIterations)
updateMCPConfig(root, h.config.MCP)
updateOpenAIConfig(root, h.config.OpenAI)
updateFOFAConfig(root, h.config.FOFA)
updateKnowledgeConfig(root, h.config.Knowledge)
// 更新外部MCP配置(使用external_mcp.go中的函数,同一包中可直接调用)
// 读取原始配置以保持向后兼容
@@ -998,6 +999,14 @@ func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) {
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) {
root := doc.Content[0]
knowledgeNode := ensureMap(root, "knowledge")
+250 -6
View File
@@ -1,8 +1,10 @@
package handler
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -11,22 +13,31 @@ import (
"time"
"cyberstrike-ai/internal/config"
openaiClient "cyberstrike-ai/internal/openai"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type FofaHandler struct {
cfg *config.Config
logger *zap.Logger
client *http.Client
cfg *config.Config
logger *zap.Logger
client *http.Client
openAIClient *openaiClient.Client
}
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{
cfg: cfg,
logger: logger,
client: &http.Client{Timeout: 30 * time.Second},
cfg: cfg,
logger: logger,
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"`
}
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 {
Error bool `json:"error"`
ErrMsg string `json:"errmsg"`
@@ -86,6 +107,229 @@ func (h *FofaHandler) resolveBaseURL() string {
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": "stringFOFA查询语法(可直接粘贴到 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)
func (h *FofaHandler) Search(c *gin.Context) {
var req fofaSearchRequest
+114 -5
View File
@@ -2142,6 +2142,10 @@ header {
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
/* 允许标题在 flex 中收缩/换行,避免把右侧按钮挤压变形 */
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.modal-close {
@@ -3854,6 +3858,36 @@ header {
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 {
padding: 10px 20px;
background: rgba(220, 53, 69, 0.08);
@@ -7808,6 +7842,27 @@ header {
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 {
max-height: 500px;
overflow-y: auto;
@@ -7847,12 +7902,13 @@ header {
}
.batch-task-header .btn-small {
margin-left: auto;
margin-left: 0;
flex-shrink: 0;
}
.batch-task-header .batch-task-edit-btn {
margin-left: 8px;
/* 仅把“编辑”(首个操作按钮)推到最右,避免多个按钮都 margin-left:auto 导致挤压/错位 */
margin-left: auto;
}
.batch-task-header .batch-task-delete-btn {
@@ -9303,6 +9359,13 @@ header {
width: 100%;
gap: 6px;
}
/* 批量队列详情弹窗:即使在窄屏也保持按钮不“变形” */
#batch-queue-detail-modal .modal-header-actions {
flex-direction: row;
width: auto;
gap: 8px;
}
.attack-chain-action-btn {
width: 100%;
@@ -10952,10 +11015,55 @@ header {
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) {
.info-collect-form-row {
grid-template-columns: 1fr;
}
.info-collect-nl-row {
flex-direction: column;
align-items: stretch;
}
.info-collect-col-actions {
width: 140px;
}
@@ -10964,7 +11072,8 @@ header {
.info-collect-form-row {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 12px;
gap: 14px;
align-items: end;
}
.info-collect-form-row .form-group {
@@ -11106,7 +11215,7 @@ header {
.info-collect-presets {
display: flex;
gap: 8px;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
@@ -11123,7 +11232,7 @@ header {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
padding: 6px 10px;
padding: 7px 12px;
border-radius: 999px;
cursor: pointer;
font-size: 0.8125rem;
+327 -23
View File
@@ -10,6 +10,11 @@ const infoCollectState = {
tableBound: false
};
// AI 解析(自然语言 -> FOFA)交互状态
let fofaParseAbortController = null;
let fofaParseSlowTimer = null;
let fofaParseToastHandle = null;
// HTML转义(如果未定义)
if (typeof escapeHtml === 'undefined') {
function escapeHtml(text) {
@@ -23,6 +28,7 @@ if (typeof escapeHtml === 'undefined') {
function getFofaFormElements() {
return {
query: document.getElementById('fofa-query'),
nl: document.getElementById('fofa-nl'),
size: document.getElementById('fofa-size'),
page: document.getElementById('fofa-page'),
fields: document.getElementById('fofa-fields'),
@@ -101,20 +107,35 @@ function initInfoCollectPage() {
}
});
// 单行输入:按内容自动增高(避免默认留空白行)
const autoGrow = () => {
// 自然语言输入:Ctrl/Cmd+Enter 触发解析
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 {
els.query.style.height = '40px';
const max = 110;
const h = Math.min(max, els.query.scrollHeight);
els.query.style.height = `${h}px`;
el.style.height = '36px';
const max = 96;
const h = Math.min(max, el.scrollHeight);
el.style.height = `${h}px`;
} catch (e) {
// 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();
@@ -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="关闭">&times;</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) {
const els = getFofaFormElements();
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');
toast.textContent = 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;';
if (id) toast.id = id;
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);
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) {
@@ -423,7 +718,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
window.location.hash = 'chat';
}
const message = buildScanMessage(target, row);
const message = buildScanMessage(target, row, { fields });
const autoSend = !!(clickEvent && (clickEvent.ctrlKey || clickEvent.metaKey));
setTimeout(async () => {
@@ -458,15 +753,12 @@ function scanFofaRow(encodedRowJson, clickEvent) {
}, 250);
}
function buildScanMessage(target, row) {
const title = row && row.title != null ? String(row.title).trim() : '';
const server = row && row.server != null ? String(row.server).trim() : '';
const hintParts = [];
if (title) hintParts.push(`title="${title}"`);
if (server) hintParts.push(`server="${server}"`);
const hint = hintParts.length ? `FOFA提示:${hintParts.join(', ')}` : '';
function buildScanMessage(target, row, options) {
const opts = options && typeof options === 'object' ? options : {};
const fields = Array.isArray(opts.fields) ? opts.fields : [];
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() {
@@ -684,6 +976,7 @@ async function batchScanSelectedFofaRows() {
const fields = p.fields || [];
const tasks = [];
const skipped = [];
selected.forEach(idx => {
const row = p.results[idx];
const target = inferTargetFromRow(row || {}, fields);
@@ -691,7 +984,10 @@ async function batchScanSelectedFofaRows() {
skipped.push(idx + 1);
return;
}
tasks.push(buildScanMessage(target, row || {}));
// 批量任务:与单条一致,只带“该行全部字段”的摘要(避免重复与超长)
tasks.push(buildScanMessage(target, row || {}, {
fields
}));
});
if (tasks.length === 0) {
@@ -701,10 +997,17 @@ async function batchScanSelectedFofaRows() {
const title = (p.query ? `FOFA 批量扫描:${p.query}` : 'FOFA 批量扫描').slice(0, 80);
try {
// 不强制切换到“信息收集”角色:沿用当前已选角色;若为默认则传空字符串交给后端走默认逻辑
let role = '';
if (typeof getCurrentRole === 'function') {
try { role = getCurrentRole() || ''; } catch (e) { /* ignore */ }
}
if (role === '默认') role = '';
const resp = await apiFetch('/api/batch-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, tasks, role: '信息收集' })
body: JSON.stringify({ title, tasks, role })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
@@ -787,6 +1090,7 @@ function showCellDetailModal(field, fullText) {
window.initInfoCollectPage = initInfoCollectPage;
window.resetFofaForm = resetFofaForm;
window.submitFofaSearch = submitFofaSearch;
window.parseFofaNaturalLanguage = parseFofaNaturalLanguage;
window.scanFofaRow = scanFofaRow;
window.copyFofaTarget = copyFofaTarget;
window.copyFofaTargetEncoded = copyFofaTargetEncoded;
+2 -1
View File
@@ -1197,7 +1197,8 @@ async function showBatchQueueDetail(queueId) {
batchQueuesState.currentQueueId = queueId;
if (title) {
title.textContent = queue.title ? `批量任务队列 - ${escapeHtml(queue.title)}` : '批量任务队列';
// textContent 本身会做转义;这里不要再 escapeHtml,否则会把 && 显示成 &amp;...(看起来像“变形/乱码”)
title.textContent = queue.title ? `批量任务队列 - ${String(queue.title)}` : '批量任务队列';
}
// 更新按钮显示
+13 -4
View File
@@ -756,6 +756,15 @@
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip=&quot;1.1.1.1&quot;')" title="填入示例">指定 IP</button>
</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="form-group">
<label for="fofa-size">返回数量</label>
@@ -1761,10 +1770,10 @@ version: 1.0.0<br>
<h2 id="batch-queue-detail-title">批量任务队列详情</h2>
<div style="display: flex; align-items: center; gap: 12px;">
<div class="modal-header-actions">
<button class="btn-secondary" 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-secondary" 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" id="batch-queue-add-task-btn" onclick="showAddBatchTaskModal()" 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 btn-small" id="batch-queue-pause-btn" onclick="pauseBatchQueue()" style="display: none;">暂停队列</button>
<button class="btn-secondary btn-small btn-danger" id="batch-queue-delete-btn" onclick="deleteBatchQueue()" style="display: none;">删除队列</button>
</div>
<span class="modal-close" onclick="closeBatchQueueDetailModal()">&times;</span>
</div>