作者:温墨白-899|分布式系统 / 安全加固|15 年经验 栈:grpc-go 1.60、SLS(阿里云日志服务)、Go 1.21 结论先行:任何写进日志的字段,默认当作"攻击者可读、可搜索、可批量下载"。3 行 debug 日志能烧掉一个季度的合规时间。
一、现象:一次 SLS 例行审计,扫出 27 万条带完整 access_token 的日志
上个月给一个 SaaS 客户做季度安全审计。他们的内部服务(3 个 gRPC 微服务:order-svc、user-svc、pay-svc)日志全量采集到 SLS,保留期 90 天。我在审计项里加了一条"敏感字段扫描",用正则 eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,} 匹配 JWT 形态,结果 3 个 logstore 里命中:
order-svc:18 421 条(占总量 0.4%)user-svc:6 203 条pay-svc:2 376 条
合计 27 000 条,跨 90 天,估算涉及 12 万独立用户会话。这些 access_token 是 15 分钟短期 token,但 token 里带了 sub(用户 ID)、tenant(租户 ID)、role(角色,含 3 个 admin),一旦拿到,攻击者可以在 15 分钟窗口内以该用户身份调用所有 gRPC 接口——等同于一次未授权访问,只是窗口短、频次高。
更糟的是 SLS 的 logstore 权限模型:审计用的 RAM 子账号本来只读,但同一 RAM 账号下的所有运维子账号都能 GetLogs——意味着这 2.7 万条日志在 90 天内对 14 个运维同事都是"可见的"。
二、根因:gRPC 拦截器里 3 行"排查超时"的 debug 日志
复盘代码,问题出在 order-svc 的一个 grpc-go 通用拦截器(另外两个服务是 copy 过来的):
// internal/interceptor/debug.go
func DebugLogger() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
if log.DebugEnabled() {
log.Printf("[gRPC] req=%v meta=%v", req, metadata.FromIncomingContext(ctx))
}
resp, err := handler(ctx, req)
if log.DebugEnabled() {
log.Printf("[gRPC] resp=%v err=%v cost=%v", resp, err, time.Since(start))
}
return resp, err
}
}
三行 log.Printf,问题在第二行:metadata.FromIncomingContext(ctx) 把客户端发来的全部 metadata 打出来,包括 authorization: Bearer eyJhbGciOi...。
为什么这段代码能活 8 个月没人发现?三个机制叠加:
- gRPC metadata 没有"敏感字段"语义。HTTP 里
Authorization是标准头,开发者有本能警觉;gRPC metadata 是key-value字典,业务字段(x-tenant-id)和敏感字段(authorization)混在一个 map 里,%v一把梭就全打出来。 log.DebugEnabled()在生产是 true。他们的日志级别策略是"生产info,但 debug 只输出到 stderr 不写文件"——然而 SLS 的 Logtail 采集的是进程 stdout/stderr 全量,级别过滤根本不起作用。%v对 struct 打印所有字段。req是 proto struct,%v会展开到每个字段,包括嵌套的token、api_key字段。
三、具体做法:五步加固,含可复现的代码与 SQL
1. 拦截器改白名单打日志(治本)
// 只打"安全字段",白名单显式声明
var safeMetaKeys = map[string]bool{
"x-request-id": true,
"x-tenant-id": true,
"x-trace-id": true,
// 注意:authorization、x-api-key、x-user-token 等一律不打
}
func DebugLogger() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
if log.DebugEnabled() {
md := metadata.FromIncomingContext(ctx)
safe := make(map[string][]string, len(md))
for k, v := range md {
if safeMetaKeys[strings.ToLower(k)] {
safe[k] = v
}
}
log.Printf("[gRPC] path=%v reqType=%T safeMeta=%v", info.FullMethod, req, safe)
}
resp, err := handler(ctx, req)
// 响应只打 type + err,不打内容
log.Printf("[gRPC] path=%v respType=%T err=%v cost=%v", info.FullMethod, resp, err, time.Since(start))
return resp, err
}
}
关键点:
- 白名单用
strings.ToLower(k)匹配,因为 gRPC metadata 的 key 会被框架强制小写化(Authorization进来变authorization),用原始大小写匹配会漏。 - 响应不打
%v,只打%T(类型)和err,避免嵌套结构体展开。
2. 加一层 metadata 脱敏中间件(兜底)
即使拦截器改对了,业务代码里可能还有 fmt.Sprintf("%v", md) 散落各处。加一个出口脱敏:
// 在 log 包的全局 formatter 里加
func Sanitize(v any) any {
s := fmt.Sprintf("%v", v)
// 匹配 JWT
s = jwtRegex.ReplaceAllString(s, "[REDACTED:JWT]")
// 匹配 API Key(32+ 位 hex 或 base64)
s = apiKeyRegex.ReplaceAllString(s, "[REDACTED:KEY]")
// 匹配 password / token / secret 等字段名
s = sensitiveFieldRegex.ReplaceAllString(s, "$1=[REDACTED]")
return s
}
var (
jwtRegex = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}`)
apiKeyRegex = regexp.MustCompile(`[a-f0-9]{32,}|[A-Za-z0-9+/]{40,}={0,2}`)
sensitiveFieldRegex = regexp.MustCompile(`(?i)(password|token|secret|api[_-]?key|credential)\s*[:=]\s*\S+`)
)
3. 存量日志清洗(止血)
SLS 的 SQL 支持 regexp_replace,但只能改查询视图,不改底表。底表还是明文。正确做法:
-- 1. 先导出 27000 条原始日志到 OSS(JSON Lines)
* | select log_time, __source__, body from logstore
where body like '%eyJ%' limit 27000
-- 2. 本地用 Python 脱敏后写回一个新 logstore
import json, re
jwt_re = re.compile(r'eyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}')
with open('export.jsonl') as f, open('clean.jsonl', 'w') as out:
for line in f:
rec = json.loads(line)
rec['body'] = jwt_re.sub('[REDACTED:JWT]', rec['body'])
out.write(json.dumps(rec) + '\n')
# 3. 用 SLS 的 PutLogs API 写回新 logstore,旧 logstore 设 TTL=7 天后销毁
4. CI 静态检查(防回归)
在 CI 里加一条 grep 规则(.github/workflows/security-lint.yml):
- name: Check for sensitive log patterns
run: |
# 匹配 log.Printf 里带 metadata / req / resp 的 %v
if grep -rnE 'log\.Printf.*%v.*metadata|log\.Printf.*%v.*\breq\b' --include='*.go' .; then
echo "::error::Potential sensitive data in log output"
exit 1
fi
5. Logtail 采集配置加字段级过滤(环境层兜底)
在 Logtail 的 processor_regex 插件里加一条:
processor_regex:
SourceKey: content
Regex: 'eyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}'
NoKeyError: true
KeepSource: true
# 命中即替换
processor_field:
Fields:
content: '[REDACTED:JWT]'
四、踩过的坑(4 个反直觉点)
- gRPC metadata key 被强制小写化。
Authorization进来变authorization,白名单匹配必须ToLower,否则漏。 - 日志级别 ≠ 采集级别。
log.DebugEnabled()控制的是"进程是否输出",但 Logtail 采集的是 stdout 全量,级别过滤在采集层无效。 %v对 proto struct 会展开嵌套字段。req.User.Credentials.AccessToken这种深层字段也会被%v打出来,白名单必须覆盖到嵌套。- SLS
regexp_replace只改视图不改底表。存量清洗必须导出→本地脱敏→写回新 logstore→旧 logstore 设短 TTL,不能指望 SQL 一行搞定。
五、结论与可复用清单
原则:日志里能出现的字段,默认当作"攻击者可读、可搜索、可批量下载"。
5 条检查项(可直接贴进安全基线):
| # | 检查项 | 验证方式 |
|---|--------|----------|
| 1 | gRPC 拦截器是否白名单打日志 | grep -rn 'metadata.FromIncomingContext' --include='*.go' |
| 2 | 是否有全局日志脱敏 formatter | grep -rn 'Sanitize\|Redact' --include='*.go' |
| 3 | CI 是否有敏感日志 grep 规则 | 检查 .github/workflows/ 或 Jenkinsfile |
| 4 | Logtail 是否配置了字段级脱敏 | SLS 控制台 → Logtail 配置 → processor 插件 |
| 5 | 存量日志是否已清洗 / TTL 是否合理 | SLS 控制台 → logstore → 保留策略 |
一句话:3 行 debug 日志能烧掉一个季度的合规时间。写日志时,把"这行日志会出现在 SLS 里被 14 个运维看到"当作第一假设。
参考:温景行「JWT 被自签成 admin:一次 alg 混淆攻击复盘与 Spring Security 加固清单」——JWT 作为敏感凭证,一旦落盘即等同于泄露,本文的日志加固是同一防线在"采集侧"的延伸。