Merge pull request #582 from shadow1ng/feature/sdk-library
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled

Add embeddable scanner SDK
This commit is contained in:
ZacharyZcR
2026-05-18 18:03:12 +08:00
committed by GitHub
35 changed files with 2198 additions and 200 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
result.txt
result.json
main
/main
.idea
fscan.exe
fscan
/fscan
fscanapi.csv
# IDE files / IDE 文件
+7
View File
@@ -51,6 +51,13 @@ func SetLanguage(l string) {
localizer = i18n.NewLocalizer(bundle, lang, FallbackLanguage)
}
// GetLanguage returns the currently configured language.
func GetLanguage() string {
mu.RLock()
defer mu.RUnlock()
return lang
}
// GetText 获取国际化文本(无参数)
func GetText(key string) string {
mu.RLock()
+42 -3
View File
@@ -14,11 +14,16 @@ import (
)
var (
globalLogger *logging.Logger
loggerOnce sync.Once
globalLogger *logging.Logger
loggerOnce sync.Once
loggerMu sync.Mutex
silentLoggerRefs int
)
func getGlobalLogger() *logging.Logger {
loggerMu.Lock()
defer loggerMu.Unlock()
loggerOnce.Do(func() {
fv := GetFlagVars()
level := getLogLevelFromString(fv.LogLevel)
@@ -27,7 +32,7 @@ func getGlobalLogger() *logging.Logger {
EnableColor: !fv.NoColor,
SlowOutput: false,
ShowProgress: !fv.DisableProgress,
Silent: fv.Silent,
Silent: fv.Silent || silentLoggerRefs > 0,
StartTime: GetGlobalState().GetStartTime(),
}
if fv.Debug {
@@ -84,6 +89,40 @@ func LogError(errMsg string) { getGlobalLogger().Error(errMsg) }
// CloseLogger 关闭日志系统,释放文件资源
func CloseLogger() {
loggerMu.Lock()
defer loggerMu.Unlock()
closeLoggerLocked()
}
// PushSilentLogger suppresses process-wide legacy log output until the returned
// restore function is called. It is reference counted so concurrent embedded
// scans can overlap safely.
func PushSilentLogger() func() {
loggerMu.Lock()
silentLoggerRefs++
resetLoggerLocked()
loggerMu.Unlock()
var once sync.Once
return func() {
once.Do(func() {
loggerMu.Lock()
if silentLoggerRefs > 0 {
silentLoggerRefs--
}
resetLoggerLocked()
loggerMu.Unlock()
})
}
}
func resetLoggerLocked() {
closeLoggerLocked()
globalLogger = nil
loggerOnce = sync.Once{}
}
func closeLoggerLocked() {
if globalLogger != nil {
globalLogger.Close()
}
+127 -21
View File
@@ -4,25 +4,31 @@ import (
"context"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/shadow1ng/fscan/common/proxy"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/common/output"
"github.com/shadow1ng/fscan/common/proxy"
)
// ResultSink receives structured scan results for one scan session.
type ResultSink func(result *output.ScanResult) error
// ScanSession 封装单次扫描的全部上下文
// 一次扫描一个 session,并发扫描各自独立
type ScanSession struct {
Config *Config // 不可变,创建后只读
State *State // 可变,原子操作,每会话独立
Params *FlagVars // 原始参数,只读
Config *Config // 不可变,创建后只读
State *State // 可变,原子操作,每会话独立
Params *FlagVars // 原始参数,只读
ResultSink ResultSink // 可选,覆盖全局输出
// 每会话 dialer(懒初始化,取决于代理配置)
dialerOnce sync.Once
dialer proxy.Dialer
dialerErr error
// 每会话 dialer按 timeout 懒初始化,取决于代理配置)
dialerMu sync.Mutex
dialers map[time.Duration]proxy.Dialer
dialerErrs map[time.Duration]error
}
// NewScanSession 从已构建的 Config、State 和 FlagVars 创建会话
@@ -34,18 +40,66 @@ func NewScanSession(config *Config, state *State, params *FlagVars) *ScanSession
}
}
// SaveResult saves a scan result through the session sink if present, otherwise
// falls back to the process-wide output pipeline used by the CLI.
func (s *ScanSession) SaveResult(result *output.ScanResult) error {
if s != nil && s.ResultSink != nil {
return s.ResultSink(result)
}
return SaveResult(result)
}
func (s *ScanSession) loggingEnabled() bool {
return s == nil || s.Config == nil || !s.Config.Output.Silent
}
// LogDebug writes through the session's logging policy.
func (s *ScanSession) LogDebug(msg string) {
if s.loggingEnabled() {
LogDebug(msg)
}
}
// LogInfo writes through the session's logging policy.
func (s *ScanSession) LogInfo(msg string) {
if s.loggingEnabled() {
LogInfo(msg)
}
}
// LogSuccess writes through the session's logging policy.
func (s *ScanSession) LogSuccess(result string) {
if s.loggingEnabled() {
LogSuccess(result)
}
}
// LogVuln writes through the session's logging policy.
func (s *ScanSession) LogVuln(result string) {
if s.loggingEnabled() {
LogVuln(result)
}
}
// LogError writes through the session's logging policy.
func (s *ScanSession) LogError(errMsg string) {
if s.loggingEnabled() {
LogError(errMsg)
}
}
// DialTCP 创建 TCP 连接,内含限速检查、代理、计数
func (s *ScanSession) DialTCP(ctx context.Context, network, address string, timeout time.Duration) (net.Conn, error) {
// 检查发包限制
if ok, err := CanSendPacketWith(s.Config, s.State); !ok {
LogError(fmt.Sprintf("TCP连接 %s 受限: %s", address, err.Error()))
s.LogError(fmt.Sprintf("TCP连接 %s 受限: %s", address, err.Error()))
return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error()))
}
// 获取 dialer
dialer, err := s.getDialer()
dialer, err := s.getDialer(timeout)
if err != nil {
LogError(fmt.Sprintf("获取代理拨号器失败: %v", err))
s.LogError(fmt.Sprintf("获取代理拨号器失败: %v", err))
s.State.IncrementTCPFailedPacketCount()
return nil, err
}
@@ -53,7 +107,7 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
s.State.IncrementTCPFailedPacketCount()
LogDebug(fmt.Sprintf("连接 %s 失败: %v", address, err))
s.LogDebug(fmt.Sprintf("连接 %s 失败: %v", address, err))
return nil, err
}
@@ -66,18 +120,70 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time
return conn, nil
}
func (s *ScanSession) getDialer() (proxy.Dialer, error) {
s.dialerOnce.Do(func() {
cfg := s.createProxyConfig()
manager := proxy.NewProxyManager(cfg)
s.dialer, s.dialerErr = manager.GetDialer()
})
return s.dialer, s.dialerErr
// HTTPDo executes an HTTP request with the session's packet limits and counters.
func (s *ScanSession) HTTPDo(client *http.Client, req *http.Request) (*http.Response, error) {
if ok, err := CanSendPacketWith(s.Config, s.State); !ok {
s.LogError(fmt.Sprintf("HTTP请求 %s 受限: %s", req.URL.String(), err.Error()))
return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error()))
}
resp, err := client.Do(req)
if err != nil {
s.State.IncrementTCPFailedPacketCount()
return nil, err
}
s.State.IncrementTCPSuccessPacketCount()
return resp, nil
}
func (s *ScanSession) createProxyConfig() *proxy.ProxyConfig {
// ProxyEnabled reports whether this scan session uses a network proxy.
func (s *ScanSession) ProxyEnabled() bool {
if s == nil || s.Config == nil {
return false
}
return s.Config.Network.Socks5Proxy != "" || s.Config.Network.HTTPProxy != ""
}
// IsSOCKS5Proxy reports whether this scan session uses SOCKS5.
func (s *ScanSession) IsSOCKS5Proxy() bool {
return s != nil && s.Config != nil && s.Config.Network.Socks5Proxy != ""
}
// ProxyReliable reports whether the session proxy should be treated as reliable.
func (s *ScanSession) ProxyReliable() bool {
if !s.ProxyEnabled() || !s.IsSOCKS5Proxy() {
return true
}
return proxy.IsProxyReliable()
}
func (s *ScanSession) getDialer(timeout time.Duration) (proxy.Dialer, error) {
if timeout <= 0 {
timeout = s.Config.Timeout
}
s.dialerMu.Lock()
defer s.dialerMu.Unlock()
if s.dialers == nil {
s.dialers = make(map[time.Duration]proxy.Dialer)
s.dialerErrs = make(map[time.Duration]error)
}
if dialer, ok := s.dialers[timeout]; ok {
return dialer, s.dialerErrs[timeout]
}
cfg := s.createProxyConfig(timeout)
manager := proxy.NewProxyManager(cfg)
dialer, err := manager.GetDialer()
s.dialers[timeout] = dialer
s.dialerErrs[timeout] = err
return dialer, err
}
func (s *ScanSession) createProxyConfig(timeout time.Duration) *proxy.ProxyConfig {
cfg := proxy.DefaultProxyConfig()
cfg.Timeout = s.Config.Timeout
cfg.Timeout = timeout
cfg.LocalAddr = s.Config.Network.Iface
// 优先 SOCKS5
+148
View File
@@ -0,0 +1,148 @@
package common
import (
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) {
loggerMu.Lock()
silentLoggerRefs = 0
resetLoggerLocked()
loggerMu.Unlock()
t.Cleanup(func() {
loggerMu.Lock()
silentLoggerRefs = 0
resetLoggerLocked()
loggerMu.Unlock()
})
cfg := NewConfig()
cfg.Output.Silent = true
session := NewScanSession(cfg, NewState(), &FlagVars{})
session.LogDebug("debug")
session.LogInfo("info")
session.LogSuccess("success")
session.LogVuln("vuln")
session.LogError("error")
loggerMu.Lock()
defer loggerMu.Unlock()
if globalLogger != nil {
t.Fatal("silent session log methods initialized global logger")
}
}
func TestScanSessionDialerCacheIsTimeoutAware(t *testing.T) {
cfg := NewConfig()
cfg.Timeout = 5 * time.Second
session := NewScanSession(cfg, NewState(), &FlagVars{})
shortTimeout := 100 * time.Millisecond
longTimeout := 2 * time.Second
shortDialer, err := session.getDialer(shortTimeout)
if err != nil {
t.Fatal(err)
}
shortDialerAgain, err := session.getDialer(shortTimeout)
if err != nil {
t.Fatal(err)
}
longDialer, err := session.getDialer(longTimeout)
if err != nil {
t.Fatal(err)
}
if shortDialer != shortDialerAgain {
t.Fatal("same timeout should reuse the session dialer")
}
if shortDialer == longDialer {
t.Fatal("different timeouts should not share one session dialer")
}
if got := session.createProxyConfig(shortTimeout).Timeout; got != shortTimeout {
t.Fatalf("proxy timeout = %v, want %v", got, shortTimeout)
}
}
func TestScanSessionHTTPDoUsesSessionState(t *testing.T) {
previousState := GetGlobalState()
globalState := NewState()
SetGlobalState(globalState)
t.Cleanup(func() { SetGlobalState(previousState) })
sessionState := NewState()
session := NewScanSession(NewConfig(), sessionState, &FlagVars{})
client := &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNoContent,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}),
}
req, err := http.NewRequest(http.MethodHead, "http://example.com", nil)
if err != nil {
t.Fatal(err)
}
resp, err := session.HTTPDo(client, req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if got := sessionState.GetTCPSuccessPacketCount(); got != 1 {
t.Fatalf("session TCP success count = %d, want 1", got)
}
if got := globalState.GetTCPSuccessPacketCount(); got != 0 {
t.Fatalf("global TCP success count = %d, want 0", got)
}
}
func TestScanSessionProxyStateComesFromConfig(t *testing.T) {
direct := NewScanSession(NewConfig(), NewState(), &FlagVars{})
if direct.ProxyEnabled() {
t.Fatal("direct session should not report proxy enabled")
}
if direct.IsSOCKS5Proxy() {
t.Fatal("direct session should not report SOCKS5")
}
if !direct.ProxyReliable() {
t.Fatal("direct session should be reliable")
}
httpCfg := NewConfig()
httpCfg.Network.HTTPProxy = "http://127.0.0.1:8080"
httpSession := NewScanSession(httpCfg, NewState(), &FlagVars{})
if !httpSession.ProxyEnabled() {
t.Fatal("HTTP proxy session should report proxy enabled")
}
if httpSession.IsSOCKS5Proxy() {
t.Fatal("HTTP proxy session should not report SOCKS5")
}
if !httpSession.ProxyReliable() {
t.Fatal("HTTP proxy session should be reliable")
}
socksCfg := NewConfig()
socksCfg.Network.Socks5Proxy = "127.0.0.1:1080"
socksSession := NewScanSession(socksCfg, NewState(), &FlagVars{})
if !socksSession.ProxyEnabled() {
t.Fatal("SOCKS5 proxy session should report proxy enabled")
}
if !socksSession.IsSOCKS5Proxy() {
t.Fatal("SOCKS5 proxy session should report SOCKS5")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+6 -7
View File
@@ -57,7 +57,7 @@ func (s *AliveScanStrategy) Description() string {
func (s *AliveScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
// 验证扫描目标(需要同时检查 -h 和 -hf 参数)
if info.Host == "" && session.Params.HostsFile == "" {
common.LogError(i18n.GetText("parse_error_target_empty"))
session.LogError(i18n.GetText("parse_error_target_empty"))
return
}
@@ -65,7 +65,7 @@ func (s *AliveScanStrategy) Execute(ctx context.Context, session *common.ScanSes
s.performAliveScan(ctx, info, session)
// 输出统计信息
s.outputStats()
s.outputStats(session)
}
// performAliveScan 执行存活探测
@@ -73,12 +73,12 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho
// 解析目标主机
hosts, err := parsers.ParseIP(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts)
if err != nil {
common.LogError(i18n.Tr("parse_target_failed", err))
session.LogError(i18n.Tr("parse_target_failed", err))
return
}
if len(hosts) == 0 {
common.LogError(i18n.GetText("parse_error_no_hosts"))
session.LogError(i18n.GetText("parse_error_no_hosts"))
return
}
@@ -87,7 +87,6 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho
s.stats.AliveHosts = 0
s.stats.DeadHosts = 0
// 执行存活检测
aliveList := CheckLive(ctx, hosts, false, session) // 使用ICMP探测
@@ -103,10 +102,10 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho
}
// outputStats 输出统计信息(精简版)
func (s *AliveScanStrategy) outputStats() {
func (s *AliveScanStrategy) outputStats(session *common.ScanSession) {
// 只输出存活主机列表,不输出冗余统计
for _, host := range s.stats.AliveHostList {
common.LogSuccess(fmt.Sprintf("alive %s", host))
session.LogSuccess(fmt.Sprintf("alive %s", host))
}
}
+10 -4
View File
@@ -81,6 +81,11 @@ func (b *BaseScanStrategy) IsPluginApplicableByName(pluginName string, targetHos
return false
}
// 显式指定插件时,尊重调用方选择,不再强制使用插件默认端口过滤。
if isCustomMode {
return b.isPluginPassesFilterType(pluginName, isCustomMode, config)
}
// 检查端口匹配和过滤器类型
return b.isPluginApplicableToPortWithHost(pluginName, targetHost, targetPort) && b.isPluginPassesFilterType(pluginName, isCustomMode, config)
}
@@ -165,7 +170,7 @@ func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomM
}
// LogPluginInfo 输出插件信息
func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) {
func (b *BaseScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) {
allPlugins, isCustomMode := b.GetPlugins(config)
var prefix string
@@ -184,6 +189,7 @@ func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) {
_ = allPlugins
_ = isCustomMode
_ = prefix
_ = session
}
// formatPluginList 格式化插件列表(超过5个时精简显示)
@@ -200,14 +206,14 @@ func (b *BaseScanStrategy) ValidateConfiguration() error {
}
// LogScanStart 输出扫描开始信息(已精简,仅在非服务扫描模式下显示)
func (b *BaseScanStrategy) LogScanStart() {
func (b *BaseScanStrategy) LogScanStart(session *common.ScanSession) {
// 服务扫描模式下不显示(插件信息已足够说明)
// 仅在本地/Web等特殊模式下显示
switch b.filterType {
case FilterLocal:
common.LogInfo(i18n.GetText("start_local_scan"))
session.LogInfo(i18n.GetText("start_local_scan"))
case FilterWeb:
common.LogInfo(i18n.GetText("start_web_scan"))
session.LogInfo(i18n.GetText("start_web_scan"))
}
}
+8 -14
View File
@@ -55,7 +55,7 @@ func CheckLive(ctx context.Context, hostslist []string, Ping bool, session *comm
chanHosts := make(chan string, len(hostslist))
// 处理存活主机
go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, &livewg)
go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, session, &livewg)
// 根据Ping参数选择检测方式
if Ping {
@@ -106,7 +106,7 @@ func tcpSupplementaryProbe(ctx context.Context, allHosts []string, aliveHosts []
}
// 提示用户正在进行 TCP 补充探测
common.LogInfo(i18n.Tr("tcp_probe_low_icmp_rate", fmt.Sprintf("%.1f%%", responseRate*100), len(unrespondedHosts)))
session.LogInfo(i18n.Tr("tcp_probe_low_icmp_rate", fmt.Sprintf("%.1f%%", responseRate*100), len(unrespondedHosts)))
// 执行 TCP 补充探测
tcpAliveHosts := runTcpProbeForHosts(ctx, unrespondedHosts, session)
@@ -114,7 +114,7 @@ func tcpSupplementaryProbe(ctx context.Context, allHosts []string, aliveHosts []
// 合并结果
if len(tcpAliveHosts) > 0 {
aliveHosts = append(aliveHosts, tcpAliveHosts...)
common.LogInfo(i18n.Tr("tcp_probe_found", len(tcpAliveHosts)))
session.LogInfo(i18n.Tr("tcp_probe_found", len(tcpAliveHosts)))
}
return aliveHosts
@@ -130,7 +130,7 @@ func IsContain(items []string, item string) bool {
return false
}
func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, livewg *sync.WaitGroup) {
func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, session *common.ScanSession, livewg *sync.WaitGroup) {
for ip := range chanHosts {
if _, ok := existHosts[ip]; !ok && IsContain(hostslist, ip) {
existHosts[ip] = struct{}{}
@@ -155,12 +155,9 @@ func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, al
"protocol": protocol,
},
}
_ = common.SaveResult(result)
_ = session.SaveResult(result)
// 保留原有的控制台输出
if !config.Output.Silent {
common.LogInfo(i18n.Tr("host_alive", ip, protocol))
}
session.LogInfo(i18n.Tr("host_alive", ip, protocol))
}
livewg.Done()
}
@@ -730,7 +727,6 @@ func tcpProbeAlive(ctx context.Context, session *common.ScanSession, host string
// runTcpProbeForHosts 对指定主机列表进行 TCP 补充探测
// 返回存活的主机列表
func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.ScanSession) []string {
config := session.Config
if len(hosts) == 0 {
return nil
}
@@ -771,11 +767,9 @@ func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.Sc
"protocol": "TCP",
},
}
_ = common.SaveResult(result)
_ = session.SaveResult(result)
if !config.Output.Silent {
common.LogInfo(i18n.Tr("host_alive", h, "TCP"))
}
session.LogInfo(i18n.Tr("host_alive", h, "TCP"))
}
}(host)
}
+7 -7
View File
@@ -22,12 +22,12 @@ func NewLocalScanStrategy() *LocalScanStrategy {
}
// LogPluginInfo 重写以只显示通过-local指定的插件
func (s *LocalScanStrategy) LogPluginInfo(config *common.Config) {
func (s *LocalScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) {
localPlugin := config.LocalPlugin
if localPlugin != "" {
common.LogInfo(i18n.Tr("local_plugin_info", localPlugin))
session.LogInfo(i18n.Tr("local_plugin_info", localPlugin))
} else {
common.LogError(i18n.GetText("local_plugin_not_specified"))
session.LogError(i18n.GetText("local_plugin_not_specified"))
}
}
@@ -46,24 +46,24 @@ func (s *LocalScanStrategy) Execute(ctx context.Context, session *common.ScanSes
config := session.Config
// 输出扫描开始信息
s.LogScanStart()
s.LogScanStart(session)
// 验证插件配置
if err := s.ValidateConfiguration(); err != nil {
common.LogError(err.Error())
session.LogError(err.Error())
return
}
// 验证本地插件是否存在
if config.LocalPlugin != "" {
if !plugins.Exists(config.LocalPlugin) {
common.LogError(i18n.Tr("local_plugin_not_found", config.LocalPlugin))
session.LogError(i18n.Tr("local_plugin_not_found", config.LocalPlugin))
return
}
}
// 输出插件信息
s.LogPluginInfo(config)
s.LogPluginInfo(config, session)
// 准备目标(本地扫描通常只有一个目标,即本机)
targets := s.PrepareTargets(info)
+39 -39
View File
@@ -143,13 +143,13 @@ func (f *failedPortCollector) Count() int {
func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout int64, session *common.ScanSession, stream chan<- string) []string {
config := session.Config
state := session.State
common.LogDebug(fmt.Sprintf("[PortScan] 开始: %d个主机, 线程数=%d", len(hosts), config.ThreadNum))
session.LogDebug(fmt.Sprintf("[PortScan] 开始: %d个主机, 线程数=%d", len(hosts), config.ThreadNum))
// 大规模扫描预筛:跨多个 /24 时先做网段探活,跳过空网段
if len(hosts) > subnetProbeThreshold {
hosts = probeSubnets(ctx, hosts, time.Duration(timeout)*time.Second, session)
if len(hosts) == 0 {
common.LogInfo(i18n.GetText("port_scan_no_alive_subnet"))
session.LogInfo(i18n.GetText("port_scan_no_alive_subnet"))
if stream != nil {
close(stream)
}
@@ -160,13 +160,13 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
// 解析端口和排除端口
portList := parsers.ParsePort(ports)
if len(portList) == 0 {
common.LogError(i18n.Tr("invalid_port", ports))
session.LogError(i18n.Tr("invalid_port", ports))
if stream != nil {
close(stream)
}
return nil
}
common.LogDebug(fmt.Sprintf("[PortScan] 端口解析完成: %d个端口", len(portList)))
session.LogDebug(fmt.Sprintf("[PortScan] 端口解析完成: %d个端口", len(portList)))
// 使用config中的排除端口配置
excludePorts := parsers.ParsePort(config.Target.ExcludePorts)
@@ -176,26 +176,26 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
}
// 检查代理可靠性,如果存在全回显问题则警告
if common.IsProxyEnabled() && !common.IsProxyReliable() {
common.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确")
if session.ProxyEnabled() && !session.ProxyReliable() {
session.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确")
}
// 创建流式迭代器(O(1) 内存,端口喷洒策略)
iter := NewSocketIterator(hosts, portList, exclude)
totalTasks := iter.Total()
common.LogDebug(fmt.Sprintf("[PortScan] 总任务数: %d", totalTasks))
session.LogDebug(fmt.Sprintf("[PortScan] 总任务数: %d", totalTasks))
// 使用传入的配置
threadNum := config.ThreadNum
// 大规模扫描警告和线程数自动调整
if totalTasks > 100000 {
common.LogInfo(fmt.Sprintf("大规模扫描: %d 个目标 (%d主机 × %d端口)", totalTasks, len(hosts), len(portList)))
session.LogInfo(fmt.Sprintf("大规模扫描: %d 个目标 (%d主机 × %d端口)", totalTasks, len(hosts), len(portList)))
// 如果任务数超过100万且线程数大于300,自动降低线程数
if totalTasks > 1000000 && threadNum > 300 {
oldThreadNum := threadNum
threadNum = 300
common.LogInfo(fmt.Sprintf("自动调整线程数: %d -> %d (大规模扫描优化)", oldThreadNum, threadNum))
session.LogInfo(fmt.Sprintf("自动调整线程数: %d -> %d (大规模扫描优化)", oldThreadNum, threadNum))
}
}
@@ -204,7 +204,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
description := fmt.Sprintf("端口扫描中(%d线程)", threadNum)
common.InitProgressBar(int64(totalTasks), description)
}
common.LogDebug("[PortScan] 进度条初始化完成")
session.LogDebug("[PortScan] 进度条初始化完成")
// 初始化并发控制
to := time.Duration(timeout) * time.Second
@@ -214,7 +214,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
failedCollector := &failedPortCollector{}
var wg sync.WaitGroup
common.LogDebug(fmt.Sprintf("[PortScan] 开始创建线程池, size=%d", threadNum))
session.LogDebug(fmt.Sprintf("[PortScan] 开始创建线程池, size=%d", threadNum))
// 创建自适应线程池(支持动态调整)
pool, err := NewAdaptivePool(threadNum, func(task interface{}) {
taskInfo, ok := task.(portScanTask)
@@ -230,19 +230,19 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
common.UpdateProgressBar(1)
}, state)
if err != nil {
common.LogError(i18n.Tr("thread_pool_create_failed", err))
session.LogError(i18n.Tr("thread_pool_create_failed", err))
if stream != nil {
close(stream)
}
return nil
}
common.LogDebug("[PortScan] 线程池创建成功")
session.LogDebug("[PortScan] 线程池创建成功")
defer pool.Release()
common.LogDebug("[PortScan] 开始滑动窗口调度")
session.LogDebug("[PortScan] 开始滑动窗口调度")
// 滑动窗口调度:维护固定数量的"飞行中"任务
slidingWindowSchedule(iter, pool, &wg, threadNum)
common.LogDebug("[PortScan] 滑动窗口调度完成")
session.LogDebug("[PortScan] 滑动窗口调度完成")
// 收集结果
aliveAddrs := collector.GetAll()
@@ -257,7 +257,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
common.FinishProgressBar()
}
common.LogInfo(i18n.Tr("port_scan_complete", count))
session.LogInfo(i18n.Tr("port_scan_complete", count))
// 检查扫描失败率,如果过高则警告用户
resourceErrors := state.GetResourceExhaustedCount()
@@ -268,18 +268,18 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
if failureRate > 20 {
// 失败率超过20%,严重警告
common.LogError(i18n.Tr("scan_failure_rate_high", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks))
common.LogError(i18n.GetText("scan_failure_reason"))
common.LogError(i18n.Tr("scan_reduce_threads_suggestion", threadNum))
session.LogError(i18n.Tr("scan_failure_rate_high", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks))
session.LogError(i18n.GetText("scan_failure_reason"))
session.LogError(i18n.Tr("scan_reduce_threads_suggestion", threadNum))
} else if failureRate > 5 {
// 失败率5-20%,一般警告
common.LogInfo(i18n.Tr("scan_partial_failure", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks))
common.LogInfo(i18n.Tr("scan_reduce_threads_accuracy", threadNum))
session.LogInfo(i18n.Tr("scan_partial_failure", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks))
session.LogInfo(i18n.Tr("scan_reduce_threads_accuracy", threadNum))
}
}
if resourceErrors > 0 {
common.LogError(i18n.Tr("resource_exhausted_warning", resourceErrors))
session.LogError(i18n.Tr("resource_exhausted_warning", resourceErrors))
}
return aliveAddrs
@@ -421,10 +421,10 @@ func matchFold(a, b string) bool {
// 格式: addr service [Product:xxx ||Version:xxx] Banner:(xxx)
func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) string {
var msg strings.Builder
msg.WriteString(fmt.Sprintf("%-21s", addr))
fmt.Fprintf(&msg, "%-21s", addr)
if serviceInfo.Name != "unknown" {
msg.WriteString(fmt.Sprintf(" %-8s", serviceInfo.Name))
fmt.Fprintf(&msg, " %-8s", serviceInfo.Name)
}
// 构建 [Product:xxx ||Version:xxx] 格式
@@ -465,16 +465,16 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada
adaptiveTO.Record(time.Since(start))
// 步骤1.5:代理连接深度验证(防止透明代理/全回显代理的假连接问题)
valid, verifyMethod := verifyProxyConnectionDeep(conn, addr)
valid, verifyMethod := verifyProxyConnectionDeep(conn, addr, session)
if !valid {
common.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod))
session.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod))
_ = conn.Close()
return
}
// 步骤1.6:如果使用了代理且进行了数据交互,需要重建连接
// 因为验证阶段可能读取了Banner或发送了HTTP GET探测,污染了连接状态
if common.IsProxyEnabled() && verifyMethod != "direct" {
if session.ProxyEnabled() && verifyMethod != "direct" {
_ = conn.Close()
// 重新建立干净的连接用于服务识别
conn, err = connectWithRetry(ctx, session, addr, timeout, 2)
@@ -487,7 +487,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada
// 步骤2:记录开放端口
atomic.AddInt64(count, 1)
collector.Add(addr)
saveOpenPort(host, port)
saveOpenPort(session, host, port)
// 步骤3:服务识别(Scanner负责关闭连接,包括探测中可能创建的新连接)
scanner := NewSmartPortInfoScanner(ctx, host, port, conn, timeout, config, session)
@@ -523,10 +523,10 @@ func handleConnectionFailure(err error, host string, port int, addr string, fail
// 1. 快速 Banner 检测 (100ms) - 大部分服务会主动发送数据
// 2. 轻量探测 (发送 \r\n) - 触发某些服务响应,同时不污染协议状态
// 3. 短超时等待 (500ms) - 平衡准确性和性能
func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) {
func verifyProxyConnectionDeep(conn net.Conn, addr string, session *common.ScanSession) (bool, string) {
// 无代理或SOCKS5代理:跳过深度验证
// SOCKS5协议层已验证连接可达性,连接成功即端口开放
if !common.IsProxyEnabled() || common.IsSOCKS5Proxy() {
if !session.ProxyEnabled() || session.IsSOCKS5Proxy() {
return true, "direct"
}
@@ -640,8 +640,8 @@ func isConnectionClosed(err error) bool {
}
// saveOpenPort 保存开放端口结果
func saveOpenPort(host string, port int) {
_ = common.SaveResult(&output.ScanResult{
func saveOpenPort(session *common.ScanSession, host string, port int) {
_ = session.SaveResult(&output.ScanResult{
Time: time.Now(),
Type: output.TypePort,
Target: host,
@@ -655,7 +655,7 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi
if serviceInfo == nil {
// 服务识别失败,尝试 HTTP 回退探测
if !tryHTTPFallbackDetection(host, port, addr, config, session) {
common.LogInfo(i18n.Tr("port_open", addr))
session.LogInfo(i18n.Tr("port_open", addr))
}
return
}
@@ -669,7 +669,7 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi
MarkAsWebService(host, port, serviceInfo)
}
_ = common.SaveResult(&output.ScanResult{
_ = session.SaveResult(&output.ScanResult{
Time: time.Now(),
Type: output.TypeService,
Target: fmt.Sprintf("%s:%d", host, port),
@@ -677,7 +677,7 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi
Details: details,
})
common.LogInfo(buildServiceLogMessage(addr, serviceInfo, isWeb))
session.LogInfo(buildServiceLogMessage(addr, serviceInfo, isWeb))
}
// buildServiceDetails 构建服务详情 map
@@ -737,7 +737,7 @@ func tryHTTPFallbackDetection(host string, port int, addr string, config *common
"is_web": true,
"detected_by": "http_probe",
}
_ = common.SaveResult(&output.ScanResult{
_ = session.SaveResult(&output.ScanResult{
Time: time.Now(),
Type: output.TypeService,
Target: fmt.Sprintf("%s:%d", host, port),
@@ -745,7 +745,7 @@ func tryHTTPFallbackDetection(host string, port int, addr string, config *common
Details: details,
})
common.LogInfo(i18n.Tr("port_open_http", addr))
session.LogInfo(i18n.Tr("port_open_http", addr))
return true
}
@@ -790,7 +790,7 @@ func probeSubnets(ctx context.Context, hosts []string, timeout time.Duration, se
return hosts
}
common.LogInfo(fmt.Sprintf("网段预筛: %d 个 /24 子网, %d 个主机", len(subnets), len(hosts)))
session.LogInfo(fmt.Sprintf("网段预筛: %d 个 /24 子网, %d 个主机", len(subnets), len(hosts)))
aliveSubnets := sync.Map{}
var wg sync.WaitGroup
@@ -872,7 +872,7 @@ done:
}
skipped := len(subnets) - aliveCount
common.LogInfo(fmt.Sprintf("网段预筛完成: %d 个存活 (网关命中 %d), %d 个跳过, 剩余 %d 主机",
session.LogInfo(fmt.Sprintf("网段预筛完成: %d 个存活 (网关命中 %d), %d 个跳过, 剩余 %d 主机",
aliveCount, gwHits, skipped, len(result)))
return result
}
+18 -15
View File
@@ -87,7 +87,7 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess
// 初始化HTTP客户端(静默,无需日志)
if err := lib.Inithttp(config); err != nil {
common.LogError(i18n.Tr("http_client_init_failed", err))
session.LogError(i18n.Tr("http_client_init_failed", err))
return
}
@@ -107,22 +107,22 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess
// 检查是否有活跃的连接需要维持
if state.IsReverseShellActive() || state.IsSocks5ProxyActive() || state.IsForwardShellActive() {
if state.IsReverseShellActive() {
common.LogInfo(i18n.GetText("active_reverse_shell"))
session.LogInfo(i18n.GetText("active_reverse_shell"))
}
if state.IsSocks5ProxyActive() {
common.LogInfo(i18n.GetText("active_socks5_proxy"))
session.LogInfo(i18n.GetText("active_socks5_proxy"))
}
if state.IsForwardShellActive() {
common.LogInfo(i18n.GetText("active_forward_shell"))
session.LogInfo(i18n.GetText("active_forward_shell"))
}
common.LogInfo(i18n.GetText("press_ctrl_c_exit"))
session.LogInfo(i18n.GetText("press_ctrl_c_exit"))
// 优雅等待信号或 context 取消(Web Stop
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
select {
case <-sigChan:
common.LogInfo(i18n.GetText("received_exit_signal"))
session.LogInfo(i18n.GetText("received_exit_signal"))
case <-ctx.Done():
}
cancel()
@@ -130,18 +130,21 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess
}
// 完成扫描
finishScan(config, state)
finishScan(session)
}
// finishScan 完成扫描并输出结果
func finishScan(config *common.Config, state *common.State) {
func finishScan(session *common.ScanSession) {
config := session.Config
state := session.State
// 确保进度条正确完成
if common.IsProgressActive() {
common.FinishProgressBar()
}
// 输出扫描完成信息
common.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum()))
session.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum()))
// 输出性能统计 JSON(如果启用)
if config.Output.PerfStats {
@@ -262,7 +265,7 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam
defer func() {
// 捕获并记录任何可能的panic
if r := recover(); r != nil {
common.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r))
session.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r))
}
// 更新统计和进度(任务真正完成时才更新)
@@ -281,13 +284,13 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam
if result != nil {
if result.Success {
// 保存成功的扫描结果到文件
savePluginResult(&target, pluginName, result)
savePluginResult(session, &target, pluginName, result)
} else if result.Type == plugins.ResultTypeCredential {
// 凭据测试完成但未发现弱密码,在error级别输出提示
common.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName))
session.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName))
} else if result.Error != nil {
// 其他类型的错误
common.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error))
session.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error))
}
}
}
@@ -382,7 +385,7 @@ var defaultSerializer = resultSerializer{
}
// savePluginResult 保存插件扫描结果
func savePluginResult(info *common.HostInfo, pluginName string, result *plugins.Result) {
func savePluginResult(session *common.ScanSession, info *common.HostInfo, pluginName string, result *plugins.Result) {
if result == nil || !result.Success || result.Skipped {
return
}
@@ -402,7 +405,7 @@ func savePluginResult(info *common.HostInfo, pluginName string, result *plugins.
// 保存结果
target := info.Target()
_ = common.SaveResult(&output.ScanResult{
_ = session.SaveResult(&output.ScanResult{
Time: time.Now(),
Type: serializer.outputType,
Target: target,
+17 -18
View File
@@ -25,27 +25,27 @@ func NewServiceScanStrategy() *ServiceScanStrategy {
}
// LogPluginInfo 重写以提供基于端口的插件过滤
func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config) {
func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) {
// 需要从命令行参数获取端口信息来进行过滤
// 如果没有指定端口,使用默认端口进行过滤显示
ports := config.Target.Ports
if ports == "" || ports == "all" {
// 默认端口扫描:显示所有插件
s.BaseScanStrategy.LogPluginInfo(config)
s.BaseScanStrategy.LogPluginInfo(config, session)
} else {
// 指定端口扫描:只显示匹配的插件
s.showPluginsForSpecifiedPorts(config)
s.showPluginsForSpecifiedPorts(config, session)
}
}
// showPluginsForSpecifiedPorts 显示指定端口的匹配插件
func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config) {
func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config, session *common.ScanSession) {
allPlugins, isCustomMode := s.GetPlugins(config)
// 解析端口
ports := s.parsePortList(config.Target.Ports)
if len(ports) == 0 {
s.BaseScanStrategy.LogPluginInfo(config)
s.BaseScanStrategy.LogPluginInfo(config, session)
return
}
@@ -71,12 +71,12 @@ func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config
if len(applicablePlugins) > 0 {
pluginStr := formatPluginList(applicablePlugins)
if isCustomMode {
common.LogInfo(i18n.Tr("service_plugin_custom", pluginStr))
session.LogInfo(i18n.Tr("service_plugin_custom", pluginStr))
} else {
common.LogInfo(i18n.Tr("service_plugin_info", pluginStr))
session.LogInfo(i18n.Tr("service_plugin_info", pluginStr))
}
} else {
common.LogInfo(i18n.GetText("service_plugin_none"))
session.LogInfo(i18n.GetText("service_plugin_none"))
}
}
@@ -118,21 +118,21 @@ func (s *ServiceScanStrategy) Execute(ctx context.Context, session *common.ScanS
// 验证扫描目标(需要同时检查 -h 和 -hf 参数)
if info.Host == "" && session.Params.HostsFile == "" {
common.LogError(i18n.GetText("parse_error_target_empty"))
session.LogError(i18n.GetText("parse_error_target_empty"))
return
}
// 输出扫描开始信息
s.LogScanStart()
s.LogScanStart(session)
// 验证插件配置
if err := s.ValidateConfiguration(); err != nil {
common.LogError(err.Error())
session.LogError(err.Error())
return
}
// 输出插件信息(重写以提供端口过滤)
s.LogPluginInfo(config)
s.LogPluginInfo(config, session)
// 执行主机扫描流程
s.performHostScan(ctx, session, info, ch, wg)
@@ -147,14 +147,14 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
// 解析目标主机
hosts, err := parsers.ParseIP(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts)
if err != nil {
common.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err))
session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err))
return
}
// 主机存活检测
if s.shouldPerformLivenessCheck(hosts, config) {
hosts = CheckLive(ctx, hosts, false, session)
common.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts)))
session.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts)))
}
if len(hosts) == 0 && len(state.GetHostPorts()) == 0 {
@@ -218,7 +218,7 @@ func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *comm
// 发现目标主机和端口
targetInfos, err := s.discoverTargets(context.Background(), info.Host, info, session)
if err != nil {
common.LogError(err.Error())
session.LogError(err.Error())
return nil
}
return targetInfos
@@ -291,7 +291,7 @@ func (s *ServiceScanStrategy) discoverTargets(ctx context.Context, hostInput str
// 主机存活检测
if s.shouldPerformLivenessCheck(hosts, config) {
hosts = CheckLive(ctx, hosts, false, session)
common.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts)))
session.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts)))
}
// 端口扫描
@@ -325,7 +325,7 @@ func (s *ServiceScanStrategy) discoverAlivePorts(ctx context.Context, hosts []st
hostPorts := state.GetHostPorts()
if len(hostPorts) > 0 {
alivePorts = mergeHostPorts(alivePorts, hostPorts)
common.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts)))
session.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts)))
state.ClearHostPorts()
}
@@ -390,4 +390,3 @@ func (s *ServiceScanStrategy) convertToTargetInfos(ports []string, baseInfo comm
return infos
}
+24 -40
View File
@@ -16,29 +16,6 @@ import (
gmtls "github.com/tjfoc/gmsm/gmtls"
)
// ===============================
// Web服务检测
// ===============================
// 全局共享 HTTP Client,复用连接池减少 TLS 握手和 TCP 建连开销
var (
sharedHTTPClientOnce sync.Once
sharedHTTPClient *http.Client
)
func getSharedHTTPClient(config *common.Config) *http.Client {
sharedHTTPClientOnce.Do(func() {
sharedHTTPClient = createHTTPClient(config)
// 启用 keep-alive 复用连接
if t, ok := sharedHTTPClient.Transport.(*http.Transport); ok {
t.DisableKeepAlives = false
t.MaxIdleConns = 100
t.MaxIdleConnsPerHost = 2
}
})
return sharedHTTPClient
}
// WebPortDetector 简化的Web检测器 - 保持API兼容
type WebPortDetector struct{}
@@ -91,7 +68,7 @@ func DetectHTTPScheme(host string, port int, config *common.Config, session *com
}
// TLS和GM TLS都失败,尝试HTTP
client := getSharedHTTPClient(config)
client := createHTTPClient(config, session)
// 使用HEAD请求(更轻量)
httpURL := fmt.Sprintf("http://%s", addr)
@@ -106,7 +83,7 @@ func DetectHTTPScheme(host string, port int, config *common.Config, session *com
}
// createHTTPClient 创建统一的HTTP客户端 - 支持HTTP/HTTPS和代理
func createHTTPClient(config *common.Config) *http.Client {
func createHTTPClient(config *common.Config, session *common.ScanSession) *http.Client {
timeout := config.Network.WebTimeout
// 创建基础Transport,配置连接和 TLS 超时
@@ -128,14 +105,14 @@ func createHTTPClient(config *common.Config) *http.Client {
if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil {
transport.Proxy = http.ProxyURL(proxyURL)
} else {
common.LogError(i18n.Tr("http_proxy_config_error", err))
session.LogError(i18n.Tr("http_proxy_config_error", err))
}
} else if networkConfig.Socks5Proxy != "" {
// 使用SOCKS5代理 - 需要特殊处理
if _, err := url.Parse(networkConfig.Socks5Proxy); err == nil {
// SOCKS5代理需要使用代理管理器
// 这里先记录警告,建议使用HTTP代理进行Web检测
common.LogError(i18n.GetText("socks5_not_supported_web"))
session.LogError(i18n.GetText("socks5_not_supported_web"))
}
}
@@ -156,15 +133,15 @@ func (w *WebPortDetector) DetectHTTPServiceOnly(host string, port int, config *c
return false
}
client := getSharedHTTPClient(config)
client := createHTTPClient(config, session)
// 尝试HTTP
if w.tryHTTP(client, host, port, "http") {
if w.tryHTTP(client, session, host, port, "http") {
return true
}
// 尝试HTTPS
if w.tryHTTP(client, host, port, "https") {
if w.tryHTTP(client, session, host, port, "https") {
return true
}
@@ -186,7 +163,7 @@ func isPortReachable(host string, port int, config *common.Config, session *comm
}
// tryHTTP 尝试HTTP请求 - 简化的核心逻辑
func (w *WebPortDetector) tryHTTP(client *http.Client, host string, port int, protocol string) bool {
func (w *WebPortDetector) tryHTTP(client *http.Client, session *common.ScanSession, host string, port int, protocol string) bool {
// 构造URL
var url string
if (port == 80 && protocol == "http") || (port == 443 && protocol == "https") {
@@ -204,8 +181,7 @@ func (w *WebPortDetector) tryHTTP(client *http.Client, host string, port int, pr
req.Header.Set("User-Agent", "fscan-web-detector/2.1")
req.Header.Set("Accept", "*/*")
// 使用统一的SafeHTTPDo以确保遵循限速策略和代理设置
resp, err := common.SafeHTTPDo(client, req)
resp, err := session.HTTPDo(client, req)
if err != nil {
return false
}
@@ -330,19 +306,19 @@ func (s *WebScanStrategy) Description() string {
// Execute 执行Web扫描策略
func (s *WebScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
// 输出扫描开始信息
s.LogScanStart()
s.LogScanStart(session)
// 验证插件配置
if err := s.ValidateConfiguration(); err != nil {
common.LogError(err.Error())
session.LogError(err.Error())
return
}
// 准备URL目标
targets := s.PrepareTargets(info, session.State)
targets := s.prepareTargets(info, session.State, session)
// 输出插件信息
s.LogPluginInfo(session.Config)
s.LogPluginInfo(session.Config, session)
// 执行扫描任务
ExecuteScanTasks(ctx, session, targets, s, ch, wg)
@@ -350,12 +326,16 @@ func (s *WebScanStrategy) Execute(ctx context.Context, session *common.ScanSessi
// PrepareTargets 准备URL目标列表
func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common.State) []common.HostInfo {
return s.prepareTargets(baseInfo, state, nil)
}
func (s *WebScanStrategy) prepareTargets(baseInfo common.HostInfo, state *common.State, session *common.ScanSession) []common.HostInfo {
var targetInfos []common.HostInfo
// 首先从State获取URL目标
urls := state.GetURLs()
for _, urlStr := range urls {
urlInfo := s.createTargetFromURL(baseInfo, urlStr)
urlInfo := s.createTargetFromURLWithSession(baseInfo, urlStr, session)
if urlInfo != nil {
targetInfos = append(targetInfos, *urlInfo)
}
@@ -363,7 +343,7 @@ func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common
// 如果URLs为空但baseInfo.Url有值,使用baseInfo.URL
if len(targetInfos) == 0 && baseInfo.URL != "" {
urlInfo := s.createTargetFromURL(baseInfo, baseInfo.URL)
urlInfo := s.createTargetFromURLWithSession(baseInfo, baseInfo.URL, session)
if urlInfo != nil {
targetInfos = append(targetInfos, *urlInfo)
}
@@ -374,6 +354,10 @@ func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common
// createTargetFromURL 从URL创建目标信息
func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr string) *common.HostInfo {
return s.createTargetFromURLWithSession(baseInfo, urlStr, nil)
}
func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInfo, urlStr string, session *common.ScanSession) *common.HostInfo {
// 确保URL包含协议头
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
urlStr = "http://" + urlStr
@@ -382,7 +366,7 @@ func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr s
// 解析URL获取Host和Port信息
parsedURL, err := url.Parse(urlStr)
if err != nil {
common.LogError(i18n.Tr("url_parse_failed", urlStr, err))
session.LogError(i18n.Tr("url_parse_failed", urlStr, err))
return nil
}
+54 -1
View File
@@ -25,7 +25,6 @@ web_scanner_test.go - WebScanner核心逻辑测试
4. 指纹缓存 - SetFingerprints, GetFingerprints
不测试的部分(需要集成测试):
- createHTTPClient - 依赖全局配置
- tryHTTP, DetectHTTPServiceOnly - 网络IO
- Execute - 完整流程
@@ -765,3 +764,57 @@ func TestDetectHTTPScheme(t *testing.T) {
}
})
}
func TestCreateHTTPClientUsesPerSessionProxy(t *testing.T) {
cfgA := common.NewConfig()
cfgA.Network.WebTimeout = time.Second
cfgA.Network.HTTPProxy = "http://127.0.0.1:18080"
sessionA := common.NewScanSession(cfgA, common.NewState(), &common.FlagVars{})
cfgB := common.NewConfig()
cfgB.Network.WebTimeout = time.Second
cfgB.Network.HTTPProxy = "http://127.0.0.1:28080"
sessionB := common.NewScanSession(cfgB, common.NewState(), &common.FlagVars{})
clientA := createHTTPClient(cfgA, sessionA)
clientB := createHTTPClient(cfgB, sessionB)
if clientA == clientB {
t.Fatal("createHTTPClient reused a process-wide client")
}
proxyA := proxyForTest(t, clientA)
proxyB := proxyForTest(t, clientB)
if proxyA == proxyB {
t.Fatalf("proxy URLs should be per config, both were %q", proxyA)
}
if proxyA != "http://127.0.0.1:18080" {
t.Fatalf("proxyA = %q, want http://127.0.0.1:18080", proxyA)
}
if proxyB != "http://127.0.0.1:28080" {
t.Fatalf("proxyB = %q, want http://127.0.0.1:28080", proxyB)
}
}
func proxyForTest(t *testing.T, client *http.Client) string {
t.Helper()
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatal("client transport is not *http.Transport")
}
if transport.Proxy == nil {
t.Fatal("client proxy is nil")
}
req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err != nil {
t.Fatal(err)
}
proxyURL, err := transport.Proxy(req)
if err != nil {
t.Fatal(err)
}
if proxyURL == nil {
t.Fatal("proxy URL is nil")
}
return proxyURL.String()
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"context"
"fmt"
"time"
fscan "github.com/shadow1ng/fscan/pkg/fscan"
)
func main() {
config := fscan.Config{
Timeout: 3 * time.Second,
Threads: 64,
DisablePing: true,
DisableBrute: true,
Plugins: []string{"ssh", "mysql", "redis"},
}
target := fscan.Target{
Host: "127.0.0.1",
Ports: []int{22, 3306, 6379},
}
if err := fscan.ValidateConfig(config, target); err != nil {
panic(err)
}
scanner := fscan.NewScanner(config)
results, err := scanner.Scan(context.Background(), target)
if err != nil {
panic(err)
}
summary := fscan.SummarizeResults(results)
fmt.Printf("scan finished: %+v\n", summary)
for _, result := range results {
if result.IsService() || result.IsVuln() {
fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"context"
"fmt"
"time"
fscan "github.com/shadow1ng/fscan/pkg/fscan"
)
func main() {
for _, plugin := range fscan.ListPlugins() {
if plugin.Default {
fmt.Printf("default plugin: %s ports=%v safe=%v\n", plugin.Name, plugin.Ports, plugin.Safe)
}
}
config := fscan.Config{
Timeout: 3 * time.Second,
Threads: 64,
DisablePing: true,
DisableBrute: true,
Plugins: []string{"ssh", "mysql", "redis"},
}
target := fscan.Target{
Host: "127.0.0.1",
Ports: []int{22, 3306, 6379},
}
var summary fscan.ResultSummary
scanner := fscan.NewScanner(config)
err := scanner.ScanEach(context.Background(), func(result fscan.Result) error {
summary.Add(result)
if service, ok := result.Service(); ok {
fmt.Printf("service=%s target=%s\n", service, result.Target)
}
if result.IsCredential() {
username, _ := result.Username()
password, _ := result.Password()
fmt.Printf("credential target=%s username=%s password=%s\n", result.Target, username, password)
}
return nil
}, target)
if err != nil {
panic(err)
}
fmt.Printf("stream summary: %+v\n", summary)
}
+66
View File
@@ -0,0 +1,66 @@
# fscan SDK
`pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged.
See `examples/embed-basic` for slice-based collection and `examples/embed-stream` for streaming integration.
```go
package main
import (
"context"
"fmt"
"time"
fscan "github.com/shadow1ng/fscan/pkg/fscan"
)
func main() {
config := fscan.Config{
Timeout: 3 * time.Second,
Threads: 128,
DisablePing: true,
DisableBrute: true,
Plugins: []string{"ssh", "mysql", "redis"},
OnResult: func(result fscan.Result) {
if result.Type == fscan.ResultTypeService || result.Type == fscan.ResultTypeVuln {
fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status)
}
},
}
if err := fscan.ValidateConfig(config, fscan.Target{Host: "192.168.1.10"}); err != nil {
panic(err)
}
scanner := fscan.NewScanner(config)
err := scanner.ScanEach(context.Background(), func(result fscan.Result) error {
// Store, forward, or filter the result in the embedding system.
return nil
}, fscan.Target{
Host: "192.168.1.10",
Ports: []int{22, 3306, 6379},
})
if err != nil {
panic(err)
}
fmt.Println("scan finished")
}
```
The SDK currently reuses fscan's existing scan core and plugin registry. Embedded scans build per-session runtime state and can run concurrently.
By default, the SDK runs a conservative service-oriented plugin set and blocks plugins with local side effects or active POC behavior. Set `AllowUnsafePlugins` only when the embedding system explicitly wants those capabilities.
## API surface
| Area | API |
| --- | --- |
| Scanning | `NewScanner`, `Scan`, `ScanEach` |
| Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` |
| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginInfo` |
| Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` |
| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential` |
| Summary | `SummarizeResults`, `ResultSummary.Add` |
Use `Scan` when you want all results returned as a slice. Use `ScanEach` when results should be streamed into another system; handler calls are serialized, and returning an error stops the scan and returns that error.
+15
View File
@@ -0,0 +1,15 @@
// Package fscan exposes fscan as an embeddable scanner library.
//
// The package is intentionally thin: it reuses the existing scan core and
// plugin registry, while hiding CLI flags, stdout output, and result files from
// callers. Embedded scans build per-session runtime state and can run
// concurrently.
//
// Embedded callers can use ValidateConfig before starting a scan, IsSafePlugin
// or ListPlugins to build plugin allow lists, and ResultType* constants instead
// of matching raw result type strings. Result exposes helpers for common detail
// fields such as port, service, plugin, credentials, and web metadata. Use
// SummarizeResults or ResultSummary for aggregate counts. Use ScanEach for
// streaming consumption when callers do not want to retain the full result set
// in memory.
package fscan
+218
View File
@@ -0,0 +1,218 @@
package fscan
import (
"fmt"
"net"
"strconv"
"strings"
)
// IsHost reports whether the result describes a live host.
func (r Result) IsHost() bool { return r.Type == ResultTypeHost }
// IsPort reports whether the result describes an open port.
func (r Result) IsPort() bool { return r.Type == ResultTypePort }
// IsService reports whether the result describes a service.
func (r Result) IsService() bool { return r.Type == ResultTypeService }
// IsVuln reports whether the result describes a vulnerability or credential.
func (r Result) IsVuln() bool { return r.Type == ResultTypeVuln }
// IsCredential reports whether the result describes a weak credential finding.
func (r Result) IsCredential() bool {
if resultType, ok := r.DetailString("type"); ok && resultType == "weak_credential" {
return true
}
return strings.HasPrefix(r.Status, "weak_credential:")
}
// SummarizeResults counts common result categories.
func SummarizeResults(results []Result) ResultSummary {
var summary ResultSummary
for _, result := range results {
summary.Add(result)
}
return summary
}
// Add includes one result in the summary.
func (s *ResultSummary) Add(result Result) {
s.Total++
switch {
case result.IsHost():
s.Hosts++
case result.IsPort():
s.Ports++
case result.IsService():
s.Services++
case result.IsVuln():
s.Vulns++
}
if result.IsWeb() {
s.Web++
}
if result.IsCredential() {
s.Credentials++
}
}
// DetailString returns a string detail value.
func (r Result) DetailString(key string) (string, bool) {
value, ok := r.Details[key]
if !ok || value == nil {
return "", false
}
switch v := value.(type) {
case string:
return v, true
case fmt.Stringer:
return v.String(), true
default:
return fmt.Sprint(v), true
}
}
// DetailInt returns an integer detail value.
func (r Result) DetailInt(key string) (int, bool) {
value, ok := r.Details[key]
if !ok || value == nil {
return 0, false
}
switch v := value.(type) {
case int:
return v, true
case int8:
return int(v), true
case int16:
return int(v), true
case int32:
return int(v), true
case int64:
return intFromInt64(v)
case uint:
return intFromUint64(uint64(v))
case uint8:
return int(v), true
case uint16:
return int(v), true
case uint32:
return intFromUint64(uint64(v))
case uint64:
return intFromUint64(v)
case float32:
return intFromFloat64(float64(v))
case float64:
return intFromFloat64(v)
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
return n, err == nil
case fmt.Stringer:
n, err := strconv.Atoi(strings.TrimSpace(v.String()))
return n, err == nil
default:
return 0, false
}
}
// DetailBool returns a boolean detail value.
func (r Result) DetailBool(key string) (bool, bool) {
value, ok := r.Details[key]
if !ok || value == nil {
return false, false
}
switch v := value.(type) {
case bool:
return v, true
case string:
b, err := strconv.ParseBool(strings.TrimSpace(v))
return b, err == nil
default:
return false, false
}
}
// Port returns the result port from details, or from a target in host:port form.
func (r Result) Port() (int, bool) {
if port, ok := r.DetailInt("port"); ok {
return port, true
}
if _, portText, err := net.SplitHostPort(r.Target); err == nil {
port, err := strconv.Atoi(portText)
return port, err == nil
}
if strings.Count(r.Target, ":") == 1 {
if idx := strings.LastIndex(r.Target, ":"); idx >= 0 && idx+1 < len(r.Target) {
port, err := strconv.Atoi(r.Target[idx+1:])
return port, err == nil
}
}
return 0, false
}
// Service returns the detected service name when present.
func (r Result) Service() (string, bool) { return r.DetailString("service") }
// Plugin returns the plugin that produced the result when present.
func (r Result) Plugin() (string, bool) { return r.DetailString("plugin") }
// Username returns the credential username when present.
func (r Result) Username() (string, bool) { return r.DetailString("username") }
// Password returns the credential password when present.
func (r Result) Password() (string, bool) { return r.DetailString("password") }
// Banner returns the service banner when present.
func (r Result) Banner() (string, bool) { return r.DetailString("banner") }
// Vulnerability returns the vulnerability description when present.
func (r Result) Vulnerability() (string, bool) { return r.DetailString("vulnerability") }
// URL returns the web result URL when present.
func (r Result) URL() (string, bool) { return r.DetailString("url") }
// Protocol returns the detected protocol when present.
func (r Result) Protocol() (string, bool) { return r.DetailString("protocol") }
// IsWeb reports whether the result is associated with an HTTP(S) service.
func (r Result) IsWeb() bool {
if ok, found := r.DetailBool("is_web"); found {
return ok
}
for _, getter := range []func() (string, bool){r.Service, r.Protocol} {
value, ok := getter()
if !ok {
continue
}
value = strings.ToLower(value)
if value == "http" || value == "https" {
return true
}
}
return false
}
func intFromInt64(v int64) (int, bool) {
max := int64(^uint(0) >> 1)
min := -max - 1
if v < min || v > max {
return 0, false
}
return int(v), true
}
func intFromUint64(v uint64) (int, bool) {
max := uint64(^uint(0) >> 1)
if v > max {
return 0, false
}
return int(v), true
}
func intFromFloat64(v float64) (int, bool) {
n := int64(v)
if float64(n) != v {
return 0, false
}
return intFromInt64(n)
}
+122
View File
@@ -0,0 +1,122 @@
package fscan
import (
"encoding/json"
"testing"
)
func TestResultHelpers(t *testing.T) {
result := Result{
Type: ResultTypeService,
Target: "127.0.0.1:8080",
Status: "identified",
Details: map[string]interface{}{
"port": float64(8080),
"service": "http",
"plugin": "webtitle",
"banner": "nginx",
"is_web": "true",
"protocol": "http",
},
}
if !result.IsService() || result.IsPort() {
t.Fatalf("unexpected type helpers for %q", result.Type)
}
if port, ok := result.Port(); !ok || port != 8080 {
t.Fatalf("Port = %d/%v, want 8080/true", port, ok)
}
if service, ok := result.Service(); !ok || service != "http" {
t.Fatalf("Service = %q/%v, want http/true", service, ok)
}
if plugin, ok := result.Plugin(); !ok || plugin != "webtitle" {
t.Fatalf("Plugin = %q/%v, want webtitle/true", plugin, ok)
}
if banner, ok := result.Banner(); !ok || banner != "nginx" {
t.Fatalf("Banner = %q/%v, want nginx/true", banner, ok)
}
if !result.IsWeb() {
t.Fatal("expected web result")
}
}
func TestResultPortFallback(t *testing.T) {
result := Result{Target: "[::1]:22"}
port, ok := result.Port()
if !ok || port != 22 {
t.Fatalf("Port = %d/%v, want 22/true", port, ok)
}
}
func TestResultPortDoesNotParseBareIPv6(t *testing.T) {
result := Result{Target: "2001:db8::1"}
if port, ok := result.Port(); ok {
t.Fatalf("Port = %d/true, want false", port)
}
}
func TestResultCredentialHelpers(t *testing.T) {
result := Result{
Type: ResultTypeVuln,
Details: map[string]interface{}{
"type": "weak_credential",
"username": "root",
"password": "toor",
},
}
if !result.IsVuln() {
t.Fatal("expected vuln result")
}
if !result.IsCredential() {
t.Fatal("expected credential result")
}
if username, ok := result.Username(); !ok || username != "root" {
t.Fatalf("Username = %q/%v, want root/true", username, ok)
}
if password, ok := result.Password(); !ok || password != "toor" {
t.Fatalf("Password = %q/%v, want toor/true", password, ok)
}
}
func TestSummarizeResults(t *testing.T) {
results := []Result{
{Type: ResultTypeHost, Target: "127.0.0.1"},
{Type: ResultTypePort, Target: "127.0.0.1", Details: map[string]interface{}{"port": 80}},
{Type: ResultTypeService, Target: "127.0.0.1:80", Details: map[string]interface{}{"service": "http"}},
{Type: ResultTypeVuln, Target: "127.0.0.1:22", Status: "weak_credential: root:toor"},
}
summary := SummarizeResults(results)
if summary.Total != 4 {
t.Fatalf("Total = %d, want 4", summary.Total)
}
if summary.Hosts != 1 || summary.Ports != 1 || summary.Services != 1 || summary.Vulns != 1 {
t.Fatalf("summary categories = %#v, want one each", summary)
}
if summary.Web != 1 {
t.Fatalf("Web = %d, want 1", summary.Web)
}
if summary.Credentials != 1 {
t.Fatalf("Credentials = %d, want 1", summary.Credentials)
}
}
func TestResultDetailIntRejectsFraction(t *testing.T) {
result := Result{Details: map[string]interface{}{"port": 22.5}}
if port, ok := result.DetailInt("port"); ok {
t.Fatalf("DetailInt = %d/true, want false", port)
}
}
func TestResultDetailIntParsesJSONNumber(t *testing.T) {
result := Result{Details: map[string]interface{}{"port": json.Number("443")}}
port, ok := result.DetailInt("port")
if !ok || port != 443 {
t.Fatalf("DetailInt = %d/%v, want 443/true", port, ok)
}
}
+447
View File
@@ -0,0 +1,447 @@
package fscan
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/shadow1ng/fscan/common"
commonconfig "github.com/shadow1ng/fscan/common/config"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/common/output"
"github.com/shadow1ng/fscan/core"
"github.com/shadow1ng/fscan/plugins"
_ "github.com/shadow1ng/fscan/plugins/local"
_ "github.com/shadow1ng/fscan/plugins/services"
_ "github.com/shadow1ng/fscan/plugins/web"
)
var defaultSafePlugins = []string{
"activemq",
"cassandra",
"elasticsearch",
"ftp",
"kafka",
"ldap",
"memcached",
"mongodb",
"mssql",
"mysql",
"neo4j",
"netbios",
"oracle",
"postgresql",
"rabbitmq",
"rdp",
"redis",
"rsync",
"smb",
"smtp",
"ssh",
"telnet",
"vnc",
"webtitle",
}
// Scanner runs fscan from another Go process.
type Scanner struct {
config Config
}
// NewScanner creates an embedded scanner.
func NewScanner(config Config) *Scanner {
return &Scanner{config: config}
}
// DefaultSafePlugins returns the plugin set used by the SDK when Config.Plugins
// is empty. The returned slice can be modified by callers.
func DefaultSafePlugins() []string {
return append([]string(nil), defaultSafePlugins...)
}
// ListPlugins returns metadata for all registered plugins, sorted by name.
func ListPlugins() []PluginInfo {
names := plugins.All()
sort.Strings(names)
items := make([]PluginInfo, 0, len(names))
for _, name := range names {
if info, ok := GetPlugin(name); ok {
items = append(items, info)
}
}
return items
}
// GetPlugin returns metadata for a registered plugin.
func GetPlugin(name string) (PluginInfo, bool) {
name = strings.TrimSpace(name)
if name == "" || !plugins.Exists(name) {
return PluginInfo{}, false
}
return PluginInfo{
Name: name,
Types: pluginTypes(name),
Ports: pluginPorts(name),
Safe: IsSafePlugin(name),
Default: isDefaultSafePlugin(name),
}, true
}
// ValidateConfig checks whether a config and target set can be used for an
// embedded scan. If no targets are passed, Config.Targets is validated.
func ValidateConfig(config Config, targets ...Target) error {
if len(targets) == 0 {
targets = config.Targets
}
return validateConfig(config, targets)
}
// IsSafePlugin reports whether a plugin may be used while AllowUnsafePlugins is
// false. Unknown plugin names are not safe.
func IsSafePlugin(name string) bool {
name = strings.TrimSpace(name)
if name == "" || !plugins.Exists(name) {
return false
}
return plugins.IsSafe(name)
}
// Scan runs the scanner for the provided targets and returns structured
// findings. If no targets are provided, Config.Targets is used.
func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) {
var (
mu sync.Mutex
results []Result
)
err := s.ScanEach(ctx, func(result Result) error {
mu.Lock()
results = append(results, result)
mu.Unlock()
return nil
}, targets...)
return snapshotResults(&mu, results), err
}
// ScanEach runs the scanner and calls handle serially for each structured
// result without retaining all results in memory. If handle returns an error,
// the scan context is canceled and that error is returned.
func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error {
if handle == nil {
return fmt.Errorf("fscan: result handler is required")
}
if ctx == nil {
ctx = context.Background()
}
if len(targets) == 0 {
targets = s.config.Targets
}
if err := validateConfig(s.config, targets); err != nil {
return err
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
restoreLogger := common.PushSilentLogger()
defer restoreLogger()
var (
errMu sync.Mutex
handleMu sync.Mutex
handlerErr error
)
for _, target := range targets {
if err := ctx.Err(); err != nil {
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stored
}
return err
}
sink := func(raw *output.ScanResult) error {
if result, ok := convertOutputResult(raw); ok {
handleMu.Lock()
if err := handle(result); err != nil {
handleMu.Unlock()
setHandlerError(&errMu, &handlerErr, err)
cancel()
return err
}
if s.config.OnResult != nil {
s.config.OnResult(result)
}
handleMu.Unlock()
}
return nil
}
if err := s.scanOne(ctx, target, sink); err != nil {
return err
}
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stored
}
}
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stored
}
return ctx.Err()
}
func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) error {
fv := buildFlagVars(s.config, target)
info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)}
previousLanguage := i18n.GetLanguage()
i18n.SetLanguage(fv.Language)
defer i18n.SetLanguage(previousLanguage)
cfg, state, err := common.BuildConfig(fv, &info)
if err != nil {
return err
}
if len(s.config.UserPassPairs) > 0 {
cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs))
for _, pair := range s.config.UserPassPairs {
cfg.Credentials.UserPassPairs = append(cfg.Credentials.UserPassPairs, commonconfig.CredentialPair{
Username: pair.Username,
Password: pair.Password,
})
}
}
cfg.Output.DisableSave = true
cfg.Output.Silent = true
cfg.Output.DisableProgress = true
cfg.Output.ShowProgress = false
session := common.NewScanSession(cfg, state, fv)
session.ResultSink = sink
core.RunScan(ctx, info, session)
return nil
}
func validateConfig(config Config, targets []Target) error {
if len(targets) == 0 {
return fmt.Errorf("fscan: at least one target is required")
}
for _, name := range normalizePlugins(config.Plugins) {
if !plugins.Exists(name) {
return fmt.Errorf("fscan: plugin %q not found", name)
}
if !config.AllowUnsafePlugins && !IsSafePlugin(name) {
return fmt.Errorf("fscan: plugin %q is not enabled for embedded safe mode", name)
}
}
for _, target := range targets {
if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" {
return fmt.Errorf("fscan: target host or URL is required")
}
if strings.TrimSpace(target.Host) != "" && strings.TrimSpace(target.URL) != "" {
return fmt.Errorf("fscan: target cannot set both Host and URL")
}
for _, port := range target.Ports {
if port < 1 || port > 65535 {
return fmt.Errorf("fscan: invalid port %d", port)
}
}
}
for _, port := range config.Ports {
if port < 1 || port > 65535 {
return fmt.Errorf("fscan: invalid port %d", port)
}
}
return nil
}
func buildFlagVars(config Config, target Target) *common.FlagVars {
timeout := secondsOrDefault(config.Timeout, common.DefaultTimeout)
webTimeout := secondsOrDefault(config.WebTimeout, 5)
threadNum := config.Threads
if threadNum <= 0 {
threadNum = common.DefaultThreadNum
}
moduleThreads := config.ModuleThreads
if moduleThreads <= 0 {
moduleThreads = 20
}
maxRetries := config.MaxRetries
if maxRetries <= 0 {
maxRetries = 3
}
maxRedirects := config.MaxRedirects
if maxRedirects <= 0 {
maxRedirects = 10
}
pocConcurrency := config.POCConcurrency
if pocConcurrency <= 0 {
pocConcurrency = 20
}
icmpRate := config.ICMPRate
if icmpRate <= 0 {
icmpRate = 0.1
}
language := config.Language
if language == "" {
language = common.DefaultLanguage
}
ports := config.Ports
if len(target.Ports) > 0 {
ports = target.Ports
}
return &common.FlagVars{
Host: strings.TrimSpace(target.Host),
Ports: formatPorts(ports),
ScanMode: formatPlugins(config),
ThreadNum: threadNum,
ModuleThreadNum: moduleThreads,
TimeoutSec: timeout,
GlobalTimeout: 180,
DisablePing: config.DisablePing,
DisableTcpProbe: config.DisableTCPProbe,
AliveOnly: false,
DisableBrute: config.DisableBrute,
MaxRetries: maxRetries,
Username: strings.Join(config.Usernames, ","),
Password: strings.Join(config.Passwords, ","),
Domain: config.Domain,
SSHKeyPath: config.SSHKeyPath,
TargetURL: strings.TrimSpace(target.URL),
WebTimeout: webTimeout,
MaxRedirects: maxRedirects,
HTTPProxy: config.HTTPProxy,
Socks5Proxy: config.Socks5Proxy,
Iface: config.Interface,
PocPath: config.POCPath,
PocName: config.POCName,
PocFull: config.POCFull,
PocNum: pocConcurrency,
DisablePocScan: config.DisablePOCScan,
PacketRateLimit: config.PacketRateLimit,
MaxPacketCount: config.MaxPacketCount,
ICMPRate: icmpRate,
Outputfile: "result.txt",
OutputFormat: "txt",
DisableSave: true,
Silent: true,
NoColor: true,
LogLevel: common.LogLevelError,
DisableProgress: true,
Language: language,
ForwardShellPort: 4444,
KeyloggerOutputFile: "keylog.txt",
}
}
func formatPlugins(config Config) string {
parts := normalizePlugins(config.Plugins)
if len(parts) == 0 {
if config.AllowUnsafePlugins {
return "all"
}
parts = defaultSafePlugins
}
return strings.Join(parts, ",")
}
func formatPorts(ports []int) string {
if len(ports) == 0 {
return commonconfig.MainPorts
}
ports = append([]int(nil), ports...)
sort.Ints(ports)
parts := make([]string, 0, len(ports))
for _, port := range ports {
parts = append(parts, strconv.Itoa(port))
}
return strings.Join(parts, ",")
}
func normalizePlugins(pluginNames []string) []string {
parts := make([]string, 0, len(pluginNames))
for _, plugin := range pluginNames {
plugin = strings.TrimSpace(plugin)
if plugin != "" {
parts = append(parts, plugin)
}
}
return parts
}
func pluginTypes(name string) []string {
types := make([]string, 0, 3)
for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal} {
if plugins.HasType(name, pluginType) {
types = append(types, pluginType)
}
}
return types
}
func pluginPorts(name string) []int {
ports := plugins.GetPluginPorts(name)
ports = append([]int(nil), ports...)
sort.Ints(ports)
return ports
}
func isDefaultSafePlugin(name string) bool {
for _, plugin := range defaultSafePlugins {
if plugin == name {
return true
}
}
return false
}
func secondsOrDefault(value time.Duration, fallback int) int64 {
if value <= 0 {
return int64(fallback)
}
seconds := int64(value.Round(time.Second) / time.Second)
if seconds < 1 {
return 1
}
return seconds
}
func convertOutputResult(raw *output.ScanResult) (Result, bool) {
if raw == nil {
return Result{}, false
}
result := Result{
Time: raw.Time,
Type: string(raw.Type),
Target: raw.Target,
Status: raw.Status,
Details: raw.Details,
}
return result, result.Target != "" || result.Status != ""
}
func snapshotResults(mu *sync.Mutex, results []Result) []Result {
mu.Lock()
defer mu.Unlock()
return append([]Result(nil), results...)
}
func setHandlerError(mu *sync.Mutex, target *error, err error) {
mu.Lock()
defer mu.Unlock()
if *target == nil {
*target = err
}
}
func getHandlerError(mu *sync.Mutex, err *error) error {
mu.Lock()
defer mu.Unlock()
return *err
}
+512
View File
@@ -0,0 +1,512 @@
package fscan
import (
"context"
"errors"
"net"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
commonconfig "github.com/shadow1ng/fscan/common/config"
"github.com/shadow1ng/fscan/common/i18n"
)
func TestBuildFlagVarsDefaults(t *testing.T) {
fv := buildFlagVars(Config{}, Target{Host: "127.0.0.1"})
if fv.Host != "127.0.0.1" {
t.Fatalf("Host = %q", fv.Host)
}
if fv.Ports != commonconfig.MainPorts {
t.Fatalf("Ports = %q, want MainPorts", fv.Ports)
}
if fv.ScanMode != formatPlugins(Config{Plugins: DefaultSafePlugins()}) {
t.Fatalf("ScanMode = %q, want safe defaults", fv.ScanMode)
}
if !fv.DisableSave || !fv.Silent || !fv.DisableProgress {
t.Fatalf("embedded defaults should disable output side effects")
}
}
func TestBuildFlagVarsBlankPluginsUseSafeDefaults(t *testing.T) {
fv := buildFlagVars(Config{Plugins: []string{" ", "\t"}}, Target{Host: "127.0.0.1"})
if fv.ScanMode != formatPlugins(Config{Plugins: DefaultSafePlugins()}) {
t.Fatalf("ScanMode = %q, want safe defaults", fv.ScanMode)
}
}
func TestBuildFlagVarsTargetPortsOverride(t *testing.T) {
fv := buildFlagVars(Config{Ports: []int{22, 80}}, Target{Host: "127.0.0.1", Ports: []int{3306, 22}})
if fv.Ports != "22,3306" {
t.Fatalf("Ports = %q, want sorted target override", fv.Ports)
}
}
func TestValidateConfig(t *testing.T) {
if err := validateConfig(Config{}, nil); err == nil {
t.Fatal("expected missing target error")
}
if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", URL: "http://127.0.0.1"}}); err == nil {
t.Fatal("expected host/url conflict")
}
if err := validateConfig(Config{Plugins: []string{"definitely-missing"}}, []Target{{Host: "127.0.0.1"}}); err == nil {
t.Fatal("expected missing plugin error")
}
if err := validateConfig(Config{Plugins: []string{"webpoc"}}, []Target{{URL: "http://127.0.0.1"}}); err == nil {
t.Fatal("expected unsafe plugin error")
}
if err := validateConfig(Config{Plugins: []string{"webpoc"}, AllowUnsafePlugins: true}, []Target{{URL: "http://127.0.0.1"}}); err != nil {
t.Fatalf("unsafe plugin with opt-in failed: %v", err)
}
if err := ValidateConfig(Config{Targets: []Target{{Host: "127.0.0.1"}}}); err != nil {
t.Fatalf("ValidateConfig with config targets failed: %v", err)
}
if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil {
t.Fatal("expected invalid port error")
}
}
func TestIsSafePlugin(t *testing.T) {
if !IsSafePlugin("ssh") {
t.Fatal("ssh should be safe")
}
if IsSafePlugin("webpoc") {
t.Fatal("webpoc should not be safe")
}
if IsSafePlugin("definitely-missing") {
t.Fatal("unknown plugin should not be safe")
}
}
func TestListPlugins(t *testing.T) {
items := ListPlugins()
if len(items) == 0 {
t.Fatal("expected registered plugins")
}
for i := 1; i < len(items); i++ {
if items[i-1].Name > items[i].Name {
t.Fatalf("plugins not sorted: %q before %q", items[i-1].Name, items[i].Name)
}
}
ssh, ok := GetPlugin("ssh")
if !ok {
t.Fatal("missing ssh plugin")
}
if ssh.Name != "ssh" {
t.Fatalf("plugin name = %q, want ssh", ssh.Name)
}
if !ssh.Safe || !ssh.Default {
t.Fatalf("ssh safe/default = %v/%v, want true/true", ssh.Safe, ssh.Default)
}
if !containsString(ssh.Types, PluginTypeService) {
t.Fatalf("ssh types = %#v, want service", ssh.Types)
}
if !containsInt(ssh.Ports, 22) {
t.Fatalf("ssh ports = %#v, want 22", ssh.Ports)
}
if _, ok := GetPlugin("definitely-missing"); ok {
t.Fatal("unknown plugin should not exist")
}
webpoc, ok := GetPlugin("webpoc")
if !ok {
t.Fatal("missing webpoc plugin")
}
if webpoc.Safe {
t.Fatal("webpoc should be marked unsafe")
}
if !containsString(webpoc.Types, PluginTypeWeb) {
t.Fatalf("webpoc types = %#v, want web", webpoc.Types)
}
}
func TestScanHonorsCanceledContext(t *testing.T) {
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Plugins: []string{"redis"},
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}})
if err != context.Canceled {
t.Fatalf("Scan error = %v, want context.Canceled", err)
}
}
func TestScanCollectsResultsThroughSessionSink(t *testing.T) {
listener := startFTPListener(t)
defer listener.Close()
var callbackCalls int32
common.SetResultCallback(func(interface{}) {
atomic.AddInt32(&callbackCalls, 1)
})
defer common.ClearResultCallback()
var streamed int32
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
OnResult: func(result Result) {
atomic.AddInt32(&streamed, 1)
},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
port := listener.Addr().(*net.TCPAddr).Port
results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}})
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Fatal("expected SDK results")
}
if got := atomic.LoadInt32(&streamed); got != int32(len(results)) {
t.Fatalf("streamed length = %d, want %d", got, len(results))
}
if !hasResult(results, ResultTypePort, "open", "") {
t.Fatalf("missing port result: %#v", results)
}
if !hasResult(results, ResultTypeService, "FTP", "ftp") {
t.Fatalf("missing ftp plugin result: %#v", results)
}
if got := atomic.LoadInt32(&callbackCalls); got != 0 {
t.Fatalf("global callback calls = %d, want 0", got)
}
}
func TestScanEachStreamsResults(t *testing.T) {
listener := startFTPListener(t)
defer listener.Close()
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
var results []Result
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
port := listener.Addr().(*net.TCPAddr).Port
err := scanner.ScanEach(ctx, func(result Result) error {
results = append(results, result)
return nil
}, Target{Host: "127.0.0.1", Ports: []int{port}})
if err != nil {
t.Fatal(err)
}
if !hasResult(results, ResultTypePort, "open", "") {
t.Fatalf("missing port result: %#v", results)
}
if !hasResult(results, ResultTypeService, "FTP", "ftp") {
t.Fatalf("missing ftp plugin result: %#v", results)
}
}
func TestScanUsesConfigTargets(t *testing.T) {
listener := startFTPListener(t)
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
scanner := NewScanner(Config{
Targets: []Target{{Host: "127.0.0.1", Ports: []int{port}}},
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results, err := scanner.Scan(ctx)
if err != nil {
t.Fatal(err)
}
if !hasPortResult(results, port) {
t.Fatalf("missing configured target port result: %#v", results)
}
}
func TestScanExplicitTargetsOverrideConfigTargets(t *testing.T) {
configured := startFTPListener(t)
defer configured.Close()
explicit := startFTPListener(t)
defer explicit.Close()
configuredPort := configured.Addr().(*net.TCPAddr).Port
explicitPort := explicit.Addr().(*net.TCPAddr).Port
scanner := NewScanner(Config{
Targets: []Target{{Host: "127.0.0.1", Ports: []int{configuredPort}}},
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{explicitPort}})
if err != nil {
t.Fatal(err)
}
if !hasPortResult(results, explicitPort) {
t.Fatalf("missing explicit target port result: %#v", results)
}
if hasPortResult(results, configuredPort) {
t.Fatalf("configured target should not run when explicit targets are passed: %#v", results)
}
}
func TestScanEachReturnsHandlerError(t *testing.T) {
listener := startFTPListener(t)
defer listener.Close()
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
stopErr := errors.New("stop scan")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
port := listener.Addr().(*net.TCPAddr).Port
err := scanner.ScanEach(ctx, func(Result) error {
return stopErr
}, Target{Host: "127.0.0.1", Ports: []int{port}})
if !errors.Is(err, stopErr) {
t.Fatalf("ScanEach error = %v, want %v", err, stopErr)
}
}
func TestScanEachRequiresHandler(t *testing.T) {
scanner := NewScanner(Config{Targets: []Target{{Host: "127.0.0.1"}}})
if err := scanner.ScanEach(context.Background(), nil); err == nil {
t.Fatal("expected missing handler error")
}
}
func TestScanEachRunsConcurrent(t *testing.T) {
first := startFTPListener(t)
defer first.Close()
second := startFTPListener(t)
defer second.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
blocked := make(chan struct{})
release := make(chan struct{})
firstErr := make(chan error, 1)
var blockOnce sync.Once
go func() {
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
port := first.Addr().(*net.TCPAddr).Port
firstErr <- scanner.ScanEach(ctx, func(result Result) error {
if result.IsPort() {
blockOnce.Do(func() { close(blocked) })
select {
case <-release:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}, Target{Host: "127.0.0.1", Ports: []int{port}})
}()
select {
case <-blocked:
case <-time.After(2 * time.Second):
t.Fatal("first scan did not reach handler")
}
secondErr := make(chan error, 1)
var secondResults []Result
go func() {
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
port := second.Addr().(*net.TCPAddr).Port
secondErr <- scanner.ScanEach(ctx, func(result Result) error {
secondResults = append(secondResults, result)
return nil
}, Target{Host: "127.0.0.1", Ports: []int{port}})
}()
select {
case err := <-secondErr:
if err != nil {
close(release)
t.Fatalf("second scan failed: %v", err)
}
case <-time.After(2 * time.Second):
close(release)
t.Fatal("second scan blocked behind first scan")
}
if !hasResult(secondResults, ResultTypePort, "open", "") {
close(release)
t.Fatalf("missing second scan result: %#v", secondResults)
}
close(release)
if err := <-firstErr; err != nil {
t.Fatalf("first scan failed: %v", err)
}
}
func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) {
listener := startFTPListener(t)
defer listener.Close()
previousConfig := common.GetGlobalConfig()
previousState := common.GetGlobalState()
previousFlags := *common.GetFlagVars()
previousLanguage := i18n.GetLanguage()
defer func() {
common.SetGlobalConfig(previousConfig)
common.SetGlobalState(previousState)
*common.GetFlagVars() = previousFlags
i18n.SetLanguage(previousLanguage)
}()
sentinelConfig := common.NewConfig()
sentinelState := common.NewState()
common.SetGlobalConfig(sentinelConfig)
common.SetGlobalState(sentinelState)
common.GetFlagVars().LogLevel = "sentinel"
i18n.SetLanguage(i18n.LangEN)
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
Language: i18n.LangZH,
})
port := listener.Addr().(*net.TCPAddr).Port
if _, err := scanner.Scan(context.Background(), Target{Host: "127.0.0.1", Ports: []int{port}}); err != nil {
t.Fatal(err)
}
if common.GetGlobalConfig() != sentinelConfig {
t.Fatal("SDK scan replaced global config")
}
if common.GetGlobalState() != sentinelState {
t.Fatal("SDK scan replaced global state")
}
if common.GetFlagVars().LogLevel != "sentinel" {
t.Fatal("SDK scan replaced global flags")
}
if got := i18n.GetLanguage(); got != i18n.LangEN {
t.Fatalf("SDK scan leaked global language = %q, want %q", got, i18n.LangEN)
}
}
func startFTPListener(t *testing.T) net.Listener {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
_, _ = conn.Write([]byte("220 test FTP\r\n"))
buf := make([]byte, 64)
_, _ = conn.Read(buf)
}(conn)
}
}()
return listener
}
func containsString(items []string, value string) bool {
for _, item := range items {
if item == value {
return true
}
}
return false
}
func containsInt(items []int, value int) bool {
for _, item := range items {
if item == value {
return true
}
}
return false
}
func hasResult(results []Result, resultType, statusText, plugin string) bool {
for _, result := range results {
if result.Type != resultType || !strings.Contains(result.Status, statusText) {
continue
}
if plugin == "" {
return true
}
if result.Details != nil && result.Details["plugin"] == plugin {
return true
}
}
return false
}
func hasPortResult(results []Result, port int) bool {
for _, result := range results {
if !result.IsPort() {
continue
}
if got, ok := result.Port(); ok && got == port {
return true
}
}
return false
}
+116
View File
@@ -0,0 +1,116 @@
package fscan
import (
"time"
)
const (
// PluginTypeWeb marks web-facing plugins.
PluginTypeWeb = "web"
// PluginTypeLocal marks plugins that operate on the local host.
PluginTypeLocal = "local"
// PluginTypeService marks network service plugins.
PluginTypeService = "service"
)
const (
// ResultTypeHost reports a live host.
ResultTypeHost = "HOST"
// ResultTypePort reports an open port.
ResultTypePort = "PORT"
// ResultTypeService reports a service fingerprint or service plugin result.
ResultTypeService = "SERVICE"
// ResultTypeVuln reports a vulnerability or credential finding.
ResultTypeVuln = "VULN"
)
// Target describes one scan target. Use Host for host/IP/CIDR/range service
// scans, or URL for web scans. Ports applies only to Host scans.
type Target struct {
Host string
URL string
Ports []int
}
// CredentialPair pins one username/password pair.
type CredentialPair struct {
Username string
Password string
}
// PluginInfo describes one registered scanner plugin.
type PluginInfo struct {
Name string `json:"name"`
Types []string `json:"types,omitempty"`
Ports []int `json:"ports,omitempty"`
Safe bool `json:"safe"`
Default bool `json:"default"`
}
// ResultSummary counts common result categories.
type ResultSummary struct {
Total int `json:"total"`
Hosts int `json:"hosts"`
Ports int `json:"ports"`
Services int `json:"services"`
Vulns int `json:"vulns"`
Web int `json:"web"`
Credentials int `json:"credentials"`
}
// ResultHandler receives one structured result. Calls are serialized by the
// scanner. Returning an error asks the scanner to stop and returns that error
// to the caller.
type ResultHandler func(Result) error
// Config controls an embedded scan. Zero values use the same conservative
// defaults as the CLI, except output is silent and file saving is disabled.
type Config struct {
Targets []Target
Plugins []string
Ports []int
// AllowUnsafePlugins permits plugins with local side effects or long-lived
// behavior. It is false by default for embedded endpoint use.
AllowUnsafePlugins bool
// OnResult is called for every structured result as it is discovered.
OnResult func(Result)
Timeout time.Duration
Threads int
ModuleThreads int
MaxRetries int
DisablePing bool
DisableTCPProbe bool
DisableBrute bool
Usernames []string
Passwords []string
UserPassPairs []CredentialPair
Domain string
SSHKeyPath string
HTTPProxy string
Socks5Proxy string
Interface string
WebTimeout time.Duration
MaxRedirects int
DisablePOCScan bool
POCPath string
POCName string
POCFull bool
POCConcurrency int
PacketRateLimit int64
MaxPacketCount int64
ICMPRate float64
Language string
}
// Result is the structured scan result returned to embedded callers.
type Result struct {
Time time.Time `json:"time"`
Type string `json:"type"`
Target string `json:"target"`
Status string `json:"status"`
Details map[string]interface{} `json:"details,omitempty"`
}
+33 -1
View File
@@ -43,7 +43,7 @@ const (
type Result struct {
Type ResultType
Success bool
Skipped bool // 扫描被跳过,不应输出结果
Skipped bool // 扫描被跳过,不应输出结果
Service string
Username string
Password string
@@ -84,6 +84,7 @@ type PluginInfo struct {
factory func() Plugin
ports []int
types []string // 插件类型标签
safe bool // 是否适合默认嵌入式扫描
}
// 插件类型常量
@@ -123,12 +124,23 @@ func RegisterWithPorts(name string, factory func() Plugin, ports []int) {
// RegisterWithTypes 注册带类型标签的插件
func RegisterWithTypes(name string, factory func() Plugin, ports []int, types []string) {
RegisterWithOptions(name, factory, ports, types, !hasPluginType(types, PluginTypeLocal))
}
// RegisterUnsafeWithTypes 注册不适合默认嵌入式扫描的插件。
func RegisterUnsafeWithTypes(name string, factory func() Plugin, ports []int, types []string) {
RegisterWithOptions(name, factory, ports, types, false)
}
// RegisterWithOptions 注册带完整元数据的插件。
func RegisterWithOptions(name string, factory func() Plugin, ports []int, types []string, safe bool) {
mutex.Lock()
defer mutex.Unlock()
plugins[name] = &PluginInfo{
factory: factory,
ports: ports,
types: types,
safe: safe,
}
}
@@ -147,6 +159,17 @@ func HasType(pluginName string, typeName string) bool {
return false
}
// IsSafe 检查插件是否适合默认嵌入式扫描。
func IsSafe(pluginName string) bool {
mutex.RLock()
defer mutex.RUnlock()
if info, exists := plugins[pluginName]; exists {
return info.safe
}
return false
}
// Get 获取插件实例
func Get(name string) Plugin {
mutex.RLock()
@@ -190,6 +213,15 @@ func GetPluginPorts(name string) []int {
return []int{} // 返回空列表表示适用于所有端口
}
func hasPluginType(types []string, typeName string) bool {
for _, t := range types {
if t == typeName {
return true
}
}
return false
}
// GenerateCredentials 生成测试凭据
func GenerateCredentials(service string, config *common.Config) []Credential {
var credentials []Credential
+3 -3
View File
@@ -58,7 +58,7 @@ func (p *CleanerPlugin) cleanFiles(output *strings.Builder, dir string, names []
for _, name := range names {
path := filepath.Join(dir, name)
if err := os.Remove(path); err == nil {
output.WriteString(fmt.Sprintf("[清理] %s\n", path))
fmt.Fprintf(output, "[清理] %s\n", path)
cleaned++
}
}
@@ -70,7 +70,7 @@ func (p *CleanerPlugin) cleanGlob(output *strings.Builder, dir, pattern string)
cleaned := 0
for _, f := range matches {
if err := os.Remove(f); err == nil {
output.WriteString(fmt.Sprintf("[清理] %s\n", f))
fmt.Fprintf(output, "[清理] %s\n", f)
cleaned++
}
}
@@ -87,7 +87,7 @@ func (p *CleanerPlugin) cleanUnix(output *strings.Builder) int {
}
for _, hf := range histFiles {
if p.scrubHistory(hf) {
output.WriteString(fmt.Sprintf("[清理] %s 中的 fscan 记录\n", hf))
fmt.Fprintf(output, "[清理] %s 中的 fscan 记录\n", hf)
cleaned++
}
}
+1 -1
View File
@@ -229,7 +229,7 @@ func (p *ActiveMQPlugin) identifyService(ctx context.Context, info *common.HostI
return &ScanResult{
Success: false,
Service: "activemq",
Error: fmt.Errorf("Failed to read response: %w", err),
Error: fmt.Errorf("failed to read response: %w", err),
}
}
if n == 0 {
+3 -2
View File
@@ -169,9 +169,10 @@ var cqlStreamID int16
func cqlSend(conn net.Conn, opcode byte, body []byte) error {
id := cqlStreamID
cqlStreamID++
if cqlStreamID > 32767 {
if cqlStreamID == 32767 {
cqlStreamID = 0
} else {
cqlStreamID++
}
// frame: [1B version|flags] [2B stream] [1B opcode] [4B length] [body]
+10 -8
View File
@@ -107,11 +107,12 @@ func TestSingleCredential(ctx context.Context, cred Credential, authFn AuthFunc)
// ConcurrentTestConfig 并发测试配置
type ConcurrentTestConfig struct {
Concurrency int // 并发数,默认 10
MaxRetries int // 最大重试次数,默认 3
RetryDelay time.Duration // 重试延迟,默认 1s
MaxConsecutiveNetErrors int // 连续网络错误阈值,超过则认为目标不可达,默认 5
TargetAddr string // 目标地址 host:port,用于 TCP 预检(可选)
Concurrency int // 并发数,默认 10
MaxRetries int // 最大重试次数,默认 3
RetryDelay time.Duration // 重试延迟,默认 1s
MaxConsecutiveNetErrors int // 连续网络错误阈值,超过则认为目标不可达,默认 5
TargetAddr string // 目标地址 host:port,用于 TCP 预检(可选)
UseProxy bool // 代理模式下跳过直连 TCP 预检
}
// DefaultConcurrentTestConfig 默认配置
@@ -125,6 +126,7 @@ func DefaultConcurrentTestConfig(config *common.Config) ConcurrentTestConfig {
MaxRetries: 3,
RetryDelay: time.Second,
MaxConsecutiveNetErrors: 5,
UseProxy: config.Network.Socks5Proxy != "" || config.Network.HTTPProxy != "",
}
}
@@ -154,7 +156,7 @@ func TestCredentialsConcurrently(
// TCP 预检:快速验证目标可达,避免对不可达目标浪费全部凭据尝试
// 代理模式下跳过:net.DialTimeout 直连无法到达代理后的内网目标
if testConfig.TargetAddr != "" && !common.IsProxyEnabled() {
if testConfig.TargetAddr != "" && !testConfig.UseProxy {
preConn, err := net.DialTimeout("tcp", testConfig.TargetAddr, 3*time.Second)
if err != nil {
return &ScanResult{
@@ -381,8 +383,8 @@ func ClassifyError(err error, authKeywords, networkKeywords []string) ErrorType
func containsIgnoreCase(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr ||
len(substr) == 0 ||
findIgnoreCase(s, substr) >= 0)
len(substr) == 0 ||
findIgnoreCase(s, substr) >= 0)
}
// findIgnoreCase 忽略大小写查找子串
+27 -1
View File
@@ -268,6 +268,33 @@ func TestTestCredentialsConcurrently_EmptyCredentials(t *testing.T) {
}
}
func TestTestCredentialsConcurrently_ProxySkipsDirectPrecheck(t *testing.T) {
var calls atomic.Int32
authFn := func(ctx context.Context, cred Credential) *AuthResult {
calls.Add(1)
return &AuthResult{
Success: true,
Conn: &mockConn{},
}
}
config := ConcurrentTestConfig{
Concurrency: 1,
MaxRetries: 1,
RetryDelay: time.Millisecond,
TargetAddr: "127.0.0.1:1",
UseProxy: true,
}
result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "u", Password: "p"}}, authFn, "test", config)
if !result.Success {
t.Fatalf("proxy mode should skip direct precheck: %v", result.Error)
}
if calls.Load() == 0 {
t.Fatal("auth function was not called")
}
}
// TestTestCredentialsConcurrently_ContextCancel 测试context取消
func TestTestCredentialsConcurrently_ContextCancel(t *testing.T) {
credentials := make([]Credential, 100)
@@ -451,4 +478,3 @@ func TestRetryLogic_AuthErrorNoRetry(t *testing.T) {
// 确保 mockConn 实现 io.Closer 接口
var _ io.Closer = (*mockConn)(nil)
+11 -7
View File
@@ -28,13 +28,13 @@ func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co
config := session.Config
state := session.State
if config.DisableBrute {
return p.identifyService(info, config, state)
return p.identifyService(info, session)
}
target := info.Target()
// 优先检测匿名访问
if result := p.testAnonymousAccess(ctx, info, config, state); result != nil && result.Success {
if result := p.testAnonymousAccess(ctx, info, session); result != nil && result.Success {
return result
}
@@ -63,7 +63,7 @@ func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co
output.WriteString(fmt.Sprintf("\n [->] %s", file))
}
}
common.LogVuln(output.String())
session.LogVuln(output.String())
}
return result
@@ -144,7 +144,9 @@ func classifyFTPErrorType(err error) ErrorType {
return ClassifyError(err, ftpAuthErrors, ftpNetworkErrors)
}
func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config, state *common.State) *ScanResult {
func (p *FTPPlugin) identifyService(info *common.HostInfo, session *common.ScanSession) *ScanResult {
config := session.Config
state := session.State
target := info.Target()
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
@@ -160,7 +162,7 @@ func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config
defer func() { _ = conn.Quit() }()
banner := "FTP"
common.LogSuccess(i18n.Tr("ftp_service", target, banner))
session.LogSuccess(i18n.Tr("ftp_service", target, banner))
return &ScanResult{
Type: plugins.ResultTypeService,
Success: true,
@@ -170,7 +172,9 @@ func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config
}
// testAnonymousAccess 测试FTP匿名访问
func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult {
func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
config := session.Config
state := session.State
target := info.Target()
anonymousCreds := []Credential{
@@ -204,7 +208,7 @@ func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostIn
output.WriteString(fmt.Sprintf("\n [->] %s", file))
}
}
common.LogVuln(output.String())
session.LogVuln(output.String())
return &ScanResult{
Type: plugins.ResultTypeCredential,
-1
View File
@@ -323,7 +323,6 @@ func mssqlParseLoginTokens(payload []byte, result *mssqlRawResult) (bool, error)
return false, fmt.Errorf("mssql: truncated done token")
}
status := binary.LittleEndian.Uint16(payload[pos : pos+2])
pos += 12
return status&(tdsDoneError|tdsDoneSrvError) == 0, nil
default:
return false, fmt.Errorf("mssql: unexpected login token 0x%02x", token)
+7 -3
View File
@@ -341,8 +341,13 @@ func (s *oracleSession) putString(v string) {
func (s *oracleSession) putInt(v interface{}, size uint8, bigEndian, compress bool) {
num := toInt64(v)
if compress {
neg := num < 0
encoded := uint64(num)
if neg {
encoded = uint64(-(num + 1)) + 1
}
temp := make([]byte, 8)
binary.BigEndian.PutUint64(temp, uint64(num))
binary.BigEndian.PutUint64(temp, encoded)
temp = bytes.TrimLeft(temp, "\x00")
if size > uint8(len(temp)) {
size = uint8(len(temp))
@@ -351,8 +356,7 @@ func (s *oracleSession) putInt(v interface{}, size uint8, bigEndian, compress bo
s.out.WriteByte(0)
return
}
if num < 0 {
num = -num
if neg {
size |= 0x80
}
s.out.WriteByte(size)
+7
View File
@@ -22,3 +22,10 @@ func RegisterWebPlugin(name string, creator func() WebPlugin) {
return creator()
}, []int{}, []string{plugins.PluginTypeWeb})
}
// RegisterUnsafeWebPlugin 注册需要显式授权的主动Web插件。
func RegisterUnsafeWebPlugin(name string, creator func() WebPlugin) {
plugins.RegisterUnsafeWithTypes(name, func() plugins.Plugin {
return creator()
}, []int{}, []string{plugins.PluginTypeWeb})
}
+1 -1
View File
@@ -130,7 +130,7 @@ func matchCDNorWAF(fingerprints []string) string {
// init 自动注册插件
func init() {
RegisterWebPlugin("webpoc", func() WebPlugin {
RegisterUnsafeWebPlugin("webpoc", func() WebPlugin {
return NewWebPocPlugin()
})
}
+1 -1
View File
@@ -243,7 +243,7 @@ func (p *WebTitlePlugin) formatHeaders(headers http.Header) string {
var builder strings.Builder
for name, values := range headers {
for _, value := range values {
builder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
fmt.Fprintf(&builder, "%s: %s\n", name, value)
}
}
return builder.String()