Merge pull request #613 from shadow1ng/release/v2.2.2

准备发布 v2.2.2:整合扫描修复与更新发布说明
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-01 13:54:03 +08:00
commit 2ede41d743
32 files changed
+1010 -159

No files matched your search

+38 -33
View File
@@ -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
```
+44
View File
@@ -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
+3
View File
@@ -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:
+6 -1
View File
@@ -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`
![](image/1.png)
+6 -1
View File
@@ -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`
![](image/1.png)
+22 -3
View File
@@ -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
}
@@ -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 {
+100
View File
@@ -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",
+7
View File
@@ -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
+3 -1
View File
@@ -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
}
// =============================================================================
+1 -1
View File
@@ -69,7 +69,7 @@ const (
// 版本信息,通过 ldflags 注入
var (
version = "2.2.1"
version = "2.2.2"
commit = "unknown"
date = "unknown"
)
+8 -2
View File
@@ -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:
+8 -2
View File
@@ -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:
+48
View File
@@ -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
View File
@@ -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) {
+6 -1
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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 {
// 发现目标主机和端口
+130
View File
@@ -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
View File
@@ -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)
+74
View File
@@ -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()
}
+131
View File
@@ -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{}
}
+53
View File
@@ -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)
}
}
+1
View File
@@ -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)
+2 -6
View File
@@ -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
+13
View File
@@ -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{}
+54 -42
View File
@@ -34,14 +34,14 @@ type ScanRequest struct {
ExcludePorts string `json:"exclude_ports"`
// 扫描控制
ScanMode string `json:"scan_mode"`
ThreadNum int `json:"thread_num"`
Timeout int `json:"timeout"`
ModuleThreadNum int `json:"module_thread_num"`
DisablePing bool `json:"disable_ping"`
DisableBrute bool `json:"disable_brute"`
DisableSubnetProbe bool `json:"disable_subnet_probe"`
AliveOnly bool `json:"alive_only"`
ScanMode string `json:"scan_mode"`
ThreadNum int `json:"thread_num"`
Timeout int `json:"timeout"`
ModuleThreadNum int `json:"module_thread_num"`
DisablePing bool `json:"disable_ping"`
DisableBrute bool `json:"disable_brute"`
DisableSubnetProbe bool `json:"disable_subnet_probe"`
AliveOnly bool `json:"alive_only"`
// 认证
Username string `json:"username"`
@@ -169,6 +169,47 @@ func (h *ScanHandler) runScan(req ScanRequest) {
})
}()
info, session, err := buildScanSession(req)
if err != nil {
common.LogError(err.Error())
return
}
state := session.State
// 过渡桥:全局状态同步(待 Phase 5 移除)
common.SetGlobalConfig(session.Config)
common.SetGlobalState(state)
// 项目缓存注入:把已知的 host:port 加入扫描目标
if req.ProjectID != "" {
if cached := globalProjectStore.CachedHostPorts(req.ProjectID); len(cached) > 0 {
state.SetHostPorts(append(state.GetHostPorts(), cached...))
}
}
// 设置WebSocket结果回调
common.SetResultCallback(func(result interface{}) {
item := h.results.Add(result)
if item != nil {
h.hub.Broadcast(ws.MsgScanResult, item)
}
})
// 执行扫描
if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) {
common.LogError(err.Error())
}
// 项目缓存回写:合并本次扫描结果
if req.ProjectID != "" {
items := h.results.List()
if len(items) > 0 {
_ = globalProjectStore.MergeResults(req.ProjectID, items)
}
}
}
func buildScanSession(req ScanRequest) (common.HostInfo, *common.ScanSession, error) {
// 构建HostInfo
info := common.HostInfo{
Host: req.Host,
@@ -218,41 +259,12 @@ func (h *ScanHandler) runScan(req ScanRequest) {
}
// 构建Config和Session
config := common.BuildConfigFromFlags(fv)
state := common.NewState()
config, state, err := common.BuildConfig(fv, &info)
if err != nil {
return info, nil, err
}
session := common.NewScanSession(config, state, fv)
// 过渡桥:全局状态同步(待 Phase 5 移除)
common.SetGlobalConfig(config)
common.SetGlobalState(state)
// 项目缓存注入:把已知的 host:port 加入扫描目标
if req.ProjectID != "" {
if cached := globalProjectStore.CachedHostPorts(req.ProjectID); len(cached) > 0 {
state.SetHostPorts(cached)
}
}
// 设置WebSocket结果回调
common.SetResultCallback(func(result interface{}) {
item := h.results.Add(result)
if item != nil {
h.hub.Broadcast(ws.MsgScanResult, item)
}
})
// 执行扫描
if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) {
common.LogError(err.Error())
}
// 项目缓存回写:合并本次扫描结果
if req.ProjectID != "" {
items := h.results.List()
if len(items) > 0 {
_ = globalProjectStore.MergeResults(req.ProjectID, items)
}
}
return info, session, nil
}
// Stop 停止扫描
+46
View File
@@ -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)
}
})
}
}
+10
View File
@@ -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())
},
},
}
}
+17
View File
@@ -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)
}
}
+38
View File
@@ -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 {
+44 -14
View File
@@ -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))
}
}
@@ -12,7 +12,7 @@ info:
created: 2025/06/11
set:
randstr: randLowercase(6)
randstr: randomLowercase(6)
rules:
r0:
request: