新增:安全告警邮件展示来源 IP 网络归属(CDN/云厂商/WAF)
- 新增 internal/security/ipinfo:基于 projectdiscovery/cdncheck v1.3.0 (离线指纹、零外部请求)识别来源 IP 归属,识别失败折叠为展示标签, 不中断告警链路 - autoban 告警回调注入归属识别;告警邮件新增「网络归属」行, 命中 CDN/代理网络时提示「封禁可能无法拦截真实来源」 - 新增邮件模板渲染回归测试 internal/mail/mail_alert_test.go - dev/s3stub:webp(cgo) 本地类型检查桩,替换 s3.go 做全模块编译验证, 生产 Linux 构建不受影响;dev/overlay.json 指向该桩 - go mod tidy 顺带升级共享依赖(lipgloss/xid/testify 等), 全模块类型检查与相关包测试通过
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
// Package ipinfo 识别 IP 的网络归属(CDN / 云厂商 / WAF)。
|
||||
// 基于 projectdiscovery/cdncheck 的离线签名(编译期嵌入,上游每周更新指纹),
|
||||
// 判定过程零外部网络请求,适合 1C1G 小型服务器与告警链路使用。
|
||||
// 当前用于安全告警邮件:展示扫描来源的网络归属,帮助管理员判断告警严重性。
|
||||
package ipinfo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/projectdiscovery/cdncheck"
|
||||
)
|
||||
|
||||
var (
|
||||
clientOnce sync.Once
|
||||
client *cdncheck.Client
|
||||
)
|
||||
|
||||
// getClient 懒加载判定客户端(签名数据编译期嵌入,New 无错误返回)。
|
||||
func getClient() *cdncheck.Client {
|
||||
clientOnce.Do(func() {
|
||||
client = cdncheck.New()
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
// Lookup 判定 IP 的网络归属。
|
||||
// 返回:
|
||||
// - label:展示用标签,如 "Cloudflare(CDN)"、"亚马逊(云厂商)";
|
||||
// 未命中返回 "未识别(普通网络)",输入非法或判定出错返回 "识别失败"。
|
||||
// - proxied:IP 是否属于 CDN/WAF/代理网络(用于提示"封禁可能无法拦截真实来源")。
|
||||
//
|
||||
// 任何内部错误都被折叠为展示标签,不向调用方传播——告警链路不应因归属识别失败而中断。
|
||||
func Lookup(ipStr string) (label string, proxied bool) {
|
||||
ip := net.ParseIP(strings.TrimSpace(ipStr))
|
||||
if ip == nil {
|
||||
return "识别失败(IP 格式异常)", false
|
||||
}
|
||||
|
||||
matched, value, itemType, err := getClient().Check(ip)
|
||||
if err != nil {
|
||||
return "识别失败", false
|
||||
}
|
||||
if !matched || value == "" {
|
||||
return "未识别(普通网络)", false
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s(%s)", title(value), typeLabel(itemType)), true
|
||||
}
|
||||
|
||||
// typeLabel 将 cdncheck 的 itemType 翻译为中文展示。
|
||||
func typeLabel(itemType string) string {
|
||||
switch itemType {
|
||||
case "cdn":
|
||||
return "CDN"
|
||||
case "waf":
|
||||
return "WAF"
|
||||
case "cloud":
|
||||
return "云厂商"
|
||||
default:
|
||||
return "未知类型"
|
||||
}
|
||||
}
|
||||
|
||||
// acronymProviders 需要保持全大写的提供商缩写(cdncheck 返回全小写,如 aws → AWS)。
|
||||
var acronymProviders = map[string]bool{
|
||||
"aws": true, "gcp": true, "oci": true, "gds": true, "ovh": true,
|
||||
}
|
||||
|
||||
// title 将提供商名转为展示形式:缩写全大写(aws → AWS),其余首字母大写
|
||||
// (cloudflare → Cloudflare)。
|
||||
func title(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
if acronymProviders[lower] {
|
||||
return strings.ToUpper(lower)
|
||||
}
|
||||
r := []rune(lower)
|
||||
return strings.ToUpper(string(r[0])) + string(r[1:])
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ipinfo
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLookupInvalidIP 非法输入必须折叠为展示标签且不 panic,proxied 恒为 false。
|
||||
func TestLookupInvalidIP(t *testing.T) {
|
||||
for _, in := range []string{"", "not-an-ip", "999.1.1.1", " "} {
|
||||
label, proxied := Lookup(in)
|
||||
if proxied {
|
||||
t.Errorf("Lookup(%q) proxied = true, want false", in)
|
||||
}
|
||||
if label == "" {
|
||||
t.Errorf("Lookup(%q) label is empty, want a fallback label", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupReturnsLabel 合法 IP 应返回非空标签且不 panic(不锁定具体归属,
|
||||
// 避免上游签名快照更新导致测试抖动)。
|
||||
func TestLookupReturnsLabel(t *testing.T) {
|
||||
for _, in := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
|
||||
label, _ := Lookup(in)
|
||||
if label == "" {
|
||||
t.Errorf("Lookup(%q) label is empty", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user