实现审计覆盖门禁与外部集成日志闭环
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m19s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m19s
This commit is contained in:
51
pkg/constants/integration_log.go
Normal file
51
pkg/constants/integration_log.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
// IntegrationDirectionInbound 表示外部系统调用本系统。
|
||||
IntegrationDirectionInbound = "inbound"
|
||||
// IntegrationDirectionOutbound 表示本系统调用外部系统。
|
||||
IntegrationDirectionOutbound = "outbound"
|
||||
)
|
||||
|
||||
const (
|
||||
// IntegrationResultPending 表示外部尝试已建立但尚未终结。
|
||||
IntegrationResultPending = "pending"
|
||||
// IntegrationResultSuccess 表示外部尝试成功。
|
||||
IntegrationResultSuccess = "success"
|
||||
// IntegrationResultFailed 表示外部尝试明确失败。
|
||||
IntegrationResultFailed = "failed"
|
||||
// IntegrationResultUnknown 表示请求已发出但结果未知,需要按记录的策略恢复。
|
||||
IntegrationResultUnknown = "unknown"
|
||||
// IntegrationResultNotFound 表示外部资源不存在。
|
||||
IntegrationResultNotFound = "not_found"
|
||||
// IntegrationResultInvalidPayload 表示入站载荷无效。
|
||||
IntegrationResultInvalidPayload = "invalid_payload"
|
||||
// IntegrationResultIgnored 表示外部尝试被安全忽略。
|
||||
IntegrationResultIgnored = "ignored"
|
||||
// IntegrationResultMerged 表示尝试被合并且未发送请求。
|
||||
IntegrationResultMerged = "merged"
|
||||
// IntegrationResultRateLimited 表示尝试因限频未发送请求。
|
||||
IntegrationResultRateLimited = "rate_limited"
|
||||
// IntegrationResultCompleted 表示业务已达预期,尝试提前完成且未发送请求。
|
||||
IntegrationResultCompleted = "completed"
|
||||
// IntegrationResultCancelled 表示尝试已取消。
|
||||
IntegrationResultCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// IntegrationResultName 返回外部尝试结果的中文名称。
|
||||
func IntegrationResultName(result string) string {
|
||||
names := map[string]string{
|
||||
IntegrationResultPending: "待处理",
|
||||
IntegrationResultSuccess: "成功",
|
||||
IntegrationResultFailed: "失败",
|
||||
IntegrationResultUnknown: "结果未知",
|
||||
IntegrationResultNotFound: "未找到",
|
||||
IntegrationResultInvalidPayload: "无效载荷",
|
||||
IntegrationResultIgnored: "已忽略",
|
||||
IntegrationResultMerged: "已合并",
|
||||
IntegrationResultRateLimited: "已限频",
|
||||
IntegrationResultCompleted: "已提前完成",
|
||||
IntegrationResultCancelled: "已取消",
|
||||
}
|
||||
return names[result]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -10,25 +11,34 @@ import (
|
||||
|
||||
// AccessPolicy 是敏感路由的安全摘要策略。
|
||||
type AccessPolicy struct {
|
||||
Name string
|
||||
Sensitive bool
|
||||
SafeFields map[string]struct{}
|
||||
FileFields map[string]struct{}
|
||||
Name string
|
||||
Sensitive bool
|
||||
PresenceOnly bool
|
||||
SafeFields map[string]struct{}
|
||||
ResultFields map[string]struct{}
|
||||
FileFields map[string]struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
loginPolicy = AccessPolicy{
|
||||
Name: "login_token", Sensitive: true,
|
||||
SafeFields: fieldSet("username", "user_id", "account_id", "success", "result_code"),
|
||||
Name: "login_token", Sensitive: true, PresenceOnly: true,
|
||||
SafeFields: fieldSet("username", "user_id", "account_id", "success", "result_code"),
|
||||
ResultFields: fieldSet("success", "result_code", "status"),
|
||||
}
|
||||
paymentPolicy = AccessPolicy{
|
||||
Name: "payment", Sensitive: true,
|
||||
SafeFields: fieldSet("order_no", "payment_no", "channel", "payment_method", "result_code", "amount", "status"),
|
||||
Name: "payment", Sensitive: true, PresenceOnly: true,
|
||||
SafeFields: fieldSet("order_no", "payment_no", "channel", "payment_method", "result_code", "amount", "status"),
|
||||
ResultFields: fieldSet("result_code", "status"),
|
||||
}
|
||||
wecomPolicy = AccessPolicy{
|
||||
Name: "wecom_callback", Sensitive: true,
|
||||
SafeFields: fieldSet("event_type", "resource_type", "resource_id", "result_code", "status"),
|
||||
}
|
||||
configPolicy = AccessPolicy{
|
||||
Name: "sensitive_config", Sensitive: true, PresenceOnly: true,
|
||||
SafeFields: fieldSet("config_key", "value_type", "module", "readonly", "sensitive", "configured", "status", "result_code"),
|
||||
ResultFields: fieldSet("readonly", "sensitive", "configured", "status", "result_code"),
|
||||
}
|
||||
filePolicy = AccessPolicy{
|
||||
Name: "file_export", Sensitive: true,
|
||||
SafeFields: fieldSet("content_type", "file_size", "size", "count", "task_id", "status", "result_code"),
|
||||
@@ -50,6 +60,8 @@ func policyForPath(path string) AccessPolicy {
|
||||
switch {
|
||||
case strings.Contains(normalized, "/login"), strings.Contains(normalized, "token"):
|
||||
return loginPolicy
|
||||
case strings.Contains(normalized, "/system-configs"), strings.Contains(normalized, "/wechat-configs"):
|
||||
return configPolicy
|
||||
case strings.Contains(normalized, "/callback") && (strings.Contains(normalized, "wecom") || strings.Contains(normalized, "wework")):
|
||||
return wecomPolicy
|
||||
case strings.Contains(normalized, "payment"), strings.Contains(normalized, "wechat-pay"),
|
||||
@@ -102,7 +114,13 @@ func collectSafeFields(value any, policy AccessPolicy, output map[string]any) {
|
||||
case string:
|
||||
collectSafeScalar(key, scalar, policy, output)
|
||||
case float64, bool:
|
||||
if _, allowed := policy.SafeFields[strings.ToLower(key)]; allowed {
|
||||
if policy.PresenceOnly {
|
||||
if _, isResult := policy.ResultFields[strings.ToLower(key)]; isResult {
|
||||
output[key] = scalar
|
||||
} else {
|
||||
output[key] = map[string]any{"present": true, "length": len(fmt.Sprint(scalar))}
|
||||
}
|
||||
} else if _, allowed := policy.SafeFields[strings.ToLower(key)]; allowed {
|
||||
output[key] = scalar
|
||||
}
|
||||
default:
|
||||
@@ -118,6 +136,14 @@ func collectSafeFields(value any, policy AccessPolicy, output map[string]any) {
|
||||
|
||||
func collectSafeScalar(key, value string, policy AccessPolicy, output map[string]any) {
|
||||
normalized := strings.ToLower(key)
|
||||
if policy.PresenceOnly {
|
||||
if _, isResult := policy.ResultFields[normalized]; isResult {
|
||||
output[key] = value
|
||||
return
|
||||
}
|
||||
output[key] = map[string]any{"present": true, "length": len(value)}
|
||||
return
|
||||
}
|
||||
if _, isFileName := policy.FileFields[normalized]; isFileName {
|
||||
base := filepath.Base(value)
|
||||
hash := digest([]byte(base))
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
@@ -135,20 +135,7 @@ func sanitizeJSONValue(value any) {
|
||||
|
||||
// shouldMaskField 判断字段名是否属于访问日志敏感字段
|
||||
func shouldMaskField(key string) bool {
|
||||
normalized := strings.ToLower(key)
|
||||
return strings.Contains(normalized, "password") ||
|
||||
strings.Contains(normalized, "passwd") ||
|
||||
strings.Contains(normalized, "credential") ||
|
||||
strings.Contains(normalized, "authorization") ||
|
||||
strings.Contains(normalized, "cookie") ||
|
||||
strings.Contains(normalized, "key") ||
|
||||
strings.Contains(normalized, "url") ||
|
||||
strings.Contains(normalized, "qr_content") ||
|
||||
strings.Contains(normalized, "verification_code") ||
|
||||
strings.Contains(normalized, "sign") ||
|
||||
strings.Contains(normalized, "nonce") ||
|
||||
strings.Contains(normalized, "token") ||
|
||||
strings.Contains(normalized, "secret")
|
||||
return sanitizer.IsForbiddenField(key)
|
||||
}
|
||||
|
||||
// Middleware 创建 Fiber 日志中间件
|
||||
|
||||
@@ -92,6 +92,8 @@ func TestSensitiveRouteMatrixNeverLogsRawPayload(t *testing.T) {
|
||||
})
|
||||
request := httptest.NewRequest("POST", tc.path+"?signature=query-secret", strings.NewReader(tc.body))
|
||||
request.Header.Set("Content-Type", tc.contentType)
|
||||
request.Header.Set("Authorization", "Bearer header-secret")
|
||||
request.Header.Set("Cookie", "session=cookie-secret")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行敏感路由请求失败:%v", err)
|
||||
@@ -99,7 +101,7 @@ func TestSensitiveRouteMatrixNeverLogsRawPayload(t *testing.T) {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
for _, secret := range []string{tc.secret, "query-secret", "response-secret"} {
|
||||
for _, secret := range []string{tc.secret, "query-secret", "response-secret", "header-secret", "cookie-secret"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("敏感路由日志泄露 %q:%s", secret, logged)
|
||||
}
|
||||
@@ -131,6 +133,92 @@ func TestSensitiveRouteLongBodyRecordsTruncationWithoutRawSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealConfigurationRoutesOnlyLogSafeSummary(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/api/admin/system-configs/payment.private_key",
|
||||
"/api/admin/wechat-configs/1",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
log := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.AddSync(&output), zapcore.InfoLevel))
|
||||
app := fiber.New()
|
||||
app.Use(MiddlewareWithLogger(log))
|
||||
app.Put(path, func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"value": "response-private-material", "status": "ok"})
|
||||
})
|
||||
request := httptest.NewRequest("PUT", path, strings.NewReader(`{"value":"request-private-material","config_key":"payment.private_key"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行配置路由请求失败:%v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
for _, secret := range []string{"request-private-material", "response-private-material"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("配置路由日志泄露 %q:%s", secret, logged)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logged, `"body_policy":"sensitive_config"`) {
|
||||
t.Fatalf("配置路由未应用安全摘要策略:%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, `\"present\":true`) || !strings.Contains(logged, `\"length\":`) {
|
||||
t.Fatalf("配置路由未记录字段存在性和长度:%s", logged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRouteDoesNotLogUsernameAndOnlyKeepsPresenceLength(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
log := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.AddSync(&output), zapcore.InfoLevel))
|
||||
app := fiber.New()
|
||||
app.Use(MiddlewareWithLogger(log))
|
||||
app.Post("/api/auth/login", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"username": "visible-user", "success": true})
|
||||
})
|
||||
request := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"visible-user","password":"login-secret"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行登录请求失败:%v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
if strings.Contains(logged, "visible-user") || strings.Contains(logged, "login-secret") {
|
||||
t.Fatalf("登录路由泄露账号或凭证:%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, `\"present\":true`) || !strings.Contains(logged, `\"length\":`) {
|
||||
t.Fatalf("登录路由未记录字段存在性和长度:%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentRouteOnlyLogsPresenceLengthAndSafeResult(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
log := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.AddSync(&output), zapcore.InfoLevel))
|
||||
app := fiber.New()
|
||||
app.Use(MiddlewareWithLogger(log))
|
||||
app.Post("/api/callback/alipay", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"payment_no": "PAY-SECRET-1", "amount": 12345, "status": "success"})
|
||||
})
|
||||
request := httptest.NewRequest("POST", "/api/callback/alipay", strings.NewReader("order_no=ORDER-SECRET-1&amount=12345&sign=signature-secret"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行支付路由请求失败:%v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
for _, value := range []string{"PAY-SECRET-1", "ORDER-SECRET-1", "signature-secret"} {
|
||||
if strings.Contains(logged, value) {
|
||||
t.Fatalf("支付路由泄露原值 %q:%s", value, logged)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logged, `\"present\":true`) || !strings.Contains(logged, `\"status\":\"success\"`) {
|
||||
t.Fatalf("支付路由缺少存在性摘要或安全结果:%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeInvalidJSONNeverFallsBackToRawBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
76
pkg/sanitizer/sanitizer.go
Normal file
76
pkg/sanitizer/sanitizer.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package sanitizer 提供 Access、Audit 与 Integration 共用的敏感字段清理能力。
|
||||
package sanitizer
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var forbiddenFragments = []string{
|
||||
"password", "passwd", "credential", "operation_password", "verification_code", "captcha",
|
||||
"access_token", "refresh_token", "authorization", "cookie", "secret", "private_key", "public_key",
|
||||
"encoding_aes_key", "callback_token", "signature", "sign", "nonce", "media_id", "signed_url",
|
||||
"private_url", "qr_content", "id_card", "identity_number",
|
||||
}
|
||||
|
||||
// IsForbiddenField 判断字段是否禁止进入普通日志、审计或外部交互摘要。
|
||||
func IsForbiddenField(key string) bool {
|
||||
normalized := strings.ToLower(strings.NewReplacer("-", "_", ".", "_").Replace(key))
|
||||
for _, fragment := range forbiddenFragments {
|
||||
if strings.Contains(normalized, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(normalized, "_key") || normalized == "key" || strings.HasSuffix(normalized, "_url") || normalized == "url" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MarshalSummary 递归删除禁止字段并返回 sonic 编码的安全 JSON。
|
||||
func MarshalSummary(value any) ([]byte, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
encoded, err := sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var normalized any
|
||||
if err := sonic.Unmarshal(encoded, &normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RemoveForbiddenFields(normalized)
|
||||
return sonic.Marshal(normalized)
|
||||
}
|
||||
|
||||
// RemoveForbiddenFields 原地递归删除 Map 或数组中的禁止字段。
|
||||
func RemoveForbiddenFields(value any) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
if IsForbiddenField(key) {
|
||||
delete(typed, key)
|
||||
continue
|
||||
}
|
||||
RemoveForbiddenFields(item)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
RemoveForbiddenFields(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TextSummary 将不可信外部文本转换为不可逆大小和哈希摘要。
|
||||
func TextSummary(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return fmt.Sprintf("外部文本摘要 bytes=%d sha256=%s", len(value), hex.EncodeToString(sum[:8]))
|
||||
}
|
||||
Reference in New Issue
Block a user