mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
feat: SDK agent integration + UDP plugin framework + SNMP plugin
SDK enhancements for endpoint agent embedding: - ScanWithController for pause/resume and live stats - OnProgress callback for periodic progress reporting - TaskID injection into every scan result - ScanController with goroutine-safe pause/resume/stats - Multi-target stats aggregation (race-free) UDP plugin infrastructure: - PluginTypeUDP registry with dedicated dispatch path - DialUDP on ScanSession with rate limiting and packet counting - UDP plugins bypass TCP port scan, probe targets directly - FilterService excludes UDP plugins from TCP port matching SNMP plugin (first UDP plugin): - SNMPv2c GetRequest probe for sysDescr detection - Community string brute force (public/private/community/etc) - Pure stdlib implementation (encoding/asn1) - Registered as safe default plugin on port 161/UDP Tests: 95.7% SDK coverage, race-free, 50+ new test cases
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
- **并发控制** - 端口扫描线程、服务扫描线程独立配置
|
- **并发控制** - 端口扫描线程、服务扫描线程独立配置
|
||||||
|
|
||||||
### 扩展功能
|
### 扩展功能
|
||||||
|
- **SDK嵌入** - `pkg/fscan`提供Go SDK,可嵌入Agent或安全平台,支持任务控制(Pause/Resume)、实时进度回调、TaskID追溯
|
||||||
- **Web管理界面** - 可视化扫描任务管理(条件编译 -tags web)
|
- **Web管理界面** - 可视化扫描任务管理(条件编译 -tags web)
|
||||||
- **Lab靶场环境** - 内置Docker靶场用于测试学习
|
- **Lab靶场环境** - 内置Docker靶场用于测试学习
|
||||||
- **插件化架构** - 服务插件/Web插件/本地插件分离,易于扩展
|
- **插件化架构** - 服务插件/Web插件/本地插件分离,易于扩展
|
||||||
@@ -234,6 +235,11 @@ yay -S fscan-git
|
|||||||
- **后两周** - Bug修复与代码整合
|
- **后两周** - Bug修复与代码整合
|
||||||
- **欢迎PR** - 期待您的贡献!
|
- **欢迎PR** - 期待您的贡献!
|
||||||
|
|
||||||
|
### SDK & Agent 集成
|
||||||
|
- 扩展SDK能力,完善端侧Agent嵌入支持
|
||||||
|
- 断点续扫、带宽级限速、内存水位控制
|
||||||
|
- 更多Agent场景的集成示例
|
||||||
|
|
||||||
### 插件生态
|
### 插件生态
|
||||||
- 持续扩展服务插件覆盖范围
|
- 持续扩展服务插件覆盖范围
|
||||||
- 为每个服务插件开发更多漏洞检测和利用能力
|
- 为每个服务插件开发更多漏洞检测和利用能力
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type ScanSession struct {
|
|||||||
State *State // 可变,原子操作,每会话独立
|
State *State // 可变,原子操作,每会话独立
|
||||||
Params *FlagVars // 原始参数,只读
|
Params *FlagVars // 原始参数,只读
|
||||||
ResultSink ResultSink // 可选,覆盖全局输出
|
ResultSink ResultSink // 可选,覆盖全局输出
|
||||||
|
PauseGate func(ctx context.Context) error
|
||||||
|
|
||||||
// 每会话 dialer(按 timeout 懒初始化,取决于代理配置)
|
// 每会话 dialer(按 timeout 懒初始化,取决于代理配置)
|
||||||
dialerMu sync.Mutex
|
dialerMu sync.Mutex
|
||||||
@@ -120,6 +121,23 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DialUDP creates a connected UDP socket with rate limiting and packet counting.
|
||||||
|
// UDP cannot be proxied; if a proxy is configured the connection is made directly.
|
||||||
|
func (s *ScanSession) DialUDP(ctx context.Context, address string, timeout time.Duration) (net.Conn, error) {
|
||||||
|
if ok, err := CanSendPacketWith(s.Config, s.State); !ok {
|
||||||
|
return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("udp", address, timeout)
|
||||||
|
if err != nil {
|
||||||
|
s.State.IncrementUDPPacketCount()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||||
|
s.State.IncrementUDPPacketCount()
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
// HTTPDo executes an HTTP request with the session's packet limits and counters.
|
// 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) {
|
func (s *ScanSession) HTTPDo(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||||
if ok, err := CanSendPacketWith(s.Config, s.State); !ok {
|
if ok, err := CanSendPacketWith(s.Config, s.State); !ok {
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ func (b *BaseScanStrategy) isLocalPlugin(pluginName string) bool {
|
|||||||
return plugins.HasType(pluginName, plugins.PluginTypeLocal)
|
return plugins.HasType(pluginName, plugins.PluginTypeLocal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *BaseScanStrategy) isUDPPlugin(pluginName string) bool {
|
||||||
|
return plugins.IsUDP(pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *BaseScanStrategy) isLocalPluginExplicitlySpecified(pluginName string, config *common.Config) bool {
|
func (b *BaseScanStrategy) isLocalPluginExplicitlySpecified(pluginName string, config *common.Config) bool {
|
||||||
return config.LocalPlugin == pluginName
|
return config.LocalPlugin == pluginName
|
||||||
}
|
}
|
||||||
@@ -141,6 +145,11 @@ func (b *BaseScanStrategy) isPluginApplicableToPort(pluginName string, targetPor
|
|||||||
|
|
||||||
// isPluginPassesFilterType 检查插件是否通过过滤器类型检查
|
// isPluginPassesFilterType 检查插件是否通过过滤器类型检查
|
||||||
func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomMode bool, config *common.Config) bool {
|
func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomMode bool, config *common.Config) bool {
|
||||||
|
// UDP 插件有独立分发路径,不参与 TCP 端口匹配流水线
|
||||||
|
if b.isUDPPlugin(pluginName) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 自定义模式下强制运行所有明确指定的插件
|
// 自定义模式下强制运行所有明确指定的插件
|
||||||
if isCustomMode {
|
if isCustomMode {
|
||||||
return true
|
return true
|
||||||
@@ -155,8 +164,8 @@ func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomM
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
case FilterService:
|
case FilterService:
|
||||||
// 服务扫描策略:排除本地插件
|
// 服务扫描策略:排除本地插件和UDP插件(UDP有独立分发路径)
|
||||||
return !b.isLocalPlugin(pluginName)
|
return !b.isLocalPlugin(pluginName) && !b.isUDPPlugin(pluginName)
|
||||||
case FilterWeb:
|
case FilterWeb:
|
||||||
// Web扫描策略:只允许Web插件
|
// Web扫描策略:只允许Web插件
|
||||||
return b.isWebPlugin(pluginName)
|
return b.isWebPlugin(pluginName)
|
||||||
@@ -231,9 +240,9 @@ func (b *BaseScanStrategy) getPluginsByFilterType() []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case FilterService:
|
case FilterService:
|
||||||
// 服务扫描策略:排除本地插件和纯Web插件,保留服务插件
|
// 服务扫描策略:排除本地插件和UDP插件,保留TCP服务插件
|
||||||
for _, pluginName := range allPlugins {
|
for _, pluginName := range allPlugins {
|
||||||
if !b.isLocalPlugin(pluginName) {
|
if !b.isLocalPlugin(pluginName) && !b.isUDPPlugin(pluginName) {
|
||||||
filteredPlugins = append(filteredPlugins, pluginName)
|
filteredPlugins = append(filteredPlugins, pluginName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ func ExecuteScanTasks(ctx context.Context, session *common.ScanSession, targets
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if session.PauseGate != nil {
|
||||||
|
if err := session.PauseGate(ctx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
targetPort := target.Port
|
targetPort := target.Port
|
||||||
|
|
||||||
for _, pluginName := range pluginsToRun {
|
for _, pluginName := range pluginsToRun {
|
||||||
@@ -262,6 +268,12 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if session.PauseGate != nil {
|
||||||
|
if err := session.PauseGate(ctx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 长驻插件不进 WaitGroup,通过 ctx 管理生命周期
|
// 长驻插件不进 WaitGroup,通过 ctx 管理生命周期
|
||||||
if longRunningPlugins[pluginName] {
|
if longRunningPlugins[pluginName] {
|
||||||
ready := make(chan struct{}, 1)
|
ready := make(chan struct{}, 1)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/shadow1ng/fscan/common"
|
"github.com/shadow1ng/fscan/common"
|
||||||
"github.com/shadow1ng/fscan/common/i18n"
|
"github.com/shadow1ng/fscan/common/i18n"
|
||||||
"github.com/shadow1ng/fscan/common/parsers"
|
"github.com/shadow1ng/fscan/common/parsers"
|
||||||
|
"github.com/shadow1ng/fscan/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceScanStrategy 服务扫描策略
|
// ServiceScanStrategy 服务扫描策略
|
||||||
@@ -161,6 +162,11 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UDP 插件并行分发:直接对存活主机发协议探测包,不走端口扫描
|
||||||
|
if len(hosts) > 0 {
|
||||||
|
s.dispatchUDPPlugins(ctx, session, hosts, info, config, ch, wg)
|
||||||
|
}
|
||||||
|
|
||||||
// 流式 channel:端口扫描发现开放端口后立即通知插件执行
|
// 流式 channel:端口扫描发现开放端口后立即通知插件执行
|
||||||
stream := make(chan string, 64)
|
stream := make(chan string, 64)
|
||||||
|
|
||||||
@@ -213,6 +219,34 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchUDPPlugins 分发UDP协议插件,跳过TCP端口扫描链路
|
||||||
|
func (s *ServiceScanStrategy) dispatchUDPPlugins(ctx context.Context, session *common.ScanSession, hosts []string, baseInfo common.HostInfo, config *common.Config, ch chan struct{}, wg *sync.WaitGroup) {
|
||||||
|
allPlugins, isCustomMode := s.GetPlugins(config)
|
||||||
|
|
||||||
|
var udpPlugins []string
|
||||||
|
for _, name := range allPlugins {
|
||||||
|
if plugins.IsUDP(name) {
|
||||||
|
if isCustomMode || plugins.IsSafe(name) {
|
||||||
|
udpPlugins = append(udpPlugins, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(udpPlugins) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, host := range hosts {
|
||||||
|
for _, pluginName := range udpPlugins {
|
||||||
|
for _, port := range plugins.GetPluginPorts(pluginName) {
|
||||||
|
target := baseInfo
|
||||||
|
target.Host = host
|
||||||
|
target.Port = port
|
||||||
|
executeScanTask(ctx, session, pluginName, target, ch, wg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// PrepareTargets 准备目标信息
|
// PrepareTargets 准备目标信息
|
||||||
func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *common.ScanSession) []common.HostInfo {
|
func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *common.ScanSession) []common.HostInfo {
|
||||||
// 发现目标主机和端口
|
// 发现目标主机和端口
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
fscan "github.com/shadow1ng/fscan/pkg/fscan"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
scanner := fscan.NewScanner(fscan.Config{
|
||||||
|
TaskID: "task-001",
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
Threads: 64,
|
||||||
|
DisablePing: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Plugins: []string{"ssh", "mysql", "redis", "ftp"},
|
||||||
|
OnProgress: func(p fscan.ScanProgress) {
|
||||||
|
fmt.Printf("[progress] %d/%d tasks, %d packets, paused=%v, elapsed=%s\n",
|
||||||
|
p.TasksCompleted, p.TasksTotal, p.Packets, p.Paused, p.Duration.Round(time.Millisecond))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ctrl, reportCh, errCh := scanner.ScanWithController(ctx,
|
||||||
|
fscan.Target{Host: "127.0.0.1", Ports: []int{21, 22, 3306, 6379}},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulate a pause command from control plane after 1 second.
|
||||||
|
go func() {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
fmt.Println("[agent] pausing scan...")
|
||||||
|
ctrl.Pause()
|
||||||
|
|
||||||
|
// Check live stats while paused.
|
||||||
|
stats := ctrl.Stats()
|
||||||
|
fmt.Printf("[agent] stats while paused: completed=%d, packets=%d\n",
|
||||||
|
stats.TasksCompleted, stats.Packets)
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
fmt.Println("[agent] resuming scan...")
|
||||||
|
ctrl.Resume()
|
||||||
|
}()
|
||||||
|
|
||||||
|
report := <-reportCh
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
fmt.Printf("[agent] scan error: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n[agent] scan complete: %d results, %d vulns, %d services\n",
|
||||||
|
report.Summary.Total, report.Summary.Vulns, report.Summary.Services)
|
||||||
|
|
||||||
|
for _, result := range report.Results {
|
||||||
|
taskID, _ := result.DetailString("task_id")
|
||||||
|
fmt.Printf(" [%s] %s %s (task=%s)\n", result.Type, result.Target, result.Status, taskID)
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
-46
@@ -1,71 +1,148 @@
|
|||||||
# fscan SDK
|
# fscan SDK
|
||||||
|
|
||||||
`pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged.
|
`pkg/fscan` exposes fscan as an embeddable Go scanner, designed for Agent and security platform integration.
|
||||||
|
|
||||||
See `examples/embed-basic` for slice-based collection and `examples/embed-stream` for streaming integration.
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
import fscan "github.com/shadow1ng/fscan/pkg/fscan"
|
||||||
|
|
||||||
import (
|
scanner := fscan.NewScanner(fscan.Config{
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
fscan "github.com/shadow1ng/fscan/pkg/fscan"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
config := fscan.Config{
|
|
||||||
Timeout: 3 * time.Second,
|
Timeout: 3 * time.Second,
|
||||||
Threads: 128,
|
Threads: 128,
|
||||||
DisablePing: true,
|
DisablePing: true,
|
||||||
DisableBrute: true,
|
|
||||||
Plugins: []string{"ssh", "mysql", "redis"},
|
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)
|
results, err := scanner.Scan(context.Background(), fscan.Target{
|
||||||
report, err := scanner.ScanReport(context.Background(), fscan.Target{
|
Host: "192.168.1.0/24",
|
||||||
Host: "192.168.1.10",
|
|
||||||
Ports: []int{22, 3306, 6379},
|
Ports: []int{22, 3306, 6379},
|
||||||
})
|
})
|
||||||
if err != nil {
|
```
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("scan finished: %+v stats=%+v\n", report.Summary, report.Stats)
|
## Scan Modes
|
||||||
for _, result := range report.Results {
|
|
||||||
// Store, forward, or filter the result in the embedding system.
|
| Mode | API | Use Case |
|
||||||
if credential, ok := result.AsCredential(); ok {
|
|------|-----|----------|
|
||||||
fmt.Printf("weak credential: %s %s:%s\n", credential.Target, credential.Username, credential.Password)
|
| Collect | `Scan` / `ScanReport` | Get all results as a slice |
|
||||||
}
|
| Stream | `ScanEach` | Process results one-by-one, no memory accumulation |
|
||||||
|
| Controlled | `ScanWithController` | Agent integration with pause/resume and live stats |
|
||||||
|
|
||||||
|
### Basic: Collect All Results
|
||||||
|
|
||||||
|
```go
|
||||||
|
scanner := fscan.NewScanner(config)
|
||||||
|
report, err := scanner.ScanReport(ctx, target)
|
||||||
|
|
||||||
|
fmt.Printf("total=%d vulns=%d\n", report.Summary.Total, report.Summary.Vulns)
|
||||||
|
for _, r := range report.Results {
|
||||||
|
if cred, ok := r.AsCredential(); ok {
|
||||||
|
fmt.Printf("%s %s:%s\n", cred.Target, cred.Username, cred.Password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The SDK currently reuses fscan's existing scan core and plugin registry. Embedded scans build per-session runtime state and can run concurrently.
|
### Stream: Process Results Without Retention
|
||||||
|
|
||||||
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.
|
```go
|
||||||
|
scanner := fscan.NewScanner(config)
|
||||||
|
err := scanner.ScanEach(ctx, func(result fscan.Result) error {
|
||||||
|
// Forward to database, message queue, etc.
|
||||||
|
return sendToBackend(result)
|
||||||
|
}, target)
|
||||||
|
```
|
||||||
|
|
||||||
## API surface
|
### Controlled: Agent Integration
|
||||||
|
|
||||||
|
```go
|
||||||
|
scanner := fscan.NewScanner(fscan.Config{
|
||||||
|
TaskID: "task-001",
|
||||||
|
Plugins: []string{"ssh", "redis"},
|
||||||
|
OnProgress: func(p fscan.ScanProgress) {
|
||||||
|
reportHeartbeat(p.TasksCompleted, p.TasksTotal, p.Paused)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctrl, reportCh, errCh := scanner.ScanWithController(ctx, target)
|
||||||
|
|
||||||
|
// Control plane commands
|
||||||
|
ctrl.Pause()
|
||||||
|
stats := ctrl.Stats() // live stats while paused
|
||||||
|
ctrl.Resume()
|
||||||
|
|
||||||
|
report := <-reportCh
|
||||||
|
err := <-errCh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent Features
|
||||||
|
|
||||||
|
### ScanController
|
||||||
|
|
||||||
|
`ScanWithController` returns a `*ScanController` for runtime control:
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `Pause()` | Pause task dispatch (in-flight tasks complete naturally) |
|
||||||
|
| `Resume()` | Resume task dispatch |
|
||||||
|
| `IsPaused()` | Check pause state |
|
||||||
|
| `Stats()` | Live `ScanStats` aggregated across all targets |
|
||||||
|
|
||||||
|
The controller is goroutine-safe. Pause takes effect at the task dispatch level -- already-running plugin tasks will finish, but no new tasks are dispatched until resumed.
|
||||||
|
|
||||||
|
### OnProgress
|
||||||
|
|
||||||
|
Set `Config.OnProgress` to receive periodic `ScanProgress` snapshots (~500ms interval):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ScanProgress struct {
|
||||||
|
TasksTotal int64
|
||||||
|
TasksCompleted int64
|
||||||
|
Duration time.Duration
|
||||||
|
Packets int64
|
||||||
|
TCPPackets int64
|
||||||
|
HTTPPackets int64
|
||||||
|
Paused bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Works with all scan modes. When used without `ScanWithController`, a lightweight internal controller is created for progress tracking.
|
||||||
|
|
||||||
|
### TaskID
|
||||||
|
|
||||||
|
Set `Config.TaskID` to inject a task identifier into every `Result.Details["task_id"]`. This lets the Agent associate scan results with control plane tasks without post-processing.
|
||||||
|
|
||||||
|
## Plugin Safety
|
||||||
|
|
||||||
|
By default, the SDK runs a conservative plugin set (service detection + auth check). Plugins with local side effects (`poc`, `local-effect`) are blocked unless `AllowUnsafePlugins` is set.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// List available plugins
|
||||||
|
for _, p := range fscan.ListPlugins() {
|
||||||
|
fmt.Printf("%s safe=%v caps=%v\n", p.Name, p.Safe, p.Capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check before use
|
||||||
|
if fscan.IsSafePlugin("webpoc") { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin capabilities: `detect`, `auth-check`, `brute`, `poc`, `local-effect`.
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
| Area | API |
|
| Area | API |
|
||||||
| --- | --- |
|
|------|-----|
|
||||||
| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport` |
|
| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport`, `ScanWithController` |
|
||||||
|
| Control | `ScanController` (`Pause`, `Resume`, `IsPaused`, `Stats`) |
|
||||||
| Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` |
|
| Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` |
|
||||||
| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities`, `PluginInfo` |
|
| Progress | `OnProgress`, `ScanProgress`, `TaskID` |
|
||||||
| Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` |
|
| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities` |
|
||||||
| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential`, `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` |
|
| Results | `Result`, `ResultType*` constants |
|
||||||
| Summary and stats | `ScanReport`, `ScanStats`, `SummarizeResults`, `ResultSummary.Add` |
|
| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential` |
|
||||||
|
| Typed views | `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` |
|
||||||
|
| Summary | `ScanReport`, `ScanStats`, `SummarizeResults`, `ResultSummary.Add` |
|
||||||
|
|
||||||
Use `Scan` when you want all results returned as a slice. Use `ScanReport` when the embedding system also needs summary counts and runtime counters. 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.
|
## Examples
|
||||||
|
|
||||||
Plugin capabilities are exposed as stable strings: `detect`, `auth-check`, `brute`, `poc`, and `local-effect`. Default embedded safe mode blocks `poc` and `local-effect` plugins unless `AllowUnsafePlugins` is set.
|
- [`examples/embed-basic`](../../examples/embed-basic) -- Minimal scan with result collection
|
||||||
|
- [`examples/embed-stream`](../../examples/embed-stream) -- Streaming results with plugin listing
|
||||||
|
- [`examples/embed-agent`](../../examples/embed-agent) -- Agent integration with pause/resume, progress, and TaskID
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package fscan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanController provides pause/resume control and live stats for an
|
||||||
|
// in-progress scan. It is safe for concurrent use.
|
||||||
|
type ScanController struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
paused int32
|
||||||
|
gate chan struct{}
|
||||||
|
stateMu sync.Mutex
|
||||||
|
states []*common.State
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newScanController() *ScanController {
|
||||||
|
gate := make(chan struct{})
|
||||||
|
close(gate)
|
||||||
|
return &ScanController{
|
||||||
|
gate: gate,
|
||||||
|
start: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScanController) Pause() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if atomic.CompareAndSwapInt32(&c.paused, 0, 1) {
|
||||||
|
c.gate = make(chan struct{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScanController) Resume() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if atomic.CompareAndSwapInt32(&c.paused, 1, 0) {
|
||||||
|
close(c.gate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScanController) IsPaused() bool {
|
||||||
|
return atomic.LoadInt32(&c.paused) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScanController) pauseGate(ctx context.Context) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
gate := c.gate
|
||||||
|
c.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-gate:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScanController) addState(s *common.State) {
|
||||||
|
c.stateMu.Lock()
|
||||||
|
c.states = append(c.states, s)
|
||||||
|
c.stateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScanController) Stats() ScanStats {
|
||||||
|
c.stateMu.Lock()
|
||||||
|
states := c.states
|
||||||
|
c.stateMu.Unlock()
|
||||||
|
|
||||||
|
stats := ScanStats{Duration: time.Since(c.start)}
|
||||||
|
for _, s := range states {
|
||||||
|
stats.TasksTotal += s.GetEnd()
|
||||||
|
stats.TasksCompleted += s.GetNum()
|
||||||
|
stats.Packets += s.GetPacketCount()
|
||||||
|
stats.TCPPackets += s.GetTCPPacketCount()
|
||||||
|
stats.TCPSuccessPackets += s.GetTCPSuccessPacketCount()
|
||||||
|
stats.TCPFailedPackets += s.GetTCPFailedPacketCount()
|
||||||
|
stats.UDPPackets += s.GetUDPPacketCount()
|
||||||
|
stats.HTTPPackets += s.GetHTTPPacketCount()
|
||||||
|
stats.ResourceExhausted += s.GetResourceExhaustedCount()
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScanController) progress() ScanProgress {
|
||||||
|
stats := c.Stats()
|
||||||
|
return ScanProgress{
|
||||||
|
TasksTotal: stats.TasksTotal,
|
||||||
|
TasksCompleted: stats.TasksCompleted,
|
||||||
|
Duration: stats.Duration,
|
||||||
|
Packets: stats.Packets,
|
||||||
|
TCPPackets: stats.TCPPackets,
|
||||||
|
HTTPPackets: stats.HTTPPackets,
|
||||||
|
Paused: c.IsPaused(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package fscan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScanControllerPauseResume(t *testing.T) {
|
||||||
|
ctrl := newScanController()
|
||||||
|
|
||||||
|
if ctrl.IsPaused() {
|
||||||
|
t.Fatal("new controller should not be paused")
|
||||||
|
}
|
||||||
|
ctrl.Pause()
|
||||||
|
if !ctrl.IsPaused() {
|
||||||
|
t.Fatal("should be paused after Pause()")
|
||||||
|
}
|
||||||
|
ctrl.Pause()
|
||||||
|
if !ctrl.IsPaused() {
|
||||||
|
t.Fatal("double Pause should still be paused")
|
||||||
|
}
|
||||||
|
ctrl.Resume()
|
||||||
|
if ctrl.IsPaused() {
|
||||||
|
t.Fatal("should not be paused after Resume()")
|
||||||
|
}
|
||||||
|
ctrl.Resume()
|
||||||
|
if ctrl.IsPaused() {
|
||||||
|
t.Fatal("double Resume should still be unpaused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanControllerPauseGateBlocks(t *testing.T) {
|
||||||
|
ctrl := newScanController()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := ctrl.pauseGate(ctx); err != nil {
|
||||||
|
t.Fatalf("unpaused gate should not block: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl.Pause()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- ctrl.pauseGate(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("paused gate should block")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
ctrl.Resume()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resumed gate error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("gate should unblock after Resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanControllerPauseGateContextCancel(t *testing.T) {
|
||||||
|
ctrl := newScanController()
|
||||||
|
ctrl.Pause()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- ctrl.pauseGate(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Fatalf("gate error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("gate should return on context cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanControllerStatsWithoutState(t *testing.T) {
|
||||||
|
ctrl := newScanController()
|
||||||
|
stats := ctrl.Stats()
|
||||||
|
if stats.Duration <= 0 {
|
||||||
|
t.Fatal("duration should be positive")
|
||||||
|
}
|
||||||
|
if stats.TasksTotal != 0 || stats.Packets != 0 {
|
||||||
|
t.Fatalf("stats without state should be zero: %+v", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanControllerProgress(t *testing.T) {
|
||||||
|
ctrl := newScanController()
|
||||||
|
ctrl.Pause()
|
||||||
|
p := ctrl.progress()
|
||||||
|
if !p.Paused {
|
||||||
|
t.Fatal("progress should report paused")
|
||||||
|
}
|
||||||
|
ctrl.Resume()
|
||||||
|
p = ctrl.progress()
|
||||||
|
if p.Paused {
|
||||||
|
t.Fatal("progress should report unpaused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanWithControllerCompletes(t *testing.T) {
|
||||||
|
listener := startTestFTPListener(t)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
scanner := NewScanner(Config{
|
||||||
|
DisablePing: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Timeout: time.Second,
|
||||||
|
Threads: 16,
|
||||||
|
Plugins: []string{"ftp"},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ctrl, reportCh, errCh := scanner.ScanWithController(ctx, Target{Host: "127.0.0.1", Ports: []int{port}})
|
||||||
|
|
||||||
|
stats := ctrl.Stats()
|
||||||
|
if stats.Duration <= 0 {
|
||||||
|
t.Fatal("live stats duration should be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
report := <-reportCh
|
||||||
|
err := <-errCh
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(report.Results) == 0 {
|
||||||
|
t.Fatal("expected results")
|
||||||
|
}
|
||||||
|
if report.Summary.Total != len(report.Results) {
|
||||||
|
t.Fatalf("summary mismatch: %+v", report.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanWithControllerPauseResume(t *testing.T) {
|
||||||
|
first := startTestFTPListener(t)
|
||||||
|
defer first.Close()
|
||||||
|
second := startTestFTPListener(t)
|
||||||
|
defer second.Close()
|
||||||
|
|
||||||
|
port1 := first.Addr().(*net.TCPAddr).Port
|
||||||
|
port2 := second.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
scanner := NewScanner(Config{
|
||||||
|
DisablePing: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Timeout: time.Second,
|
||||||
|
Threads: 16,
|
||||||
|
Plugins: []string{"ftp"},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ctrl, reportCh, errCh := scanner.ScanWithController(ctx,
|
||||||
|
Target{Host: "127.0.0.1", Ports: []int{port1}},
|
||||||
|
Target{Host: "127.0.0.1", Ports: []int{port2}},
|
||||||
|
)
|
||||||
|
|
||||||
|
ctrl.Pause()
|
||||||
|
if !ctrl.IsPaused() {
|
||||||
|
t.Fatal("should be paused")
|
||||||
|
}
|
||||||
|
ctrl.Resume()
|
||||||
|
|
||||||
|
report := <-reportCh
|
||||||
|
err := <-errCh
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(report.Results) == 0 {
|
||||||
|
t.Fatal("expected results after resume")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnProgressCalled(t *testing.T) {
|
||||||
|
listener := startTestFTPListener(t)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
var called int32
|
||||||
|
scanner := NewScanner(Config{
|
||||||
|
DisablePing: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Timeout: time.Second,
|
||||||
|
Threads: 16,
|
||||||
|
Plugins: []string{"ftp"},
|
||||||
|
OnProgress: func(p ScanProgress) {
|
||||||
|
atomic.AddInt32(&called, 1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
port := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
_, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// OnProgress fires every 500ms; scan takes at least a moment
|
||||||
|
// We mainly verify it doesn't panic; calls may be 0 for very fast scans
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskIDInjected(t *testing.T) {
|
||||||
|
listener := startTestFTPListener(t)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(Config{
|
||||||
|
DisablePing: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Timeout: time.Second,
|
||||||
|
Threads: 16,
|
||||||
|
Plugins: []string{"ftp"},
|
||||||
|
TaskID: "task-abc-123",
|
||||||
|
})
|
||||||
|
|
||||||
|
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 results")
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
taskID, ok := r.DetailString("task_id")
|
||||||
|
if !ok || taskID != "task-abc-123" {
|
||||||
|
t.Fatalf("result missing task_id: %#v", r.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskIDNotInjectedWhenEmpty(t *testing.T) {
|
||||||
|
listener := startTestFTPListener(t)
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(Config{
|
||||||
|
DisablePing: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Timeout: time.Second,
|
||||||
|
Threads: 16,
|
||||||
|
Plugins: []string{"ftp"},
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
for _, r := range results {
|
||||||
|
if _, ok := r.Details["task_id"]; ok {
|
||||||
|
t.Fatalf("task_id should not be present when TaskID is empty: %#v", r.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanWithControllerCanceledContext(t *testing.T) {
|
||||||
|
scanner := NewScanner(Config{
|
||||||
|
DisablePing: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Timeout: time.Second,
|
||||||
|
Plugins: []string{"redis"},
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, reportCh, errCh := scanner.ScanWithController(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}})
|
||||||
|
<-reportCh
|
||||||
|
err := <-errCh
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Fatalf("error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTestFTPListener(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
|
||||||
|
}
|
||||||
@@ -2,7 +2,10 @@ package fscan
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResultHelpers(t *testing.T) {
|
func TestResultHelpers(t *testing.T) {
|
||||||
@@ -57,6 +60,22 @@ func TestResultPortDoesNotParseBareIPv6(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResultPortFromSimpleTarget(t *testing.T) {
|
||||||
|
result := Result{Target: "10.0.0.1:3306"}
|
||||||
|
|
||||||
|
port, ok := result.Port()
|
||||||
|
if !ok || port != 3306 {
|
||||||
|
t.Fatalf("Port = %d/%v, want 3306/true", port, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultPortNoPort(t *testing.T) {
|
||||||
|
result := Result{Target: "10.0.0.1"}
|
||||||
|
if _, ok := result.Port(); ok {
|
||||||
|
t.Fatal("expected no port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResultCredentialHelpers(t *testing.T) {
|
func TestResultCredentialHelpers(t *testing.T) {
|
||||||
result := Result{
|
result := Result{
|
||||||
Type: ResultTypeVuln,
|
Type: ResultTypeVuln,
|
||||||
@@ -83,6 +102,31 @@ func TestResultCredentialHelpers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResultCredentialViaStatusPrefix(t *testing.T) {
|
||||||
|
result := Result{
|
||||||
|
Type: ResultTypeVuln,
|
||||||
|
Target: "127.0.0.1:22",
|
||||||
|
Status: "weak_credential: root:pass",
|
||||||
|
}
|
||||||
|
if !result.IsCredential() {
|
||||||
|
t.Fatal("expected credential via status prefix")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultNotCredentialWithoutMarker(t *testing.T) {
|
||||||
|
result := Result{
|
||||||
|
Type: ResultTypeVuln,
|
||||||
|
Target: "127.0.0.1:445",
|
||||||
|
Status: "MS17-010",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"vulnerability": "MS17-010",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if result.IsCredential() {
|
||||||
|
t.Fatal("vuln without credential marker should not be credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTypedResultViews(t *testing.T) {
|
func TestTypedResultViews(t *testing.T) {
|
||||||
portResult, ok := (Result{
|
portResult, ok := (Result{
|
||||||
Type: ResultTypePort,
|
Type: ResultTypePort,
|
||||||
@@ -138,6 +182,84 @@ func TestTypedResultViews(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAsPortNonPortResult(t *testing.T) {
|
||||||
|
_, ok := (Result{Type: ResultTypeHost, Target: "10.0.0.1"}).AsPort()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsPort should return false for non-port result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsPortNoPortValue(t *testing.T) {
|
||||||
|
_, ok := (Result{Type: ResultTypePort, Target: "10.0.0.1"}).AsPort()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsPort should return false when no port available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsServiceNonServiceResult(t *testing.T) {
|
||||||
|
_, ok := (Result{Type: ResultTypePort, Target: "10.0.0.1"}).AsService()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsService should return false for non-service result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsServiceNoUsefulFields(t *testing.T) {
|
||||||
|
_, ok := (Result{Type: ResultTypeService, Target: "10.0.0.1"}).AsService()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsService should return false when no useful service fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsCredentialNonCredential(t *testing.T) {
|
||||||
|
_, ok := (Result{
|
||||||
|
Type: ResultTypeVuln,
|
||||||
|
Target: "10.0.0.1:445",
|
||||||
|
Details: map[string]interface{}{"vulnerability": "MS17-010"},
|
||||||
|
}).AsCredential()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsCredential should return false for non-credential vuln")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsCredentialNoUsernamePassword(t *testing.T) {
|
||||||
|
_, ok := (Result{
|
||||||
|
Type: ResultTypeVuln,
|
||||||
|
Target: "10.0.0.1:22",
|
||||||
|
Status: "weak_credential: ???",
|
||||||
|
Details: map[string]interface{}{},
|
||||||
|
}).AsCredential()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsCredential should return false without username/password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsVulnerabilityCredentialExcluded(t *testing.T) {
|
||||||
|
_, ok := (Result{
|
||||||
|
Type: ResultTypeVuln,
|
||||||
|
Target: "10.0.0.1:22",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"type": "weak_credential",
|
||||||
|
"vulnerability": "ssh weak password",
|
||||||
|
"username": "root",
|
||||||
|
"password": "toor",
|
||||||
|
},
|
||||||
|
}).AsVulnerability()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsVulnerability should exclude credential results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsVulnerabilityEmptyVulnField(t *testing.T) {
|
||||||
|
_, ok := (Result{
|
||||||
|
Type: ResultTypeVuln,
|
||||||
|
Target: "10.0.0.1:445",
|
||||||
|
Details: map[string]interface{}{},
|
||||||
|
}).AsVulnerability()
|
||||||
|
if ok {
|
||||||
|
t.Fatal("AsVulnerability should return false without vulnerability field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSummarizeResults(t *testing.T) {
|
func TestSummarizeResults(t *testing.T) {
|
||||||
results := []Result{
|
results := []Result{
|
||||||
{Type: ResultTypeHost, Target: "127.0.0.1"},
|
{Type: ResultTypeHost, Target: "127.0.0.1"},
|
||||||
@@ -161,6 +283,13 @@ func TestSummarizeResults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSummarizeEmpty(t *testing.T) {
|
||||||
|
summary := SummarizeResults(nil)
|
||||||
|
if summary.Total != 0 {
|
||||||
|
t.Fatalf("Total = %d, want 0", summary.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResultDetailIntRejectsFraction(t *testing.T) {
|
func TestResultDetailIntRejectsFraction(t *testing.T) {
|
||||||
result := Result{Details: map[string]interface{}{"port": 22.5}}
|
result := Result{Details: map[string]interface{}{"port": 22.5}}
|
||||||
|
|
||||||
@@ -177,3 +306,413 @@ func TestResultDetailIntParsesJSONNumber(t *testing.T) {
|
|||||||
t.Fatalf("DetailInt = %d/%v, want 443/true", port, ok)
|
t.Fatalf("DetailInt = %d/%v, want 443/true", port, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetailIntTypes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value interface{}
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"int", int(42), 42, true},
|
||||||
|
{"int8", int8(8), 8, true},
|
||||||
|
{"int16", int16(16), 16, true},
|
||||||
|
{"int32", int32(32), 32, true},
|
||||||
|
{"int64", int64(64), 64, true},
|
||||||
|
{"uint", uint(10), 10, true},
|
||||||
|
{"uint8", uint8(8), 8, true},
|
||||||
|
{"uint16", uint16(16), 16, true},
|
||||||
|
{"uint32", uint32(32), 32, true},
|
||||||
|
{"uint64", uint64(64), 64, true},
|
||||||
|
{"float32", float32(80), 80, true},
|
||||||
|
{"float64", float64(443), 443, true},
|
||||||
|
{"string", "8080", 8080, true},
|
||||||
|
{"string-spaces", " 22 ", 22, true},
|
||||||
|
{"string-invalid", "abc", 0, false},
|
||||||
|
{"nil", nil, 0, false},
|
||||||
|
{"bool", true, 0, false},
|
||||||
|
{"missing", nil, 0, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"v": tt.value}}
|
||||||
|
if tt.name == "missing" {
|
||||||
|
r = Result{Details: map[string]interface{}{}}
|
||||||
|
}
|
||||||
|
got, ok := r.DetailInt("v")
|
||||||
|
if ok != tt.ok || got != tt.want {
|
||||||
|
t.Fatalf("DetailInt = %d/%v, want %d/%v", got, ok, tt.want, tt.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailIntOverflow(t *testing.T) {
|
||||||
|
if _, ok := (Result{Details: map[string]interface{}{"v": uint64(math.MaxUint64)}}).DetailInt("v"); ok {
|
||||||
|
t.Fatal("uint64 max should overflow")
|
||||||
|
}
|
||||||
|
if _, ok := (Result{Details: map[string]interface{}{"v": float64(1.5)}}).DetailInt("v"); ok {
|
||||||
|
t.Fatal("non-integer float should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testStringer struct{ s string }
|
||||||
|
|
||||||
|
func (ts testStringer) String() string { return ts.s }
|
||||||
|
|
||||||
|
func TestDetailStringStringer(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"k": testStringer{"hello"}}}
|
||||||
|
v, ok := r.DetailString("k")
|
||||||
|
if !ok || v != "hello" {
|
||||||
|
t.Fatalf("DetailString(Stringer) = %q/%v, want hello/true", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailStringFallback(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"k": 42}}
|
||||||
|
v, ok := r.DetailString("k")
|
||||||
|
if !ok || v != "42" {
|
||||||
|
t.Fatalf("DetailString(int) = %q/%v, want 42/true", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailStringNil(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"k": nil}}
|
||||||
|
_, ok := r.DetailString("k")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("DetailString(nil) should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailStringMissing(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{}}
|
||||||
|
_, ok := r.DetailString("missing")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("DetailString(missing) should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailIntStringer(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"v": testStringer{"99"}}}
|
||||||
|
got, ok := r.DetailInt("v")
|
||||||
|
if !ok || got != 99 {
|
||||||
|
t.Fatalf("DetailInt(Stringer) = %d/%v, want 99/true", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailBool(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
val interface{}
|
||||||
|
want bool
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"true", true, true, true},
|
||||||
|
{"false", false, false, true},
|
||||||
|
{"string-true", "true", true, true},
|
||||||
|
{"string-false", "false", false, true},
|
||||||
|
{"string-1", "1", true, true},
|
||||||
|
{"string-0", "0", false, true},
|
||||||
|
{"string-invalid", "maybe", false, false},
|
||||||
|
{"nil", nil, false, false},
|
||||||
|
{"int", 1, false, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"b": tt.val}}
|
||||||
|
got, ok := r.DetailBool("b")
|
||||||
|
if ok != tt.ok || got != tt.want {
|
||||||
|
t.Fatalf("DetailBool = %v/%v, want %v/%v", got, ok, tt.want, tt.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailBoolMissing(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{}}
|
||||||
|
_, ok := r.DetailBool("missing")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("DetailBool(missing) should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWebViaProtocol(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"protocol": "https"}}
|
||||||
|
if !r.IsWeb() {
|
||||||
|
t.Fatal("expected web via protocol=https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWebViaService(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"service": "HTTP"}}
|
||||||
|
if !r.IsWeb() {
|
||||||
|
t.Fatal("expected web via service=HTTP (case-insensitive)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWebFalse(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"service": "ssh"}}
|
||||||
|
if r.IsWeb() {
|
||||||
|
t.Fatal("ssh should not be web")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWebNoDetails(t *testing.T) {
|
||||||
|
r := Result{}
|
||||||
|
if r.IsWeb() {
|
||||||
|
t.Fatal("empty result should not be web")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultTypeHelpers(t *testing.T) {
|
||||||
|
if !(Result{Type: ResultTypeHost}).IsHost() {
|
||||||
|
t.Fatal("IsHost")
|
||||||
|
}
|
||||||
|
if !(Result{Type: ResultTypePort}).IsPort() {
|
||||||
|
t.Fatal("IsPort")
|
||||||
|
}
|
||||||
|
if !(Result{Type: ResultTypeService}).IsService() {
|
||||||
|
t.Fatal("IsService")
|
||||||
|
}
|
||||||
|
if !(Result{Type: ResultTypeVuln}).IsVuln() {
|
||||||
|
t.Fatal("IsVuln")
|
||||||
|
}
|
||||||
|
if (Result{Type: ResultTypeHost}).IsPort() {
|
||||||
|
t.Fatal("host should not be port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultURLAndVulnerability(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{
|
||||||
|
"url": "http://example.com",
|
||||||
|
"vulnerability": "CVE-2021-1234",
|
||||||
|
}}
|
||||||
|
if u, ok := r.URL(); !ok || u != "http://example.com" {
|
||||||
|
t.Fatalf("URL = %q/%v", u, ok)
|
||||||
|
}
|
||||||
|
if v, ok := r.Vulnerability(); !ok || v != "CVE-2021-1234" {
|
||||||
|
t.Fatalf("Vulnerability = %q/%v", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultProtocol(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"protocol": "tcp"}}
|
||||||
|
if p, ok := r.Protocol(); !ok || p != "tcp" {
|
||||||
|
t.Fatalf("Protocol = %q/%v", p, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailIntNilDetails(t *testing.T) {
|
||||||
|
r := Result{}
|
||||||
|
_, ok := r.DetailInt("port")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("DetailInt on nil details should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailStringNilDetails(t *testing.T) {
|
||||||
|
r := Result{}
|
||||||
|
_, ok := r.DetailString("service")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("DetailString on nil details should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetailBoolNilDetails(t *testing.T) {
|
||||||
|
r := Result{}
|
||||||
|
_, ok := r.DetailBool("is_web")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("DetailBool on nil details should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsWebExplicitBoolDetail(t *testing.T) {
|
||||||
|
r := Result{Details: map[string]interface{}{"is_web": true}}
|
||||||
|
if !r.IsWeb() {
|
||||||
|
t.Fatal("explicit is_web=true should mark as web")
|
||||||
|
}
|
||||||
|
|
||||||
|
r2 := Result{Details: map[string]interface{}{"is_web": false, "service": "http"}}
|
||||||
|
if r2.IsWeb() {
|
||||||
|
t.Fatal("explicit is_web=false should override service heuristic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntFromInt64Overflow(t *testing.T) {
|
||||||
|
if _, ok := intFromInt64(math.MaxInt64); !ok {
|
||||||
|
t.Fatal("max int64 should fit on 64-bit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntFromUint64Overflow(t *testing.T) {
|
||||||
|
if _, ok := intFromUint64(math.MaxUint64); ok {
|
||||||
|
t.Fatal("max uint64 should overflow int")
|
||||||
|
}
|
||||||
|
if v, ok := intFromUint64(0); !ok || v != 0 {
|
||||||
|
t.Fatalf("intFromUint64(0) = %d/%v", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntFromFloat64NonInteger(t *testing.T) {
|
||||||
|
if _, ok := intFromFloat64(3.14); ok {
|
||||||
|
t.Fatal("non-integer float should fail")
|
||||||
|
}
|
||||||
|
if v, ok := intFromFloat64(100.0); !ok || v != 100 {
|
||||||
|
t.Fatalf("intFromFloat64(100.0) = %d/%v", v, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultSummaryAddWebCredential(t *testing.T) {
|
||||||
|
var s ResultSummary
|
||||||
|
s.Add(Result{
|
||||||
|
Type: ResultTypeVuln,
|
||||||
|
Target: "10.0.0.1:80",
|
||||||
|
Status: "weak_credential: admin:admin",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"service": "http",
|
||||||
|
"is_web": true,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if s.Vulns != 1 || s.Web != 1 || s.Credentials != 1 {
|
||||||
|
t.Fatalf("summary = %+v, want vulns=1 web=1 credentials=1", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsServiceWithPortOnly(t *testing.T) {
|
||||||
|
sr, ok := (Result{
|
||||||
|
Type: ResultTypeService,
|
||||||
|
Target: "10.0.0.1:3306",
|
||||||
|
Details: map[string]interface{}{"port": 3306},
|
||||||
|
}).AsService()
|
||||||
|
if !ok || sr.Port != 3306 {
|
||||||
|
t.Fatalf("AsService with port only = %#v/%v", sr, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOutputResultNil(t *testing.T) {
|
||||||
|
_, ok := convertOutputResult(nil)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("convertOutputResult(nil) should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOutputResultEmpty(t *testing.T) {
|
||||||
|
_, ok := convertOutputResult(&output.ScanResult{})
|
||||||
|
if ok {
|
||||||
|
t.Fatal("empty output result should return false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOutputResultValid(t *testing.T) {
|
||||||
|
raw := &output.ScanResult{
|
||||||
|
Type: output.ResultType(ResultTypePort),
|
||||||
|
Target: "10.0.0.1",
|
||||||
|
Status: "open",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"port": 22,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
r, ok := convertOutputResult(raw)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected valid conversion")
|
||||||
|
}
|
||||||
|
if r.Type != ResultTypePort || r.Target != "10.0.0.1" {
|
||||||
|
t.Fatalf("converted = %#v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultJSON(t *testing.T) {
|
||||||
|
r := Result{
|
||||||
|
Type: ResultTypePort,
|
||||||
|
Target: "10.0.0.1",
|
||||||
|
Status: "open",
|
||||||
|
Details: map[string]interface{}{
|
||||||
|
"port": 22,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded Result
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Type != ResultTypePort || decoded.Target != "10.0.0.1" {
|
||||||
|
t.Fatalf("round-trip failed: %#v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortResultJSON(t *testing.T) {
|
||||||
|
pr := PortResult{Target: "10.0.0.1", Port: 80}
|
||||||
|
data, err := json.Marshal(pr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := string(data); got != `{"target":"10.0.0.1","port":80}` {
|
||||||
|
t.Fatalf("PortResult JSON = %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceResultJSON(t *testing.T) {
|
||||||
|
sr := ServiceResult{Target: "10.0.0.1:80", Port: 80, Service: "http", IsWeb: true}
|
||||||
|
data, err := json.Marshal(sr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded ServiceResult
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Service != "http" || !decoded.IsWeb {
|
||||||
|
t.Fatalf("round-trip failed: %#v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialResultJSON(t *testing.T) {
|
||||||
|
cr := CredentialResult{Target: "10.0.0.1:22", Service: "ssh", Username: "root", Password: "toor"}
|
||||||
|
data, err := json.Marshal(cr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded CredentialResult
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Username != "root" || decoded.Password != "toor" {
|
||||||
|
t.Fatalf("round-trip failed: %#v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVulnerabilityResultJSON(t *testing.T) {
|
||||||
|
vr := VulnerabilityResult{Target: "10.0.0.1:445", Service: "smb", Vulnerability: "MS17-010"}
|
||||||
|
data, err := json.Marshal(vr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded VulnerabilityResult
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Vulnerability != "MS17-010" {
|
||||||
|
t.Fatalf("round-trip failed: %#v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultSummaryJSON(t *testing.T) {
|
||||||
|
summary := ResultSummary{Total: 10, Hosts: 2, Ports: 3, Services: 3, Vulns: 2}
|
||||||
|
data, err := json.Marshal(summary)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded ResultSummary
|
||||||
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded.Total != 10 || decoded.Hosts != 2 {
|
||||||
|
t.Fatalf("round-trip failed: %#v", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+82
-15
@@ -42,6 +42,7 @@ var defaultSafePlugins = []string{
|
|||||||
"rsync",
|
"rsync",
|
||||||
"smb",
|
"smb",
|
||||||
"smtp",
|
"smtp",
|
||||||
|
"snmp",
|
||||||
"ssh",
|
"ssh",
|
||||||
"telnet",
|
"telnet",
|
||||||
"vnc",
|
"vnc",
|
||||||
@@ -122,6 +123,10 @@ func PluginCapabilities(name string) []string {
|
|||||||
return pluginCapabilities(name)
|
return pluginCapabilities(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type scanOpts struct {
|
||||||
|
controller *ScanController
|
||||||
|
}
|
||||||
|
|
||||||
// Scan runs the scanner for the provided targets and returns structured
|
// Scan runs the scanner for the provided targets and returns structured
|
||||||
// findings. If no targets are provided, Config.Targets is used.
|
// findings. If no targets are provided, Config.Targets is used.
|
||||||
func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) {
|
func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) {
|
||||||
@@ -131,11 +136,40 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error)
|
|||||||
|
|
||||||
// ScanReport runs the scanner and returns results with summary and runtime stats.
|
// ScanReport runs the scanner and returns results with summary and runtime stats.
|
||||||
func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport, error) {
|
func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport, error) {
|
||||||
|
return s.collectReport(ctx, scanOpts{}, targets...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
_, err := s.scanEach(ctx, scanOpts{}, handle, targets...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanWithController starts a scan and returns a controller for pause/resume
|
||||||
|
// and live stats. The scan runs in a background goroutine; read the returned
|
||||||
|
// channels to get the report and error when the scan completes.
|
||||||
|
func (s *Scanner) ScanWithController(ctx context.Context, targets ...Target) (*ScanController, <-chan ScanReport, <-chan error) {
|
||||||
|
ctrl := newScanController()
|
||||||
|
reportCh := make(chan ScanReport, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
report, err := s.collectReport(ctx, scanOpts{controller: ctrl}, targets...)
|
||||||
|
reportCh <- report
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ctrl, reportCh, errCh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) collectReport(ctx context.Context, opts scanOpts, targets ...Target) (ScanReport, error) {
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
results []Result
|
results []Result
|
||||||
)
|
)
|
||||||
stats, err := s.scanEach(ctx, func(result Result) error {
|
stats, err := s.scanEach(ctx, opts, func(result Result) error {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
@@ -149,15 +183,7 @@ func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport
|
|||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanEach runs the scanner and calls handle serially for each structured
|
func (s *Scanner) scanEach(ctx context.Context, opts scanOpts, handle ResultHandler, targets ...Target) (ScanStats, error) {
|
||||||
// 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 {
|
|
||||||
_, err := s.scanEach(ctx, handle, targets...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ...Target) (ScanStats, error) {
|
|
||||||
if handle == nil {
|
if handle == nil {
|
||||||
return ScanStats{}, fmt.Errorf("fscan: result handler is required")
|
return ScanStats{}, fmt.Errorf("fscan: result handler is required")
|
||||||
}
|
}
|
||||||
@@ -171,11 +197,34 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ..
|
|||||||
return ScanStats{}, err
|
return ScanStats{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctrl := opts.controller
|
||||||
|
if ctrl == nil && s.config.OnProgress != nil {
|
||||||
|
ctrl = newScanController()
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
restoreLogger := common.PushSilentLogger()
|
restoreLogger := common.PushSilentLogger()
|
||||||
defer restoreLogger()
|
defer restoreLogger()
|
||||||
|
|
||||||
|
if ctrl != nil && s.config.OnProgress != nil {
|
||||||
|
progressCtx, progressCancel := context.WithCancel(ctx)
|
||||||
|
defer progressCancel()
|
||||||
|
onProgress := s.config.OnProgress
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
onProgress(ctrl.progress())
|
||||||
|
case <-progressCtx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errMu sync.Mutex
|
errMu sync.Mutex
|
||||||
handleMu sync.Mutex
|
handleMu sync.Mutex
|
||||||
@@ -192,6 +241,7 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ..
|
|||||||
}
|
}
|
||||||
sink := func(raw *output.ScanResult) error {
|
sink := func(raw *output.ScanResult) error {
|
||||||
if result, ok := convertOutputResult(raw); ok {
|
if result, ok := convertOutputResult(raw); ok {
|
||||||
|
s.injectTaskID(&result)
|
||||||
handleMu.Lock()
|
handleMu.Lock()
|
||||||
if err := handle(result); err != nil {
|
if err := handle(result); err != nil {
|
||||||
handleMu.Unlock()
|
handleMu.Unlock()
|
||||||
@@ -206,7 +256,7 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ..
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
report, err := s.scanOne(ctx, target, sink)
|
report, err := s.scanOne(ctx, target, sink, opts)
|
||||||
stats.add(coreStatsToSDK(report))
|
stats.add(coreStatsToSDK(report))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
|
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
|
||||||
@@ -225,7 +275,17 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ..
|
|||||||
return stats, ctx.Err()
|
return stats, ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) (core.ScanReport, error) {
|
func (s *Scanner) injectTaskID(result *Result) {
|
||||||
|
if s.config.TaskID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Details == nil {
|
||||||
|
result.Details = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
result.Details["task_id"] = s.config.TaskID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink, opts scanOpts) (core.ScanReport, error) {
|
||||||
fv := buildFlagVars(s.config, target)
|
fv := buildFlagVars(s.config, target)
|
||||||
info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)}
|
info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)}
|
||||||
|
|
||||||
@@ -255,6 +315,12 @@ func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.Result
|
|||||||
|
|
||||||
session := common.NewScanSession(cfg, state, fv)
|
session := common.NewScanSession(cfg, state, fv)
|
||||||
session.ResultSink = sink
|
session.ResultSink = sink
|
||||||
|
|
||||||
|
if opts.controller != nil {
|
||||||
|
session.PauseGate = opts.controller.pauseGate
|
||||||
|
opts.controller.addState(state)
|
||||||
|
}
|
||||||
|
|
||||||
return core.RunScan(ctx, info, session)
|
return core.RunScan(ctx, info, session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,8 +475,8 @@ func normalizePlugins(pluginNames []string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pluginTypes(name string) []string {
|
func pluginTypes(name string) []string {
|
||||||
types := make([]string, 0, 3)
|
types := make([]string, 0, 4)
|
||||||
for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal} {
|
for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal, PluginTypeUDP} {
|
||||||
if plugins.HasType(name, pluginType) {
|
if plugins.HasType(name, pluginType) {
|
||||||
types = append(types, pluginType)
|
types = append(types, pluginType)
|
||||||
}
|
}
|
||||||
@@ -431,7 +497,7 @@ func pluginCapabilities(name string) []string {
|
|||||||
capSet[capability] = struct{}{}
|
capSet[capability] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if plugins.HasType(name, PluginTypeService) || plugins.HasType(name, PluginTypeWeb) {
|
if plugins.HasType(name, PluginTypeService) || plugins.HasType(name, PluginTypeWeb) || plugins.HasType(name, PluginTypeUDP) {
|
||||||
add(PluginCapabilityDetect)
|
add(PluginCapabilityDetect)
|
||||||
}
|
}
|
||||||
if serviceAuthPlugins[name] {
|
if serviceAuthPlugins[name] {
|
||||||
@@ -484,6 +550,7 @@ var serviceAuthPlugins = map[string]bool{
|
|||||||
"rsync": true,
|
"rsync": true,
|
||||||
"smb": true,
|
"smb": true,
|
||||||
"smtp": true,
|
"smtp": true,
|
||||||
|
"snmp": true,
|
||||||
"ssh": true,
|
"ssh": true,
|
||||||
"telnet": true,
|
"telnet": true,
|
||||||
"vnc": true,
|
"vnc": true,
|
||||||
|
|||||||
@@ -575,3 +575,280 @@ func hasPortResult(results []Result, port int) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultSafePluginsReturnsIndependentCopy(t *testing.T) {
|
||||||
|
a := DefaultSafePlugins()
|
||||||
|
b := DefaultSafePlugins()
|
||||||
|
if len(a) == 0 {
|
||||||
|
t.Fatal("empty default safe plugins")
|
||||||
|
}
|
||||||
|
a[0] = "MODIFIED"
|
||||||
|
if b[0] == "MODIFIED" {
|
||||||
|
t.Fatal("DefaultSafePlugins returned shared slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPluginWhitespaceAndEmpty(t *testing.T) {
|
||||||
|
if _, ok := GetPlugin(""); ok {
|
||||||
|
t.Fatal("empty name should not exist")
|
||||||
|
}
|
||||||
|
if _, ok := GetPlugin(" "); ok {
|
||||||
|
t.Fatal("whitespace name should not exist")
|
||||||
|
}
|
||||||
|
if info, ok := GetPlugin(" ssh "); !ok || info.Name != "ssh" {
|
||||||
|
t.Fatalf("trimmed lookup failed: %#v/%v", info, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginCapabilitiesEmpty(t *testing.T) {
|
||||||
|
if caps := PluginCapabilities(""); caps != nil {
|
||||||
|
t.Fatalf("empty name caps = %#v, want nil", caps)
|
||||||
|
}
|
||||||
|
if caps := PluginCapabilities("definitely-missing"); caps != nil {
|
||||||
|
t.Fatalf("missing plugin caps = %#v, want nil", caps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSafePluginWhitespace(t *testing.T) {
|
||||||
|
if IsSafePlugin("") {
|
||||||
|
t.Fatal("empty should not be safe")
|
||||||
|
}
|
||||||
|
if IsSafePlugin(" ") {
|
||||||
|
t.Fatal("whitespace should not be safe")
|
||||||
|
}
|
||||||
|
if !IsSafePlugin(" ssh ") {
|
||||||
|
t.Fatal("trimmed ssh should be safe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigPortOnConfig(t *testing.T) {
|
||||||
|
if err := validateConfig(Config{Ports: []int{0}}, []Target{{Host: "127.0.0.1"}}); err == nil {
|
||||||
|
t.Fatal("expected invalid config port error for port 0")
|
||||||
|
}
|
||||||
|
if err := validateConfig(Config{Ports: []int{99999}}, []Target{{Host: "127.0.0.1"}}); err == nil {
|
||||||
|
t.Fatal("expected invalid config port error for port 99999")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigEmptyTarget(t *testing.T) {
|
||||||
|
if err := validateConfig(Config{}, []Target{{}}); err == nil {
|
||||||
|
t.Fatal("expected empty target error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFlagVarsCustomValues(t *testing.T) {
|
||||||
|
config := Config{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
WebTimeout: 15 * time.Second,
|
||||||
|
Threads: 100,
|
||||||
|
ModuleThreads: 50,
|
||||||
|
MaxRetries: 5,
|
||||||
|
MaxRedirects: 3,
|
||||||
|
POCConcurrency: 10,
|
||||||
|
ICMPRate: 0.5,
|
||||||
|
DisablePing: true,
|
||||||
|
DisableTCPProbe: true,
|
||||||
|
DisableBrute: true,
|
||||||
|
Domain: "WORKGROUP",
|
||||||
|
SSHKeyPath: "/tmp/id_rsa",
|
||||||
|
HTTPProxy: "http://proxy:8080",
|
||||||
|
Socks5Proxy: "127.0.0.1:1080",
|
||||||
|
Interface: "eth0",
|
||||||
|
POCPath: "/tmp/pocs",
|
||||||
|
POCName: "test-poc",
|
||||||
|
POCFull: true,
|
||||||
|
DisablePOCScan: true,
|
||||||
|
Language: "zh",
|
||||||
|
Usernames: []string{"admin", "root"},
|
||||||
|
Passwords: []string{"pass1", "pass2"},
|
||||||
|
}
|
||||||
|
fv := buildFlagVars(config, Target{Host: "10.0.0.1"})
|
||||||
|
|
||||||
|
if fv.TimeoutSec != 10 {
|
||||||
|
t.Fatalf("TimeoutSec = %d, want 10", fv.TimeoutSec)
|
||||||
|
}
|
||||||
|
if fv.WebTimeout != 15 {
|
||||||
|
t.Fatalf("WebTimeout = %d, want 15", fv.WebTimeout)
|
||||||
|
}
|
||||||
|
if fv.ThreadNum != 100 {
|
||||||
|
t.Fatalf("ThreadNum = %d, want 100", fv.ThreadNum)
|
||||||
|
}
|
||||||
|
if fv.ModuleThreadNum != 50 {
|
||||||
|
t.Fatalf("ModuleThreadNum = %d, want 50", fv.ModuleThreadNum)
|
||||||
|
}
|
||||||
|
if fv.MaxRetries != 5 {
|
||||||
|
t.Fatalf("MaxRetries = %d, want 5", fv.MaxRetries)
|
||||||
|
}
|
||||||
|
if fv.MaxRedirects != 3 {
|
||||||
|
t.Fatalf("MaxRedirects = %d, want 3", fv.MaxRedirects)
|
||||||
|
}
|
||||||
|
if fv.PocNum != 10 {
|
||||||
|
t.Fatalf("PocNum = %d, want 10", fv.PocNum)
|
||||||
|
}
|
||||||
|
if fv.ICMPRate != 0.5 {
|
||||||
|
t.Fatalf("ICMPRate = %f, want 0.5", fv.ICMPRate)
|
||||||
|
}
|
||||||
|
if !fv.DisablePing {
|
||||||
|
t.Fatal("DisablePing should be true")
|
||||||
|
}
|
||||||
|
if !fv.DisableTcpProbe {
|
||||||
|
t.Fatal("DisableTcpProbe should be true")
|
||||||
|
}
|
||||||
|
if !fv.DisableBrute {
|
||||||
|
t.Fatal("DisableBrute should be true")
|
||||||
|
}
|
||||||
|
if fv.Domain != "WORKGROUP" {
|
||||||
|
t.Fatalf("Domain = %q", fv.Domain)
|
||||||
|
}
|
||||||
|
if fv.SSHKeyPath != "/tmp/id_rsa" {
|
||||||
|
t.Fatalf("SSHKeyPath = %q", fv.SSHKeyPath)
|
||||||
|
}
|
||||||
|
if fv.HTTPProxy != "http://proxy:8080" {
|
||||||
|
t.Fatalf("HTTPProxy = %q", fv.HTTPProxy)
|
||||||
|
}
|
||||||
|
if fv.Socks5Proxy != "127.0.0.1:1080" {
|
||||||
|
t.Fatalf("Socks5Proxy = %q", fv.Socks5Proxy)
|
||||||
|
}
|
||||||
|
if fv.Iface != "eth0" {
|
||||||
|
t.Fatalf("Iface = %q", fv.Iface)
|
||||||
|
}
|
||||||
|
if fv.PocPath != "/tmp/pocs" {
|
||||||
|
t.Fatalf("PocPath = %q", fv.PocPath)
|
||||||
|
}
|
||||||
|
if fv.PocName != "test-poc" {
|
||||||
|
t.Fatalf("PocName = %q", fv.PocName)
|
||||||
|
}
|
||||||
|
if !fv.PocFull {
|
||||||
|
t.Fatal("PocFull should be true")
|
||||||
|
}
|
||||||
|
if !fv.DisablePocScan {
|
||||||
|
t.Fatal("DisablePocScan should be true")
|
||||||
|
}
|
||||||
|
if fv.Language != "zh" {
|
||||||
|
t.Fatalf("Language = %q", fv.Language)
|
||||||
|
}
|
||||||
|
if fv.Username != "admin,root" {
|
||||||
|
t.Fatalf("Username = %q", fv.Username)
|
||||||
|
}
|
||||||
|
if fv.Password != "pass1,pass2" {
|
||||||
|
t.Fatalf("Password = %q", fv.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFlagVarsURLTarget(t *testing.T) {
|
||||||
|
fv := buildFlagVars(Config{}, Target{URL: "https://example.com"})
|
||||||
|
if fv.TargetURL != "https://example.com" {
|
||||||
|
t.Fatalf("TargetURL = %q", fv.TargetURL)
|
||||||
|
}
|
||||||
|
if fv.Host != "" {
|
||||||
|
t.Fatalf("Host should be empty for URL target, got %q", fv.Host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPortsEmpty(t *testing.T) {
|
||||||
|
result := formatPorts(nil)
|
||||||
|
if result != commonconfig.MainPorts {
|
||||||
|
t.Fatalf("formatPorts(nil) = %q, want MainPorts", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPortsSorted(t *testing.T) {
|
||||||
|
result := formatPorts([]int{443, 22, 80})
|
||||||
|
if result != "22,80,443" {
|
||||||
|
t.Fatalf("formatPorts = %q, want sorted", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPluginsAllowUnsafe(t *testing.T) {
|
||||||
|
result := formatPlugins(Config{AllowUnsafePlugins: true})
|
||||||
|
if result != "all" {
|
||||||
|
t.Fatalf("formatPlugins(unsafe) = %q, want all", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatPluginsExplicit(t *testing.T) {
|
||||||
|
result := formatPlugins(Config{Plugins: []string{"ssh", "ftp"}})
|
||||||
|
if result != "ssh,ftp" {
|
||||||
|
t.Fatalf("formatPlugins = %q, want ssh,ftp", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePluginsTrimsWhitespace(t *testing.T) {
|
||||||
|
result := normalizePlugins([]string{" ssh ", "", " ftp "})
|
||||||
|
if len(result) != 2 || result[0] != "ssh" || result[1] != "ftp" {
|
||||||
|
t.Fatalf("normalizePlugins = %#v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecondsOrDefault(t *testing.T) {
|
||||||
|
if got := secondsOrDefault(0, 3); got != 3 {
|
||||||
|
t.Fatalf("secondsOrDefault(0, 3) = %d, want 3", got)
|
||||||
|
}
|
||||||
|
if got := secondsOrDefault(-1*time.Second, 5); got != 5 {
|
||||||
|
t.Fatalf("secondsOrDefault(-1s, 5) = %d, want 5", got)
|
||||||
|
}
|
||||||
|
if got := secondsOrDefault(10*time.Second, 3); got != 10 {
|
||||||
|
t.Fatalf("secondsOrDefault(10s, 3) = %d, want 10", got)
|
||||||
|
}
|
||||||
|
if got := secondsOrDefault(500*time.Millisecond, 3); got != 1 {
|
||||||
|
t.Fatalf("secondsOrDefault(500ms, 3) = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSNMPPluginRegistration(t *testing.T) {
|
||||||
|
info, ok := GetPlugin("snmp")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("snmp plugin not registered")
|
||||||
|
}
|
||||||
|
if !info.Safe {
|
||||||
|
t.Fatal("snmp should be safe")
|
||||||
|
}
|
||||||
|
if !info.Default {
|
||||||
|
t.Fatal("snmp should be in default safe plugins")
|
||||||
|
}
|
||||||
|
if !containsString(info.Types, PluginTypeUDP) {
|
||||||
|
t.Fatalf("snmp types = %#v, want udp", info.Types)
|
||||||
|
}
|
||||||
|
if containsString(info.Types, PluginTypeService) {
|
||||||
|
t.Fatal("snmp should not be service type")
|
||||||
|
}
|
||||||
|
if !containsInt(info.Ports, 161) {
|
||||||
|
t.Fatalf("snmp ports = %#v, want 161", info.Ports)
|
||||||
|
}
|
||||||
|
if !containsString(info.Capabilities, PluginCapabilityDetect) {
|
||||||
|
t.Fatalf("snmp capabilities = %#v, want detect", info.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanStatsAdd(t *testing.T) {
|
||||||
|
var s ScanStats
|
||||||
|
s.add(ScanStats{
|
||||||
|
Duration: 2 * time.Second,
|
||||||
|
TasksTotal: 10,
|
||||||
|
TasksCompleted: 8,
|
||||||
|
Packets: 100,
|
||||||
|
TCPPackets: 80,
|
||||||
|
UDPPackets: 20,
|
||||||
|
HTTPPackets: 5,
|
||||||
|
})
|
||||||
|
s.add(ScanStats{
|
||||||
|
Duration: 3 * time.Second,
|
||||||
|
TasksTotal: 5,
|
||||||
|
TasksCompleted: 5,
|
||||||
|
Packets: 50,
|
||||||
|
TCPPackets: 40,
|
||||||
|
UDPPackets: 10,
|
||||||
|
})
|
||||||
|
if s.Duration != 5*time.Second {
|
||||||
|
t.Fatalf("Duration = %s, want 5s", s.Duration)
|
||||||
|
}
|
||||||
|
if s.TasksTotal != 15 || s.TasksCompleted != 13 {
|
||||||
|
t.Fatalf("Tasks = %d/%d, want 15/13", s.TasksTotal, s.TasksCompleted)
|
||||||
|
}
|
||||||
|
if s.Packets != 150 || s.TCPPackets != 120 || s.UDPPackets != 30 {
|
||||||
|
t.Fatalf("Packets = %d/%d/%d", s.Packets, s.TCPPackets, s.UDPPackets)
|
||||||
|
}
|
||||||
|
if s.HTTPPackets != 5 {
|
||||||
|
t.Fatalf("HTTPPackets = %d, want 5", s.HTTPPackets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const (
|
|||||||
PluginTypeLocal = "local"
|
PluginTypeLocal = "local"
|
||||||
// PluginTypeService marks network service plugins.
|
// PluginTypeService marks network service plugins.
|
||||||
PluginTypeService = "service"
|
PluginTypeService = "service"
|
||||||
|
// PluginTypeUDP marks UDP protocol plugins that bypass TCP port scanning.
|
||||||
|
PluginTypeUDP = "udp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -86,6 +88,17 @@ type ScanStats struct {
|
|||||||
ResourceExhausted int64 `json:"resource_exhausted"`
|
ResourceExhausted int64 `json:"resource_exhausted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScanProgress reports live scan progress for Agent integrations.
|
||||||
|
type ScanProgress struct {
|
||||||
|
TasksTotal int64 `json:"tasks_total"`
|
||||||
|
TasksCompleted int64 `json:"tasks_completed"`
|
||||||
|
Duration time.Duration `json:"duration"`
|
||||||
|
Packets int64 `json:"packets"`
|
||||||
|
TCPPackets int64 `json:"tcp_packets"`
|
||||||
|
HTTPPackets int64 `json:"http_packets"`
|
||||||
|
Paused bool `json:"paused"`
|
||||||
|
}
|
||||||
|
|
||||||
// ScanReport returns structured results with summary and runtime counters.
|
// ScanReport returns structured results with summary and runtime counters.
|
||||||
type ScanReport struct {
|
type ScanReport struct {
|
||||||
Results []Result `json:"results"`
|
Results []Result `json:"results"`
|
||||||
@@ -110,6 +123,10 @@ type Config struct {
|
|||||||
AllowUnsafePlugins bool
|
AllowUnsafePlugins bool
|
||||||
// OnResult is called for every structured result as it is discovered.
|
// OnResult is called for every structured result as it is discovered.
|
||||||
OnResult func(Result)
|
OnResult func(Result)
|
||||||
|
// OnProgress is called periodically with live scan progress.
|
||||||
|
OnProgress func(ScanProgress)
|
||||||
|
// TaskID is injected into every Result.Details["task_id"] when non-empty.
|
||||||
|
TaskID string
|
||||||
|
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
Threads int
|
Threads int
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const (
|
|||||||
PluginTypeWeb = "web" // Web类型插件
|
PluginTypeWeb = "web" // Web类型插件
|
||||||
PluginTypeLocal = "local" // 本地类型插件
|
PluginTypeLocal = "local" // 本地类型插件
|
||||||
PluginTypeService = "service" // 服务类型插件
|
PluginTypeService = "service" // 服务类型插件
|
||||||
|
PluginTypeUDP = "udp" // UDP协议插件,跳过TCP端口扫描
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -122,6 +123,16 @@ func RegisterWithPorts(name string, factory func() Plugin, ports []int) {
|
|||||||
RegisterWithTypes(name, factory, ports, []string{PluginTypeService})
|
RegisterWithTypes(name, factory, ports, []string{PluginTypeService})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterUDPWithPorts 注册UDP协议插件,跳过TCP端口扫描链路
|
||||||
|
func RegisterUDPWithPorts(name string, factory func() Plugin, ports []int) {
|
||||||
|
RegisterWithTypes(name, factory, ports, []string{PluginTypeUDP})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUDP 检查插件是否为UDP协议插件
|
||||||
|
func IsUDP(pluginName string) bool {
|
||||||
|
return HasType(pluginName, PluginTypeUDP)
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterWithTypes 注册带类型标签的插件
|
// RegisterWithTypes 注册带类型标签的插件
|
||||||
func RegisterWithTypes(name string, factory func() Plugin, ports []int, types []string) {
|
func RegisterWithTypes(name string, factory func() Plugin, ports []int, types []string) {
|
||||||
RegisterWithOptions(name, factory, ports, types, !hasPluginType(types, PluginTypeLocal))
|
RegisterWithOptions(name, factory, ports, types, !hasPluginType(types, PluginTypeLocal))
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//go:build plugin_snmp || !plugin_selective
|
||||||
|
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/asn1"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shadow1ng/fscan/common"
|
||||||
|
"github.com/shadow1ng/fscan/plugins"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SNMPPlugin struct {
|
||||||
|
plugins.BasePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSNMPPlugin() *SNMPPlugin {
|
||||||
|
return &SNMPPlugin{BasePlugin: plugins.NewBasePlugin("snmp")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SNMPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
|
||||||
|
config := session.Config
|
||||||
|
timeout := time.Duration(config.Timeout.Seconds()) * time.Second
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
target := fmt.Sprintf("%s:%d", info.Host, info.Port)
|
||||||
|
|
||||||
|
result := p.probe(ctx, target, "public", timeout, session)
|
||||||
|
if result == nil {
|
||||||
|
return &ScanResult{Success: false, Service: "snmp"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DisableBrute {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
communities := p.buildCommunityList(config)
|
||||||
|
var found []string
|
||||||
|
for _, community := range communities {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if r := p.probe(ctx, target, community, timeout, session); r != nil && r.Success {
|
||||||
|
found = append(found, community)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(found) > 0 {
|
||||||
|
return &ScanResult{
|
||||||
|
Success: true,
|
||||||
|
Type: plugins.ResultTypeCredential,
|
||||||
|
Service: "snmp",
|
||||||
|
Password: strings.Join(found, ","),
|
||||||
|
Banner: result.Banner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SNMPPlugin) probe(ctx context.Context, target, community string, timeout time.Duration, session *common.ScanSession) *ScanResult {
|
||||||
|
conn, err := session.DialUDP(ctx, target, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
pkt := buildSNMPGetRequest(community, []int{1, 3, 6, 1, 2, 1, 1, 1, 0})
|
||||||
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sysDescr := parseSNMPResponse(buf[:n])
|
||||||
|
if sysDescr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ScanResult{
|
||||||
|
Success: true,
|
||||||
|
Type: plugins.ResultTypeService,
|
||||||
|
Service: "snmp",
|
||||||
|
Banner: fmt.Sprintf("community=%s sysDescr=%s", community, sysDescr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SNMPPlugin) buildCommunityList(config *common.Config) []string {
|
||||||
|
defaults := []string{"public", "private", "community", "manager", "monitor", "admin", "snmp", "default"}
|
||||||
|
|
||||||
|
passwords := config.Credentials.Passwords
|
||||||
|
if len(passwords) > 0 {
|
||||||
|
seen := make(map[string]struct{}, len(defaults)+len(passwords))
|
||||||
|
var merged []string
|
||||||
|
for _, c := range append(defaults, passwords...) {
|
||||||
|
if _, ok := seen[c]; !ok {
|
||||||
|
seen[c] = struct{}{}
|
||||||
|
merged = append(merged, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
// SNMPv2c GetRequest 编码
|
||||||
|
func buildSNMPGetRequest(community string, oid []int) []byte {
|
||||||
|
requestID := int(time.Now().UnixNano() & 0x7FFFFFFF)
|
||||||
|
|
||||||
|
varbind, _ := asn1.Marshal(asn1.RawValue{
|
||||||
|
Class: asn1.ClassUniversal,
|
||||||
|
Tag: asn1.TagSequence,
|
||||||
|
IsCompound: true,
|
||||||
|
Bytes: marshalOIDWithNull(oid),
|
||||||
|
})
|
||||||
|
|
||||||
|
varbindList, _ := asn1.Marshal(asn1.RawValue{
|
||||||
|
Class: asn1.ClassUniversal,
|
||||||
|
Tag: asn1.TagSequence,
|
||||||
|
IsCompound: true,
|
||||||
|
Bytes: varbind,
|
||||||
|
})
|
||||||
|
|
||||||
|
reqIDBytes, _ := asn1.Marshal(requestID)
|
||||||
|
errorStatusBytes, _ := asn1.Marshal(0)
|
||||||
|
errorIndexBytes, _ := asn1.Marshal(0)
|
||||||
|
|
||||||
|
var pduContent []byte
|
||||||
|
pduContent = append(pduContent, reqIDBytes...)
|
||||||
|
pduContent = append(pduContent, errorStatusBytes...)
|
||||||
|
pduContent = append(pduContent, errorIndexBytes...)
|
||||||
|
pduContent = append(pduContent, varbindList...)
|
||||||
|
|
||||||
|
pdu := asn1.RawValue{
|
||||||
|
Class: asn1.ClassContextSpecific,
|
||||||
|
Tag: 0, // GetRequest-PDU
|
||||||
|
IsCompound: true,
|
||||||
|
Bytes: pduContent,
|
||||||
|
}
|
||||||
|
pduBytes, _ := asn1.Marshal(pdu)
|
||||||
|
|
||||||
|
versionBytes, _ := asn1.Marshal(1) // SNMPv2c
|
||||||
|
communityBytes, _ := asn1.Marshal([]byte(community))
|
||||||
|
|
||||||
|
var messageContent []byte
|
||||||
|
messageContent = append(messageContent, versionBytes...)
|
||||||
|
messageContent = append(messageContent, communityBytes...)
|
||||||
|
messageContent = append(messageContent, pduBytes...)
|
||||||
|
|
||||||
|
message, _ := asn1.Marshal(asn1.RawValue{
|
||||||
|
Class: asn1.ClassUniversal,
|
||||||
|
Tag: asn1.TagSequence,
|
||||||
|
IsCompound: true,
|
||||||
|
Bytes: messageContent,
|
||||||
|
})
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalOIDWithNull(oid []int) []byte {
|
||||||
|
oidBytes, _ := asn1.Marshal(asn1.ObjectIdentifier(oid))
|
||||||
|
nullBytes, _ := asn1.Marshal(asn1.RawValue{Class: asn1.ClassUniversal, Tag: asn1.TagNull})
|
||||||
|
var result []byte
|
||||||
|
result = append(result, oidBytes...)
|
||||||
|
result = append(result, nullBytes...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSNMPResponse(data []byte) string {
|
||||||
|
var message asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(data, &message); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if message.Tag != asn1.TagSequence {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rest := message.Bytes
|
||||||
|
// version
|
||||||
|
var version asn1.RawValue
|
||||||
|
rest, _ = asn1.Unmarshal(rest, &version)
|
||||||
|
if len(rest) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// community
|
||||||
|
var community asn1.RawValue
|
||||||
|
rest, _ = asn1.Unmarshal(rest, &community)
|
||||||
|
if len(rest) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// PDU (GetResponse = context-specific tag 2)
|
||||||
|
var pdu asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(rest, &pdu); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
pduRest := pdu.Bytes
|
||||||
|
// skip requestID, errorStatus, errorIndex
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
var skip asn1.RawValue
|
||||||
|
var err error
|
||||||
|
pduRest, err = asn1.Unmarshal(pduRest, &skip)
|
||||||
|
if err != nil || len(pduRest) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// varbindList -> varbind -> (oid, value)
|
||||||
|
var varbindList asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(pduRest, &varbindList); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var varbind asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(varbindList.Bytes, &varbind); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
vbRest := varbind.Bytes
|
||||||
|
// skip OID
|
||||||
|
var oidVal asn1.RawValue
|
||||||
|
vbRest, _ = asn1.Unmarshal(vbRest, &oidVal)
|
||||||
|
if len(vbRest) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// value
|
||||||
|
var value asn1.RawValue
|
||||||
|
if _, err := asn1.Unmarshal(vbRest, &value); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.Tag == asn1.TagOctetString || value.Tag == asn1.TagUTF8String {
|
||||||
|
s := strings.TrimSpace(string(value.Bytes))
|
||||||
|
if len(s) > 200 {
|
||||||
|
s = s[:200]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("(type=%d, len=%d)", value.Tag, len(value.Bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterUDPPluginWithPorts("snmp", func() Plugin {
|
||||||
|
return NewSNMPPlugin()
|
||||||
|
}, []int{161})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -25,4 +25,11 @@ func RegisterPluginWithPorts(name string, factory func() Plugin, ports []int) {
|
|||||||
}, ports)
|
}, ports)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterUDPPluginWithPorts 注册UDP协议插件
|
||||||
|
func RegisterUDPPluginWithPorts(name string, factory func() Plugin, ports []int) {
|
||||||
|
plugins.RegisterUDPWithPorts(name, func() plugins.Plugin {
|
||||||
|
return factory()
|
||||||
|
}, ports)
|
||||||
|
}
|
||||||
|
|
||||||
var GenerateCredentials = plugins.GenerateCredentials
|
var GenerateCredentials = plugins.GenerateCredentials
|
||||||
|
|||||||
Reference in New Issue
Block a user