From 6431dcb240d96b9e0ff956dbbdaff93cc6120e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:53:38 +0800 Subject: [PATCH] Add files via upload --- internal/app/app.go | 2 + internal/handler/fofa.go | 169 ++++++++++++++++++++- web/static/css/style.css | 82 +++++++++- web/static/js/info-collect.js | 276 ++++++++++++++++++++++++++++++++-- web/templates/index.html | 9 ++ 5 files changed, 517 insertions(+), 21 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 49f78ec0..4d273370 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/handler/fofa.go b/internal/handler/fofa.go index 2c07b304..e203429b 100644 --- a/internal/handler/fofa.go +++ b/internal/handler/fofa.go @@ -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,142 @@ 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": "string,FOFA查询语法(可直接粘贴到 FOFA 或本系统查询框)", + "explanation": "string,可选,解释你如何映射字段/逻辑", + "warnings": ["string"...] 可选,列出歧义/风险/需要人工确认的点 +} + +查询语法要点(来自 FOFA 语法参考): +- 逻辑连接符:&&(与)、||(或),必要时用 () 包住子表达式以确认优先级(括号优先级最高) +- 比较/匹配: + - = 匹配;当字段="" 时,可查询“不存在该字段”或“值为空”的情况 + - == 完全匹配;当字段=="" 时,可查询“字段存在且值为空”的情况 + - != 不匹配;当字段!="" 时,可查询“值不为空”的情况 + - *= 模糊匹配;可使用 * 或 ? 进行搜索 +- 直接输入关键词(不带字段)会在标题、HTML内容、HTTP头、URL字段中搜索;但当意图明确时优先用字段表达(更可控、更准确) + +常用字段速查(来自截图的分类示例,按需使用): +- 基础类(General):ip、port、domain、host、os、server、asn、org、is_domain、is_ipv6 +- 标记类(Special Label):app、fid、product、product.version +- 其它筛选:category、type(常见:type="service" / type="subdomain")、cloud_name、is_cloud、is_fraud、is_honeypot +- 网站类(type=subdomain):title、header、header_hash、body、body_hash、js_name、js_md5、cname、cname_domain、icon_hash、status_code、icp、sdk_hash +- 地理位置(Location):country、region、city +- 证书类(Certificate):cert、cert.subject、cert.issuer、cert.subject.org、cert.subject.cn、cert.issuer.org、cert.issuer.cn、cert.domain、 + cert.is_equal、cert.is_valid、cert.is_match、cert.is_expired、jarm + +生成规则(务必遵守): +- 字符串值一律用英文双引号包裹,例如 title="登录"、country="CN" +- 不要捏造不存在的 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 diff --git a/web/static/css/style.css b/web/static/css/style.css index 0821556b..326b42c2 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3854,6 +3854,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); @@ -10952,10 +10982,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 +11039,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 +11182,7 @@ header { .info-collect-presets { display: flex; - gap: 8px; + gap: 10px; flex-wrap: wrap; margin-top: 8px; } @@ -11123,7 +11199,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; diff --git a/web/static/js/info-collect.js b/web/static/js/info-collect.js index deacce81..a840a5e2 100644 --- a/web/static/js/info-collect.js +++ b/web/static/js/info-collect.js @@ -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 + ? `