The registry had two blockers. #59 fixed the first (nodes resolved the hostname to the non-hairpinned WAN and failed TLS against the router's CN=%D cert). This is the second: the pull is unauthenticated, and this registry refuses unauthenticated clients outright — measured 2026-10-05, its anonymous token carries access: null and every path 401s. pm-agent-registry is a dockerconfigjson Secret (SOPS, placeholders) referenced by imagePullSecrets on the dispatcher Deployment and on the committed worker Job template. It holds the SAME Gitea token as pm-agent-credentials, because the two consumers need two formats: the application reads a raw token from env, the kubelet reads a dockerconfigjson before any container starts. Same one-value-in-two-files pattern as the Keycloak client secret, documented in both .example files. The operator's part is the value plus one new scope: read:package. ADMISSION GAINED A SEVENTH RULE. imagePullSecrets is a pod-spec field no container references, which is exactly why it is easy to leave unread — the same shape of omission as initContainers, ephemeralContainers, env and envFrom before it. The kubelet reads the named Secret, in this namespace, before anything runs. Rule 1's pattern now REQUIRES the committed entry; rule 7 forbids any other and any second one. Both halves are needed: a foreach over an absent list yields no result. The leverage is small and the policy says so: the image is digest-pinned, so a different pull credential cannot change WHAT runs. Pinned anyway — 'only the committed shape' is the premise, and the rule costs four lines. kyverno test: 33 passed, 0 failed (was 28). Three new fixtures, each a real path: the committed entry plus a second one, a different Secret, and none at all. The gate also caught me shipping the Secret in plaintext — I never ran sops -e -i on the placeholder. Recorded in the handoff entry, because the file looked finished. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
123 lines
4.4 KiB
YAML
123 lines
4.4 KiB
YAML
# R-SEC-02 — the PM agent dispatcher cannot escalate to arbitrary privileged execution.
|
|
#
|
|
# The dispatcher's one privileged act is `create jobs` in its own namespace, and Kubernetes
|
|
# places no `use` check on serviceAccountName — so without
|
|
# platform/kyverno/admission/pm-agent-job-shape.yaml that single verb IS arbitrary code
|
|
# execution in the namespace, holding a Gitea write token and an Anthropic subscription
|
|
# token. These rows are what prove the policy actually refuses each route to it.
|
|
#
|
|
# user-info.yaml supplies the requesting identity. WITHOUT it the policy's `subjects` match
|
|
# never fires, every case trivially passes, and this file becomes coverage theatre.
|
|
#
|
|
# A NOTE ON WHICH ROWS NAME WHICH FIXTURES: a `foreach` over an absent list produces no
|
|
# result at all, so a fixture with no `volumes` (or no `env`) is NOT reported by the rule
|
|
# that iterates them and must not be listed for it. That is why the volume and env rows name
|
|
# fixtures individually instead of listing everything.
|
|
apiVersion: cli.kyverno.io/v1alpha1
|
|
kind: Test
|
|
metadata:
|
|
name: pm-agent-job-shape
|
|
policies:
|
|
- ../../admission/pm-agent-job-shape.yaml
|
|
resources:
|
|
- resources.yaml
|
|
userinfo: user-info.yaml
|
|
results:
|
|
# ---------------- the committed run is ADMITTED ----------------
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-must-be-the-committed-shape
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-must-be-one-container-with-no-args
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-volumes-must-be-workspace-or-tmp-emptydirs
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-may-not-declare-init-or-ephemeral-containers
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
# Only `real-template` and `injected-env` set env at all, so they are the only fixtures the
|
|
# env rule reports on.
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-env-names-must-be-the-committed-set
|
|
kind: Job
|
|
resources: [real-template]
|
|
result: pass
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-may-not-use-envfrom
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-pull-secret-must-be-the-committed-one
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
|
|
# ---------------- THE escalation, and the shape rule's other refusals ----------------
|
|
# `evil-command` is the case the whole model exists for: right namespace, right creator,
|
|
# arbitrary command. The rest are each a single field away from compliant.
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-must-be-the-committed-shape
|
|
kind: Job
|
|
resources:
|
|
- evil-command
|
|
- evil-image
|
|
- tag-not-digest
|
|
- worker-as-dispatcher-sa
|
|
- token-mounted
|
|
- writable-rootfs
|
|
- host-namespaces
|
|
- restart-onfailure
|
|
- second-container
|
|
- no-pull-secret
|
|
result: fail
|
|
|
|
# ---------------- the pull secret: a second one, or a different one ----------------
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-pull-secret-must-be-the-committed-one
|
|
kind: Job
|
|
resources: [extra-pull-secret, wrong-pull-secret]
|
|
result: fail
|
|
|
|
# ---------------- args, and the second container ----------------
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-must-be-one-container-with-no-args
|
|
kind: Job
|
|
resources: [injected-args, second-container]
|
|
result: fail
|
|
|
|
# ---------------- an extra volume, or an allowed name over a hostPath ----------------
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-volumes-must-be-workspace-or-tmp-emptydirs
|
|
kind: Job
|
|
resources: [extra-volume, hostpath-named-workspace]
|
|
result: fail
|
|
|
|
# ---------------- the same escalation via the other two container lists ----------------
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-may-not-declare-init-or-ephemeral-containers
|
|
kind: Job
|
|
resources: [evil-initcontainer, evil-ephemeral-container]
|
|
result: fail
|
|
|
|
# ---------------- and via env, and via envFrom ----------------
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-env-names-must-be-the-committed-set
|
|
kind: Job
|
|
resources: [injected-env]
|
|
result: fail
|
|
- policy: pm-agent-job-shape
|
|
rule: worker-may-not-use-envfrom
|
|
kind: Job
|
|
resources: [envfrom-whole-secret]
|
|
result: fail
|