From dcf0fc31e878f9f05afe5967bc60c0d5b4a5e7ca Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 1 Oct 2026 13:02:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=AB=E6=8F=8F=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E3=80=81=E4=BC=9A=E8=AF=9D=E7=8A=B6=E6=80=81=E5=92=8C?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=A4=87=E4=BB=BD=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test-build.yml | 3 + common/config_builder.go | 14 ++- common/i18n/locales/en.yaml | 2 + common/i18n/locales/zh.yaml | 2 + common/output/backup_recovery_test.go | 48 ++++++++++ common/output/writers.go | 70 ++++++-------- common/parsers/host_iterator.go | 7 +- common/target_config_test.go | 40 ++++++++ core/service_scanner.go | 27 ++++-- core/target_scope_test.go | 130 ++++++++++++++++++++++++++ pkg/fscan/progress_test.go | 53 +++++++++++ pkg/fscan/scanner.go | 1 + web/api/scan.go | 96 ++++++++++--------- web/api/scan_config_test.go | 46 +++++++++ 14 files changed, 444 insertions(+), 95 deletions(-) create mode 100644 common/output/backup_recovery_test.go create mode 100644 common/target_config_test.go create mode 100644 core/target_scope_test.go create mode 100644 pkg/fscan/progress_test.go create mode 100644 web/api/scan_config_test.go diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 578928b..164e328 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -128,6 +128,9 @@ jobs: PKGS=$(go list ./... | grep -v '/libs/grdp/') go test -vet=off -race -coverprofile=coverage.out -covermode=atomic $PKGS + - name: Web 接口回归测试 + run: go test -vet=off -race -tags web ./web/api + - name: 上传覆盖率 uses: actions/upload-artifact@v4 with: diff --git a/common/config_builder.go b/common/config_builder.go index 14559c7..0adfce6 100644 --- a/common/config_builder.go +++ b/common/config_builder.go @@ -238,8 +238,20 @@ func parseHashes(fv *FlagVars) ([]string, [][]byte, error) { // ============================================================================= func parseTargets(fv *FlagVars, info *HostInfo, cfg *Config, state *State) error { - // 检查是否为 host:port 格式 ports := fv.Ports + if fv.PortsFile != "" { + lines, err := parsers.ReadLinesFromFile(fv.PortsFile) + if err != nil { + return fmt.Errorf("%s", i18n.Tr("config_read_ports_failed", fv.PortsFile, err)) + } + ports = strings.Join(lines, ",") + if len(parsers.ParsePort(ports)) == 0 { + return fmt.Errorf("%s", i18n.Tr("invalid_port", ports)) + } + cfg.Target.Ports = ports + } + + // 检查是否为 host:port 格式 if info.Host != "" && strings.Contains(info.Host, ":") { if _, portStr, err := net.SplitHostPort(info.Host); err == nil { if port, portErr := strconv.Atoi(portStr); portErr == nil && port >= 1 && port <= 65535 { diff --git a/common/i18n/locales/en.yaml b/common/i18n/locales/en.yaml index c5aa486..a4b8278 100644 --- a/common/i18n/locales/en.yaml +++ b/common/i18n/locales/en.yaml @@ -311,6 +311,8 @@ config_read_passwords_failed: other: "Failed to read password file {{.Arg1}}: {{.Arg2}}" config_read_urls_failed: other: "Failed to read URL file {{.Arg1}}: {{.Arg2}}" +config_read_ports_failed: + other: "Failed to read port file {{.Arg1}}: {{.Arg2}}" # ========================= Plugin Scan Messages (with parameters) ========================= scan_plugin_not_found: diff --git a/common/i18n/locales/zh.yaml b/common/i18n/locales/zh.yaml index 1e9371b..c921733 100644 --- a/common/i18n/locales/zh.yaml +++ b/common/i18n/locales/zh.yaml @@ -311,6 +311,8 @@ config_read_passwords_failed: other: "读取密码文件 {{.Arg1}} 失败: {{.Arg2}}" config_read_urls_failed: other: "读取URL文件 {{.Arg1}} 失败: {{.Arg2}}" +config_read_ports_failed: + other: "读取端口文件 {{.Arg1}} 失败: {{.Arg2}}" # ========================= 插件扫描消息 (带参数) ========================= scan_plugin_not_found: diff --git a/common/output/backup_recovery_test.go b/common/output/backup_recovery_test.go new file mode 100644 index 0000000..445d5cc --- /dev/null +++ b/common/output/backup_recovery_test.go @@ -0,0 +1,48 @@ +package output + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestWritersPreserveBackupOnFinalWriteFailure(t *testing.T) { + for _, format := range []Format{FormatTXT, FormatJSON, FormatCSV} { + t.Run(string(format), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "result."+string(format)) + manager, err := NewManager(DefaultManagerConfig(path, format)) + if err != nil { + t.Fatal(err) + } + defer manager.Close() + if err := manager.SaveResult(&ScanResult{Type: TypeHost, Target: "127.0.0.1"}); err != nil { + t.Fatal(err) + } + backupPath := path + ".realtime.tmp" + before, err := os.ReadFile(backupPath) + if err != nil || len(before) == 0 { + t.Fatalf("backup before Close: %q, %v", before, err) + } + var file *os.File + switch writer := manager.writer.(type) { + case *TXTWriter: + file = writer.file + case *JSONWriter: + file = writer.file + case *CSVWriter: + file = writer.file + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + if err := manager.Close(); err == nil { + t.Fatal("final write failure was not reported") + } + after, err := os.ReadFile(backupPath) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("recovery backup was lost after final write failed: %q, %v", after, err) + } + }) + } +} diff --git a/common/output/writers.go b/common/output/writers.go index c0013eb..5d3edc2 100644 --- a/common/output/writers.go +++ b/common/output/writers.go @@ -71,6 +71,25 @@ func targetWithPort(target string, port interface{}) string { return net.JoinHostPort(target, portText) } +// closeOutputFiles 仅在最终结果完整落盘并关闭后删除恢复备份。 +func closeOutputFiles(file, backup *os.File, backupPath string, writeErr error) error { + if writeErr == nil { + writeErr = file.Sync() + } + if err := file.Close(); err != nil && writeErr == nil { + writeErr = err + } + if backup != nil { + if err := backup.Close(); err != nil && writeErr == nil { + writeErr = err + } + if writeErr == nil { + writeErr = os.Remove(backupPath) + } + } + return writeErr +} + // ============================================================================= // TXTWriter - 文本格式写入器 // ============================================================================= @@ -347,23 +366,7 @@ func (w *TXTWriter) Close() error { w.closed = true - // 关闭并删除实时备份文件(正常结束,不再需要) - if w.realtimeFile != nil { - w.realtimeFile.Close() - os.Remove(w.realtimePath) - } - - var firstErr error - if err := w.bufWriter.Flush(); err != nil { - firstErr = err - } - if err := w.file.Sync(); err != nil && firstErr == nil { - firstErr = err - } - if err := w.file.Close(); err != nil && firstErr == nil { - firstErr = err - } - return firstErr + return closeOutputFiles(w.file, w.realtimeFile, w.realtimePath, w.bufWriter.Flush()) } // writeSection 写入一个分类的所有结果 @@ -565,22 +568,11 @@ func (w *JSONWriter) Close() error { } data, err := json.MarshalIndent(output, JSONIndentPrefix, JSONIndentString) - if err != nil { - return err - } - w.closed = true - - // 关闭并删除实时备份文件(正常结束,不再需要) - if w.realtimeFile != nil { - w.realtimeFile.Close() - os.Remove(w.realtimePath) + if err == nil { + _, err = w.file.Write(data) } - - if _, err := w.file.Write(data); err != nil { - return err - } - return w.file.Close() + return closeOutputFiles(w.file, w.realtimeFile, w.realtimePath, err) } // GetFormat 获取格式类型 @@ -692,20 +684,12 @@ func (w *CSVWriter) Close() error { w.closed = true - // 关闭并删除实时备份文件(正常结束,不再需要) - if w.realtimeFile != nil { - w.realtimeFile.Close() - os.Remove(w.realtimePath) - } - w.csvWriter.Flush() - if err := w.csvWriter.Error(); err != nil { - return err + err := w.csvWriter.Error() + if err == nil { + err = w.bufWriter.Flush() } - if err := w.bufWriter.Flush(); err != nil { - return err - } - return w.file.Close() + return closeOutputFiles(w.file, w.realtimeFile, w.realtimePath, err) } func (w *CSVWriter) writeSection(title string, headers []string, results []*ScanResult, formatter func(*ScanResult) []string) { diff --git a/common/parsers/host_iterator.go b/common/parsers/host_iterator.go index 654cef6..4811af5 100644 --- a/common/parsers/host_iterator.go +++ b/common/parsers/host_iterator.go @@ -73,6 +73,11 @@ func (it *HostIterator) Close() error { return firstErr } +// IsExcluded 检查单个主机是否匹配当前迭代器的排除规则。 +func (it *HostIterator) IsExcluded(host string) bool { + return it.exclude != nil && it.exclude.match(host) +} + func (it *HostIterator) Next() (string, bool, error) { for { if it.current == nil { @@ -94,7 +99,7 @@ func (it *HostIterator) Next() (string, bool, error) { it.current = nil continue } - if it.exclude != nil && it.exclude.match(host) { + if it.IsExcluded(host) { continue } return host, true, nil diff --git a/common/target_config_test.go b/common/target_config_test.go new file mode 100644 index 0000000..256ac93 --- /dev/null +++ b/common/target_config_test.go @@ -0,0 +1,40 @@ +package common + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/parsers" +) + +func TestBuildConfigLoadsPortsFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "ports.txt") + if err := os.WriteFile(path, []byte("# ports\n22\n8000-8002\n443,22\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, _, err := BuildConfig(&FlagVars{Ports: config.MainPorts, PortsFile: path}, &HostInfo{Host: "127.0.0.1"}) + if err != nil { + t.Fatal(err) + } + if got, want := parsers.ParsePort(cfg.Target.Ports), []int{22, 443, 8000, 8001, 8002}; !reflect.DeepEqual(got, want) { + t.Fatalf("ports = %v, want %v", got, want) + } +} + +func TestBuildConfigRejectsUnusablePortsFile(t *testing.T) { + for _, contents := range []string{"", "# no ports\n", "invalid\n0\n65536\n"} { + path := filepath.Join(t.TempDir(), "ports.txt") + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := BuildConfig(&FlagVars{Ports: "22", PortsFile: path}, &HostInfo{}); err == nil { + t.Errorf("accepted unusable port file %q", contents) + } + } + if _, _, err := BuildConfig(&FlagVars{PortsFile: filepath.Join(t.TempDir(), "missing.txt")}, &HostInfo{}); err == nil { + t.Error("accepted missing port file") + } +} diff --git a/core/service_scanner.go b/core/service_scanner.go index 4c62c92..dc14301 100644 --- a/core/service_scanner.go +++ b/core/service_scanner.go @@ -106,9 +106,10 @@ func (s *ServiceScanStrategy) Description() string { // Execute 执行服务扫描策略 func (s *ServiceScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) { config := session.Config + s.SetState(session.State) - // 验证扫描目标(需要同时检查 -h 和 -hf 参数) - if info.Host == "" && session.Params.HostsFile == "" { + // host:port 已在配置解析阶段转入 State。 + if info.Host == "" && session.Params.HostsFile == "" && len(session.State.GetHostPorts()) == 0 { session.LogError(i18n.GetText("parse_error_target_empty")) return } @@ -209,7 +210,11 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm if len(hostPorts) > 0 { merged := mergeHostPorts(nil, hostPorts) targets := s.convertToTargetInfos(merged, info) + excludedPorts := portSet(config.Target.ExcludePorts) for _, target := range targets { + if iter.IsExcluded(target.Host) || excludedPorts[target.Port] { + continue + } for _, pluginName := range pluginsToRun { if s.IsPluginApplicableByName(pluginName, target.Host, target.Port, isCustomMode, config) { executeScanTask(ctx, session, pluginName, target, ch, wg) @@ -277,17 +282,14 @@ func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *c // 用户指定 -p 时,只调度端口有交集的 UDP 插件 var userPorts map[int]bool if config.Target.Ports != "" && config.Target.Ports != "all" { - parsed := parsers.ParsePort(config.Target.Ports) - userPorts = make(map[int]bool, len(parsed)) - for _, p := range parsed { - userPorts[p] = true - } + userPorts = portSet(config.Target.Ports) } + excludedPorts := portSet(config.Target.ExcludePorts) for _, host := range hosts { for _, pluginName := range udpPlugins { for _, port := range plugins.GetPluginPorts(pluginName) { - if userPorts != nil && !userPorts[port] { + if excludedPorts[port] || (userPorts != nil && !userPorts[port]) { continue } target := baseInfo @@ -299,6 +301,15 @@ func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *c } } +func portSet(ports string) map[int]bool { + parsed := parsers.ParsePort(ports) + set := make(map[int]bool, len(parsed)) + for _, port := range parsed { + set[port] = true + } + return set +} + // PrepareTargets 准备目标信息 func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *common.ScanSession) []common.HostInfo { // 发现目标主机和端口 diff --git a/core/target_scope_test.go b/core/target_scope_test.go new file mode 100644 index 0000000..66a6878 --- /dev/null +++ b/core/target_scope_test.go @@ -0,0 +1,130 @@ +package core + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type targetRecorderPlugin struct { + plugins.BasePlugin + calls chan common.HostInfo +} + +func (p *targetRecorderPlugin) Scan(_ context.Context, info *common.HostInfo, _ *common.ScanSession) *plugins.Result { + p.calls <- *info + return &plugins.Result{Skipped: true} +} + +func registerTargetRecorder(t *testing.T, kind string, ports ...int) (string, chan common.HostInfo) { + t.Helper() + name := "scope_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_")) + calls := make(chan common.HostInfo, 16) + plugins.RegisterWithTypes(name, func() plugins.Plugin { + return &targetRecorderPlugin{BasePlugin: plugins.NewBasePlugin(name), calls: calls} + }, ports, []string{kind}) + return name, calls +} + +func TestServiceScanAcceptsExplicitHostPort(t *testing.T) { + for _, target := range []string{"127.0.0.1:10001", "[::1]:10001"} { + t.Run(target, func(t *testing.T) { + name, calls := registerTargetRecorder(t, plugins.PluginTypeService, 10001) + flags := &common.FlagVars{ScanMode: name, ThreadNum: 1, Silent: true} + info := common.HostInfo{Host: target} + cfg, state, err := common.BuildConfig(flags, &info) + if err != nil { + t.Fatal(err) + } + session := common.NewScanSession(cfg, state, flags) + var wg sync.WaitGroup + NewServiceScanStrategy().Execute(context.Background(), session, info, make(chan struct{}, 1), &wg) + wg.Wait() + if len(calls) != 1 { + t.Fatalf("dispatched %d targets, want the explicit host:port", len(calls)) + } + called := <-calls + if got := called.Target(); got != target { + t.Errorf("dispatched %q, want %q", got, target) + } + }) + } +} + +func TestCachedTargetsRespectExclusions(t *testing.T) { + name, calls := registerTargetRecorder(t, plugins.PluginTypeService, 10001, 10002, 10003) + dir := t.TempDir() + hostsFile := filepath.Join(dir, "hosts.txt") + excludeFile := filepath.Join(dir, "exclude.txt") + if err := os.WriteFile(hostsFile, nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(excludeFile, []byte("192.0.2.2\n::1\n"), 0600); err != nil { + t.Fatal(err) + } + flags := &common.FlagVars{ScanMode: name, HostsFile: hostsFile, ExcludeHosts: "192.0.2.1,198.51.100.0/24", ExcludeHostsFile: excludeFile, ExcludePorts: "10002-10003", Silent: true} + cfg := common.BuildConfigFromFlags(flags) + state := common.NewState() + state.SetHostPorts([]string{"127.0.0.1:10001", "127.0.0.1:10002", "127.0.0.1:10003", "192.0.2.1:10001", "192.0.2.2:10001", "198.51.100.20:10001", "[::1]:10001"}) + session := common.NewScanSession(cfg, state, flags) + var wg sync.WaitGroup + NewServiceScanStrategy().Execute(context.Background(), session, common.HostInfo{}, make(chan struct{}, 8), &wg) + wg.Wait() + if len(calls) != 1 { + t.Fatalf("dispatched %d cached targets, want only the allowed target", len(calls)) + } + called := <-calls + if got := called.Target(); got != "127.0.0.1:10001" { + t.Errorf("dispatched excluded target %q", got) + } +} + +func TestUDPDispatchRespectsExcludedPorts(t *testing.T) { + name, calls := registerTargetRecorder(t, plugins.PluginTypeUDP, 10001, 10002) + cfg := common.NewConfig() + cfg.Mode = name + cfg.Target.Ports = "10001,10002" + cfg.Target.ExcludePorts = "10002" + session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{}) + var wg sync.WaitGroup + NewServiceScanStrategy().dispatchUDPPlugins(context.Background(), session, []string{"127.0.0.1"}, common.HostInfo{}, cfg, make(chan struct{}, 2), &wg) + wg.Wait() + if len(calls) != 1 { + t.Fatalf("dispatched %d UDP targets, want 1", len(calls)) + } + if got := (<-calls).Port; got != 10001 { + t.Errorf("dispatched excluded UDP port %d", got) + } +} + +func TestServiceScanUsesSessionCache(t *testing.T) { + name, calls := registerTargetRecorder(t, plugins.PluginTypeService, 10002) + hostsFile := filepath.Join(t.TempDir(), "hosts.txt") + if err := os.WriteFile(hostsFile, nil, 0600); err != nil { + t.Fatal(err) + } + flags := &common.FlagVars{ScanMode: "all", HostsFile: hostsFile, Silent: true} + cfg := common.BuildConfigFromFlags(flags) + state := common.NewState() + state.SetHostPorts([]string{"127.0.0.1:10003"}) + CacheServiceInfoWithState(state, "127.0.0.1", 10003, &ServiceInfo{Name: name}) + previous := globalState + SetGlobalState(common.NewState()) + t.Cleanup(func() { SetGlobalState(previous) }) + session := common.NewScanSession(cfg, state, flags) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var wg sync.WaitGroup + NewServiceScanStrategy().Execute(ctx, session, common.HostInfo{}, make(chan struct{}, 16), &wg) + wg.Wait() + if len(calls) != 1 { + t.Fatalf("session service cache was ignored: dispatched %d matching plugins", len(calls)) + } +} diff --git a/pkg/fscan/progress_test.go b/pkg/fscan/progress_test.go new file mode 100644 index 0000000..372308e --- /dev/null +++ b/pkg/fscan/progress_test.go @@ -0,0 +1,53 @@ +package fscan + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/plugins" +) + +type progressPlugin struct { + plugins.BasePlugin + observed <-chan struct{} +} + +func (p *progressPlugin) Scan(ctx context.Context, _ *common.HostInfo, session *common.ScanSession) *plugins.Result { + session.State.IncrementPacketCount() + select { + case <-p.observed: + case <-ctx.Done(): + } + return &plugins.Result{Skipped: true} +} + +func TestOnProgressIncludesScanStateWithoutController(t *testing.T) { + const name = "test_progress_state" + observed := make(chan struct{}) + plugins.RegisterWithTypes(name, func() plugins.Plugin { + return &progressPlugin{BasePlugin: plugins.NewBasePlugin(name), observed: observed} + }, []int{10001}, []string{plugins.PluginTypeService}) + var once sync.Once + scanner := NewScanner(Config{ + Plugins: []string{name}, + OnProgress: func(p ScanProgress) { + if p.Packets > 0 { + once.Do(func() { close(observed) }) + } + }, + }) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, err := scanner.Scan(ctx, Target{Host: "127.0.0.1:10001"}) + select { + case <-observed: + default: + t.Fatalf("OnProgress never observed the active scan state: %v", err) + } + if err != nil { + t.Fatal(err) + } +} diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index fc6984e..d48628d 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -206,6 +206,7 @@ func (s *Scanner) scanEach(ctx context.Context, opts scanOpts, handle ResultHand ctrl := opts.controller if ctrl == nil && s.config.OnProgress != nil { ctrl = newScanController() + opts.controller = ctrl } ctx, cancel := context.WithCancel(ctx) diff --git a/web/api/scan.go b/web/api/scan.go index 6858f0b..f9327f0 100644 --- a/web/api/scan.go +++ b/web/api/scan.go @@ -34,14 +34,14 @@ type ScanRequest struct { ExcludePorts string `json:"exclude_ports"` // 扫描控制 - ScanMode string `json:"scan_mode"` - ThreadNum int `json:"thread_num"` - Timeout int `json:"timeout"` - ModuleThreadNum int `json:"module_thread_num"` - DisablePing bool `json:"disable_ping"` - DisableBrute bool `json:"disable_brute"` - DisableSubnetProbe bool `json:"disable_subnet_probe"` - AliveOnly bool `json:"alive_only"` + ScanMode string `json:"scan_mode"` + ThreadNum int `json:"thread_num"` + Timeout int `json:"timeout"` + ModuleThreadNum int `json:"module_thread_num"` + DisablePing bool `json:"disable_ping"` + DisableBrute bool `json:"disable_brute"` + DisableSubnetProbe bool `json:"disable_subnet_probe"` + AliveOnly bool `json:"alive_only"` // 认证 Username string `json:"username"` @@ -169,6 +169,47 @@ func (h *ScanHandler) runScan(req ScanRequest) { }) }() + info, session, err := buildScanSession(req) + if err != nil { + common.LogError(err.Error()) + return + } + state := session.State + + // 过渡桥:全局状态同步(待 Phase 5 移除) + common.SetGlobalConfig(session.Config) + common.SetGlobalState(state) + + // 项目缓存注入:把已知的 host:port 加入扫描目标 + if req.ProjectID != "" { + if cached := globalProjectStore.CachedHostPorts(req.ProjectID); len(cached) > 0 { + state.SetHostPorts(append(state.GetHostPorts(), cached...)) + } + } + + // 设置WebSocket结果回调 + common.SetResultCallback(func(result interface{}) { + item := h.results.Add(result) + if item != nil { + h.hub.Broadcast(ws.MsgScanResult, item) + } + }) + + // 执行扫描 + if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) { + common.LogError(err.Error()) + } + + // 项目缓存回写:合并本次扫描结果 + if req.ProjectID != "" { + items := h.results.List() + if len(items) > 0 { + _ = globalProjectStore.MergeResults(req.ProjectID, items) + } + } +} + +func buildScanSession(req ScanRequest) (common.HostInfo, *common.ScanSession, error) { // 构建HostInfo info := common.HostInfo{ Host: req.Host, @@ -218,41 +259,12 @@ func (h *ScanHandler) runScan(req ScanRequest) { } // 构建Config和Session - config := common.BuildConfigFromFlags(fv) - state := common.NewState() + config, state, err := common.BuildConfig(fv, &info) + if err != nil { + return info, nil, err + } session := common.NewScanSession(config, state, fv) - - // 过渡桥:全局状态同步(待 Phase 5 移除) - common.SetGlobalConfig(config) - common.SetGlobalState(state) - - // 项目缓存注入:把已知的 host:port 加入扫描目标 - if req.ProjectID != "" { - if cached := globalProjectStore.CachedHostPorts(req.ProjectID); len(cached) > 0 { - state.SetHostPorts(cached) - } - } - - // 设置WebSocket结果回调 - common.SetResultCallback(func(result interface{}) { - item := h.results.Add(result) - if item != nil { - h.hub.Broadcast(ws.MsgScanResult, item) - } - }) - - // 执行扫描 - if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) { - common.LogError(err.Error()) - } - - // 项目缓存回写:合并本次扫描结果 - if req.ProjectID != "" { - items := h.results.List() - if len(items) > 0 { - _ = globalProjectStore.MergeResults(req.ProjectID, items) - } - } + return info, session, nil } // Stop 停止扫描 diff --git a/web/api/scan_config_test.go b/web/api/scan_config_test.go new file mode 100644 index 0000000..384cade --- /dev/null +++ b/web/api/scan_config_test.go @@ -0,0 +1,46 @@ +//go:build web + +package api + +import ( + "reflect" + "testing" + + scanplugins "github.com/shadow1ng/fscan/plugins" +) + +func TestBuildScanSessionUsesRequestedCredentials(t *testing.T) { + for _, tc := range []struct { + name string + username string + password string + want []scanplugins.Credential + }{ + {"single pair", "operator", " secret ", []scanplugins.Credential{{Username: "operator", Password: " secret "}}}, + {"multiple users", "alice,bob", "secret", []scanplugins.Credential{{Username: "alice", Password: "secret"}, {Username: "bob", Password: "secret"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, session, err := buildScanSession(ScanRequest{Host: "127.0.0.1", Username: tc.username, Password: tc.password}) + if err != nil { + t.Fatal(err) + } + if got := scanplugins.GenerateCredentials("ssh", session.Config); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("credentials = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBuildScanSessionParsesExplicitHostPort(t *testing.T) { + for _, host := range []string{"127.0.0.1:10001", "[::1]:10001"} { + t.Run(host, func(t *testing.T) { + info, session, err := buildScanSession(ScanRequest{Host: host}) + if err != nil { + t.Fatal(err) + } + if got := session.State.GetHostPorts(); info.Host != "" || !reflect.DeepEqual(got, []string{host}) { + t.Fatalf("explicit target was not parsed: host=%q, cached=%v", info.Host, got) + } + }) + } +}