From b73c707a3bf27ceede28078464c6d7b2f2373cf6 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 1 Sep 2026 15:09:00 +0800 Subject: [PATCH 1/5] fix: handle encoded POC set values --- webscan/lib/eval_misc.go | 10 ++++ webscan/lib/eval_test.go | 17 ++++++ webscan/lib/poc_executor.go | 38 ++++++++++++ webscan/lib/poc_executor_test.go | 58 ++++++++++++++----- .../ecology-hrmcareerapplyperview-sqli.yaml | 2 +- 5 files changed, 110 insertions(+), 15 deletions(-) diff --git a/webscan/lib/eval_misc.go b/webscan/lib/eval_misc.go index c6e06564..e540194f 100644 --- a/webscan/lib/eval_misc.go +++ b/webscan/lib/eval_misc.go @@ -21,6 +21,10 @@ func registerMiscDeclarations() []*exprpb.Decl { decls.NewOverload("tongda_date", []*exprpb.Type{}, decls.String)), + decls.NewFunction("timestamp_second", + decls.NewOverload("timestamp_second_zero", + []*exprpb.Type{}, + decls.Int)), } } @@ -47,5 +51,11 @@ func registerMiscImplementations() []*functions.Overload { return types.String(time.Now().Format("0601")) }, }, + { + Operator: "timestamp_second_zero", + Function: func(value ...ref.Val) ref.Val { + return types.Int(time.Now().Unix()) + }, + }, } } diff --git a/webscan/lib/eval_test.go b/webscan/lib/eval_test.go index 9115dd85..3f83ac87 100644 --- a/webscan/lib/eval_test.go +++ b/webscan/lib/eval_test.go @@ -10,6 +10,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/google/cel-go/common/types" ) @@ -1393,3 +1394,19 @@ func TestMakeVarDecl(t *testing.T) { }) } } + +func TestTimestampSecond(t *testing.T) { + before := time.Now().Unix() + result, err := Evaluate(GetBaseEnv(), "timestamp_second()", map[string]interface{}{}) + if err != nil { + t.Fatal(err) + } + got, ok := result.Value().(int64) + if !ok { + t.Fatalf("timestamp_second() type = %T, want int64", result.Value()) + } + after := time.Now().Unix() + if got < before || got > after { + t.Fatalf("timestamp_second() = %d, want [%d, %d]", got, before, after) + } +} diff --git a/webscan/lib/poc_executor.go b/webscan/lib/poc_executor.go index 0f258799..c87f8007 100644 --- a/webscan/lib/poc_executor.go +++ b/webscan/lib/poc_executor.go @@ -869,6 +869,10 @@ func cloneMap(tags map[string]string) map[string]string { // evalset 执行CEL表达式并处理特殊类型结果 func evalset(env *cel.Env, variableMap map[string]interface{}, k string, expression string) (string, error) { + if isPlainLiteral(expression, variableMap) { + variableMap[k] = expression + return expression, nil + } out, err := Evaluate(env, expression, variableMap) if err != nil { variableMap[k] = "" @@ -915,6 +919,11 @@ func isPlainLiteral(expr string, variableMap map[string]interface{}) bool { if _, exists := variableMap[expr]; exists { return false } + // Base64/JWT 常量常包含 +、/、= 或 .,这些字符在 CEL 中也是语法符号。 + // 先识别编码值,避免把密钥和令牌误当成表达式编译。 + if isEncodedLiteral(expr) { + return true + } // 含 CEL 语法特征的需要走 CEL 编译 for _, c := range expr { switch c { @@ -925,6 +934,35 @@ func isPlainLiteral(expr string, variableMap map[string]interface{}) bool { return true } +func isEncodedLiteral(value string) bool { + if strings.Count(value, ".") == 2 { + parts := strings.Split(value, ".") + for _, part := range parts { + if part == "" || strings.IndexFunc(part, func(r rune) bool { + return !isASCIIAlphaNumeric(r) && r != '-' && r != '_' + }) >= 0 { + return false + } + } + return true + } + + if len(value) < 4 || len(value)%4 != 0 { + return false + } + padding := strings.TrimRight(value, "=") + if len(value)-len(padding) > 2 { + return false + } + return strings.IndexFunc(padding, func(r rune) bool { + return !isASCIIAlphaNumeric(r) && r != '+' && r != '/' + }) < 0 +} + +func isASCIIAlphaNumeric(r rune) bool { + return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' +} + // CheckInfoPoc 检查POC信息并返回别名 func CheckInfoPoc(infostr string) string { for _, poc := range fingerprint.PocDatas { diff --git a/webscan/lib/poc_executor_test.go b/webscan/lib/poc_executor_test.go index d5ea0696..3bb1fe84 100644 --- a/webscan/lib/poc_executor_test.go +++ b/webscan/lib/poc_executor_test.go @@ -608,16 +608,16 @@ func stringMatrixEqual(a, b [][]string) bool { func TestBuildVulnDetails(t *testing.T) { tests := []struct { - name string - pocDef *Poc - vulName string - params StrMap - wantKeys []string - wantNoKeys []string - wantVulnType string - wantVulnName string - wantParamVal string - wantParamKey string + name string + pocDef *Poc + vulName string + params StrMap + wantKeys []string + wantNoKeys []string + wantVulnType string + wantVulnName string + wantParamVal string + wantParamKey string }{ { name: "最小Poc只有Name", @@ -647,15 +647,15 @@ func TestBuildVulnDetails(t *testing.T) { wantVulnName: "Full Vuln", }, { - name: "有params则details含parameters字段", - pocDef: &Poc{Name: "poc-yaml-params"}, + name: "有params则details含parameters字段", + pocDef: &Poc{Name: "poc-yaml-params"}, vulName: "Params Vuln", params: StrMap{ {Key: "user", Value: "admin"}, {Key: "pass", Value: "123456"}, }, - wantKeys: []string{"vulnerability_type", "vulnerability_name", "parameters"}, - wantNoKeys: []string{"author"}, + wantKeys: []string{"vulnerability_type", "vulnerability_name", "parameters"}, + wantNoKeys: []string{"author"}, wantParamKey: "user", wantParamVal: "admin", }, @@ -865,3 +865,33 @@ func TestCollectVarDeclarations(t *testing.T) { } }) } + +func TestEvalSetTreatsEncodedValuesAsLiterals(t *testing.T) { + env := GetBaseEnv() + tests := []string{ + "fsHspZw/92PrS3XrPW+vxw==", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuYWNvcyJ9.feetKmWoPnMkAebjkNnyuKo6c21_hzTgu0dfNqbdpZQ", + } + + for _, value := range tests { + variables := map[string]interface{}{} + got, err := evalset(env, variables, "token", value) + if err != nil { + t.Fatalf("evalset(%q) error = %v", value, err) + } + if got != value || variables["token"] != value { + t.Fatalf("evalset(%q) = %q, stored %v", value, got, variables["token"]) + } + } +} + +func TestEvalSetStillEvaluatesExpressions(t *testing.T) { + variables := map[string]interface{}{} + got, err := evalset(GetBaseEnv(), variables, "token", "randomLowercase(6)") + if err != nil { + t.Fatal(err) + } + if len(got) != 6 { + t.Fatalf("randomLowercase result length = %d, want 6", len(got)) + } +} diff --git a/webscan/pocs/ecology-hrmcareerapplyperview-sqli.yaml b/webscan/pocs/ecology-hrmcareerapplyperview-sqli.yaml index f813ea73..5183fe69 100644 --- a/webscan/pocs/ecology-hrmcareerapplyperview-sqli.yaml +++ b/webscan/pocs/ecology-hrmcareerapplyperview-sqli.yaml @@ -12,7 +12,7 @@ info: created: 2025/06/11 set: - randstr: randLowercase(6) + randstr: randomLowercase(6) rules: r0: request: From 4f169206d7114192b57483ca771e52317b3657d1 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 1 Oct 2026 12:46:29 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=87=AD=E6=8D=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E5=92=8C=E6=A8=A1=E5=9D=97=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++ README_EN.md | 5 ++ common/config_builder.go | 11 ++- common/config_builder_test.go | 100 ++++++++++++++++++++++++ common/flag.go | 7 ++ common/flag_config.go | 4 +- common/i18n/locales/en.yaml | 8 +- common/i18n/locales/zh.yaml | 8 +- main_cli.go | 10 +++ main_cli_plugins.go | 74 ++++++++++++++++++ main_cli_plugins_test.go | 127 +++++++++++++++++++++++++++++++ plugins/services/netbios.go | 8 +- plugins/services/netbios_test.go | 13 ++++ 13 files changed, 367 insertions(+), 13 deletions(-) create mode 100644 main_cli_plugins.go create mode 100644 main_cli_plugins_test.go diff --git a/README.md b/README.md index 35f42aa3..3fc733ad 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,11 @@ yay -S fscan-git ## 运行截图 +使用 `fscan -list-plugins` 查看当前构建可用的模块名称、类型、默认端口和调用方式。 +通过 `-m` 指定有固定端口的模块且未传 `-p/-pf` 时,扫描范围使用所选模块默认端口的并集; +例如 `fscan -hf ip.txt -m netbios` 使用 `137,139`。指定 `-p` 可扫描非标准端口,Web 模块仍使用通用端口范围。 +`-pwda/-usera` 追加到默认字典;与 `-pwd/-user` 或 `-pwdf/-userf` 同用时,追加到指定字典。 + `fscan.exe -h 192.168.x.x` ![](image/1.png) diff --git a/README_EN.md b/README_EN.md index 1865ff34..39134c9c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -200,6 +200,11 @@ yay -S fscan-git ## Screenshots +Use `fscan -list-plugins` to list the plugins available in the current build, their types, default ports and usage. +When `-m` selects plugins with fixed ports and neither `-p` nor `-pf` is supplied, scanning uses the union of their default ports; +for example, `fscan -hf ip.txt -m netbios` uses `137,139`. Use `-p` for nonstandard ports. Web plugins retain the common port range. +`-pwda/-usera` append to the default dictionaries, or to the supplied dictionaries when used with `-pwd/-user` or `-pwdf/-userf`. + `fscan.exe -h 192.168.x.x` ![](image/1.png) diff --git a/common/config_builder.go b/common/config_builder.go index 14559c74..3378492d 100644 --- a/common/config_builder.go +++ b/common/config_builder.go @@ -55,8 +55,12 @@ func parseCredentials(fv *FlagVars, cfg *Config) error { return err } if len(usernames) > 0 { - for serviceName := range cfg.Credentials.Userdict { - cfg.Credentials.Userdict[serviceName] = usernames + for serviceName, defaults := range cfg.Credentials.Userdict { + if fv.Username == "" && fv.UsersFile == "" { + cfg.Credentials.Userdict[serviceName] = removeDuplicate(append(defaults, usernames...)) + } else { + cfg.Credentials.Userdict[serviceName] = usernames + } } } @@ -66,6 +70,9 @@ func parseCredentials(fv *FlagVars, cfg *Config) error { return err } if len(passwords) > 0 { + if fv.Password == "" && fv.PasswordsFile == "" { + passwords = removeDuplicate(append(cfg.Credentials.Passwords, passwords...)) + } cfg.Credentials.Passwords = passwords } diff --git a/common/config_builder_test.go b/common/config_builder_test.go index bdf9af2e..d8b3f398 100644 --- a/common/config_builder_test.go +++ b/common/config_builder_test.go @@ -1,6 +1,8 @@ package common import ( + "os" + "path/filepath" "reflect" "testing" "time" @@ -8,6 +10,104 @@ import ( fscanconfig "github.com/shadow1ng/fscan/common/config" ) +func TestBuildConfigAdditionalCredentials(t *testing.T) { + original := NewConfig().Credentials + dir := t.TempDir() + usersFile := filepath.Join(dir, "users.txt") + passwordsFile := filepath.Join(dir, "passwords.txt") + if err := os.WriteFile(usersFile, []byte("file-user\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(passwordsFile, []byte("file-password\n"), 0600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + flags FlagVars + users []string + passwords []string + appendUsers bool + }{ + {name: "defaults"}, + { + name: "append to defaults", + flags: FlagVars{ + AddUsers: "extra-user,extra-user", + AddPasswords: "extra-password extra-password", + }, + users: []string{"extra-user"}, + passwords: append(append([]string(nil), fscanconfig.DefaultPasswords...), "extra-password"), + appendUsers: true, + }, + { + name: "default password is not duplicated", + flags: FlagVars{AddPasswords: "123456,123456"}, + }, + { + name: "append to command line overrides", + flags: FlagVars{ + Username: "custom-user", Password: "primary password", + AddUsers: "extra-user,custom-user", AddPasswords: "extra-password extra-password", + }, + users: []string{"custom-user", "extra-user"}, + passwords: []string{"primary password", "extra-password"}, + }, + { + name: "append to file overrides", + flags: FlagVars{ + UsersFile: usersFile, PasswordsFile: passwordsFile, + AddUsers: "extra-user,file-user", AddPasswords: "extra-password file-password", + }, + users: []string{"file-user", "extra-user"}, + passwords: []string{"file-password", "extra-password"}, + }, + { + name: "command line and files still replace defaults", + flags: FlagVars{ + Username: "custom-user", Password: "primary password", + UsersFile: usersFile, PasswordsFile: passwordsFile, + AddUsers: "extra-user", AddPasswords: "extra-password", + }, + users: []string{"custom-user", "file-user", "extra-user"}, + passwords: []string{"primary password", "file-password", "extra-password"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, _, err := BuildConfig(&tt.flags, &HostInfo{}) + if err != nil { + t.Fatal(err) + } + wantPasswords := tt.passwords + if wantPasswords == nil { + wantPasswords = append([]string(nil), fscanconfig.DefaultPasswords...) + } + if !reflect.DeepEqual(cfg.Credentials.Passwords, wantPasswords) { + t.Errorf("passwords = %v, want %v", cfg.Credentials.Passwords, wantPasswords) + } + for service, defaults := range fscanconfig.DefaultUserDict { + wantUsers := tt.users + if wantUsers == nil || tt.appendUsers { + wantUsers = append(append([]string{}, defaults...), wantUsers...) + } + if !reflect.DeepEqual(cfg.Credentials.Userdict[service], wantUsers) { + t.Errorf("%s users = %v, want %v", service, cfg.Credentials.Userdict[service], wantUsers) + } + } + }) + } + defaults, _, err := BuildConfig(&FlagVars{}, &HostInfo{}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(defaults.Credentials.Passwords, original.Passwords) || + !reflect.DeepEqual(defaults.Credentials.Userdict, original.Userdict) { + t.Fatal("additional credentials leaked into a later config") + } +} + func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) { fv := &FlagVars{ Password: "root admin,pass0", diff --git a/common/flag.go b/common/flag.go index 29bf7ab2..a7114603 100644 --- a/common/flag.go +++ b/common/flag.go @@ -209,6 +209,7 @@ func Flag(Info *HostInfo) error { // 帮助参数 flag.BoolVar(&fv.ShowHelp, "help", false, i18n.GetText("flag_help")) + flag.BoolVar(&fv.ListPlugins, "list-plugins", false, i18n.GetText("flag_list_plugins")) // 解析命令行参数 if err := parseCommandLineArgs(); err != nil { @@ -218,6 +219,8 @@ func Flag(Info *HostInfo) error { // 检测用户是否显式指定了 -t flag.Visit(func(f *flag.Flag) { switch f.Name { + case "p": + fv.PortsExplicit = true case "t": fv.ThreadNumExplicit = true case "time": @@ -348,6 +351,10 @@ func preProcessLanguage() { // shouldShowHelp 检查是否应该显示帮助信息 func shouldShowHelp(Info *HostInfo, fv *FlagVars) bool { + if fv.ListPlugins { + return false + } + // Web模式不需要目标参数 if WebMode { return false diff --git a/common/flag_config.go b/common/flag_config.go index 160e3dfe..e62753f6 100644 --- a/common/flag_config.go +++ b/common/flag_config.go @@ -26,6 +26,7 @@ type FlagVars struct { ExcludeHosts string ExcludeHostsFile string Ports string + PortsExplicit bool ExcludePorts string HostsFile string PortsFile string @@ -121,7 +122,8 @@ type FlagVars struct { DownloadSavePath string // 帮助 - ShowHelp bool + ShowHelp bool + ListPlugins bool } // ============================================================================= diff --git a/common/i18n/locales/en.yaml b/common/i18n/locales/en.yaml index c5aa4860..232afc14 100644 --- a/common/i18n/locales/en.yaml +++ b/common/i18n/locales/en.yaml @@ -9,7 +9,7 @@ flag_exclude_hosts: flag_exclude_hosts_file: other: "Exclude hosts file" flag_ports: - other: "Ports: default 1000 common ports" + other: "Ports: defaults to selected plugins' ports, otherwise common ports" flag_exclude_ports: other: "Exclude ports" flag_hosts_file: @@ -17,7 +17,11 @@ flag_hosts_file: flag_ports_file: other: "Ports file" flag_scan_mode: - other: "Scan mode: all(all plugins), icmp(alive detection), or specific plugin names" + other: "Scan mode: all(all plugins), icmp(alive detection), or specific plugin names (see -list-plugins)" +flag_list_plugins: + other: "List available plugins, types, default ports and usage, then exit" +plugin_list_header: + other: "Plugin\tType\tDefault ports\tUsage" flag_thread_num: other: "Port scan thread count" flag_timeout: diff --git a/common/i18n/locales/zh.yaml b/common/i18n/locales/zh.yaml index 1e9371b1..567dddf8 100644 --- a/common/i18n/locales/zh.yaml +++ b/common/i18n/locales/zh.yaml @@ -9,7 +9,7 @@ flag_exclude_hosts: flag_exclude_hosts_file: other: "排除主机文件" flag_ports: - other: "端口: 默认1000个常用端口" + other: "端口: 指定模块时默认使用模块端口,否则使用常用端口" flag_exclude_ports: other: "排除端口" flag_hosts_file: @@ -17,7 +17,11 @@ flag_hosts_file: flag_ports_file: other: "端口文件" flag_scan_mode: - other: "扫描模式: all(全部), icmp(存活探测), 或指定插件名称" + other: "扫描模式: all(全部), icmp(存活探测), 或指定插件名称(-list-plugins 查看)" +flag_list_plugins: + other: "列出当前构建可用的模块、类型、默认端口和调用方式后退出" +plugin_list_header: + other: "模块\t类型\t默认端口\t调用方式" flag_thread_num: other: "端口扫描线程数" flag_timeout: diff --git a/main_cli.go b/main_cli.go index 2c5802de..526b3b95 100644 --- a/main_cli.go +++ b/main_cli.go @@ -38,11 +38,21 @@ func run() int { return 1 } + flags := common.GetFlagVars() + if flags.ListPlugins { + if err := printPluginList(os.Stdout); err != nil { + common.LogError(i18n.Tr("error_generic", err)) + return 1 + } + return 0 + } + // 检查参数互斥性 if err := common.ValidateExclusiveParams(&info); err != nil { common.LogError(i18n.Tr("error_generic", err)) return 1 } + setPluginDefaultPorts(flags) // 统一初始化:解析 → 配置 → 输出 result, err := common.Initialize(&info) diff --git a/main_cli_plugins.go b/main_cli_plugins.go new file mode 100644 index 00000000..6129bb4e --- /dev/null +++ b/main_cli_plugins.go @@ -0,0 +1,74 @@ +//go:build !web + +package main + +import ( + "fmt" + "io" + "slices" + "strconv" + "strings" + "text/tabwriter" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/plugins" +) + +func setPluginDefaultPorts(flags *common.FlagVars) { + if flags.PortsExplicit || flags.PortsFile != "" || flags.AliveOnly || flags.LocalPlugin != "" { + return + } + + var ports []int + for _, name := range strings.Split(flags.ScanMode, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + defaults := plugins.GetPluginPorts(name) + // Web、本地插件和特殊模式没有固定端口,保留通用端口范围。 + if len(defaults) == 0 { + return + } + ports = append(ports, defaults...) + } + if len(ports) > 0 { + flags.Ports = formatPluginPorts(ports) + } +} + +func formatPluginPorts(ports []int) string { + ports = slices.Clone(ports) + slices.Sort(ports) + var values []string + for _, port := range slices.Compact(ports) { + values = append(values, strconv.Itoa(port)) + } + return strings.Join(values, ",") +} + +func printPluginList(w io.Writer) error { + names := plugins.All() + slices.Sort(names) + table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(table, i18n.GetText("plugin_list_header")) + for _, name := range names { + var types []string + for _, kind := range []string{plugins.PluginTypeService, plugins.PluginTypeUDP, plugins.PluginTypeWeb, plugins.PluginTypeLocal} { + if plugins.HasType(name, kind) { + types = append(types, kind) + } + } + ports := formatPluginPorts(plugins.GetPluginPorts(name)) + if ports == "" { + ports = "-" + } + option := "-m" + if plugins.HasType(name, plugins.PluginTypeLocal) { + option = "-local" + } + _, _ = fmt.Fprintf(table, "%s\t%s\t%s\t%s %s\n", name, strings.Join(types, ","), ports, option, name) + } + return table.Flush() +} diff --git a/main_cli_plugins_test.go b/main_cli_plugins_test.go new file mode 100644 index 00000000..646c2061 --- /dev/null +++ b/main_cli_plugins_test.go @@ -0,0 +1,127 @@ +//go:build !web && !plugin_selective + +package main + +import ( + "bytes" + "flag" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/plugins" +) + +func TestCLIPluginDefaultPorts(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "netbios", args: []string{"-m", "netbios"}, want: "137,139"}, + {name: "multiple plugins", args: []string{"-m", "ssh, netbios,ssh,"}, want: "22,137,139,2200,2222,22222"}, + {name: "nonstandard port", args: []string{"-m", "ssh", "-p", "2222"}, want: "2222"}, + {name: "explicit common ports", args: []string{"-m", "netbios", "-p", config.MainPorts}, want: config.MainPorts}, + {name: "ports file", args: []string{"-m", "netbios", "-pf", "ports.txt"}, want: config.MainPorts}, + {name: "all", args: []string{"-m", "all"}, want: config.MainPorts}, + {name: "web plugin", args: []string{"-m", "webtitle"}, want: config.MainPorts}, + {name: "web and service", args: []string{"-m", "ssh,webtitle"}, want: config.MainPorts}, + {name: "unknown plugin", args: []string{"-m", "ssh,unknown-plugin"}, want: config.MainPorts}, + {name: "alive only", args: []string{"-m", "netbios", "-ao"}, want: config.MainPorts}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setCLIArgs(t, append([]string{"-h", "127.0.0.1", "-silent"}, tt.args...)...) + info := &common.HostInfo{} + if err := common.Flag(info); err != nil { + t.Fatal(err) + } + setPluginDefaultPorts(common.GetFlagVars()) + cfg, _, err := common.BuildConfig(common.GetFlagVars(), info) + if err != nil { + t.Fatal(err) + } + if cfg.Target.Ports != tt.want { + t.Errorf("ports = %q, want %q", cfg.Target.Ports, tt.want) + } + }) + } +} + +func TestPrintPluginList(t *testing.T) { + var output bytes.Buffer + if err := printPluginList(&output); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(output.String()), "\n") + names := plugins.All() + slices.Sort(names) + if len(lines) != len(names)+1 { + t.Fatalf("got %d rows for %d plugins", len(lines)-1, len(names)) + } + for i, name := range names { + fields := strings.Fields(lines[i+1]) + if len(fields) != 5 { + t.Fatalf("invalid plugin row: %q", lines[i+1]) + } + if fields[0] != name || fields[len(fields)-1] != name { + t.Errorf("plugin row = %q, want %s", lines[i+1], name) + } + if name == "netbios" && strings.Join(fields, " ") != "netbios service 137,139 -m netbios" { + t.Errorf("netbios metadata = %q", lines[i+1]) + } + if plugins.HasType(name, plugins.PluginTypeLocal) && fields[len(fields)-2] != "-local" { + t.Errorf("local plugin usage = %q", lines[i+1]) + } + } +} + +func TestCLIListPluginsExitsWithoutTargetOrOutput(t *testing.T) { + dir := t.TempDir() + outputFile := filepath.Join(dir, "result.txt") + setCLIArgs(t, "-list-plugins", "-silent", "-o", outputFile) + output, err := os.CreateTemp(dir, "stdout-") + if err != nil { + t.Fatal(err) + } + defer output.Close() + previousStdout := os.Stdout + os.Stdout = output + t.Cleanup(func() { os.Stdout = previousStdout }) + if code := run(); code != 0 { + t.Fatalf("exit code = %d", code) + } + if _, err := os.Stat(outputFile); !os.IsNotExist(err) { + t.Fatalf("listing initialized scan output: %v", err) + } + if _, err := output.Seek(0, io.SeekStart); err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(output) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "netbios") || !strings.Contains(string(data), "137,139") { + t.Fatalf("plugin list missing from output: %s", data) + } +} + +func setCLIArgs(t *testing.T, args ...string) { + t.Helper() + previousArgs := os.Args + previousFlagSet := flag.CommandLine + previousFlags := *common.GetFlagVars() + t.Cleanup(func() { + os.Args = previousArgs + flag.CommandLine = previousFlagSet + *common.GetFlagVars() = previousFlags + }) + os.Args = append([]string{"fscan-test"}, args...) + flag.CommandLine = flag.NewFlagSet("fscan-test", flag.ContinueOnError) + *common.GetFlagVars() = common.FlagVars{} +} diff --git a/plugins/services/netbios.go b/plugins/services/netbios.go index 44bfcacf..b9ff5b62 100644 --- a/plugins/services/netbios.go +++ b/plugins/services/netbios.go @@ -36,13 +36,9 @@ func (p *NetBIOSPlugin) Scan(ctx context.Context, info *common.HostInfo, session state := session.State target := info.Target() - // 检查端口类型 + // 多模块扫描会共享端口列表,跳过其他模块的端口。 if info.Port != 137 && info.Port != 139 { - return &ScanResult{ - Success: false, - Service: "netbios", - Error: fmt.Errorf("%s", i18n.GetText("netbios_port_only")), - } + return &ScanResult{Skipped: true, Service: "netbios"} } var netbiosInfo *NetBIOSInfo diff --git a/plugins/services/netbios_test.go b/plugins/services/netbios_test.go index 8435ca5c..21db5dbb 100644 --- a/plugins/services/netbios_test.go +++ b/plugins/services/netbios_test.go @@ -3,11 +3,24 @@ package services import ( + "context" "encoding/binary" "testing" "unicode/utf16" + + "github.com/shadow1ng/fscan/common" ) +func TestNetBIOSSkipsUnrelatedPorts(t *testing.T) { + session := common.NewScanSession(common.NewConfig(), common.NewState(), &common.FlagVars{}) + for _, port := range []int{22, 80, 443} { + result := NewNetBIOSPlugin().Scan(context.Background(), &common.HostInfo{Host: "127.0.0.1", Port: port}, session) + if result == nil || !result.Skipped || result.Success || result.Error != nil { + t.Errorf("port %d: result = %+v, want skipped without error", port, result) + } + } +} + func TestParseNTLMInfoUsesFullTargetInfoOffset(t *testing.T) { p := NewNetBIOSPlugin() info := &NetBIOSInfo{} From dcf0fc31e878f9f05afe5967bc60c0d5b4a5e7ca Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 1 Oct 2026 13:02:33 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E3=80=81=E4=BC=9A=E8=AF=9D=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=92=8C=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 578928bf..164e3285 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 14559c74..0adfce69 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 c5aa4860..a4b82788 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 1e9371b1..c9217336 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 00000000..445d5ccf --- /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 c0013ebe..5d3edc22 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 := w.file.Write(data); err != nil { - return err + if err == nil { + _, err = w.file.Write(data) } - 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 - } - if err := w.bufWriter.Flush(); err != nil { - return err + err := w.csvWriter.Error() + if err == nil { + err = w.bufWriter.Flush() } - 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 654cef64..4811af50 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 00000000..256ac939 --- /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 4c62c921..dc14301f 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 00000000..66a6878f --- /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 00000000..372308ed --- /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 fc6984e3..d48628df 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 6858f0bf..f9327f00 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() - 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) - } + config, state, err := common.BuildConfig(fv, &info) + if err != nil { + return info, nil, err } + session := common.NewScanSession(config, state, fv) + 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 00000000..384cadea --- /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) + } + }) + } +} From 2f70227a025c74749d2aea7c36dcbcc4bfea6d25 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 1 Oct 2026 13:17:34 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E5=87=86=E5=A4=87=20v2.2.2=20=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E4=B8=8E=E5=8F=91=E5=B8=83=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/RELEASE.md | 71 ++++++++++++++++++--------------- .github/release-notes/v2.2.2.md | 44 ++++++++++++++++++++ README.md | 2 +- README_EN.md | 2 +- common/globals.go | 2 +- 5 files changed, 85 insertions(+), 36 deletions(-) create mode 100644 .github/release-notes/v2.2.2.md diff --git a/.github/RELEASE.md b/.github/RELEASE.md index 0711d129..4428780a 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -1,36 +1,56 @@ # 发版流程 +正式版由 `main` 的 push 触发:读取 `common/globals.go` 的版本号、创建对应 tag,并在同一次工作流中构建和发布 GitHub Release。 + +**合并到 `main` 就会开始发布。** 合并前必须更新版本号、准备 Release Notes,并验证包含全部待发布改动的候选提交。测试构建工作流与发布工作流独立,发布不会等待合并后的测试结果。 + ## 预检查 ```bash -# 1. 确认 CI 通过 -gh run list --branch dev --limit 3 +# 使用已包含全部待发布改动的分支:dev 或 release/* +RELEASE_REF=release/v2.2.2 + +# 1. 确认该分支最新提交的测试 CI 通过 +gh run list --branch "$RELEASE_REF" --workflow test-build.yml --limit 3 + +# 2. 全平台 dry-run;必须指定 ref,避免默认构建 main +gh workflow run release.yml --ref "$RELEASE_REF" -f snapshot=true +gh run list --branch "$RELEASE_REF" --workflow release.yml --limit 3 -# 2. 全平台 dry-run(手动触发 snapshot 模式) -gh workflow run release.yml -f snapshot=true +# 3. 在候选分支确认版本号一致,且 Release Notes 已就绪 +rg 'version = ' common/globals.go +rg '^\*\*版本\*\*:' README.md +rg '^\*\*Version\*\*:' README_EN.md +cat .github/release-notes/v2.2.2.md -# 3. 确认版本号一致 -grep "version" common/globals.go -grep "版本" README.md -grep "Version" README_EN.md +# 4. 新版本 tag 应不存在;已有 tag 不可移动或覆盖 +git ls-remote --tags origin refs/tags/v2.2.2 ``` -## 发版 +等待 snapshot 工作流成功,并核对该次运行的 SHA 与待合并提交一致。产物包括标准版、无本地插件版、Web 版和 SHA256 校验文件;snapshot 不创建 tag 或 GitHub Release。 + +## 正式版发布 ```bash -# 1. 确认 release notes 已就绪 -cat .github/release-notes/v.md +# 从已验证的候选分支创建 PR +gh pr create --base main --head "$RELEASE_REF" -# 2. 打 tag(RC 手动打;正式版合并到 main 后由 CI 自动打 tag) -git tag v -git push origin v +# 审核通过后合并 PR;此次 main push 会自动打 tag 并发布 +# 无需再手动执行 git tag / git push tag -# CI 自动执行: -# - goreleaser 全平台构建 + UPX 压缩 -# - 创建 GitHub Release(RC 自动标记 pre-release) -# - 用 .github/release-notes/ 下的文件覆盖 release body +# 跟踪发布任务,确认 Release 与附件完整 +gh run list --branch main --workflow release.yml --limit 3 +gh release view v2.2.2 ``` +若工作流提示 tag 已存在且不指向当前提交,说明版本号未递增或该版本已经发布;先核对提交和版本,不能移动已有 tag。若构建失败,在对应 Actions 运行中查看失败步骤。 + +发布完成后将 `main` 同步回 `dev`,保留已发布提交的历史。手动重跑正式版构建必须指定已有 tag;`draft=true` 仅控制本次手动运行创建草稿,不改变 `main` 合并即发布的行为。 + +## 预发布 + +在候选分支准备 RC 版本号与同名 Release Notes,验证后手动推送 `vX.Y.Z-rc.N` tag。tag push 会触发构建,GoReleaser 自动将 RC 标记为 prerelease。 + ## 版本号规范 | 场景 | 格式 | 分支 | 示例 | @@ -44,18 +64,3 @@ git push origin v 放在 `.github/release-notes/.md`,格式参考 `v2.2.0-rc.md`。 如果文件不存在,goreleaser 会自动生成基于 commit 的 changelog。 - -## 正式版发布(RC → 正式) - -```bash -# 1. 在 dev 分支准备正式版内容 -# common/globals.go, README.md, README_EN.md -# .github/release-notes/v2.2.0.md - -# 2. 创建 dev -> main PR -gh pr create --base main --head dev - -# 3. 合并 PR -# main push 会自动读取 common/globals.go 中的版本号,创建 v tag -# tag push 会触发 GoReleaser 构建并创建 GitHub Release -``` diff --git a/.github/release-notes/v2.2.2.md b/.github/release-notes/v2.2.2.md new file mode 100644 index 00000000..fd907456 --- /dev/null +++ b/.github/release-notes/v2.2.2.md @@ -0,0 +1,44 @@ +# fscan v2.2.2 + +v2.2.2 是 v2.2 系列的修复版本,重点解决扫描参数未生效、目标遗漏、SMTP 扫描挂起、POC 表达式兼容性和结果备份丢失问题。 + +## 扫描参数与目标调度 + +- 修复只指定 `-pwda` 或 `-usera` 时默认字典被覆盖的问题;追加参数保留基础字典 +- 新增 `-list-plugins`,列出当前构建实际可用的模块及注册端口 +- 指定固定端口服务模块且未设置 `-p`、`-pf` 时,使用所选模块的注册端口集合;NetBIOS 跳过不适用端口 +- 修复 `-pf` 端口文件未读取的问题,支持逐行端口、范围和逗号列表;不可读或没有有效端口的文件返回错误 +- 修复 IPv4/IPv6 `host:port` 在解析后被误判为缺少扫描目标的问题 +- 服务扫描中的缓存目标遵守 `-eh`、`-ehf`、`-ep`,UDP 插件调度也遵守 `-ep` +- 服务扫描策略使用当前 session 的服务识别缓存 + +## 协议与 POC 兼容性 + +- 为 SMTP 匿名访问和开放中继检查设置连接读写期限,避免目标接受连接后不发送 greeting 导致扫描一直等待 +- 修复 Base64、JWT 等编码字符串被误当作 CEL 表达式编译的问题,覆盖 POC 的 `set` 和 `sets` +- 补充 `timestamp_second()`,修正 POC 中 `randomLowercase` 的调用 + +## Web、SDK 与结果保存 + +- Web 扫描使用完整配置解析,使自定义账号密码和 `host:port` 生效;项目缓存与显式目标合并 +- SDK 普通扫描的 `OnProgress` 回调关联实际扫描状态 +- TXT、JSON、CSV 仅在最终结果写入、同步并关闭成功后删除 `.realtime.tmp`;失败时保留恢复备份并关闭文件资源 +- 增加对应回归测试,并将 Web 接口测试纳入 CI + +## 相关 Issue 与 PR + +- #605:POC CEL 表达式兼容性 +- #609、#610、#611:凭据追加、模块端口选择与模块列表 +- #608:SMTP 静默服务端导致扫描挂起 +- #612:端口文件、目标调度、Web/SDK 状态及结果备份 + +## 升级说明 + +- 从 v2.2.1 可直接升级 +- 使用 `-pwda`、`-usera` 追加凭据时会保留基础字典;只测试指定凭据时请使用 `-user`、`-pwd` 或相应字典文件参数 +- 如需指定完整扫描端口集合,请显式使用 `-p` 或 `-pf` +- 结果保存失败时可检查输出文件旁的 `.realtime.tmp` 恢复备份 + +完整变更记录: + +https://github.com/shadow1ng/fscan/compare/v2.2.1...v2.2.2 diff --git a/README.md b/README.md index 3fc733ad..ba3c0ef6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 内网综合扫描工具,一键自动化漏扫。 -**版本**: 2.2.1 +**版本**: 2.2.2 ## 功能特性 diff --git a/README_EN.md b/README_EN.md index 39134c9c..4709a739 100644 --- a/README_EN.md +++ b/README_EN.md @@ -4,7 +4,7 @@ Comprehensive intranet scanning tool for automated vulnerability assessment. -**Version**: 2.2.1 +**Version**: 2.2.2 ## Features diff --git a/common/globals.go b/common/globals.go index ffcdc8eb..c112c04c 100644 --- a/common/globals.go +++ b/common/globals.go @@ -69,7 +69,7 @@ const ( // 版本信息,通过 ldflags 注入 var ( - version = "2.2.1" + version = "2.2.2" commit = "unknown" date = "unknown" ) From e5dddf4bbf693b3d95bc69cab1278d89e4d2a210 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 1 Oct 2026 13:18:58 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8E=E6=A8=A1=E5=9D=97=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E7=9A=84=E7=BB=84=E5=90=88=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main_cli_plugins_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/main_cli_plugins_test.go b/main_cli_plugins_test.go index 646c2061..9d5073f3 100644 --- a/main_cli_plugins_test.go +++ b/main_cli_plugins_test.go @@ -18,6 +18,10 @@ import ( ) func TestCLIPluginDefaultPorts(t *testing.T) { + portsFile := filepath.Join(t.TempDir(), "ports.txt") + if err := os.WriteFile(portsFile, []byte("9139\n8445\n"), 0600); err != nil { + t.Fatal(err) + } tests := []struct { name string args []string @@ -27,7 +31,7 @@ func TestCLIPluginDefaultPorts(t *testing.T) { {name: "multiple plugins", args: []string{"-m", "ssh, netbios,ssh,"}, want: "22,137,139,2200,2222,22222"}, {name: "nonstandard port", args: []string{"-m", "ssh", "-p", "2222"}, want: "2222"}, {name: "explicit common ports", args: []string{"-m", "netbios", "-p", config.MainPorts}, want: config.MainPorts}, - {name: "ports file", args: []string{"-m", "netbios", "-pf", "ports.txt"}, want: config.MainPorts}, + {name: "ports file", args: []string{"-m", "netbios", "-pf", portsFile}, want: "9139,8445"}, {name: "all", args: []string{"-m", "all"}, want: config.MainPorts}, {name: "web plugin", args: []string{"-m", "webtitle"}, want: config.MainPorts}, {name: "web and service", args: []string{"-m", "ssh,webtitle"}, want: config.MainPorts},