第一章:Go安全编码突击学习导论
Go语言凭借其简洁语法、静态编译、内存安全机制(如自动垃圾回收与无指针算术)成为云原生与高并发服务的首选,但语言层面的安全保障不等于应用层自动免疫。开发者仍需主动防御常见漏洞:SQL注入、命令注入、不安全反序列化、硬编码凭证、不校验的用户输入、TLS配置缺陷等。本导论聚焦实战导向的安全编码思维转变——从“功能优先”转向“默认安全”。
安全编码的核心原则
- 最小权限原则:进程以非root用户运行,文件系统访问严格限定路径白名单;
- 输入即不可信:所有外部数据(HTTP参数、环境变量、配置文件、CLI参数)必须显式验证与净化;
- 纵深防御:单一防护(如仅用
html.EscapeString)不足,需组合使用类型约束、正则白名单、上下文感知转义(如JSON输出用json.Marshal而非字符串拼接)。
快速启动:初始化一个安全基线项目
执行以下命令创建带基础安全检查的Go模块:
# 1. 初始化模块(强制启用Go modules)
go mod init secureapp && go mod tidy
# 2. 添加静态分析工具依赖(用于后续CI集成)
go install golang.org/x/tools/cmd/staticcheck@latest
go install github.com/securego/gosec/v2/cmd/gosec@latest
# 3. 创建main.go并启用HTTP安全头(示例)
package main
import (
"net/http"
"time"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 强制设置安全响应头
w.Header().Set("Content-Security-Policy", "default-src 'self'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
w.Write([]byte("Hello, secure world!"))
})
http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil) // 生产环境务必使用有效证书
}
关键检查项清单
| 检查点 | 推荐实践 |
|---|---|
| 依赖管理 | go list -u -m all 定期扫描过时/有CVE的包 |
| 密钥处理 | 禁止硬编码,改用os.Getenv("DB_PASSWORD") + Vault/KMS |
| 日志输出 | 避免记录敏感字段(如token、密码),使用结构化日志库如zerolog |
安全不是附加功能,而是每个func、每行import、每次http.HandleFunc的决策起点。
第二章:SQL注入(SQLi)防御实战
2.1 SQLi攻击原理与Go中常见漏洞模式分析
SQL注入(SQLi)本质是将用户输入拼接进SQL语句,绕过语法边界执行恶意逻辑。在Go中,database/sql包若误用字符串拼接,极易触发漏洞。
危险写法示例
// ❌ 绝对禁止:直接拼接用户输入
username := r.URL.Query().Get("user")
query := "SELECT * FROM users WHERE name = '" + username + "'"
rows, _ := db.Query(query) // 输入 'admin' OR '1'='1' 将全表泄露
该代码未做输入校验、未使用参数化查询,username中的单引号可提前闭合字符串,后续逻辑被任意控制。
安全模式对比
| 模式 | 是否安全 | 原因 |
|---|---|---|
db.Query("WHERE name = ?", user) |
✅ | 预编译+绑定,参数隔离 |
fmt.Sprintf("WHERE name = '%s'", user) |
❌ | 字符串插值,无类型防护 |
防御核心路径
- 强制使用
?占位符与Query/Exec参数绑定 - 对动态表名/列名等无法参数化的场景,白名单校验
- 启用
sql.Scanner进行类型安全反序列化
graph TD
A[用户输入] --> B{是否经参数化处理?}
B -->|否| C[SQLi风险]
B -->|是| D[数据库驱动安全绑定]
D --> E[执行隔离的预编译语句]
2.2 使用database/sql预处理语句实现参数化查询
预处理语句(Prepared Statement)是抵御SQL注入的核心机制,database/sql通过Stmt类型提供安全、高效的参数化查询能力。
为何需要预处理?
- SQL语句结构与数据分离,避免拼接字符串
- 数据库可缓存执行计划,提升重复查询性能
- 驱动自动处理类型转换与转义
基本使用流程
// 准备带占位符的SQL(?为通用占位符)
stmt, err := db.Prepare("SELECT id, name FROM users WHERE age > ? AND status = ?")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
// 执行时传入具体值,类型由驱动自动适配
rows, err := stmt.Query(18, "active")
✅ ? 占位符由底层驱动映射为数据库原生参数(如 PostgreSQL 的 $1, MySQL 的 ?)
✅ Query()/Exec() 方法确保值以二进制协议传输,杜绝文本注入可能
预处理 vs 直接Query对比
| 场景 | 直接Query | 预处理Stmt |
|---|---|---|
| 安全性 | 依赖手动转义 | 内置隔离,零风险 |
| 性能(100次相同查询) | 每次解析+优化 | 一次准备,多次复用 |
| 类型支持 | 字符串强制转换 | 原生Go类型直传 |
graph TD
A[应用层调用db.Prepare] --> B[驱动发送PREPARE命令至DB]
B --> C[数据库编译SQL并缓存执行计划]
C --> D[返回Stmt句柄]
D --> E[后续Query/Exec复用该计划]
2.3 ORM框架(GORM)的安全配置与防注入最佳实践
启用自动转义与禁用原始SQL拼接
GORM 默认对 Where、First 等方法的参数进行占位符绑定,但需显式避免 Where("name = ?", name) 被误写为 Where("name = '" + name + "'")。
// ✅ 安全:参数化查询(GORM 自动使用 ? 占位符)
db.Where("email = ?", userInput).First(&user)
// ❌ 危险:字符串拼接,直接触发 SQL 注入
db.Where("email = '" + userInput + "'").First(&user) // 绝对禁止
逻辑分析:GORM 将
?参数交由数据库驱动预处理,底层调用sql.Stmt,确保用户输入永不进入 SQL 解析上下文;userInput中的' OR '1'='1将被作为字面量值处理。
关键安全配置项
| 配置项 | 推荐值 | 说明 |
|---|---|---|
PrepareStmt |
true |
启用预编译语句,复用执行计划并强化参数隔离 |
SkipDefaultTransaction |
true(高并发场景) |
避免隐式事务扩大攻击面 |
Logger |
自定义日志过滤敏感字段 | 防止 SQL 日志泄露参数值 |
查询白名单机制流程
graph TD
A[接收用户输入] --> B{是否在白名单字段?}
B -->|是| C[构造 GORM 链式查询]
B -->|否| D[拒绝请求并记录审计事件]
C --> E[执行参数化查询]
2.4 动态查询场景下的白名单校验与表达式安全构造
动态查询常需拼接用户输入字段或操作符,直接解析易引发表达式注入(如 Ognl/SpEL 注入)。核心防御策略是白名单驱动的语法树裁剪。
白名单字段与操作符约束
- 允许字段:
["id", "name", "status", "created_at"] - 允许操作符:
["=", "!=", ">", "<", "IN", "LIKE"] - 禁止嵌套方法调用、类加载、静态属性访问
安全表达式构造示例
// 基于白名单校验后的安全拼接
String safeExpr = String.format(
"%s %s '%s'",
whitelistField("name"), // 校验后返回 "name" 或抛异常
whitelistOperator("="), // 校验后返回 "="
escapeForLike(userInput) // 转义通配符
);
逻辑分析:whitelistField() 查表确认字段合法性;whitelistOperator() 防止 ;, ?, # 等非法符号;escapeForLike() 对 %, _ 进行 \ 转义并设置 ESCAPE '\'。
风险操作符拦截效果对比
| 输入操作符 | 白名单校验结果 | 是否放行 |
|---|---|---|
= |
✅ 合法 | 是 |
IN |
✅ 合法 | 是 |
? |
❌ 非法 | 否 |
#context |
❌ 非法 | 否 |
graph TD
A[用户输入表达式] --> B{字段/操作符白名单校验}
B -->|通过| C[转义值参数]
B -->|拒绝| D[抛出SecurityException]
C --> E[生成预编译Query]
2.5 SQLi检测与运行时防护:中间件+AST静态扫描联动实现
联动架构设计
核心思路是将 AST 静态扫描结果(如危险拼接模式)注入运行时中间件的规则库,实现“编译期发现 → 运行期拦截”的闭环。
// 中间件动态加载AST识别的高危变量名(如 user_input)
app.use((req, res, next) => {
const riskyParams = ['username', 'id', 'query']; // 来自AST扫描输出的敏感标识符
for (const param of riskyParams) {
if (req.query[param] && /['";\\]|--|\/\*|\*\//i.test(req.query[param])) {
return res.status(400).json({ error: "Potential SQLi detected" });
}
}
next();
});
逻辑分析:该中间件不依赖正则盲检,而是基于 AST 提前标记的语义敏感参数名进行定向校验;riskyParams 由静态扫描器导出,确保覆盖真实代码中参与拼接的变量,避免漏报。
防护能力对比
| 方式 | 检测粒度 | 误报率 | 覆盖阶段 |
|---|---|---|---|
| 纯正则WAF | 字符串级 | 高 | 运行时 |
| AST静态扫描 | AST节点级(如 BinaryExpression + TemplateLiteral) | 极低 | 编译期 |
| 中间件+AST联动 | 变量语义级 + 上下文感知 | 最低 | 编译期+运行时 |
执行流程
graph TD
A[AST扫描器解析源码] --> B[提取SQL拼接上下文节点]
B --> C[生成riskyParams白名单]
C --> D[注入HTTP中间件规则]
D --> E[请求匹配时精准拦截]
第三章:跨站脚本(XSS)防御实战
3.1 XSS在Go Web服务中的典型触发路径与上下文分类
XSS漏洞在Go Web中常源于未过滤的用户输入直接嵌入HTML响应。核心触发路径包括:模板渲染、JSON API响应、HTTP头注入及前端JavaScript动态赋值。
常见上下文分类
- HTML主体上下文:
<div>{{.UserInput}}</div>→ 需HTML转义 - JavaScript数据上下文:
var name = "{{.Name}}";→ 需JS字符串转义 - URL属性上下文:
<a href="?q={{.Query}}">→ 需URL编码
典型危险代码示例
// ❌ 危险:直接插入用户输入到HTML模板
func handler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
tmpl := `<h1>Hello, {{.}}</h1>`
t := template.Must(template.New("xss").Parse(tmpl))
t.Execute(w, name) // 若name="><script>alert(1)</script>",即触发反射型XSS
}
此处name未经template.HTMLEscapeString或安全模板函数处理,Go模板默认仅对{{.}}在HTML上下文做基础转义,但若开发者显式使用template.HTML或绕过模板引擎拼接,将完全失效。
| 上下文类型 | 推荐防护方式 | Go标准库支持 |
|---|---|---|
| HTML主体 | template.HTMLEscapeString |
✅ 内置 |
| JavaScript字符串 | json.Marshal + template.JS |
✅(需配合template.JS) |
| URL参数 | url.QueryEscape |
✅ |
graph TD
A[用户输入] --> B{上下文识别}
B --> C[HTML主体] --> D[html.EscapeString]
B --> E[JS字符串] --> F[json.Marshal + template.JS]
B --> G[URL属性] --> H[url.QueryEscape]
3.2 html/template自动转义机制深度解析与边界绕过规避
html/template 的核心安全契约是:所有未显式标记为安全的数据,默认经 HTML 转义后输出。其转义器在模板渲染时动态注入,依据上下文(HTML text、attr、JS、CSS、URL)选择对应转义策略。
转义上下文判定逻辑
// 模板中 {{.UserInput}} 自动进入 HTML 文本上下文
// {{.JSCode | js}} 强制进入 JS 字符串上下文并执行 jsEscaper
jsEscaper对\u0000–\u001f、<,>,&,',"等字符进行 Unicode 编码(如<→\u003c),但不处理\x00–\x1f中的\x00(空字节)在某些 JS 引擎中的解析歧义——这是已知边界场景。
常见误用与规避清单
- ✅ 正确:
{{.SafeHTML | safeHTML}}(需template.HTML类型) - ❌ 危险:
{{printf "%s" .RawHTML | html}}(双重转义导致显示异常) - ⚠️ 高危:
<script>{{.InlineJS}}</script>—— 即使.InlineJS经js过滤,若含</script>字符串仍会提前闭合标签
安全上下文映射表
| 上下文位置 | 默认转义器 | 关键限制 |
|---|---|---|
<div>{{.X}}</div> |
htmlEscaper |
不允许 javascript: 协议 |
<a href="{{.URL}}"> |
urlEscaper |
对 "、'、<、> 编码 |
<script>{{.JS}}</script> |
jsEscaper |
不过滤 \x00,需额外校验 |
graph TD
A[模板解析] --> B{上下文识别}
B -->|HTML text| C[htmlEscaper]
B -->|JS string| D[jsEscaper]
B -->|URL attr| E[urlEscaper]
C --> F[输出 <script>]
D --> G[输出 \u003cscript\u003e]
3.3 前端渲染混合场景下Content-Security-Policy与Go后端协同策略
在 SSR + CSR 混合渲染中,CSP 必须兼顾服务端注入的内联脚本(如 __INITIAL_STATE__)与客户端动态执行需求。
CSP Header 动态生成策略
Go 后端需根据请求上下文生成差异化 Content-Security-Policy 响应头:
// 根据是否启用 hydration 注入 nonce
func setCSPHeader(w http.ResponseWriter, r *http.Request) {
nonce := generateNonce() // base64-encoded 16-byte random
mode := getRenderMode(r) // "ssr", "csr", or "hybrid"
var policy strings.Builder
policy.WriteString("default-src 'self'; ")
if mode == "hybrid" {
policy.WriteString(fmt.Sprintf("script-src 'self' 'nonce-%s' 'strict-dynamic'; ", nonce))
w.Header().Set("Content-Security-Policy", policy.String())
w.Header().Set("X-Script-Nonce", nonce) // 供模板注入
}
}
逻辑分析:'strict-dynamic' 允许由可信脚本(带 nonce)动态创建的子资源执行,避免白名单膨胀;X-Script-Nonce 供 HTML 模板安全插入 <script nonce="{{.Nonce}}">。
关键策略对比
| 场景 | script-src 配置 | 安全性 | 兼容性 |
|---|---|---|---|
| 纯 SSR | 'self' 'unsafe-inline' |
⚠️ 低 | ✅ |
| Hybrid(含 hydration) | 'self' 'nonce-...' 'strict-dynamic' |
✅ 高 | 🟡 Chrome 52+ |
| 纯 CSR | 'self' https: |
✅ 中 | ✅ |
数据同步机制
前端通过 window.__CSP_NONCE__ = "{{.Nonce}}" 获取 nonce,hydration 脚本使用该值创建动态 <script> 标签,确保 CSP 允许执行。
第四章:服务端请求伪造(SSRF)防御实战
4.1 SSRF在Go HTTP客户端中的高危API识别与默认行为剖析
Go标准库 net/http 中,http.Transport 的 DialContext、DialTLSContext 及 Proxy 字段是SSRF风险的核心载体。
默认代理行为隐式启用
当未显式设置 http.DefaultTransport.Proxy 时,Go会自动读取环境变量 HTTP_PROXY/HTTPS_PROXY,并构造 http.ProxyFromEnvironment —— 这一行为常被忽视,却直接导致内网请求泄露。
高危API示例
client := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment, // ⚠️ 默认启用,可能转发至内网地址
},
}
该配置使客户端无条件遵循系统代理策略,若服务端环境变量被污染(如 HTTP_PROXY=http://127.0.0.1:8080),所有 outbound 请求均经由本地代理,形成SSRF入口。
常见危险协议支持对比
| 协议 | http.Transport 默认支持 |
是否可触发内网请求 |
|---|---|---|
http:// |
✅ | 是(含 http://localhost) |
https:// |
✅ | 否(需中间人或DNS重绑定) |
file:// |
❌(被 defaultRoundTripper 拒绝) |
— |
graph TD
A[Client.Do(req)] --> B{req.URL.Scheme}
B -->|http://| C[ProxyFromEnvironment → Dial]
B -->|https://| D[TLS握手 → 不经Proxy]
C -->|Proxy URL resolves to 10.0.0.1| E[SSRF触发]
4.2 自定义http.Transport的URL白名单与DNS重绑定防护
DNS重绑定攻击利用客户端缓存的IP地址绕过同源策略,使恶意域名解析到内网地址。防御核心在于请求发起前校验目标主机是否在可信白名单中。
白名单校验逻辑
type WhitelistTransport struct {
transport *http.Transport
whitelist map[string]bool
}
func (t *WhitelistTransport) RoundTrip(req *http.Request) (*http.Response, error) {
host := req.URL.Hostname()
if !t.whitelist[host] {
return nil, fmt.Errorf("host %s not in whitelist", host)
}
return t.transport.RoundTrip(req)
}
该实现拦截所有请求,在RoundTrip阶段解析Hostname()并比对预设白名单,阻断非法域名访问。
常见白名单配置示例
| 场景 | 允许域名 | 说明 |
|---|---|---|
| SaaS API | api.example.com |
生产环境唯一上游服务 |
| 内部微服务 | auth.svc.cluster.local |
Kubernetes Service DNS |
| 静态资源CDN | cdn.example.org |
不含通配符,防子域名滥用 |
防护流程示意
graph TD
A[HTTP请求] --> B{解析Hostname}
B --> C[查白名单]
C -->|命中| D[放行至Transport]
C -->|未命中| E[返回错误]
4.3 内网资源访问控制:基于net/http/httputil与context超时熔断
反向代理与上下文注入
使用 httputil.NewSingleHostReverseProxy 构建内网代理,并通过 context.WithTimeout 注入请求生命周期约束:
proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: "10.0.1.5:8080"})
proxy.Transport = &http.Transport{ // 自定义 Transport 控制底层连接
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
该配置确保代理建立连接不超过3秒,避免阻塞线程;DialContext 与外层 context 联动,实现端到端超时传递。
熔断逻辑分层嵌套
- 请求级:
context.WithTimeout(req.Context(), 5*time.Second) - 连接级:
Transport.DialContext超时 - 响应级:
http.Client.Timeout(不推荐,易覆盖上下文)
| 层级 | 作用域 | 推荐设置 |
|---|---|---|
| Context | 整个请求生命周期 | 5s |
| Transport | TCP连接建立 | 3s |
| ReadHeader | Header读取阶段 | Client.ExpectContinueTimeout |
流量控制流程
graph TD
A[客户端请求] --> B[注入5s context]
B --> C[代理转发至内网服务]
C --> D{连接建立 ≤3s?}
D -->|是| E[发送请求并等待响应]
D -->|否| F[立即返回503]
E --> G{响应在剩余context时间内到达?}
G -->|是| H[返回结果]
G -->|否| I[Cancel并关闭连接]
4.4 外部服务调用链路中的Referer、Origin与IP信誉校验集成
在跨域服务调用中,仅依赖Token鉴权易被绕过。需叠加客户端上下文可信度验证。
校验维度协同策略
Referer:识别上游跳转来源(防CSRF伪造请求)Origin:强制CORS预检,校验协议+域名+端口三元组IP信誉:对接实时威胁情报库(如AbuseIPDB),拒绝高风险ASN或历史恶意IP
校验流程时序
// Node.js中间件示例(Express)
app.use('/api/external', (req, res, next) => {
const referer = req.get('Referer') || '';
const origin = req.get('Origin') || '';
const clientIP = getClientIP(req); // X-Forwarded-For清洗后的真实IP
if (!validateReferer(referer) ||
!validateOrigin(origin) ||
!isIPTrusted(clientIP)) {
return res.status(403).json({ error: 'Access denied' });
}
next();
});
逻辑分析:getClientIP()需剥离代理头并校验可信跳转链;validateOrigin()必须严格匹配白名单正则(如 ^https?:\/\/(app\.example\.com|dashboard\.example\.com)$);isIPTrusted()应支持异步缓存查询,避免阻塞主线程。
三要素校验权重对照表
| 维度 | 实时性 | 可伪造性 | 推荐校验时机 |
|---|---|---|---|
| Origin | 高 | 中(需配合CORS) | 预检请求阶段 |
| Referer | 中 | 高(可被浏览器禁用) | 主请求阶段 |
| IP信誉 | 低(依赖缓存TTL) | 极低 | 请求入口处 |
graph TD
A[HTTP请求] --> B{Origin校验}
B -->|通过| C{Referer校验}
B -->|失败| D[403 Forbidden]
C -->|通过| E{IP信誉查询}
C -->|失败| D
E -->|可信| F[放行]
E -->|不可信| D
第五章:Go安全编码能力进阶路线图
深度防御:HTTP请求上下文与取消机制的协同实践
在高并发Web服务中,未受控的请求生命周期极易引发资源耗尽。以下代码展示了如何结合context.WithTimeout与http.Request.Context()实现请求级熔断:
func handlePayment(w http.ResponseWriter, r *http.Request) {
// 为支付操作设置严格超时(含DB、第三方API调用)
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
// 所有下游调用必须传递该ctx
if err := chargeCard(ctx, r.FormValue("card")); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timeout", http.StatusGatewayTimeout)
return
}
http.Error(w, "payment failed", http.StatusInternalServerError)
return
}
}
敏感数据零内存残留策略
使用crypto/rand.Read替代math/rand生成密钥,并通过bytes.EqualConstantTime防范时序攻击:
| 场景 | 不安全做法 | 安全替代方案 |
|---|---|---|
| 密钥生成 | rand.Intn(256) |
rand.Read(key[:]) |
| 密码比对 | == 运算符 |
subtle.ConstantTimeCompare() |
| 内存擦除 | key = nil |
memclr(key[:])(需unsafe包) |
SQL注入与ORM层双重防护
GORM v1.25+默认启用参数化查询,但动态字段名仍需白名单校验:
// 危险:直接拼接字段名
db.Where(fmt.Sprintf("status = ? AND %s > ?", fieldName), status, value)
// 安全:预定义可排序字段
validSortFields := map[string]bool{"created_at": true, "amount": true}
if !validSortFields[fieldName] {
http.Error(w, "invalid sort field", http.StatusBadRequest)
return
}
db.Order(fieldName + " DESC")
TLS证书验证的生产级配置
禁用不安全协议并强制SNI验证:
tr := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
// 禁用弱密码套件
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
},
// 强制SNI防止中间人降级
ServerName: "api.example.com",
},
}
依赖供应链安全审计流程
通过go list -json -deps生成依赖树,结合golang.org/x/tools/go/vulndb扫描已知漏洞:
# 生成依赖JSON报告
go list -json -deps ./... > deps.json
# 使用govulncheck检测CVE
govulncheck ./... -format=json > vulns.json
并发安全:原子操作替代互斥锁的场景
当仅需计数器或标志位时,sync/atomic比sync.Mutex更高效且无死锁风险:
// 错误:过度使用锁
var mu sync.Mutex
var failedLoginCount int
mu.Lock()
failedLoginCount++
mu.Unlock()
// 正确:原子递增
var failedLoginCount uint64
atomic.AddUint64(&failedLoginCount, 1)
容器环境下的最小权限实践
Dockerfile中禁止root用户运行:
FROM golang:1.22-alpine
# 创建非特权用户
RUN addgroup -g 1001 -f appgroup && \
adduser -s /bin/sh -u 1001 -U -f -d /home/appuser appuser
USER appuser
COPY --chown=appuser:appgroup . .
CMD ["./app"]
日志脱敏的结构化处理
使用log/slog的ReplaceAttr钩子自动过滤敏感字段:
func redactSensitiveAttrs(groups []string, a slog.Attr) slog.Attr {
switch a.Key {
case "password", "token", "credit_card":
return slog.String(a.Key, "[REDACTED]")
case "user_id":
if len(a.Value.String()) > 8 {
return slog.String(a.Key, "***"+a.Value.String()[len(a.Value.String())-4:])
}
}
return a
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: redactSensitiveAttrs,
})) 