Merge L3 default-deny-failclosed (R-NET fail-closed green)

This commit is contained in:
jakman85 committed 2026-08-17 17:42:35 -05:00
commit cde3335086
2 files changed
+161 -28

No files matched your search

@@ -1,20 +1,15 @@
# ============================================================================= # =============================================================================
# L3 FAULT-INJECTION SCAFFOLD — Default-deny fail-closed (R-NET-01, R-NET-02) # L3 FAULT-INJECTION — Default-deny fail-closed (R-NET-01, R-NET-02) *SAFETY-CRITICAL* [RUNNABLE]
# *SAFETY-CRITICAL*
# Catalog/spec: docs/14 R-TEST-05 lists default-deny among the behaviors that MUST # Catalog/spec: docs/14 R-TEST-05 lists default-deny among the behaviors that MUST
# be proven by fault-injection (not happy-path). This is the L3 partner of the L2 # be proven by FAULT INJECTION (not happy-path). L3 partner of tests/l2-behavioral/default-deny.
# behavioral default-deny test (tests/l2-behavioral/default-deny).
# #
# GIVEN a source that CAN reach a dest only because of an explicit allow rule, # GIVEN a source that can reach dest:8080 ONLY because of an explicit allow rule,
# WHEN the allow rule is REVOKED (fault injection: delete the CiliumNetworkPolicy), # WHEN the allow rule is REVOKED (fault: delete both halves of the NetworkPolicy),
# THEN connectivity drops to fail-closed within a bounded window (revocation takes # THEN connectivity drops to fail-closed within a bounded window (the baseline denies
# effect immediately — the baseline denies once the allow is gone), while DNS # once the allow is gone), while DNS still resolves.
# still resolves.
# #
# This proves the deny is enforced by the live datapath, not merely declared, and # Proves the deny is enforced by the live datapath, not merely declared, and that
# that removing access fails closed rather than lingering open. # removing access fails CLOSED rather than lingering open.
#
# STATUS: scaffold — needs T-2.2 (baseline) + T-2.3 (rendered allow) + T-3.0 harness.
# ============================================================================= # =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1 apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test kind: Test
@@ -27,43 +22,73 @@ metadata:
conformance.home.k3s/requirements: "R-NET-01, R-NET-02" conformance.home.k3s/requirements: "R-NET-01, R-NET-02"
conformance.home.k3s/layer: "L3" conformance.home.k3s/layer: "L3"
conformance.home.k3s/safety-critical: "true" conformance.home.k3s/safety-critical: "true"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >- conformance.home.k3s/intent: >-
Revoking an allow rule drops connectivity to fail-closed immediately; DNS Revoking an allow rule drops connectivity to fail-closed immediately; DNS
still works — the datapath enforces default-deny, not just declares it. still works — the datapath enforces default-deny, not just declares it.
spec: spec:
timeouts:
apply: 90s
assert: 120s
exec: 150s
steps: steps:
- name: given-source-allowed-to-dest - name: given-source-allowed-to-dest
try: try:
- apply: - apply:
file: fixtures/allowed-pair.yaml # TODO(T-2.3): source+dest+allow policy file: fixtures/allowed-pair.yaml
# No readinessProbe (default-deny drops the kubelet probe) → Ready once Running.
- assert:
resource:
apiVersion: v1
kind: Pod
metadata: { name: source }
status: { phase: Running }
- assert:
resource:
apiVersion: v1
kind: Pod
metadata: { name: dest }
status: { phase: Running }
- script: - script:
# The explicit allow is the SOLE reason this succeeds (baseline denies all else).
content: | content: |
set -euo pipefail set -eu
kubectl -n "$NAMESPACE" exec source -- nc -z -w 3 dest 80 && echo ALLOWED_OK kubectl -n "$NAMESPACE" exec source -- nc -z -w 5 dest 8080 && echo ALLOWED_OK
check: check:
(contains($stdout, 'ALLOWED_OK')): true (contains($stdout, 'ALLOWED_OK')): true
- name: when-revoke-allow-then-fail-closed - name: when-revoke-allow-then-fail-closed
try: try:
# FAULT INJECTION: delete the allow policy; baseline default-deny remains. # -- FAULT INJECTION: revoke the allow (both halves); baseline default-deny remains. --
- script: - script:
content: | content: |
set -euo pipefail set -eu
echo "TODO(T-3.0): delete the source->dest CiliumNetworkPolicy allow" kubectl -n "$NAMESPACE" delete networkpolicy \
allow-source-to-dest-egress allow-source-to-dest-ingress --wait=true
echo REVOKED
- script: - script:
# Bounded poll: connection must start failing (fail-closed on revocation). # Connectivity must fail CLOSED. Poll briefly for the datapath to converge; a
# persistent success is a LEAK (fails the test).
content: | content: |
set -euo pipefail set -u
if kubectl -n "$NAMESPACE" exec source -- nc -z -w 3 dest 80; then for i in $(seq 1 10); do
echo "LEAK: still reachable after allow revoked" >&2; exit 1 if kubectl -n "$NAMESPACE" exec source -- nc -z -w 5 dest 8080 2>/dev/null; then
fi echo "attempt $i: still reachable, waiting for revocation to converge..."; sleep 3
echo FAIL_CLOSED_OK else
echo FAIL_CLOSED_OK; exit 0
fi
done
echo "LEAK: dest still reachable after allow revoked" >&2; exit 1
check: check:
(contains($stdout, 'FAIL_CLOSED_OK')): true (contains($stdout, 'FAIL_CLOSED_OK')): true
- script: - script:
# DNS must still work (default-deny permits DNS egress). # DNS must STILL resolve (baseline permits DNS egress) — the deny is scoped, not total.
# busybox nslookup can exit non-zero on a successful forward lookup (server-PTR quirk),
# so assert on the RESOLVED ANSWER in the output, not the exit code.
content: | content: |
set -euo pipefail set -u
kubectl -n "$NAMESPACE" exec source -- nslookup kubernetes.default && echo DNS_OK out="$(kubectl -n "$NAMESPACE" exec source -- nslookup kubernetes.default 2>&1 || true)"
echo "$out"
echo "$out" | grep -qiE 'kubernetes\.default|10\.43\.' && echo DNS_OK || { echo "DNS_BLOCKED" >&2; exit 1; }
check: check:
(contains($stdout, 'DNS_OK')): true (contains($stdout, 'DNS_OK')): true
finally: finally:
@@ -0,0 +1,108 @@
# =============================================================================
# Fixtures for the default-deny FAIL-CLOSED fault-injection test (R-NET-01/02).
# Applied into the per-test namespace chainsaw creates (hermetic, self-cleaned).
#
# Models: a namespace default-deny baseline + ONE explicit allow that is the SOLE
# reason source can reach dest:8080. The test later DELETES the allow (fault) and
# proves connectivity drops to fail-closed while DNS still resolves.
#
# Standard Kubernetes NetworkPolicy (Cilium enforces it). No readinessProbe on the
# pods: under default-deny the kubelet TCP probe (node->pod) is dropped, so a
# probe-bearing pod would never go Ready — without a probe the container is Ready
# once running, which is all the test needs (K2 finding).
# =============================================================================
---
# --- Default-deny baseline: deny all ingress+egress, allow only DNS egress. -----------
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-baseline
spec:
podSelector: {}
policyTypes: [Ingress, Egress]
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# --- The ONE explicit allow (source -> dest tcp/8080): the two matching halves a
# real rendered dependency produces (egress permit on source, ingress on dest).
# Deleting BOTH is the fault injection.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-source-to-dest-egress
spec:
podSelector:
matchLabels: { app: source }
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: { app: dest }
ports:
- protocol: TCP
port: 8080
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-source-to-dest-ingress
spec:
podSelector:
matchLabels: { app: dest }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: source }
ports:
- protocol: TCP
port: 8080
---
# --- source: an idle probe pod (nc is issued via kubectl exec). ------------------------
apiVersion: v1
kind: Pod
metadata:
name: source
labels: { app: source }
spec:
containers:
- name: app
image: busybox:1.36.1
command: ["sh", "-c", "sleep 3600"]
---
# --- dest: LISTENS on 8080 so a block is caused by POLICY, not a missing listener. -----
apiVersion: v1
kind: Pod
metadata:
name: dest
labels: { app: dest }
spec:
containers:
- name: app
image: busybox:1.36.1
command: ["httpd", "-f", "-p", "8080", "-h", "/tmp"]
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: dest
spec:
selector: { app: dest }
ports:
- name: p8080
port: 8080
targetPort: 8080