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)
# *SAFETY-CRITICAL*
# L3 FAULT-INJECTION — Default-deny fail-closed (R-NET-01, R-NET-02) *SAFETY-CRITICAL* [RUNNABLE]
# 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
# behavioral default-deny test (tests/l2-behavioral/default-deny).
# be proven by FAULT INJECTION (not happy-path). L3 partner of tests/l2-behavioral/default-deny.
#
# GIVEN a source that CAN reach a dest only because of an explicit allow rule,
# WHEN the allow rule is REVOKED (fault injection: delete the CiliumNetworkPolicy),
# THEN connectivity drops to fail-closed within a bounded window (revocation takes
# effect immediately — the baseline denies once the allow is gone), while DNS
# still resolves.
# GIVEN a source that can reach dest:8080 ONLY because of an explicit allow rule,
# WHEN the allow rule is REVOKED (fault: delete both halves of the NetworkPolicy),
# THEN connectivity drops to fail-closed within a bounded window (the baseline denies
# once the allow is gone), while DNS still resolves.
#
# This proves the deny is enforced by the live datapath, not merely declared, and
# that removing access fails closed rather than lingering open.
#
# STATUS: scaffold — needs T-2.2 (baseline) + T-2.3 (rendered allow) + T-3.0 harness.
# Proves the deny is enforced by the live datapath, not merely declared, and that
# removing access fails CLOSED rather than lingering open.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
@@ -27,43 +22,73 @@ metadata:
conformance.home.k3s/requirements: "R-NET-01, R-NET-02"
conformance.home.k3s/layer: "L3"
conformance.home.k3s/safety-critical: "true"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
Revoking an allow rule drops connectivity to fail-closed immediately; DNS
still works — the datapath enforces default-deny, not just declares it.
spec:
timeouts:
apply: 90s
assert: 120s
exec: 150s
steps:
- name: given-source-allowed-to-dest
try:
- 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:
# The explicit allow is the SOLE reason this succeeds (baseline denies all else).
content: |
set -euo pipefail
kubectl -n "$NAMESPACE" exec source -- nc -z -w 3 dest 80 && echo ALLOWED_OK
set -eu
kubectl -n "$NAMESPACE" exec source -- nc -z -w 5 dest 8080 && echo ALLOWED_OK
check:
(contains($stdout, 'ALLOWED_OK')): true
- name: when-revoke-allow-then-fail-closed
try:
# FAULT INJECTION: delete the allow policy; baseline default-deny remains.
# -- FAULT INJECTION: revoke the allow (both halves); baseline default-deny remains. --
- script:
content: |
set -euo pipefail
echo "TODO(T-3.0): delete the source->dest CiliumNetworkPolicy allow"
set -eu
kubectl -n "$NAMESPACE" delete networkpolicy \
allow-source-to-dest-egress allow-source-to-dest-ingress --wait=true
echo REVOKED
- 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: |
set -euo pipefail
if kubectl -n "$NAMESPACE" exec source -- nc -z -w 3 dest 80; then
echo "LEAK: still reachable after allow revoked" >&2; exit 1
fi
echo FAIL_CLOSED_OK
set -u
for i in $(seq 1 10); do
if kubectl -n "$NAMESPACE" exec source -- nc -z -w 5 dest 8080 2>/dev/null; then
echo "attempt $i: still reachable, waiting for revocation to converge..."; sleep 3
else
echo FAIL_CLOSED_OK; exit 0
fi
done
echo "LEAK: dest still reachable after allow revoked" >&2; exit 1
check:
(contains($stdout, 'FAIL_CLOSED_OK')): true
- 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: |
set -euo pipefail
kubectl -n "$NAMESPACE" exec source -- nslookup kubernetes.default && echo DNS_OK
set -u
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:
(contains($stdout, 'DNS_OK')): true
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