diff --git a/conformance/tests/l3-fault-injection/default-deny-failclosed/chainsaw-test.yaml b/conformance/tests/l3-fault-injection/default-deny-failclosed/chainsaw-test.yaml index 4b4f38a..ef332ce 100644 --- a/conformance/tests/l3-fault-injection/default-deny-failclosed/chainsaw-test.yaml +++ b/conformance/tests/l3-fault-injection/default-deny-failclosed/chainsaw-test.yaml @@ -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: diff --git a/conformance/tests/l3-fault-injection/default-deny-failclosed/fixtures/allowed-pair.yaml b/conformance/tests/l3-fault-injection/default-deny-failclosed/fixtures/allowed-pair.yaml new file mode 100644 index 0000000..90ac419 --- /dev/null +++ b/conformance/tests/l3-fault-injection/default-deny-failclosed/fixtures/allowed-pair.yaml @@ -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