Merge feature/p0-kyverno into main
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
28 files changed
+1243
-2
No files matched your search
@@ -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.
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
Executable
+42
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user