发现项归一、例外与整改闭环
一个团队同时接入清单检查、集群配置扫描和镜像漏洞报告后,同一 Deployment 一夜之间生成了十几张工单。有人按资源名把它们批量合并,却把构建时旧镜像与线上新 digest 当成同一资产;旧镜像的工单关闭时,真正运行的漏洞也被一起隐藏。第二天发布又重建了 Deployment,同名对象沿用“已修复”标签,页面很干净,证据身份却已经完全错位。
另一处现场中,节点基准规则升级后更换了 control ID,系统把旧发现全部标为已解决,再以新 ID 创建同量工单。管理层看到修复率突然上升,应用团队则收到一批无法执行的节点任务。原因不是扫描器出错,而是归一层没有保存规则包版本、语义映射和资产 owner;它把“规则改名”“风险消失”和“责任转移”混成了同一种状态变化。
归一不是把所有结果压成一张表
归一层要保存差异,再提供一致的查询与流转方式。静态清单结果绑定 Git revision 和渲染 digest;集群报告绑定对象 UID、resourceVersion 或镜像 digest;节点基准绑定 node UID、benchmark target 与配置;运行时事件绑定事件时间、进程、容器和节点。它们都可以叫 finding,但不能假设拥有相同生命周期。
初学者可把一条发现拆成五部分:asset 回答影响谁,rule 回答按什么逻辑判断,evidence 回答何时看到了什么,workflow 回答谁在处理,provenance 回答原始内容从哪里来。严重度、标题和修复建议只是属性,不足以构成身份。
成熟归一层还要接住“没有结果”的原因。适配器执行失败、权限不足、输入不完整、目标不支持、报告过期和真正零发现必须是不同状态。否则查询端会把一切空集都解释成安全。
先固定资产键再谈去重
工作负载发现建议同时保存实例身份与服务身份。实例身份使用 cluster_id / namespace / kind / uid,能防止同名对象删除重建后串证据;服务身份使用受治理的 service_id,用于跨 ReplicaSet、Deployment 重建和集群迁移保持 owner 连续性。name 便于人读,但不能单独作为主键。
Pod 发现沿 metadata.ownerReferences 逐层归属到顶层控制器。镜像漏洞必须把容器名和不可变 digest 纳入键;tag 只能作为显示字段。节点发现使用 cluster、node UID 与 benchmark target。仓库清单尚无 Kubernetes UID 时,使用 repository、path、document identity、环境与渲染 digest,部署后再建立 declared_asset -> live_asset 关联,不能提前假装二者相同。
asset_instance:
type: kubernetes-workload
cluster_id: cluster-sandbox
namespace: checkout
api_group: apps
kind: Deployment
name: api
uid: <resource-uid>
container: app
image_digest: sha256:<image-digest>
asset_service:
service_id: service-checkout-api
owner_source: catalog
owner: team-checkout采集资产时只需资源元数据、owner reference、容器镜像身份和治理标签。不要读取 Secret 值。Kubernetes 的 ObjectMeta API可用于核对 uid、ownerReferences、generation 与 resourceVersion 的语义;其中 resourceVersion 是并发与变更观察字段,不应被当成跨对象稳定 ID。
规则身份必须包含语义版本
规则 ID 往往不是永久语义。工具升级可能重命名规则、拆分控制项、改变查询逻辑或严重度;团队也可能通过自定义参数改变结果。规则键至少包含 source、rule_id 和 ruleset_digest,并保存工具版本、framework/benchmark、参数摘要与规则正文引用。
若两个规则只是名称不同但控制目标等价,可通过受评审的映射表汇聚到内部 control_id。映射必须有版本与生效范围,不能靠字符串模糊匹配。若新规则扩展了资产范围或判断条件,它应形成新的语义版本,旧例外不能自动继承。
rule:
source: <scanner-name>
source_version: vX.Y.Z
rule_id: RULE-012
ruleset_digest: sha256:<ruleset-digest>
parameter_digest: sha256:<parameter-digest>
control_id: workload.non_root
mapping_version: controls-v3规则仓库应走代码评审,发布不可变制品并保留签名或摘要。适配器只接受已登记规则版本;未知版本可以保存原始证据,但应标为 mapping_pending,避免错误归并后自动关闭工单。
报告年龄与发现状态是两条轴
open/closed 描述处置状态,fresh/stale/unknown 描述证据时效,二者不能合并。一条风险可以仍为 open,但最新报告已过期;一条 verified 记录也应保留当时的新证据引用。报告 TTL 删除只意味着当前 CR 不存在,不等于风险修复。
每个适配器输出 observed_at、ingested_at、valid_until、collection_state 与 source_ref。observed_at 来自源系统,ingested_at 用于观察管道延迟;时钟漂移较大的来源要记录不确定性。valid_until 根据风险面和采集频率计算。漏洞数据库年龄、规则包版本和输入对象身份也属于有效性条件。
Trivy Operator 报告是 Kubernetes 自定义资源,其CRD 文档给出报告类型与关联方式;Operator 的配置文档用于核对报告保留、并发和扫描行为。消费方还要同时观察扫描 Job 与控制器错误,不能只 watch 成功报告。
设计可解释的 finding ID
finding ID 需要在“同一风险重复观察时稳定”和“资产或规则语义改变时分离”之间取平衡。一个实用输入是 asset_instance_key + normalized_control_id + rule_semantic_version + scope 的规范化序列,再计算 SHA-256。证据时间、严重度和描述不进入 ID,否则每次重扫都会新建工单。
canonical = join("\n", [
"finding-id/v2",
asset_instance_key,
normalized_control_id,
rule_semantic_version,
finding_scope
])
finding_id = "sha256:" + sha256(canonical)规范化要定义 Unicode、大小写、空值、列表排序与转义,不允许各适配器自行拼字符串。schema 和算法都带版本;算法升级时同时计算旧 ID 与新 ID,生成迁移关系。数据库对 (tenant, finding_id) 建唯一约束,消息消费使用相同键实现幂等。
服务身份不适合直接替代实例身份:同一服务的新 digest 可能引入新漏洞。服务键可作为父级聚合视图,实例键决定证据与复核。这样看板能按服务汇总,工单又不会把不同运行事实误合并。
去重必须经过候选、比较与合并
去重先生成候选集,再比较语义,最后决定聚合。候选条件可使用同一服务、同一内部 control、相近观察窗口;比较阶段检查实例资产、输入层、规则版本、修复动作与证据来源。只有“同一修复能同时消除这些事实”时,才适合共享一个处置单。
多个工具发现同一容器缺少只读根文件系统,可以共享工作项,但静态渲染、live object 与运行实例三份证据都要保留。镜像漏洞和 Pod 安全上下文即便严重度相同,也不是重复。节点基准与应用清单更不应因标题都含“权限”而合并。
聚合记录保存 member_finding_ids、dedup_reason、dedup_policy_version 和决策者。拆分同样要可审计。严重度不取平均;优先级由最高可信风险、环境暴露、可利用性、补偿控制和业务关键度计算,原始来源等级完整保留。
正向实验证明幂等与复核
准备一个不含敏感数据的 fixture,连续向测试入口提交两次完全相同的归一记录。预期只有一个 finding、一条活跃工作项,两次 ingestion 分别留下审计记录;第二次更新 last_seen_at,不增加未结数量。随后提交同资产、同规则语义但更新 observed_at 的成功重扫,预期仍是同一 finding 的新证据。
修复 fixture 后提交 result=pass,但只有当资产实例仍匹配、规则版本可比较且证据新于修复部署时,状态才从 fix_pending 进入 verified。预期工作项保留旧失败证据和新通过证据,两者通过 verification_ref 关联。
curl -fsS -X POST https://security-api.example.com/v1/findings:ingest \
-H 'Authorization: Bearer <short-lived-token>' \
-H 'Content-Type: application/json' \
--data-binary @fixtures/finding-fail.json
curl -fsS -X POST https://security-api.example.com/v1/findings:ingest \
-H 'Authorization: Bearer <short-lived-token>' \
-H 'Content-Type: application/json' \
--data-binary @fixtures/finding-fail.json
# 预期:相同 finding_id 只对应一个活跃工作项,重复消息计数增加。令牌使用测试租户的短期身份,fixture 中使用占位 cluster 与 UID。实验完成后撤销令牌、删除测试工作项和 fixture 原始载荷,保留不含资源细节的幂等统计。
反向实验暴露错误合并与陈旧证据
第一组反例只改变 image_digest 或 Kubernetes UID,保持名称与规则相同。预期生成新的实例 finding,并在服务视图下关联;如果系统沿用旧 verified 状态,说明资产键错误。第二组反例保持资产不变但改变 ruleset_digest 和语义映射,预期进入 mapping_pending 或新规则分支,不能直接继承旧例外。
第三组反例把 observed_at 设置为超过有效窗口,或将 collection_state 改为 failed。预期证据状态为 stale/failed,覆盖率下降并触发采集健康告警,未结风险不应自动关闭。第四组反例发送同一消息多次并打乱顺序,预期唯一约束和版本检查拒绝旧状态覆盖新状态。
{
"asset": {
"uid": "<new-resource-uid>",
"image_digest": "sha256:<new-digest>"
},
"rule": { "id": "RULE-012", "ruleset_digest": "sha256:<new-ruleset>" },
"evidence": { "observed_at": "T0", "collection_state": "failed" }
}接口应返回明确的 schema 或映射状态,不能伪造一个具体响应体。验收只判断不变量:新实例不继承关闭态,未知规则不被自动合并,失败采集不计为通过,乱序消息不回退状态。清理时删除测试租户数据并验证生产租户计数不变。
owner 路由要绑定修改权
owner 路由按事实类型和资产归属共同决定。工作负载清单与镜像通常交给服务 owner;namespace 配额、节点基准、CNI 或控制面交给平台 owner;托管控制面不可见项交给 provider 责任记录与内部服务 owner。安全团队拥有规则、例外审批与风险解释,不默认拥有业务仓库和节点配置的修改权。
映射来源按可信度排序:受治理服务目录、namespace/工作负载标签、仓库 CODEOWNERS、平台资源目录,最后才是人工分配。每条映射保存来源与版本。owner 缺失时工单进入 unassigned 队列并计时,不能停止 SLA;长期无法分配本身就是资产治理缺口。
共享平台问题可能影响多个服务,但主工作项应落到能修改平台事实的 owner,受影响服务作为订阅者。反过来,把节点基准任务复制给每个应用团队只会制造重复和无人能修的工单。
SLA 从可行动时刻开始并由证据停止
SLA 模型至少包含 detected_at、triaged_at、assigned_at、due_at、暂停原因和完成证据。高危不一定统一短时限:暴露面、可利用性、业务关键度、维护窗口和补偿控制共同决定 due class。政策存储版本化,工单保存计算时所用政策版本,未来调整不会改写历史承诺。
允许暂停的原因应枚举,例如等待已批准的发布窗口或上游补丁;“等待回复”不能无限暂停。owner 变更时重新路由但不清零年龄。风险接受进入例外状态,计时按政策处理;误报复核则需要规则 owner 给出可重复反证。
SLA 停止条件只有两类:新证据证明同一资产与规则已通过,或可信资产退出证据证明目标不再存在。人工关闭、报告 TTL 清理、扫描器离线和规则改名都不能停止计时。
例外是带失效条件的风险对象
例外 schema 应包含 finding/asset/rule 引用、原因、业务必要性、补偿控制、owner、审批人、创建时间、到期时间、最大续期次数和复核动作。例外范围越小越好:优先绑定实例资产与规则语义版本,不使用整个 namespace 的宽泛通配符。
exception:
id: EXC-<id>
finding_id: sha256:<finding-id>
asset_uid: <resource-uid>
rule_semantic_version: workload.non_root/v3
owner: team-checkout
reason: <business-necessity>
compensating_controls:
- restricted-service-account
- isolated-node-pool
expires_at: T+N
invalidates_on:
- asset_uid_change
- image_digest_change
- ruleset_change
- owner_change例外到期任务使用数据库时间和幂等状态转换,把 exception_active 改为 exception_expired,恢复发现路由并请求重扫。通知只是附加动作,通知失败不能阻止到期。续期创建新审批记录,不改写旧到期时间。补偿控制需要机器证据时,复核器应查询 ServiceAccount、节点选择、NetworkPolicy 或镜像来源的当前状态。
中央例外库应生成各工具需要的配置,生成物带 digest 并进入 Git 评审。禁止业务直接写自由 ignore annotation 后再由中央系统“同步”,否则被检查者同时拥有申请与生效权。
整改先改变权威声明再改变运行态
配置发现的权威修复入口通常是 Git 中的 Helm values、Kustomize patch 或清单。提交经过渲染、静态检查和评审后由发布链进入集群。复核器确认 observed generation、ReplicaSet、Pod template hash、UID 和镜像 digest,确保运行对象真的承载新声明。只修改 live object 会被 GitOps 回滚,只改仓库但不触发滚动则旧 Pod 仍有风险。
镜像漏洞修复需要新 digest,并保存扫描数据库或规则证据;节点基准修复则由平台分批改变 kubelet、运行时或主机配置,复核时绑定具体 node UID 与 target。不同事实可以共享工作流,但修复动作与爆炸半径不能被统一成一条“重新部署”。
自动修复服务拥有写权限时,按 namespace、资源和字段限制 RBAC,设置每批上限、审批门槛与熔断。Kubernetes RBAC 指南可用于设计 patch/update 的最小权限;自动服务不应同时拥有例外审批权。
复核状态机防止人工“点绿”
状态从 observed 到 triaged,再到 assigned 与 fix_pending。部署完成触发 verification_requested,适配器以同一或明确映射的规则对新资产事实重扫。成功证据满足身份、版本与时间不变量后进入 verified;重扫失败进入 verification_failed,回到 owner,而不是关闭。
资产删除时要读取删除事件、Git 变更或服务目录退出记录,形成 retired_with_asset_evidence。同名重建对象拥有新 UID,旧发现可以 retired,新对象重新评估。规则废弃则进入 rule_retired,并记录替代规则,不能冒充风险已修复。
状态更新采用乐观并发或事件序号,拒绝旧消息覆盖新状态。人工可以补充说明、请求复核或申请例外,但不能直接写 verified=true。审计日志保存 actor、旧状态、新状态、policy version 与 evidence ref。
接入消息、存储与工单系统
适配器按源系统独立部署,输出同一版本化 schema。消息至少按 tenant 或 cluster 分区,finding ID 作为幂等键;死信队列保存 schema 错误、未知规则与授权失败。消费者先落原始摘要和来源引用,再更新发现状态,避免工单 API 短暂失败导致证据丢失。
存储层通常分三类:当前发现与工作流使用事务数据库,原始大对象进入受控对象存储,查询与聚合可进入搜索或分析系统。不要把完整报告长期塞进工单描述。工单适配器记录外部 ID 与最后同步版本,双向同步只允许 owner、状态和评论等明确字段,防止外部系统覆盖证据身份。
schema 升级采用双读或双写窗口。先发布能读新旧版本的消费者,再升级生产者,最后停止旧版本。消息积压、重复率、死信率、映射等待数和工单 API 限流是核心指标。
权限与敏感数据不能在归一层扩散
源适配器只读目标报告和必要元数据;中央服务不需要集群管理员权限。registry 凭证留在扫描器侧,归一消息只携带镜像 digest 与脱敏引用。内部仓库、节点路径、软件包清单和漏洞细节按角色授权,高风险利用信息不应默认向所有 namespace 用户开放。
API 使用工作负载身份或短期 token,按 ingest、read、triage、approve-exception、admin 分离权限。例外审批者不能修改原始证据,自动修复者不能批准自己的例外。日志过滤 Authorization、Cookie、Secret 值和完整环境变量;对象存储启用加密、访问审计与生命周期策略。
数据删除也要保持引用一致:原始载荷到期后保留 payload digest、最小字段和删除记录,工单链接显示证据已按策略销毁,而不是变成未知的 404。跨租户查询在存储层强制 tenant 条件,不能只靠前端过滤。
容量、高可用与成本从基数推导
容量模型使用资产实例数乘规则命中率、扫描频率和平均证据大小,再加入发布峰值、规则升级重评、重复消息和失败重试。去重只能降低工作项数量,不能假设会降低原始证据写入。高频运行时事件不应逐条进入工单数据库,先在事件平台聚合成具备时间窗与上下文的发现。
数据库需要唯一键与热点评估;对象存储按保留层级估算;消息系统关注分区倾斜和恢复回放。多副本 API 不能替代幂等消费和状态并发控制。区域或中央平台中断时,适配器应有限缓冲并暴露最老消息年龄;恢复后限速回放,避免同时压垮数据库与工单 API。
成本包括计算、消息吞吐、事务存储、对象存储、搜索索引、跨区传输和工单/SIEM 许可。降低原始保留期可能影响审计,提高聚合粒度可能影响调查。架构选择要让每种节省对应的证据损失可见。
升级、回滚和退出先保护身份连续性
升级 schema、finding ID 算法、规则映射或去重策略前,冻结一组 fixture 与脱敏生产样本,双版本计算并比较 ID 变化、合并/拆分、owner、SLA 和例外继承。新版先旁路写比较表,不直接驱动工单。切换后保留 old-to-new 映射和回滚窗口。
回滚需要旧消费者镜像、schema、规则映射、数据库迁移方案和消息位点。不可逆数据库迁移先做备份恢复演练;若新版已经产生工单,回滚时不能简单删除,应按映射恢复关联并保留审计。规则升级与平台升级分开发布,避免出现无法归因的大规模差异。
退出平台时冻结新例外,导出开放格式的资产键、规则键、发现状态、原始引用、owner、SLA 与未结风险。替代系统双轨消费同一 fixture,证明幂等、陈旧证据、未知规则、例外到期和复核不变量。随后停止生产者、排空消息、关闭工单同步,撤销集群读取身份、API token、对象存储权限与通知 webhook。
最后验证旧 endpoint 和 token 被拒绝,旧队列无生产者与消费者,数据库、对象存储、搜索索引和账单资源按保留策略核销。替代系统必须完成一次从新发现到新证据复核的全链路,退出才算没有留下失控的风险与身份。
用不变量替代“工单变少了”
可持续闭环依赖几条不变量:同一资产与规则语义的重复消息只产生一个活跃 finding;资产实例改变不会继承旧关闭态;报告过期和采集失败永远不等于通过;例外到期会恢复评估;owner 必须拥有修改权;SLA 只由复核证据或资产退出证据停止;规则改名不会制造虚假修复。
把这些不变量写进 fixture、数据库约束、状态机测试和升级比较,比追求一个统一安全分数更可靠。最终看板应同时呈现有效覆盖、采集失败、超龄证据、未分配 owner、逾期工作项、活跃与到期例外以及复核失败。数字可能不漂亮,但它们能让团队知道下一步该修哪一段链路。
