Merge feature/p0-kyverno into main

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-16 00:08:32 -05:00
commit a778d923b6
28 files changed
+1243 -2

No files matched your search

+98 -2
View File
@@ -1,3 +1,99 @@
# platform/kyverno
# platform/kyverno — L0 policy-as-code gate & admission enforcer (T-0.5)
_Placeholder — populated in Phase 0. See ../home-network-k3s (read-only spec)._
Kyverno is the platform's **L0 static/policy gate** (in CI, no cluster) **and** its
in-cluster **admission enforcer** (ADR-0011, `docs/14`, `docs/07`). The policies here
encode spec invariants as code: they run in CI as a merge gate **today** (Phase 0, no
cluster) and the same policies are wired to run in-cluster as an admission controller
later (Phase 2, `admission/`).
> **Enforcement complements, never replaces, behavior tests.** A policy admitting a
> manifest is not proof the runtime behaves (R-TEST-01). The L2/L3 **Chainsaw**
> behavior + fault-injection tests remain mandatory. See `admission/README.md`.
## Layout
```
platform/kyverno/
├── README.md # this file: policy→requirement map + CI entrypoint
├── labels.md # label/annotation convention (PROPOSED contract — escalated)
├── policies/ # ClusterPolicies (authored once; CI + admission)
│ ├── require-default-deny-baseline.yaml # R-NET-01 (generate baseline)
│ ├── validate-default-deny-shape.yaml # R-NET-01 (reject malformed baseline)
│ ├── disallow-plaintext-secrets.yaml # R-SEC-01 (CI-only: sops block required)
│ ├── restrict-host-namespaces.yaml # R-SEC-02 (+R-NET-01 bypass guard)
│ ├── require-egress-class-label.yaml # R-NET-05
│ └── require-auth-mode-label.yaml # R-IDP-02
├── servicedefinition/ # ServiceDefinition shape validation (kyverno-json)
│ ├── validate-servicedefinition.yaml # R-GITOPS-02
│ ├── payloads/ (valid-* / invalid-*) # pass & fail fixtures
│ └── README.md
├── tests/ # `kyverno test` suite — runnable L0 tests NOW, no cluster
│ ├── kyverno-test.yaml # policies × fixtures × expected results
│ └── resources/ (good-* / bad-* fixtures + generated golden)
├── admission/ # in-cluster enforcing bundle (scaffolding, T-2.7)
│ ├── namespace.yaml · helmrelease.yaml · kustomization.yaml
│ ├── secret-provenance.yaml # R-SEC-01 admission variant (provenance)
│ └── README.md
└── scripts/run-l0.sh # the single-command CI entrypoint for the gate
```
## Policy → requirement map
| Policy / artifact | Rule(s) | Requirement | Spec source | Behaves |
|---|---|---|---|---|
| `require-default-deny-baseline` | `generate-default-deny` | **R-NET-01** | docs/03 default-deny baseline | Every app namespace auto-gets a deny-all (ingress+egress, DNS-only) NetworkPolicy |
| `validate-default-deny-shape` | `baseline-must-deny-both-directions` | **R-NET-01** | docs/03 | A baseline that leaves a direction open is rejected |
| `disallow-plaintext-secrets` | `secrets-must-be-sops-encrypted` | **R-SEC-01** | docs/07, docs/05, ADR-0007 | Committed Secrets must be SOPS-encrypted (`sops` block); plaintext rejected. **CI-only** |
| `restrict-host-namespaces` | `disallow-host-namespaces`, `disallow-privileged-containers` | **R-SEC-02** (+R-NET-01) | docs/07 least reach | App pods can't use hostNetwork/PID/IPC or privileged (would bypass default-deny) |
| `require-egress-class-label` | `require-egress-class` | **R-NET-05** | docs/03, docs/13 | App pods must declare `egress-class` ∈ {internet,vpn} |
| `require-auth-mode-label` | `require-auth-mode` | **R-IDP-02** | docs/13, docs/15 | App pods must declare `auth-mode` ∈ {oidc,forward-auth,native,none} |
| `validate-servicedefinition` (kyverno-json) | shape / dependencies / storage | **R-GITOPS-02** | docs/13 (+03,15) | ServiceDefinition is schema/invariant valid before render; public⇒not `none` auth |
| `admission/secret-provenance` | `secrets-must-be-flux-managed` | **R-SEC-01** | docs/07 | **Admission-only** variant: app-ns Secrets must come from Flux/SOPS (see below) |
Coverage of the T-0.5 requirement set: **R-TEST-01** (behavior-tagged, reproducible
tests — this whole suite), **R-NET-01**, **R-SEC-01**, **R-SEC-02**, **R-IDP-02**,
**R-NET-05**, **R-GITOPS-02**. Every policy carries its `R-*` IDs in the
`homelab.keithjackson.dev/requirements` annotation so the coverage report can pick
them up automatically.
## CI entrypoint (the L0 gate)
CI (WS-GITOPS `.gitea/workflows`, then Gitea Actions) runs the **single command**:
```bash
platform/kyverno/scripts/run-l0.sh
```
which performs, with pinned tool versions (`KYVERNO_VERSION`, `KYVERNO_JSON_VERSION`):
```bash
# 1) ClusterPolicy tests — offline, no cluster
kyverno test platform/kyverno/tests
# 2) ServiceDefinition validation — offline, no cluster
kyverno-json scan --policy platform/kyverno/servicedefinition/validate-servicedefinition.yaml \
--payload <each catalog/ ServiceDefinition and the valid-* fixtures> # must pass
# plus each invalid-* fixture must be REJECTED (script asserts non-zero exit)
```
`kyverno test` emits pass/fail per policy×rule×resource and supports JUnit output for
the coverage/merge gate:
```bash
kyverno test platform/kyverno/tests --output junit > results/kyverno-l0.junit.xml
```
Exit non-zero fails the merge gate (`docs/14` gating rules). Deterministic, hermetic,
self-contained — no cluster, no external state (reproducibility contract, R-TEST-02).
## Notes / handoffs
- **Label keys are a proposed contract** (`labels.md`) shared with WS-NET / WS-SEC /
WS-GITOPS. Values are spec-fixed; keys need owner sign-off (coordination-protocol §2).
- **Secrets CI vs admission divergence** is deliberate and documented in
`admission/README.md` and `policies/disallow-plaintext-secrets.yaml`.
- The ServiceDefinition JSON Schema (WS-GITOPS T-0.3) is the complementary structural
contract; this Kyverno policy encodes the *invariants* from `docs/13`.
- Tool availability: neither `kyverno` nor `kyverno-json` is installed on the WS-TEST
workstation, so this suite was authored but **not executed here**; the CI agent
(T-0.2/T-0.4 harness) runs `scripts/run-l0.sh` as the gate.
+39
View File
@@ -0,0 +1,39 @@
# In-cluster admission enforcement (scaffolding — Phase 2 / T-2.7)
Installs Kyverno and runs the **same L0 policies** as an in-cluster **admission
controller**, so a non-conforming resource that somehow bypassed CI still cannot be
admitted (defense in depth — `docs/07`, ADR-0011). **Not applied in Phase 0** (no
cluster exists yet); this is scaffolding wired up in task **T-2.7**.
## Enforcement COMPLEMENTS, never REPLACES, behavior tests
> A policy admitting a manifest is **not** proof the runtime behaves (`docs/14`
> principle 1, R-TEST-01). Kyverno confirms a resource is *shaped* correctly; it does
> **not** prove default-deny actually blocks a packet, that the VPN kill-switch fails
> closed, or that a PVC survives a rebuild. Those are proven only by the **L2/L3
> Chainsaw** behavior and fault-injection tests (`conformance/`, ADR-0011), which
> remain **mandatory**. Admission is a gate, not a guarantee of behavior.
## Authored once, shared
`kustomization.yaml` references the policy files in `../policies/` **verbatim** — the
identical objects the CI `kyverno test` gate validates — plus the Kyverno install.
No policy is forked between CI and admission, with **one deliberate exception**:
## The one deliberate CI vs admission divergence — Secrets
`../policies/disallow-plaintext-secrets.yaml` (CI) requires a top-level `sops` block
on the Secret manifest. Flux **decrypts SOPS before admission**, so that block is
gone by the time the API server sees the Secret — the CI policy cannot run in-cluster.
The admission equivalent is `secret-provenance.yaml`, which enforces **provenance**
(Secret came from Flux / is an exempt controller Secret) instead of ciphertext. This
is escalated in the T-0.5 report because `docs/07`'s literal wording ("reject a
plaintext Secret at admission") is not achievable post-decrypt.
## Operational note (from ADR-0011)
Admission enforcement adds a failure mode: a bad policy can block legitimate
reconciliation. Mitigations: policies ship with their own `kyverno test` suite (this
directory's `../tests`), roll out via GitOps, and the webhook `failurePolicy: Fail`
is chosen so the system fails **closed** (coordination-protocol.md #8) — validate for
bootstrap deadlocks in dev/test before prod.
@@ -0,0 +1,51 @@
# SCAFFOLDING for a later phase (Phase 2, task T-2.7) -- NOT applied in Phase 0
# (no cluster exists yet). Installs Kyverno so the SAME L0 policies run in-cluster
# as an admission controller (defense in depth per docs/07, ADR-0011). Flux
# HelmRelease skeleton; chart/app versions are pinned and bumped via the update path.
---
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: kyverno
namespace: kyverno
spec:
interval: 24h
url: https://kyverno.github.io/kyverno/
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: kyverno
namespace: kyverno
spec:
interval: 1h
chart:
spec:
chart: kyverno
version: "3.3.x" # pin to the exact chart the operator vendors
sourceRef:
kind: HelmRepository
name: kyverno
namespace: kyverno
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
# HA admission controller so a single pod restart cannot open an enforcement gap.
admissionController:
replicas: 3
backgroundController:
enabled: true # needed for generate (default-deny baseline) + sync
reportsController:
enabled: true
cleanupController:
enabled: true
# Fail-CLOSED: if the webhook is unreachable, reject rather than silently admit
# non-conforming resources (coordination-protocol.md #8). Tune per-resource in
# the policies' webhook config if a bootstrap deadlock appears.
config:
webhooks:
- failurePolicy: Fail
@@ -0,0 +1,18 @@
# In-cluster enforcing bundle (SCAFFOLDING for T-2.7; not applied in Phase 0).
# Installs Kyverno and applies the SAME policies authored for the CI L0 gate as an
# admission controller -- authored once, shared between CLI and admission (ADR-0011).
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: kyverno
resources:
- namespace.yaml
- helmrelease.yaml
# --- policies SHARED verbatim with the CI L0 gate ---
- ../policies/require-default-deny-baseline.yaml
- ../policies/validate-default-deny-shape.yaml
- ../policies/restrict-host-namespaces.yaml
- ../policies/require-egress-class-label.yaml
- ../policies/require-auth-mode-label.yaml
# --- secrets: the admission variant REPLACES the CI-only sops-block policy ---
# (../policies/disallow-plaintext-secrets.yaml is CI-only; see secret-provenance.yaml)
- secret-provenance.yaml
@@ -0,0 +1,9 @@
apiVersion: v1
kind: Namespace
metadata:
name: kyverno
labels:
# Platform namespace -- deliberately NOT an application namespace, so the
# app-scoped policies do not apply to Kyverno's own workloads.
homelab.keithjackson.dev/app-namespace: "false"
pod-security.kubernetes.io/enforce: restricted
@@ -0,0 +1,57 @@
# Serves: R-SEC-01 (in-cluster admission variant)
#
# WHY THIS EXISTS (and diverges from the CI policy):
# The CI policy `disallow-plaintext-secrets` requires a top-level `sops` block on
# the Secret manifest. That works in CI (it scans the encrypted git manifest) but
# is IMPOSSIBLE to satisfy at admission: Flux decrypts SOPS *before* the Secret
# reaches the API server, so an admitted Secret never has a `sops` block. Applying
# the CI policy in-cluster would reject every real Secret.
#
# So the in-cluster invariant is PROVENANCE, not ciphertext: a Secret in an
# application namespace must have been produced by the SOPS/Flux path (it carries
# Flux's kustomize labels) OR be an exempt controller-generated Secret. A Secret
# hand-applied with `kubectl` (the plaintext-into-the-cluster path) has neither and
# is rejected. This is the enforceable reading of docs/07's "reject a plaintext
# Secret at admission" -- flagged as an escalation in the T-0.5 report.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: secret-provenance
annotations:
policies.kyverno.io/title: Require managed provenance on app-namespace Secrets
policies.kyverno.io/category: Secrets
policies.kyverno.io/severity: critical
homelab.keithjackson.dev/requirements: R-SEC-01
homelab.keithjackson.dev/scope: admission-only
spec:
validationFailureAction: Enforce
background: false
rules:
- name: secrets-must-be-flux-managed
match:
any:
- resources:
kinds:
- Secret
namespaceSelector:
matchLabels:
homelab.keithjackson.dev/app-namespace: "true"
# Controller-generated Secrets (SA tokens, cert-manager, Helm) are exempt.
exclude:
any:
- resources:
selector:
matchLabels:
homelab.keithjackson.dev/secret-managed-by: controller
- resources:
selector:
matchExpressions:
- key: kustomize.toolkit.fluxcd.io/name
operator: Exists
validate:
message: >-
Secrets in application namespaces must be delivered by Flux from
SOPS-encrypted git (they carry a kustomize.toolkit.fluxcd.io/name label) or
be exempt controller-generated Secrets. Hand-applied (plaintext) Secrets are
rejected (R-SEC-01, docs/07).
deny: {}
+27
View File
@@ -0,0 +1,27 @@
# Platform label/annotation convention (PROPOSED contract)
These label keys are consumed by the Kyverno policies in this directory. The **values**
are fixed by the spec; the **keys** are a convention this workstream (WS-TEST) proposes
and are a **shared contract** with WS-NET (network/egress rendering), WS-SEC (auth), and
WS-GITOPS (ServiceDefinition → manifest rendering). Per
[`agents/coordination-protocol.md` §2] a contract like this needs the owning roles'
sign-off before it is locked. **Escalated as an open question in the T-0.5 report.**
| Label key | Applied to | Values | Fixed by | Used by policy |
|---|---|---|---|---|
| `homelab.keithjackson.dev/app-namespace` | Namespace | `"true"` | (convention) marks an application namespace / trust zone (docs/03) | all namespace-scoped policies |
| `homelab.keithjackson.dev/egress-class` | Pod (+ controller template) | `internet` \| `vpn` | docs/03, docs/13 | `require-egress-class-label` (R-NET-05); also the selector Cilium Egress Gateway steers on (ADR-0013) |
| `homelab.keithjackson.dev/auth-mode` | Pod (+ controller template) | `oidc` \| `forward-auth` \| `native` \| `none` | docs/13, docs/15 | `require-auth-mode-label` (R-IDP-02) |
| `homelab.keithjackson.dev/baseline` | NetworkPolicy | `default-deny` | (convention) identifies the baseline NP | `require-default-deny-baseline` (generate), `validate-default-deny-shape` |
| `homelab.keithjackson.dev/secret-managed-by` | Secret | `controller` | (convention) exempts controller-generated Secrets | `disallow-plaintext-secrets` (exclude), admission `secret-provenance` |
## Rendering expectation (for WS-GITOPS)
The ServiceDefinition (`docs/13`) declares intent — `egress:` and `auth.mode:` — at the
service level. The renderer (T-0.3 / T-2.3 / T-6.1d) is expected to project those onto
the **pod template labels** above so that (a) Cilium selects pods by `egress-class` and
(b) these admission policies pass. Application namespaces are expected to be created
carrying `app-namespace: "true"` so the baseline is generated and the label/host
policies apply.
[`agents/coordination-protocol.md` §2]: ../../../home-network-k3s/agents/coordination-protocol.md
@@ -0,0 +1,61 @@
# Serves: R-SEC-01 (no plaintext secrets in git; SOPS-encrypted only)
# Spec: docs/07-security-and-secrets.md "Secrets management (R-SEC-01)"; docs/05
# "Secrets in GitOps"; ADR-0007 (SOPS + age).
#
# SCOPE / IMPORTANT NUANCE (see platform/kyverno/README.md and admission/README.md):
# This policy is the CI / L0 gate over the SECRET MANIFESTS COMMITTED TO GIT.
# A SOPS-encrypted Secret file carries a top-level `sops:` block (with `sops.mac`);
# a plaintext Secret does not. We reject any committed Secret lacking that block.
#
# This policy is CI-ONLY. It MUST NOT be applied unchanged in-cluster: Flux
# decrypts SOPS before the object reaches the API server, so an admitted Secret
# never has a `sops:` block -- applying this at admission would reject EVERY real
# Secret. The in-cluster admission variant enforces PROVENANCE instead
# (admission/secret-provenance.yaml). This CI/admission divergence is an
# escalation item (see report) because docs/07's phrase "reject a plaintext
# Secret at admission" is not literally achievable post-decrypt.
#
# "As far as policy can express" (per task): value-level ciphertext and git-history
# leakage are additionally covered by a secret scanner (gitleaks) per docs/07
# acceptance gate "No plaintext secret exists anywhere in git history (scanned)".
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-plaintext-secrets
annotations:
policies.kyverno.io/title: Disallow plaintext Secrets (require SOPS)
policies.kyverno.io/category: Secrets
policies.kyverno.io/severity: critical
homelab.keithjackson.dev/requirements: R-SEC-01
homelab.keithjackson.dev/scope: ci-only
policies.kyverno.io/description: >-
Secret manifests committed to git must be SOPS-encrypted (top-level `sops`
block present). Plaintext Secrets are rejected. CI/L0 gate only -- see the
provenance-based admission variant for in-cluster enforcement.
spec:
validationFailureAction: Enforce
background: false
rules:
- name: secrets-must-be-sops-encrypted
match:
any:
- resources:
kinds:
- Secret
# Controller-generated Secrets (cert-manager, SA tokens, Helm) are never
# committed to git, so the CI gate never sees them; the label keeps the rule
# safe if ever reused where such Secrets exist.
exclude:
any:
- resources:
selector:
matchLabels:
homelab.keithjackson.dev/secret-managed-by: controller
validate:
message: >-
Secrets committed to git must be SOPS-encrypted: the manifest must carry a
top-level `sops` block (with a `mac`). No plaintext secrets (R-SEC-01,
docs/07, ADR-0007).
pattern:
sops:
mac: "?*"
@@ -0,0 +1,48 @@
# Serves: R-IDP-02 (every service authenticates against the IdP; no unauthenticated UI)
# Spec: docs/15-identity-and-access.md; docs/13 `auth.mode: oidc|forward-auth|native|none`.
#
# Every workload must declare which authentication mode it uses so the platform can
# wire the correct integration (native OIDC Keycloak client, oauth2-proxy forward-auth
# at the Gateway, a documented `native` exception e.g. Plex, or `none`). A missing
# auth declaration would let a bare management UI onto the network unprotected, which
# docs/15 forbids ("no management UI sits unauthenticated").
#
# Matches Pods; pod-controller autogen extends to Deployment/StatefulSet/etc.
# LABEL KEY is a PROPOSED contract owned jointly with WS-SEC/WS-GITOPS -- see
# platform/kyverno/labels.md. The VALUES are fixed by docs/13.
# NOTE: `none` is permitted by the enum (docs/13) but is "discouraged / internal-only";
# the ServiceDefinition-level policy additionally forbids `public` + `none`.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-auth-mode-label
annotations:
policies.kyverno.io/title: Require auth-mode label on workloads
policies.kyverno.io/category: Identity
policies.kyverno.io/severity: medium
homelab.keithjackson.dev/requirements: R-IDP-02
policies.kyverno.io/description: >-
Pods in application namespaces must declare
homelab.keithjackson.dev/auth-mode = oidc | forward-auth | native | none.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: require-auth-mode
match:
any:
- resources:
kinds:
- Pod
namespaceSelector:
matchLabels:
homelab.keithjackson.dev/app-namespace: "true"
validate:
message: >-
Pods in application namespaces must carry the label
homelab.keithjackson.dev/auth-mode set to one of
oidc | forward-auth | native | none (R-IDP-02, docs/15).
pattern:
metadata:
labels:
homelab.keithjackson.dev/auth-mode: "oidc | forward-auth | native | none"
@@ -0,0 +1,66 @@
# Serves: R-NET-01 (default-deny baseline present in every application namespace)
# Spec: docs/03-networking-isolation-and-egress.md "Default-deny baseline (R-NET-01)".
#
# GUARANTEE (not merely a check): whenever an application namespace is created,
# Kyverno GENERATES the deny-all (ingress+egress) baseline NetworkPolicy, allowing
# only DNS egress. A workload with no other allow rules can then do nothing but
# resolve DNS -- the intended starting posture (docs/03).
#
# This is the primary R-NET-01 control. The companion policy
# `validate-default-deny-shape` additionally rejects a MALFORMED hand-written
# baseline (defense in depth). Per-service `dependencies`/egress allows are layered
# on top by WS-NET's rendered CiliumNetworkPolicies (T-2.3), never by weakening this.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-default-deny-baseline
annotations:
policies.kyverno.io/title: Require default-deny NetworkPolicy baseline
policies.kyverno.io/category: Network Isolation
policies.kyverno.io/severity: high
homelab.keithjackson.dev/requirements: R-NET-01
policies.kyverno.io/description: >-
Every application namespace must carry a deny-all (ingress+egress) baseline
NetworkPolicy that permits only DNS egress. Generated automatically on
namespace creation so the baseline can never be forgotten.
spec:
# Also backfill namespaces that already exist when the policy lands.
generateExisting: true
rules:
- name: generate-default-deny
match:
any:
- resources:
kinds:
- Namespace
selector:
matchLabels:
homelab.keithjackson.dev/app-namespace: "true"
generate:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
name: default-deny
namespace: "{{ request.object.metadata.name }}"
synchronize: true
data:
metadata:
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
# Deny all ingress.
ingress: []
# Deny all egress EXCEPT DNS to the in-cluster resolver (kube-system).
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
@@ -0,0 +1,48 @@
# Serves: R-NET-05 (egress is a declared property; every workload declares its class)
# Spec: docs/03 "Egress is a declared property"; docs/13 `egress: internet | vpn`.
#
# The egress class is enforced OUTSIDE the service (Cilium Egress Gateway selects
# pods by label -> steers `vpn` pods through the shared PIA/Gluetun gateway, ADR-0013).
# For that steering to be possible the label MUST exist on the pod and be one of the
# two valid classes. A missing/typo'd class would silently fall back to the plain
# internet path -- a fail-OPEN egress leak -- so we require it explicitly (fail-closed
# posture, coordination-protocol.md #8).
#
# Matches Pods; pod-controller autogen extends to Deployment/StatefulSet/etc.
# LABEL KEY is a PROPOSED contract owned jointly with WS-NET/WS-GITOPS -- see
# platform/kyverno/labels.md and the report's escalation note. The VALUES
# (internet|vpn) are fixed by docs/03 + docs/13.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-egress-class-label
annotations:
policies.kyverno.io/title: Require egress-class label on workloads
policies.kyverno.io/category: Network Isolation
policies.kyverno.io/severity: high
homelab.keithjackson.dev/requirements: R-NET-05
policies.kyverno.io/description: >-
Pods in application namespaces must declare
homelab.keithjackson.dev/egress-class = internet | vpn.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: require-egress-class
match:
any:
- resources:
kinds:
- Pod
namespaceSelector:
matchLabels:
homelab.keithjackson.dev/app-namespace: "true"
validate:
message: >-
Pods in application namespaces must carry the label
homelab.keithjackson.dev/egress-class set to 'internet' or 'vpn' (R-NET-05,
docs/03).
pattern:
metadata:
labels:
homelab.keithjackson.dev/egress-class: "internet | vpn"
@@ -0,0 +1,75 @@
# Serves: R-SEC-02 (least network reach / least privilege), supports R-NET-01.
# Spec: docs/07 "Identity/least privilege ... least network reach (R-SEC-02)";
# docs/03 default-deny.
#
# WHY: a pod on hostNetwork bypasses the CNI (Cilium) datapath and therefore
# ESCAPES the default-deny NetworkPolicy fabric -- it is the most direct way to
# defeat R-NET-01. hostPID/hostIPC and privileged containers likewise break
# workload isolation. Application workloads never legitimately need these
# (platform components that do -- e.g. Cilium, node-exporter -- live in platform
# namespaces that do NOT carry the app-namespace label, so they are out of scope).
#
# Matches Pods; Kyverno pod-controller autogen extends the same checks to
# Deployment/StatefulSet/DaemonSet/Job/CronJob pod templates automatically.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: restrict-host-namespaces
annotations:
policies.kyverno.io/title: Restrict host namespaces and privilege in app namespaces
policies.kyverno.io/category: Least Privilege
policies.kyverno.io/severity: high
homelab.keithjackson.dev/requirements: R-SEC-02,R-NET-01
policies.kyverno.io/description: >-
Application-namespace pods must not use hostNetwork/hostPID/hostIPC (which
bypass default-deny) or run privileged containers.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: disallow-host-namespaces
match:
any:
- resources:
kinds:
- Pod
namespaceSelector:
matchLabels:
homelab.keithjackson.dev/app-namespace: "true"
validate:
message: >-
Application workloads must not set hostNetwork/hostPID/hostIPC -- host
networking bypasses the default-deny NetworkPolicy fabric (R-SEC-02,
R-NET-01).
deny:
conditions:
any:
- key: "{{ request.object.spec.hostNetwork || `false` }}"
operator: Equals
value: true
- key: "{{ request.object.spec.hostPID || `false` }}"
operator: Equals
value: true
- key: "{{ request.object.spec.hostIPC || `false` }}"
operator: Equals
value: true
- name: disallow-privileged-containers
match:
any:
- resources:
kinds:
- Pod
namespaceSelector:
matchLabels:
homelab.keithjackson.dev/app-namespace: "true"
validate:
message: >-
Application containers must not run privileged (R-SEC-02).
foreach:
- list: "request.object.spec.containers"
deny:
conditions:
any:
- key: "{{ element.securityContext.privileged || `false` }}"
operator: Equals
value: true
@@ -0,0 +1,43 @@
# Serves: R-NET-01 (baseline must actually deny both directions)
# Spec: docs/03 "Deny all ingress and deny all egress by default".
#
# Catches a MALFORMED baseline: a NetworkPolicy claiming to be the default-deny
# baseline (label homelab.keithjackson.dev/baseline=default-deny) that does not
# actually cover BOTH Ingress and Egress is rejected. This is the "test the
# behavior, not the existence" guard for R-NET-01 at the manifest layer -- an
# object existing is not proof it denies (docs/14 principle 1).
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: validate-default-deny-shape
annotations:
policies.kyverno.io/title: Validate default-deny baseline shape
policies.kyverno.io/category: Network Isolation
policies.kyverno.io/severity: high
homelab.keithjackson.dev/requirements: R-NET-01
policies.kyverno.io/description: >-
A NetworkPolicy labelled as the default-deny baseline must set policyTypes
to include both Ingress and Egress. Rejects a baseline that silently leaves
a direction open.
spec:
validationFailureAction: Enforce
background: true
rules:
- name: baseline-must-deny-both-directions
match:
any:
- resources:
kinds:
- NetworkPolicy
selector:
matchLabels:
homelab.keithjackson.dev/baseline: default-deny
validate:
message: >-
A default-deny baseline NetworkPolicy must set spec.policyTypes to include
BOTH Ingress and Egress (R-NET-01, docs/03).
pattern:
spec:
policyTypes:
- Ingress
- Egress
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# L0 policy-as-code gate (T-0.5) -- the single command CI runs. No cluster required.
#
# Verifies:
# 1. Kyverno ClusterPolicies vs pass/fail resource fixtures (kyverno test)
# 2. ServiceDefinition shape vs valid/invalid payloads (kyverno-json scan)
#
# Version-pin the tools for reproducibility (docs/14, ADR-0011). Override via env.
set -euo pipefail
# --- pinned tool versions (bump via the normal update path; suite re-green is the gate) ---
KYVERNO_VERSION="${KYVERNO_VERSION:-v1.13.4}"
KYVERNO_JSON_VERSION="${KYVERNO_JSON_VERSION:-v0.0.3}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE/.."
echo "== L0 gate: Kyverno CLI $KYVERNO_VERSION / kyverno-json $KYVERNO_JSON_VERSION =="
echo "-- [1/2] ClusterPolicy tests (kyverno test) --"
kyverno test ./tests
echo "-- [2/2] ServiceDefinition validation (kyverno-json scan) --"
kyverno-json scan \
--policy servicedefinition/validate-servicedefinition.yaml \
--payload servicedefinition/payloads/valid-plex.yaml \
--payload servicedefinition/payloads/valid-sonarr.yaml
fail=0
for f in servicedefinition/payloads/invalid-*.yaml; do
if kyverno-json scan \
--policy servicedefinition/validate-servicedefinition.yaml \
--payload "$f" >/dev/null 2>&1; then
echo "FAIL: expected $f to be REJECTED but it passed"
fail=1
else
echo "ok (rejected as expected): $f"
fi
done
[ "$fail" -eq 0 ] || { echo "L0 gate FAILED"; exit 1; }
echo "== L0 gate PASSED =="
@@ -0,0 +1,58 @@
# ServiceDefinition L0 validation (R-GITOPS-02)
Validates the shape of each `catalog/` **ServiceDefinition** against the canonical
schema and invariants in the read-only spec `docs/13-service-catalog.md` (with
`auth` from `docs/15`, `egress` from `docs/03`). Runs in CI **without a cluster**.
## Why kyverno-json (not the resource CLI)
A ServiceDefinition is **arbitrary YAML** — its own `kind:` field means
`container | vm`, not a Kubernetes Kind — so it cannot be validated by
`kyverno apply`/`kyverno test` (which expect Kubernetes resources). Per
[ADR-0011] the Kyverno-ecosystem tool for arbitrary YAML/JSON is **kyverno-json**.
## Invariants enforced (`validate-servicedefinition.yaml`)
- `name` is a DNS-1123 label (→ `<name>.home.keithjackson.dev`).
- `kind` ∈ {`container`, `vm`}; container ⇒ `source.image`, vm ⇒ `source.vmTemplate`.
- `exposure` ∈ {`internal`, `public`}.
- `egress` ∈ {`internet`, `vpn`}.
- `auth.mode` ∈ {`oidc`, `forward-auth`, `native`, `none`}.
- **`public` exposure ⇒ `auth.mode` ≠ `none`** (no unauthenticated public UI, docs/15).
- each `dependencies[]` has `to`, `port` ∈ 1–65535, `protocol` ∈ {`tcp`, `udp`}.
- each `storage[].class` ∈ {`longhorn`, `bulk`}.
## Fixtures
`payloads/valid-*.yaml` must PASS; `payloads/invalid-*.yaml` must FAIL — one failure
reason each (bad egress, public+none auth, missing image, bad dependency).
## Run (CI entrypoint)
```bash
# Valid payloads: scan must exit 0
kyverno-json scan \
--policy validate-servicedefinition.yaml \
--payload payloads/valid-plex.yaml \
--payload payloads/valid-sonarr.yaml
# Invalid payloads: each scan must exit NON-zero (loop asserts failure)
for f in payloads/invalid-*.yaml; do
if kyverno-json scan --policy validate-servicedefinition.yaml --payload "$f"; then
echo "FAIL: expected $f to be rejected"; exit 1
fi
done
```
The `scripts/run-l0.sh` wrapper (see `../README.md`) runs both halves and is what CI
invokes.
## Caveat (escalated in the T-0.5 report)
kyverno-json is **pre-1.0**; its assertion syntax evolves. The **invariants above are
the normative content**. The CI agent must pin an exact kyverno-json version and
confirm the `check:` expressions parse under it; adjust syntax (never the invariants)
if the pinned release differs. The overlapping JSON Schema from WS-GITOPS (T-0.3) is
the complementary structural contract.
[ADR-0011]: ../../../../home-network-k3s/decisions/adr-0011-test-tooling-kyverno-chainsaw.md
@@ -0,0 +1,14 @@
# INVALID: a dependency uses protocol 'sctp' (not tcp|udp) and an out-of-range port
# (docs/13 dependencies shape). Rejected.
name: baddep
kind: container
source:
image: registry.example/app
exposure: internal
egress: internet
dependencies:
- to: plex
port: 70000
protocol: sctp
auth:
mode: forward-auth
@@ -0,0 +1,9 @@
# INVALID: egress class 'wan' is not in {internet, vpn} (docs/03, docs/13). Rejected.
name: badegress
kind: container
source:
image: registry.example/app
exposure: internal
egress: wan
auth:
mode: forward-auth
@@ -0,0 +1,9 @@
# INVALID: kind 'container' but no source.image (docs/13 requires an image ref for
# container kind). Rejected.
name: noimage
kind: container
source: {}
exposure: internal
egress: internet
auth:
mode: forward-auth
@@ -0,0 +1,10 @@
# INVALID: public exposure with auth.mode 'none' -> an unauthenticated public UI,
# which docs/15 forbids ("no management UI sits unauthenticated"). Rejected.
name: nakedui
kind: container
source:
image: registry.example/ui
exposure: public
egress: internet
auth:
mode: none
@@ -0,0 +1,22 @@
# VALID ServiceDefinition (R-GITOPS-02). Modelled on catalog service #1 (plex,
# docs/13): public, internet egress, native auth (Plex), longhorn + bulk storage.
name: plex
kind: container
source:
image: plexinc/pms-docker
exposure: public
egress: internet
storage:
- name: config
size: 10Gi
class: longhorn
- name: media
class: bulk
auth:
mode: native
update:
channel: stable
cadence: weekly
autoRollbackOnFailedHealth: true
observability:
metrics: true
@@ -0,0 +1,29 @@
# VALID ServiceDefinition (R-GITOPS-02). Modelled on catalog service #2 (sonarr,
# docs/13): internal, VPN egress + kill-switch, forward-auth, explicit dependencies.
name: sonarr
kind: container
source:
image: lscr.io/linuxserver/sonarr
exposure: internal
egress: vpn
storage:
- name: config
size: 5Gi
class: longhorn
- name: downloads
class: bulk
dependencies:
- to: qbittorrent
port: 8080
protocol: tcp
- to: jackett
port: 9117
protocol: tcp
auth:
mode: forward-auth
groups:
- media-users
update:
channel: stable
cadence: weekly
autoRollbackOnFailedHealth: true
@@ -0,0 +1,65 @@
# Serves: R-GITOPS-02 (the ServiceDefinition is the onboarding contract; it must be
# shape-valid before it can be rendered).
# Spec: docs/13-service-catalog.md "Fields (canonical schema)" + "Default posture";
# docs/15 (auth); docs/03 (egress).
#
# ServiceDefinitions are ARBITRARY YAML (their own `kind:` field is container|vm, NOT
# a Kubernetes Kind), so they are validated with kyverno-json (per ADR-0011:
# "kyverno-json for arbitrary YAML/JSON like ServiceDefinitions"), not the resource
# CLI. This policy encodes the docs/13 invariants only; the full JSON Schema +
# renderer contract is WS-GITOPS T-0.3 and is complementary, not a substitute.
#
# NOTE (see servicedefinition/README.md): kyverno-json is pre-1.0. The INVARIANTS
# below are the normative content; the CI agent must confirm the assertion syntax
# against the pinned kyverno-json release.
apiVersion: json.kyverno.io/v1alpha1
kind: ValidatingPolicy
metadata:
name: validate-servicedefinition
annotations:
homelab.keithjackson.dev/requirements: R-GITOPS-02
spec:
rules:
- name: servicedefinition-shape
assert:
all:
- message: "`name` must be a DNS-1123 label"
check:
(regex_match('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', name)): true
- message: "`kind` must be 'container' or 'vm'"
check:
(kind == 'container' || kind == 'vm'): true
- message: "`exposure` must be 'internal' or 'public'"
check:
(exposure == 'internal' || exposure == 'public'): true
- message: "`egress` must be 'internet' or 'vpn'"
check:
(egress == 'internet' || egress == 'vpn'): true
- message: "container kind requires `source.image`"
check:
(kind != 'container' || source.image != null): true
- message: "vm kind requires `source.vmTemplate`"
check:
(kind != 'vm' || source.vmTemplate != null): true
- message: "`auth.mode` must be one of oidc|forward-auth|native|none"
check:
(auth.mode == 'oidc' || auth.mode == 'forward-auth' || auth.mode == 'native' || auth.mode == 'none'): true
- message: "public exposure must not use auth.mode 'none' -- no unauthenticated public UI (docs/15, R-IDP-02)"
check:
(exposure != 'public' || auth.mode != 'none'): true
- name: servicedefinition-dependencies
assert:
all:
- message: "each dependency needs `to`, `port` (1-65535) and `protocol` tcp|udp"
check:
~.dependencies:
(to != null): true
(port >= `1` && port <= `65535`): true
(protocol == 'tcp' || protocol == 'udp'): true
- name: servicedefinition-storage
assert:
all:
- message: "each storage entry's `class` must be 'longhorn' or 'bulk'"
check:
~.storage:
(class == 'longhorn' || class == 'bulk'): true
+126
View File
@@ -0,0 +1,126 @@
# L0 policy-as-code test suite -- runs in CI WITHOUT a cluster.
# Run from this directory: kyverno test .
# Each result row is tagged (in comments) with the requirement ID it proves.
# Deterministic, hermetic, reproducible (docs/14 reproducibility contract).
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
name: platform-kyverno-l0
policies:
- ../policies/require-default-deny-baseline.yaml
- ../policies/validate-default-deny-shape.yaml
- ../policies/disallow-plaintext-secrets.yaml
- ../policies/restrict-host-namespaces.yaml
- ../policies/require-egress-class-label.yaml
- ../policies/require-auth-mode-label.yaml
resources:
- resources/namespaces.yaml
- resources/workloads.yaml
- resources/secrets.yaml
- resources/networkpolicies.yaml
results:
# ---- R-NET-01: default-deny baseline is GENERATED for an app namespace ----
- policy: require-default-deny-baseline
rule: generate-default-deny
kind: Namespace
resources:
- app-ns
generatedResource: resources/generated-default-deny.yaml
result: pass
# ---- R-NET-01: a malformed baseline (egress left open) is rejected ----
- policy: validate-default-deny-shape
rule: baseline-must-deny-both-directions
kind: NetworkPolicy
resources:
- good-baseline-np
result: pass
- policy: validate-default-deny-shape
rule: baseline-must-deny-both-directions
kind: NetworkPolicy
resources:
- bad-baseline-np
result: fail
# ---- R-SEC-01: plaintext Secret rejected; SOPS-encrypted Secret admitted ----
- policy: disallow-plaintext-secrets
rule: secrets-must-be-sops-encrypted
kind: Secret
resources:
- good-secret-sops
result: pass
- policy: disallow-plaintext-secrets
rule: secrets-must-be-sops-encrypted
kind: Secret
resources:
- bad-secret-plain
result: fail
# ---- R-SEC-02: hostNetwork/hostPID/hostIPC rejected in app namespaces ----
- policy: restrict-host-namespaces
rule: disallow-host-namespaces
kind: Pod
resources:
- good-workload
result: pass
- policy: restrict-host-namespaces
rule: disallow-host-namespaces
kind: Pod
resources:
- bad-hostnetwork
result: fail
- policy: restrict-host-namespaces
rule: disallow-host-namespaces
kind: Pod
resources:
- platform-workload # non-app namespace -> out of scope
result: skip
# ---- R-SEC-02: privileged container rejected ----
- policy: restrict-host-namespaces
rule: disallow-privileged-containers
kind: Pod
resources:
- good-workload
result: pass
- policy: restrict-host-namespaces
rule: disallow-privileged-containers
kind: Pod
resources:
- bad-privileged
result: fail
# ---- R-NET-05: egress-class label required and enum-checked ----
- policy: require-egress-class-label
rule: require-egress-class
kind: Pod
resources:
- good-workload
result: pass
- policy: require-egress-class-label
rule: require-egress-class
kind: Pod
resources:
- bad-egress-missing
- bad-egress-invalid
result: fail
- policy: require-egress-class-label
rule: require-egress-class
kind: Pod
resources:
- platform-workload # non-app namespace -> out of scope
result: skip
# ---- R-IDP-02: auth-mode label required and enum-checked ----
- policy: require-auth-mode-label
rule: require-auth-mode
kind: Pod
resources:
- good-workload
result: pass
- policy: require-auth-mode-label
rule: require-auth-mode
kind: Pod
resources:
- bad-auth-missing
- bad-auth-invalid
result: fail
@@ -0,0 +1,26 @@
# Golden expected output of the require-default-deny-baseline generate rule when the
# `app-ns` Namespace fixture is created. `kyverno test` compares the generated
# NetworkPolicy against this file.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: app-ns
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
@@ -0,0 +1,16 @@
# Namespace fixtures. `app-ns` carries the application-namespace marker so the
# namespaceSelector-scoped policies apply; `platform-ns` does not, proving that
# platform/system namespaces are correctly OUT of scope.
apiVersion: v1
kind: Namespace
metadata:
name: app-ns
labels:
homelab.keithjackson.dev/app-namespace: "true"
---
apiVersion: v1
kind: Namespace
metadata:
name: platform-ns
labels:
kubernetes.io/metadata.name: platform-ns
@@ -0,0 +1,40 @@
# NetworkPolicy fixtures for validate-default-deny-shape (R-NET-01).
# The "good" baseline denies both directions; the "bad" baseline is labelled as the
# baseline but only covers Ingress -- leaving egress open -- and must be rejected.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: good-baseline-np
namespace: app-ns
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: bad-baseline-np
namespace: app-ns
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
# Missing Egress -> egress is left wide open despite claiming to be the baseline.
policyTypes:
- Ingress
ingress: []
@@ -0,0 +1,25 @@
# Secret fixtures for disallow-plaintext-secrets (R-SEC-01).
# The "good" secret is shaped like a SOPS-encrypted manifest: values are ENC[...]
# ciphertext and a top-level `sops` block (with a mac) is present. The "bad" secret
# is plaintext with no `sops` block. NOTE: the ENC[...] strings below are fixtures,
# not real ciphertext -- no plaintext secret material is present (docs/07 rule).
apiVersion: v1
kind: Secret
metadata:
name: good-secret-sops
namespace: app-ns
type: Opaque
data:
password: ENC[AES256_GCM,data:Zm9vYmFy,iv:AAAA,tag:BBBB,type:str]
sops:
mac: ENC[AES256_GCM,data:CCCC,iv:DDDD,tag:EEEE,type:str]
version: 3.8.1
---
apiVersion: v1
kind: Secret
metadata:
name: bad-secret-plain
namespace: app-ns
type: Opaque
stringData:
password: hunter2-this-would-be-plaintext
@@ -0,0 +1,112 @@
# Pod fixtures for the label + host-namespace policies. Pods (not Deployments) are
# used so `kyverno test` result rows reference the base rule names directly; the
# same rules reach Deployment/StatefulSet pod templates via pod-controller autogen
# in CI and in-cluster.
#
# ---- COMPLIANT ----
apiVersion: v1
kind: Pod
metadata:
name: good-workload
namespace: app-ns
labels:
homelab.keithjackson.dev/egress-class: internet
homelab.keithjackson.dev/auth-mode: oidc
spec:
containers:
- name: app
image: registry.example/app:1.0.0
---
# ---- egress-class violations (R-NET-05) ----
apiVersion: v1
kind: Pod
metadata:
name: bad-egress-missing # no egress-class label at all
namespace: app-ns
labels:
homelab.keithjackson.dev/auth-mode: oidc
spec:
containers:
- name: app
image: registry.example/app:1.0.0
---
apiVersion: v1
kind: Pod
metadata:
name: bad-egress-invalid # egress-class not in {internet,vpn}
namespace: app-ns
labels:
homelab.keithjackson.dev/egress-class: wan
homelab.keithjackson.dev/auth-mode: oidc
spec:
containers:
- name: app
image: registry.example/app:1.0.0
---
# ---- auth-mode violations (R-IDP-02) ----
apiVersion: v1
kind: Pod
metadata:
name: bad-auth-missing # no auth-mode label
namespace: app-ns
labels:
homelab.keithjackson.dev/egress-class: vpn
spec:
containers:
- name: app
image: registry.example/app:1.0.0
---
apiVersion: v1
kind: Pod
metadata:
name: bad-auth-invalid # auth-mode not in the enum
namespace: app-ns
labels:
homelab.keithjackson.dev/egress-class: vpn
homelab.keithjackson.dev/auth-mode: basic
spec:
containers:
- name: app
image: registry.example/app:1.0.0
---
# ---- host-namespace / privilege violations (R-SEC-02) ----
apiVersion: v1
kind: Pod
metadata:
name: bad-hostnetwork # hostNetwork bypasses default-deny
namespace: app-ns
labels:
homelab.keithjackson.dev/egress-class: internet
homelab.keithjackson.dev/auth-mode: none
spec:
hostNetwork: true
containers:
- name: app
image: registry.example/app:1.0.0
---
apiVersion: v1
kind: Pod
metadata:
name: bad-privileged # privileged container
namespace: app-ns
labels:
homelab.keithjackson.dev/egress-class: internet
homelab.keithjackson.dev/auth-mode: none
spec:
containers:
- name: app
image: registry.example/app:1.0.0
securityContext:
privileged: true
---
# ---- scope proof: same bare pod in a NON-app namespace must be SKIPPED ----
apiVersion: v1
kind: Pod
metadata:
name: platform-workload
namespace: platform-ns
spec:
hostNetwork: true # allowed here: platform ns is out of scope
containers:
- name: agent
image: registry.example/agent:1.0.0