Trivy、Grype 与 OWASP Dependency-Check:从漏洞命中到供应链治理
凌晨发布窗口里,镜像扫描突然新增一个 CRITICAL。应用代码一行没改,昨天还是绿灯;研发认为是误报,安全同学要求立即阻断,发布经理只想知道“到底能不能上线”。这个冲突通常不是工具失灵,而是团队只保存了红色告警,没有保存目标镜像摘要、包清单、漏洞库时间、匹配依据和例外证据。
SCA 扫描器做的是一条证据推导:先从镜像、目录或 SBOM 识别软件包,再以包名、版本、发行版等信息查询漏洞情报,最后套用严重度、修复状态和组织策略。任何一段发生变化,结论都可能变化。因而“昨天通过、今天失败”完全可能是漏洞库更新的正常结果;“三个扫描器数量不同”也不能直接判定其中两个错了。
先分清三种扫描入口
Trivy 覆盖文件系统、容器镜像、SBOM 及多类配置检查,适合在 PR 和制品阶段形成统一入口。Grype 专注软件包目录与漏洞匹配,能够直接消费 Syft 生成的 SBOM,适合保存稳定的软件成分证据后反复复扫。OWASP Dependency-Check 以 CPE 和证据匹配为核心,在 Maven、Gradle 等传统 Java 构建链中仍很常见。
扫描源码目录速度快,但它看到的是仓库状态;扫描最终镜像更接近交付物,却可能因多阶段构建丢失锁文件和元数据;扫描 SBOM 最利于归档与复扫,但结论上限取决于 SBOM 的完整度。成熟流水线通常不会三选一:PR 扫目录,构建后扫描固定 digest 的镜像,同时归档 SBOM 供后续情报更新时复扫。
安装并固定可追溯版本
示例固定 Trivy 0.72.0、Grype 0.115.0 和 Dependency-Check 12.2.2。升级时先在工具仓库修改版本与校验值,通过基准项目后再推广,不让每个 Runner 自行追逐 latest。
Trivy 的签名验证文档说明,0.68.1 之后每个 Release 资产都带独立的 Sigstore bundle。Linux 构建机不仅要核对摘要,还要验证 GitHub Actions 颁发的短期证书、OIDC issuer 和发布工作流身份;同源 checksums 只能发现传输损坏,不能单独证明发布者身份:
set -euo pipefail
TRIVY_VERSION=0.72.0
TRIVY_ASSET="trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
TRIVY_BASE="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}"
curl -fLO "${TRIVY_BASE}/${TRIVY_ASSET}"
curl -fLO "${TRIVY_BASE}/${TRIVY_ASSET}.sigstore.json"
cosign verify-blob "$TRIVY_ASSET" \
--bundle "${TRIVY_ASSET}.sigstore.json" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--certificate-identity "https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v${TRIVY_VERSION}"
tar -xzf "$TRIVY_ASSET" trivy
install -m 0755 trivy "$HOME/.local/bin/trivy"
trivy --versionGrype 不执行会随时间变化的远程安装脚本。固定 Release 资产,先用 Cosign 验证 checksums 的证书身份和签名,再用已验证的摘要核对二进制;普通 CI 只从内部工具仓库取这份制品:
set -euo pipefail
GRYPE_VERSION=0.115.0
GRYPE_ASSET="grype_${GRYPE_VERSION}_linux_amd64.tar.gz"
GRYPE_SUMS="grype_${GRYPE_VERSION}_checksums.txt"
GRYPE_BASE="https://github.com/anchore/grype/releases/download/v${GRYPE_VERSION}"
for file in "$GRYPE_ASSET" "$GRYPE_SUMS" "${GRYPE_SUMS}.pem" "${GRYPE_SUMS}.sig"; do
curl -fLO "${GRYPE_BASE}/${file}"
done
cosign verify-blob "$GRYPE_SUMS" \
--certificate "${GRYPE_SUMS}.pem" \
--signature "${GRYPE_SUMS}.sig" \
--certificate-identity-regexp 'https://github\.com/anchore/grype/\.github/workflows/.+' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
grep " ${GRYPE_ASSET}$" "$GRYPE_SUMS" | sha256sum -c -
tar -xzf "$GRYPE_ASSET" grype
install -m 0755 grype "$HOME/.local/bin/grype"
grype version -o jsonDependency-Check CLI Release 需要 Java,官方提供的是 .asc GPG 签名而不是摘要文件。签名公钥必须核对完整指纹 259A55407DD6C00299E6607EFFDE55BE73A2D1ED,再验证 ZIP;仅看到 Good signature 而不核对指纹,仍可能信任错误密钥:
set -euo pipefail
DC_VERSION=12.2.2
DC_ZIP="dependency-check-${DC_VERSION}-release.zip"
DC_BASE="https://github.com/dependency-check/DependencyCheck/releases/download/v${DC_VERSION}"
export GNUPGHOME="$(mktemp -d)"
chmod 700 "$GNUPGHOME"
gpg --keyserver hkps://keyserver.ubuntu.com \
--recv-keys 259A55407DD6C00299E6607EFFDE55BE73A2D1ED
test "$(gpg --with-colons --fingerprint 259A55407DD6C00299E6607EFFDE55BE73A2D1ED | awk -F: '$1=="fpr" {print $10; exit}')" \
= "259A55407DD6C00299E6607EFFDE55BE73A2D1ED"
curl -fLO "${DC_BASE}/${DC_ZIP}"
curl -fLO "${DC_BASE}/${DC_ZIP}.asc"
gpg --batch --verify "${DC_ZIP}.asc" "$DC_ZIP"
unzip -q "$DC_ZIP" -d "$HOME/.local/lib"
rm -rf -- "$GNUPGHOME"
unset GNUPGHOME
java -version
"$HOME/.local/lib/dependency-check/bin/dependency-check.sh" --version
"$HOME/.local/lib/dependency-check/bin/dependency-check.sh" \
--data "$HOME/.cache/dependency-check" --updateonly容器工具不能只固定 tag。先解析并登记镜像摘要,再让流水线引用 image@sha256:...:
docker buildx imagetools inspect aquasec/trivy:0.72.0
docker buildx imagetools inspect anchore/grype:v0.115.0摘要固定解决的是工具制品漂移,漏洞数据库仍会持续变化。这两个维度必须分别记录。
建立一组无害的正反实验
不要把生产镜像当试验材料,也不要把已知危险依赖加入业务仓库。下面在临时目录中生成两份最小 CycloneDX SBOM:空组件清单验证解析和报告链,第二份只声明 Log4j Core 2.14.1,专门验证漏洞门禁。它们不会参与构建,也不含可执行代码;漏洞情报会随数据库更新,因此每次运行都要保存 DB 构建时间和实际命中结果。
set -euo pipefail
LAB_DIR="$(mktemp -d)"
mkdir -p "$LAB_DIR/reports" "$LAB_DIR/cache/trivy" "$LAB_DIR/cache/grype"
cat > "$LAB_DIR/clean.cdx.json" <<'JSON'
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:00000000-0000-4000-8000-000000000001",
"version": 1,
"components": []
}
JSON
cat > "$LAB_DIR/vulnerable.cdx.json" <<'JSON'
{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"serialNumber": "urn:uuid:00000000-0000-4000-8000-000000000002",
"version": 1,
"components": [{
"type": "library",
"group": "org.apache.logging.log4j",
"name": "log4j-core",
"version": "2.14.1",
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
}]
}
JSON
GRYPE_DB_CACHE_DIR="$LAB_DIR/cache/grype" grype db update
trivy sbom --cache-dir "$LAB_DIR/cache/trivy" \
--format json --output "$LAB_DIR/reports/trivy.json" \
"$LAB_DIR/clean.cdx.json"
GRYPE_DB_CACHE_DIR="$LAB_DIR/cache/grype" \
GRYPE_DB_AUTO_UPDATE=false GRYPE_CHECK_FOR_APP_UPDATE=false grype \
"sbom:$LAB_DIR/clean.cdx.json" -o json \
> "$LAB_DIR/reports/grype.json"
test -s "$LAB_DIR/reports/trivy.json"
test -s "$LAB_DIR/reports/grype.json"正向实验只证明扫描链能运行。反向实验要证明门禁真的会失败,而且失败后报告仍被保留。为此使用经过审核、固定摘要并专供培训的漏洞 fixture;不要使用滚动 tag,因为数据库和镜像一起漂移后无法解释结果。
set +e
trivy sbom --severity HIGH,CRITICAL --exit-code 23 \
--cache-dir "$LAB_DIR/cache/trivy" \
--format json --output "$LAB_DIR/reports/trivy-gate.json" \
"$LAB_DIR/vulnerable.cdx.json"
trivy_rc=$?
GRYPE_DB_CACHE_DIR="$LAB_DIR/cache/grype" \
GRYPE_DB_AUTO_UPDATE=false GRYPE_CHECK_FOR_APP_UPDATE=false grype \
"sbom:$LAB_DIR/vulnerable.cdx.json" \
--fail-on high -o json > "$LAB_DIR/reports/grype-gate.json"
grype_rc=$?
set -e
printf 'trivy_rc=%s grype_rc=%s\n' "$trivy_rc" "$grype_rc"
test "$trivy_rc" -eq 23
test "$grype_rc" -ne 0
test -s "$LAB_DIR/reports/trivy-gate.json"
test -s "$LAB_DIR/reports/grype-gate.json"这里刻意给 Trivy 设置 23,避免把“发现漏洞”和“工具自身失败”混成一个状态。Grype 的 --fail-on high 表示发现达到阈值的漏洞时非零退出;Runner 还应把数据库下载失败、目标不存在和报告写入失败单独分类。只写 scanner ... || true 会把真正的执行故障也洗成绿灯。
数据库、缓存与受限网络
Trivy 安装包不携带完整安全情报,首次运行会拉取漏洞 DB;扫描 JAR 时还可能使用 Java DB。可在联网同步任务中预热:
TRIVY_CACHE_DIR="/secure-transfer/trivy-cache"
trivy image --cache-dir "$TRIVY_CACHE_DIR" --download-db-only
trivy image --cache-dir "$TRIVY_CACHE_DIR" --download-java-db-only
trivy image --cache-dir "$TRIVY_CACHE_DIR" \
--skip-db-update --skip-java-db-update --offline-scan \
--format json --output artifacts/security/trivy-offline.json \
registry.internal.example/team/app@sha256:"$IMAGE_DIGEST"Trivy 的数据库配置允许指定多个 OCI 仓库并按顺序回退。内部镜像入口应记录同步时间、源 digest、schema 和责任人。--skip-db-update 只适用于缓存中已经有兼容 DB 的情况,不能把空缓存变成离线扫描器。
Grype 使用本地 SQLite 漏洞库,默认启动时检查更新,并在数据库构建时间超过五天时使扫描失败。这个保护来自数据库年龄策略,不应为了流水线变绿而永久关闭:
grype db status -o json
grype db check
grype db update隔离区从批准的中转目录导入数据库:
export GRYPE_DB_CACHE_DIR="/secure-transfer/grype-cache"
export GRYPE_DB_AUTO_UPDATE=false
export GRYPE_CHECK_FOR_APP_UPDATE=false
grype db import /secure-transfer/vulnerability-db.tar.zst
grype db status -o json
grype "sbom:/secure-transfer/app.cdx.json" -o json \
> artifacts/security/grype-offline.jsondb.max-allowed-built-age 是离线同步 SLA,不是随意放宽的开关。超过期限应先报“情报陈旧”,再决定是否阻断发布;扫描结果为空但数据库已经过期,风险反而更高。
Dependency-Check 首次 NVD 初始化可能耗时较长并触发限流。--nvdApiKey 应由 CI 密钥注入,数据目录需要持久化:
dependency-check.sh \
--data .cache/dependency-check \
--nvdApiKey "$NVD_API_KEY" \
--updateonly
dependency-check.sh \
--data .cache/dependency-check \
--noupdate --scan . \
--format JSON --format HTML \
--out artifacts/security根据其CLI 参数,--noupdate 会关闭 NVD、Hosted Suppressions 和 RetireJS 的自动更新,但某些 Analyzer 仍可能访问外部服务。受限网络必须用 Runner 出站日志验证,而不是从一个参数名推断“完全离线”。NVD、Hosted Suppressions 和其他外部数据可以镜像到内网,代理证书则应进入系统信任链,不能通过关闭 TLS 校验解决。
Dependency-Check 也要验证“报告”和“门禁”是两条独立路径。临时下载公开的 Log4j Core 2.14.1 训练制品并核对固定 SHA-256;它只进入 LAB_DIR,不进入项目依赖。第一次只生成证据,第二次才设置阈值:
set -euo pipefail
DC_DATA="$LAB_DIR/cache/dependency-check"
DC_REPORT="$LAB_DIR/reports/dependency-check"
mkdir -p "$DC_DATA" "$DC_REPORT"
cp -a .cache/dependency-check/. "$DC_DATA/"
DC_FIXTURE="$LAB_DIR/fixture/log4j-core-2.14.1.jar"
mkdir -p "$(dirname "$DC_FIXTURE")"
curl -fL \
'https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar' \
-o "$DC_FIXTURE"
printf '%s %s\n' \
'ade7402a70667a727635d5c4c29495f4ff96f061f12539763f6f123973b465b0' \
"$DC_FIXTURE" | sha256sum -c -
dependency-check.sh --noupdate \
--data "$DC_DATA" \
--project security-fixture \
--scan "$DC_FIXTURE" \
--format JSON --format HTML \
--out "$DC_REPORT/full"
set +e
dependency-check.sh --noupdate \
--data "$DC_DATA" \
--project security-fixture \
--scan "$DC_FIXTURE" \
--format JSON --out "$DC_REPORT/gate" \
--failOnCVSS 8
dc_rc=$?
set -e
test -s "$DC_REPORT/full/dependency-check-report.json"
test -s "$DC_REPORT/gate/dependency-check-report.json"
printf 'dependency-check gate rc=%s\n' "$dc_rc"
test "$dc_rc" -ne 0Fixture 的预期 CVE、最高 CVSS、归档摘要和 DB 构建时间应放进测试断言。门禁实验只有在固定 fixture 确实包含达到阈值的已知命中时才断言非零;如果数据库更新后编号或评分变化,先审阅差异,不能为了维持旧断言降低扫描质量。
让报告成为可复核证据
每次发布至少绑定以下信息:源码提交、镜像 digest、SBOM digest、扫描器版本、漏洞库构建时间、策略版本、完整机器报告和最终门禁结论。HTML 适合阅读,JSON/SARIF 适合自动化;只保存截图无法重放判断。
同一 CVE 在三个工具中可能出现不同严重度,因为 NVD、Linux 发行版和上游厂商的评价不同。排查顺序是先确认扫描对象一致,再比较 PURL/CPE、已安装版本、发行版、数据库时间和忽略规则。Dependency-Check 依赖 CPE 证据匹配,名称相似可能导致误报;Trivy 与 Grype 对 OS 包的厂商状态、Java 归档识别也可能不同。
VEX 不是“安全团队说没事”的备注。not_affected 必须绑定产品和漏洞,并说明组件不存在、执行路径不可达或利用条件不成立等可复核依据。抑制规则同样需要最小匹配范围、owner、审批单、到期时间和复核方法。一个无限期忽略某 CVE 的全局规则,会把未来新资产上的真实风险一起隐藏。
接入项目与流水线
PR 阶段只阻断新增且可修复的高风险,避免历史债务让所有变更无法合并;制品阶段扫描最终镜像和 SBOM;每日存量任务在数据库更新后重扫已发布 SBOM。三层使用同一政策仓库,但具有不同 SLA。
set -euo pipefail
IMAGE_REF="registry.example.com/team/app@sha256:${IMAGE_DIGEST}"
mkdir -p artifacts/security artifacts/sbom
trivy image --format cyclonedx \
--output artifacts/sbom/app.cdx.json "$IMAGE_REF"
trivy image --format json \
--output artifacts/security/trivy-full.json "$IMAGE_REF"
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
--exit-code 23 "$IMAGE_REF"完整报告保留所有发现,门禁扫描才使用 --ignore-unfixed。否则团队会把“当前没有补丁”误解为“库存中没有风险”。Grype 可以消费同一 SBOM:
grype sbom:artifacts/sbom/app.cdx.json \
--fail-on high -o json \
> artifacts/security/grype.jsonDependency-Check 可通过 --failOnCVSS 返回失败,但 CVSS 阈值不应成为唯一策略。互联网暴露、CISA KEV、固定版本可用性、数据等级和补偿控制都会改变处置优先级。
故障证据与排查路径
首次扫描很慢或频繁 429,先看 DB 下载日志和缓存命中,常见根因是并发 Job 各自重建数据库。离线任务仍尝试联网,逐项检查漏洞 DB、Java DB、Checks Bundle、Hosted Suppressions 和各 Analyzer。镜像扫描漏掉应用依赖,则比较最终层内容、锁文件和构建阶段 SBOM,不能只换扫描器碰碰运气。
数据库 schema 不兼容时,应成对升级扫描器与 DB 镜像。报告数量突增时,先比较 DB 时间与策略提交;如果目标和工具都没变,新增情报就是最可能原因。抑制后绿灯仍需查看全量报告,确认规则没有扩大到其他包、版本或路径。
权限、数据与运行成本
扫描私有 Registry 只需要拉取权限,不应拥有推送和删除权限。挂载 Docker Socket 几乎等于授予宿主容器控制能力,外部 PR 的 Runner 不得继承该挂载;优先扫描 Registry 中固定 digest 的镜像。NVD API Key、Registry 密码和代理凭证使用密钥注入,禁止写入命令历史和报告。
SBOM 与漏洞报告会暴露组件版本、内部镜像名和攻击面。制品库需要下载权限、加密、保留期限和删除审计。扫描成本主要来自镜像传输、包目录解析、数据库更新和报告存储:大型 Monorepo 可在 PR 扫变更范围,但发布门禁不能省略最终制品扫描;共享缓存按工具版本与 DB schema 分区,避免并发写坏。
清理、回滚与长期治理
临时实验只删除自己创建并核验过的目录:
case "$LAB_DIR" in
/tmp/*|/var/tmp/*) rm -rf -- "$LAB_DIR" ;;
*) printf 'refuse to remove unexpected path: %s\n' "$LAB_DIR" >&2; exit 64 ;;
esactrivy clean --vuln-db --java-db、grype db delete 与 Dependency-Check --purge 会影响后续任务,在共享 Runner 上必须由维护窗口执行。策略回滚应回到上一份已签名策略和工具镜像,同时保留新旧结果差异;不能删除失败报告来“恢复发布”。
平台团队维护工具镜像、数据库同步和 Runner 权限;安全团队维护门禁政策、VEX 证据要求与例外审批;应用团队负责升级、影响分析和回归验证;发布负责人只根据有证据的门禁结论决策。持续度量新增漏洞数量、平均修复时间、过期例外、数据库年龄、扫描失败率和存量复扫覆盖率,才能把一次扫描变成长期供应链治理。
