Files
sub2api/backend/internal/service/audit_log_test.go
T
shaw 2de6ccb071 fix(security): 补齐审计日志脱敏缺口 + step-up 下载/取消体验修复
审计发现修复:

高危——审计日志请求体脱敏不全(audit_logs 沦为明文凭证聚合点):
- 键名归一化比对(小写+去分隔符),覆盖 privateKey/apiv3key 等无分隔符与 camelCase 写法
- 程序化并入 SensitiveCredentialKeys 与 providerSensitiveConfigFields 两份权威敏感表,防清单漂移
- 补齐 proxy_key(内嵌代理密码)、custom_key(自设 API Key 明文)精确键
- Codex session 导入路由 body 整体由粘贴的 auth JSON 构成,键级脱敏无法覆盖,整体不入库
- 新增守卫测试:两份权威表的每个键必须被审计脱敏命中;provider_key 等渠道标识保留以便追责

中危——step-up 前端体验:
- 备份下载改同页 anchor 导航(预签名 URL 后端强制 attachment disposition),
  避免 step-up 弹窗 await 耗尽瞬态激活后 window.open 被浏览器拦截
- useStepUp.run 用户取消时抛 StepUpCancelledError sentinel,
  三个调用点静默处理,不再把取消误报为红色错误 toast

低危:
- 修正审计中间件挂载位置的陈旧注释(实际挂在认证之后)
- 审计 body 捕获改 LimitReader 按 256KB 上限截断读取,超出部分拼接回填,
  避免大体积导入请求被完整复制进内存两次
- TOTP step-up 弹窗验证成功后即时清空验证码输入
2026-07-16 14:38:16 +08:00

