mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-09-01 15:32:55 +08:00
@@ -61,6 +61,7 @@ type APIGroup struct {
|
||||
WafOwaspApi
|
||||
WafHostPathRuleApi
|
||||
WafAppApi
|
||||
WafAIApi
|
||||
}
|
||||
|
||||
var APIGroupAPP = new(APIGroup)
|
||||
@@ -113,6 +114,9 @@ var (
|
||||
|
||||
wafAnalysisService = waf_service.WafAnalysisServiceApp
|
||||
|
||||
wafAIService = waf_service.WafAIServiceApp
|
||||
wafAILabelService = waf_service.WafAILabelServiceApp
|
||||
|
||||
wafPrivateInfoService = waf_service.WafPrivateInfoServiceApp
|
||||
wafPrivateGroupService = waf_service.WafPrivateGroupServiceApp
|
||||
wafCacheRuleService = waf_service.WafCacheRuleServiceApp
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/model/common/response"
|
||||
"SamWaf/model/request"
|
||||
"SamWaf/utils"
|
||||
"SamWaf/wafai"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WafAIApi struct {
|
||||
}
|
||||
|
||||
const (
|
||||
aiModelDir = "ai_model"
|
||||
aiModelFile = "current.swai"
|
||||
aiExportDir = "ai_export"
|
||||
maxAIModelUpload = 64 * 1024 * 1024 // 模型包上传上限 64MB
|
||||
defaultExportMax = 200000 // 默认最多导出条数
|
||||
)
|
||||
|
||||
// aiStatusResp AI 检测状态响应
|
||||
type aiStatusResp struct {
|
||||
GlobalEnable int64 `json:"global_enable"` // 全局总开关
|
||||
Mode string `json:"mode"` // observe / block
|
||||
ModelLoaded bool `json:"model_loaded"` // 当前是否已加载模型
|
||||
FeatureVersion string `json:"feature_version"` // 引擎特征版本
|
||||
Manifest *wafai.Manifest `json:"manifest"` // 当前模型元数据(未加载为 null)
|
||||
}
|
||||
|
||||
// GetAIStatusApi 获取 AI 检测状态与当前模型信息
|
||||
func (w *WafAIApi) GetAIStatusApi(c *gin.Context) {
|
||||
resp := aiStatusResp{
|
||||
GlobalEnable: global.GCONFIG_AI_ENABLE,
|
||||
Mode: global.GCONFIG_AI_MODE,
|
||||
FeatureVersion: wafai.FeatureVersion,
|
||||
}
|
||||
if global.GWAF_AI_DETECTOR != nil {
|
||||
if m, ok := global.GWAF_AI_DETECTOR.CurrentManifest(); ok {
|
||||
resp.ModelLoaded = true
|
||||
resp.Manifest = &m
|
||||
}
|
||||
}
|
||||
response.OkWithData(resp, c)
|
||||
}
|
||||
|
||||
// UploadAIModelApi 上传 .swai 模型包:校验 -> 热加载 -> 持久化
|
||||
func (w *WafAIApi) UploadAIModelApi(c *gin.Context) {
|
||||
if global.GWAF_AI_DETECTOR == nil {
|
||||
response.FailWithMessage("AI检测器未初始化", c)
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.FailWithMessage("文件上传失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if strings.ToLower(filepath.Ext(file.Filename)) != ".swai" {
|
||||
response.FailWithMessage("不支持的文件类型,仅支持 .swai 模型包", c)
|
||||
return
|
||||
}
|
||||
if file.Size > maxAIModelUpload {
|
||||
response.FailWithMessage("模型包过大(上限 64MB)", c)
|
||||
return
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
response.FailWithMessage("打开上传文件失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(src, maxAIModelUpload+1))
|
||||
if err != nil {
|
||||
response.FailWithMessage("读取上传文件失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if int64(len(data)) > maxAIModelUpload {
|
||||
response.FailWithMessage("模型包过大(上限 64MB)", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 先校验+热加载到内存(含特征版本/sha256/zip 安全校验),通过后再落盘
|
||||
manifest, err := global.GWAF_AI_DETECTOR.LoadFromBytes(data)
|
||||
if err != nil {
|
||||
response.FailWithMessage("模型校验失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
// 持久化到 data/ai_model/current.swai(原子替换),供下次启动加载
|
||||
dir := filepath.Join(utils.GetCurrentDir(), "data", aiModelDir)
|
||||
if err = os.MkdirAll(dir, 0750); err != nil {
|
||||
response.FailWithMessage("创建模型目录失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
finalPath := filepath.Join(dir, aiModelFile)
|
||||
tmpPath := finalPath + ".tmp"
|
||||
if err = os.WriteFile(tmpPath, data, 0640); err != nil {
|
||||
response.FailWithMessage("保存模型文件失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err = os.Rename(tmpPath, finalPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
response.FailWithMessage("替换模型文件失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithDetailed(manifest, "模型上传并加载成功", c)
|
||||
}
|
||||
|
||||
// ReloadAIModelApi 从磁盘重新加载当前模型
|
||||
func (w *WafAIApi) ReloadAIModelApi(c *gin.Context) {
|
||||
if global.GWAF_AI_DETECTOR == nil {
|
||||
response.FailWithMessage("AI检测器未初始化", c)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(utils.GetCurrentDir(), "data", aiModelDir, aiModelFile)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
response.FailWithMessage("没有可加载的模型文件,请先上传", c)
|
||||
return
|
||||
}
|
||||
manifest, err := global.GWAF_AI_DETECTOR.LoadFromFile(path)
|
||||
if err != nil {
|
||||
response.FailWithMessage("模型加载失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithDetailed(manifest, "模型重新加载成功", c)
|
||||
}
|
||||
|
||||
// UnloadAIModelApi 卸载当前模型(不删除磁盘文件)
|
||||
func (w *WafAIApi) UnloadAIModelApi(c *gin.Context) {
|
||||
if global.GWAF_AI_DETECTOR != nil {
|
||||
global.GWAF_AI_DETECTOR.Unload()
|
||||
}
|
||||
response.OkWithMessage("模型已卸载", c)
|
||||
}
|
||||
|
||||
// GetAIDashboardApi AI检测看板:按类别汇总 + 分数分布 + observe/block 趋势
|
||||
func (w *WafAIApi) GetAIDashboardApi(c *gin.Context) {
|
||||
var req request.WafAIDashboardReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
response.OkWithData(wafAIService.DashboardApi(req), c)
|
||||
}
|
||||
|
||||
// MarkLabelApi 标记某条日志的训练标签修正(误报→正常 / 确认攻击 / 忽略)
|
||||
func (w *WafAIApi) MarkLabelApi(c *gin.Context) {
|
||||
var req request.WafAILabelMarkReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := wafAILabelService.MarkApi(req); err != nil {
|
||||
response.FailWithMessage("标记失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("标记成功", c)
|
||||
}
|
||||
|
||||
// UnmarkLabelApi 取消某条日志的标记
|
||||
func (w *WafAIApi) UnmarkLabelApi(c *gin.Context) {
|
||||
var req request.WafAILabelUnmarkReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := wafAILabelService.UnmarkApi(req.ReqUuid); err != nil {
|
||||
response.FailWithMessage("取消标记失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage("已取消标记", c)
|
||||
}
|
||||
|
||||
// LabelByUuidsApi 按 req_uuid 批量查询标记状态(日志列表回显用)
|
||||
func (w *WafAIApi) LabelByUuidsApi(c *gin.Context) {
|
||||
var req request.WafAILabelByUuidsReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
response.OkWithData(wafAILabelService.GetMapByUuidsApi(req.ReqUuids), c)
|
||||
}
|
||||
|
||||
// LabelListApi 标注工作台列表:AI 命中分页 + 标记状态过滤/回显
|
||||
func (w *WafAIApi) LabelListApi(c *gin.Context) {
|
||||
var req request.WafAILabelListReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
response.OkWithData(wafAILabelService.ListApi(req), c)
|
||||
}
|
||||
|
||||
// BatchMarkLabelApi 批量标记训练标签(误报→正常 / 确认攻击 / 忽略)
|
||||
func (w *WafAIApi) BatchMarkLabelApi(c *gin.Context) {
|
||||
var req request.WafAILabelBatchMarkReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
n, err := wafAILabelService.BatchMarkApi(req)
|
||||
if err != nil {
|
||||
response.FailWithMessage(fmt.Sprintf("批量标记失败(已处理%d条): %s", n, err.Error()), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage(fmt.Sprintf("已标记 %d 条", n), c)
|
||||
}
|
||||
|
||||
// BatchUnmarkLabelApi 批量取消标记
|
||||
func (w *WafAIApi) BatchUnmarkLabelApi(c *gin.Context) {
|
||||
var req request.WafAILabelBatchUnmarkReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailWithMessage("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
n, err := wafAILabelService.BatchUnmarkApi(req.ReqUuids)
|
||||
if err != nil {
|
||||
response.FailWithMessage("批量取消标记失败: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMessage(fmt.Sprintf("已取消 %d 条标记", n), c)
|
||||
}
|
||||
|
||||
// trainSample 导出的训练样本(字段名与 SamWafAI samwafai/data/sample.py 对齐)
|
||||
type trainSample struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
Body string `json:"body"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Label int `json:"label"`
|
||||
AttackType string `json:"attack_type"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ExportTrainDataApi 导出脱敏训练数据为 JSONL,落到 data/ai_export/ 供本地训练
|
||||
// aiExportRunning 导出任务并发保护:同一时刻只允许一个导出在跑
|
||||
var aiExportRunning int32
|
||||
|
||||
func (w *WafAIApi) ExportTrainDataApi(c *gin.Context) {
|
||||
var req request.WafAIExportReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
maxCount := req.MaxCount
|
||||
if maxCount <= 0 || maxCount > defaultExportMax {
|
||||
maxCount = defaultExportMax
|
||||
}
|
||||
|
||||
if global.GWAF_LOCAL_LOG_DB == nil {
|
||||
response.FailWithMessage("日志库未初始化", c)
|
||||
return
|
||||
}
|
||||
|
||||
// 导出是耗时操作(大表查询 + 逐行脱敏写盘),异步执行避免请求超时;
|
||||
// 完成/失败通过 OpResultMessageInfo 推送到管理端通知中心。
|
||||
if !atomic.CompareAndSwapInt32(&aiExportRunning, 0, 1) {
|
||||
response.FailWithMessage("已有导出任务正在进行,请等待完成", c)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer atomic.StoreInt32(&aiExportRunning, 0)
|
||||
outPath, nAttack, nNormal, nDrop, _, err := runAIExport(req, maxCount)
|
||||
serverName := global.GWAF_CUSTOM_SERVER_NAME
|
||||
if err != nil {
|
||||
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.OpResultMessageInfo{
|
||||
BaseMessageInfo: innerbean.BaseMessageInfo{OperaType: "AI训练数据导出", Server: serverName},
|
||||
Msg: "AI训练数据导出失败:" + err.Error(),
|
||||
Success: "false",
|
||||
})
|
||||
return
|
||||
}
|
||||
global.GQEQUE_MESSAGE_DB.Enqueue(innerbean.OpResultMessageInfo{
|
||||
BaseMessageInfo: innerbean.BaseMessageInfo{OperaType: "AI训练数据导出", Server: serverName},
|
||||
Msg: fmt.Sprintf("AI训练数据导出完毕:%s(写入%d条,攻击%d/正常%d,已忽略%d)",
|
||||
outPath, nAttack+nNormal, nAttack, nNormal, nDrop),
|
||||
Success: "true",
|
||||
})
|
||||
}()
|
||||
|
||||
response.OkWithMessage("导出任务已开始,完成后会在通知中心提示,文件生成在服务器 data/ai_export/ 目录", c)
|
||||
}
|
||||
|
||||
// runAIExport 实际执行训练数据导出,返回文件路径与各类计数。
|
||||
func runAIExport(req request.WafAIExportReq, maxCount int) (outPath string, nAttack, nNormal, nDrop, total int, err error) {
|
||||
query := global.GWAF_LOCAL_LOG_DB.Model(&innerbean.WebLog{}).
|
||||
Select("REQ_UUID", "METHOD", "URL", "RawQuery", "BODY", "POST_FORM", "USER_AGENT", "ACTION", "RULE", "LogOnlyMode")
|
||||
if req.Days > 0 {
|
||||
cutoff := time.Now().AddDate(0, 0, -req.Days).Format("2006-01-02 15:04:05")
|
||||
query = query.Where("create_time >= ?", cutoff)
|
||||
}
|
||||
|
||||
var rows []innerbean.WebLog
|
||||
if err = query.Order("unix_add_time desc").Limit(maxCount).Find(&rows).Error; err != nil {
|
||||
return "", 0, 0, 0, 0, fmt.Errorf("查询日志失败: %w", err)
|
||||
}
|
||||
total = len(rows)
|
||||
|
||||
dir := filepath.Join(utils.GetCurrentDir(), "data", aiExportDir)
|
||||
if err = os.MkdirAll(dir, 0750); err != nil {
|
||||
return "", 0, 0, 0, total, fmt.Errorf("创建导出目录失败: %w", err)
|
||||
}
|
||||
outPath = filepath.Join(dir, fmt.Sprintf("train_%s.jsonl", time.Now().Format("20060102_150405")))
|
||||
fh, ferr := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0640)
|
||||
if ferr != nil {
|
||||
return "", 0, 0, 0, total, fmt.Errorf("创建导出文件失败: %w", ferr)
|
||||
}
|
||||
defer fh.Close()
|
||||
enc := json.NewEncoder(fh)
|
||||
|
||||
// 一次性加载人工标记(含请求快照),人工修正优先于规则弱标签
|
||||
marks := wafAILabelService.GetAllFull()
|
||||
|
||||
// 1) 过滤窗口内的日志:仅处理"未人工标记"的(已标记的统一在第2步从快照产出,
|
||||
// 确保人工修正不会被时间/条数条件漏掉)
|
||||
for i := range rows {
|
||||
r := &rows[i]
|
||||
if _, marked := marks[r.REQ_UUID]; marked {
|
||||
continue
|
||||
}
|
||||
verdict, at := wafai.WeakLabel(r.ACTION, r.RULE, r.LogOnlyMode)
|
||||
if verdict == wafai.VerdictDrop {
|
||||
nDrop++
|
||||
continue
|
||||
}
|
||||
label := 0
|
||||
attackType := ""
|
||||
if verdict == wafai.VerdictAttack {
|
||||
// 仅信任高置信检测器(libinjection/OWASP)的攻击标签;
|
||||
// 低置信(scan/rce/traversal 等)未经人工确认则丢弃,避免规则误报污染训练
|
||||
if !wafai.IsHighConfidenceAttackType(at) {
|
||||
nDrop++
|
||||
continue
|
||||
}
|
||||
label = 1
|
||||
attackType = at
|
||||
}
|
||||
path, rawQuery := splitURL(r.URL, r.RawQuery)
|
||||
body := r.BODY
|
||||
if body == "" {
|
||||
body = r.POST_FORM
|
||||
}
|
||||
if label == 1 {
|
||||
nAttack++
|
||||
} else {
|
||||
nNormal++
|
||||
}
|
||||
s := trainSample{
|
||||
Method: strings.ToUpper(r.METHOD),
|
||||
Path: path,
|
||||
Query: desensitizeForExport(rawQuery),
|
||||
Body: desensitizeForExport(body),
|
||||
UserAgent: r.USER_AGENT,
|
||||
Label: label,
|
||||
AttackType: attackType,
|
||||
Source: "samwaf_log",
|
||||
}
|
||||
if err = enc.Encode(&s); err != nil {
|
||||
return outPath, nAttack, nNormal, nDrop, total, fmt.Errorf("写入导出文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 全部人工标记从快照产出,不受时间/条数条件限制(连原始日志被清理也能产出)
|
||||
for _, m := range marks {
|
||||
if m.Mark == "ignore" {
|
||||
nDrop++
|
||||
continue
|
||||
}
|
||||
label := 0
|
||||
if m.Mark == "attack" {
|
||||
label = 1
|
||||
}
|
||||
path, rawQuery := splitURL(m.URL, m.RAW_QUERY)
|
||||
if label == 1 {
|
||||
nAttack++
|
||||
} else {
|
||||
nNormal++
|
||||
}
|
||||
s := trainSample{
|
||||
Method: strings.ToUpper(m.METHOD),
|
||||
Path: path,
|
||||
Query: desensitizeForExport(rawQuery),
|
||||
Body: desensitizeForExport(m.BODY),
|
||||
UserAgent: m.USER_AGENT,
|
||||
Label: label,
|
||||
AttackType: m.AttackType,
|
||||
Source: "manual",
|
||||
}
|
||||
if err = enc.Encode(&s); err != nil {
|
||||
return outPath, nAttack, nNormal, nDrop, total, fmt.Errorf("写入导出文件失败: %w", err)
|
||||
}
|
||||
}
|
||||
return outPath, nAttack, nNormal, nDrop, total, nil
|
||||
}
|
||||
|
||||
// splitURL 将 URL 拆为 path 与 query;优先用已有的 RawQuery
|
||||
func splitURL(rawURL, rawQuery string) (string, string) {
|
||||
path := rawURL
|
||||
if rawQuery == "" {
|
||||
if u, err := url.Parse(rawURL); err == nil {
|
||||
path = u.Path
|
||||
rawQuery = u.RawQuery
|
||||
}
|
||||
} else if idx := strings.IndexByte(rawURL, '?'); idx >= 0 {
|
||||
path = rawURL[:idx]
|
||||
}
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
return path, rawQuery
|
||||
}
|
||||
|
||||
// desensitizeForExport 导出前再做一次脱敏(复用 godlp 引擎),避免敏感参数值随训练数据外泄
|
||||
func desensitizeForExport(s string) string {
|
||||
if s == "" || global.GWAF_DLP == nil {
|
||||
return s
|
||||
}
|
||||
return utils.DeSenText(s)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"SamWaf/model/wafenginmodel"
|
||||
"SamWaf/plugin"
|
||||
"SamWaf/utils"
|
||||
"SamWaf/wafai"
|
||||
"SamWaf/wafappengine"
|
||||
"SamWaf/wafconfig"
|
||||
"SamWaf/wafdb"
|
||||
@@ -263,6 +264,18 @@ func (m *wafSystenService) run() {
|
||||
global.GWAF_DLP_CONFIG = ldpConfig
|
||||
global.GWAF_REG_PUBLIC_KEY = publicKey
|
||||
|
||||
//初始化AI智能检测器,若存在已上传的模型包则加载(失败安全,不影响启动)
|
||||
global.GWAF_AI_DETECTOR = wafai.NewDetector()
|
||||
aiModelPath := filepath.Join(utils.GetCurrentDir(), "data", "ai_model", "current.swai")
|
||||
if _, err := os.Stat(aiModelPath); err == nil {
|
||||
if manifest, err := global.GWAF_AI_DETECTOR.LoadFromFile(aiModelPath); err != nil {
|
||||
zlog.Warn("AI模型加载失败,AI检测将不可用: ", err.Error())
|
||||
} else {
|
||||
zlog.Info(fmt.Sprintf("AI模型加载成功: version=%s feature=%s type=%s",
|
||||
manifest.ModelVersion, manifest.FeatureVersion, manifest.ModelType))
|
||||
}
|
||||
}
|
||||
|
||||
//owasp资源 释放
|
||||
err = wafinit.CheckAndReleaseDataset(owaspAssets, utils.GetCurrentDir()+"/data/owasp", "owasp")
|
||||
if err != nil {
|
||||
|
||||
+16
-12
@@ -28,18 +28,22 @@ var (
|
||||
GCONFIG_ENABLE_STRICT_IP_BINDING int64 = 1 // 是否启用严格IP绑定 1启用 0禁用
|
||||
GCONFIG_ENABLE_REPLAY_PROTECT int64 = 1 // 防重放攻击开关 1启用 0禁用
|
||||
|
||||
GCONFIG_RECORD_ENABLE_OWASP int64 = 0 //启动OWASP数据检测
|
||||
GCONFIG_OWASP_MODE string = "DetectionOnly" //OWASP 检测引擎工作模式: On(拦截) / DetectionOnly(观察/仅记录) / Off(关闭)
|
||||
GCONFIG_OWASP_BLOCK_THRESHOLD int64 = 7 //OWASP 入站 anomaly score 阈值(官方默认 5,我们宽松到 7)
|
||||
GCONFIG_RECORD_ENABLE_HTTP_80 int64 = 0 //启动80端口服务(为自动申请证书使用 HTTP文件验证类型,DNS验证不需要)
|
||||
GCONFIG_RECORD_SSLOrder_EXPIRE_DAY int64 = 30 // 提前多少天进行自动申请
|
||||
GCONFIG_RECORD_SSL_IP_CERT_IP string = "" // 获取IP证书时的IP地址
|
||||
GCONFIG_RECORD_SSL_IP_EXPIRE_DAY int64 = 3 // IP证书提前多少天进行自动申请
|
||||
GCONFIG_RECORD_SSLHTTP_CHECK int64 = 0 // ssl申请文件验证类型 是否校验原始路径HTTP响应代码 1 校验 0 不校验
|
||||
GCONFIG_RECORD_SSLMinVerson string = "TLS 1.2" // ssl最低版本
|
||||
GCONFIG_RECORD_SSLMaxVerson string = "TLS 1.3" // ssl最大版本
|
||||
GCONFIG_RECORD_CONNECT_TIME_OUT int64 = 30 // 连接超时 默认30s
|
||||
GCONFIG_RECORD_KEEPALIVE_TIME_OUT int64 = 30 // 保持活动超时 默认30s
|
||||
GCONFIG_RECORD_ENABLE_OWASP int64 = 0 //启动OWASP数据检测
|
||||
GCONFIG_OWASP_MODE string = "DetectionOnly" //OWASP 检测引擎工作模式: On(拦截) / DetectionOnly(观察/仅记录) / Off(关闭)
|
||||
|
||||
GCONFIG_AI_ENABLE int64 = 0 //AI智能检测总开关 1启用 0关闭(需先在AI模型管理上传模型包)
|
||||
GCONFIG_AI_MODE string = "observe" //AI检测工作模式: observe(仅记录/观察) / block(达到拦截阈值则拦截)
|
||||
|
||||
GCONFIG_OWASP_BLOCK_THRESHOLD int64 = 7 //OWASP 入站 anomaly score 阈值(官方默认 5,我们宽松到 7)
|
||||
GCONFIG_RECORD_ENABLE_HTTP_80 int64 = 0 //启动80端口服务(为自动申请证书使用 HTTP文件验证类型,DNS验证不需要)
|
||||
GCONFIG_RECORD_SSLOrder_EXPIRE_DAY int64 = 30 // 提前多少天进行自动申请
|
||||
GCONFIG_RECORD_SSL_IP_CERT_IP string = "" // 获取IP证书时的IP地址
|
||||
GCONFIG_RECORD_SSL_IP_EXPIRE_DAY int64 = 3 // IP证书提前多少天进行自动申请
|
||||
GCONFIG_RECORD_SSLHTTP_CHECK int64 = 0 // ssl申请文件验证类型 是否校验原始路径HTTP响应代码 1 校验 0 不校验
|
||||
GCONFIG_RECORD_SSLMinVerson string = "TLS 1.2" // ssl最低版本
|
||||
GCONFIG_RECORD_SSLMaxVerson string = "TLS 1.3" // ssl最大版本
|
||||
GCONFIG_RECORD_CONNECT_TIME_OUT int64 = 30 // 连接超时 默认30s
|
||||
GCONFIG_RECORD_KEEPALIVE_TIME_OUT int64 = 30 // 保持活动超时 默认30s
|
||||
//GCONFIG_RECORD_PATCH_VERSION_CORE int64 = 20250106 // 核心数据库补丁日期
|
||||
//GCONFIG_RECORD_PATCH_VERSION_LOG int64 = 20250106 // 日志数据库补丁日期
|
||||
GCONFIG_RECORD_ALL_SRC_BYTE_INFO int64 = 0 //记录原始信息(默认不开启)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"SamWaf/iplocation"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/spec"
|
||||
"SamWaf/wafai"
|
||||
"SamWaf/wafnotify"
|
||||
"SamWaf/wafowasp"
|
||||
"SamWaf/wafsnowflake"
|
||||
@@ -99,6 +100,7 @@ var (
|
||||
GWAF_APP_OP_PASSWORD string //应用操作密码(高危操作二次确认,自动生成存 config.yml)
|
||||
GWAF_DLP dlpheader.EngineAPI // 脱敏引擎
|
||||
GWAF_DLP_CONFIG string // 脱敏引擎配置数据
|
||||
GWAF_AI_DETECTOR *wafai.Detector // AI智能检测器(持有当前模型,失败安全)
|
||||
|
||||
GWAF_OWASP *wafowasp.WafOWASP //owasp引擎(兼容保留:由 GWAF_OWASP_MANAGER.Current() 更新;请勿直接赋值新实例,否则热重载会失效)
|
||||
GWAF_OWASP_MANAGER *wafowasp.OwaspManager //owasp 管理器(支持热重载)
|
||||
|
||||
@@ -80,6 +80,7 @@ require (
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dmitryikh/leaves v0.0.0-20230708180554-25d19a787328 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
|
||||
@@ -111,6 +111,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
|
||||
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
|
||||
github.com/dmitryikh/leaves v0.0.0-20230708180554-25d19a787328 h1:ht/zhLOAy9iiEKTKGkXvpw92Z7O6NK0bIVZVREy0kIE=
|
||||
github.com/dmitryikh/leaves v0.0.0-20230708180554-25d19a787328/go.mod h1:wzMig9tMIJB8HsxXHppa9yRPo8BpO0eBM/Z4xnaohCQ=
|
||||
github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
|
||||
github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
|
||||
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
|
||||
|
||||
+50
-49
@@ -3,55 +3,56 @@ package innerbean
|
||||
import "strings"
|
||||
|
||||
type WebLog struct {
|
||||
WafInnerDFlag string `gorm:"size:10" json:"waf_inner_dflag"` //日志队列处理方式
|
||||
HOST string `gorm:"size:255" json:"host"`
|
||||
URL string `gorm:"type:text" json:"url"`
|
||||
RawQuery string `gorm:"type:text" json:"raw_query"` //原始URL查询
|
||||
REFERER string `gorm:"type:text" json:"referer"`
|
||||
USER_AGENT string `gorm:"size:500" json:"user_agent"`
|
||||
METHOD string `gorm:"size:20" json:"method"`
|
||||
HEADER string `gorm:"type:text" json:"header"`
|
||||
SRC_IP string `gorm:"size:64" json:"src_ip"`
|
||||
SRC_PORT string `gorm:"size:10" json:"src_port"`
|
||||
COUNTRY string `gorm:"size:100" json:"country"`
|
||||
PROVINCE string `gorm:"size:100" json:"province"`
|
||||
CITY string `gorm:"size:100" json:"city"`
|
||||
CREATE_TIME string `gorm:"size:32;index:idx_weblog_time" json:"create_time"`
|
||||
CONTENT_LENGTH int64 `json:"content_length"`
|
||||
RES_CONTENT_LENGTH int64 `json:"res_content_length"` //响应内容大小(字节)
|
||||
COOKIES string `gorm:"type:text" json:"cookies"`
|
||||
BODY string `gorm:"type:text" json:"body"`
|
||||
REQ_UUID string `gorm:"size:64" json:"req_uuid"`
|
||||
USER_CODE string `gorm:"size:64;index" json:"user_code"`
|
||||
TenantId string `gorm:"size:64;index" json:"tenant_id"` //租户ID(主要键)
|
||||
HOST_CODE string `gorm:"size:64" json:"host_code"` //主机ID (主要键)
|
||||
Day int `json:"day"` //日 (主要键)
|
||||
ACTION string `gorm:"size:100" json:"action"`
|
||||
RULE string `gorm:"type:text" json:"rule"`
|
||||
STATUS string `gorm:"size:50" json:"status"` //状态
|
||||
STATUS_CODE int `json:"status_code"` //状态编码
|
||||
RES_BODY string `gorm:"type:text" json:"res_body"` //返回信息
|
||||
POST_FORM string `gorm:"type:text" json:"post_form"` //提交的表单数据
|
||||
TASK_FLAG int `json:"task_flag" gorm:"default:-1;index"` //任务处理标记 -1 等待处理;1 可以进行处理;2 处理完毕
|
||||
UNIX_ADD_TIME int64 `json:"unix_add_time" gorm:"index"` //添加日期unix
|
||||
RISK_LEVEL int `json:"risk_level"` //危险等级 0:正常 1:轻微 2:有害 3:严重 4:特别严重
|
||||
GUEST_IDENTIFICATION string `gorm:"column:guest_id_entification;size:191" json:"guest_identification"` //访客身份识别
|
||||
IsBot int `json:"is_bot"` //是否是机器人 0 不是机器人 1 机器人
|
||||
TimeSpent int64 `json:"time_spent"` //用时
|
||||
NetSrcIp string `gorm:"size:64" json:"net_src_ip"` //获取的原始IP
|
||||
SrcByteBody []byte `json:"src_byte_body"` //原始body信息
|
||||
SrcByteResBody []byte `json:"src_byte_res_body"` //返回body bytes信息
|
||||
WebLogVersion int `json:"web_log_version"` //日志版本信息早期的是空和0,后期实时增加
|
||||
Scheme string `gorm:"size:20" json:"scheme"` //HTTP 协议
|
||||
SrcURL []byte `json:"src_url"` //原始url信息
|
||||
PreCheckCost int64 `json:"pre_check_cost"` // 前置检查耗时(ms)
|
||||
ForwardCost int64 `json:"forward_cost"` // 转发耗时(ms)
|
||||
BackendCheckCost int64 `json:"backend_check_cost"` // 后端处理耗时(ms)
|
||||
ResHeader string `gorm:"type:text" json:"res_header"` // 返回header情况
|
||||
BodyHash string `gorm:"size:100" json:"body_hash"` // body hash值
|
||||
LogOnlyMode int `json:"log_only_mode"` //是否只记录日志 1 是 0 不是
|
||||
IsBalance int `json:"is_balance"` //是否是负载均衡 1 是 0 不是
|
||||
BalanceInfo string `gorm:"size:255" json:"balance_info"` //负载均衡IP端口信息
|
||||
WafInnerDFlag string `gorm:"size:10" json:"waf_inner_dflag"` //日志队列处理方式
|
||||
HOST string `gorm:"size:255" json:"host"`
|
||||
URL string `gorm:"type:text" json:"url"`
|
||||
RawQuery string `gorm:"type:text" json:"raw_query"` //原始URL查询
|
||||
REFERER string `gorm:"type:text" json:"referer"`
|
||||
USER_AGENT string `gorm:"size:500" json:"user_agent"`
|
||||
METHOD string `gorm:"size:20" json:"method"`
|
||||
HEADER string `gorm:"type:text" json:"header"`
|
||||
SRC_IP string `gorm:"size:64" json:"src_ip"`
|
||||
SRC_PORT string `gorm:"size:10" json:"src_port"`
|
||||
COUNTRY string `gorm:"size:100" json:"country"`
|
||||
PROVINCE string `gorm:"size:100" json:"province"`
|
||||
CITY string `gorm:"size:100" json:"city"`
|
||||
CREATE_TIME string `gorm:"size:32;index:idx_weblog_time" json:"create_time"`
|
||||
CONTENT_LENGTH int64 `json:"content_length"`
|
||||
RES_CONTENT_LENGTH int64 `json:"res_content_length"` //响应内容大小(字节)
|
||||
COOKIES string `gorm:"type:text" json:"cookies"`
|
||||
BODY string `gorm:"type:text" json:"body"`
|
||||
REQ_UUID string `gorm:"size:64" json:"req_uuid"`
|
||||
USER_CODE string `gorm:"size:64;index" json:"user_code"`
|
||||
TenantId string `gorm:"size:64;index" json:"tenant_id"` //租户ID(主要键)
|
||||
HOST_CODE string `gorm:"size:64" json:"host_code"` //主机ID (主要键)
|
||||
Day int `json:"day"` //日 (主要键)
|
||||
ACTION string `gorm:"size:100" json:"action"`
|
||||
RULE string `gorm:"type:text" json:"rule"`
|
||||
STATUS string `gorm:"size:50" json:"status"` //状态
|
||||
STATUS_CODE int `json:"status_code"` //状态编码
|
||||
RES_BODY string `gorm:"type:text" json:"res_body"` //返回信息
|
||||
POST_FORM string `gorm:"type:text" json:"post_form"` //提交的表单数据
|
||||
TASK_FLAG int `json:"task_flag" gorm:"default:-1;index"` //任务处理标记 -1 等待处理;1 可以进行处理;2 处理完毕
|
||||
UNIX_ADD_TIME int64 `json:"unix_add_time" gorm:"index"` //添加日期unix
|
||||
RISK_LEVEL int `json:"risk_level"` //危险等级 0:正常 1:轻微 2:有害 3:严重 4:特别严重
|
||||
GUEST_IDENTIFICATION string `gorm:"column:guest_id_entification;size:191" json:"guest_identification"` //访客身份识别
|
||||
IsBot int `json:"is_bot"` //是否是机器人 0 不是机器人 1 机器人
|
||||
TimeSpent int64 `json:"time_spent"` //用时
|
||||
NetSrcIp string `gorm:"size:64" json:"net_src_ip"` //获取的原始IP
|
||||
SrcByteBody []byte `json:"src_byte_body"` //原始body信息
|
||||
SrcByteResBody []byte `json:"src_byte_res_body"` //返回body bytes信息
|
||||
WebLogVersion int `json:"web_log_version"` //日志版本信息早期的是空和0,后期实时增加
|
||||
Scheme string `gorm:"size:20" json:"scheme"` //HTTP 协议
|
||||
SrcURL []byte `json:"src_url"` //原始url信息
|
||||
PreCheckCost int64 `json:"pre_check_cost"` // 前置检查耗时(ms)
|
||||
ForwardCost int64 `json:"forward_cost"` // 转发耗时(ms)
|
||||
BackendCheckCost int64 `json:"backend_check_cost"` // 后端处理耗时(ms)
|
||||
ResHeader string `gorm:"type:text" json:"res_header"` // 返回header情况
|
||||
BodyHash string `gorm:"size:100" json:"body_hash"` // body hash值
|
||||
LogOnlyMode int `json:"log_only_mode"` //是否只记录日志 1 是 0 不是
|
||||
IsBalance int `json:"is_balance"` //是否是负载均衡 1 是 0 不是
|
||||
BalanceInfo string `gorm:"size:255" json:"balance_info"` //负载均衡IP端口信息
|
||||
AI_SCORE float64 `json:"ai_score"` //AI检测得分[0,1],0表示未经AI检测或未命中;命中(观察/拦截)时记录实际分数
|
||||
}
|
||||
|
||||
// GetHeaderValue 从HEADER字段中提取指定header的值
|
||||
|
||||
@@ -61,6 +61,7 @@ type HostsDefense struct {
|
||||
DEFENSE_SENSITIVE int `json:"sensitive"` //敏感词检测
|
||||
DEFENSE_DIR_TRAVERSAL int `json:"traversal"` //目录穿越检测
|
||||
DEFENSE_OWASP_SET int `json:"owaspset"` //OWASP集检测
|
||||
DEFENSE_AI int `json:"ai"` //AI智能检测(默认关闭,需先上传模型包并开启全局AI开关)
|
||||
}
|
||||
|
||||
// HealthyConfig 健康度检测
|
||||
@@ -307,6 +308,7 @@ func ParseHostsDefense(defenseJSON string) HostsDefense {
|
||||
defense.DEFENSE_SENSITIVE = 1
|
||||
defense.DEFENSE_DIR_TRAVERSAL = 1
|
||||
defense.DEFENSE_OWASP_SET = 0
|
||||
defense.DEFENSE_AI = 0
|
||||
|
||||
// 如果JSON不为空,则解析覆盖默认值
|
||||
if defenseJSON != "" {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package request
|
||||
|
||||
// WafAIExportReq 训练数据导出入参
|
||||
type WafAIExportReq struct {
|
||||
Days int `json:"days"` // 导出最近多少天的日志(<=0 表示不限)
|
||||
MaxCount int `json:"max_count"` // 最多导出多少条(<=0 表示用默认上限)
|
||||
}
|
||||
|
||||
// WafAIDashboardReq AI检测看板入参
|
||||
type WafAIDashboardReq struct {
|
||||
StartDay int `json:"start_day"` // 起始日 YYYYMMDD(<=0 表示不限)
|
||||
EndDay int `json:"end_day"` // 结束日 YYYYMMDD(<=0 表示不限)
|
||||
HostCode string `json:"host_code"` // 站点编码,空表示全部站点
|
||||
}
|
||||
|
||||
// WafAILabelMarkReq 训练标签人工修正入参
|
||||
type WafAILabelMarkReq struct {
|
||||
ReqUuid string `json:"req_uuid" binding:"required"` // 关联请求日志
|
||||
HostCode string `json:"host_code"`
|
||||
Mark string `json:"mark" binding:"required"` // normal / attack / ignore
|
||||
AttackType string `json:"attack_type"` // mark=attack 时的人工分类,空=自动判定
|
||||
Rule string `json:"rule"` // 原始触发规则(快照)
|
||||
SrcIp string `json:"src_ip"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
// WafAILabelUnmarkReq 取消标记入参
|
||||
type WafAILabelUnmarkReq struct {
|
||||
ReqUuid string `json:"req_uuid" binding:"required"`
|
||||
}
|
||||
|
||||
// WafAILabelByUuidsReq 按 req_uuid 批量查询标记状态(用于日志列表回显)
|
||||
type WafAILabelByUuidsReq struct {
|
||||
ReqUuids []string `json:"req_uuids"`
|
||||
}
|
||||
|
||||
// WafAILabelListReq 标注工作台列表入参:在 AI 命中(ai_score>0)子集上分页 + 过滤
|
||||
type WafAILabelListReq struct {
|
||||
HostCode string `json:"host_code"` // 站点编码,空=全部
|
||||
StartDay int `json:"start_day"` // 起始日 YYYYMMDD(<=0 不限)
|
||||
EndDay int `json:"end_day"` // 结束日 YYYYMMDD(<=0 不限)
|
||||
MarkStatus string `json:"mark_status"` // ""=全部 / unmarked / marked / normal / attack / ignore
|
||||
MinScore float64 `json:"min_score"` // 最小分数(<=0 不限)
|
||||
PageIndex int `json:"page_index"` // 页码,从 1 开始
|
||||
PageSize int `json:"page_size"` // 每页条数
|
||||
}
|
||||
|
||||
// WafAILabelBatchMarkReq 批量标记入参
|
||||
type WafAILabelBatchMarkReq struct {
|
||||
ReqUuids []string `json:"req_uuids" binding:"required"` // 待标记的请求列表
|
||||
Mark string `json:"mark" binding:"required"` // normal / attack / ignore
|
||||
AttackType string `json:"attack_type"` // mark=attack 时的人工分类,空=每条按原始日志自动判定
|
||||
}
|
||||
|
||||
// WafAILabelBatchUnmarkReq 批量取消标记入参
|
||||
type WafAILabelBatchUnmarkReq struct {
|
||||
ReqUuids []string `json:"req_uuids" binding:"required"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package response
|
||||
|
||||
// WafAINameValue 通用名称-数值对(类别汇总/分数分布用)
|
||||
type WafAINameValue struct {
|
||||
Name string `json:"name"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// WafAITrendPoint AI命中按天趋势点
|
||||
type WafAITrendPoint struct {
|
||||
Day int `json:"day"` // YYYYMMDD
|
||||
Observe int64 `json:"observe"` // 观察命中数(log_only_mode=1)
|
||||
Block int64 `json:"block"` // 拦截命中数(log_only_mode=0)
|
||||
}
|
||||
|
||||
// WafAIDashboard AI检测看板聚合结果
|
||||
type WafAIDashboard struct {
|
||||
Total int64 `json:"total"` // AI命中总数
|
||||
ObserveCnt int64 `json:"observe_cnt"` // 观察命中数
|
||||
BlockCnt int64 `json:"block_cnt"` // 拦截命中数
|
||||
Categories []WafAINameValue `json:"categories"` // 按类别汇总
|
||||
ScoreHist []WafAINameValue `json:"score_hist"` // 分数分布直方图(10桶)
|
||||
Trend []WafAITrendPoint `json:"trend"` // 按天 observe/block 趋势
|
||||
}
|
||||
|
||||
// WafAILabelItem 标注工作台列表项(AI命中 + 当前标记状态 + 请求详情)
|
||||
type WafAILabelItem struct {
|
||||
ReqUuid string `json:"req_uuid"`
|
||||
CreateTime string `json:"create_time"`
|
||||
HostCode string `json:"host_code"`
|
||||
SrcIp string `json:"src_ip"`
|
||||
Method string `json:"method"`
|
||||
Url string `json:"url"`
|
||||
RawQuery string `json:"raw_query"`
|
||||
Body string `json:"body"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
AiScore float64 `json:"ai_score"`
|
||||
Rule string `json:"rule"` // 命中规则文本(AI检测:<类别>)
|
||||
LogOnlyMode int `json:"log_only_mode"` // 1 观察 0 拦截
|
||||
Mark string `json:"mark"` // 当前人工标记 normal/attack/ignore,空=未标记
|
||||
AttackType string `json:"attack_type"` // 人工分类
|
||||
}
|
||||
|
||||
// WafAILabelList 标注工作台分页结果
|
||||
type WafAILabelList struct {
|
||||
Total int64 `json:"total"`
|
||||
Rows []WafAILabelItem `json:"rows"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import "SamWaf/model/baseorm"
|
||||
|
||||
/*
|
||||
*
|
||||
训练标签人工修正
|
||||
针对单条访问日志(按 req_uuid)修正其训练标签,用于纠正规则误报导致的错误弱标签。
|
||||
导出训练数据时优先采用此处的人工修正结果。
|
||||
*/
|
||||
type WafLogLabelMark struct {
|
||||
baseorm.BaseOrm
|
||||
REQ_UUID string `gorm:"size:64;index" json:"req_uuid"` // 关联的请求日志
|
||||
HOST_CODE string `gorm:"size:64" json:"host_code"` // 主机码
|
||||
Mark string `gorm:"size:20" json:"mark"` // normal=实际正常 / attack=确认攻击 / ignore=不参与训练
|
||||
AttackType string `gorm:"size:32" json:"attack_type"` // 当 mark=attack 时的人工分类(sqli/xss/rce/traversal/inject/scan/other),空=用自动判定
|
||||
RULE string `gorm:"type:text" json:"rule"` // 原始触发规则(快照,便于审阅)
|
||||
SRC_IP string `gorm:"size:64" json:"src_ip"` // 来源IP(快照)
|
||||
URL string `gorm:"type:text" json:"url"` // 访问URL(快照)
|
||||
// 训练字段快照:标记时即固化,导出不受时间/条数条件影响,日志被清理也不丢
|
||||
METHOD string `gorm:"size:20" json:"method"` // 请求方法(快照)
|
||||
RAW_QUERY string `gorm:"type:text" json:"raw_query"` // 查询串(快照)
|
||||
BODY string `gorm:"type:text" json:"body"` // 请求体(快照,body 或 post_form)
|
||||
USER_AGENT string `gorm:"size:500" json:"user_agent"` // UA(快照)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ type ApiGroup struct {
|
||||
WafOwaspRouter
|
||||
WafHostPathRuleRouter
|
||||
WafAppRouter
|
||||
WafAIRouter
|
||||
}
|
||||
type PublicApiGroup struct {
|
||||
LoginRouter
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"SamWaf/api"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WafAIRouter struct {
|
||||
}
|
||||
|
||||
// InitWafAIRouter AI智能检测:模型管理与训练数据导出。
|
||||
// 涉及模型上传(会被引擎加载)与数据导出(数据出库),属安全敏感接口,
|
||||
// 应挂在仅 Token 登录可访问的路由组上。
|
||||
func (receiver *WafAIRouter) InitWafAIRouter(group *gin.RouterGroup) {
|
||||
apiInstance := api.APIGroupAPP.WafAIApi
|
||||
router := group.Group("/api/v1/ai")
|
||||
{
|
||||
router.GET("/status", apiInstance.GetAIStatusApi)
|
||||
router.POST("/dashboard", apiInstance.GetAIDashboardApi)
|
||||
router.POST("/model/upload", apiInstance.UploadAIModelApi)
|
||||
router.POST("/model/reload", apiInstance.ReloadAIModelApi)
|
||||
router.POST("/model/unload", apiInstance.UnloadAIModelApi)
|
||||
router.POST("/export", apiInstance.ExportTrainDataApi)
|
||||
router.POST("/label/mark", apiInstance.MarkLabelApi)
|
||||
router.POST("/label/unmark", apiInstance.UnmarkLabelApi)
|
||||
router.POST("/label/by_uuids", apiInstance.LabelByUuidsApi)
|
||||
router.POST("/label/list", apiInstance.LabelListApi)
|
||||
router.POST("/label/batch_mark", apiInstance.BatchMarkLabelApi)
|
||||
router.POST("/label/batch_unmark", apiInstance.BatchUnmarkLabelApi)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package waf_service
|
||||
|
||||
import (
|
||||
"SamWaf/common/uuid"
|
||||
"SamWaf/customtype"
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/model"
|
||||
"SamWaf/model/baseorm"
|
||||
"SamWaf/model/request"
|
||||
response2 "SamWaf/model/response"
|
||||
"SamWaf/wafai"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WafAILabelService struct{}
|
||||
|
||||
var WafAILabelServiceApp = new(WafAILabelService)
|
||||
|
||||
// 合法的标记取值
|
||||
var validMarks = map[string]bool{"normal": true, "attack": true, "ignore": true}
|
||||
|
||||
// LabelMarkInfo 标记回显/导出用的精简信息
|
||||
type LabelMarkInfo struct {
|
||||
Mark string `json:"mark"`
|
||||
AttackType string `json:"attack_type"`
|
||||
}
|
||||
|
||||
// MarkApi 新增/更新某条日志的训练标签修正(按 req_uuid 幂等 upsert)。
|
||||
// 标记时即从日志库读取该请求的训练字段做快照,导出不再受时间/条数条件限制,
|
||||
// 即使原始日志后续被清理也能产出该样本。
|
||||
func (receiver *WafAILabelService) MarkApi(req request.WafAILabelMarkReq) error {
|
||||
if !validMarks[req.Mark] {
|
||||
return errors.New("非法的标记类型")
|
||||
}
|
||||
|
||||
// 读取原始日志做快照(标记时日志通常仍存在)
|
||||
var wl innerbean.WebLog
|
||||
global.GWAF_LOCAL_LOG_DB.
|
||||
Select("METHOD", "URL", "RawQuery", "BODY", "POST_FORM", "USER_AGENT", "ACTION", "RULE", "SRC_IP", "HOST_CODE", "LogOnlyMode").
|
||||
Where("REQ_UUID = ?", req.ReqUuid).Limit(1).Find(&wl)
|
||||
|
||||
body := wl.BODY
|
||||
if body == "" {
|
||||
body = wl.POST_FORM
|
||||
}
|
||||
hostCode := wl.HOST_CODE
|
||||
if hostCode == "" {
|
||||
hostCode = req.HostCode
|
||||
}
|
||||
rule := wl.RULE
|
||||
if rule == "" {
|
||||
rule = req.Rule
|
||||
}
|
||||
srcIp := wl.SRC_IP
|
||||
if srcIp == "" {
|
||||
srcIp = req.SrcIp
|
||||
}
|
||||
url := wl.URL
|
||||
if url == "" {
|
||||
url = req.Url
|
||||
}
|
||||
|
||||
// 解析攻击分类:优先人工指定;为空且标记为攻击时按原始日志自动判定
|
||||
attackType := req.AttackType
|
||||
if req.Mark == "attack" && attackType == "" {
|
||||
if _, at := wafai.WeakLabel(wl.ACTION, wl.RULE, wl.LogOnlyMode); at != "" {
|
||||
attackType = at
|
||||
} else {
|
||||
attackType = "other"
|
||||
}
|
||||
}
|
||||
|
||||
fields := map[string]interface{}{
|
||||
"mark": req.Mark,
|
||||
"attack_type": attackType,
|
||||
"rule": rule,
|
||||
"host_code": hostCode,
|
||||
"src_ip": srcIp,
|
||||
"url": url,
|
||||
"method": strings.ToUpper(wl.METHOD),
|
||||
"raw_query": wl.RawQuery,
|
||||
"body": body,
|
||||
"user_agent": wl.USER_AGENT,
|
||||
"update_time": customtype.JsonTime(time.Now()),
|
||||
}
|
||||
|
||||
var existing model.WafLogLabelMark
|
||||
global.GWAF_LOCAL_DB.Where("req_uuid = ? and tenant_id = ? and user_code = ?",
|
||||
req.ReqUuid, global.GWAF_TENANT_ID, global.GWAF_USER_CODE).First(&existing)
|
||||
|
||||
if existing.Id != "" {
|
||||
return global.GWAF_LOCAL_DB.Model(&model.WafLogLabelMark{}).Where("id = ?", existing.Id).
|
||||
Updates(fields).Error
|
||||
}
|
||||
|
||||
bean := &model.WafLogLabelMark{
|
||||
BaseOrm: baseorm.BaseOrm{
|
||||
Id: uuid.GenUUID(),
|
||||
USER_CODE: global.GWAF_USER_CODE,
|
||||
Tenant_ID: global.GWAF_TENANT_ID,
|
||||
CREATE_TIME: customtype.JsonTime(time.Now()),
|
||||
UPDATE_TIME: customtype.JsonTime(time.Now()),
|
||||
},
|
||||
REQ_UUID: req.ReqUuid,
|
||||
HOST_CODE: hostCode,
|
||||
Mark: req.Mark,
|
||||
AttackType: attackType,
|
||||
RULE: rule,
|
||||
SRC_IP: srcIp,
|
||||
URL: url,
|
||||
METHOD: strings.ToUpper(wl.METHOD),
|
||||
RAW_QUERY: wl.RawQuery,
|
||||
BODY: body,
|
||||
USER_AGENT: wl.USER_AGENT,
|
||||
}
|
||||
return global.GWAF_LOCAL_DB.Create(bean).Error
|
||||
}
|
||||
|
||||
// UnmarkApi 取消某条日志的标记
|
||||
func (receiver *WafAILabelService) UnmarkApi(reqUuid string) error {
|
||||
return global.GWAF_LOCAL_DB.Where("req_uuid = ? and tenant_id = ? and user_code = ?",
|
||||
reqUuid, global.GWAF_TENANT_ID, global.GWAF_USER_CODE).Delete(&model.WafLogLabelMark{}).Error
|
||||
}
|
||||
|
||||
// GetMapByUuidsApi 按 req_uuid 批量返回标记(用于日志列表回显)
|
||||
func (receiver *WafAILabelService) GetMapByUuidsApi(uuids []string) map[string]LabelMarkInfo {
|
||||
result := map[string]LabelMarkInfo{}
|
||||
if len(uuids) == 0 {
|
||||
return result
|
||||
}
|
||||
var rows []model.WafLogLabelMark
|
||||
global.GWAF_LOCAL_DB.Where("tenant_id = ? and user_code = ? and req_uuid in ?",
|
||||
global.GWAF_TENANT_ID, global.GWAF_USER_CODE, uuids).Find(&rows)
|
||||
for _, r := range rows {
|
||||
result[r.REQ_UUID] = LabelMarkInfo{Mark: r.Mark, AttackType: r.AttackType}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllFull 返回当前租户/用户的全部标记(含请求快照),导出时一次性加载,按 req_uuid 索引。
|
||||
func (receiver *WafAILabelService) GetAllFull() map[string]model.WafLogLabelMark {
|
||||
result := map[string]model.WafLogLabelMark{}
|
||||
var rows []model.WafLogLabelMark
|
||||
global.GWAF_LOCAL_DB.Where("tenant_id = ? and user_code = ?",
|
||||
global.GWAF_TENANT_ID, global.GWAF_USER_CODE).Find(&rows)
|
||||
for _, r := range rows {
|
||||
result[r.REQ_UUID] = r
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetMarkStatusMap 轻量返回当前租户/用户全部标记的状态(mark+attack_type),不含请求快照。
|
||||
// 供标注工作台列表回显与按标记状态过滤使用。
|
||||
func (receiver *WafAILabelService) GetMarkStatusMap() map[string]LabelMarkInfo {
|
||||
result := map[string]LabelMarkInfo{}
|
||||
var rows []model.WafLogLabelMark
|
||||
global.GWAF_LOCAL_DB.Select("req_uuid", "mark", "attack_type").
|
||||
Where("tenant_id = ? and user_code = ?", global.GWAF_TENANT_ID, global.GWAF_USER_CODE).Find(&rows)
|
||||
for _, r := range rows {
|
||||
result[r.REQ_UUID] = LabelMarkInfo{Mark: r.Mark, AttackType: r.AttackType}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ListApi 标注工作台列表:在 AI 命中(ai_score>0)子集上分页查询,并合并人工标记状态。
|
||||
// 标记数据在 core 库、日志在 log 库(跨库无法 JOIN),故先取本租户标记集合,
|
||||
// 在内存中做"按标记状态过滤"与逐行回显。
|
||||
func (receiver *WafAILabelService) ListApi(req request.WafAILabelListReq) response2.WafAILabelList {
|
||||
res := response2.WafAILabelList{Rows: []response2.WafAILabelItem{}}
|
||||
if global.GWAF_LOCAL_LOG_DB == nil {
|
||||
return res
|
||||
}
|
||||
|
||||
pageIndex := req.PageIndex
|
||||
if pageIndex <= 0 {
|
||||
pageIndex = 1
|
||||
}
|
||||
pageSize := req.PageSize
|
||||
if pageSize <= 0 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
markMap := receiver.GetMarkStatusMap()
|
||||
|
||||
// 按标记状态预先算出 IN / NOT IN 的 uuid 集合
|
||||
var inUuids []string // 命中即纳入
|
||||
useNotIn := false // true 时改用 NOT IN
|
||||
emptyResult := false // 过滤后必然为空,直接返回
|
||||
switch req.MarkStatus {
|
||||
case "unmarked":
|
||||
for u := range markMap {
|
||||
inUuids = append(inUuids, u)
|
||||
}
|
||||
useNotIn = true // 排除全部已标记
|
||||
case "marked":
|
||||
for u := range markMap {
|
||||
inUuids = append(inUuids, u)
|
||||
}
|
||||
if len(inUuids) == 0 {
|
||||
emptyResult = true
|
||||
}
|
||||
case "normal", "attack", "ignore":
|
||||
for u, info := range markMap {
|
||||
if info.Mark == req.MarkStatus {
|
||||
inUuids = append(inUuids, u)
|
||||
}
|
||||
}
|
||||
if len(inUuids) == 0 {
|
||||
emptyResult = true
|
||||
}
|
||||
}
|
||||
if emptyResult {
|
||||
return res
|
||||
}
|
||||
|
||||
buildQ := func() *gorm.DB {
|
||||
q := global.GWAF_LOCAL_LOG_DB.Model(&innerbean.WebLog{}).Where("ai_score > 0")
|
||||
if req.StartDay > 0 && req.EndDay > 0 {
|
||||
q = q.Where("day between ? and ?", req.StartDay, req.EndDay)
|
||||
}
|
||||
if req.HostCode != "" {
|
||||
q = q.Where("host_code = ?", req.HostCode)
|
||||
}
|
||||
if req.MinScore > 0 {
|
||||
q = q.Where("ai_score >= ?", req.MinScore)
|
||||
}
|
||||
if len(inUuids) > 0 {
|
||||
if useNotIn {
|
||||
q = q.Where("req_uuid not in ?", inUuids)
|
||||
} else {
|
||||
q = q.Where("req_uuid in ?", inUuids)
|
||||
}
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
buildQ().Count(&res.Total)
|
||||
|
||||
var rows []innerbean.WebLog
|
||||
buildQ().Select("REQ_UUID", "CREATE_TIME", "HOST_CODE", "SRC_IP", "METHOD", "URL",
|
||||
"RawQuery", "BODY", "POST_FORM", "USER_AGENT", "AI_SCORE", "RULE", "LogOnlyMode").
|
||||
Order("ai_score desc").Order("unix_add_time desc").
|
||||
Offset((pageIndex - 1) * pageSize).Limit(pageSize).Find(&rows)
|
||||
|
||||
for i := range rows {
|
||||
r := &rows[i]
|
||||
body := r.BODY
|
||||
if body == "" {
|
||||
body = r.POST_FORM
|
||||
}
|
||||
item := response2.WafAILabelItem{
|
||||
ReqUuid: r.REQ_UUID,
|
||||
CreateTime: r.CREATE_TIME,
|
||||
HostCode: r.HOST_CODE,
|
||||
SrcIp: r.SRC_IP,
|
||||
Method: strings.ToUpper(r.METHOD),
|
||||
Url: r.URL,
|
||||
RawQuery: r.RawQuery,
|
||||
Body: body,
|
||||
UserAgent: r.USER_AGENT,
|
||||
AiScore: r.AI_SCORE,
|
||||
Rule: r.RULE,
|
||||
LogOnlyMode: r.LogOnlyMode,
|
||||
}
|
||||
if info, ok := markMap[r.REQ_UUID]; ok {
|
||||
item.Mark = info.Mark
|
||||
item.AttackType = info.AttackType
|
||||
}
|
||||
res.Rows = append(res.Rows, item)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// BatchMarkApi 批量标记:逐条复用 MarkApi(每条独立读取请求快照、按需自动判定分类)。
|
||||
// 返回成功标记条数。
|
||||
func (receiver *WafAILabelService) BatchMarkApi(req request.WafAILabelBatchMarkReq) (int, error) {
|
||||
if !validMarks[req.Mark] {
|
||||
return 0, errors.New("非法的标记类型")
|
||||
}
|
||||
n := 0
|
||||
for _, u := range req.ReqUuids {
|
||||
if strings.TrimSpace(u) == "" {
|
||||
continue
|
||||
}
|
||||
if err := receiver.MarkApi(request.WafAILabelMarkReq{
|
||||
ReqUuid: u,
|
||||
Mark: req.Mark,
|
||||
AttackType: req.AttackType,
|
||||
}); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// BatchUnmarkApi 批量取消标记,返回删除条数。
|
||||
func (receiver *WafAILabelService) BatchUnmarkApi(uuids []string) (int, error) {
|
||||
var clean []string
|
||||
for _, u := range uuids {
|
||||
if strings.TrimSpace(u) != "" {
|
||||
clean = append(clean, u)
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res := global.GWAF_LOCAL_DB.Where("tenant_id = ? and user_code = ? and req_uuid in ?",
|
||||
global.GWAF_TENANT_ID, global.GWAF_USER_CODE, clean).Delete(&model.WafLogLabelMark{})
|
||||
return int(res.RowsAffected), res.Error
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package waf_service
|
||||
|
||||
import (
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/model/request"
|
||||
response2 "SamWaf/model/response"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WafAIService struct{}
|
||||
|
||||
var WafAIServiceApp = new(WafAIService)
|
||||
|
||||
// DashboardApi 聚合 AI 检测看板数据:按类别汇总、分数分布、observe/block 趋势。
|
||||
// 数据源为 web_logs 中 ai_score>0 的命中子集(相对较小),按 day 范围/站点过滤。
|
||||
func (receiver *WafAIService) DashboardApi(req request.WafAIDashboardReq) response2.WafAIDashboard {
|
||||
var res response2.WafAIDashboard
|
||||
res.Categories = []response2.WafAINameValue{}
|
||||
res.Trend = []response2.WafAITrendPoint{}
|
||||
|
||||
if global.GWAF_LOCAL_LOG_DB == nil {
|
||||
res.ScoreHist = buildEmptyScoreHist()
|
||||
return res
|
||||
}
|
||||
|
||||
// 每次查询都用全新的 where 链,避免 GORM 条件被复用污染
|
||||
base := func() *gorm.DB {
|
||||
q := global.GWAF_LOCAL_LOG_DB.Model(&innerbean.WebLog{}).Where("ai_score > 0")
|
||||
if req.StartDay > 0 && req.EndDay > 0 {
|
||||
q = q.Where("day between ? and ?", req.StartDay, req.EndDay)
|
||||
}
|
||||
if req.HostCode != "" {
|
||||
q = q.Where("host_code = ?", req.HostCode)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// 1) 按类别(rule)汇总
|
||||
base().Select("rule as name, count(*) as value").
|
||||
Group("rule").Order("value desc").Scan(&res.Categories)
|
||||
|
||||
// 2) 分数分布直方图(10 桶:0.0-0.1 ... 0.9-1.0;score==1.0 归入最后一桶)
|
||||
type bucketRow struct {
|
||||
Bucket int
|
||||
Cnt int64
|
||||
}
|
||||
var brows []bucketRow
|
||||
base().Select("cast(ai_score*10 as int) as bucket, count(*) as cnt").
|
||||
Group("bucket").Scan(&brows)
|
||||
counts := make([]int64, 10)
|
||||
for _, b := range brows {
|
||||
idx := b.Bucket
|
||||
if idx >= 10 {
|
||||
idx = 9 // score==1.0 归入 0.9-1.0
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
counts[idx] += b.Cnt
|
||||
}
|
||||
res.ScoreHist = make([]response2.WafAINameValue, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
res.ScoreHist[i] = response2.WafAINameValue{
|
||||
Name: fmt.Sprintf("%.1f-%.1f", float64(i)/10, float64(i+1)/10),
|
||||
Value: counts[i],
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 按天 observe/block 趋势
|
||||
base().Select("day, sum(case when log_only_mode=1 then 1 else 0 end) as observe, sum(case when log_only_mode=0 then 1 else 0 end) as block").
|
||||
Group("day").Order("day asc").Scan(&res.Trend)
|
||||
|
||||
// 4) 汇总总数
|
||||
for _, t := range res.Trend {
|
||||
res.ObserveCnt += t.Observe
|
||||
res.BlockCnt += t.Block
|
||||
}
|
||||
res.Total = res.ObserveCnt + res.BlockCnt
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func buildEmptyScoreHist() []response2.WafAINameValue {
|
||||
hist := make([]response2.WafAINameValue, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
hist[i] = response2.WafAINameValue{Name: fmt.Sprintf("%.1f-%.1f", float64(i)/10, float64(i+1)/10)}
|
||||
}
|
||||
return hist
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package wafai
|
||||
|
||||
// InferenceEngine 推理引擎抽象。
|
||||
//
|
||||
// 一期实现为纯 Go GBDT(engine_gbdt.go,dmitryikh/leaves 加载 LightGBM)。
|
||||
// 二期可选 ONNX 深度模型增强档(engine_onnx.go,build tag 隔离)。
|
||||
type InferenceEngine interface {
|
||||
// Score 输入特征向量,返回 [0,1] 的攻击概率。
|
||||
Score(features []float64) float64
|
||||
// FeatureVersion 模型训练时使用的特征版本,用于与 Go 侧特征版本校验。
|
||||
FeatureVersion() string
|
||||
// Type 引擎类型标识:"gbdt" / "onnx"。
|
||||
Type() string
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package wafai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"github.com/dmitryikh/leaves"
|
||||
)
|
||||
|
||||
// gbdtEngine 基于 dmitryikh/leaves 的纯 Go LightGBM 推理引擎(一期)。
|
||||
type gbdtEngine struct {
|
||||
model *leaves.Ensemble
|
||||
featureVersion string
|
||||
}
|
||||
|
||||
// newGBDTEngine 从 LightGBM 文本模型字节构建引擎。
|
||||
// loadTransformation=true 让 leaves 加载 sigmoid 变换,PredictSingle 直接输出概率。
|
||||
func newGBDTEngine(modelBytes []byte, featureVersion string) (*gbdtEngine, error) {
|
||||
br := bufio.NewReader(bytes.NewReader(modelBytes))
|
||||
model, err := leaves.LGEnsembleFromReader(br, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 LightGBM 模型失败: %w", err)
|
||||
}
|
||||
if nf := model.NFeatures(); nf != FeatureCount {
|
||||
return nil, fmt.Errorf("模型特征维度=%d 与引擎=%d 不一致", nf, FeatureCount)
|
||||
}
|
||||
return &gbdtEngine{model: model, featureVersion: featureVersion}, nil
|
||||
}
|
||||
|
||||
func (e *gbdtEngine) Score(features []float64) float64 {
|
||||
// nIterations=0 表示使用全部树
|
||||
return e.model.PredictSingle(features, 0)
|
||||
}
|
||||
|
||||
func (e *gbdtEngine) FeatureVersion() string { return e.featureVersion }
|
||||
|
||||
func (e *gbdtEngine) Type() string { return "gbdt" }
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !samwaf_onnx
|
||||
|
||||
package wafai
|
||||
|
||||
import "errors"
|
||||
|
||||
// newONNXEngine 默认构建不含 ONNX 运行时(保持主分支零新增 CGO/外部依赖)。
|
||||
// 需要 ONNX 深度模型增强档时,用 `-tags samwaf_onnx` 编译并提供 engine_onnx.go 实现。
|
||||
func newONNXEngine(modelBytes []byte, featureVersion string) (InferenceEngine, error) {
|
||||
return nil, errors.New("当前构建未启用 ONNX 引擎(需使用 -tags samwaf_onnx 编译)")
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package wafai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
)
|
||||
|
||||
// 特征规范 v1
|
||||
//
|
||||
// !!! 重要 !!!
|
||||
// 本文件与 SamWafAI 仓库 samwafai/features/lexical.py 是同一份规范
|
||||
// (SamWafTechDoc 特征规范 v1)的双语言实现,二者必须逐字节对齐。
|
||||
// 任何改动(常量/词表/顺序/算法)必须同步修改 Python 侧并升级 FeatureVersion,
|
||||
// 且重新生成 wafai/testdata/feature_golden.json(uv run samwafai gen-golden)。
|
||||
const (
|
||||
FeatureVersion = "v1"
|
||||
maxComponentBytes = 4096 // 每个分量截断长度
|
||||
decodeMax = 3 // 百分号解码最大迭代次数
|
||||
ngramN = 3 // 字节 n-gram 长度
|
||||
ngramBuckets = 64 // n-gram 哈希桶数量
|
||||
|
||||
scalarFeatureCount = 22
|
||||
FeatureCount = scalarFeatureCount + ngramBuckets // 86
|
||||
)
|
||||
|
||||
// methodIdx HTTP 方法编码(大写比较;未知方法 -> 7)
|
||||
var methodIdx = map[string]float64{
|
||||
"GET": 0, "POST": 1, "PUT": 2, "DELETE": 3,
|
||||
"HEAD": 4, "OPTIONS": 5, "PATCH": 6,
|
||||
}
|
||||
|
||||
const methodIdxOther = 7
|
||||
|
||||
// 关键词表(小写;与 Python 侧严格一致)
|
||||
var (
|
||||
kwSQL = [][]byte{
|
||||
[]byte("select"), []byte("union"), []byte("insert into"), []byte("update "),
|
||||
[]byte("delete from"), []byte("drop table"), []byte("information_schema"),
|
||||
[]byte("sleep("), []byte("benchmark("), []byte("load_file"), []byte("group by"),
|
||||
[]byte("order by"), []byte("or 1=1"), []byte("' or '"), []byte("\" or \""),
|
||||
[]byte("concat("), []byte("char("), []byte("0x"), []byte("xp_"), []byte("exec("),
|
||||
[]byte("waitfor delay"), []byte("/*"), []byte("*/"), []byte("--"), []byte("@@"),
|
||||
}
|
||||
kwXSS = [][]byte{
|
||||
[]byte("<script"), []byte("</script"), []byte("javascript:"), []byte("onerror="),
|
||||
[]byte("onload="), []byte("onmouseover="), []byte("alert("), []byte("prompt("),
|
||||
[]byte("confirm("), []byte("document.cookie"), []byte("document.write"),
|
||||
[]byte("eval("), []byte("settimeout("), []byte("fromcharcode"), []byte("<iframe"),
|
||||
[]byte("<svg"), []byte("<img"), []byte("expression("), []byte("vbscript:"),
|
||||
[]byte("base64,"),
|
||||
}
|
||||
kwCMD = [][]byte{
|
||||
[]byte("/etc/passwd"), []byte("/etc/shadow"), []byte("/bin/sh"), []byte("/bin/bash"),
|
||||
[]byte("cmd.exe"), []byte("powershell"), []byte("wget "), []byte("curl "),
|
||||
[]byte("chmod "), []byte("nc -e"), []byte("bash -i"), []byte("whoami"),
|
||||
[]byte("ipconfig"), []byte("ifconfig"), []byte("&&"), []byte("||"), []byte("$("),
|
||||
[]byte("`"), []byte("ping -c"), []byte("net user"), []byte("system("),
|
||||
[]byte("passthru("), []byte("shell_exec("), []byte("popen("),
|
||||
}
|
||||
kwTraversal = [][]byte{
|
||||
[]byte("../"), []byte("..\\"), []byte("%2e%2e"), []byte("..%2f"), []byte("..%5c"),
|
||||
[]byte("/etc/"), []byte("c:\\"), []byte("c:/"), []byte("web.config"),
|
||||
[]byte("boot.ini"), []byte("win.ini"), []byte("/proc/self"), []byte("wp-config"),
|
||||
}
|
||||
kwProto = [][]byte{
|
||||
[]byte("<?php"), []byte("<%"), []byte("${"), []byte("#{"), []byte("{{"),
|
||||
[]byte("jndi:"), []byte("ldap://"), []byte("rmi://"), []byte("file://"),
|
||||
[]byte("php://"), []byte("data://"), []byte("gopher://"), []byte("dict://"),
|
||||
[]byte("expect://"), []byte("phar://"),
|
||||
}
|
||||
kwScannerUA = [][]byte{
|
||||
[]byte("sqlmap"), []byte("nikto"), []byte("nmap"), []byte("acunetix"),
|
||||
[]byte("nessus"), []byte("burp"), []byte("dirbuster"), []byte("wfuzz"),
|
||||
[]byte("fuzz"), []byte("masscan"), []byte("zgrab"), []byte("python-requests"),
|
||||
[]byte("go-http-client"), []byte("curl/"), []byte("wget/"), []byte("libwww"),
|
||||
}
|
||||
)
|
||||
|
||||
// FeatureNames 返回特征名顺序(与 Python feature_names() 一致),用于调试/导出。
|
||||
func FeatureNames() []string {
|
||||
names := []string{
|
||||
"path_len", "query_len", "body_len", "ua_len",
|
||||
"param_count", "max_param_len", "path_depth", "method_idx",
|
||||
"special_ratio", "digit_ratio", "letter_ratio", "upper_ratio",
|
||||
"nonascii_ratio", "entropy", "decode_layers", "query_eq_count",
|
||||
"kw_sql", "kw_xss", "kw_cmd", "kw_traversal", "kw_proto", "kw_scanner_ua",
|
||||
}
|
||||
for i := 0; i < ngramBuckets; i++ {
|
||||
names = append(names, "ngram_"+itoa(i))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// 关键词家族在特征向量中的下标(与 ExtractFeatures 布局一致)
|
||||
const (
|
||||
idxKwSQL = 16
|
||||
idxKwXSS = 17
|
||||
idxKwCMD = 18
|
||||
idxKwTraversal = 19
|
||||
idxKwProto = 20
|
||||
)
|
||||
|
||||
// CategoryHint 依据特征里的关键词家族计数,给 AI 命中一个粗粒度、可读且稳定的
|
||||
// 类别标签,用于日志展示与"按规则汇总"统计(取值是有限小集合,不会像分数那样发散)。
|
||||
//
|
||||
// 注意:这只是基于可疑关键词的启发式标注,模型本身只输出"异常概率",并不真正分类;
|
||||
// 该函数是 Go 侧的展示辅助,不参与打分、也不属于特征规范的双语言一致性约束。
|
||||
func CategoryHint(features []float64) string {
|
||||
if len(features) < scalarFeatureCount {
|
||||
return "异常请求"
|
||||
}
|
||||
cats := []struct {
|
||||
idx int
|
||||
name string
|
||||
}{
|
||||
{idxKwSQL, "SQL注入"},
|
||||
{idxKwXSS, "XSS"},
|
||||
{idxKwCMD, "命令执行"},
|
||||
{idxKwTraversal, "目录穿越"},
|
||||
{idxKwProto, "注入攻击"},
|
||||
}
|
||||
best := 0.0
|
||||
bestName := ""
|
||||
for _, c := range cats {
|
||||
if features[c.idx] > best { // 严格大于:并列时保留更高优先级(靠前)的类别
|
||||
best = features[c.idx]
|
||||
bestName = c.name
|
||||
}
|
||||
}
|
||||
if bestName == "" {
|
||||
return "异常请求"
|
||||
}
|
||||
return bestName
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [4]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
func truncate(s string) []byte {
|
||||
b := []byte(s)
|
||||
if len(b) > maxComponentBytes {
|
||||
b = b[:maxComponentBytes]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func isHex(c byte) bool {
|
||||
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')
|
||||
}
|
||||
|
||||
func hexVal(c byte) byte {
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
return c - '0'
|
||||
case c >= 'A' && c <= 'F':
|
||||
return c - 'A' + 10
|
||||
default:
|
||||
return c - 'a' + 10
|
||||
}
|
||||
}
|
||||
|
||||
// percentDecodeOnce 单次百分号解码:%XX -> 字节;'+' -> 空格;非法 % 序列原样保留。
|
||||
func percentDecodeOnce(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b))
|
||||
n := len(b)
|
||||
for i := 0; i < n; {
|
||||
c := b[i]
|
||||
if c == '%' && i+2 < n && isHex(b[i+1]) && isHex(b[i+2]) {
|
||||
out = append(out, hexVal(b[i+1])<<4|hexVal(b[i+2]))
|
||||
i += 3
|
||||
} else if c == '+' {
|
||||
out = append(out, ' ')
|
||||
i++
|
||||
} else {
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// iterDecode 迭代解码至多 decodeMax 次,返回 (解码结果, 实际生效的解码层数)。
|
||||
func iterDecode(b []byte) ([]byte, int) {
|
||||
layers := 0
|
||||
cur := b
|
||||
for k := 0; k < decodeMax; k++ {
|
||||
dec := percentDecodeOnce(cur)
|
||||
if bytes.Equal(dec, cur) {
|
||||
break
|
||||
}
|
||||
cur = dec
|
||||
layers++
|
||||
}
|
||||
return cur, layers
|
||||
}
|
||||
|
||||
// asciiLower 仅对 ASCII 'A'-'Z' 转小写(不处理多字节字符)。
|
||||
func asciiLower(b []byte) []byte {
|
||||
out := make([]byte, len(b))
|
||||
for i, c := range b {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
out[i] = c + 32
|
||||
} else {
|
||||
out[i] = c
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fnv1a32(b []byte) uint32 {
|
||||
var h uint32 = 2166136261
|
||||
for _, c := range b {
|
||||
h ^= uint32(c)
|
||||
h *= 16777619
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func entropy(b []byte) float64 {
|
||||
if len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
var counts [256]int
|
||||
for _, c := range b {
|
||||
counts[c]++
|
||||
}
|
||||
n := float64(len(b))
|
||||
ent := 0.0
|
||||
for _, cnt := range counts {
|
||||
if cnt > 0 {
|
||||
p := float64(cnt) / n
|
||||
ent -= p * math.Log2(p)
|
||||
}
|
||||
}
|
||||
return ent
|
||||
}
|
||||
|
||||
func isPunct(c byte) bool {
|
||||
return (c >= 0x21 && c <= 0x2F) || (c >= 0x3A && c <= 0x40) ||
|
||||
(c >= 0x5B && c <= 0x60) || (c >= 0x7B && c <= 0x7E)
|
||||
}
|
||||
|
||||
func countKeywords(haystack []byte, kws [][]byte) float64 {
|
||||
total := 0
|
||||
for _, kw := range kws {
|
||||
total += bytes.Count(haystack, kw)
|
||||
}
|
||||
return float64(total)
|
||||
}
|
||||
|
||||
// ExtractFeatures 提取特征向量(FeatureCount 维,顺序固定,与 Python 一致)。
|
||||
//
|
||||
// 入参均为原始字符串(path/query/body 未解码;query 不含 '?';method 任意大小写)。
|
||||
func ExtractFeatures(method, path, query, body, userAgent string) []float64 {
|
||||
pathB := truncate(path)
|
||||
queryB := truncate(query)
|
||||
bodyB := truncate(body)
|
||||
uaB := truncate(userAgent)
|
||||
|
||||
pathD, l1 := iterDecode(pathB)
|
||||
queryD, l2 := iterDecode(queryB)
|
||||
bodyD, l3 := iterDecode(bodyB)
|
||||
decodeLayers := l1
|
||||
if l2 > decodeLayers {
|
||||
decodeLayers = l2
|
||||
}
|
||||
if l3 > decodeLayers {
|
||||
decodeLayers = l3
|
||||
}
|
||||
|
||||
combined := make([]byte, 0, len(pathD)+len(queryD)+len(bodyD)+2)
|
||||
combined = append(combined, pathD...)
|
||||
combined = append(combined, '\n')
|
||||
combined = append(combined, queryD...)
|
||||
combined = append(combined, '\n')
|
||||
combined = append(combined, bodyD...)
|
||||
combinedLower := asciiLower(combined)
|
||||
uaLower := asciiLower(uaB)
|
||||
|
||||
f := make([]float64, FeatureCount)
|
||||
|
||||
// --- 长度/结构类 ---
|
||||
f[0] = float64(len(pathB))
|
||||
f[1] = float64(len(queryB))
|
||||
f[2] = float64(len(bodyB))
|
||||
f[3] = float64(len(uaB))
|
||||
|
||||
paramCount := 0
|
||||
maxParamLen := 0
|
||||
for _, seg := range bytes.Split(queryD, []byte("&")) {
|
||||
if len(seg) == 0 {
|
||||
continue
|
||||
}
|
||||
paramCount++
|
||||
eq := bytes.IndexByte(seg, '=')
|
||||
vlen := 0
|
||||
if eq >= 0 {
|
||||
vlen = len(seg) - eq - 1
|
||||
}
|
||||
if vlen > maxParamLen {
|
||||
maxParamLen = vlen
|
||||
}
|
||||
}
|
||||
f[4] = float64(paramCount)
|
||||
f[5] = float64(maxParamLen)
|
||||
f[6] = float64(bytes.Count(pathD, []byte("/")))
|
||||
if v, ok := methodIdx[upperASCII(method)]; ok {
|
||||
f[7] = v
|
||||
} else {
|
||||
f[7] = methodIdxOther
|
||||
}
|
||||
|
||||
// --- 字符分布类(在 combined 上统计)---
|
||||
n := len(combined)
|
||||
if n > 0 {
|
||||
var punct, digit, letter, upper, nonascii int
|
||||
for _, c := range combined {
|
||||
if isPunct(c) {
|
||||
punct++
|
||||
}
|
||||
if c >= '0' && c <= '9' {
|
||||
digit++
|
||||
}
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
|
||||
letter++
|
||||
}
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
upper++
|
||||
}
|
||||
if c >= 0x80 {
|
||||
nonascii++
|
||||
}
|
||||
}
|
||||
fn := float64(n)
|
||||
f[8] = float64(punct) / fn
|
||||
f[9] = float64(digit) / fn
|
||||
f[10] = float64(letter) / fn
|
||||
f[11] = float64(upper) / fn
|
||||
f[12] = float64(nonascii) / fn
|
||||
}
|
||||
f[13] = entropy(combined)
|
||||
f[14] = float64(decodeLayers)
|
||||
f[15] = float64(bytes.Count(queryD, []byte("=")))
|
||||
|
||||
// --- 关键词类 ---
|
||||
f[16] = countKeywords(combinedLower, kwSQL)
|
||||
f[17] = countKeywords(combinedLower, kwXSS)
|
||||
f[18] = countKeywords(combinedLower, kwCMD)
|
||||
f[19] = countKeywords(combinedLower, kwTraversal)
|
||||
f[20] = countKeywords(combinedLower, kwProto)
|
||||
f[21] = countKeywords(uaLower, kwScannerUA)
|
||||
|
||||
// --- 字节 3-gram 哈希桶频率 ---
|
||||
ln := len(combinedLower)
|
||||
if ln >= ngramN {
|
||||
var counts [ngramBuckets]int
|
||||
totalNgrams := ln - ngramN + 1
|
||||
for i := 0; i < totalNgrams; i++ {
|
||||
h := fnv1a32(combinedLower[i : i+ngramN])
|
||||
counts[h%ngramBuckets]++
|
||||
}
|
||||
for bi := 0; bi < ngramBuckets; bi++ {
|
||||
f[scalarFeatureCount+bi] = float64(counts[bi]) / float64(totalNgrams)
|
||||
}
|
||||
}
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func upperASCII(s string) string {
|
||||
b := []byte(s)
|
||||
changed := false
|
||||
for i, c := range b {
|
||||
if c >= 'a' && c <= 'z' {
|
||||
b[i] = c - 32
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return s
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package wafai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// goldenFile 由 SamWafAI 生成:uv run samwafai gen-golden --out wafai/testdata/feature_golden.json
|
||||
type goldenFile struct {
|
||||
FeatureVersion string `json:"feature_version"`
|
||||
FeatureNames []string `json:"feature_names"`
|
||||
Cases []struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
Body string `json:"body"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Features []float64 `json:"features"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
|
||||
// TestFeatureParity 校验 Go 特征提取与 Python(golden 文件)逐项一致。
|
||||
// 这是 AI 检测正确性的基石:特征不对齐 -> 模型打分错乱。
|
||||
func TestFeatureParity(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "feature_golden.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("读取 golden 文件失败(请先运行 uv run samwafai gen-golden): %v", err)
|
||||
}
|
||||
var g goldenFile
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
t.Fatalf("解析 golden 文件失败: %v", err)
|
||||
}
|
||||
|
||||
if g.FeatureVersion != FeatureVersion {
|
||||
t.Fatalf("特征版本不一致: golden=%s go=%s", g.FeatureVersion, FeatureVersion)
|
||||
}
|
||||
if len(g.FeatureNames) != FeatureCount {
|
||||
t.Fatalf("特征维度不一致: golden=%d go=%d", len(g.FeatureNames), FeatureCount)
|
||||
}
|
||||
goNames := FeatureNames()
|
||||
for i, name := range g.FeatureNames {
|
||||
if goNames[i] != name {
|
||||
t.Fatalf("特征名[%d]不一致: golden=%s go=%s", i, name, goNames[i])
|
||||
}
|
||||
}
|
||||
|
||||
const eps = 1e-9
|
||||
for ci, c := range g.Cases {
|
||||
got := ExtractFeatures(c.Method, c.Path, c.Query, c.Body, c.UserAgent)
|
||||
if len(got) != len(c.Features) {
|
||||
t.Fatalf("case[%d] 维度不一致: got=%d want=%d", ci, len(got), len(c.Features))
|
||||
}
|
||||
for fi := range got {
|
||||
if math.Abs(got[fi]-c.Features[fi]) > eps {
|
||||
t.Errorf("case[%d] 特征[%d:%s] 不一致: go=%.12f py=%.12f (path=%q query=%q)",
|
||||
ci, fi, goNames[fi], got[fi], c.Features[fi], c.Path, c.Query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentDecode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"%2e%2e", ".."},
|
||||
{"a+b", "a b"},
|
||||
{"%zz", "%zz"},
|
||||
{"%2", "%2"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := string(percentDecodeOnce([]byte(c.in))); got != c.want {
|
||||
t.Errorf("percentDecodeOnce(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIterDecodeLayers(t *testing.T) {
|
||||
dec, layers := iterDecode([]byte("%252e%252e"))
|
||||
if string(dec) != ".." || layers != 2 {
|
||||
t.Errorf("iterDecode 双层解码失败: got=%q layers=%d", string(dec), layers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryHint(t *testing.T) {
|
||||
cases := []struct {
|
||||
method, path, query, body, ua string
|
||||
want string
|
||||
}{
|
||||
{"GET", "/p", "id=1' or '1'='1 union select", "", "", "SQL注入"},
|
||||
{"GET", "/c", "q=<script>alert(1)</script>", "", "", "XSS"},
|
||||
{"GET", "/d", "file=../../../../etc/passwd", "", "", "目录穿越"},
|
||||
{"GET", "/x", "host=127.0.0.1;cat /etc/passwd", "", "", "命令执行"},
|
||||
{"GET", "/", "page=1&sort=asc", "", "Mozilla/5.0", "异常请求"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
f := ExtractFeatures(c.method, c.path, c.query, c.body, c.ua)
|
||||
if got := CategoryHint(f); got != c.want {
|
||||
t.Errorf("CategoryHint(query=%q)=%q want %q", c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFNV1a(t *testing.T) {
|
||||
if fnv1a32([]byte("")) != 2166136261 {
|
||||
t.Error("fnv1a32 空串错误")
|
||||
}
|
||||
if fnv1a32([]byte("a")) != 0xE40C292C {
|
||||
t.Errorf("fnv1a32('a')=%#x", fnv1a32([]byte("a")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package wafai
|
||||
|
||||
import "strings"
|
||||
|
||||
// 规则弱标签:把 SamWaf 日志里的规则命中转换为训练标签。
|
||||
// 与 SamWafAI samwafai/data/labeling.py 保持一致的判定逻辑。
|
||||
//
|
||||
// 只把"载荷型"攻击规则的命中当作正样本;CC/IP黑白名单/防盗链/验证码等
|
||||
// 行为型或策略型拦截与请求载荷内容无关,全部丢弃,避免污染训练。
|
||||
|
||||
// payloadRuleKeywords RULE 标题包含这些关键字 -> 载荷型攻击(正样本)
|
||||
var payloadRuleKeywords = []struct {
|
||||
kw string
|
||||
attackType string
|
||||
}{
|
||||
{"sql", "sqli"}, {"注入", "sqli"},
|
||||
{"xss", "xss"}, {"跨站", "xss"},
|
||||
{"rce", "rce"}, {"命令执行", "rce"}, {"代码执行", "rce"},
|
||||
{"扫描", "scan"}, {"scan", "scan"},
|
||||
{"穿越", "traversal"}, {"traversal", "traversal"},
|
||||
{"owasp", "owasp"},
|
||||
}
|
||||
|
||||
// excludeRuleKeywords RULE 标题包含这些关键字 -> 非载荷型,直接丢弃
|
||||
var excludeRuleKeywords = []string{
|
||||
"cc", "频次", "rate limit",
|
||||
"ip", "黑名单", "白名单",
|
||||
"防盗链", "盗链",
|
||||
"验证码", "captcha",
|
||||
"敏感词",
|
||||
"bot", "爬虫", "蜘蛛",
|
||||
"url黑", "url白", "禁止url", "路径禁止",
|
||||
"应用",
|
||||
"ai检测", // AI 自己产生的命中不能再回灌当弱标签,避免自我强化
|
||||
}
|
||||
|
||||
var blockActions = []string{"禁止", "阻止"}
|
||||
|
||||
// highConfidenceAttackTypes 高置信攻击类型:命中即基本可判定为真攻击(误报率低),
|
||||
// 未经人工确认也可作为训练正样本。
|
||||
// - sqli/xss:libinjection 词法分析,精度高。
|
||||
// - owasp:OWASP CRS 规则集。
|
||||
// - rce:检测仅匹配 phpinfo()/call_user_func_array/invokefunction 等极具体签名,正常流量几乎不出现,误报极低。
|
||||
// - traversal:匹配 ../、..\、%2e%2e 等穿越特征,正常流量很少出现,误报低。
|
||||
//
|
||||
// scan 由 User-Agent 判定(curl/python-requests 等合法工具也会命中),误报高,仍排除在高置信外,
|
||||
// 未经人工确认不当攻击;自定义规则同理。
|
||||
var highConfidenceAttackTypes = map[string]bool{
|
||||
"sqli": true,
|
||||
"xss": true,
|
||||
"owasp": true,
|
||||
"rce": true,
|
||||
"traversal": true,
|
||||
}
|
||||
|
||||
// IsHighConfidenceAttackType 该攻击类型是否高置信(可在无人工确认时信任为正样本)。
|
||||
func IsHighConfidenceAttackType(attackType string) bool {
|
||||
return highConfidenceAttackTypes[attackType]
|
||||
}
|
||||
|
||||
// WeakLabelVerdict 弱标签判定结果。
|
||||
type WeakLabelVerdict int
|
||||
|
||||
const (
|
||||
VerdictDrop WeakLabelVerdict = iota // 丢弃(不用于训练)
|
||||
VerdictAttack // 载荷型攻击正样本
|
||||
VerdictNormal // 正常负样本
|
||||
)
|
||||
|
||||
// WeakLabel 根据日志的 action/rule/logOnlyMode 给出弱标签。
|
||||
func WeakLabel(action, rule string, logOnlyMode int) (WeakLabelVerdict, string) {
|
||||
ruleL := strings.ToLower(strings.TrimSpace(rule))
|
||||
blocked := false
|
||||
for _, a := range blockActions {
|
||||
if strings.TrimSpace(action) == a {
|
||||
blocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if logOnlyMode == 1 && ruleL != "" {
|
||||
blocked = true
|
||||
}
|
||||
|
||||
if !blocked {
|
||||
if ruleL == "" {
|
||||
return VerdictNormal, ""
|
||||
}
|
||||
return VerdictDrop, ""
|
||||
}
|
||||
|
||||
for _, p := range payloadRuleKeywords {
|
||||
if strings.Contains(ruleL, p.kw) {
|
||||
return VerdictAttack, p.attackType
|
||||
}
|
||||
}
|
||||
for _, kw := range excludeRuleKeywords {
|
||||
if strings.Contains(ruleL, kw) {
|
||||
return VerdictDrop, ""
|
||||
}
|
||||
}
|
||||
return VerdictDrop, ""
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package wafai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Detector AI 检测器:持有当前模型,提供线程安全的打分与热加载。
|
||||
//
|
||||
// 失败安全:模型未加载/打分 panic 时,Predict 返回非命中结果,绝不影响业务转发。
|
||||
type Detector struct {
|
||||
mu sync.RWMutex
|
||||
current atomic.Pointer[loadedModel]
|
||||
}
|
||||
|
||||
type loadedModel struct {
|
||||
engine InferenceEngine
|
||||
manifest Manifest
|
||||
}
|
||||
|
||||
// PredictResult 单次推理结果。
|
||||
type PredictResult struct {
|
||||
Loaded bool // 当前是否有可用模型
|
||||
Score float64 // 攻击概率 [0,1]
|
||||
Category string // 粗粒度类别提示(SQL注入/XSS/.../异常请求),用于日志展示与按规则汇总
|
||||
BlockThreshold float64 // 模型建议的拦截阈值
|
||||
ObserveThreshold float64 // 模型建议的观察阈值
|
||||
ModelVersion string
|
||||
}
|
||||
|
||||
// NewDetector 创建空检测器(尚未加载模型)。
|
||||
func NewDetector() *Detector {
|
||||
return &Detector{}
|
||||
}
|
||||
|
||||
// LoadFromFile 从 .swai 文件加载模型并原子热替换。
|
||||
func (d *Detector) LoadFromFile(swaiPath string) (Manifest, error) {
|
||||
pkg, err := loadPackageFile(swaiPath)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return d.loadPackage(pkg)
|
||||
}
|
||||
|
||||
// LoadFromBytes 从内存字节加载模型并原子热替换。
|
||||
func (d *Detector) LoadFromBytes(data []byte) (Manifest, error) {
|
||||
pkg, err := loadPackageBytes(data)
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return d.loadPackage(pkg)
|
||||
}
|
||||
|
||||
func (d *Detector) loadPackage(pkg *loadedPackage) (Manifest, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
var engine InferenceEngine
|
||||
var err error
|
||||
switch pkg.Manifest.ModelType {
|
||||
case "gbdt", "":
|
||||
engine, err = newGBDTEngine(pkg.ModelBytes, pkg.Manifest.FeatureVersion)
|
||||
case "onnx":
|
||||
engine, err = newONNXEngine(pkg.ModelBytes, pkg.Manifest.FeatureVersion)
|
||||
default:
|
||||
return Manifest{}, fmt.Errorf("不支持的模型类型: %q", pkg.Manifest.ModelType)
|
||||
}
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
|
||||
d.current.Store(&loadedModel{engine: engine, manifest: pkg.Manifest})
|
||||
return pkg.Manifest, nil
|
||||
}
|
||||
|
||||
// Unload 卸载当前模型。
|
||||
func (d *Detector) Unload() {
|
||||
d.current.Store(nil)
|
||||
}
|
||||
|
||||
// IsLoaded 当前是否已加载模型。
|
||||
func (d *Detector) IsLoaded() bool {
|
||||
return d.current.Load() != nil
|
||||
}
|
||||
|
||||
// CurrentManifest 返回当前模型 manifest(未加载时第二个返回值为 false)。
|
||||
func (d *Detector) CurrentManifest() (Manifest, bool) {
|
||||
m := d.current.Load()
|
||||
if m == nil {
|
||||
return Manifest{}, false
|
||||
}
|
||||
return m.manifest, true
|
||||
}
|
||||
|
||||
// PredictRequest 对一次请求的关键字段打分。失败安全:任何异常返回 Loaded=false。
|
||||
func (d *Detector) PredictRequest(method, path, query, body, userAgent string) (res PredictResult) {
|
||||
lm := d.current.Load()
|
||||
if lm == nil {
|
||||
return PredictResult{Loaded: false}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// 推理 panic 时降级为未命中,绝不阻断业务
|
||||
res = PredictResult{Loaded: false}
|
||||
}
|
||||
}()
|
||||
|
||||
features := ExtractFeatures(method, path, query, body, userAgent)
|
||||
score := lm.engine.Score(features)
|
||||
return PredictResult{
|
||||
Loaded: true,
|
||||
Score: score,
|
||||
Category: CategoryHint(features),
|
||||
BlockThreshold: lm.manifest.BlockThreshold,
|
||||
ObserveThreshold: lm.manifest.ObserveThreshold,
|
||||
ModelVersion: lm.manifest.ModelVersion,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package wafai
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
)
|
||||
|
||||
const (
|
||||
swaiFormatVersion = 1
|
||||
manifestName = "manifest.json"
|
||||
maxModelBytes = 64 * 1024 * 1024 // 单个模型文件解压上限 64MB(防 zip 炸弹)
|
||||
maxManifestBytes = 1 * 1024 * 1024 // manifest 上限 1MB
|
||||
maxPackageEntries = 16 // 包内条目数上限
|
||||
)
|
||||
|
||||
// Manifest .swai 模型包元数据(与 SamWafAI export/package.py 对齐)。
|
||||
type Manifest struct {
|
||||
SwaiFormatVersion int `json:"swai_format_version"`
|
||||
ModelVersion string `json:"model_version"`
|
||||
ModelType string `json:"model_type"`
|
||||
FeatureVersion string `json:"feature_version"`
|
||||
FeatureCount int `json:"feature_count"`
|
||||
ModelFile string `json:"model_file"`
|
||||
ModelSha256 string `json:"model_sha256"`
|
||||
BlockThreshold float64 `json:"block_threshold"`
|
||||
ObserveThreshold float64 `json:"observe_threshold"`
|
||||
DataFingerprint string `json:"data_fingerprint"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// loadedPackage 解析后的模型包内容。
|
||||
type loadedPackage struct {
|
||||
Manifest Manifest
|
||||
ModelBytes []byte
|
||||
}
|
||||
|
||||
// loadPackageFile 从磁盘加载并校验 .swai 模型包。
|
||||
func loadPackageFile(swaiPath string) (*loadedPackage, error) {
|
||||
zr, err := zip.OpenReader(swaiPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开模型包失败: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
return parsePackage(&zr.Reader)
|
||||
}
|
||||
|
||||
// loadPackageBytes 从内存字节加载并校验 .swai 模型包。
|
||||
func loadPackageBytes(data []byte) (*loadedPackage, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析模型包失败: %w", err)
|
||||
}
|
||||
return parsePackage(zr)
|
||||
}
|
||||
|
||||
func parsePackage(zr *zip.Reader) (*loadedPackage, error) {
|
||||
if len(zr.File) > maxPackageEntries {
|
||||
return nil, fmt.Errorf("模型包条目过多: %d", len(zr.File))
|
||||
}
|
||||
|
||||
var manifestRaw []byte
|
||||
files := map[string][]byte{}
|
||||
for _, zf := range zr.File {
|
||||
// 防路径穿越:条目名必须是简单文件名
|
||||
name := zf.Name
|
||||
if path.IsAbs(name) || name != path.Clean(name) || containsDotDot(name) {
|
||||
return nil, fmt.Errorf("非法的模型包条目名: %q", name)
|
||||
}
|
||||
limit := int64(maxModelBytes)
|
||||
if name == manifestName {
|
||||
limit = maxManifestBytes
|
||||
}
|
||||
data, err := readZipEntry(zf, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取条目 %q 失败: %w", name, err)
|
||||
}
|
||||
if name == manifestName {
|
||||
manifestRaw = data
|
||||
} else {
|
||||
files[name] = data
|
||||
}
|
||||
}
|
||||
|
||||
if manifestRaw == nil {
|
||||
return nil, errors.New("模型包缺少 manifest.json")
|
||||
}
|
||||
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(manifestRaw, &m); err != nil {
|
||||
return nil, fmt.Errorf("解析 manifest 失败: %w", err)
|
||||
}
|
||||
|
||||
if m.SwaiFormatVersion != swaiFormatVersion {
|
||||
return nil, fmt.Errorf("不支持的模型包格式版本: %d(当前支持 %d)", m.SwaiFormatVersion, swaiFormatVersion)
|
||||
}
|
||||
// 特征版本硬约束:与 Go 侧不一致则拒绝加载
|
||||
if m.FeatureVersion != FeatureVersion {
|
||||
return nil, fmt.Errorf("特征版本不匹配: 模型=%s, 引擎=%s(请用匹配版本的 SamWafAI 重新训练)", m.FeatureVersion, FeatureVersion)
|
||||
}
|
||||
if m.FeatureCount != 0 && m.FeatureCount != FeatureCount {
|
||||
return nil, fmt.Errorf("特征维度不匹配: 模型=%d, 引擎=%d", m.FeatureCount, FeatureCount)
|
||||
}
|
||||
|
||||
modelBytes, ok := files[m.ModelFile]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("模型包缺少模型文件: %q", m.ModelFile)
|
||||
}
|
||||
// sha256 完整性校验
|
||||
if m.ModelSha256 != "" {
|
||||
sum := sha256.Sum256(modelBytes)
|
||||
if got := hex.EncodeToString(sum[:]); got != m.ModelSha256 {
|
||||
return nil, fmt.Errorf("模型文件 sha256 校验失败: 期望 %s 实际 %s", m.ModelSha256, got)
|
||||
}
|
||||
}
|
||||
|
||||
return &loadedPackage{Manifest: m, ModelBytes: modelBytes}, nil
|
||||
}
|
||||
|
||||
func readZipEntry(zf *zip.File, limit int64) ([]byte, error) {
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
// 限制读取量防 zip 炸弹:多读 1 字节用于判断是否超限
|
||||
data, err := io.ReadAll(io.LimitReader(rc, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("条目解压大小超过上限 %d 字节", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func containsDotDot(p string) bool {
|
||||
for i := 0; i+1 < len(p); i++ {
|
||||
if p[i] == '.' && p[i+1] == '.' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package wafai
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadAndScore 端到端:加载 SamWafAI 产出的 .swai 模型,对正常/攻击请求打分。
|
||||
// 模型由合成数据训练(examples/gen_synthetic.py),攻击样本应显著高于正常样本。
|
||||
func TestLoadAndScore(t *testing.T) {
|
||||
swaiPath := filepath.Join("testdata", "sample_model.swai")
|
||||
if _, err := os.Stat(swaiPath); err != nil {
|
||||
t.Skip("无 sample_model.swai,跳过(由 SamWafAI pipeline 生成)")
|
||||
}
|
||||
|
||||
d := NewDetector()
|
||||
if d.IsLoaded() {
|
||||
t.Fatal("新建检测器不应已加载")
|
||||
}
|
||||
// 未加载时打分应失败安全
|
||||
if res := d.PredictRequest("GET", "/", "id=1", "", ""); res.Loaded {
|
||||
t.Fatal("未加载模型时 Loaded 应为 false")
|
||||
}
|
||||
|
||||
manifest, err := d.LoadFromFile(swaiPath)
|
||||
if err != nil {
|
||||
t.Fatalf("加载模型失败: %v", err)
|
||||
}
|
||||
if manifest.FeatureVersion != FeatureVersion {
|
||||
t.Fatalf("特征版本不一致: %s", manifest.FeatureVersion)
|
||||
}
|
||||
if !d.IsLoaded() {
|
||||
t.Fatal("加载后 IsLoaded 应为 true")
|
||||
}
|
||||
|
||||
normal := d.PredictRequest("GET", "/products", "page=1&sort=asc", "", "Mozilla/5.0")
|
||||
attack := d.PredictRequest("GET", "/index.php", "id=1' or '1'='1 union select null,null--", "", "sqlmap/1.5")
|
||||
if !normal.Loaded || !attack.Loaded {
|
||||
t.Fatal("加载后打分应成功")
|
||||
}
|
||||
t.Logf("normal score=%.4f attack score=%.4f block_thr=%.4f", normal.Score, attack.Score, manifest.BlockThreshold)
|
||||
if attack.Score <= normal.Score {
|
||||
t.Errorf("攻击样本分数(%.4f)应高于正常样本(%.4f)", attack.Score, normal.Score)
|
||||
}
|
||||
|
||||
d.Unload()
|
||||
if d.IsLoaded() {
|
||||
t.Fatal("卸载后 IsLoaded 应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectBadFeatureVersion(t *testing.T) {
|
||||
// 篡改 manifest 的特征版本应被拒绝(构造最小 zip)
|
||||
bad := buildTestPackage(t, `{"swai_format_version":1,"feature_version":"v999","model_type":"gbdt","model_file":"model_lgbm.txt","model_sha256":""}`, "dummy")
|
||||
d := NewDetector()
|
||||
if _, err := d.LoadFromBytes(bad); err == nil {
|
||||
t.Fatal("特征版本不匹配应加载失败")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectPathTraversalEntry(t *testing.T) {
|
||||
bad := buildTestPackageNamed(t, manifestName, `{"swai_format_version":1,"feature_version":"v1"}`, "../evil.txt", "x")
|
||||
d := NewDetector()
|
||||
if _, err := d.LoadFromBytes(bad); err == nil {
|
||||
t.Fatal("含路径穿越条目应加载失败")
|
||||
}
|
||||
}
|
||||
Vendored
+853
@@ -0,0 +1,853 @@
|
||||
{
|
||||
"feature_version": "v1",
|
||||
"feature_names": [
|
||||
"path_len",
|
||||
"query_len",
|
||||
"body_len",
|
||||
"ua_len",
|
||||
"param_count",
|
||||
"max_param_len",
|
||||
"path_depth",
|
||||
"method_idx",
|
||||
"special_ratio",
|
||||
"digit_ratio",
|
||||
"letter_ratio",
|
||||
"upper_ratio",
|
||||
"nonascii_ratio",
|
||||
"entropy",
|
||||
"decode_layers",
|
||||
"query_eq_count",
|
||||
"kw_sql",
|
||||
"kw_xss",
|
||||
"kw_cmd",
|
||||
"kw_traversal",
|
||||
"kw_proto",
|
||||
"kw_scanner_ua",
|
||||
"ngram_0",
|
||||
"ngram_1",
|
||||
"ngram_2",
|
||||
"ngram_3",
|
||||
"ngram_4",
|
||||
"ngram_5",
|
||||
"ngram_6",
|
||||
"ngram_7",
|
||||
"ngram_8",
|
||||
"ngram_9",
|
||||
"ngram_10",
|
||||
"ngram_11",
|
||||
"ngram_12",
|
||||
"ngram_13",
|
||||
"ngram_14",
|
||||
"ngram_15",
|
||||
"ngram_16",
|
||||
"ngram_17",
|
||||
"ngram_18",
|
||||
"ngram_19",
|
||||
"ngram_20",
|
||||
"ngram_21",
|
||||
"ngram_22",
|
||||
"ngram_23",
|
||||
"ngram_24",
|
||||
"ngram_25",
|
||||
"ngram_26",
|
||||
"ngram_27",
|
||||
"ngram_28",
|
||||
"ngram_29",
|
||||
"ngram_30",
|
||||
"ngram_31",
|
||||
"ngram_32",
|
||||
"ngram_33",
|
||||
"ngram_34",
|
||||
"ngram_35",
|
||||
"ngram_36",
|
||||
"ngram_37",
|
||||
"ngram_38",
|
||||
"ngram_39",
|
||||
"ngram_40",
|
||||
"ngram_41",
|
||||
"ngram_42",
|
||||
"ngram_43",
|
||||
"ngram_44",
|
||||
"ngram_45",
|
||||
"ngram_46",
|
||||
"ngram_47",
|
||||
"ngram_48",
|
||||
"ngram_49",
|
||||
"ngram_50",
|
||||
"ngram_51",
|
||||
"ngram_52",
|
||||
"ngram_53",
|
||||
"ngram_54",
|
||||
"ngram_55",
|
||||
"ngram_56",
|
||||
"ngram_57",
|
||||
"ngram_58",
|
||||
"ngram_59",
|
||||
"ngram_60",
|
||||
"ngram_61",
|
||||
"ngram_62",
|
||||
"ngram_63"
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/",
|
||||
"query": "",
|
||||
"body": "",
|
||||
"user_agent": "",
|
||||
"features": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.3333333333333333,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.9182958340544896,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/index.php",
|
||||
"query": "id=1' or '1'='1",
|
||||
"body": "",
|
||||
"user_agent": "Mozilla/5.0",
|
||||
"features": [
|
||||
10.0,
|
||||
15.0,
|
||||
0.0,
|
||||
11.0,
|
||||
1.0,
|
||||
12.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.2962962962962963,
|
||||
0.1111111111111111,
|
||||
0.4444444444444444,
|
||||
0.0,
|
||||
0.0,
|
||||
3.838039816898156,
|
||||
0.0,
|
||||
2.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.04,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.16,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.04,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.04,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.04,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/login",
|
||||
"query": "",
|
||||
"body": "user=admin&pwd=123",
|
||||
"user_agent": "sqlmap/1.5",
|
||||
"features": [
|
||||
6.0,
|
||||
0.0,
|
||||
18.0,
|
||||
10.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.15384615384615385,
|
||||
0.11538461538461539,
|
||||
0.6538461538461539,
|
||||
0.0,
|
||||
0.0,
|
||||
4.315824333525707,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.08333333333333333,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.125,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.08333333333333333,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.125,
|
||||
0.0,
|
||||
0.0,
|
||||
0.08333333333333333,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.041666666666666664,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/a/b/c",
|
||||
"query": "q=%3Cscript%3Ealert(1)%3C%2Fscript%3E",
|
||||
"body": "",
|
||||
"user_agent": "curl/7.0",
|
||||
"features": [
|
||||
6.0,
|
||||
37.0,
|
||||
0.0,
|
||||
8.0,
|
||||
1.0,
|
||||
25.0,
|
||||
3.0,
|
||||
0.0,
|
||||
0.3142857142857143,
|
||||
0.02857142857142857,
|
||||
0.6,
|
||||
0.0,
|
||||
0.0,
|
||||
4.09314980247381,
|
||||
1.0,
|
||||
1.0,
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.09090909090909091,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.030303030303030304,
|
||||
0.030303030303030304,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.06060606060606061,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.06060606060606061,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.09090909090909091,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.06060606060606061,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.030303030303030304,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.0,
|
||||
0.06060606060606061,
|
||||
0.0,
|
||||
0.06060606060606061,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304,
|
||||
0.06060606060606061,
|
||||
0.0,
|
||||
0.0,
|
||||
0.06060606060606061,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.030303030303030304
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/download",
|
||||
"query": "file=../../../../etc/passwd",
|
||||
"body": "",
|
||||
"user_agent": "",
|
||||
"features": [
|
||||
9.0,
|
||||
27.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
22.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.39473684210526316,
|
||||
0.0,
|
||||
0.5526315789473685,
|
||||
0.0,
|
||||
0.0,
|
||||
3.7146469211675206,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
5.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.1111111111111111,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.027777777777777776,
|
||||
0.1111111111111111,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.1111111111111111,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.027777777777777776
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/cmd",
|
||||
"query": "x=%2e%2e%252fetc%252fpasswd",
|
||||
"body": "",
|
||||
"user_agent": "",
|
||||
"features": [
|
||||
4.0,
|
||||
27.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
13.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.2857142857142857,
|
||||
0.0,
|
||||
0.6190476190476191,
|
||||
0.0,
|
||||
0.0,
|
||||
3.689703732199547,
|
||||
2.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
2.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.10526315789473684,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.10526315789473684,
|
||||
0.0,
|
||||
0.0,
|
||||
0.10526315789473684,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.05263157894736842,
|
||||
0.05263157894736842,
|
||||
0.05263157894736842,
|
||||
0.05263157894736842,
|
||||
0.05263157894736842,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/upload/中文路径",
|
||||
"query": "name=测试&v=1",
|
||||
"body": "数据body",
|
||||
"user_agent": "Go-http-client/1.1",
|
||||
"features": [
|
||||
20.0,
|
||||
15.0,
|
||||
10.0,
|
||||
18.0,
|
||||
2.0,
|
||||
6.0,
|
||||
2.0,
|
||||
2.0,
|
||||
0.10638297872340426,
|
||||
0.02127659574468085,
|
||||
0.3191489361702128,
|
||||
0.0,
|
||||
0.5106382978723404,
|
||||
5.001397362315936,
|
||||
0.0,
|
||||
2.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.044444444444444446,
|
||||
0.022222222222222223,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.0,
|
||||
0.044444444444444446,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.044444444444444446,
|
||||
0.022222222222222223,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.044444444444444446,
|
||||
0.044444444444444446,
|
||||
0.0,
|
||||
0.06666666666666667,
|
||||
0.022222222222222223,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.0,
|
||||
0.044444444444444446,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.044444444444444446,
|
||||
0.0,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.022222222222222223,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.06666666666666667,
|
||||
0.044444444444444446,
|
||||
0.0,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.0,
|
||||
0.022222222222222223,
|
||||
0.022222222222222223,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.044444444444444446,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api",
|
||||
"query": "",
|
||||
"body": "{\"q\":\"union select null,null--\"}",
|
||||
"user_agent": "python-requests/2.0",
|
||||
"features": [
|
||||
4.0,
|
||||
0.0,
|
||||
32.0,
|
||||
19.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
0.2894736842105263,
|
||||
0.0,
|
||||
0.6052631578947368,
|
||||
0.0,
|
||||
0.0,
|
||||
4.133071514059368,
|
||||
0.0,
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.05555555555555555,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.08333333333333333,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.08333333333333333,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.08333333333333333,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.0,
|
||||
0.05555555555555555,
|
||||
0.0,
|
||||
0.0,
|
||||
0.027777777777777776,
|
||||
0.027777777777777776,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
package wafai
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildTestPackage 构造一个含 manifest + 单模型文件的内存 zip(仅供测试)。
|
||||
func buildTestPackage(t *testing.T, manifestJSON, modelContent string) []byte {
|
||||
t.Helper()
|
||||
return buildTestPackageNamed(t, manifestName, manifestJSON, "model_lgbm.txt", modelContent)
|
||||
}
|
||||
|
||||
func buildTestPackageNamed(t *testing.T, manifestEntry, manifestJSON, modelEntry, modelContent string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
if w, err := zw.Create(manifestEntry); err == nil {
|
||||
_, _ = w.Write([]byte(manifestJSON))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w, err := zw.Create(modelEntry); err == nil {
|
||||
_, _ = w.Write([]byte(modelContent))
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
@@ -989,6 +989,54 @@ func RunCoreDBMigrations(db *gorm.DB) error {
|
||||
return tx.Migrator().DropTable(&model.WafAppChangeLog{})
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "202606120003_add_waf_log_label_marks_table",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
zlog.Info("迁移 202606120003: 创建 waf_log_label_marks 表(AI训练标签人工修正)")
|
||||
if err := tx.AutoMigrate(&model.WafLogLabelMark{}); err != nil {
|
||||
return fmt.Errorf("创建 waf_log_label_marks 表失败: %w", err)
|
||||
}
|
||||
zlog.Info("waf_log_label_marks 表创建成功")
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
zlog.Info("回滚 202606120003: 删除 waf_log_label_marks 表")
|
||||
return tx.Migrator().DropTable(&model.WafLogLabelMark{})
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "202606120004_add_label_mark_attack_type",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
zlog.Info("迁移 202606120004: 为 waf_log_label_marks 添加 attack_type 字段")
|
||||
if tx.Migrator().HasColumn(&model.WafLogLabelMark{}, "attack_type") {
|
||||
return nil
|
||||
}
|
||||
if err := tx.Migrator().AddColumn(&model.WafLogLabelMark{}, "AttackType"); err != nil {
|
||||
return fmt.Errorf("添加 attack_type 字段失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
if tx.Migrator().HasColumn(&model.WafLogLabelMark{}, "attack_type") {
|
||||
return tx.Migrator().DropColumn(&model.WafLogLabelMark{}, "AttackType")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "202606120005_add_label_mark_snapshot_fields",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
zlog.Info("迁移 202606120005: 为 waf_log_label_marks 添加请求快照字段")
|
||||
// AutoMigrate 仅新增缺失列,幂等安全
|
||||
if err := tx.AutoMigrate(&model.WafLogLabelMark{}); err != nil {
|
||||
return fmt.Errorf("同步 waf_log_label_marks 快照字段失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -188,6 +188,46 @@ func RunLogDBMigrations(db *gorm.DB) error {
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// 迁移6: 为 web_logs 表添加 ai_score 字段(AI智能检测得分,支持集中查看观察/拦截)
|
||||
{
|
||||
ID: "202606120001_add_web_logs_ai_score",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
zlog.Info("迁移 202606120001: 为 web_logs 表添加 ai_score 字段")
|
||||
|
||||
if tx.Migrator().HasColumn(&innerbean.WebLog{}, "ai_score") {
|
||||
zlog.Info("ai_score 字段已存在,跳过添加")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Migrator().AddColumn(&innerbean.WebLog{}, "AI_SCORE"); err != nil {
|
||||
return fmt.Errorf("添加 ai_score 字段失败: %w", err)
|
||||
}
|
||||
|
||||
zlog.Info("ai_score 字段添加成功(用于记录AI检测得分,支持观察/拦截集中查看)")
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
zlog.Info("回滚 202606120001: 删除 web_logs 表的 ai_score 字段")
|
||||
if tx.Migrator().HasColumn(&innerbean.WebLog{}, "ai_score") {
|
||||
return tx.Migrator().DropColumn(&innerbean.WebLog{}, "AI_SCORE")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
// 迁移7: 为 web_logs 表的 ai_score 建索引(AI看板按 ai_score>0 过滤,命中是极小子集,
|
||||
// 走索引可避免全表扫描;复合 day 兼顾按天范围过滤与趋势排序)
|
||||
{
|
||||
ID: "202606120002_add_web_logs_ai_score_index",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
zlog.Info("迁移 202606120002: 为 web_logs.ai_score 创建索引")
|
||||
return safeCreateIndex(tx, "web_logs", "idx_web_logs_ai_score_day",
|
||||
"CREATE INDEX IF NOT EXISTS idx_web_logs_ai_score_day ON web_logs (ai_score, day)")
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
zlog.Info("回滚 202606120002: 删除 web_logs.ai_score 索引")
|
||||
return safeDropIndex(tx, "web_logs", "idx_web_logs_ai_score_day")
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 执行迁移
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package wafenginecore
|
||||
|
||||
import (
|
||||
"SamWaf/global"
|
||||
"SamWaf/innerbean"
|
||||
"SamWaf/model/detection"
|
||||
"SamWaf/model/wafenginmodel"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
/*
|
||||
*
|
||||
AI智能检测
|
||||
|
||||
补现有规则引擎(正则 / libinjection / OWASP CRS)的盲区:变形绕过、混淆、未知 payload。
|
||||
推理走纯 Go 内嵌 GBDT(wafai.Detector + dmitryikh/leaves),失败安全:
|
||||
模型未加载/打分异常一律放行,绝不影响业务转发。
|
||||
|
||||
进入条件(在 wafengine 检测链中由调用方判断):
|
||||
- 全局开关 global.GCONFIG_AI_ENABLE == 1
|
||||
- 站点开关 hostDefense.DEFENSE_AI == 1
|
||||
|
||||
工作模式 global.GCONFIG_AI_MODE:
|
||||
- "observe":仅记录命中(达到观察阈值即在日志标注 AI 分数),不拦截
|
||||
- "block" :达到模型建议拦截阈值则拦截,介于观察/拦截阈值之间则仅记录
|
||||
*/
|
||||
func (waf *WafEngine) CheckAI(r *http.Request, weblogbean *innerbean.WebLog, formValue url.Values, hostTarget *wafenginmodel.HostSafe, globalHostTarget *wafenginmodel.HostSafe) detection.Result {
|
||||
result := detection.Result{
|
||||
JumpGuardResult: false,
|
||||
IsBlock: false,
|
||||
Title: "",
|
||||
Content: "",
|
||||
}
|
||||
|
||||
detector := global.GWAF_AI_DETECTOR
|
||||
if detector == nil || !detector.IsLoaded() {
|
||||
return result
|
||||
}
|
||||
|
||||
// 取请求关键字段做特征:path 与 query 分离,body 用解密后的明文
|
||||
path := ""
|
||||
if r != nil && r.URL != nil {
|
||||
path = r.URL.Path
|
||||
}
|
||||
body := weblogbean.BODY
|
||||
if body == "" {
|
||||
body = weblogbean.POST_FORM
|
||||
}
|
||||
|
||||
pred := detector.PredictRequest(weblogbean.METHOD, path, weblogbean.RawQuery, body, weblogbean.USER_AGENT)
|
||||
if !pred.Loaded {
|
||||
// 失败安全:未命中
|
||||
return result
|
||||
}
|
||||
|
||||
// 低于观察阈值:忽略(不记录分数,避免污染正常日志)
|
||||
if pred.Score < pred.ObserveThreshold {
|
||||
return result
|
||||
}
|
||||
|
||||
// RULE 用稳定的类别标签(不含分数),便于"按规则汇总"统计;分数单独存 AI_SCORE 列
|
||||
title := "AI检测:" + pred.Category
|
||||
weblogbean.RISK_LEVEL = 2
|
||||
weblogbean.AI_SCORE = pred.Score
|
||||
|
||||
// block 模式且达到拦截阈值 -> 拦截(由 handleBlock 走 EchoErrorInfo 记录为"阻止")
|
||||
if global.GCONFIG_AI_MODE == "block" && pred.Score >= pred.BlockThreshold {
|
||||
result.IsBlock = true
|
||||
result.Title = title
|
||||
result.Content = "请正确访问"
|
||||
return result
|
||||
}
|
||||
|
||||
// 观察命中(observe 模式,或 block 模式下分数在[观察,拦截)之间):
|
||||
// 标记为"仅记录",让请求照常放行,但在访问日志里可按 log_only_mode 筛出、
|
||||
// 并通过 ai_score 列排序/查看——对标 AWS WAF Count / Cloudflare log 动作。
|
||||
weblogbean.RULE = title
|
||||
weblogbean.LogOnlyMode = 1
|
||||
return result
|
||||
}
|
||||
@@ -89,6 +89,11 @@ func inferAttackType(ruleTitle string) string {
|
||||
return "owasp_attack"
|
||||
}
|
||||
|
||||
// AI 智能检测:Title 格式为 "AI检测:score=x.xx",需在 SQL/RCE 等关键词匹配之前优先处理
|
||||
if strings.HasPrefix(ruleTitle, "ai检测") {
|
||||
return "ai_attack"
|
||||
}
|
||||
|
||||
// CC攻击
|
||||
if strings.Contains(ruleTitle, "cc") || strings.Contains(ruleTitle, "频次") || strings.Contains(ruleTitle, "rate limit") {
|
||||
return "cc_attack"
|
||||
@@ -556,6 +561,12 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if handleBlock(waf.CheckRule) {
|
||||
return
|
||||
}
|
||||
//AI智能检测(全局开关开启且站点开启,规则抓确定的,AI 抓漏网的)
|
||||
if global.GCONFIG_AI_ENABLE == 1 && hostDefense.DEFENSE_AI == 1 {
|
||||
if handleBlock(waf.CheckAI) {
|
||||
return
|
||||
}
|
||||
}
|
||||
//检测敏感词
|
||||
if hostDefense.DEFENSE_SENSITIVE == 1 {
|
||||
if handleBlock(waf.CheckSensitive) {
|
||||
|
||||
@@ -173,6 +173,8 @@ func (web *WafWebManager) initRouter(r *gin.Engine) {
|
||||
router.ApiGroupApp.InitSqlQueryRouter(TokenOnlyRouterGroup)
|
||||
// 应用管理:可执行任意命令,拒绝 API Key,功能默认关闭
|
||||
router.ApiGroupApp.InitWafAppRouter(TokenOnlyRouterGroup)
|
||||
// AI模型管理与训练数据导出:模型会被引擎加载、数据出库,拒绝 API Key
|
||||
router.ApiGroupApp.InitWafAIRouter(TokenOnlyRouterGroup)
|
||||
}
|
||||
|
||||
// 保存 gin.Engine 引用供 API 文档生成使用
|
||||
|
||||
@@ -77,6 +77,9 @@ func setConfigIntValue(name string, value int64, change int) {
|
||||
case "enable_owasp":
|
||||
global.GCONFIG_RECORD_ENABLE_OWASP = value
|
||||
break
|
||||
case "ai_enable":
|
||||
global.GCONFIG_AI_ENABLE = value
|
||||
break
|
||||
case "owasp_block_threshold":
|
||||
if value <= 0 {
|
||||
value = 7
|
||||
@@ -228,6 +231,15 @@ func setConfigStringValue(name string, value string, change int) {
|
||||
// 同步到 wafowasp 热路径,使 DetectionOnly "本该拦截" 的 INFO 日志能按当前模式生效
|
||||
wafowasp.SetEngineMode(global.GCONFIG_OWASP_MODE)
|
||||
break
|
||||
case "ai_mode":
|
||||
switch value {
|
||||
case "observe", "block":
|
||||
global.GCONFIG_AI_MODE = value
|
||||
default:
|
||||
zlog.Warn("invalid ai_mode value, fallback to observe", value)
|
||||
global.GCONFIG_AI_MODE = "observe"
|
||||
}
|
||||
break
|
||||
case "kafka_url":
|
||||
global.GCONFIG_RECORD_KAFKA_URL = value
|
||||
break
|
||||
@@ -394,6 +406,8 @@ func TaskLoadSetting(initLoad bool) {
|
||||
updateConfigIntItem(initLoad, "system", "login_max_error_time", global.GCONFIG_RECORD_LOGIN_MAX_ERROR_TIME, "登录周期里错误最大次数 请大于0 ", "int", "", configMap)
|
||||
updateConfigIntItem(initLoad, "system", "login_limit_mintutes", global.GCONFIG_RECORD_LOGIN_LIMIT_MINTUTES, "登录错误记录周期 单位分钟数,默认1分钟", "int", "", configMap)
|
||||
updateConfigIntItem(initLoad, "system", "enable_owasp", global.GCONFIG_RECORD_ENABLE_OWASP, "启动OWASP数据检测(1启动 0关闭)", "int", "", configMap)
|
||||
updateConfigIntItem(initLoad, "system", "ai_enable", global.GCONFIG_AI_ENABLE, "启动AI智能检测总开关(1启动 0关闭,需先在AI模型管理上传模型包并在站点开启)", "int", "", configMap)
|
||||
updateConfigStringItem(initLoad, "system", "ai_mode", global.GCONFIG_AI_MODE, "AI检测工作模式:observe(仅记录/观察) block(达拦截阈值则拦截)", "options", "observe|仅记录,block|拦截", configMap)
|
||||
|
||||
updateConfigIntItem(initLoad, "ssl", "enable_http_80", global.GCONFIG_RECORD_ENABLE_HTTP_80, "启动80端口服务(为自动申请证书使用 HTTP文件验证类型需要,DNS验证不需要)", "int", "", configMap)
|
||||
updateConfigIntItem(initLoad, "ssl", "sslorder_expire_day", global.GCONFIG_RECORD_SSLOrder_EXPIRE_DAY, "自动续期检测小于多少天开始发起自动申请 默认30天", "int", "", configMap)
|
||||
|
||||
Reference in New Issue
Block a user