被投毒的不是代码,而是权重文件
一个反直觉的事实是:AI 系统最脆弱的一环通常不是推理服务,而是构建流水线拉下来的那个 .safetensors 或 requirements.txt。传统软件供应链攻击要拿到代码执行权限才能作恶,而 AI 供应链攻击只要让模型"表现差一点",或者对特定输入输出特定内容就够了。你的单元测试会全绿,监控曲线平滑,问题只会在三个月后的一条业务告警里浮现。
AI 特有的三个攻击面决定了防御重点与传统软件供应链不同:
- 反序列化执行 :PyTorch 的
.bin/.pt走 pickle,加载即执行任意代码。换成 safetensors(Hugging Face 主推的纯张量只读格式)能整类消除这条路径,代价是并非所有历史模型都有对应转换。 - 权重后门 :攻击者不需要执行任何代码,发布一个"效果更好"的微调模型,里面埋触发词即可。哈希校验对这类攻击完全无效------文件确实是你签名拉下来的那一个,只是它的语义被污染了。
- 配置与模板 :tokenizer 的 special tokens、chat template 里的 Jinja 模板(可注入)、
requirements.txt的传递依赖,都位于签名覆盖范围之外。
所以第一件要想清楚的事:签名校验解决的是"来源可信 + 内容未被篡改",它不解决"模型语义是否安全"。把两者混为一谈,是绝大多数落地失败的起点。
概念对齐:摘要、来源证明、签名
四个词必须分清,否则后面的策略没法写:
- 制品摘要(digest):对文件内容做 sha256。名字和 tag 可变,digest 不可变。所有缓存、策略、审计都必须以 digest 为主键。
- 来源证明(provenance):in-toto 格式的 attestation,描述"谁、用什么构建系统、从哪个 commit 构建的"。SLSA v1.0 规范把它拆成 build definition 与 run details 两部分,等级越高,构建过程越不可被单人篡改。
- 签名(signature):Sigstore cosign 的 keyless 模式用 CI 的 OIDC 身份签发短期证书,签名条目写入透明日志 Rekor,从而把"信任"从一把长期密钥转移到"这个 GitHub Actions workflow 的身份"上。
- 策略(policy) :准入判断,形如"签名者身份必须匹配
acme/ml-pipeline,issuer 必须是 GitHub OIDC,且必须存在 Rekor 条目"。
适用边界要讲清楚:签名只保证"这是我签的",不保证"这是安全的"。SLSA 等级描述的是构建过程可信度,不是代码质量。指望签名挡住投毒,等于指望门锁挡住屋里的内鬼。
机制拆解:从 OIDC 签名到 Redis 缓存校验
完整的准入链路是这样的:
- 拉取前先解析出制品 digest,而不是先下载再算;
- 用
cosign verify-blob校验签名、证书身份、OIDC issuer 与 Rekor 条目; - 校验结论按 digest 写入 Redis,TTL 与证书有效期或策略窗口对齐;
- 后续流水线命中缓存直接放行,避免每次构建都打一次 Rekor(透明日志查询是网络调用,延迟不可控);
- 密钥泄露或策略升级时,通过 pub/sub 广播删除对应 key,保证吊销能传播。
Redis 7.4 引入的 hash 字段级过期(HEXPIRE / HTTL)在这里刚好合用:一个 digest 下挂多个结论字段------签名校验、SBOM 检查、恶意权重扫描------它们的最长有效期完全不同,字段级 TTL 比给整个 key 设一个折中的过期时间更准确。
python
import subprocess, redis
r = redis.Redis(host="redis", port=6379, decode_responses=True)
IDENTITY = "--certificate-identity-regexp=^https://github.com/acme/ml-pipeline/"
ISSUER = "--certificate-oidc-issuer=https://token.actions.githubusercontent.com"
def verify(digest: str, blob: str) -> bool:
key = f"attest:{digest}" # 主键只认 digest,绝不用模型名或 tag
if r.hget(key, "sig") == "ok": # 命中缓存,跳过 Rekor 查询
return True
p = subprocess.run(
["cosign", "verify-blob",
"--signature", f"{blob}.sig", "--certificate", f"{blob}.pem",
*IDENTITY.split(), *ISSUER.split(), blob],
capture_output=True)
if p.returncode != 0: # 校验失败不写缓存,防止负结论被固化
return False
r.hset(key, mapping={"sig": "ok", "policy": "v3"})
# 原生命令:HEXPIRE attest:<digest> 86400 FIELDS 1 sig
r.hexpire(key, 86400, "sig") # 签名结论 24h 后强制重验
r.hexpire(key, 604800, "policy") # 策略版本结论 7d