
当一份容器安全扫描报告摆在面前时第一眼看到的不应该是恐慌而是一份资产清单。我们维护的 NanoClaw 镜像在第一次做全量漏洞扫描时报告里出现了 1,400 多个 CVE。当时团队的第一反应是“这镜像还能不能用”第二反应是“这要修到什么时候”。后来我们把工作重点从“逐个 CVE 打补丁”改成“从镜像构建源头减少风险暴露面”用一个迭代周期把严重和高危漏洞清零整体 CVE 数量从千级降到个位数。这篇文章就把这次加固的完整思路和落地方法整理出来。NanoClaw 是一个以容器化方式运行的 AI Agent 工作流项目镜像里既要装 Python 运行时又要装模型调用和工具执行相关的依赖。这类镜像的典型问题是为了“省事”基础镜像选通用开发镜像依赖包不锁定版本运行时还保留 shell、包管理器、编译工具链。扫描器把这些历史包袱全部折算成 CVE 清单时数字自然非常惊人。这篇文章会讲清楚三件事CVE 到底是怎么进入镜像的如何通过基础镜像、依赖层、运行权限三个方向的改造把风险降下来以及如何把漏洞扫描和准入门禁嵌进 CI/CD 流程防止问题再次反弹。文章中的方法不只适用于 NanoClaw也适用于任何基于 Python 或 Node.js 的 AI 服务、Agent 工具和内部平台镜像。1. 1,400 个 CVE 意味着什么先纠正一个常见误区CVE 数量并不等于“镜像一定不安全”但它是安全审计、合规审查和镜像交付时的硬指标。CVE 的全称是 Common Vulnerabilities and Exposures也就是公开的漏洞编号体系。扫描器读镜像里的软件清单与公开漏洞库做匹配命中一个就给一条记录。镜像里的软件越多、版本越老、来源越杂CVE 数量就越多。CVE 的严重程度通常用 CVSS 评分分级等级CVSS 分数范围典型处理策略Critical9.0 - 10.0立即修复阻断发布High7.0 - 8.9优先修复短周期内处理Medium4.0 - 6.9按风险评估排期修复Low0.1 - 3.9记录跟踪随版本更新看到 1,400 这个数字时首先要做的是区分漏洞来源。容器镜像中的 CVE 并不来自同一个地方通常由四类组件构成基础镜像自带的系统软件包比如 libc、openssl、curl、ca-certificates语言依赖包比如 Python 的 pip 包、Node.js 的 npm 包项目自己引入的二进制程序比如下载到镜像里的 CLI 工具镜像构建过程中临时安装又没清理的编译工具和缓存文件。AI Agent 类项目更容易中招原因是嵌套依赖非常深。模型调用 SDK 可能引入 aiohttp、httpx、pydantic、numpy 等一大批间接依赖间接依赖又可能继续引入底层 C 扩展库。如果依赖文件只写了顶层包名而没有锁定版本构建时拉到的间接依赖可能会在几个月内从“安全”变成“危险”。所以 1,400 个 CVE 的真正含义是镜像的可信度被打了问号。它不一定是 1,400 个真实可利用的攻击入口但它意味着镜像瘦身、基线和依赖管理的优先级必须上调。2. NanoClaw 镜像出现大量 CVE 的根因分析在一次加固前的镜像分析里我们把 NanoClaw 镜像的分层拆开把每一层里命中的 CVE 按来源归因最后得到了三个根因。2.1 基础镜像层直接复用通用开发镜像一开始Dockerfile 的基础镜像写得非常随意FROM python:3.11-bullseye这个镜像自带完整的 Debian 发行版用户态包含软件包管理器、Shell、编译工具链。对一个最终只需要“运行 Python 应用”的镜像来说这里面有不少组件在运行时根本不会被使用。每个系统软件包都有各自的漏洞跟踪记录即使很多只存在于镜像里而不会被攻击者触达扫描器也会如实在 CVE 清单里记录。2.2 依赖层版本不锁定全量安装requirements.txt 里充满了形如下面这样的写法flask2.0 requests openai这种写法在本地开发时问题不大但一旦固化成镜像就有两个隐患。第一2.0这种范围是“构建时漂移”今天构建和三个月后构建拉到的版本可能不同覆盖的 CVE 也随之变化第二openai这种包会拉取大量间接依赖不做锁定就无法追溯某一条漏洞来自哪个依赖。另一个问题是安装方式。很多 Python 镜像会写成RUN pip install -r requirements.txt这条命令会把所有依赖装进系统 Python 的 site-packages然后为了兼容某些需要编译的包又必须保留 gcc、python3-dev。编译工具链一旦进入最终镜像扫描器就会把这些编译工具依赖的 CVE 也算在镜像头上。2.3 运行时配置层root 用户与完整 Shell加固前的镜像没有单独创建运行用户容器默认以 root 身份运行。这意味着一旦某个 CVE 被实际利用攻击者直接获得的是容器内最高权限。镜像里还保留了/bin/bash、/bin/sh、curl、wget等工具这些工具本身是运维排查时的好帮手但也是真实的攻击面。权限和攻击面都不收口CVE 数量自然收不回去。从这三个根因可以得出一个重要结论CVE 的源头是镜像生产过程中的选择不是漏洞库本身的膨胀。修复工作如果只在扫描报告出来之后一个个 Suppress永远追不上漏洞库的更新速度只有改变镜像构建的原料和过程才能在下一次扫描时让清单自然变短。3. 加固镜像的总体思路从“事后补丁”走向“源头治理”我们在 NanoClaw 加固项目里的核心思路可以概括为一句话先量化再分层后治理。3.1 先量化不要拿着扫描报告就直接改。先做一次全量扫描把结果导出成 JSON 或 CSV按 CVE 严重程度、组件类型、是否可被利用三个维度分组。这样能回答三个问题哪些 CVE 来自系统层哪些来自 Python 依赖哪些 CVE 属于“已修复但镜像没更新”哪些 CVE 只影响开发工具完全不影响运行路径量化阶段推荐使用 Trivy 全量扫描trivy image --format json --output nanoclaw-scan.json \ your-registry.com/nanoclaw-agent:v1.4.0把 JSON 导入本地分析工具后按Class字段分组通常能直观看出系统包和语言包各占多少比例。3.2 后分层镜像加固按下面四个层次推进每个层次有独立的验收标准层次改造内容验收标准基础镜像层更换为精简镜像不装系统包管理器系统级 CVE 数量明显下降依赖层锁定直接和间接依赖移除运行无关包语言级 CVE 可控、可复现运行时层非 root 运行避免安装 shell只读根文件系统攻击面收口权限最小化供应链层生成 SBOM镜像签名CI 加入扫描门禁每次发布有清单、有签名、有可追溯性3.3 后治理最后一步是把扫描接入 CI/CD。不是扫描完看一眼就好而是设置退出码门槛严重和高危 CVE 不为零时流水线失败。中危漏洞可以放行但必须记录在案并设定修复时间。这样下一次安全审计来临时你能拿出的是“策略 数据 例外说明”而不是一张巨大的漏洞表格。这个治理思路也能避免“CVE 清零后又反弹”的问题只有构建流程里强制扫描和阻断镜像的漏洞水平才会持续被控制在阈值以下。4. 基础镜像替换把“万能镜像”换成“最小镜像”基础镜像是 CVE 的第一大来源。替换基础镜像是性价比最高的动作但替换不是无脑选 “Alpine” 或 “Distroless”要考虑运行时兼容性和团队维护能力。4.1 先看改造前的 Dockerfile加固前典型的 NanoClaw 镜像构建文件# 文件路径Dockerfile.before FROM python:3.11-bullseye WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . ENTRYPOINT [python, main.py]这个镜像的问题一目了然基础镜像包含完整 Debian 用户态pip 安装没有关闭缓存构建工具链全量保留并且没有创建非 root 用户。4.2 加固后的 Dockerfile加固后我们采用“构建阶段和运行阶段分离”的多阶段构建方式。构建阶段负责安装依赖和打包运行阶段只保留运行需要的最小内容# 文件路径Dockerfile FROM python:3.11-slim-bookworm AS builder ENV PIP_NO_CACHE_DIR1 \ PIP_DISABLE_PIP_VERSION_CHECK1 \ PYTHONDONTWRITEBYTECODE1 WORKDIR /app COPY requirements.txt . RUN apt-get update \ apt-get install -y --no-install-recommends build-essential \ rm -rf /var/lib/apt/lists/* \ pip install --prefix/install -r requirements.txt FROM python:3.11-slim-bookworm RUN apt-get update \ apt-get install -y --no-install-recommends ca-certificates \ rm -rf /var/lib/apt/lists/* \ useradd --create-home --shell /usr/sbin/nologin appuser WORKDIR /app COPY --frombuilder /install /usr/local COPY --frombuilder /app /app COPY . . ENV PYTHONUNBUFFERED1 USER appuser EXPOSE 8080 ENTRYPOINT [python, main.py]这个 Dockerfile 做了几件关键的事基础镜像从bullseye换成slim-bookworm减少大量未使用系统包构建阶段的build-essential只存在于 builder 阶段最终镜像不保留--prefix/install把 Python 依赖安装到独立目录运行阶段只复制该目录运行阶段只保留ca-certificates删除 apt 缓存创建appuser容器不再以 root 运行。4.3 如何选择精简基础镜像如果对兼容性要求更高可以继续走两个方向基础镜像优点需要注意的问题python:slim体积适中glibc 兼容性好仍包含部分系统工具python:alpine体积小musl libc部分 C 扩展需要单独做 musl 编译gcr.io/distroless体积最小无 shell排查问题不方便没有包管理器cgr.dev/chainguard/python默认非 rootCVE 数极少包更新策略需要跟随上游对 NanoClaw 这类需要频繁调试 AI 依赖的镜像我们最终没有直接用 Distroless而是选择python:3.11-slim-bookworm搭配多阶段构建。原因是 pydantic-core 等 C 扩展在 glibc 环境下兼容性最好团队排查问题也更方便。如果条件允许后续可以把运行阶段换成 Chainguard 镜像进一步压降系统 CVE。这里真正容易踩坑的地方是不要为了“CVE 更少”而选一个团队不熟悉的基础镜像。镜像能不能正常启动比 CVE 数字多几个少几个更重要。先用最小改动替代一个大基础镜像再逐步激进是更稳妥的路径。5. 依赖层加固锁定版本、删除冗余、生成 SBOM依赖层是 AI 项目 CVE 的第二大来源。一个 NanoClaw 镜像里Python 依赖包数量通常在 100 到 300 之间其中一半以上是间接依赖。间接依赖的版本不可控是 CVE 追踪困难的根源。5.1 用 pip-tools 锁定直接依赖和间接依赖推荐的做法是维护两个文件requirements.in记录直接依赖requirements.txt记录完全锁定后的依赖清单。requirements.in示例flask3.0,4.0 openai1.30.0 requests2.31.0 pydantic2.5.0然后用 pip-tools 生成完整锁文件pip install pip-tools pip-compile requirements.in --output-file requirements.txt生成的requirements.txt会列出所有间接依赖和精确版本号。构建时用这个文件安装两次构建拿到的依赖完全一致CVE 扫描结果也就可复现、可追踪。如果需要进一步防篡改可以在锁文件里加入哈希校验pip-compile requirements.in --output-file requirements.txt --generate-hashes安装时加上校验参数pip install --require-hashes -r requirements.txt这样做的好处是保证依赖不会被中间环节篡改坏处是每次升级依赖都要重新生成哈希维护成本略高。对于面向公网发布的 AI 镜像强烈建议开启。5.2 移除运行无关的依赖AI 项目中经常出现“调试依赖”与“运行依赖”混装。比如pytest、ruff、ipython这类工具在本地开发时需要但不应该进入最终镜像。把它们从requirements.in中拆出去放到requirements-dev.in中配合多阶段构建只安装运行依赖。判断一个依赖是否该进入镜像的方法很简单镜像必须能独立启动服务但不需要跑测试和静态检查。凡是只在开发时使用的包全部排除。5.3 生成 SBOM 和镜像签名依赖锁定解决的是可复现问题SBOM 解决的是可追溯问题。SBOM 的全称是 Software Bill of Materials软件物料清单。它把镜像里的组件、版本、许可证、依赖关系整理成一份机器可读的清单安全审计时可以快速定位某个 CVE 影响哪个组件。用 Syft 生成 SBOMsyft packages your-registry.com/nanoclaw-agent:v1.4.0 \ -o spdx-json nanoclaw-sbom.spdx.json生成 SPDX 格式的 SBOM 后可以把它作为制品和镜像一起发布。更进一步用 Cosign 对镜像签名保证镜像本身和 SBOM 未被篡改cosign sign --key cosign.key \ your-registry.com/nanoclaw-agent:v1.4.0不要求立刻上容器镜像仓库的签名校验完整链路但至少在 CI 里加上签名这一步后续做准入校验时就有了基础。6. 用 Trivy 做基线扫描与 CI 门禁镜像加固完成后最怕的是下次发布时 CVE 反弹。所以扫描和门禁必须嵌入 CI/CD。6.1 本地扫描验证先做一次本地扫描确认改造效果trivy image --severity HIGH,CRITICAL \ --ignore-unfixed \ your-registry.com/nanoclaw-agent:v1.5.0参数说明--severity HIGH,CRITICAL只输出高风险漏洞减少噪音--ignore-unfixed忽略上游还没有修复补丁的漏洞避免把“无法解决”的历史包袱算进来。理想情况下这条命令的输出结果应该是 “Total: 0 (HIGH: 0, CRITICAL: 0)”。如果还残留少量漏洞先确认是不是基础镜像更新滞后。常见操作是把基础镜像重新拉取一次再触发一次构建扫描很多 CVE 会因为上游镜像更新而消失。6.2 在 CI/CD 中加入阻断门禁扫描命令加上--exit-code 1当高危险漏洞数量不为零时命令退出码为 1流水线失败trivy image --severity HIGH,CRITICAL \ --ignore-unfixed \ --exit-code 1 \ --format table \ your-registry.com/nanoclaw-agent:v1.5.0以 GitLab CI 为例可以在镜像构建阶段后加入一个 scan 阶段# 文件路径.gitlab-ci.yml 片段 image_build: stage: build script: - docker build -t $IMAGE_TAG . - docker push $IMAGE_TAG image_scan: stage: test script: - trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 $IMAGE_TAG rules: - if: $CI_COMMIT_BRANCH main这样每次合入主分支或发布版本时镜像都必须通过扫描门槛。中危和低危漏洞可以记录在扫描报告里不阻断发布但要在发布记录中注明。6.3 例外漏洞的治理即使做了多阶段构建和基础镜像替换某些 CVE 仍可能因为上游依赖尚未修复而存在。此时不要直接关闭扫描而是通过.trivyignore文件管理例外并写明原因# 文件路径.trivyignore CVE-2024-0000 # 说明该漏洞仅影响解析恶意构造的 X 文件功能本镜像不处理用户输入文件 CVE-2024-1111 # 说明依赖 libfoo 1.2.3上游修复版本 1.2.4 尚未发布已建立升级计划例外清单要定期评审随着上游修复版本发布及时移除。一个 CVE 例外如果连续几个版本仍然存在说明依赖已经严重滞后应该升级依赖而不是继续忽略。7. 常见问题与排查思路在加固镜像和接入扫描的过程中团队会遇到一些典型问题。下面的表格来自我们维护 NanoClaw 镜像时的真实排查记录。问题现象可能原因排查方式解决方案Alpine 镜像安装 pydantic 失败musl libc 不兼容部分 C 扩展的预编译 wheel查看 pip 安装日志确认是否触发源码编译换用 slim-bookworm 或指定强制编译参数镜像启动后提示 openssl 找不到精简镜像裁剪掉了系统证书或 SSL 库docker run --entrypoint python image -c import ssl检查安装 ca-certificates确认依赖库已复制扫描结果与实际依赖不一致锁文件未生成或构建时使用了缓存对比 requirements.txt 与镜像内pip freeze输出重新生成锁文件构建时关闭缓存Trivy 扫描出大量无法修复的 CVE基础镜像过旧或软件包未更新重新 pull 基础镜像触发重建定期更新基础镜像 tag跟踪上游安全公告流水线扫描失败但本地通过CI 中没有登录私有镜像仓库查看 CI 日志中的拉取错误在扫描前执行镜像仓库登录操作忽略策略不生效扫描命令未指定 ignore 文件路径检查 Trivy 是否读取.trivyignore使用--ignorefile .trivyignore显式指定切换 Distroless 后无法调试镜像里没有 shell使用debug版本或通过docker run --debug附加调试容器仅在排查时启用调试镜像运行镜像保持精简这里特别想提醒的是“CVE 扫描为零”不一定是终态。只要基础镜像或依赖有更新一个旧镜像的扫描结果很快会重新出现新的 CVE。所以扫描不应该是“发布前的一次性动作”而应该是“每次构建的固定步骤”。建立这个习惯比某一个镜像的 CVE 清零更重要。8. 生产环境的最佳实践与工程建议完成一轮加固后如果团队负责的不只是 NanoClaw 一个镜像而是多个 AI 服务镜像下面这些建议可以直接复制到团队规范中。8.1 镜像标签使用不可变版本生产环境用摘要不要在生产环境使用latest标签。latest指向的镜像会漂移扫描报告和实际运行镜像对不上。发布时使用语义化版本标签docker build -t your-registry.com/nanoclaw-agent:v1.5.0 . docker push your-registry.com/nanoclaw-agent:v1.5.0更严格的团队可以记录镜像摘要并在部署 YAML 中固定docker inspect your-registry.com/nanoclaw-agent:v1.5.0 \ --format{{index .RepoDigests 0}}把输出形如your-registry.com/nanoclaw-agentsha256:xxxx的地址写到 Kubernetes Deployment 的image字段避免同一 tag 被覆盖后产生漂移。8.2 非 root 与只读文件系统加固后的 Dockerfile 已经创建了appuser生产环境部署时还可以进一步配置只读根文件系统。在 Kubernetes 中设置readOnlyRootFilesystem: true、runAsNonRoot: true业务需要写数据的目录单独挂载 volume# 文件路径deployment.yaml 片段 securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 10001 allowPrivilegeEscalation: false这个配置能保证即使镜像里某个 CVE 被利用攻击者也无法写系统目录也不能提权。8.3 组件责任到人CVE 治理最怕“人人都管人人都不管”。建议为依赖组件划分负责人组件负责人更新频率基础镜像平台组每月更新一次 tagPython 依赖应用开发者每周检查安全公告SBOM/签名CI 平台组每次发布自动生成例外清单安全评审人每两周评审一次8.4 扫描报告存档CI 里每次扫描后生成 JSON 报告并归档保留至少 90 天。这样后续安全审计时可以直接对比“上个版本有多少 CVE这个版本为什么多了两条”。归档建议使用独立的 S3 或对象存储目录按镜像名和版本号组织trivy image --format json --output \ reports/nanoclaw-agent/v1.5.0.json \ your-registry.com/nanoclaw-agent:v1.5.08.5 安全基线分级镜像不是所有 CVE 都必须清零。生产环境建议关注“严重和高危清零”中危和低危按照修复成本和可利用性评估。如果中危漏洞只影响一个非网络暴露的组件且没有攻击路径可以把它列入例外并设置修复日期。但如果一个中危漏洞涉及网络请求处理即使评分不高也要尽快修复。9. 总结与后续学习方向这次 NanoClaw 镜像加固表面上是从 1,400 个 CVE 降到了个位数实际做的是把镜像构建从“能跑就行”变成了“可追踪、可扫描、可治理”。核心动作就四个基础镜像精简、依赖锁定、运行时权限收口、CI 扫描门禁。从 1,400 到 0真正改变的其实不是数字而是镜像生产流程。如果你手头的项目也有类似的镜像漏洞积压建议不要直接跳进“逐条修复”的坑先做一次全量扫描把漏洞按来源分组然后从基础镜像和依赖层开始改。你会很快看到效果。下一步值得继续深入的方向有三个一是镜像签名和准入控制比如用 Cosign 在镜像仓库侧做签名校验二是把治理范围从镜像扩展到整个软件供应链比如对基础镜像的来源和 SBOM 的真实性做验证三是用策略引擎自动处理例外清单比如把.trivyignore的变更纳入评审流程避免“为清零而忽略”。容器镜像安全没有终点但有了基线、锁文件和门禁每次新 CVE 出现时团队不再需要重新面对一份千级漏洞清单。希望这篇实践记录对你加固自己的镜像有帮助。