mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-10-24 17:04:27 +00:00
Merge pull request #613 from shadow1ng/release/v2.2.2
准备发布 v2.2.2:整合扫描修复与更新发布说明
This commit is contained in:
32 files changed
+986
-135
No files matched your search
+38
-33
@@ -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
|
||||
|
||||
# 2. 全平台 dry-run(手动触发 snapshot 模式)
|
||||
gh workflow run release.yml -f snapshot=true
|
||||
# 1. 确认该分支最新提交的测试 CI 通过
|
||||
gh run list --branch "$RELEASE_REF" --workflow test-build.yml --limit 3
|
||||
|
||||
# 3. 确认版本号一致
|
||||
grep "version" common/globals.go
|
||||
grep "版本" README.md
|
||||
grep "Version" README_EN.md
|
||||
# 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
|
||||
|
||||
# 3. 在候选分支确认版本号一致,且 Release Notes 已就绪
|
||||
rg 'version = ' common/globals.go
|
||||
rg '^\*\*版本\*\*:' README.md
|
||||
rg '^\*\*Version\*\*:' README_EN.md
|
||||
cat .github/release-notes/v2.2.2.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<VERSION>.md
|
||||
# 从已验证的候选分支创建 PR
|
||||
gh pr create --base main --head "$RELEASE_REF"
|
||||
|
||||
# 2. 打 tag(RC 手动打;正式版合并到 main 后由 CI 自动打 tag)
|
||||
git tag v<VERSION>
|
||||
git push origin v<VERSION>
|
||||
# 审核通过后合并 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<VERSION>
|
||||
放在 `.github/release-notes/<tag>.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<VERSION> tag
|
||||
# tag push 会触发 GoReleaser 构建并创建 GitHub Release
|
||||
```
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
内网综合扫描工具,一键自动化漏扫。
|
||||
|
||||
**版本**: 2.2.1
|
||||
**版本**: 2.2.2
|
||||
|
||||
## 功能特性
|
||||
|
||||
@@ -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`
|
||||

|
||||
|
||||
|
||||
+6
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Comprehensive intranet scanning tool for automated vulnerability assessment.
|
||||
|
||||
**Version**: 2.2.1
|
||||
**Version**: 2.2.2
|
||||
|
||||
## Features
|
||||
|
||||
@@ -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`
|
||||

|
||||
|
||||
|
||||
@@ -55,10 +55,14 @@ func parseCredentials(fv *FlagVars, cfg *Config) error {
|
||||
return err
|
||||
}
|
||||
if len(usernames) > 0 {
|
||||
for serviceName := range cfg.Credentials.Userdict {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析密码
|
||||
passwords, err := parsePasswords(fv)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -238,8 +245,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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ type FlagVars struct {
|
||||
ExcludeHosts string
|
||||
ExcludeHostsFile string
|
||||
Ports string
|
||||
PortsExplicit bool
|
||||
ExcludePorts string
|
||||
HostsFile string
|
||||
PortsFile string
|
||||
@@ -122,6 +123,7 @@ type FlagVars struct {
|
||||
|
||||
// 帮助
|
||||
ShowHelp bool
|
||||
ListPlugins bool
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ const (
|
||||
|
||||
// 版本信息,通过 ldflags 注入
|
||||
var (
|
||||
version = "2.2.1"
|
||||
version = "2.2.2"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
@@ -311,6 +315,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:
|
||||
|
||||
@@ -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:
|
||||
@@ -311,6 +315,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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+27
-43
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+19
-8
@@ -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 {
|
||||
// 发现目标主机和端口
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+10
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//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) {
|
||||
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
|
||||
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", 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},
|
||||
{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{}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
+46
-34
@@ -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 停止扫描
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ info:
|
||||
created: 2025/06/11
|
||||
|
||||
set:
|
||||
randstr: randLowercase(6)
|
||||
randstr: randomLowercase(6)
|
||||
rules:
|
||||
r0:
|
||||
request:
|
||||
|
||||
Reference in new issue
Block a user