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
+130 -53
View File
@@ -1,71 +1,148 @@
# 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
package main
import fscan "github.com/shadow1ng/fscan/pkg/fscan"
import (
"context"
"fmt"
"time"
fscan "github.com/shadow1ng/fscan/pkg/fscan"
)
func main() {
config := fscan.Config{
Timeout: 3 * time.Second,
Threads: 128,
DisablePing: true,
DisableBrute: true,
Plugins: []string{"ssh", "mysql", "redis"},
OnResult: func(result fscan.Result) {
if result.Type == fscan.ResultTypeService || result.Type == fscan.ResultTypeVuln {
fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status)
}
},
}
if err := fscan.ValidateConfig(config, fscan.Target{Host: "192.168.1.10"}); err != nil {
panic(err)
}
scanner := fscan.NewScanner(config)
report, err := scanner.ScanReport(context.Background(), fscan.Target{
Host: "192.168.1.10",
Ports: []int{22, 3306, 6379},
scanner := fscan.NewScanner(fscan.Config{
Timeout: 3 * time.Second,
Threads: 128,
DisablePing: true,
Plugins: []string{"ssh", "mysql", "redis"},
})
if err != nil {
panic(err)
}
fmt.Printf("scan finished: %+v stats=%+v\n", report.Summary, report.Stats)
for _, result := range report.Results {
// Store, forward, or filter the result in the embedding system.
if credential, ok := result.AsCredential(); ok {
fmt.Printf("weak credential: %s %s:%s\n", credential.Target, credential.Username, credential.Password)
}
}
results, err := scanner.Scan(context.Background(), fscan.Target{
Host: "192.168.1.0/24",
Ports: []int{22, 3306, 6379},
})
```
## Scan Modes
| Mode | API | Use Case |
|------|-----|----------|
| 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 |
| --- | --- |
| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport` |
|------|-----|
| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport`, `ScanWithController` |
| Control | `ScanController` (`Pause`, `Resume`, `IsPaused`, `Stats`) |
| Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` |
| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities`, `PluginInfo` |
| Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` |
| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential`, `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` |
| Summary and stats | `ScanReport`, `ScanStats`, `SummarizeResults`, `ResultSummary.Add` |
| Progress | `OnProgress`, `ScanProgress`, `TaskID` |
| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities` |
| Results | `Result`, `ResultType*` constants |
| 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 (
"encoding/json"
"math"
"testing"
"github.com/shadow1ng/fscan/common/output"
)
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) {
result := Result{
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) {
portResult, ok := (Result{
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) {
results := []Result{
{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) {
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)
}
}
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",
"smb",
"smtp",
"snmp",
"ssh",
"telnet",
"vnc",
@@ -122,6 +123,10 @@ func PluginCapabilities(name string) []string {
return pluginCapabilities(name)
}
type scanOpts struct {
controller *ScanController
}
// Scan runs the scanner for the provided targets and returns structured
// findings. If no targets are provided, Config.Targets is used.
func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) {
@@ -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.
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 (
mu sync.Mutex
results []Result
)
stats, err := s.scanEach(ctx, func(result Result) error {
stats, err := s.scanEach(ctx, opts, func(result Result) error {
mu.Lock()
results = append(results, result)
mu.Unlock()
@@ -149,15 +183,7 @@ func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport
}, err
}
// ScanEach runs the scanner and calls handle serially for each structured
// result without retaining all results in memory. If handle returns an error,
// the scan context is canceled and that error is returned.
func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error {
_, err := s.scanEach(ctx, handle, targets...)
return err
}
func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ...Target) (ScanStats, error) {
func (s *Scanner) scanEach(ctx context.Context, opts scanOpts, handle ResultHandler, targets ...Target) (ScanStats, error) {
if handle == nil {
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
}
ctrl := opts.controller
if ctrl == nil && s.config.OnProgress != nil {
ctrl = newScanController()
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
restoreLogger := common.PushSilentLogger()
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 (
errMu 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 {
if result, ok := convertOutputResult(raw); ok {
s.injectTaskID(&result)
handleMu.Lock()
if err := handle(result); err != nil {
handleMu.Unlock()
@@ -206,7 +256,7 @@ func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ..
}
return nil
}
report, err := s.scanOne(ctx, target, sink)
report, err := s.scanOne(ctx, target, sink, opts)
stats.add(coreStatsToSDK(report))
if err != 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()
}
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)
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.ResultSink = sink
if opts.controller != nil {
session.PauseGate = opts.controller.pauseGate
opts.controller.addState(state)
}
return core.RunScan(ctx, info, session)
}
@@ -409,8 +475,8 @@ func normalizePlugins(pluginNames []string) []string {
}
func pluginTypes(name string) []string {
types := make([]string, 0, 3)
for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal} {
types := make([]string, 0, 4)
for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal, PluginTypeUDP} {
if plugins.HasType(name, pluginType) {
types = append(types, pluginType)
}
@@ -431,7 +497,7 @@ func pluginCapabilities(name string) []string {
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)
}
if serviceAuthPlugins[name] {
@@ -484,6 +550,7 @@ var serviceAuthPlugins = map[string]bool{
"rsync": true,
"smb": true,
"smtp": true,
"snmp": true,
"ssh": true,
"telnet": true,
"vnc": true,
+277
View File
@@ -575,3 +575,280 @@ func hasPortResult(results []Result, port int) bool {
}
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"
// PluginTypeService marks network service plugins.
PluginTypeService = "service"
// PluginTypeUDP marks UDP protocol plugins that bypass TCP port scanning.
PluginTypeUDP = "udp"
)
const (
@@ -86,6 +88,17 @@ type ScanStats struct {
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.
type ScanReport struct {
Results []Result `json:"results"`
@@ -110,6 +123,10 @@ type Config struct {
AllowUnsafePlugins bool
// OnResult is called for every structured result as it is discovered.
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
Threads int