feat(sec): constrain the conformance CI trigger at admission #13

Merged
jakman85 merged 1 commits from feat/conformance-trigger-admission-boundary into main 2026-09-29 18:16:00 +00:00
Owner

Implements the credential model you chose: a narrow trigger SA bounded by a fail-closed
Kyverno admission policy
.

The escalation this closes

Kubernetes places no use check on a Job's serviceAccountName. Anyone who 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, however narrow its own Role looks. RBAC cannot
fix this — only constraining the shape of what the trigger may create.

Verified with kyverno apply (pass 10, fail 6, error 0), not assumed

Case Result
evil-command — right SA, right image, arbitrary command DENIED at /containers/0/command/ ← the attack
evil-image DENIED at /image/
tag-not-digest — mutable tag is republishable DENIED at /image/
host-namespaces DENIED at /hostPID/
extra-volume — the way out of a constrained pod DENIED
injected-args DENIED
good-run ADMITTED

The policy is Enforce + failurePolicy: Fail and deliberately does not use
${KYVERNO_VALIDATION_ACTION} — a boundary an overlay can downgrade to Audit is not a boundary.
It constrains only conformance-trigger, so a mistake here can't wedge the cluster.

⚠️ It 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. I hit this while
writing the policy — a schema error produced exactly that, and ci/l0.sh would have certified
a security policy that does not load as green.

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.
Proven: broken policy → l0 rc=1; restored → rc=0.

c2-root-surface

The new Kustomization reconciles a tree that check doesn't scan, so it failed — correctly. Fixed
with a narrow, self-policing exemption: conformance/incluster is exempt and asserted to
declare no route and no hostname-publishing Service, so the exemption can't rot into a fail-open.

Honest limits

  • Image pinning: the runner image doesn't exist, so this enforces the enforceable half —
    under the conformance repo path and digest-pinned. Residual surface: anyone who can push
    there. Tighten to the literal digest when the image is built.
  • Not wired: no runner image, no trigger kubeconfig, no CLUSTER_AVAILABLE. These identities
    are inert until those exist, and #12 makes CLUSTER_AVAILABLE=true block rather than fake green.

Gate: PASS, rc 0, 76s.

Co-Authored-By: Claude Opus 5 (1M context) [email protected]

🤖 Generated with Claude Code

Implements the credential model you chose: a **narrow trigger SA bounded by a fail-closed Kyverno admission policy**. ### The escalation this closes Kubernetes places **no `use` check** on a Job's `serviceAccountName`. Anyone who 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, however narrow its own Role looks. RBAC cannot fix this — only constraining the *shape* of what the trigger may create. ### Verified with `kyverno apply` (pass 10, fail 6, **error 0**), not assumed | Case | Result | |---|---| | `evil-command` — right SA, right image, arbitrary command | **DENIED** at `/containers/0/command/` ← *the* attack | | `evil-image` | DENIED at `/image/` | | `tag-not-digest` — mutable tag is republishable | DENIED at `/image/` | | `host-namespaces` | DENIED at `/hostPID/` | | `extra-volume` — the way out of a constrained pod | DENIED | | `injected-args` | DENIED | | `good-run` | **ADMITTED** | The policy is `Enforce` + `failurePolicy: Fail` and deliberately does **not** use `${KYVERNO_VALIDATION_ACTION}` — a boundary an overlay can downgrade to Audit is not a boundary. It constrains *only* `conformance-trigger`, so a mistake here can't wedge the cluster. ### ⚠️ It 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. I hit this while writing the policy — a schema error produced exactly that, and `ci/l0.sh` would have certified **a security policy that does not load** as green. 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`. Proven: broken policy → `l0 rc=1`; restored → `rc=0`. ### c2-root-surface The new Kustomization reconciles a tree that check doesn't scan, so it failed — correctly. Fixed with a **narrow, self-policing exemption**: `conformance/incluster` is exempt *and* asserted to declare no route and no hostname-publishing Service, so the exemption can't rot into a fail-open. ### Honest limits - **Image pinning:** the runner image doesn't exist, so this enforces the enforceable half — under the conformance repo path **and** digest-pinned. Residual surface: anyone who can push there. Tighten to the literal digest when the image is built. - **Not wired:** no runner image, no trigger kubeconfig, no `CLUSTER_AVAILABLE`. These identities are inert until those exist, and #12 makes `CLUSTER_AVAILABLE=true` block rather than fake green. Gate: **PASS, rc 0, 76s**. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-09-29 08:24:16 +00:00
feat(sec): constrain the conformance CI trigger at admission (R-SEC-02)
gate / gate (pull_request) Successful in 1m38s
gate / conformance (pull_request) Has been skipped
8eb5baffa4
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]>
jakman85 approved these changes 2026-09-29 18:15:56 +00:00
jakman85 merged commit 4791d4a43a into main 2026-09-29 18:16:00 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#13