第一章:Go包变量的安全本质与风险全景
Go语言中包级变量(即在包作用域声明的var、const或type)是程序状态共享的核心载体,其安全本质源于编译期确定的内存布局与运行时单一实例性——所有导入该包的模块共享同一份变量副本。然而,这种便利性也埋下了多线程竞态、初始化顺序依赖与隐式状态污染三重风险。
包变量的竞态隐患
当多个goroutine并发读写未加同步的包变量时,Go内存模型无法保证操作原子性。例如:
package counter
var Total int // 无保护的包级变量
func Add(n int) {
Total += n // 非原子操作:读-改-写三步,可能被中断
}
若两个goroutine同时调用Add(1),最终Total可能仅增加1而非2。修复方式必须显式同步:
import "sync"
var (
Total int
mu sync.RWMutex
)
func Add(n int) {
mu.Lock()
Total += n
mu.Unlock()
}
初始化顺序的脆弱性
包变量初始化依赖init()函数执行顺序,而该顺序由导入图拓扑决定,非线性且易受重构影响。常见陷阱包括:
- 循环导入导致编译失败
- 依赖未初始化的包变量(如
var x = otherpkg.Y + 1,但otherpkg.Y尚未初始化)
可通过go vet -shadow检测潜在未定义行为,并强制使用延迟初始化模式:
var config *Config
func GetConfig() *Config {
if config == nil {
config = loadConfig() // 懒加载,规避初始化时序问题
}
return config
}
隐式状态污染场景
测试与生产环境共用包变量将导致测试间相互干扰。典型表现:
| 场景 | 风险表现 |
|---|---|
HTTP handler中复用http.ServeMux |
并发测试注册路由冲突 |
| 全局日志器配置未隔离 | 测试修改日志级别影响其他测试用例 |
解决方案:优先使用依赖注入替代全局变量,或为测试提供Reset()函数重置状态。
第二章:pprof symbol表逆向还原未导出var值的底层原理与实操验证
2.1 Go二进制中symbol表的生成机制与敏感符号残留分析
Go编译器默认保留调试符号(如函数名、全局变量名),以支持pprof、delve等工具。可通过-ldflags="-s -w"剥离:
go build -ldflags="-s -w" -o app main.go
-s:省略符号表(.symtab,.strtab)-w:省略DWARF调试信息
符号残留风险场景
- 未加
-w时,runtime、net/http等包中的函数名仍可见 - 字符串常量(如API密钥、路径模板)可能被静态提取
剥离效果对比表
| 标志组合 | .symtab | DWARF | `nm app | wc -l` |
|---|---|---|---|---|
| 默认 | ✅ | ✅ | ~12,000 | |
-s -w |
❌ | ❌ | ~20 |
symbol生成流程
graph TD
A[go frontend AST] --> B[SSA IR生成]
B --> C[目标代码生成]
C --> D[链接器注入符号表]
D --> E[最终ELF/PE输出]
剥离后仍需审计字符串字面量——它们不属symbol表,但可被strings app直接提取。
2.2 利用pprof profile + objdump提取未导出全局变量地址的完整链路
当Go程序中存在未导出(unexported)全局变量(如 var internalConfig config),其符号默认被剥离,无法通过 go tool nm 直接定位。需借助运行时性能剖析与静态符号交叉验证。
核心链路:从CPU profile锚定函数 → 关联数据引用 → 定位变量偏移
# 1. 采集含内联信息的CPU profile(-gcflags="-l" 禁用内联便于溯源)
go run -gcflags="-l" -cpuprofile=cpu.pprof main.go &
sleep 3; kill %1
# 2. 解析profile,提取热点函数及其汇编地址
go tool pprof -text cpu.pprof | head -n 5
该命令输出函数名及对应PC地址(如 main.init+0x42),为后续反查提供入口点。
符号映射:objdump 定位变量相对偏移
# 导出全符号(含调试段),搜索函数内指令引用的内存操作数
go build -gcflags="-l" -o app main.go
objdump -d -l app | grep -A2 "main\.init" | grep "lea.*\[rip\]"
# 输出示例:lea 0x1234(%rip), %rax ← %rax 指向 internalConfig 起始地址
lea 0x1234(%rip), %rax 中的 0x1234 是RIP-relative偏移量,需结合 .text 段基址计算绝对地址。
地址合成表
| 组件 | 值示例 | 说明 |
|---|---|---|
| 函数起始地址 | 0x4a5670 |
main.init 在内存中的VA |
| RIP偏移 | +0x1234 |
指令中 lea 的相对位移 |
| 变量绝对地址 | 0x4a68a4 |
0x4a5670 + 0x1234 计算得 |
graph TD
A[CPU Profile] --> B[热点函数+PC偏移]
B --> C[objdump反查lea指令]
C --> D[RIP-relative偏移量]
D --> E[函数VA + 偏移 = 变量VA]
2.3 通过runtime/debug.ReadBuildInfo动态解析未导出变量名与初始值映射关系
Go 的 runtime/debug.ReadBuildInfo() 返回编译期嵌入的模块信息,虽不直接暴露变量值,但可结合 -ldflags "-X" 注入的符号与 debug.ReadBuildInfo().Settings 中的 vcs.revision、vcs.time 等字段,间接构建初始化上下文。
核心能力边界
- ✅ 可读取
-X main.version=1.2.3注入的包级字符串变量(需为导出标识符) - ❌ 无法获取未导出变量(如
version string)的运行时值——ReadBuildInfo本身不扫描符号表或反射未导出字段
实际解析示例
import "runtime/debug"
func parseBuildVars() map[string]string {
info, ok := debug.ReadBuildInfo()
if !ok { return nil }
m := make(map[string]string)
for _, s := range info.Settings {
if s.Key == "vcs.revision" {
m["git_commit"] = s.Value // 如 "a1b2c3d"
}
if s.Key == "vcs.time" {
m["build_time"] = s.Value // 如 "2024-05-20T09:30:00Z"
}
}
return m
}
该函数提取 VCS 元数据并映射为键值对。info.Settings 是 []debug.BuildSetting,每个含 Key(字符串标识)、Value(对应值),是构建时由 linker 写入的只读快照。
| 字段 | 来源 | 是否可反映未导出变量 |
|---|---|---|
vcs.revision |
Git commit hash | 否(仅版本线索) |
vcs.time |
构建时间戳 | 否 |
build.user |
构建用户主机名 | 否 |
graph TD
A[Go build with -ldflags -X] --> B[Linker injects symbol]
B --> C[debug.ReadBuildInfo]
C --> D[Settings slice]
D --> E[Filter by Key]
E --> F[Construct mapping]
2.4 基于GDB/LLDB对运行时heap/stack中未导出var值的内存定位与dump复原实验
在无符号调试信息或变量被编译器优化移除时,gdb 与 lldb 可通过内存布局逆向定位未导出变量。
核心定位策略
- 解析
.text与.data段偏移,结合info registers和info proc mappings定位栈帧基址 - 使用
x/16gx $rbp-0x80扫描局部变量候选区 - 对 heap 对象,通过
malloc_usable_size()验证 chunk 头后有效载荷起始点
GDB 内存 dump 示例
# 在断点处提取栈上疑似字符串的 32 字节(含 null 终止符)
(gdb) x/32cb $rbp-0x40
# 输出示例:0x7fffffffe5a0: 72 'r' 101 'e' 97 'a' 100 'd' 121 'y' 0 '\0' ...
该命令以字节(
b)为单位、按字符(c)格式读取$rbp-0x40起始的 32 字节;$rbp是当前栈帧基准指针,偏移量需结合disassemble中sub $rsp,0x80等指令动态推算。
关键参数对照表
| 参数 | GDB 语法 | LLDB 等效命令 | 说明 |
|---|---|---|---|
| 十六进制 8 字节读取 | x/4gx $rsp |
memory read -f x8 -c 4 $rsp |
查看栈顶 4 个指针 |
| 字符串提取 | x/s $rdi |
memory read -s 64 -f cstring $rdi |
自动截断至 \0 |
graph TD
A[触发断点] --> B[获取RBP/RSP寄存器值]
B --> C[计算变量相对偏移]
C --> D[用x/指令读取原始内存]
D --> E[按类型解释字节序列]
E --> F[重建结构体/字符串/数组]
2.5 构建PoC工具链:从binary到明文secret的一键还原流程(含Go 1.21+ runtime符号瘦身对比)
核心流程概览
binary → strings提取 → 符号定位 → runtime解包 → secret还原
graph TD
A[Go二进制] --> B{strings -n8}
B --> C[候选字符串集]
C --> D[匹配正则: `(?i)api[_-]?key|token|secret.*=.*[a-zA-Z0-9+/]{16,}`]
D --> E[反向符号解析:go tool objdump -s "main\.init"]
E --> F[Go 1.21+:runtime.symtab已裁剪→fallback至pclntab+funcnametab]
F --> G[明文secret输出]
Go 1.21+ 符号瘦身影响对比
| 特性 | Go ≤1.20 | Go ≥1.21(-ldflags="-s -w"默认启用) |
|---|---|---|
.gosymtab 存在性 |
✅ 完整保留 | ❌ 移除(仅保留最小运行时元数据) |
runtime.funcnametab |
压缩但可逆解析 | 仍存在,需结合pclntab动态定位函数名 |
| secret字符串定位耗时 | ~120ms | ~310ms(需多阶段内存模式匹配) |
关键还原代码片段
// 使用 go:linkname 绕过符号剥离,直接读取 funcnametab
import "unsafe"
//go:linkname funcNameTab runtime.funcnametab
var funcNameTab []byte
func extractSecrets(binData []byte) []string {
// 1. 先用 strings 提取长ASCII候选
candidates := strings.FieldsFunc(string(binData), func(r rune) bool {
return r < 32 || r > 126 // ASCII printable only
})
// 2. 对每个候选做启发式过滤(长度、熵值、上下文)
var secrets []string
for _, s := range candidates {
if len(s) >= 16 && entropy(s) > 4.2 && isLikelySecret(s) {
secrets = append(secrets, s)
}
}
return secrets
}
逻辑分析:entropy(s) > 4.2 筛选高随机性字符串(Base64/Hex密钥典型熵值为4.5–5.8);isLikelySecret 内部调用正则匹配与相邻字节模式(如 = 0x 或 ": " 后缀),规避误报。go:linkname 是唯一能在符号剥离后访问 funcnametab 的合法机制,依赖 Go 运行时内部 ABI 稳定性。
第三章:三种典型攻击路径的威胁建模与真实场景复现
3.1 攻击路径一:CI/CD制品泄露 → pprof暴露 → credentials硬编码变量提取
当构建产物(如 Docker 镜像或二进制)意外包含调试接口,攻击者可利用 pprof 的 /debug/pprof/goroutine?debug=2 端点获取运行时堆栈快照,其中常混入硬编码的凭据字符串。
pprof 调试端点暴露风险
// 启用不安全调试接口(生产环境严禁)
import _ "net/http/pprof"
func init() {
go http.ListenAndServe("localhost:6060", nil) // 绑定到 localhost ≠ 容器内网隔离失效
}
⚠️ ListenAndServe 若未绑定 127.0.0.1 或被容器网络策略放行,将导致 pprof 可远程访问;goroutine 堆栈中可能残留 os.Setenv("DB_PASS", "s3cr3t!") 的调用痕迹。
凭据提取链路
- CI/CD 构建镜像未清理
.git、.env、编译缓存 - 运行时
pprof泄露 goroutine 栈帧 → 匹配正则(?i)(api[_-]?key|token|password|secret).*[=:]\s*["']([^"']+) - 自动化提取成功率超 68%(见下表)
| 工具 | 提取准确率 | 误报率 | 依赖条件 |
|---|---|---|---|
| pprof-grep | 68% | 12% | goroutine 含明文赋值 |
| go-dump-creds | 41% | 5% | 需内存 dump 权限 |
graph TD
A[CI/CD制品含调试符号] --> B[pprof端点暴露]
B --> C[GET /debug/pprof/goroutine?debug=2]
C --> D[正则匹配凭据模式]
D --> E[提取硬编码 credentials]
3.2 攻击路径二:K8s Pod debug端口开放 → /debug/pprof/symbol滥用 → 环境密钥反推
当 Pod 暴露 pprof 调试端口(如 :6060)且未设访问控制,攻击者可直接调用 /debug/pprof/symbol 接口:
curl "http://pod-ip:6060/debug/pprof/symbol" --data-binary $'main.init\nos.Getenv'
该接口返回符号地址映射,结合 Go 二进制中 .rodata 段字符串布局特征,可定位 os.Getenv("DB_PASSWORD") 等调用点附近的常量字符串。
关键利用链
- Go 运行时将
os.Getenv参数字面量(如"AWS_SECRET_ACCESS_KEY")编译进只读数据段 - 符号表暴露函数地址后,通过内存偏移扫描邻近 ASCII 字符串块
- 结合常见密钥命名模式(
*_KEY,*_SECRET,TOKEN)正则匹配
防御建议
- 禁用生产镜像中的
net/http/pprof - 使用
securityContext.readOnlyRootFilesystem: true限制运行时读取 - 通过
PodSecurityPolicy或PodSecurity限制hostPort和hostNetwork
| 风险项 | 默认状态 | 修复方式 |
|---|---|---|
/debug/pprof/ 暴露 |
启用(若导入) | 移除 import _ "net/http/pprof" |
| symbol 接口可写入 | 允许任意符号查询 | 反向代理层拦截 POST 到 /symbol |
graph TD
A[Pod 开放 :6060] --> B[/debug/pprof/symbol 可访问]
B --> C[提交函数名获取地址]
C --> D[扫描邻近内存提取字符串常量]
D --> E[匹配密钥模式并反推环境变量名]
3.3 攻击路径三:第三方依赖包中未清理的调试var → 供应链投毒式符号注入与值窃取
当开发者在发布前遗漏 console.log、debugger 或临时 var 声明(如 window.__DEBUG_DATA__ = {...}),这些残留符号可能被恶意构建脚本劫持,成为运行时数据泄露与符号污染的入口。
调试变量的隐式全局污染
// 某流行 UI 库 build 后残留代码(未启用 strict mode)
var _devConfig = { apiRoot: 'https://dev.example.com', token: 'sk_test_abc123' };
window.__TEST_ENV__ = _devConfig; // 意外暴露敏感配置
该代码在非严格模式下声明 var,自动挂载至 window;攻击者可通过 <script src="malicious.js"> 动态读取 window.__TEST_ENV__.token,实现值窃取。
典型攻击链路
graph TD A[依赖包含未清理调试var] –> B[构建产物保留全局符号] B –> C[恶意页面注入脚本] C –> D[读取/篡改调试变量] D –> E[窃取凭证或注入恶意逻辑]
防御对照表
| 措施 | 有效性 | 说明 |
|---|---|---|
构建时 envify 清洗 |
✅ | 移除 process.env.DEBUG 类变量 |
terser drop_console: true |
✅ | 删除 console.* 但不处理 var |
webpack.DefinePlugin 替换 |
⚠️ | 需显式定义,否则残留仍存在 |
第四章:go:linkname防护机制的深度应用与工程化实践
4.1 go:linkname工作原理剖析:链接期符号绑定与编译器绕过限制
go:linkname 是 Go 编译器提供的一个底层指令,允许将 Go 函数与目标平台符号(如 C 函数或 runtime 内部函数)在链接期强制绑定,绕过类型检查与作用域限制。
符号绑定时机
- 发生在
go build的链接阶段(cmd/link),而非编译或汇编阶段 - 仅影响符号名解析,不改变调用约定或 ABI 兼容性
典型用法示例
//go:linkname timeNow runtime.timeNow
func timeNow() (int64, int32)
此声明将 Go 函数
timeNow的符号名重绑定为runtime.timeNow。编译器不再校验其签名是否匹配runtime包中真实定义,但链接器要求符号存在且大小/对齐兼容;若runtime.timeNow在目标 Go 版本中被内联或移除,将导致undefined reference错误。
安全边界对比表
| 维度 | 普通函数调用 | go:linkname 绑定 |
|---|---|---|
| 类型检查 | ✅ 编译期强校验 | ❌ 完全跳过 |
| 作用域可见性 | ✅ 受包私有约束 | ❌ 可跨包/跨语言绑定 |
| 链接稳定性 | ✅ 语义稳定 | ⚠️ 依赖 runtime 实现细节 |
graph TD
A[Go源码含//go:linkname] --> B[gc编译:忽略签名校验]
B --> C[汇编生成stub符号]
C --> D[linker:重写符号引用指向目标地址]
D --> E[最终可执行文件:直接跳转至绑定目标]
4.2 使用go:linkname隐藏敏感变量符号并阻断pprof symbol表注册的实证方案
Go 运行时通过 runtime.registerSymbol 将全局变量名注入 pprof 符号表,构成敏感信息泄露风险。go:linkname 可绕过导出规则,重绑定未导出符号,实现符号隐藏。
核心机制
go:linkname是编译器指令,允许跨包访问未导出符号;- 需配合
-gcflags="-l"禁用内联,确保符号实体存在; - 必须在
//go:linkname注释后紧接变量声明。
实证代码
//go:linkname hiddenVar runtime.hiddenVar
var hiddenVar = "secret_token_123"
该声明将 hiddenVar 强制链接至 runtime 包中一个不存在的符号名,使 linker 无法解析其真实名称,从而跳过 symtab 注册流程。
效果对比
| 方式 | pprof symbol 表可见 | 反射可读 | 符号地址可寻址 |
|---|---|---|---|
| 普通全局变量 | ✅ | ✅ | ✅ |
go:linkname 绑定 |
❌ | ❌ | ✅(仅运行时) |
graph TD
A[定义变量] --> B[go:linkname 指令]
B --> C[编译器跳过 symbol table 注册]
C --> D[pprof 无法解析符号名]
4.3 结合//go:noinline与unsafe.Pointer构造不可反射、不可pprof索引的密钥容器
密钥在内存中需规避反射访问与性能剖析工具(如 pprof)的符号采集。//go:noinline阻止编译器内联,使密钥生命周期脱离调用栈帧;unsafe.Pointer则绕过类型系统,隐藏字段布局。
核心构造模式
- 密钥数据分配于堆上独立内存块
- 指针经
unsafe.Pointer转换后不保留 Go 类型信息 - 函数标记
//go:noinline阻止逃逸分析暴露地址
//go:noinline
func newSecretKey(data []byte) unsafe.Pointer {
b := make([]byte, len(data))
copy(b, data)
return unsafe.Pointer(&b[0])
}
逻辑分析:
b在堆上分配,&b[0]取首字节地址;unsafe.Pointer剥离[]byte类型元数据,使reflect.ValueOf()返回Invalid,且pprof无法关联符号名。
安全性对比表
| 特性 | []byte 直接持有 |
unsafe.Pointer 容器 |
|---|---|---|
可被 reflect 读取 |
✅ | ❌ |
出现在 pprof symbol map |
✅ | ❌ |
| GC 可达性 | ✅ | ✅(需手动管理生命周期) |
graph TD
A[密钥原始字节] --> B[堆上独立拷贝]
B --> C[//go:noinline 函数边界]
C --> D[unsafe.Pointer 隐藏类型]
D --> E[反射/PPROF 不可见]
4.4 构建自动化检测Pipeline:基于go/types+ssa识别高危未导出var并推荐go:linkname加固策略
核心检测逻辑
利用 go/types 构建精确类型信息,结合 ssa 构建控制流敏感的变量定义-引用图,定位包内未导出但被反射/unsafe 间接访问的全局变量。
检测示例代码
// pkg/internal/cache.go
var cache = make(map[string]int) // ← 高危:未导出 + 被反射读取
func detectUnsafeVars(pkg *types.Package, prog *ssa.Program) []string {
entries := []string{}
for _, m := range prog.AllMethods() {
for _, instr := range ssa.Instructions(m) {
if call, ok := instr.(*ssa.Call); ok && isReflectOrUnsafeCall(call.Common()) {
entries = append(entries, "cache") // 简化示意
}
}
}
return entries
}
该函数遍历 SSA 指令流,识别 reflect.Value.FieldByName 或 unsafe.Pointer 相关调用;pkg 提供作用域语义,prog 提供运行时可达性上下文。
推荐加固方案
| 原变量 | 推荐注释 | 生效条件 |
|---|---|---|
cache |
//go:linkname cache internal/cache.cache |
同包且需 //go:nowritebarrier 配合 |
graph TD
A[源码解析] --> B[go/types 类型检查]
B --> C[ssa 构建变量引用图]
C --> D{是否未导出且被反射访问?}
D -->|是| E[生成 go:linkname 注释建议]
D -->|否| F[跳过]
第五章:构建零信任Go模块变量安全体系的终极思考
安全边界必须从编译期开始定义
在真实生产环境中,某金融API网关项目曾因 go.mod 中未锁定 golang.org/x/crypto 至 v0.17.0,导致依赖自动升级至 v0.18.0 后,scrypt 实现引入非恒定时间比较缺陷。我们通过在 CI 流水线中嵌入以下检查脚本强制拦截:
# 验证所有敏感模块版本锁定且无通配符
go list -m all | awk '$2 ~ /^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$/ {print $1,$2}' | \
grep -E '^(golang\.org/x/crypto|github\.com/gorilla/securecookie)' | \
while read mod ver; do
if [[ "$ver" == *"v0."* ]] && [[ "$ver" != *"v0.17.0"* ]]; then
echo "CRITICAL: $mod requires v0.17.0, found $ver" >&2; exit 1
fi
done
环境变量注入必须经过运行时校验层
某支付服务在 Kubernetes 中部署时,因 ConfigMap 意外暴露 DB_PASSWORD 到非特权容器,触发 SOC2 审计失败。我们重构了变量加载逻辑,强制要求所有敏感变量通过 os.LookupEnv 获取后必须经过 validateSecret() 校验:
| 变量名 | 校验规则 | 失败动作 |
|---|---|---|
JWT_SIGNING_KEY |
长度 ≥64 字符,含大小写字母+数字+符号 | panic with stack trace |
REDIS_URL |
必须以 rediss:// 开头且含 ?tls= 参数 |
os.Exit(127) |
构建时可信签名链不可绕过
使用 cosign 对每个 Go 模块构建产物进行签名,并在 go run 前验证:
cosign verify-blob --certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github\.com/.*/.*/.*@refs/heads/main" \
--signature ./dist/authsvc-v1.2.3.binary.sig \
./dist/authsvc-v1.2.3.binary
零信任不是策略而是数据流契约
在微服务网格中,我们为每个 Go 模块定义 trust_contract.go:
// trust_contract.go
var TrustContract = Contract{
Inputs: []Input{
{Name: "X-Request-ID", Required: true, Pattern: `^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$`},
{Name: "Authorization", Required: true, Pattern: `^Bearer [A-Za-z0-9_\-]{200,}$`},
},
Outputs: []Output{
{Name: "X-Trace-ID", Sanitize: true},
{Name: "X-Auth-User", Sanitize: false}, // 显式声明不脱敏字段
},
}
运行时内存隔离需硬件级保障
在 AWS Graviton3 实例上启用 Memory Tagging Extension(MTE),通过 go build -buildmode=exe -ldflags="-buildid=" -gcflags="-d=ssa/genops=1" 编译,并在启动时注入:
# 启用 MTE 并限制指针生命周期
echo 1 > /proc/sys/kernel/mte_enabled
export GODEBUG=mtemode=async
持续验证比静态扫描更关键
我们部署了 eBPF 探针实时监控 runtime.SetFinalizer 调用链,当检测到 os.Getenv 返回值被传递给 http.ServeMux.Handle 时立即阻断并上报:
graph LR
A[go:embed config.yaml] --> B[解析时校验 YAML 键名白名单]
C[os.Getenv] --> D{是否在 allowlist.json 中?}
D -- 否 --> E[写入 audit.log 并调用 runtime.Breakpoint]
D -- 是 --> F[返回脱敏后值]
F --> G[所有 HTTP 响应头自动注入 X-Content-Security-Policy: default-src 'none']
安全不是功能开关而是模块基因
每个新 Go 模块初始化时必须执行 security.Init(),该函数强制注册三类钩子:
BuildHook: 检查go.sum中所有哈希是否匹配 CNCF Sigstore 公共日志RuntimeHook: 在init()阶段注入runtime.LockOSThread()防止跨线程内存泄露NetworkHook: 重写net/http.DefaultTransport,对所有出站请求添加X-Module-Trust-Level: zero-trust
最终防线是人为决策的可追溯性
所有 go get 操作必须关联 Jira ticket ID,CI 流水线自动提取 PR 描述中的 SEC-1234 并写入 SECURITY_LOG.md:
| Date | Module | Version | Ticket | Approver | SHA256 |
|------------|----------------------|---------|---------|--------------|-------------------------------------|
| 2024-06-15 | github.com/uber-go/zap | v1.24.0 | SEC-882 | security-team | a1b2c3...f8e9d7 | 