Kubernetes Secret 是用于保存工作负载所需机密值的 API 对象,提供专门的类型、消费路径和访问控制目标。但它不会让所有其他参与者都无法读取内容。设计边界时,需要同时考虑谁能取回对象、谁能创建消费者、谁能查看消费者输出,以及谁能管理运行它们的节点。仅区分配置对象与秘密对象,还不足以完成权限设计。

编码与保护处于不同层次

在 JSON API 表示中,Secret 的 data 使用 base64;stringData 则便于在写入时提交字符串。Base64 无需密钥就能还原,并不是加密。这种表示也不能证明某个具体集群怎样保护底层存储。Kubernetes 文档说明,若未配置相应加密,秘密数据默认不在存储层加密。应核对实际 API 服务器和存储设置,不能看到编码文字就推断保密措施已经生效。

存储加密同样不会阻止获得授权的 API 读取者拿到数据。RBAC 中的 get、list 和 watch 权限都可能暴露 Secret 内容,广泛的列表权限并不只是允许查看对象名称。备份和诊断输出也需要保护。正常应用最终仍要获得可用值,因此,一旦有权消费数据的程序被控制,攻击者就已经进入这份数据的信任边界,不能靠编码再次隔离它。

Secret 适合小规模值,单对象有一 MiB 限制。应只挂载需要的键,并只交付给真正需要它的容器。文件模式可以缩小工作负载内部的读取范围,却不能替代 API 授权或对创建工作负载权限的控制。环境变量注入也有暴露范围和生命周期方面的取舍,应根据应用需求选择,不能默认它一定比文件更安全。

坏掉的样子

禁止直接 get Secret,不一定禁止间接取得内容。主体如果可以在命名空间创建 Pod,通常就能在自己提交的规范中指定一个 Secret 卷,由 kubelet 把数据交给该工作负载。创建相应控制器的权限也可能产生类似效果。因此,除了直接读取数据的规则,还必须一起审查创建工作负载的权限及适用的准入限制,否则边界会漏掉实际消费路径。

把全部键挂进去,会扩大进程能够读取的内容集合。通过 volume items 只选一个键,可以限制这一次投射,但有更大创建权限的主体仍可能提交另一份投射定义。不能把一份范围较小的清单,误当成编写清单的人本身也受到同等范围限制。预期消费者是谁,以及谁能定义消费者,是需要分别处理的两个权限问题。

轮换也不只是修改 API 对象。数据交付、应用重新加载、凭据签发和旧值撤销都有各自时序。环境快照与 subPath 挂载不会像普通投射那样自动刷新;文件更新也不会强迫应用丢弃已经缓存的旧值。只有把签发系统与消费者一起验证,才能判断旧凭据是否确实不再被接受,不能仅以对象更新时间作为轮换完成证据。

快速验证

以下实验及观察结果沿用原文记录。本次审阅没有重新执行 Kubernetes 集成实验;文中的成功结果属于历史记录,不代表本轮取得了新的运行证据。

准备可丢弃的 Kubernetes 集群,并使用有测试 impersonation 权限的管理员连接,确保节点已有 python:3.11-slim-bookworm 镜像。保存为 secret-access.py,执行 python3 secret-access.py KUBECONFIG_PATH CONTEXT,明确指定专用配置和上下文。实测版本为 Kubernetes 1.34.11。这里全部数值都是明确的合成示例,输出只报告检查结果,不打印存储内容。

import json
import subprocess
import sys
import time
from uuid import uuid4

assert len(sys.argv) == 3, 'usage: python3 example.py DISPOSABLE_KUBECONFIG CONTEXT'
base = ['kubectl', '--kubeconfig', sys.argv[1], '--context', sys.argv[2], '--request-timeout=30s']
image = 'python:3.11-slim-bookworm'


def k(*args, payload=None, expected=0):
    result = subprocess.run(base + list(args), input=json.dumps(payload) if payload is not None else None,
                            capture_output=True, text=True, timeout=150)
    if expected is not None:
        assert result.returncode == expected, result.stderr
    return result


def obj(namespace, kind, name=None, selector=None):
    args = ['get', kind, '-n', namespace, '-o', 'json']
    if name:
        args.append(name)
    if selector:
        args += ['-l', selector]
    return json.loads(k(*args).stdout)


def apply(namespace, data):
    return k('apply', '-n', namespace, '-f', '-', payload=data)


def wait_for(read, ready, seconds=120):
    until = time.monotonic() + seconds
    while time.monotonic() < until:
        value = read()
        if ready(value):
            return value
        time.sleep(0.5)
    raise AssertionError('expected Kubernetes condition did not arrive')


def pod_ready(pod):
    return any(c['type'] == 'Ready' and c['status'] == 'True'
               for c in pod.get('status', {}).get('conditions', []))


