diff --git a/docs/feature/fofa-region/repair.md b/docs/feature/fofa-region/repair.md index 68b5b690..78dc8bf4 100644 --- a/docs/feature/fofa-region/repair.md +++ b/docs/feature/fofa-region/repair.md @@ -29,3 +29,9 @@ REQ-003 / AC-003:返回的 region 可导入资产 province,兼容已有 prov 新增独立 Go 字段规范化辅助文件;handler 加调用点,修改默认值;前端配置、HTML 和工具说明定点替换;资产映射加 region。 验证:Go 模拟服务检查真实 outbound fields 并返回按序行,覆盖默认、旧别名、重复和结果映射;Node 验证 FOFA 预设以及对象/数组资产导入,保留旧数据及 Quake 行为。执行现有 handler、前端相关测试和语法、diff 检查。 回退:撤销本分支代码提交,无数据迁移。真实 API 查询会消耗外部账户额度,本次采用合同模拟验证,不使用真实凭据。 + +## F4 实施记录 + +已修正后端、JS、HTML 默认和 FOFA 字段预设;独立 normalizeFofaFields 在 FOFA 分支转换 province、清理重复字段并保持请求/响应顺序。空白 fields 同空值使用默认值。旧表单可由后端兼容,无需清空浏览器存储;保留旧 FOFA 默认字段识别,避免其他提供商恢复表单时误用 FOFA 字段。 +资产导入读取 region,并兼容 province、location.province_cn;工具字段说明同步更新为 region/asn/org。 +已通过 FOFA 和其他提供商定向 Go handler 测试、16 项 Node 测试、JS 语法及 diff 检查。新增测试最初因 Node VM 提取函数的结束标记拼写错误失败,修正测试加载边界后通过;未掩盖运行错误。完整 handler 包测试另行记录于 F5。 diff --git a/internal/handler/fofa.go b/internal/handler/fofa.go index 38bb105a..66545d02 100644 --- a/internal/handler/fofa.go +++ b/internal/handler/fofa.go @@ -144,7 +144,7 @@ func defaultFieldsForProvider(provider string) string { case "shodan": return "ip_str,port,hostnames,domains,org,isp,location.country_name,location.city,product,transport" default: - return "host,ip,port,domain,title,protocol,country,province,city,server" + return "host,ip,port,domain,title,protocol,country,region,city,server" } } @@ -602,7 +602,7 @@ func (h *FofaHandler) Search(c *gin.Context) { if req.Size > 10000 { req.Size = 10000 } - if req.Fields == "" { + if strings.TrimSpace(req.Fields) == "" { req.Fields = defaultFieldsForProvider(provider) } @@ -611,6 +611,8 @@ func (h *FofaHandler) Search(c *gin.Context) { return } + req.Fields = normalizeFofaFields(req.Fields) + apiKey := h.resolveAPIKey(provider) if apiKey == "" { c.JSON(http.StatusBadRequest, gin.H{ diff --git a/internal/handler/fofa_fields.go b/internal/handler/fofa_fields.go new file mode 100644 index 00000000..4c21be86 --- /dev/null +++ b/internal/handler/fofa_fields.go @@ -0,0 +1,22 @@ +package handler + +import "strings" + +// normalizeFofaFields accepts the legacy province return-field alias from saved +// forms and API clients. FOFA exports this value as region. Query syntax and +// other search providers do not pass through this conversion. +func normalizeFofaFields(raw string) string { + fields := splitAndCleanCSV(raw) + seen := make(map[string]bool, len(fields)) + normalized := make([]string, 0, len(fields)) + for _, field := range fields { + if field == "province" { + field = "region" + } + if !seen[field] { + normalized = append(normalized, field) + seen[field] = true + } + } + return strings.Join(normalized, ",") +} diff --git a/internal/handler/fofa_fields_test.go b/internal/handler/fofa_fields_test.go new file mode 100644 index 00000000..e39a22a1 --- /dev/null +++ b/internal/handler/fofa_fields_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "cyberstrike-ai/internal/config" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestFofaSearchReturnFields(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FOFA_API_KEY", "") + // A contract-checking upstream: unsupported fields are rejected rather than + // silently accepted by a mock, and values are returned in requested order. + values := map[string]string{ + "host": "https://example.com", "ip": "192.0.2.1", "port": "443", + "domain": "example.com", "title": "Example", "protocol": "https", + "country": "CN", "region": "Beijing", "city": "Beijing", "server": "nginx", + } + defaults := "host,ip,port,domain,title,protocol,country,region,city,server" + for _, tc := range []struct{ name, fields, want string }{ + {"default", "", defaults}, + {"blank default", " ", defaults}, + {"legacy form", strings.ReplaceAll(defaults, "region", "province"), defaults}, + {"legacy alias and duplicate", " host, province, region, city, province,host ", "host,region,city"}, + {"canonical region", "region,host", "region,host"}, + } { + t.Run(tc.name, func(t *testing.T) { + var received string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = r.URL.Query().Get("fields") + row := []string{} + for _, field := range strings.Split(received, ",") { + value, ok := values[field] + if !ok { + http.Error(w, "unsupported field: "+field, http.StatusBadRequest) + return + } + row = append(row, value) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"error": false, "size": 1, "page": 1, "results": [][]string{row}}) + })) + defer server.Close() + h := NewFofaHandler(&config.Config{FOFA: config.FofaConfig{APIKey: "test-key", BaseURL: server.URL}}, zap.NewNop()) + body, _ := json.Marshal(fofaSearchRequest{Query: `domain="example.com"`, Fields: tc.fields}) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(string(body))) + ctx.Request.Header.Set("Content-Type", "application/json") + h.Search(ctx) + if recorder.Code != http.StatusOK { + t.Fatalf("Search status = %d: %s", recorder.Code, recorder.Body.String()) + } + if received != tc.want { + t.Fatalf("upstream fields = %q, want %q", received, tc.want) + } + var result fofaSearchResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result.Fields, strings.Split(tc.want, ",")) || len(result.Results) != 1 { + t.Fatalf("incorrect result shape: %+v", result) + } + for _, field := range result.Fields { + if result.Results[0][field] != values[field] { + t.Errorf("result[%s] = %v, want %s", field, result.Results[0][field], values[field]) + } + } + }) + } +} diff --git a/tools/fofa_search.yaml b/tools/fofa_search.yaml index 6b53d0e2..df35bf79 100644 --- a/tools/fofa_search.yaml +++ b/tools/fofa_search.yaml @@ -310,7 +310,7 @@ parameters: 需要返回的字段,多个字段用逗号分隔。 默认字段:ip,port,domain - 可用字段:host,title,ip,domain,port,protocol,country,province,city,server,icp,header,banner,body,as_number,as_organization + 可用字段:host,title,ip,domain,port,protocol,country,region,city,server,icp,header,banner,body,asn,org required: false position: 5 format: "positional" diff --git a/web/static/js/assets.js b/web/static/js/assets.js index 8ac07dd5..969f8cb9 100644 --- a/web/static/js/assets.js +++ b/web/static/js/assets.js @@ -1660,7 +1660,7 @@ function fofaResultToAsset(row, fields) { return { host: firstValue(['host', 'hostname']), ip, port: Number.isFinite(port) ? port : 0, domain, protocol: /^[a-z][a-z0-9+.-]{0,31}$/.test(rawProtocol) ? rawProtocol : '', title: firstValue(['title', 'service.http.title']), server: firstValue(['server', 'product']), - country: firstValue(['country', 'location.country_cn', 'location.country_name']), province: firstValue(['province', 'location.province_cn']), city: firstValue(['city', 'location.city_cn', 'location.city']), + country: firstValue(['country', 'location.country_cn', 'location.country_name']), province: firstValue(['region', 'province', 'location.province_cn']), city: firstValue(['city', 'location.city_cn', 'location.city']), source: (window.infoCollectState?.currentPayload?.provider || 'fofa'), status: 'active' }; } diff --git a/web/static/js/fofa-fields.test.cjs b/web/static/js/fofa-fields.test.cjs new file mode 100644 index 00000000..90f65268 --- /dev/null +++ b/web/static/js/fofa-fields.test.cjs @@ -0,0 +1,41 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const info = fs.readFileSync(path.join(__dirname, 'info-collect.js'), 'utf8'); +const assets = fs.readFileSync(path.join(__dirname, 'assets.js'), 'utf8'); +const c = vm.createContext({ window: {}, URL }); +vm.runInContext(info.slice(0, info.indexOf('const infoCollectState')), c); +const providers = vm.runInContext('INFO_COLLECT_PROVIDERS', c); + +test('FOFA default, presets, and HTML use supported return fields', () => { + const supported = new Set('ip,port,protocol,country,country_name,region,city,longitude,latitude,asn,org,host,domain,os,server,icp,title,jarm,header,banner,cert,base_protocol,link,body'.split(',')); + const html = fs.readFileSync(path.join(__dirname, '../../templates/index.html'), 'utf8'); + const initial = html.match(/id="fofa-fields" value="([^"]+)"/)[1]; + assert.equal(initial, providers.fofa.fields); + for (const fields of [initial, ...providers.fofa.fieldPresets.map(p => p[1])]) { + for (const field of fields.split(',')) assert.ok(supported.has(field), 'unsupported return field: ' + field); + } + assert.ok(providers.quake.fields.includes('location.province_cn')); +}); + +// Execute the real conversion and its validation helpers, including array rows. +vm.runInContext(assets.slice(assets.indexOf('function assetEditorIsIPv4('), assets.indexOf('function parseAssetEditorTarget(')), c); +vm.runInContext(assets.slice(assets.indexOf('function fofaResultToAsset('), assets.indexOf('async function importFofaAssetsByIndexes(')), c); +for (const [field, provider] of [['region', 'fofa'], ['province', 'fofa'], ['location.province_cn', 'quake']]) { + for (const array of [false, true]) { + test(`${provider} ${field} ${array ? 'array' : 'object'} row keeps province on import`, () => { + c.window.infoCollectState = { currentPayload: { provider } }; + const fields = ['host', 'ip', 'port', field, 'city']; + const values = ['https://example.com', '192.0.2.1', 443, 'Beijing', 'Beijing']; + const row = array ? values : Object.fromEntries(fields.map((f, i) => [f, values[i]])); + const asset = c.fofaResultToAsset(row, fields); + assert.equal(asset.province, 'Beijing'); + assert.equal(asset.ip, '192.0.2.1'); + assert.equal(asset.port, 443); + assert.equal(asset.city, 'Beijing'); + assert.equal(asset.source, provider); + }); + } +} diff --git a/web/static/js/info-collect.js b/web/static/js/info-collect.js index c61d61ba..5fb4e342 100644 --- a/web/static/js/info-collect.js +++ b/web/static/js/info-collect.js @@ -19,7 +19,7 @@ const INFO_COLLECT_PROVIDERS = { label: '完整模式', hint: '向 FOFA 传 full=true,返回更完整/更实时的数据,可能消耗更多额度。' }, - fields: 'host,ip,port,domain,title,protocol,country,province,city,server', + fields: 'host,ip,port,domain,title,protocol,country,region,city,server', presets: [ ['Apache + 中国', 'app="Apache" && country="CN"'], ['登录页 + 中国', 'title="登录" && country="CN"'], @@ -28,8 +28,8 @@ const INFO_COLLECT_PROVIDERS = { ], fieldPresets: [ ['最小字段', 'host,ip,port,domain'], - ['Web 常用', 'host,title,ip,port,domain,protocol,server,icp,country,province,city'], - ['情报增强', 'host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner'] + ['Web 常用', 'host,title,ip,port,domain,protocol,server,icp,country,region,city'], + ['情报增强', 'host,ip,port,domain,title,protocol,country,region,city,server,asn,org,icp,header,banner'] ], syntaxGuide: { summary: 'FOFA 使用 field="value" 精确匹配,支持 &&、||、! 和括号组合;字符串建议用双引号包裹。', @@ -268,6 +268,7 @@ function initInfoCollectPage() { const savedFields = String(saved.fields || '').trim(); shouldResetProviderFields = provider !== 'fofa' && ( savedFields === INFO_COLLECT_PROVIDERS.fofa.fields || + savedFields === 'host,ip,port,domain,title,protocol,country,province,city,server' || savedFields === 'host,ip,port,domain' ); } diff --git a/web/templates/index.html b/web/templates/index.html index 85ed32fb..2719ab9e 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2275,7 +2275,7 @@ 04 - +