187 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"strings"
"testing"
)
func TestMaskAuditCredential(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"short", "abc", "****"},
{"boundary_14", "12345678901234", "****"},
{"long", "sk-ant-api03-abcdefghijklmnop1234", "sk-ant****1234"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := MaskAuditCredential(tc.in)
if got != tc.want {
t.Fatalf("MaskAuditCredential(%q) = %q, want %q", tc.in, got, tc.want)
}
// 掩码结果绝不能包含原始凭证的中间部分。
if len(tc.in) > 14 && strings.Contains(got, tc.in) {
t.Fatalf("masked value leaks full credential: %q", got)
}
})
}
}
func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) {
raw := []byte(`{
"name": "acc1",
"base_url": "https://evil.example.com",
"credentials": {"api_key": "sk-secret-123", "base_url": "https://evil.example.com"},
"new_password": "hunter2",
"totp_code": "123456",
"nested": [{"access_token": "tok_abc"}]
}`)
out := RedactAuditBody(raw, "application/json")
var parsed map[string]any
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, out)
}
// 敏感字段被擦除。
for _, secret := range []string{"sk-secret-123", "hunter2", "123456", "tok_abc"} {
if strings.Contains(out, secret) {
t.Fatalf("redacted body still contains secret %q: %s", secret, out)
}
}
// 非敏感字段(base_url、name)保留以便追责。
if !strings.Contains(out, "evil.example.com") {
t.Fatalf("base_url should be preserved for accountability: %s", out)
}
if !strings.Contains(out, "acc1") {
t.Fatalf("name should be preserved: %s", out)
}
}
// TestRedactAuditBody_AuthoritativeTablesSynced 覆盖曾经漏网的凭证字段:
// 账号 credentials 敏感子键、支付渠道无分隔符密钥、字符串值内嵌凭证的 proxy_key / custom_key,
// 以及 camelCase 等命名变体(归一化比对)。
func TestRedactAuditBody_AuthoritativeTablesSynced(t *testing.T) {
raw := []byte(`{
"credentials": {
"session_key": "sk-session-aaa",
"service_account_json": "{\"private_key\":\"pem-body-bbb\"}",
"service_account": "sa-blob-ccc"
},
"proxy_key": "socks5|1.2.3.4|1080|proxyuser|proxypass-ddd",
"custom_key": "sk-custom-eee",
"config": {
"pkey": "easypay-merchant-fff",
"privateKey": "alipay-pem-ggg",
"apiv3key": "wxpay-v3-hhh",
"SecretKey": "stripe-sk-iii",
"webhookSecret": "whsec-jjj"
},
"provider_key": "stripe",
"name": "instance-1"
}`)
out := RedactAuditBody(raw, "application/json")
for _, secret := range []string{
"sk-session-aaa", "pem-body-bbb", "sa-blob-ccc",
"proxypass-ddd", "sk-custom-eee",
"easypay-merchant-fff", "alipay-pem-ggg", "wxpay-v3-hhh",
"stripe-sk-iii", "whsec-jjj",
} {
if strings.Contains(out, secret) {
t.Fatalf("redacted body still contains secret %q: %s", secret, out)
}
}
// provider_key 是渠道标识而非密钥,必须保留以便追责。
if !strings.Contains(out, `"provider_key":"stripe"`) {
t.Fatalf("provider_key should be preserved for accountability: %s", out)
}
if !strings.Contains(out, "instance-1") {
t.Fatalf("name should be preserved: %s", out)
}
}
// SensitiveCredentialKeys 中的每个键都必须被审计脱敏判定命中(防两表漂移的守卫)。
func TestAuditSensitiveKeys_CoverCredentialTable(t *testing.T) {
for _, k := range SensitiveCredentialKeys {
if !isAuditSensitiveBodyKey(k) {
t.Fatalf("credential key %q is not covered by audit redaction", k)
}
}
for provider, fields := range providerSensitiveConfigFields {
for k := range fields {
if !isAuditSensitiveBodyKey(k) {
t.Fatalf("payment provider %q sensitive field %q is not covered by audit redaction", provider, k)
}
}
}
}
func TestRedactAuditBody_NonJSONOmitted(t *testing.T) {
out := RedactAuditBody([]byte("username=admin&password=secret"), "application/x-www-form-urlencoded")
if strings.Contains(out, "secret") {
t.Fatalf("non-json body must not leak content: %s", out)
}
if !strings.Contains(out, "omitted") {
t.Fatalf("expected omission marker, got: %s", out)
}
}
func TestRedactAuditBody_Empty(t *testing.T) {
if got := RedactAuditBody(nil, "application/json"); got != "" {
t.Fatalf("expected empty for nil body, got %q", got)
}
}
func TestSessionBindingHash(t *testing.T) {
a := &SessionBinding{IP: "1.2.3.4", UserAgent: "Mozilla/5.0"}
b := &SessionBinding{IP: "1.2.3.4", UserAgent: "Mozilla/5.0"}
if a.Hash() != b.Hash() {
t.Fatalf("identical bindings must hash equal")
}
if a.Hash() == "" {
t.Fatalf("non-empty binding must produce non-empty hash")
}
// IP 变化 → 哈希变化。
c := &SessionBinding{IP: "5.6.7.8", UserAgent: "Mozilla/5.0"}
if a.Hash() == c.Hash() {
t.Fatalf("changing IP must change hash")
}
// UA 变化 → 哈希变化。
d := &SessionBinding{IP: "1.2.3.4", UserAgent: "curl/8.0"}
if a.Hash() == d.Hash() {
t.Fatalf("changing UA must change hash")
}
// 空指纹 → 空哈希(旧 token 兼容)。
empty := &SessionBinding{}
if empty.Hash() != "" {
t.Fatalf("empty binding must hash to empty string")
}
var nilBinding *SessionBinding
if nilBinding.Hash() != "" {
t.Fatalf("nil binding must hash to empty string")
}
}
func TestParseAuditLogRetentionDays(t *testing.T) {
cases := map[string]int{
"": defaultAuditLogRetentionDays,
"abc": defaultAuditLogRetentionDays,
"90": 90,
"0": 0,
"-1": 0,
" 30 ": 30,
}
for in, want := range cases {
if got := parseAuditLogRetentionDays(in); got != want {
t.Fatalf("parseAuditLogRetentionDays(%q) = %d, want %d", in, got, want)
}
}
}