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) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-16 00:08:32 -05:00
1 parent bb08d0e048
commit bb2e9fc931
28 files changed
+1243 -2

No files matched your search

@@ -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