mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 19:21:52 +08:00
## 架构重构
- 全局变量消除,迁移至 Config/State 对象
- SMB 插件融合(smb/smb2/smbghost/smbinfo)
- 服务探测重构,实现 Nmap 风格 fallback 机制
- 输出系统重构,TXT 实时刷盘 + 双写机制
- i18n 框架升级至 go-i18n
## 性能优化
- 正则表达式预编译
- 内存优化 map[string]struct{}
- 并发指纹匹配
- SOCKS5 连接复用
- 滑动窗口调度 + 自适应线程池
## 新功能
- Web 管理界面
- 多格式 POC 适配(xray/afrog)
- 增强指纹库(3139条)
- Favicon hash 指纹识别
- 插件选择性编译(Build Tags)
- fscan-lab 靶场环境
- 默认端口扩展(62→133)
## 构建系统
- 添加 no_local tag 支持排除本地插件
- 多版本构建:fscan/fscan-nolocal/fscan-web
- CI 添加 snapshot 模式支持仅测试构建
## Bug 修复
- 修复 120+ 个问题,包括 RDP panic、批量扫描漏报、
JSON 输出格式、Redis 检测、Context 超时等
## 测试增强
- 单元测试覆盖率 74-100%
- 并发安全测试
- 集成测试(Web/端口/服务/SSH/ICMP)
251 lines
6.8 KiB
Go
251 lines
6.8 KiB
Go
//go:build (plugin_downloader || !plugin_selective) && !no_local
|
|
|
|
package local
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shadow1ng/fscan/common"
|
|
"github.com/shadow1ng/fscan/common/i18n"
|
|
"github.com/shadow1ng/fscan/plugins"
|
|
)
|
|
|
|
// DownloaderPlugin 文件下载插件
|
|
// 设计哲学:直接实现,删除过度设计
|
|
// - 删除复杂的继承体系
|
|
// - 直接实现文件下载功能
|
|
// - 保持原有功能逻辑
|
|
type DownloaderPlugin struct {
|
|
plugins.BasePlugin
|
|
}
|
|
|
|
// NewDownloaderPlugin 创建文件下载插件
|
|
func NewDownloaderPlugin() *DownloaderPlugin {
|
|
return &DownloaderPlugin{
|
|
BasePlugin: plugins.NewBasePlugin("downloader"),
|
|
}
|
|
}
|
|
|
|
// Scan 执行文件下载任务 - 直接实现
|
|
func (p *DownloaderPlugin) Scan(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *plugins.Result {
|
|
var output strings.Builder
|
|
|
|
// 从config获取配置
|
|
downloadURL := config.LocalExploit.DownloadURL
|
|
savePath := config.LocalExploit.DownloadSavePath
|
|
downloadTimeout := 30 * time.Second
|
|
maxFileSize := int64(100 * 1024 * 1024) // 100MB
|
|
|
|
output.WriteString("=== 文件下载 ===\n")
|
|
|
|
// 验证参数
|
|
if err := p.validateParameters(downloadURL, &savePath); err != nil {
|
|
output.WriteString(fmt.Sprintf("参数验证失败: %v\n", err))
|
|
return &plugins.Result{
|
|
Success: false,
|
|
Output: output.String(),
|
|
Error: err,
|
|
}
|
|
}
|
|
|
|
output.WriteString(fmt.Sprintf("下载URL: %s\n", downloadURL))
|
|
output.WriteString(fmt.Sprintf("保存路径: %s\n", savePath))
|
|
output.WriteString(fmt.Sprintf("平台: %s\n\n", runtime.GOOS))
|
|
|
|
// 检查保存路径权限
|
|
if err := p.checkSavePathPermissions(&savePath); err != nil {
|
|
output.WriteString(fmt.Sprintf("保存路径检查失败: %v\n", err))
|
|
return &plugins.Result{
|
|
Success: false,
|
|
Output: output.String(),
|
|
Error: err,
|
|
}
|
|
}
|
|
|
|
// 执行下载
|
|
downloadInfo, err := p.downloadFile(ctx, downloadURL, savePath, downloadTimeout, maxFileSize)
|
|
if err != nil {
|
|
output.WriteString(fmt.Sprintf("下载失败: %v\n", err))
|
|
return &plugins.Result{
|
|
Success: false,
|
|
Output: output.String(),
|
|
Error: err,
|
|
}
|
|
}
|
|
|
|
// 输出下载结果
|
|
output.WriteString("✓ 文件下载成功!\n")
|
|
output.WriteString(fmt.Sprintf("文件大小: %v bytes\n", downloadInfo["file_size"]))
|
|
if contentType, ok := downloadInfo["content_type"]; ok && contentType != "" {
|
|
output.WriteString(fmt.Sprintf("文件类型: %v\n", contentType))
|
|
}
|
|
output.WriteString(fmt.Sprintf("下载用时: %v\n", downloadInfo["download_time"]))
|
|
|
|
common.LogSuccess(i18n.Tr("downloader_success",
|
|
downloadURL, savePath, downloadInfo["file_size"]))
|
|
|
|
return &plugins.Result{
|
|
Success: true,
|
|
Type: plugins.ResultTypeService,
|
|
Output: output.String(),
|
|
Error: nil,
|
|
}
|
|
}
|
|
|
|
// validateParameters 验证输入参数
|
|
func (p *DownloaderPlugin) validateParameters(downloadURL string, savePath *string) error {
|
|
if downloadURL == "" {
|
|
return fmt.Errorf("下载URL不能为空,请使用 -download-url 参数指定")
|
|
}
|
|
|
|
// 验证URL格式
|
|
if !strings.HasPrefix(strings.ToLower(downloadURL), "http://") &&
|
|
!strings.HasPrefix(strings.ToLower(downloadURL), "https://") {
|
|
return fmt.Errorf("无效的URL格式,必须以 http:// 或 https:// 开头")
|
|
}
|
|
|
|
// 如果没有指定保存路径,使用URL中的文件名
|
|
if *savePath == "" {
|
|
filename := p.extractFilenameFromURL(downloadURL)
|
|
if filename == "" {
|
|
filename = "downloaded_file"
|
|
}
|
|
*savePath = filename
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractFilenameFromURL 从URL中提取文件名
|
|
func (p *DownloaderPlugin) extractFilenameFromURL(url string) string {
|
|
// 移除查询参数
|
|
if idx := strings.Index(url, "?"); idx != -1 {
|
|
url = url[:idx]
|
|
}
|
|
|
|
// 获取路径的最后一部分
|
|
parts := strings.Split(url, "/")
|
|
if len(parts) > 0 {
|
|
filename := parts[len(parts)-1]
|
|
if filename != "" && !strings.Contains(filename, "=") {
|
|
return filename
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// checkSavePathPermissions 检查保存路径权限
|
|
func (p *DownloaderPlugin) checkSavePathPermissions(savePath *string) error {
|
|
// 获取保存目录
|
|
saveDir := filepath.Dir(*savePath)
|
|
if saveDir == "." || saveDir == "" {
|
|
// 使用当前目录
|
|
var err error
|
|
saveDir, err = os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("获取当前目录失败: %w", err)
|
|
}
|
|
*savePath = filepath.Join(saveDir, filepath.Base(*savePath))
|
|
}
|
|
|
|
// 确保目录存在
|
|
if err := os.MkdirAll(saveDir, 0755); err != nil {
|
|
return fmt.Errorf("创建保存目录失败: %w", err)
|
|
}
|
|
|
|
// 检查写入权限
|
|
testFile := filepath.Join(saveDir, ".fscan_write_test")
|
|
file, err := os.Create(testFile)
|
|
if err != nil {
|
|
return fmt.Errorf("保存目录无写入权限: %w", err)
|
|
}
|
|
_ = file.Close() // 测试文件,Close错误可忽略
|
|
_ = os.Remove(testFile)
|
|
|
|
return nil
|
|
}
|
|
|
|
// downloadFile 执行文件下载
|
|
func (p *DownloaderPlugin) downloadFile(ctx context.Context, downloadURL, savePath string, downloadTimeout time.Duration, maxFileSize int64) (map[string]interface{}, error) {
|
|
startTime := time.Now()
|
|
|
|
// 创建带超时的HTTP客户端
|
|
client := &http.Client{
|
|
Timeout: downloadTimeout,
|
|
}
|
|
|
|
// 创建请求
|
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
|
|
}
|
|
|
|
// 设置User-Agent
|
|
req.Header.Set("User-Agent", "fscan-downloader/1.0")
|
|
|
|
// 发送请求
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("HTTP请求失败: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }() // HTTP响应体,Close错误可安全忽略
|
|
|
|
// 检查HTTP状态码
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("HTTP请求失败,状态码: %d %s", resp.StatusCode, resp.Status)
|
|
}
|
|
|
|
// 检查文件大小
|
|
contentLength := resp.ContentLength
|
|
if contentLength > maxFileSize {
|
|
return nil, fmt.Errorf("文件过大 (%d bytes),超过最大限制 (%d bytes)",
|
|
contentLength, maxFileSize)
|
|
}
|
|
|
|
// 创建保存文件
|
|
outFile, err := os.Create(savePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("创建保存文件失败: %w", err)
|
|
}
|
|
defer func() { _ = outFile.Close() }() // 文件资源清理,Close错误可安全忽略
|
|
|
|
// 使用带限制的Reader防止过大文件
|
|
limitedReader := io.LimitReader(resp.Body, maxFileSize)
|
|
|
|
// 复制数据
|
|
written, err := io.Copy(outFile, limitedReader)
|
|
if err != nil {
|
|
// 清理部分下载的文件
|
|
_ = os.Remove(savePath) // 清理临时文件,Remove错误可忽略
|
|
return nil, fmt.Errorf("文件下载失败: %w", err)
|
|
}
|
|
|
|
downloadTime := time.Since(startTime)
|
|
|
|
// 返回下载信息
|
|
downloadInfo := map[string]interface{}{
|
|
"save_path": savePath,
|
|
"file_size": written,
|
|
"content_type": resp.Header.Get("Content-Type"),
|
|
"download_time": downloadTime,
|
|
}
|
|
|
|
return downloadInfo, nil
|
|
}
|
|
|
|
// 注册插件
|
|
func init() {
|
|
RegisterLocalPlugin("downloader", func() Plugin {
|
|
return NewDownloaderPlugin()
|
|
})
|
|
}
|