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:
ZacharyZcR
2026-05-18 23:11:39 +08:00
parent eb4fa38fea
commit a1588a321f
16 changed files with 1881 additions and 72 deletions
+6
View File
@@ -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场景的集成示例
### 插件生态 ### 插件生态
- 持续扩展服务插件覆盖范围 - 持续扩展服务插件覆盖范围
- 为每个服务插件开发更多漏洞检测和利用能力 - 为每个服务插件开发更多漏洞检测和利用能力
+18
View File
@@ -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 {
+13 -4
View File
@@ -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)
} }
} }
+12
View File
@@ -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)
+34
View File
@@ -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 {
// 发现目标主机和端口 // 发现目标主机和端口
+61
View File
@@ -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
View File
@@ -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
+101
View File
@@ -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(),
}
}
+317
View File
@@ -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
}
+539
View File
@@ -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
View File
@@ -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,
+277
View File
@@ -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)
}
}
+17
View File
@@ -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
+11
View File
@@ -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))
+256
View File
@@ -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})
}
+7
View File
@@ -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