# 用 Notary 和 OPA 在 Kubernetes 上使用内容签名

原文：[Ensure Content Trust on Kubernetes using Notary and Open Policy Agent](https://medium.com/@siegert.maximilian/ensure-content-trust-on-kubernetes-using-notary-and-open-policy-agent-485ab3a9423c)

作者：[Daniel Geiger](https://medium.com/u/42543b7496a6?source=post_page-----485ab3a9423c--------------------------------) [Maximilian Siegert](https://medium.com/u/185afb909cc2?source=post_page-----485ab3a9423c--------------------------------)

> 在 Kubernetes 上使用策略对部署行为进行限制，仅允许运行有签名的镜像。

我们希望借助本文，让读者了解到如何在 Kubernetes 中使用可信镜像，其中依赖两个著名的 CNCF 开源项目：Notary 和 OPA。主要思路是使用 OPA 策略来定义自己的内容限制策略。

主要内容如下：

* 完成示例的先决条件
    
* Notary 和镜像信任的基本概念
    
* 在 Kubernetes 上安装 Kubernetes
    
* OPA 和 Admission Control 的基本概念
    
* 在 Kubernetes 上安装 OPA
    
* 定义 Validating Admission Control 控制内容信任
    
* 定义 Mutating Admission Control 完成自动化
    
* 总结和展望
    

如果读者已经熟知 Notary 或者 OPA 的相关内容，可以跳过上述的两节基本概念部分。

## 完成示例的先决条件

如果要遵循后续的安装步骤，需要下列准备：

1. 如果是 Kubernetes 集群，至少启用了 `MutatingAdmissionWebhook` 和`ValidatingAdmissionWebhook`；如果是 Minikube，应该使用如下启动方式：
    
    ```text
    $ minikube start \
    --extra-config=apiserver.enable-admission-plugins=MutatingAdmissionWebhook,ValidatingAdmissionWebhook
    ```
    
2. 私有镜像库，或者一个 Docker Hub ID，用于推送签名镜像。
    
3. 从[我们的 Github 仓库](https://github.com/k8s-gadgets/k8s-content-trust)获取用于安装 OPA、Notary 以及 Notary-Wrapper 的 Helm Chart。
    

## Notary 和镜像信任的基本概念

将代码、可执行文件或者脚本进行签名，保障仅有受信内容才可运行，这是一个已知的最佳实践。软件签名不是什么新概念，有很多相关的供应商和方案，每个组织都有自己的方式来处理制品的签署和信任。然而如果把目光投向容器领域，可能会发现并没有那么多选择。

### Notary 是什么

你可能已经听说过 Notary，这是一个基于 [TUF 项目](https://theupdateframework.github.io/)的用于软件制品签名的开源软件。

### Notary 如何运作

首先说说 Notary 的核心概念。Notary 使用角色和元数据文件对受信集合内容进行签署，这些内容被称为全局唯一名称（GUN——Global Unique Name）。

以 Docker 镜像为例，GUN 相当于 `[registry]/[repository name]:[tag]`。

`[registry]` 是镜像的源仓库，`[repository name]` 是镜像的名称。`[tag]` 对镜像进行标记（通常代表版本）。

Notary 借助 TUF 的角色和密钥层级关系对镜像进行签名。有五种密钥类型用于对元数据文件进行签署，并用 `.json` 的方式保存到 [Notary 数据库](https://docs.docker.com/notary/service_architecture/)。下图描述了密钥层级以及这些密钥的典型存储位置。

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1745913994008/8cefaedf-88f1-42d4-a8b0-12c29d00b38b.png align="center")

1. 根密钥：每个 GUN 都有自己的根角色和密钥。根密钥是所有信任关系的基础，用于对根级元数据文件（其中包含根 ID、目标、快照以及时间戳公钥的 ID）进行签名。通常这个密钥是由（GUN）的属主管理的，并使用离线的方式进行保存（例如在本地目录或者硬件密钥设备）。
    
2. 目标密钥：目标密钥负责签署目标元数据文件，其中包含该集合中的所有文件名、尺寸以及对应的哈希值。这个元数据文件用于对该仓库中的所有实际内容进行完整性验证。这还表示目标元数据文件包含了每个镜像标签的入口。目标密钥可以使用委托角色把信任关系委托给其它的合作者。目标密钥也是属于 GUN 属主的，同样用离线方式保存。
    
3. 委托密钥：如上文所说，目标密钥能够委托给其它角色。这些角色会有自己的密钥来签署被委托的元数据文件，其中同样会包含该集合中的文件名、尺寸以及对应的哈希。委托元数据文件能用于校验仓库中部分或者全部内容的完整性。这些密钥属于这个集合的协作者。
    
4. 快照密钥：快照密钥负责签署快照元数据文件，其中遍历了每个 GUN 的根、目标和委托元数据。这个元数据文件的目标就是验证其它元数据文件的完整性。快照密钥属于协作属主（本地），或者如果 Notary 服务（通过委托角色使用多个协作者）。
    
5. 时间戳密钥：时间戳密钥用于签署时间戳元数据文件，这个密钥的存在目的是保障集合的时效性。这其中包含了元数据的最短过期时间、最近快照的文件名、尺寸以及哈希。这个元数据文件用来检验快照文件的完整性。时间戳密钥由 Notary 服务保存，这样这个密钥就能自动的根据服务器的请求自动重新生成。
    

管理密钥的 Notary 服务架构包括两个组件：

1. Notary 服务器，用来保存和更新信任 GUN 的签署后元数据文件。
    
2. Notary Signer 保存了私钥，用于为 Notary Server 提供元数据签署能力。
    

Docker 文档中[这张 Notary 的示意图](https://docs.docker.com/notary/service_architecture/)很好的概括了客户端与 Notary Server 以及 Signer 之间的通信。下图是一个简化版本：

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1745914004769/aa91a1a0-5c18-43d1-827a-e8e3f440b2fa.png align="center")

1. Notary 服务器可以使用 JWT Token 进行认证。如果没有使用这个功能，可以简单地上传新的元数据文件。如果客户端上传了新的元数据文件，Notary Server 会对老版本进行冲突检测，并对签名、校验和以及元数据的有效性进行检测。
    
2. 上传的元数据通过验证以后，Notary 服务器会生成时间戳元数据，并将元数据发给 Signer 进行签名。
    
3. Notary Signer 从数据库中获取加密的密钥，解密后对元数据进行签署。如果签署成功，则将签名发回给 Notary 服务器。
    
4. Notary Server 是所有受信集合（GUN）真实状态的来源，TUF 数据库中存储了客户端上传和服务器生成的元数据。生成的时间戳和快照元数据证明客户端上传的元数据是该可信集合的最新数据。Notary 服务器会通知客户其上传成功。
    
5. 客户端能够从服务器下载最新的元数据。Notary 服务器从数据库中取出元数据即可。
    

如果时间戳过期，Notary 服务器会重新完成流程，生成新的时间戳，申请 Signer 签名，并在数据库中保存新签署的时间戳。然后发送新的时间戳以及用户请求的其它元数据。

Notary 签署过程看起来很复杂，不过一个好消息就是，Docker 客户端中集成了用 Notary 签署镜像的能力。可以轻松地使用环境变量在本地设备上启用镜像信任机制：

* `DOCKER_CONTENT_TRUST=1`：在客户端启用 Notary
    
* `DOCKER_CONTENT_TRUST_SERVER=”<url-to-your-Notary-server>”`：使用自己的 Notary 服务提供信任关系
    

设置这些之后，Docker 客户端就会在拉取之前检查签名，并在推送之前请求签署凭据来对镜像进行签名。Docker HUB 还提供了自己的缺省 Notary 服务 `https://notary.docker.io`，如果启用了内容信任，会用它对推送镜像进行签署。

如果拉取镜像是有签名的，可以简单的使用 `docker trust inspect <GUN>` 来检查签名情况：

```plaintext
$ docker trust inspect nginx:latest
[
    {
        "Name": "nginx:latest",
        "SignedTags": [
            {
                "SignedTag": "latest",
                "Digest": "b2xxxxxxxxxxxxx4a0395f18b9f7999b768f2",
                "Signers": [
                    "Repo Admin"
                ]
            }
        ],
        "Signers": [],
        "AdministrativeKeys": [
            {
                "Name": "Root",
                "Keys": [
                    {
                        "ID": "d2fxxxxxxx042989d4655a176e8aad40d"
                    }
                ]
            },
            ...
        ]
    }
]
```

除了使用 `docker trust` 之外，也可以下载 [Notary 客户端](https://github.com/theupdateframework/notary/releases)，直接和服务器进行通信。

## 在 Kubernetes 上安装 Notary

到现在我们已经对 Notary 的工作机制有了个初步的认识。我们可以更进一步，在 Kubernetes 上安装自己的 Notary 服务。我们准备了两个 Shell 脚本和 Helm Chart，这样就可以很方便的进行安装了。开始之前请克隆我们的代码仓库：

```plaintext
$ git clone https://github.com/k8s-gadgets/k8s-content-trust
...
```

### 安装

进入 `notary-k8s` 目录。

> 可选项目：构建 Notary 并加入自己的镜像库。 要从头构建最新的 Notary 镜像，需要从 `build` 目录开始。如果要构建和推送 Notary 镜像到你自己的镜像仓库，可以编辑 `build.sh` 文件，编辑 `REGISTRY` 变量，使之匹配自己的镜像库，并执行 `build.sh` 脚本。

```plaintext
$ bash build.sh
...
```

接下来需要进入 `helm/notary` 目录，并生成 TLS 证书，来确保和 Notary 服务通信的安全性：

```plaintext
$ cd helm/notary
...
$ bash generateCerts.sh
...
```

在准备好 Docker 镜像并把 TLS 证书写入 Chart 之后，就可以使用 Helm 在 Kubernetes 上进行部署了。另外也可以看看 `values.yaml` 文件，修改一些必要的参数，例如缺省密码（`passwordalias1Name`、 `passwordalias1Value`）或者私有仓库。

然后就是创建命名空间并安装 Helm Chart：

```plaintext
$ kubectl create namespace notary
# 切换到 notary 命名空间
$ helm install notary notary
```

检查镜像是否已经启动运行：

```plaintext
$ kubectl get pods –n notary
...
```

如果 Pod 已经运行，就表明 Notary 安装成功了。然而在我们试用 Notary 服务之前，我们应该提交最后生成的 Notary Wrapper 模板。

`Notary Wrapper` 是我们写的一个扩展，借助这个扩展，OPA 就能就能和 Notary 服务进行交互了。这是一个 CLI REST 界面，仅实现了获取已签名镜像哈希以及在服务上检查新人数据的功能。

从 `notary-k8s/helm/certs` 复制证书文件到 `helm/notary-wrapper/certs`：

* notary-wrapper.crt
    
* notary-wrapper.key
    
* root-ca.crt
    

进入源码的 `notary-wrapper` 子目录。创建 OPA 命名空间并执行 Helm 安装过程。

```plaintext
$ kubectl create namespace opa
# switch to namespace opa
helm install notary-wrapper notary-wrapper
```

## 测试 Notary

组件安装结束之后，就可以开始用我们的信任数据来测试 Notary 了，下图展示了这个过程：

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1745914025590/695a556b-faad-4e29-89ff-02efaff9d8ad.png align="center")

我们需要签署一些本地镜像作为测试素材，所以首先从 Docker Hub 拉取一些镜像：

> 如果你已经启用了 `DOCKER_CONTENT_TRUST`，并且没有指定 `DOCKER_CONTENT_TRUST_SERVER`，或者指定到了你的新服务器，拉取过程可能会失败。

```plaintext
docker pull nginx:latest
docker pull busybox:latest
```

下一步就要连接我们的 Notary 客户端和服务器了：

1. 把 Notary 服务器加入 `/etc/hosts`：`127.0.0.1 notary-server-svc`
    
2. 在终端中打开第二个 Tab，并为 Notary Server 的 Pod 创建一个端口转发，以便本地使用：`kubectl port-forward notary-server-<...> 4443:4443`
    
3. 第一次要签名之前，要把你的 `root-ca.crt` 从安装目录拷贝到你的 `.docker/tls` 目录：
    
    ```plaintext
    mkdir -p $HOME/.docker/tls/notary-server-svc:4443
    cp <...>/helm/notary/certs/root-ca.crt $HOME/.docker/tls/notary-server-svc:4443/
    ```
    
4. 回到第一个终端 Tab，启用内容信任机制：
    
    ```plaintext
    export DOCKER_CONTENT_TRUST_SERVER=https://notary-server-svc:4443
    export DOCKER_CONTENT_TRUST=1
    ```
    

Notary 已经启动，应该已经无法拉取任何没有被你的 Notary 服务签名的镜像了。不过可以打标签、签名和推送镜像（在我们的例子中，我们会简单的推送到我们自己的 Docker Hub 空间，使用的是我们自己的镜像签名）：

```plaintext
docker tag nginx:latest docker.io/<hub-id>/nginx:1 
docker push docker.io/<hub-id>/nginx:1
docker tag busybox:latest docker.io/<hub-id>busybox:1
docker push docker.io/<hub-id>/busybox:1
```

这个推送命令会提示生成密码，用于请求签名密钥。这些步骤完成后，镜像会被推送到 Docker Hub，信任数据则会保存到 Notary Server。要进行校验，可以使用前面提到的 `docker trust inspect` 命令，如果安装了 Notary 客户端，也可以用 `notary list` 命令。命令执行结果类似：

```plaintext
$ notary -s https://notary-server-svc:4443 --tlscacert $HOME/.docker/tls/notary-server-svc:4443/root-ca.crt list docker.io/<hub-id>/nginx
# output
NAME    DIGEST                                SIZE (BYTES)  ROLE
----    ------                                ------------  ----
1       cccef6d6bdea671c394954b0dxxxxxxxx     948           targets
```

> 如果必须重新部署 Notary，并使用新的密钥进行镜像签署，必须删除之前存储在 `.docker/tls` 目录中保存的密钥。另外还需要删除 `.docker/trust/tuf` 中现存的需要重新签署的镜像的信任数据。

现在可以开始测试 Notary Wrapper。再新开一个终端 Tab，在 /etc/hosts 文件中加入该服务的地址：`127.0.0.1 notary-wrapper-svc`。

保存之后，对端口 4445 进行端口转发：

```plaintext
# switch to namespace opa
kubectl port-forward notary-wrapper-<...> 4445:4445
```

完成后就可以使用两个操作来检查 GUN、Tag 后者哈希的信任数据了，因为我们用的是 TLS 连接，要信任前面生成的根证书：

* 把 GUN 和 Tag 数据提交给 `https://notary-wrapper-svc:4445/list`，获取最新的镜像信任数据，例如：
    
    ```plaintext
    $ curl -X POST https://notary-wrapper-svc:4445/list -H “Content-Type: application/json” -d ‘{“GUN”:”docker.io/<hub-id>/nginx”, “Tag”:”1", “notaryServer”:”notary-server-svc.notary.svc:4443”}’ --cacert PATH/TO/YOUR/NOTARY/certs/root-ca.crt
    # output - One item
    {
        "Name":"1",
        "Digest":"cccef6d6bdexxxxxx422",
        "Size":"948",
        "Role":"targets"
    }
    ```
    
* 把 GUN 和哈希码发送到 `https://notary-wrapper-svc:4445/verify` 验证这个哈希对应的信任数据是否存在（返回码 200 或 404）。如果不知道哈希吗，可以使用 `docker inspect GUN:Tag` 命令查看。
    
    ```plaintext
    $ curl -X POST https://notary-wrapper-svc:4445/verify -H “Content-Type: application/json” -d ‘{“GUN”:”docker.io/<hub-id>/nginx”, “SHA”:”<your-RepoDigest>”, “notaryServer”:”notary-server-svc.notary.svc:4443”}’ --cacert PATH/TO/YOUR/NOTARY/certs/root-ca.crt
    ...
    ```
    

后面会使用 Notary Wrapper 来实现内容信任。完成这个测试之后，就可以关闭端口转发，继续下面的内容了。

### 在 Kubernetes 上实施内容信任

现在我们已经可以签署镜像生成信任数据了，拼图还差最后一块——在 Kubernetes 上实施内容信任策略。这临门一脚的难处在于，Kubernetes 中并没有提供什么开关可以激活内容信任。

又一个可能的方案就是依赖底层的 Docker 引擎，调用镜像验证插件，启用 `DOCKER_CONTENT_TRUST`（可以参考这个 [Issue](https://github.com/kubernetes/kubernetes/issues/30603#issuecomment-430889781)），这种方法有两个弊端：

1. 集群节点需要依赖 Docker 引擎完成信任工作。
    
2. `DOCKER_CONTENT_TRUST` 是个非此即彼的开关，打开之后，无法拉取没有在 Notary 上签名的镜像。
    
3. `DOCKER_CONTENT_TRUST` 只能检查一个镜像是否存在签名元数据，但是并不负责检查该签名是否属于这个 Tag。
    

为了克服几个弊端，我们把注意力放在了 Kubernetes Admission Control 上。

## OPA 和 Admission Control 的基本概念

长话短说。Kubernetes Admission Controller 是一种插件机制，可以用来对集群上的资源进行校验和配置。它的作用包含在 Kubernetes API 请求的生命周期之中，除了内置的 30 个控制器（例如 [PodSecurity Policy](https://kubernetes.io/docs/concepts/policy/pod-security-policy/#enabling-pod-security-policies)）之外，还会有使用自己的控制规则的需要。就可以创建自己的 Validating 或者 Mutating Webhook 了。

* **Mutating**：这种 Webhook 会对请求对象进行变更，来满足特定的配置需求。
    
* **Validating**：它可以对请求对象进行验证，拒绝验证失败的请求。
    

Admission Control 触发的顺序是非常重要的知识点：

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1745914203090/5b1c7761-da17-4770-85b1-01fbd5cbc498.png align="center")

Kubernetes 会首先执行 Mutating 过程，然后才是进行验证。这样就能确保被变更过的请求对象能够正确地被校验。OPA 就是最好的实现 Mutaiting 和 Validating Webhook 的方法之一。

### 什么是 OPA

OPA 是一个通用的策略引擎，它使用一种高级的声明式语言（Rego）编写策略。下图展示了 OPA 集成到 Kubernetes API 生命周期的形式：

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1745914211059/4dca04a5-09ed-45bc-8e0f-4896b7e4b6f2.png align="center")

## 在 Kubernetes 上安装 OPA

我们希望在 Kubernetes 上借助 OPA/Rego 的弹性策略实现内容信任机制。然而在开始之前，首先要在集群上部署 OPA。

假设你已经有了符合条件的集群，在完成命名空间创建和 Notary 步骤之后，就可以开始进入仓库中的 OPA 目录开始安装了。

Kubernetes 和 OPA 之间的通信必须是 TLS 加密的，因此需要给 OPA 创建额外的证书和密钥。

```plaintext
# copy the root-ca
cp ~/PATH/TO/k8-content-trust/notary-k8s/helm/notary/certs/root-ca.crt ~/PATH/TO/k8-content-trust/open-policy-agent/helm/opa/certs
# generate the additional OPA certs 
cd helm/opa
bash generateCerts.sh
```

OPA 在安装后是自动生效的，因此应该排除一些命名空间：

```plaintext
kubectl label ns kube-system openpolicyagent.org/webhook=ignore
kubectl label ns opa openpolicyagent.org/webhook=ignore
kubectl label ns notary openpolicyagent.org/webhook=ignore
```

接下来我们要确认一下 `values.yaml` 中的 `validating` 和 `mutating` 是否已经配置（晚些时候我们会设置 `mutating: true`）：

```yaml
# open-policy-agent/helm/opa/values.yml
...
validating: true
mutating: false
...
```

```plaintext
# switch to namespace opa
helm upgrade --install opa opa
```

在安装结束之后，可以在终端打开一个新 Tab，会看到 OPA 日志中 API Server 的进入请求。

```plaintext
# ctrl-c to exit
kubectl logs -n opa -f opa-deploy-<...> opa
```

## 定义 Validating Admission Control 控制内容信任

总算到了有意思的部分了，开始实现内容信任机制。Notary 和 OPA 都已整装待发，首先我们想拒绝一切不受信任的镜像。要完成这个任务，要先搞清楚 Docker Tag 和哈希之间的关系。

一般来说，我们会使用 GUN 以及标签来部署镜像。然而多数人会忽略一个事实，镜像标签是可以覆盖的，因此它的唯一性是靠不住的。一个集合的所有者能够用同样的 Tag 多次推送变更了的已签署镜像。为了避免这种情况，应该使用唯一摘要进行镜像拉取。

我们定义两条 Rego 规则来完成这个 Webhook：

1. 拒绝只使用普通 Tag (包括 `latest`)的部署。
    
2. 拒绝使用了哈希但是没有被 Notary 签名的镜像。
    

> 已经随 Helm 安装好。

先看看第一条规则（`helm/opa/policy/validating/rules.rego`）

```rego
package policy.validating

operations := {"CREATE", "UPDATE"}

kind := {"Pod", "Deployment"}

# rule to deny digests for pods and deployments
deny[msg] {
  operations[input.request.operation]
  kind[input.request.kind.kind]
  image = get_images[_]
  not contains(image.name, "@sha256:")
  msg := sprintf("%v contains tag; only images with checksum are allowed", [image.name])
}

# rule deny if digest is not in notary
deny[msg] {
  operations[input.request.operation]
  kind[input.request.kind.kind]
  image = get_images[_]
  contains(image.name, "@sha256:")

  # Example to mock digest comparison
  # parts := split_image(image.name)
  # not parts.digest == "@sha256:50"

  get_checksum_status(image.name) != 200
  msg := sprintf("No trust data found for the following image: %v ", [image.name])
}

# helper rules
# get images if pod
get_images[x] {
  input.request.kind.kind == "Pod"
  name := input.request.object.spec.containers[i].image
  x := {
    "index": i,
    "name": name,
  }
}

## get images if deployment
get_images[x] {
  input.request.kind.kind == "Deployment"
  name := input.request.object.spec.template.spec.containers[i].image
  x := {
    "index": i,
    "name": name,
  }
}

# rule to split gun and tag
split_image(image) = x {
  parts := split(image, "@sha256:")
  x := {
    "gun": parts[0],
    "digest": parts[1],
  }
}

# rule to get digest from notary-wrapper
get_checksum_status(image) = status {
  wrapperRootCa := "/etc/certs/notary/root-ca.crt"
  notaryWrapperURL = "https://notary-wrapper-svc.opa.svc:4445/verify"
  parts := split_image(image)
  body := {
    "GUN": parts.gun,
    "SHA": parts.digest,
    "notaryServer": "notary-server-svc.notary.svc:4443",
  }

  headers_json := {"Content-Type": "application/json"}
  output := http.send({"method": "post", "url": notaryWrapperURL, "headers": headers_json, "body": body, "tls_ca_cert_file": wrapperRootCa})
  status := output.status_code
}
```

上面的规则会检查尝试创建或更新 Pod 或者 Deployment 类型的 API 请求。

根据资源类型，`get_image[x]` 规则会确保遍历请求中的所有容器，检查这些容器是否用摘要（例如 `[GUN]@sha256:[digest hash]`）进行拉取。

因此简单的检查一下，镜像是否用了 `@sha256` 就可以了。否则我们会认为此次尝试部署的是一个用 Tag 标识的镜像。如果这一规则被触发，请求就会被阻拦，并得到返回的错误消息。

接下来我们继续定义第二个规则，拒绝没有被 Notary 信任的摘要。

在这个规则里，我们在 `get_checksum_status(image)` 中用了 OPA 中集成的 `http.send` 函数。首先会从请求中获取每个镜像的哈希，然后在 `get_checksum_status(image)` 中发送镜像的 GUN 和摘要到 Notary Wrapper，Notary Wrapper 会检查每个镜像是否都已签名。如果请求返回的不是 200，那么部署动作会被制止。

简单说 `http.send` 函数在目标不可用时不会返回响应（可以参考 OPA 的一个[功能申请](https://github.com/open-policy-agent/opa/issues/2187)）。在我们这里因为有了 Notary Wrapper，只要它正常工作，就不会遇到这个困扰。然而一旦 Notary Wrapper 不可用，OPA 也会故障，会被 `ValidatingWebhookConfiguration` 中的 `failurePolicy: Fail` 定义所捕获。

上面描述的两条规则就足以在 Kubernetes 集群中完成对内容信任的控制了。

要进行测试，只需要简单的部署一个新的 Pod：

```yaml
# trust-pinning-test
apiVersion: v1
kind: Pod
metadata:
  name: trust-pinning-test
  namespace: default
spec:
  containers:
  # trigger rule 1:
  - image: GUN/<hub-id>/nginx:1
  # trigger rule 2:
  # - image: GUN/<hub-id>/nginx@sha256:89cce606b29fb2xxxxx
  # valid deployment:
  # - image: GUN/<hub-id>/nginx@sha256:<your-signed-RepoDigest>
```

另外在 `open-policy-agent/tests` 中还包含了多个针对不同需求的过个测试。

接下来的示意图展示了我们目前的工作成果：

![opa-image-verification](images/opa-image-verification.png align="left")

每次部署都会发出 API 请求，随即开始校验过程：

1. 请求触发了校验 Webhook，发起对 OPA 的调用。
    
2. OPA 会检查镜像的拉取方式，如果使用的是摘要方式，就会向 Notary Wrapper 请求信任数据。Notary Wrapper 则会从 Notary 服务器查询数据，并返回给 OPA，OPA 据此进行决策。如果没有触发规则，Kubernetes 会继续部署。
    
3. 根据哈希从镜像库拉取（本例中是 DockerHub）。
    
4. 部署 Pod。
    

到此为止，我们已经成功的实现了内容信任机制。然而查询 `RepoDigests` 是个很麻烦的事情。如果能基于 Tag 使用内容信任就两全其美了。

## 定义 Mutating Admission Control 完成自动化

Mutating Webhook 是用于在校验之前对请求内容进行变更的，我们接下来会编写这样一个功能。每次用户尝试部署一个带标签的镜像时，就启动 Webhook，自动将镜像引用改为哈希模式。大致工作流程如下：

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1745914230922/af1fc650-174a-4c5a-be67-9ffe7d8bcff7.png align="center")

API 请求流经 Webhook：

1. 如果请求中包含 Pod，操作类型是创建或者更新，并且镜像是用 Tag 标识的，就会触发 OPA 的 Mutating Webhook（在所有的验证之前）。
    
2. OPA 会用 Tag 去检查镜像，接下来 OPA 会为每个标签发起新的 `http.send` 请求到 Notary Wrapper，向 Notary 服务器发起查询。
    
3. 如果 Notary Wrapper 在 Notary 服务器上找到了对应这个标签的条目，就会返回最新的 `RepoDigest` 给 OPA，否则报错。
    
4. OPA 对 Deployment 进行修改，把镜像标签更换为哈希，并把变更后的请求内容发送给 API Server。
    
5. API Server 继续完成创建或更新流程，校验 Webhook 会对请求进行检查，如果请求有效，就用 `RepoDigest` 从可信的仓库拉取镜像，并完成部署。
    

因为我们已经在安装过程中给 OPA 注册了 Mutating Webhook，我们只需要加入新的 Rego 规则就可以了。最简单的方式就是回到本地的 Helm 目录，启用 `mutating`，然后执行 `helm upgrade`：

```yaml
# open-policy-agent/helm/opa/values.yml
...
validating: true
mutating: true
```

```plaintext
# switch to namespace opa
helm upgrade --install opa opa
```

OPA 中的 Mutating Webhook 是 `main` 方法的一部分，这个方法会在 API 请求时发起变更。`helm upgrade` 会加入下面的新规则：

```rego
package policy.mutating

import data.k8s.matches

main = {
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": response,
}

default uid = "missing-uid"

uid = input.request.uid

# default allow without patch
response = r {
  count(patch) == 0
  r := {
    "uid": uid,
    "allowed": true,
  }
}

# response with patch
response = {
  "uid": input.request.uid,
  "allowed": true,
  "patchType": "JSONPatch",
  "patch": patch_bytes,
} {
  count(patch) > 0
  patch_json = json.marshal(patch)
  patch_bytes = base64url.encode(patch_json)
}

# patch
default patch = []

patch = result {
  operations := {"CREATE", "UPDATE"}
  kind := {"Pod", "Deployment"}
  
  
  operations[input.request.operation]
  kind[input.request.kind.kind]

  # construct patch for each image in the container array that requires it.
  result := [p |
    image = get_images[_]
    not contains(image.name, "@sha256:")

    parts := split_image(image.name)

    # format: registry/project@sha256:xxx
    patchedImage := concat("", [parts.gun, "@sha256:", get_digest(image.name)])

    # cconstruct JSON Patch for the deployment.
    # kube-apiserver expects changes to be represented as
    # JSON Patch operation against the resource.
    # the JSON Patch must be JSON serialized and base64 encoded.
    p := {
      "op": "replace",
      "path": get_path(image.index),
      "value": patchedImage,
    }
  ]
}

# helper rules

# rule to compute images set
# the first line ensures that its matched to the right k8s resource
# the second line iterates over each container and extracts the image
get_images[x] {
  input.request.kind.kind == "Pod"
  name := input.request.object.spec.containers[i].image
  x := {
    "index": i,
    "name": name,
  }
}

get_images[x] {
  input.request.kind.kind == "Deployment"
  name := input.request.object.spec.template.spec.containers[i].image
  x := {
    "index": i,
    "name": name,
  }
}

# construct and returns json path for "Pods"
get_path(index) = path {
  input.request.kind.kind == "Pod"
  path := concat("/", ["", "spec", "containers", format_int(index, 10), "image"])
}

# construct and returns json path for "Deployment"
get_path(index) = path {
  input.request.kind.kind == "Deployment"
  path := concat("/", ["", "spec", "template", "spec", "containers", format_int(index, 10), "image"])
}

split_image(image) = x {
  parts := split(image, ":")
  x := {
    "gun": parts[0],
    "tag": parts[1],
  }
}

# helper rule to retrieve the digest from notary using notary-wrapper
get_digest(image) = digest {
  wrapperRootCa := "/etc/certs/notary/root-ca.crt"
  notaryWrapperURL = "https://notary-wrapper-svc.opa.svc:4445/list"
  parts := split_image(image)
  body := {
    "GUN": parts.gun,
    "Tag": parts.tag,
    "notaryServer": "notary-server-svc.notary.svc:4443"
  }

  headers_json := {"Content-Type": "application/json"}
  output := http.send({"method": "post", "url": notaryWrapperURL, "headers": headers_json, "body": body, "tls_ca_cert_file": wrapperRootCa})
  digest := output.body.Digest
}
```

简单说一下这段代码的功能：

1. OPA 会使用 `response` 规则中的代码加入需要的响应。
    
2. 第一个 `response` 针对的是无需变更的请求，允许任意的 API 请求通过。
    
3. 第二个 `response` 会调用 `patch` 规则。
    
4. `patch` 规则会对任何面向 `Pod` 或者 `Deployment` 的 API 请求进行变更。结果参数首先会获取 API 请求中的镜像，检查是否每个镜像都是使用哈希进行拉取的（URL 中包含了 `@shar256:`）。
    
5. 如果不满足上一个条件，就会使用 `split_image` 规则将镜像分为名称和标签两部分。
    
6. `split_image` 返回的是一个数组，`get_digest` 中使用这个数组调用 `http.send` 函数通过 Notary Wrapper 向 Notary 请求哈希。如果 Notary 没有对应的哈希，会得到 404 的返回值。
    
7. Kubernetes 中使用 `.json` 格式的补丁。`.json` 补丁（赋值给 `p`）需要在 `path` 参数中指定的路径上执行 `replace` 操作，从而替换原有的拉取方式。在 Pod 和 Deployment 中，镜像字段的路径是不同的，我们需要创建两个 `get_digest` 和 `get_path` 来应对两种情况。
    
8. OPA 会对补丁进行编码，并返回变更后的 API 请求给 API Server，继续后续操作。
    

如果想要测试这个 Webhook，可以看看 `open-policy-agent/tests`，如果保存了前面的校验 Webhook，可以测试一下有效和无效的 Tag 或者哈希。下表总结了 Webhook 的响应情况：

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1745914243221/b0d31ac8-0f52-46b4-b38d-4867925f420a.png align="center")

## 总结和展望

最终，我们成功地在 Kubernetes 集群上，无需改动部署习惯的情况下，实现了内容信任机制，除了这个，OPA 还能做很多其它的校验工作。

我们知道这篇文章很长，但是我希望尽可能多地为读者提供更多细节。我们认为，虽然有很多的容器扫描和加固方面的技术，镜像签署和信任是目前容器安全方面的最大盲区之一。

下一步需要做点什么呢？还有很多细节我们没能说明：

* **性能**：校验和变更过程的性能测试。
    
* **生产就绪**：提供高可用的 Notary 部署，并把客户端（包括 Docker 客户端）做到硬件安全模块。
    
* **CI-CD 集成**：在 CI/CD 中自动化地进行签名。
    

感谢阅读全文，希望对你有所助益。这里尤其要感谢来自 OPA/Styra 的 Asad、Torin 以及 Jeff，对我们编写的规则作出很多支持。

## 相关链接

* Github 仓库：`https://github.com/k8s-gadgets/k8s-content-trust`
    
* TUF 项目：`https://theupdateframework.github.io/`
    
* Notary 数据库：`https://docs.docker.com/notary/service_architecture/`
    
* Notary 架构：`https://docs.docker.com/notary/service_architecture/`
    
* Notary 客户端：`https://github.com/theupdateframework/notary/releases`
    
* Kubernetes 关于内容信任的讨论：`https://github.com/kubernetes/kubernetes/issues/30603#issuecomment-430889781`
