Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f4bef13
fix: make archived log shard connections concurrency-safe and recover…
samwafgo Sep 17, 2026
efb0727
fix: build the WebSocket online table before its consumers start
samwafgo Sep 17, 2026
1d0369a
perf: cap stored payloads, index log lookups by IP and rule, batch IP…
samwafgo Sep 17, 2026
d3d473b
fix: keep archived log shards readable when the live table gains a co…
samwafgo Sep 17, 2026
f854860
feat: merge IP tag history when their database location changes
samwafgo Sep 17, 2026
4de6438
refactor: move request payloads out of the access log into their own …
samwafgo Sep 17, 2026
a84b1c7
feat: scope log database export to a time range and selected tiers
samwafgo Sep 18, 2026
c340ee7
feat: add an IP watchlist that full-captures watched IPs
samwafgo Sep 18, 2026
d37b8e5
feat: split log storage into security events, access rows, and payloa…
samwafgo Sep 18, 2026
ad583fc
feat: roll up requests per actor and path into daily analysis tables
samwafgo Sep 20, 2026
23c00d4
fix:actor key by ip
samwafgo Sep 20, 2026
5ad3452
feat: add a source and path analysis page over the daily rollups
samwafgo Sep 20, 2026
fe5dd8c
feat: exclude chosen source IPs from web log recording
samwafgo Sep 21, 2026
55e7202
docs: add upgrade notes for the analysis page and log IP exclusion
samwafgo Sep 24, 2026
13220fe
feat: derive the log partition from the time range and look up identi…
samwafgo Sep 28, 2026
785b607
feat: add time partition verbs and the period naming they share
samwafgo Sep 28, 2026
ab2ce5e
feat: cut log archives per month and expire them by dropping partitions
samwafgo Sep 28, 2026
25ba6f2
feat: manage log partitions, and answer why an IP has no logs
samwafgo Sep 28, 2026
8f7b951
fix: read archived SQLite log shards read-only and choose tiers by data
samwafgo Sep 28, 2026
8e95273
fix: count the live log shard from access_log instead of a stale snap…
samwafgo Sep 28, 2026
baccdb1
fix:export db bug
samwafgo Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ dist/
# 只挡 wafsec/ 下这两个固定文件名,不影响内嵌资源 cmd/samwaf/exedata/public_key.pem。
/wafsec/private_key.pem
/wafsec/public_key.pem
/wafinit/testdata
/wafinit/testdata
/download/
2 changes: 2 additions & 0 deletions api/entrance.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ var APIGroupAPP = new(APIGroup)
var (
wafHostService = waf_service.WafHostServiceApp
wafLogService = waf_service.WafLogServiceApp
wafIPWatchlistService = waf_service.WafIPWatchlistServiceApp
wafStatService = waf_service.WafStatServiceApp
wafRuleService = waf_service.WafRuleServiceApp
wafIpAllowService = waf_service.WafWhiteIpServiceApp
Expand Down Expand Up @@ -144,6 +145,7 @@ var (
wafOtpService = waf_service.WafOtpServiceApp

wafAnalysisService = waf_service.WafAnalysisServiceApp
wafAnalysisViewService = waf_service.WafAnalysisViewServiceApp

wafAIService = waf_service.WafAIServiceApp
wafAILabelService = waf_service.WafAILabelServiceApp
Expand Down
62 changes: 56 additions & 6 deletions api/waf_ai_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package api
import (
"SamWaf/global"
"SamWaf/innerbean"
"SamWaf/model"
"SamWaf/model/common/response"
"SamWaf/model/request"
"SamWaf/service/waf_service"
"SamWaf/utils"
"SamWaf/wafai"
"SamWaf/wafdb/dialect"
"encoding/json"
"fmt"
"io"
Expand All @@ -18,6 +21,7 @@ import (
"time"

"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

type WafAIApi struct {
Expand Down Expand Up @@ -286,20 +290,66 @@ func (w *WafAIApi) ExportTrainDataApi(c *gin.Context) {
}

// runAIExport 实际执行训练数据导出,返回文件路径与各类计数。
//
// 分层后的读源(C6):正样本读 security_event(命中的请求全在这里),
// 负样本读采样负样本池——event_payload(kind=sample) 里的报文配上 access_log 的窄行。
// 正常请求不再全量落报文,训练要的真实负样本报文只能来自采样池(D2)。
// 过渡期补充:采样池还没攒起来时,web_logs 里的存量行(升级前写入、尚在保留期内)
// 照旧能补负样本;它不再写入,随保留期自然退场。
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")
narrowCols := []string{"REQ_UUID", "METHOD", "URL", "RawQuery", "USER_AGENT", "ACTION", "RULE", "LogOnlyMode"}
cutoff := ""
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)
cutoff = time.Now().AddDate(0, 0, -req.Days).Format("2006-01-02 15:04:05")
}
applyWindow := func(q *gorm.DB) *gorm.DB {
if cutoff != "" {
q = q.Where("create_time >= ?", cutoff)
}
return q
}

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)

// 正样本:安全事件
var attackRows []innerbean.WebLog
err = applyWindow(global.GWAF_LOCAL_LOG_DB.Model(&model.SecurityEvent{}).Select(narrowCols)).
Order("unix_add_time desc").Limit(maxCount).Find(&attackRows).Error
if err != nil {
return "", 0, 0, 0, 0, fmt.Errorf("查询安全事件失败: %w", err)
}
rows = append(rows, attackRows...)

// 负样本:采样池(窄行在 access_log,报文在 event_payload kind=sample,按 req_uuid 对齐)
var normalRows []innerbean.WebLog
err = applyWindow(global.GWAF_LOCAL_LOG_DB.Model(&model.AccessLog{}).Select(narrowCols).
Where("req_uuid in (select req_uuid from event_payload where kind = 'sample')")).
Order("unix_add_time desc").Limit(maxCount).Find(&normalRows).Error
if err != nil {
return "", 0, 0, 0, 0, fmt.Errorf("查询采样负样本失败: %w", err)
}
rows = append(rows, normalRows...)

// 过渡期:采样池未攒起来时,从存量 web_logs(不再写入,尚在保留期内的部分)补负样本
if len(normalRows) < maxCount && dialect.Get().TableExists(global.GWAF_LOCAL_LOG_DB, "web_logs") {
var legacy []innerbean.WebLog
err = applyWindow(global.GWAF_LOCAL_LOG_DB.Model(&innerbean.WebLog{}).
Select(append(append([]string{}, narrowCols...), "BODY", "POST_FORM")).
Where("ACTION = ? and RULE = ?", "放行", "")).
Order("unix_add_time desc").Limit(maxCount - len(normalRows)).Find(&legacy).Error
if err == nil {
rows = append(rows, legacy...)
}
}
total = len(rows)

// BODY/POST_FORM 在 event_payload 里,批量补回来再导出,否则样本只剩 URL
fillRows := make([]*innerbean.WebLog, 0, len(rows))
for i := range rows {
fillRows = append(fillRows, &rows[i])
}
waf_service.FillLivePayloads(fillRows)

dir := filepath.Join(utils.GetCurrentDir(), "data", aiExportDir)
if err = os.MkdirAll(dir, 0750); err != nil {
return "", 0, 0, 0, total, fmt.Errorf("创建导出目录失败: %w", err)
Expand Down
38 changes: 38 additions & 0 deletions api/waf_analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,41 @@ func (w *WafAnalysisApi) AnalysisSpiderRangeApi(c *gin.Context) {
response.FailWithMessage("解析失败", c)
}
}

// AnalysisActorListApi 来源与路径分析 - 行为视角(谁在打)
func (w *WafAnalysisApi) AnalysisActorListApi(c *gin.Context) {
var req request.WafAnalysisActorReq
if err := c.ShouldBind(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
response.OkWithDetailed(wafAnalysisViewService.ActorListApi(req), "获取成功", c)
}

// AnalysisPathListApi 来源与路径分析 - 目标视角(打哪里)
func (w *WafAnalysisApi) AnalysisPathListApi(c *gin.Context) {
var req request.WafAnalysisPathReq
if err := c.ShouldBind(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
response.OkWithDetailed(wafAnalysisViewService.PathListApi(req), "获取成功", c)
}

// AnalysisDetailApi 来源与路径分析 - 抽屉下钻
func (w *WafAnalysisApi) AnalysisDetailApi(c *gin.Context) {
var req request.WafAnalysisDetailReq
if err := c.ShouldBind(&req); err != nil {
response.FailWithMessage("解析失败", c)
return
}
if req.Kind != "actor" && req.Kind != "path" {
response.FailWithMessage("kind 只能是 actor 或 path", c)
return
}
if req.Key == "" {
response.FailWithMessage("请传入要查看的 key", c)
return
}
response.OkWithDetailed(wafAnalysisViewService.DetailApi(req), "获取成功", c)
}
33 changes: 33 additions & 0 deletions api/waf_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"SamWaf/utils"
"SamWaf/wafenginecore"
"SamWaf/wafenginecore/clientip"
"SamWaf/wafenginecore/ipset"
"errors"
"fmt"
"net"
Expand Down Expand Up @@ -106,6 +107,30 @@ func checkIPSourceConfig(cfg *ipSourceConfig) error {
return nil
}

// checkExcludeIPLog 校验「排除记录日志的IP」清单:长度封顶 + 每行必须是可解析的
// IP 模式(单IP/CIDR/通配符/区间)或 group:组短码。保存时拒绝,避免配错一行静默不生效。
func checkExcludeIPLog(raw string) error {
if len(raw) > 10000 {
return errors.New("排除记录日志的IP清单过长(上限10000字符)")
}
for _, line := range strings.FieldsFunc(raw, func(r rune) bool { return r == '\n' || r == '\r' || r == ',' }) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if len(line) >= len("group:") && strings.EqualFold(line[:len("group:")], "group:") {
if strings.TrimSpace(line[len("group:"):]) == "" {
return errors.New("排除清单里的 group: 后面缺少组短码")
}
continue
}
if _, err := ipset.ParsePatternLenient(line); err != nil {
return fmt.Errorf("排除清单无法识别的IP模式: %s", line)
}
}
return nil
}

// checkCDNPresetTrustSource cdn_preset 模式必须至少有一个可信来源可用(中心库回源段 或 手填可信网段)。
//
// 缺了它保存下去不是"少一层校验",而是静默降级成更危险的状态:来源判定恒为 false,
Expand Down Expand Up @@ -153,6 +178,10 @@ func (w *WafHostAPi) AddApi(c *gin.Context) {
req.IPSourceMode, req.IPTrustDepth, req.IPRealHeader = ipCfg.Mode, ipCfg.Depth, ipCfg.Header
req.IPTrustProxies, req.CDNProvider = ipCfg.TrustProxies, ipCfg.Provider

if verr := checkExcludeIPLog(req.EXCLUDE_IP_LOG); verr != nil {
response.FailWithMessage(verr.Error(), c)
return
}
// 端口监听表校验(issue #955):仅当本次显式携带时才阻断(脏数据/addr预留/HTTPS无证书一律拒绝)
listens, verr := wafHostService.ValidatePortListensReq(req.PortListensJSON, req.Port, req.Ssl, req.AutoJumpHTTPS)
if verr != nil {
Expand Down Expand Up @@ -452,6 +481,10 @@ func (w *WafHostAPi) ModifyHostApi(c *gin.Context) {

wafHostOld := wafHostService.GetDetailByCodeApi(req.CODE)

if verr := checkExcludeIPLog(req.EXCLUDE_IP_LOG); verr != nil {
response.FailWithMessage(verr.Error(), c)
return
}
// 端口监听表校验(issue #955):nil=本次未携带(旧前端),不校验不阻断(存量冲突不能卡死普通编辑)
if req.PortListensJSON != nil && strings.TrimSpace(*req.PortListensJSON) != "" {
listens, verr := wafHostService.ValidatePortListensReq(*req.PortListensJSON, req.Port, req.Ssl, req.AutoJumpHTTPS)
Expand Down
56 changes: 56 additions & 0 deletions api/waf_ip_watchlist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package api

import (
"SamWaf/model/common/response"
"SamWaf/model/request"

"github.com/gin-gonic/gin"
)

// AddIPWatchlistApi 加入重点 IP 观察名单(或续期)
func (w *WafLogAPi) AddIPWatchlistApi(c *gin.Context) {
var req request.WafIPWatchlistAddReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误: "+err.Error(), c)
return
}
if err := wafIPWatchlistService.AddApi(req); err != nil {
response.FailWithMessage("加入观察名单失败: "+err.Error(), c)
return
}
response.OkWithMessage("已加入观察名单", c)
}

// DelIPWatchlistApi 移出观察名单
func (w *WafLogAPi) DelIPWatchlistApi(c *gin.Context) {
var req request.WafIPWatchlistDelReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误: "+err.Error(), c)
return
}
if err := wafIPWatchlistService.DelApi(req.IP); err != nil {
response.FailWithMessage("移除失败: "+err.Error(), c)
return
}
response.OkWithMessage("已移出观察名单", c)
}

// GetIPWatchlistApi 观察名单分页
func (w *WafLogAPi) GetIPWatchlistApi(c *gin.Context) {
var req request.WafIPWatchlistSearch
if err := c.ShouldBindJSON(&req); err != nil {
response.FailWithMessage("参数错误: "+err.Error(), c)
return
}
list, total, err := wafIPWatchlistService.ListApi(req)
if err != nil {
response.FailWithMessage("查询失败: "+err.Error(), c)
return
}
response.OkWithData(response.PageResult{
List: list,
Total: total,
PageIndex: req.PageIndex,
PageSize: req.PageSize,
}, c)
}
Loading
Loading