def spec(script='import time; time.sleep(600)'):
    return {'automountServiceAccountToken': False, 'terminationGracePeriodSeconds': 1,
            'containers': [{'name': 'app', 'image': image, 'imagePullPolicy': 'IfNotPresent',
                            'command': ['python3', '-c', script],
                            'resources': {'requests': {'cpu': '10m', 'memory': '24Mi'},
                                          'limits': {'cpu': '200m', 'memory': '96Mi'}}}]}

import base64
namespace = 'notes-secrets-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
    demo = 'DEMO_ONLY_NOT_A_CREDENTIAL'
    k('create', '-n', namespace, '-f', '-', payload={'apiVersion': 'v1', 'kind': 'Secret', 'metadata': {'name': 'example'},
                      'type': 'Opaque', 'stringData': {'needed': demo, 'unused': 'UNUSED_DEMO'}})
    stored = obj(namespace, 'secret', 'example')
    assert base64.b64decode(stored['data']['needed']).decode() == demo
    apply(namespace, {'apiVersion': 'v1', 'kind': 'ServiceAccount', 'metadata': {'name': 'creator'},
                      'automountServiceAccountToken': False})
    apply(namespace, {'apiVersion': 'rbac.authorization.k8s.io/v1', 'kind': 'Role',
        'metadata': {'name': 'pod-creator'},
        'rules': [{'apiGroups': [''], 'resources': ['pods'], 'verbs': ['create', 'get']},
                  {'apiGroups': [''], 'resources': ['pods/log'], 'verbs': ['get']}]})
    apply(namespace, {'apiVersion': 'rbac.authorization.k8s.io/v1', 'kind': 'RoleBinding',
        'metadata': {'name': 'pod-creator'},
        'subjects': [{'kind': 'ServiceAccount', 'name': 'creator', 'namespace': namespace}],
        'roleRef': {'apiGroup': 'rbac.authorization.k8s.io', 'kind': 'Role', 'name': 'pod-creator'}})
    identity = 'system:serviceaccount:' + namespace + ':creator'
    denied = k('--as', identity, 'get', 'secret', 'example', '-n', namespace, expected=None)
    assert denied.returncode != 0 and 'Forbidden' in denied.stderr
    probe = "from pathlib import Path\nimport json,time\nroot=Path('/example')\nprint(json.dumps({'synthetic_value_matches':(root/'one').read_text()=='DEMO_ONLY_NOT_A_CREDENTIAL',\n                  'unused_key_absent':not (root/'unused').exists(),\n                  'service_account_token_absent':not Path('/var/run/secrets/kubernetes.io/serviceaccount/token').exists()}),flush=True)\ntime.sleep(600)"
    podspec = spec(probe)
    podspec['serviceAccountName'] = 'creator'
    podspec['volumes'] = [{'name': 'example', 'secret': {'secretName': 'example',
        'items': [{'key': 'needed', 'path': 'one'}], 'defaultMode': 0o400}}]
    podspec['containers'][0]['volumeMounts'] = [{'name': 'example', 'mountPath': '/example', 'readOnly': True}]
    definition = {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'reader', 'namespace': namespace},
                  'spec': podspec}
    k('--as', identity, 'create', '-f', '-', payload=definition)
    wait_for(lambda: obj(namespace, 'pod', 'reader'), pod_ready)
    output = wait_for(lambda: k('--as', identity, 'logs', '-n', namespace, 'reader').stdout.strip(), bool)
    observed = json.loads(output)
    assert all(observed.values())
    print(json.dumps({'api_base64_decodes_to_synthetic_value': True,
                      'direct_secret_get_forbidden': True, 'same_identity_can_create_consuming_pod': True,
                      'mount_observation': observed, 'etcd_encryption_tested': False}))
finally:
    k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

程序创建包含两个演示键的 Secret,确认 API 中的 base64 可以还原成给定的合成文字。然后为服务账号绑定一个角色,允许创建 Pod 和读取 Pod 日志,却不给读取 Secret 的权限。管理员以这个账号身份发出的直接读取请求会返回 Forbidden,因此这一步实际验证了对应的 API 授权拒绝。

同一个被模拟的主体接着创建 Pod,只投射需要的键。由它指定的工作程序确认:读到预期演示值,未需要的键没有出现,也不存在自动挂载的服务账号令牌。该主体再通过获准的日志接口读取这些布尔结果。原文报告全部检查通过,展示了直接请求被拒绝之后,工作负载路径仍然能够取得指定内容,而不是仅从权限配置进行猜测。

命名空间最后被删除。本实验没有使用真实凭据或实际签发的服务账号令牌,也没有测试底层存储加密和轮换流程。它建立的是授权与投射行为的证据,不是一套完整秘密管理系统。在称某份数据已经严格限定范围之前,应同时评估直接读取者,以及能够创建或修改读取者的主体。

延伸阅读

来源