进了镜像的令牌收不回来
一句话总结
环境变量不是存储位置,而是一种传递方式。 而且,镜像构建过程中放入的 秘密不会因为删除就消失。如果已经推送到 registry,就应视为已经泄漏并将其 废弃。
为什么需要了解这一点
很容易认为加载 .env 文件后将其删除就安全了。但
/proc/<pid>/environ 在进程存活期间始终可读。 同一主机上的
sidecar、调试 shell、崩溃转储和监控 agent 都可以读取这个位置。
环境变量只是把值交给进程的通道,并不是隐藏值的机制。
镜像方面更糟。通过 ENV 放入的 token 会永久留在镜像配置中。
docker inspect -f '{{json .Config.Env}}' bad:v1
["NPM_TOKEN=npm_9fA3...", ...]
通过 ARG 接收后再用 rm ~/.npmrc 删除的方法也是如此。文件仍留在
下层 layer 中,也会保留在构建历史里。
docker history --no-trunc bad:v2 | grep -o 'NPM_TOKEN=[^ ]*'
由此只能得出一个结论。如果已经推送到 registry,就应认为该 token 已经 泄漏并予以废弃。删除镜像无法挽回泄漏。
工作原理
如果构建过程中确实需要秘密,应使用 BuildKit 的 secret mount。
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
这个 mount 位于 tmpfs,相应 RUN 结束后便会消失,不会留在 layer 中。 它与通过构建参数传入秘密有根本区别。
Kubernetes Secret 的 base64 不是加密。 它只是编码;只有另行启用 etcd 加密或外部秘密存储,真正的保护才算开始。
对已经提交的秘密,处置顺序是固定的:废弃 → 影响调查 → 清理历史。 不能打乱这个顺序。而且,重写历史不是挽回泄漏的措施,只是减少再次发生的 卫生工作。因为该秘密已经被复制到 fork、本地 clone、PR 引用和 CI cache 中。
实际工作中的表现
轮换不是一项运维流程,而是一种设计属性。 如果代码假设“有效的 key 恰好只有 一个”,轮换必然失败。因为部署新 key 的时刻会与仍有客户端使用旧 key 的时刻重叠。 所以必须设计成:签名只用一个当前 key,验证则使用整个有效 key 集合。 没有 这一原则,轮换计划书就无法执行。
检测工具也有三个局限。第一,pre-commit hook 只需一次 --no-verify 就能绕过。
因此,hook 是便利机制,真正的控制机制是服务器端检查。 第二,规则只对
AKIA、sk_live_ 等形态清晰的值有效。内部系统自定义格式的 token 很难捕获。
第三,scanner 只查看代码 repository,不会查看 wiki、issue 附件或聊天日志。
最后,有一个问题必须向团队提出:假设这份凭证现在已经公开,需要多少分钟才能 将其废弃、更换并恢复服务正常运行? 如果无法回答这个问题,就说明还没有建立 秘密管理体系。
文件挂载也不能放任不管
从环境变量迁移到文件是一种改进,但文件不会自动变得安全。迁移后仍有几项需要确认。
权限与所有者。 如果文件设置成其他用户也能读取,迁移就失去了意义。即使容器内看似只有一个用户,只要 sidecar 挂载了同一个 volume,它也能读取。挂载时要明确指定权限,并且不要给 sidecar 挂载该 volume。
是否留在磁盘上。 如果存放秘密的 volume 基于内存,就不会写入节点磁盘;如果是普通 volume,则会原样留下。判断范围还应包括节点废弃时磁盘是否会被擦除。
日志与转储。 如前所述,如果读取值的代码把它写进错误消息,无论来自文件还是环境变量,结果都一样。而且进程终止时留下的内存转储,会原样包含已经读入的值。还要一并确认:是否启用了收集转储的设置、转储会堆积在哪里,以及谁能读取。
更新时会发生什么。 文件挂载的价值之一是无需重启即可更新,但如果应用程序不重新读取该文件,就毫无意义。 这与前面配置注入的讨论相同,在秘密管理中更为重要。如果旧 key 已经废弃,而进程仍保有旧值,从那一刻起所有请求都会失败。
总而言之,秘密管理不是选择存储位置的问题,而是设计生命周期的问题。真正决定安全性的,不是放在哪里,而是谁能够读取、何时变化,以及如何发现它已经变化。
下个练习将做什么
确认通过环境变量传递的秘密会原样出现在 docker inspect 中,再把同一个值
改为文件挂载,确认它从 inspect 中消失。然后在不重启容器的情况下更改值,
亲自测量两种方式在轮换特性上的差异,最后编写一个简单的秘密检测脚本。