Kubernetes admission, network, and image-policy lab
Exercises pod hardening, network-policy, and image-decision cases using local fixtures and pinned policy schemas.
Cloud Security 3 min read
Implementation: Partially tested
Implementation
labs/kubernetes-security/policies/hardened-pods.yaml
1apiVersion: policies.kyverno.io/v1
2kind: ValidatingPolicy
3metadata:
4 name: hardened-pods
5 annotations:
6 policies.kyverno.io/title: Hardened Pod baseline
7 policies.kyverno.io/category: Pod Security
8 policies.kyverno.io/severity: high
9spec:
10 validationActions:
11 - Deny
12 failurePolicy: Fail
13 evaluation:
14 admission:
15 enabled: true
16 background:
17 enabled: true
18 mode: Kubernetes
19 webhookConfiguration:
20 timeoutSeconds: 10
21 matchConstraints:
22 resourceRules:
23 - apiGroups:
24 - ""
25 apiVersions:
26 - v1
27 operations:
28 - CREATE
29 - UPDATE
30 resources:
31 - pods
32 variables:
33 - name: allContainers
34 expression: >-
35 object.spec.containers
36 + object.spec.?initContainers.orValue([])
37 + object.spec.?ephemeralContainers.orValue([])
38 validations:
39 - message: Host PID, IPC, and network namespaces are not allowed.
40 expression: >-
41 !object.spec.?hostPID.orValue(false)
42 && !object.spec.?hostIPC.orValue(false)
43 && !object.spec.?hostNetwork.orValue(false)
44 - message: hostPath volumes are not allowed by this workload baseline.
45 expression: >-
46 !has(object.spec.volumes)
47 || object.spec.volumes.all(volume, !has(volume.hostPath))
48 - message: Service-account token automount must be explicitly disabled.
49 expression: object.spec.?automountServiceAccountToken.orValue(true) == false
50 - message: Containers must run as non-root without privileged mode or privilege escalation.
51 expression: >-
52 variables.allContainers.all(container,
53 has(container.securityContext)
54 && container.securityContext.?runAsNonRoot.orValue(false) == true
55 && container.securityContext.?privileged.orValue(false) == false
56 && container.securityContext.?allowPrivilegeEscalation.orValue(true) == false)
57 - message: Containers must use a read-only root filesystem and drop all capabilities.
58 expression: >-
59 variables.allContainers.all(container,
60 container.securityContext.?readOnlyRootFilesystem.orValue(false) == true
61 && has(container.securityContext.capabilities)
62 && container.securityContext.capabilities.?drop.orValue([]).exists(capability, capability == "ALL")
63 && size(container.securityContext.capabilities.?add.orValue([])) == 0)
64 - message: A RuntimeDefault or Localhost seccomp profile is required.
65 expression: >-
66 has(object.spec.securityContext)
67 && has(object.spec.securityContext.seccompProfile)
68 && object.spec.securityContext.seccompProfile.type in ["RuntimeDefault", "Localhost"]
69 - message: CPU and memory requests and limits are required for every container.
70 expression: >-
71 variables.allContainers.all(container,
72 has(container.resources)
73 && has(container.resources.requests)
74 && has(container.resources.requests.cpu)
75 && has(container.resources.requests.memory)
76 && has(container.resources.limits)
77 && has(container.resources.limits.cpu)
78 && has(container.resources.limits.memory))
1apiVersion: policies.kyverno.io/v1
2kind: ImageValidatingPolicy
3metadata:
4 name: verify-secureobs-release
5 annotations:
6 policies.kyverno.io/title: Verify SecureObs release identity and provenance
7 policies.kyverno.io/category: Software Supply Chain
8 policies.kyverno.io/severity: high
9spec:
10 validationActions:
11 - Deny
12 failurePolicy: Fail
13 evaluation:
14 admission:
15 enabled: true
16 background:
17 enabled: true
18 mode: Kubernetes
19 webhookConfiguration:
20 timeoutSeconds: 15
21 matchConstraints:
22 resourceRules:
23 - apiGroups:
24 - ""
25 apiVersions:
26 - v1
27 operations:
28 - CREATE
29 - UPDATE
30 resources:
31 - pods
32 matchImageReferences:
33 - glob: "ghcr.io/jasonachkar/secureobs:*"
34 - glob: "ghcr.io/jasonachkar/secureobs@sha256:*"
35 attestors:
36 - name: releaseIdentity
37 cosign:
38 keyless:
39 identities:
40 - issuer: "https://token.actions.githubusercontent.com"
41 subject: "https://github.com/jasonachkar/secureobs/.github/workflows/release.yml@refs/heads/main"
42 ctlog:
43 url: "https://rekor.sigstore.dev"
44 attestations:
45 - name: slsaProvenance
46 intoto:
47 type: "https://slsa.dev/provenance/v1"
48 validationConfigurations:
49 mutateDigest: true
50 required: true
51 verifyDigest: true
52 validations:
53 - message: Image signature is missing or the keyless workflow identity is not authorized.
54 expression: >-
55 images.containers
56 .map(image, verifyImageSignatures(image, [attestors.releaseIdentity]))
57 .all(result, result > 0)
58 - message: SLSA provenance is missing or is not signed by the authorized workflow identity.
59 expression: >-
60 images.containers
61 .map(image, verifyAttestationSignatures(
62 image,
63 attestations.slsaProvenance,
64 [attestors.releaseIdentity]))
65 .all(result, result > 0)
1"use strict";
2
3const assert = require("node:assert/strict");
4const fs = require("node:fs");
5const path = require("node:path");
6const YAML = require("yaml");
7
8const lab = path.resolve(__dirname, "..");
9const read = (relative) => fs.readFileSync(path.join(lab, relative), "utf8");
10
11const podPolicy = YAML.parse(read("policies/hardened-pods.yaml"));
12assert.equal(podPolicy.apiVersion, "policies.kyverno.io/v1");
13assert.equal(podPolicy.kind, "ValidatingPolicy");
14assert.deepEqual(podPolicy.spec.validationActions, ["Deny"]);
15assert.equal(podPolicy.spec.failurePolicy, "Fail");
16assert.equal(podPolicy.spec.evaluation.admission.enabled, true);
17assert.equal(podPolicy.spec.evaluation.background.enabled, true);
18
19const validationText = JSON.stringify(podPolicy.spec.validations);
20for (const required of [
21 "hostPID",
22 "hostIPC",
23 "hostNetwork",
24 "hostPath",
25 "automountServiceAccountToken",
26 "runAsNonRoot",
27 "privileged",
28 "allowPrivilegeEscalation",
29 "readOnlyRootFilesystem",
30 "capabilities",
31 "seccomp",
32 "requests",
33 "limits",
34]) {
35 assert.match(validationText, new RegExp(required));
36}
37
38const imagePolicy = YAML.parse(read("policies/verify-release-images.yaml"));
39assert.equal(imagePolicy.apiVersion, "policies.kyverno.io/v1");
40assert.equal(imagePolicy.kind, "ImageValidatingPolicy");
41assert.equal(imagePolicy.spec.failurePolicy, "Fail");
42assert.deepEqual(imagePolicy.spec.validationActions, ["Deny"]);
43assert.equal(imagePolicy.spec.validationConfigurations.required, true);
44assert.equal(imagePolicy.spec.validationConfigurations.verifyDigest, true);
45assert.equal(imagePolicy.spec.validationConfigurations.mutateDigest, true);
46
47const imageCases = JSON.parse(read("fixtures/image-cases.json"));
48const expected = imageCases.expected;
49const identity = imagePolicy.spec.attestors[0].cosign.keyless.identities[0];
50assert.equal(identity.issuer, expected.issuer);
51assert.equal(identity.subject, expected.subject);
52assert.equal(imagePolicy.spec.attestations[0].intoto.type, expected.predicateType);
53assert.deepEqual(
54 imagePolicy.spec.matchImageReferences.map((reference) => reference.glob),
55 [`${expected.repository}:*`, `${expected.repository}@sha256:*`],
56);
57
58function matchesImageReference(reference) {
59 return imagePolicy.spec.matchImageReferences.some(({ glob }) => {
60 assert.match(glob, /\*$/, "the offline matcher supports reviewed trailing-star globs only");
61 return reference.startsWith(glob.slice(0, -1));
62 });
63}
64
65assert.equal(matchesImageReference(`${expected.repository}:release-2026-07-23`), true);
66assert.equal(matchesImageReference(`${expected.repository}@${expected.digest}`), true);
67assert.equal(matchesImageReference(`ghcr.io/attacker/secureobs@${expected.digest}`), false);
68assert.equal(matchesImageReference(`ghcr.io/jasonachkar/secureobs-evil@${expected.digest}`), false);
69
70function evaluate(candidate) {
71 const imageDigest = candidate.image.includes("@") ? candidate.image.split("@")[1] : null;
72 const repository = candidate.image.split(/[@:]/)[0];
73 return (
74 candidate.verifierSucceeded !== false &&
75 candidate.signed === true &&
76 candidate.issuer === expected.issuer &&
77 candidate.subject === expected.subject &&
78 repository === expected.repository &&
79 candidate.predicateType === expected.predicateType &&
80 candidate.provenanceValid === true &&
81 imageDigest === expected.digest &&
82 candidate.subjectDigest === expected.digest
83 );
84}
85
86const evaluatedImageCases = imageCases.cases.filter((candidate) => candidate.accepted !== null);
87for (const candidate of evaluatedImageCases) {
88 assert.equal(evaluate(candidate), candidate.accepted, candidate.name);
89}
90
91const tagOnlyCase = imageCases.cases.find(
92 (candidate) => candidate.name === "tag-only-reference-match",
93);
94assert.equal(matchesImageReference(tagOnlyCase.image), true);
95assert.equal(tagOnlyCase.accepted, null);
96assert.match(tagOnlyCase.matchOnlyReason, /not executed/i);
97
98assert.deepEqual(
99 evaluatedImageCases.filter((candidate) => !candidate.accepted).map((candidate) => candidate.name),
100 [
101 "unsigned",
102 "wrong-repository",
103 "wrong-workflow",
104 "wrong-branch",
105 "wrong-issuer",
106 "missing-attestation",
107 "malformed-provenance",
108 "wrong-predicate",
109 "verifier-or-transparency-failure",
110 "tag-substitution-digest-mismatch",
111 ],
112);
113
114const networkPolicies = YAML.parseAllDocuments(read("fixtures/network-policies.yaml"))
115 .map((document) => document.toJSON());
116const defaultDeny = networkPolicies.find(
117 (policy) => policy.metadata.name === "default-deny-ingress-and-egress",
118);
119assert.deepEqual(defaultDeny.spec.podSelector, {});
120assert.deepEqual(defaultDeny.spec.policyTypes.sort(), ["Egress", "Ingress"]);
121assert.equal(defaultDeny.spec.ingress, undefined);
122assert.equal(defaultDeny.spec.egress, undefined);
123
124const dns = networkPolicies.find((policy) => policy.metadata.name === "allow-cluster-dns");
125assert.deepEqual(
126 dns.spec.egress[0].ports.map((port) => `${port.protocol}/${port.port}`).sort(),
127 ["TCP/53", "UDP/53"],
128);
129
130console.log(
131 `PASS: Kubernetes admission, image identity (${imageCases.cases.length} cases), ` +
132 "and NetworkPolicy structural tests completed.",
133);
Run it
kyverno test labs/kubernetes-securitynode labs/kubernetes-security/tests/run-tests.js
Evidence status: partially tested lab. Native Kyverno v1.18.2 testing exercised the hardened-pod policy. The image policy is a schema-validated example; signature, certificate, registry, transparency, mutation, and live admission were not tested.
This lab validates a narrow Kubernetes security baseline with positive and negative fixtures. It does not claim that a namespace, admission policy, or NetworkPolicy is a hard hostile-tenant boundary.
Pinned validation scope
- Kubernetes API shapes reviewed for Kubernetes
1.34. - Kyverno CLI
1.18.2. - Kyverno
policies.kyverno.io/v1ValidatingPolicyandImageValidatingPolicy, stable in Kyverno 1.18.
Run the dependency-free structural and identity-policy tests:
node labs/kubernetes-security/tests/run-tests.js
Run the native Kyverno tests:
kyverno test labs/kubernetes-security --remove-color
On 2026-07-23, the official Kyverno v1.18.2 Windows CLI asset (SHA-256 b5c9d1cb75587a312dc8334537a5773bdedb1a985deae9d89a5251385afb831f) ran the native hardened-pod suite: 7 tests passed and 0 tests failed. This native run does not include verify-release-images.yaml.
Enforcement demonstrated
policies/hardened-pods.yaml denies:
- privileged containers and privilege escalation;
- host PID, IPC, or network namespaces;
hostPathvolumes;- added Linux capabilities or failure to drop
ALL; - writable root filesystems;
- missing non-root and seccomp configuration;
- missing CPU/memory requests or limits; and
- service-account token automount unless explicitly disabled.
The accepted Pod is tested alongside privileged, host-namespace, hostPath, capability, missing-resource, and token-automount negative fixtures.
policies/verify-release-images.yaml uses the stable Kyverno 1.18 policies.kyverno.io/v1 ImageValidatingPolicy. It constrains repository, keyless issuer, full workflow-and-branch subject, SLSA provenance predicate type, required verification, digest verification, and failurePolicy: Fail. Separate :* and @sha256:* globs make tagged and digest references to the exact repository eligible for evaluation while excluding lookalike repositories.
On 2026-07-23, the manifest conformed to Kyverno v1.18.2's official ImageValidatingPolicy CRD v1 schema. The downloaded CRD asset SHA-256 was 3528151f3717c9946ee56d60866f2cf6c29a4b1a7e759c72af60451147b995c2. The Node harness separately evaluates eleven synthetic, already-resolved evidence claim sets. It returns false for unsigned, wrong repository/workflow/branch/issuer/predicate, missing or malformed provenance, verifier/transparency failure, and digest mismatch.
The twelfth case proves only that a tag such as :latest matches the reviewed tag selector. Its admission result is deliberately null: the harness does not model registry resolution or Kyverno's mutateDigest behavior and therefore makes no accept/deny claim for tag-only input. This is a schema-validated example; no live enforcement test. Signature, certificate, registry, transparency-log, mutation, webhook failure, and controller behavior were not executed.
Network boundary
fixtures/network-policies.yaml contains namespace-wide default-deny ingress and egress plus an explicit DNS exception. NetworkPolicy has an effect only when the selected CNI implements it. DNS labels, ports, node-local DNS paths, dual-stack behavior, and required application egress must be verified in each cluster. NetworkPolicy does not control all host-network, node, service-mesh, or cloud-network paths.
Operational rollout
Use observe → audit → warn → enforce → measure bypasses. Track policy evaluation errors, denials, exceptions and expiry, unsigned-image attempts, registry/rekor availability, admission latency, and workloads that require a separate runtime class or cluster. Keep a reviewed break-glass path outside tenant administrator control.