From bb2e9fc931f55b6e6b68e628706354dbed07ecbe Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Sun, 16 Aug 2026 00:08:32 -0500 Subject: [PATCH] T-0.5 Kyverno L0 policies + CLI tests + admission scaffolding (R-TEST-01, R-NET-01, R-SEC-01/02, R-IDP-02, R-GITOPS-02) Co-Authored-By: Claude Opus 4.8 (1M context) --- platform/kyverno/README.md | 100 +++++++++++++- platform/kyverno/admission/README.md | 39 ++++++ platform/kyverno/admission/helmrelease.yaml | 51 +++++++ platform/kyverno/admission/kustomization.yaml | 18 +++ platform/kyverno/admission/namespace.yaml | 9 ++ .../kyverno/admission/secret-provenance.yaml | 57 ++++++++ platform/kyverno/labels.md | 27 ++++ .../policies/disallow-plaintext-secrets.yaml | 61 +++++++++ .../policies/require-auth-mode-label.yaml | 48 +++++++ .../require-default-deny-baseline.yaml | 66 +++++++++ .../policies/require-egress-class-label.yaml | 48 +++++++ .../policies/restrict-host-namespaces.yaml | 75 +++++++++++ .../policies/validate-default-deny-shape.yaml | 43 ++++++ platform/kyverno/scripts/run-l0.sh | 42 ++++++ platform/kyverno/servicedefinition/README.md | 58 ++++++++ .../payloads/invalid-bad-dependency.yaml | 14 ++ .../payloads/invalid-bad-egress.yaml | 9 ++ .../payloads/invalid-missing-image.yaml | 9 ++ .../payloads/invalid-public-no-auth.yaml | 10 ++ .../payloads/valid-plex.yaml | 22 +++ .../payloads/valid-sonarr.yaml | 29 ++++ .../validate-servicedefinition.yaml | 65 +++++++++ platform/kyverno/tests/kyverno-test.yaml | 126 ++++++++++++++++++ .../resources/generated-default-deny.yaml | 26 ++++ .../kyverno/tests/resources/namespaces.yaml | 16 +++ .../tests/resources/networkpolicies.yaml | 40 ++++++ platform/kyverno/tests/resources/secrets.yaml | 25 ++++ .../kyverno/tests/resources/workloads.yaml | 112 ++++++++++++++++ 28 files changed, 1243 insertions(+), 2 deletions(-) create mode 100644 platform/kyverno/admission/README.md create mode 100644 platform/kyverno/admission/helmrelease.yaml create mode 100644 platform/kyverno/admission/kustomization.yaml create mode 100644 platform/kyverno/admission/namespace.yaml create mode 100644 platform/kyverno/admission/secret-provenance.yaml create mode 100644 platform/kyverno/labels.md create mode 100644 platform/kyverno/policies/disallow-plaintext-secrets.yaml create mode 100644 platform/kyverno/policies/require-auth-mode-label.yaml create mode 100644 platform/kyverno/policies/require-default-deny-baseline.yaml create mode 100644 platform/kyverno/policies/require-egress-class-label.yaml create mode 100644 platform/kyverno/policies/restrict-host-namespaces.yaml create mode 100644 platform/kyverno/policies/validate-default-deny-shape.yaml create mode 100755 platform/kyverno/scripts/run-l0.sh create mode 100644 platform/kyverno/servicedefinition/README.md create mode 100644 platform/kyverno/servicedefinition/payloads/invalid-bad-dependency.yaml create mode 100644 platform/kyverno/servicedefinition/payloads/invalid-bad-egress.yaml create mode 100644 platform/kyverno/servicedefinition/payloads/invalid-missing-image.yaml create mode 100644 platform/kyverno/servicedefinition/payloads/invalid-public-no-auth.yaml create mode 100644 platform/kyverno/servicedefinition/payloads/valid-plex.yaml create mode 100644 platform/kyverno/servicedefinition/payloads/valid-sonarr.yaml create mode 100644 platform/kyverno/servicedefinition/validate-servicedefinition.yaml create mode 100644 platform/kyverno/tests/kyverno-test.yaml create mode 100644 platform/kyverno/tests/resources/generated-default-deny.yaml create mode 100644 platform/kyverno/tests/resources/namespaces.yaml create mode 100644 platform/kyverno/tests/resources/networkpolicies.yaml create mode 100644 platform/kyverno/tests/resources/secrets.yaml create mode 100644 platform/kyverno/tests/resources/workloads.yaml diff --git a/platform/kyverno/README.md b/platform/kyverno/README.md index 3a86672..388d61f 100644 --- a/platform/kyverno/README.md +++ b/platform/kyverno/README.md @@ -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 # 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. diff --git a/platform/kyverno/admission/README.md b/platform/kyverno/admission/README.md new file mode 100644 index 0000000..5f10c3c --- /dev/null +++ b/platform/kyverno/admission/README.md @@ -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. diff --git a/platform/kyverno/admission/helmrelease.yaml b/platform/kyverno/admission/helmrelease.yaml new file mode 100644 index 0000000..bcce8f5 --- /dev/null +++ b/platform/kyverno/admission/helmrelease.yaml @@ -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 diff --git a/platform/kyverno/admission/kustomization.yaml b/platform/kyverno/admission/kustomization.yaml new file mode 100644 index 0000000..c771fb7 --- /dev/null +++ b/platform/kyverno/admission/kustomization.yaml @@ -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 diff --git a/platform/kyverno/admission/namespace.yaml b/platform/kyverno/admission/namespace.yaml new file mode 100644 index 0000000..6eb6ab0 --- /dev/null +++ b/platform/kyverno/admission/namespace.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 diff --git a/platform/kyverno/admission/secret-provenance.yaml b/platform/kyverno/admission/secret-provenance.yaml new file mode 100644 index 0000000..988384b --- /dev/null +++ b/platform/kyverno/admission/secret-provenance.yaml @@ -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: {} diff --git a/platform/kyverno/labels.md b/platform/kyverno/labels.md new file mode 100644 index 0000000..1ee6b1a --- /dev/null +++ b/platform/kyverno/labels.md @@ -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 diff --git a/platform/kyverno/policies/disallow-plaintext-secrets.yaml b/platform/kyverno/policies/disallow-plaintext-secrets.yaml new file mode 100644 index 0000000..1879792 --- /dev/null +++ b/platform/kyverno/policies/disallow-plaintext-secrets.yaml @@ -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: "?*" diff --git a/platform/kyverno/policies/require-auth-mode-label.yaml b/platform/kyverno/policies/require-auth-mode-label.yaml new file mode 100644 index 0000000..8b182af --- /dev/null +++ b/platform/kyverno/policies/require-auth-mode-label.yaml @@ -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" diff --git a/platform/kyverno/policies/require-default-deny-baseline.yaml b/platform/kyverno/policies/require-default-deny-baseline.yaml new file mode 100644 index 0000000..5d68f5a --- /dev/null +++ b/platform/kyverno/policies/require-default-deny-baseline.yaml @@ -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 diff --git a/platform/kyverno/policies/require-egress-class-label.yaml b/platform/kyverno/policies/require-egress-class-label.yaml new file mode 100644 index 0000000..fd58d7d --- /dev/null +++ b/platform/kyverno/policies/require-egress-class-label.yaml @@ -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" diff --git a/platform/kyverno/policies/restrict-host-namespaces.yaml b/platform/kyverno/policies/restrict-host-namespaces.yaml new file mode 100644 index 0000000..469d952 --- /dev/null +++ b/platform/kyverno/policies/restrict-host-namespaces.yaml @@ -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 diff --git a/platform/kyverno/policies/validate-default-deny-shape.yaml b/platform/kyverno/policies/validate-default-deny-shape.yaml new file mode 100644 index 0000000..0016d9e --- /dev/null +++ b/platform/kyverno/policies/validate-default-deny-shape.yaml @@ -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 diff --git a/platform/kyverno/scripts/run-l0.sh b/platform/kyverno/scripts/run-l0.sh new file mode 100755 index 0000000..b8e788f --- /dev/null +++ b/platform/kyverno/scripts/run-l0.sh @@ -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 ==" diff --git a/platform/kyverno/servicedefinition/README.md b/platform/kyverno/servicedefinition/README.md new file mode 100644 index 0000000..a657559 --- /dev/null +++ b/platform/kyverno/servicedefinition/README.md @@ -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 (→ `.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 diff --git a/platform/kyverno/servicedefinition/payloads/invalid-bad-dependency.yaml b/platform/kyverno/servicedefinition/payloads/invalid-bad-dependency.yaml new file mode 100644 index 0000000..c5f1a50 --- /dev/null +++ b/platform/kyverno/servicedefinition/payloads/invalid-bad-dependency.yaml @@ -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 diff --git a/platform/kyverno/servicedefinition/payloads/invalid-bad-egress.yaml b/platform/kyverno/servicedefinition/payloads/invalid-bad-egress.yaml new file mode 100644 index 0000000..b183e84 --- /dev/null +++ b/platform/kyverno/servicedefinition/payloads/invalid-bad-egress.yaml @@ -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 diff --git a/platform/kyverno/servicedefinition/payloads/invalid-missing-image.yaml b/platform/kyverno/servicedefinition/payloads/invalid-missing-image.yaml new file mode 100644 index 0000000..4d17d5d --- /dev/null +++ b/platform/kyverno/servicedefinition/payloads/invalid-missing-image.yaml @@ -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 diff --git a/platform/kyverno/servicedefinition/payloads/invalid-public-no-auth.yaml b/platform/kyverno/servicedefinition/payloads/invalid-public-no-auth.yaml new file mode 100644 index 0000000..ee5f65e --- /dev/null +++ b/platform/kyverno/servicedefinition/payloads/invalid-public-no-auth.yaml @@ -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 diff --git a/platform/kyverno/servicedefinition/payloads/valid-plex.yaml b/platform/kyverno/servicedefinition/payloads/valid-plex.yaml new file mode 100644 index 0000000..4bfda9c --- /dev/null +++ b/platform/kyverno/servicedefinition/payloads/valid-plex.yaml @@ -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 diff --git a/platform/kyverno/servicedefinition/payloads/valid-sonarr.yaml b/platform/kyverno/servicedefinition/payloads/valid-sonarr.yaml new file mode 100644 index 0000000..c290aaa --- /dev/null +++ b/platform/kyverno/servicedefinition/payloads/valid-sonarr.yaml @@ -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 diff --git a/platform/kyverno/servicedefinition/validate-servicedefinition.yaml b/platform/kyverno/servicedefinition/validate-servicedefinition.yaml new file mode 100644 index 0000000..1fd7fa9 --- /dev/null +++ b/platform/kyverno/servicedefinition/validate-servicedefinition.yaml @@ -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 diff --git a/platform/kyverno/tests/kyverno-test.yaml b/platform/kyverno/tests/kyverno-test.yaml new file mode 100644 index 0000000..dfbbc4d --- /dev/null +++ b/platform/kyverno/tests/kyverno-test.yaml @@ -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 diff --git a/platform/kyverno/tests/resources/generated-default-deny.yaml b/platform/kyverno/tests/resources/generated-default-deny.yaml new file mode 100644 index 0000000..27e5282 --- /dev/null +++ b/platform/kyverno/tests/resources/generated-default-deny.yaml @@ -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 diff --git a/platform/kyverno/tests/resources/namespaces.yaml b/platform/kyverno/tests/resources/namespaces.yaml new file mode 100644 index 0000000..6bc1f42 --- /dev/null +++ b/platform/kyverno/tests/resources/namespaces.yaml @@ -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 diff --git a/platform/kyverno/tests/resources/networkpolicies.yaml b/platform/kyverno/tests/resources/networkpolicies.yaml new file mode 100644 index 0000000..71c8cce --- /dev/null +++ b/platform/kyverno/tests/resources/networkpolicies.yaml @@ -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: [] diff --git a/platform/kyverno/tests/resources/secrets.yaml b/platform/kyverno/tests/resources/secrets.yaml new file mode 100644 index 0000000..549eb2f --- /dev/null +++ b/platform/kyverno/tests/resources/secrets.yaml @@ -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 diff --git a/platform/kyverno/tests/resources/workloads.yaml b/platform/kyverno/tests/resources/workloads.yaml new file mode 100644 index 0000000..f4e72b1 --- /dev/null +++ b/platform/kyverno/tests/resources/workloads.yaml @@ -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